Text

Text tables

Rows as aligned plain-text tables for e-mails, logs, terminals and READMEs: box-drawn, ASCII, plain or Markdown, numbers to the right, wide characters measured right, long cells cut; and Markdown tables read back into rows.

Version 1.0.0 · 3 functions · 2 crates

#reports#email#terminal#markdown

Functions

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

text_table(rows: any, options: any) → stror an error

rows as an aligned text table: a list of maps (columns in the first rows' key order) or a list of lists (the first the header, unless options.header is false). options: style ("box", "rounded", "ascii", "markdown", "plain"), columns (which keys, in order), titles ({ key: "Header" }), align ({ key: "right" }; numbers go right by themselves), decimals for floats, max_width to cut long cells with "…".

text_table_markdown(rows: any, options: any) → stror an error

rows as a GitHub-flavoured Markdown table, the alignment row saying which columns are numbers: for READMEs, issues, chat and docs. The same options as text_table.

text_table_from_markdown(markdown: str) → listor an error

The first Markdown table in markdown read back as rows: a list of maps keyed by the header, numbers in number-only cells made numbers.

Get it

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

Or with curl:

$ curl -O https://quillolang.org/plugins/files/text_table.qwer$ curl -O https://quillolang.org/plugins/files/text_table.qwe

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

main.qwe
use text_table.qweruse text_table, text_table_markdown from text_table      // Quillo 0.6: only these

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

[dependencies]
unicode-width = "0.2"serde_json = { version = "1", features = ["preserve_order"] }

Example

text_table.qwe
// The monthly sales report for the plain-text e-mail, the server log and the README: one set of rows, several tables.////   quillo run text_table.qwe      then open http://localhost:8080/report.txtuse "text_table.qwer"let SALES = [    { region: "North", product: "Coffee beans 1 kg", units: 1240, revenue: 18476.5 },    { region: "South", product: "Thé vert 東京", units: 86, revenue: 1032.0 },    { region: "East", product: "Espresso machine with a very long product name", units: 12, revenue: 7188.75 },]route GET /report.txt = respond(200, text_table(SALES, { style: "box", titles: { region: "Region", product: "Product", units: "Units", revenue: "Revenue €" }, decimals: 2, max_width: 24 })?, { "content-type": "text/plain; charset=utf-8" })route GET /tables {    {        ascii: text_table([["Plan", "Seats", "Price"], ["Starter", 3, 9.0], ["Team", 25, 49.0]], { style: "ascii", decimals: 2 })?,        plain: text_table(SALES, { style: "plain", columns: ["region", "units"] })?,        markdown: text_table_markdown(SALES, { columns: ["region", "units", "revenue"] })?,        read_back: text_table_from_markdown("Intro text\n\n| name | qty |\n|------|----:|\n| pens | 12 |\n| ink \\| blue | 3 |\n")?,    }}
$ quillo run text_table.qwe$ curl 'localhost:8080/report.txt'$ curl 'localhost:8080/tables'

Source

text_table.qwer
//! Rows as aligned plain-text tables for e-mails, logs, terminals, receipts and READMEs: box-drawn, ASCII or Markdown, numbers to the right, wide characters (中文, emoji) measured right, long cells cut; and Markdown tables read back into rows.//! version: 1.0.0//!//! [dependencies]//! unicode-width = "0.2"//! serde_json = { version = "1", features = ["preserve_order"] }use serde_json::{Map, Value};use unicode_width::UnicodeWidthStr;#[derive(Clone, Copy, PartialEq)]enum Align { Left, Right, Center }struct Grid { head: Vec<String>, rows: Vec<Vec<String>>, align: Vec<Align> }fn cell(v: &Value, decimals: Option<usize>) -> String {    match v {        Value::Null => String::new(),        Value::String(s) => s.replace(['\n', '\r', '\t'], " "),        Value::Number(n) => match (decimals, n.as_f64()) {            (Some(d), Some(f)) if !n.is_i64() => format!("{:.*}", d, f),            _ => n.to_string(),        },        Value::Bool(b) => b.to_string(),        other => other.to_string(),    }}fn width(s: &str) -> usize { UnicodeWidthStr::width(s) }fn cut(s: &str, max: usize) -> String {    if max == 0 || width(s) <= max { return s.to_string() }    let mut out = String::new();    for c in s.chars() {        let w = unicode_width::UnicodeWidthChar::width(c).unwrap_or(0);        if width(&out) + w + 1 > max { break }        out.push(c);    }    out.push('…');    out}fn pad(s: &str, w: usize, a: Align) -> String {    let gap = w.saturating_sub(width(s));    match a {        Align::Left => format!("{}{}", s, " ".repeat(gap)),        Align::Right => format!("{}{}", " ".repeat(gap), s),        Align::Center => format!("{}{}{}", " ".repeat(gap / 2), s, " ".repeat(gap - gap / 2)),    }}fn grid(rows: &Value, options: &Value) -> Result<Grid, String> {    let list = rows.as_array().ok_or("rows are a list")?;    let decimals = options.get("decimals").and_then(|d| d.as_u64()).map(|d| d as usize);    let max = options.get("max_width").and_then(|d| d.as_u64()).unwrap_or(0) as usize;    let titles = options.get("titles").and_then(|t| t.as_object()).cloned().unwrap_or_default();    let (keys, head, body): (Vec<String>, Vec<String>, Vec<Vec<Value>>) = if list.first().map(|r| r.is_object()).unwrap_or(false) {        let keys: Vec<String> = match options.get("columns").and_then(|c| c.as_array()) {            Some(c) => c.iter().filter_map(|x| x.as_str().map(String::from)).collect(),            None => {                let mut k: Vec<String> = Vec::new();                for r in list { if let Some(o) = r.as_object() { for key in o.keys() { if !k.contains(key) { k.push(key.clone()) } } } }                k            }        };        let head = keys.iter().map(|k| titles.get(k).and_then(|t| t.as_str()).unwrap_or(k).to_string()).collect();        let body = list.iter().map(|r| keys.iter().map(|k| r.get(k).cloned().unwrap_or(Value::Null)).collect()).collect();        (keys, head, body)    } else {        let mut lists: Vec<Vec<Value>> = list.iter().map(|r| r.as_array().cloned().unwrap_or_else(|| vec![r.clone()])).collect();        let header = options.get("header").and_then(|h| h.as_bool()).unwrap_or(true);        let n = lists.iter().map(|r| r.len()).max().unwrap_or(0);        let head: Vec<String> = if header && !lists.is_empty() { lists.remove(0).iter().map(|v| cell(v, None)).collect() } else { vec![] };        let keys = (0..n).map(|i| head.get(i).cloned().unwrap_or_else(|| i.to_string())).collect();        (keys, head, lists)    };    let n = keys.len().max(head.len()).max(body.iter().map(|r| r.len()).max().unwrap_or(0));    let align_opts = options.get("align").and_then(|a| a.as_object()).cloned().unwrap_or_default();    let align: Vec<Align> = (0..n).map(|i| {        if let Some(a) = keys.get(i).and_then(|k| align_opts.get(k)).and_then(|a| a.as_str()) {            return match a { "right" => Align::Right, "center" => Align::Center, _ => Align::Left };        }        let col: Vec<&Value> = body.iter().filter_map(|r| r.get(i)).filter(|v| !v.is_null()).collect();        if !col.is_empty() && col.iter().all(|v| v.is_number()) { Align::Right } else { Align::Left }    }).collect();    let rows = body.iter().map(|r| (0..n).map(|i| cut(&cell(r.get(i).unwrap_or(&Value::Null), decimals), max)).collect()).collect();    let head = if head.is_empty() { vec![] } else { (0..n).map(|i| cut(head.get(i).map(|s| s.as_str()).unwrap_or(""), max)).collect() };    Ok(Grid { head, rows, align })}/// `rows` as an aligned text table: a list of maps (columns in the first rows' key order) or a list of lists (the first the header, unless `options.header` is false)./// `options`: `style` (`"box"`, `"rounded"`, `"ascii"`, `"markdown"`, `"plain"`), `columns` (which keys, in order), `titles` (`{ key: "Header" }`), `align` (`{ key: "right" }`; numbers go right by themselves), `decimals` for floats, `max_width` to cut long cells with "…".pub fn text_table(rows: serde_json::Value, options: serde_json::Value) -> Result<String, String> {    let g = grid(&rows, &options)?;    let style = options.get("style").and_then(|s| s.as_str()).unwrap_or("box");    if style == "markdown" { return Ok(markdown(&g)) }    let n = g.align.len();    let w: Vec<usize> = (0..n).map(|i| g.rows.iter().map(|r| width(&r[i])).chain(g.head.get(i).map(|h| width(h))).max().unwrap_or(0)).collect();    // corners and lines: (top, middle, bottom) each (left, join, right, fill), and the vertical bar.    let (top, mid, bot, bar): ([&str; 4], [&str; 4], [&str; 4], &str) = match style {        "box" => (["┌", "┬", "┐", "─"], ["├", "┼", "┤", "─"], ["└", "┴", "┘", "─"], "│"),        "rounded" => (["╭", "┬", "╮", "─"], ["├", "┼", "┤", "─"], ["╰", "┴", "╯", "─"], "│"),        "ascii" => (["+", "+", "+", "-"], ["+", "+", "+", "-"], ["+", "+", "+", "-"], "|"),        "plain" => (["", "", "", ""], ["", "  ", "", "-"], ["", "", "", ""], ""),        s => return Err(format!("`{}` is not a style: box, rounded, ascii, markdown or plain", s)),    };    let line = |p: [&str; 4]| -> String {        if p[3].is_empty() { return String::new() }        if style == "plain" { return w.iter().map(|x| p[3].repeat(*x)).collect::<Vec<_>>().join(p[1]) }        format!("{}{}{}", p[0], w.iter().map(|x| p[3].repeat(x + 2)).collect::<Vec<_>>().join(p[1]), p[2])    };    let row = |cells: &Vec<String>, head: bool| -> String {        let parts: Vec<String> = (0..n).map(|i| pad(&cells[i], w[i], if head { if g.align[i] == Align::Right { Align::Right } else { Align::Left } } else { g.align[i] })).collect();        if style == "plain" { parts.join("  ").trim_end().to_string() } else { format!("{} {} {}", bar, parts.join(&format!(" {} ", bar)), bar) }    };    let mut out = Vec::new();    let t = line(top);    if !t.is_empty() { out.push(t) }    if !g.head.is_empty() { out.push(row(&g.head, true)); out.push(line(mid)); }    for r in &g.rows { out.push(row(r, false)) }    let b = line(bot);    if !b.is_empty() { out.push(b) }    Ok(out.join("\n"))}fn markdown(g: &Grid) -> String {    let esc = |s: &str| s.replace('|', "\\|");    let n = g.align.len();    let w: Vec<usize> = (0..n).map(|i| g.rows.iter().map(|r| width(&esc(&r[i]))).chain(g.head.get(i).map(|h| width(&esc(h)))).max().unwrap_or(0).max(3)).collect();    let head: Vec<String> = if g.head.is_empty() { vec![String::new(); n] } else { g.head.clone() };    let mut out = vec![format!("| {} |", (0..n).map(|i| pad(&esc(&head[i]), w[i], Align::Left)).collect::<Vec<_>>().join(" | "))];    out.push(format!("|{}|", (0..n).map(|i| match g.align[i] {        Align::Right => format!("{}:", "-".repeat(w[i] + 1)),        Align::Center => format!(":{}:", "-".repeat(w[i])),        Align::Left => "-".repeat(w[i] + 2),    }).collect::<Vec<_>>().join("|")));    for r in &g.rows { out.push(format!("| {} |", (0..n).map(|i| pad(&esc(&r[i]), w[i], g.align[i])).collect::<Vec<_>>().join(" | "))) }    out.join("\n")}/// `rows` as a GitHub-flavoured Markdown table, the alignment row saying which columns are numbers: for READMEs, issues, chat and docs. The same `options` as `text_table`.pub fn text_table_markdown(rows: serde_json::Value, options: serde_json::Value) -> Result<String, String> {    Ok(markdown(&grid(&rows, &options)?))}/// The first Markdown table in `markdown` read back as rows: a list of maps keyed by the header, numbers in number-only cells made numbers.pub fn text_table_from_markdown(markdown: &str) -> Result<Vec<Value>, String> {    let split = |l: &str| -> Vec<String> {        let t = l.trim().trim_start_matches('|').trim_end_matches('|');        let mut cells = Vec::new();        let mut cur = String::new();        let mut esc = false;        for c in t.chars() {            if esc { cur.push(c); esc = false; continue }            match c { '\\' => esc = true, '|' => { cells.push(cur.trim().to_string()); cur.clear() } _ => cur.push(c) }        }        cells.push(cur.trim().to_string());        cells    };    let lines: Vec<&str> = markdown.lines().collect();    let is_rule = |l: &str| { let c = split(l); !c.is_empty() && c.iter().all(|x| !x.is_empty() && x.chars().all(|ch| ch == '-' || ch == ':')) };    let at = (1..lines.len()).find(|&i| lines[i - 1].contains('|') && is_rule(lines[i])).ok_or("no Markdown table found")?;    let head = split(lines[at - 1]);    let mut out = Vec::new();    for l in &lines[at + 1..] {        if !l.contains('|') || l.trim().is_empty() { break }        let cells = split(l);        let mut m = Map::new();        for (i, h) in head.iter().enumerate() {            let c = cells.get(i).cloned().unwrap_or_default();            let v = if let Ok(n) = c.replace(',', "").parse::<i64>() { if c.contains(',') && !c.chars().all(|x| x.is_ascii_digit() || x == ',' || x == '-') { Value::String(c) } else { Value::from(n) } }                else if let Ok(f) = c.parse::<f64>() { Value::from(f) } else { Value::String(c) };            m.insert(h.clone(), v);        }        out.push(Value::Object(m));    }    Ok(out)}
Text wrapping text_wrap.qwer · 1.0.0Text wrapped and filled at a width for plain-text emails and terminals: paragraphs refilled, hanging indents for lists, and > quoting for replies.emailtextterminalexport7 views · 6 downloadsDMARC reports dmarc_report.qwer · 1.0.0Read DMARC aggregate reports from their XML or their gzip and zip attachments, with a limit on what they unpack to, into records and summaries by source and alignment that show who sends as your domain and what fails.emaildnsreportsxml2 views · 6 downloadsChunker chunker.qwer · 1.0.0Text and Markdown split into chunks by characters or OpenAI tokens, with overlap, cut at headings, paragraphs and sentences, for embeddings and LLM prompts.aillmembeddingsmarkdownsearch9 views · 8 downloadsHTML to Markdown html_markdown.qwer · 1.0.0HTML turned into clean Markdown as Turndown does: headings, lists, links, images, code blocks, quotes and tables kept, scripts and the parts you name dropped; for importing old posts, editor output, and pages for an LLM.htmlmarkdownimportllm7 views · 6 downloadsHTML to text html_text.qwer · 1.0.0HTML as readable plain text for the text part of an email: headings, lists and tables laid out, links listed at the end, and one-line previews.emailhtmltextexport7 views · 7 downloadsHyphenation hyphenation.qwer · 1.0.0Words hyphenated by the rules of 80 languages and spellings, patterns built in: a word's syllables, soft hyphens put in text and HTML for narrow columns and e-mails, and text wrapped at a width with words broken where they may be.textlanguagesemailwriting6 views · 7 downloads
Thread
77Useful? Like it, or add to its thread below.

Thread 0 posts

On Text tables: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.