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
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 errorrows 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 errorrows 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 errorThe 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.qweOr with curl:
$ curl -O https://quillolang.org/plugins/files/text_table.qwer$ curl -O https://quillolang.org/plugins/files/text_table.qweThen, in your program, all of it or only what you call:
use text_table.qweruse text_table, text_table_markdown from text_table // Quillo 0.6: only theseIt uses these crates, which cargo fetches the first time the plugin is built:
unicode-width = "0.2"serde_json = { version = "1", features = ["preserve_order"] }Example
// 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
//! 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)}Related
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.7 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.2 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.9 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.7 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.7 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.6 views · 7 downloadsThread 0 posts
On Text tables: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.