RTF
RTF documents read as plain text with their title and author, and simple RTF written: headings, bold and italic runs and tables, for Word, Pages and TextEdit.
Version 1.0.0 · 4 functions · 1 crate
Functions
What use rtf.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use rtf_to_text, rtf_file_text from rtf (Quillo 0.6).
rtf_to_text(rtf: str) → stror an errorThe text of an RTF document: paragraphs on lines of their own, table cells split by tabs, rows on lines; fonts, colours, pictures, field codes and headers left out. Accented letters in any code page and \u characters come through as they are.
rtf_file_text(path: str) → stror an errorThe text of the RTF file at path, as rtf_to_text gives it.
rtf_info(path: str) → anyor an errorWhat the RTF file at path says of itself (its \info group): any of title, subject, author, operator (who saved it last), company, keywords, doccomm (comments), category, manager, and created, revised and printed as "2026-10-29T09:30"; with words, the document's own count of its text.
rtf_build(blocks: list, options: any) → stror an errorAn RTF document from blocks, each a paragraph: a text, or { text, bold, italic, underline, size, align, heading } where text may be a list of runs (texts or { text, bold, italic, underline, size }), align is left, center, right or justify, heading 1 to 3; or a table { table: [[cell, ...], ...], widths: [cm, ...], header: true }. options has any of font ("Helvetica"), size (points, 11), title, author. Gives the RTF text: write it with write_file, or send it as application/rtf.
Get it
$ quillo install rtf # rtf.qwer into this folder$ quillo install rtf --example # and its example, rtf.qweOr with curl:
$ curl -O https://quillolang.org/plugins/files/rtf.qwer$ curl -O https://quillolang.org/plugins/files/rtf.qwe$ curl -O https://quillolang.org/plugins/files/rtf_sample.rtfThen, in your program, all of it or only what you call:
use rtf.qweruse rtf_to_text, rtf_file_text from rtf // Quillo 0.6: only theseIt uses this crate, which cargo fetches the first time the plugin is built:
encoding_rs = "0.8"Example
// A letting agent's letters: a tenant's RTF letter read into the case file, and a new letter written in RTF for Word.//// quillo run rtf.qwe then open http://localhost:8080/letter, /info and /writeuse "rtf.qwer"route GET /letter = respond(200, rtf_file_text("rtf_sample.rtf")?, { "content-type": "text/plain; charset=utf-8" })route GET /info = rtf_info("rtf_sample.rtf")?route GET /write { let doc = rtf_build([ { text: "Rent statement", heading: 1, align: "center" }, ["Dear ", { text: "Ms Pereira", bold: true }, ", here is this year's rent, in €."], { table: [["Month", "Paid"], ["October", "€1,250"], ["November", "€1,250"]], widths: [6.0, 4.0], header: true }, { text: "Thank you — Harbour Lettings", italic: true }, ], { font: "Calibri", size: 11.0, title: "Rent statement", author: "Harbour Lettings" })? write_file("data/rent-statement.rtf", doc) { bytes: len(doc), back: rtf_to_text(doc)?, title: rtf_info("data/rent-statement.rtf")?.title }}route GET /download = respond(200, rtf_build(["Hello from Quillo", { text: "Bold and big", bold: true, size: 16.0 }], {})?, { "content-type": "application/rtf", "content-disposition": "attachment; filename=hello.rtf" })$ quillo run rtf.qwe$ curl 'localhost:8080/letter'$ curl 'localhost:8080/info'$ curl 'localhost:8080/write'Source
//! RTF documents read as plain text with their title and author, and simple RTF written (headings, bold and italic runs, lists of paragraphs, tables) that Word, Pages and TextEdit open.//! version: 1.0.0//!//! [dependencies]//! encoding_rs = "0.8"use serde_json::{json, Map, Value};const MAX_RTF: usize = 256 * 1024 * 1024;// Groups whose text is not the document's: tables of fonts and colours, pictures, field codes, embedded objects, themes.const SKIP: &[&str] = &[ "fonttbl", "colortbl", "stylesheet", "listtable", "listoverridetable", "rsidtbl", "generator", "pict", "object", "objdata", "fldinst", "themedata", "colorschememapping", "latentstyles", "datastore", "xmlnstbl", "mmathPr", "pgdsctbl", "filetbl", "revtbl", "header", "headerl", "headerr", "headerf", "footer", "footerl", "footerr", "footerf", "footnote", "bkmkstart", "bkmkend", "nonshppict", "shppict", "sp", "shpinst", "xe", "tc", "listtext", "pntext", "pntxta", "pntxtb", "falt", "panose",];const INFO_TEXT: &[&str] = &["title", "subject", "author", "operator", "company", "keywords", "doccomm", "category", "manager"];const INFO_DATES: &[&str] = &["creatim", "revtim", "printim"];#[derive(Clone, Default)]struct State { skip: bool, uc: usize, info: bool, capture: Option<String>, date: Option<String>,}struct Parsed { text: String, info: Map<String, Value>,}fn decoder(cp: i64) -> &'static encoding_rs::Encoding { let label = match cp { 65001 => "utf-8".to_string(), 932 => "shift_jis".to_string(), 936 => "gbk".to_string(), 949 => "euc-kr".to_string(), 950 => "big5".to_string(), 10000 => "macintosh".to_string(), 437 | 850 => "windows-1252".to_string(), n => format!("windows-{}", n), }; encoding_rs::Encoding::for_label(label.as_bytes()).unwrap_or(encoding_rs::WINDOWS_1252)}fn parse(d: &[u8]) -> Result<Parsed, String> { if d.len() > MAX_RTF { return Err("more than 256 MB of RTF".into()); } if !d.trim_ascii_start().starts_with(b"{\\rtf") { return Err("not RTF: it does not start with {\\rtf".into()); } let mut enc = encoding_rs::WINDOWS_1252; let mut st = State { uc: 1, ..Default::default() }; let mut stack: Vec<State> = Vec::new(); let mut text = String::new(); let mut info = Map::new(); let mut bytes: Vec<u8> = Vec::new(); // \'hh bytes waiting to be decoded together (two-byte code pages) let mut fallback = 0usize; // characters still to drop after a \uN let mut group_start = false; let mut i = 0; fn emit(st: &State, text: &mut String, info: &mut Map<String, Value>, s: &str) { if let Some(k) = &st.capture { let e = info.entry(k.clone()).or_insert(Value::String(String::new())); if let Value::String(v) = e { v.push_str(s); } } else if !st.skip && !st.info { text.push_str(s); } } let flush = |bytes: &mut Vec<u8>, st: &State, text: &mut String, info: &mut Map<String, Value>, enc: &'static encoding_rs::Encoding| { if !bytes.is_empty() { let (s, _, _) = enc.decode(bytes); emit(st, text, info, &s); bytes.clear(); } }; while i < d.len() { let c = d[i]; match c { b'{' => { flush(&mut bytes, &st, &mut text, &mut info, enc); stack.push(st.clone()); fallback = 0; group_start = true; i += 1; continue; } b'}' => { flush(&mut bytes, &st, &mut text, &mut info, enc); st = stack.pop().unwrap_or_default(); fallback = 0; i += 1; } b'\\' if i + 1 < d.len() => { let n = d[i + 1]; if n.is_ascii_alphabetic() { let mut j = i + 1; while j < d.len() && d[j].is_ascii_alphabetic() { j += 1; } let word = std::str::from_utf8(&d[i + 1..j]).unwrap_or(""); let mut k = j; if k < d.len() && d[k] == b'-' { k += 1; } while k < d.len() && d[k].is_ascii_digit() { k += 1; } let param: Option<i64> = std::str::from_utf8(&d[j..k]).ok().and_then(|p| p.parse().ok()); if k < d.len() && d[k] == b' ' { k += 1; } i = k; if word != "u" { flush(&mut bytes, &st, &mut text, &mut info, enc); } let first = std::mem::replace(&mut group_start, false); if first && SKIP.contains(&word) { st.skip = true; } if first && word == "info" { st.info = true; } if first && st.info && INFO_TEXT.contains(&word) { st.capture = Some(word.to_string()); info.insert(word.to_string(), Value::String(String::new())); } if first && st.info && INFO_DATES.contains(&word) { st.date = Some(word.to_string()); info.insert(word.to_string(), json!({})); } if let Some(dk) = st.date.clone() { if matches!(word, "yr" | "mo" | "dy" | "hr" | "min" | "sec") { if let Some(Value::Object(m)) = info.get_mut(&dk) { m.insert(word.to_string(), json!(param.unwrap_or(0))); } } continue; } match word { "ansicpg" => enc = decoder(param.unwrap_or(1252)), "mac" => enc = encoding_rs::MACINTOSH, "uc" => st.uc = param.unwrap_or(1).clamp(0, 10) as usize, "u" => { flush(&mut bytes, &st, &mut text, &mut info, enc); let mut cp = param.unwrap_or(0); if cp < 0 { cp += 65536; } let cp = cp as u32; // A surrogate pair comes as two \u words. if (0xD800..0xDC00).contains(&cp) { // Skip the fallback of the first half, then expect \uN for the second. let mut skipped = 0; let mut p = i; while skipped < st.uc && p < d.len() { if d[p] == b'\\' && p + 1 < d.len() && d[p + 1] == b'\'' { p += 4; } else { p += 1; } skipped += 1; } let rest = &d[p.min(d.len())..]; if rest.starts_with(b"\\u") { let mut e = 2; if e < rest.len() && rest[e] == b'-' { e += 1; } while e < rest.len() && rest[e].is_ascii_digit() { e += 1; } if let Ok(mut lo) = std::str::from_utf8(&rest[2..e]).unwrap_or("").parse::<i64>() { if lo < 0 { lo += 65536; } if (0xDC00..0xE000).contains(&(lo as u32)) { let ch = 0x10000 + ((cp - 0xD800) << 10) + (lo as u32 - 0xDC00); if let Some(ch) = char::from_u32(ch) { emit(&st, &mut text, &mut info, &ch.to_string()); } i = p + e; if i < d.len() && d[i] == b' ' { i += 1; } fallback = st.uc; continue; } } } } else if let Some(ch) = char::from_u32(cp) { emit(&st, &mut text, &mut info, &ch.to_string()); } fallback = st.uc; continue; } "par" | "line" | "row" | "sect" | "page" => emit(&st, &mut text, &mut info, "\n"), "tab" | "cell" => emit(&st, &mut text, &mut info, "\t"), "emdash" => emit(&st, &mut text, &mut info, "\u{2014}"), "endash" => emit(&st, &mut text, &mut info, "\u{2013}"), "bullet" => emit(&st, &mut text, &mut info, "\u{2022}"), "lquote" => emit(&st, &mut text, &mut info, "\u{2018}"), "rquote" => emit(&st, &mut text, &mut info, "\u{2019}"), "ldblquote" => emit(&st, &mut text, &mut info, "\u{201C}"), "rdblquote" => emit(&st, &mut text, &mut info, "\u{201D}"), "bin" => i += param.unwrap_or(0).max(0) as usize, _ => {} } fallback = 0; continue; } group_start = false; match n { b'\'' => { if i + 4 <= d.len() { let h = std::str::from_utf8(&d[i + 2..i + 4]).ok().and_then(|h| u8::from_str_radix(h, 16).ok()); i += 4; if fallback > 0 { fallback -= 1; continue; } if let Some(b) = h { bytes.push(b); } } else { i = d.len(); } continue; } b'*' => { st.skip = true; } b'~' => emit(&st, &mut text, &mut info, "\u{00A0}"), b'_' => emit(&st, &mut text, &mut info, "\u{2011}"), b'-' => {} b'\n' | b'\r' => emit(&st, &mut text, &mut info, "\n"), b'\\' | b'{' | b'}' => { if fallback > 0 { fallback -= 1; } else { flush(&mut bytes, &st, &mut text, &mut info, enc); emit(&st, &mut text, &mut info, &(n as char).to_string()); } } _ => {} } i += 2; } b'\r' | b'\n' => i += 1, _ => { group_start = false; // Plain text runs to the next control character; it is ASCII, or the code page's bytes. let mut j = i; while j < d.len() && !matches!(d[j], b'\\' | b'{' | b'}' | b'\r' | b'\n') { j += 1; } let mut run = &d[i..j]; while fallback > 0 && !run.is_empty() { run = &run[1..]; fallback -= 1; } flush(&mut bytes, &st, &mut text, &mut info, enc); if !run.is_empty() { let (s, _, _) = enc.decode(run); emit(&st, &mut text, &mut info, &s); } i = j; } } } flush(&mut bytes, &st, &mut text, &mut info, enc); Ok(Parsed { text, info })}fn tidy(text: &str) -> String { let lines: Vec<&str> = text.split('\n').map(|l| l.trim_end_matches([' ', '\t'])).collect(); let mut out = String::new(); let mut blank = 0; for l in lines { if l.trim().is_empty() { blank += 1; continue; } if !out.is_empty() { out.push_str(if blank > 0 { "\n\n" } else { "\n" }); } blank = 0; out.push_str(l); } out}/// The text of an RTF document: paragraphs on lines of their own, table cells split by tabs, rows on lines; fonts, colours,/// pictures, field codes and headers left out. Accented letters in any code page and `\u` characters come through as they are.pub fn rtf_to_text(rtf: &str) -> Result<String, String> { Ok(tidy(&parse(rtf.as_bytes())?.text))}/// The text of the RTF file at `path`, as `rtf_to_text` gives it.pub fn rtf_file_text(path: &str) -> Result<String, String> { let d = std::fs::read(path).map_err(|e| format!("{}: {}", path, e))?; Ok(tidy(&parse(&d).map_err(|e| format!("{}: {}", path, e))?.text))}/// What the RTF file at `path` says of itself (its `\info` group): any of `title`, `subject`, `author`, `operator` (who saved it last),/// `company`, `keywords`, `doccomm` (comments), `category`, `manager`, and `created`, `revised` and `printed` as "2026-10-29T09:30"; with `words`, the/// document's own count of its text.pub fn rtf_info(path: &str) -> Result<Value, String> { let d = std::fs::read(path).map_err(|e| format!("{}: {}", path, e))?; let p = parse(&d).map_err(|e| format!("{}: {}", path, e))?; let mut out = Map::new(); for (k, v) in p.info { match v { Value::String(s) => { out.insert(k, Value::String(s.trim().to_string())); } Value::Object(m) => { let n = |f: &str| m.get(f).and_then(|x| x.as_i64()).unwrap_or(0); let name = match k.as_str() { "creatim" => "created", "revtim" => "revised", _ => "printed", }; if n("yr") > 0 { out.insert(name.into(), Value::String(format!("{:04}-{:02}-{:02}T{:02}:{:02}", n("yr"), n("mo").max(1), n("dy").max(1), n("hr"), n("min")))); } } _ => {} } } out.insert("words".into(), json!(tidy(&p.text).split_whitespace().count())); Ok(Value::Object(out))}fn esc(s: &str) -> String { let mut o = String::with_capacity(s.len()); for ch in s.chars() { match ch { '\\' => o.push_str("\\\\"), '{' => o.push_str("\\{"), '}' => o.push_str("\\}"), '\n' => o.push_str("\\line "), '\t' => o.push_str("\\tab "), c if (c as u32) < 0x80 => o.push(c), c => { let mut buf = [0u16; 2]; for u in c.encode_utf16(&mut buf) { o.push_str(&format!("\\u{}?", *u as i16)); } } } } o}fn runs(v: &Value) -> String { match v { Value::String(s) => esc(s), Value::Array(a) => a.iter().map(runs).collect(), Value::Object(m) => { let inner = m.get("text").map(runs).unwrap_or_default(); let flag = |k: &str| m.get(k).and_then(|x| x.as_bool()).unwrap_or(false); let mut on = String::new(); if flag("bold") { on.push_str("\\b"); } if flag("italic") { on.push_str("\\i"); } if flag("underline") { on.push_str("\\ul"); } if let Some(sz) = m.get("size").and_then(|x| x.as_f64()) { on.push_str(&format!("\\fs{}", (sz * 2.0).round() as i64)); } if on.is_empty() { format!("{{{}}}", inner) } else { format!("{{{} {}}}", on, inner) } } Value::Null => String::new(), other => esc(&other.to_string()), }}/// An RTF document from `blocks`, each a paragraph: a text, or `{ text, bold, italic, underline, size, align, heading }` where `text` may be a list/// of runs (texts or `{ text, bold, italic, underline, size }`), `align` is left, center, right or justify, `heading` 1 to 3; or a table/// `{ table: [[cell, ...], ...], widths: [cm, ...], header: true }`. `options` has any of `font` ("Helvetica"), `size` (points, 11), `title`, `author`./// Gives the RTF text: write it with `write_file`, or send it as `application/rtf`.pub fn rtf_build(blocks: Vec<serde_json::Value>, options: serde_json::Value) -> Result<String, String> { let font = options.get("font").and_then(|v| v.as_str()).unwrap_or("Helvetica"); let size = options.get("size").and_then(|v| v.as_f64()).unwrap_or(11.0); let base = (size * 2.0).round() as i64; let mut o = format!("{{\\rtf1\\ansi\\ansicpg1252\\deff0\\uc1\n{{\\fonttbl{{\\f0\\fswiss\\fcharset0 {};}}}}\n", esc(font).replace(';', "")); let mut meta = String::new(); for k in ["title", "author"] { if let Some(v) = options.get(k).and_then(|v| v.as_str()) { meta.push_str(&format!("{{\\{} {}}}", k, esc(v))); } } if !meta.is_empty() { o.push_str(&format!("{{\\info{}}}\n", meta)); } o.push_str(&format!("\\f0\\fs{}\n", base)); for b in &blocks { if let Some(rows) = b.get("table").and_then(|t| t.as_array()) { let cols = rows.iter().filter_map(|r| r.as_array()).map(|r| r.len()).max().unwrap_or(0); if cols == 0 { continue; } let widths: Vec<f64> = b.get("widths").and_then(|w| w.as_array()).map(|w| w.iter().filter_map(|x| x.as_f64()).collect()).unwrap_or_default(); let header = b.get("header").and_then(|h| h.as_bool()).unwrap_or(false); for (ri, r) in rows.iter().enumerate() { let cells = r.as_array().cloned().unwrap_or_default(); o.push_str("\\trowd\\trgaph108"); let mut x = 0.0; for c in 0..cols { // Widths in centimetres; RTF counts twips (567 to the centimetre). x += widths.get(c).copied().unwrap_or(16.0 / cols as f64); o.push_str(&format!("\\clbrdrt\\brdrs\\clbrdrl\\brdrs\\clbrdrb\\brdrs\\clbrdrr\\brdrs\\cellx{}", (x * 567.0).round() as i64)); } o.push('\n'); for c in 0..cols { let cell = cells.get(c).cloned().unwrap_or(Value::Null); let body = runs(&cell); if header && ri == 0 { o.push_str(&format!("\\pard\\intbl{{\\b {}}}\\cell\n", body)); } else { o.push_str(&format!("\\pard\\intbl {}\\cell\n", body)); } } o.push_str("\\row\n"); } o.push_str("\\pard\n"); continue; } let align = match b.get("align").and_then(|a| a.as_str()).unwrap_or("left") { "center" | "centre" => "\\qc", "right" => "\\qr", "justify" => "\\qj", _ => "\\ql", }; let heading = b.get("heading").and_then(|h| h.as_i64()).unwrap_or(0); let mut pre = format!("\\pard{}\\sa120", align); if (1..=3).contains(&heading) { pre.push_str(&format!("\\b\\fs{}", base + [0, 16, 10, 4][heading as usize])); } let body = if b.is_object() { runs(&json!([b.get("text").cloned().unwrap_or(Value::Null)])) } else { runs(b) }; let body = if b.is_object() && (b.get("bold").is_some() || b.get("italic").is_some() || b.get("underline").is_some() || b.get("size").is_some()) { let mut m = b.as_object().cloned().unwrap_or_default(); m.remove("align"); m.remove("heading"); runs(&Value::Object(m)) } else { body }; o.push_str(&format!("{{{} {}\\par}}\n", pre, body)); } o.push('}'); Ok(o)}Related
odf_text.qwer · 1.0.0The text of LibreOffice files (.odt, .ods, .odp): paragraphs with headings and lists, sheets as rows of typed cells, slides with their notes, and the title and author.7 views · 9 downloadsText encodings text_encoding.qwer · 1.0.0Text files in old encodings (Windows-1252, Latin-1, Shift_JIS, GBK, Big5...) detected and read as UTF-8, and written back in them.8 views · 8 downloadsWord text docx_text.qwer · 1.0.0The text of Word documents: all of it, paragraph by paragraph with their styles, the tables as rows, and the title and author.7 views · 9 downloadsMailboxes mbox.qwer · 1.0.0Mailboxes in the mbox format read message by message: lists, search, one message in full with its attachments, each saved as .eml, and messages appended.7 views · 9 downloadsPDF text pdf_text.qwer · 1.0.0The text of a PDF, whole or by page, and the lines holding a word: for search, imports and reading invoices.7 views · 6 downloadsPowerPoint text pptx_text.qwer · 1.0.0The text of PowerPoint files: each slide's title, text and speaker notes in slide order, for search and imports.7 views · 7 downloadsThread 0 posts
On RTF: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.