Files

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

#documents#word#text#import#export

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 error

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.

rtf_file_text(path: str) → stror an error

The text of the RTF file at path, as rtf_to_text gives it.

rtf_info(path: str) → anyor an error

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.

rtf_build(blocks: list, options: any) → stror an error

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.

Get it

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

Or 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.rtf

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

main.qwe
use rtf.qweruse rtf_to_text, rtf_file_text from rtf      // Quillo 0.6: only these

It uses this crate, which cargo fetches the first time the plugin is built:

[dependencies]
encoding_rs = "0.8"

Example

rtf.qwe
// 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.qwer
//! 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)}
Thread
78Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.