Text

Typography

Typed text set as a typographer would, by language: curly quotes (“ ”, « », „ “) and apostrophes, en and em dashes, ellipses, ×, ©, French spaces before ; : ! ?, and no widows; in plain text or HTML, code left alone.

Version 1.0.0 · 3 functions · 0 crates

#writing#text#html#locale

Functions

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

typography_text(text: str, lang: str) → str

Plain text set by the rules of lang ("en", "en-gb", "fr", "de", "de-ch", "es", "it", "nl", "pl", "cs", "sv", "ja"...): curly quotes and apostrophes (’90s, rock ’n’ roll), -- and - as an en dash, --- as an em dash, ... as …, 3 x 4 as 3 × 4, (c) (r) (tm) as © ® ™; French narrow no-break spaces before ; : ! ? % and inside « »; Czech, Slovak and Polish one-letter words kept with the next.

typography_html(html: str, lang: str) → str

The same for HTML: only the text between tags is changed (&quot; and &#39; too), quotes followed across inline tags; <code>, <pre>, <kbd>, <samp>, <script>, <style> and <textarea> are left as they are.

typography_widows(text: str) → str

No widows: a no-break space between the last two words of each paragraph (each line of plain text, or each <p>, heading, list item and caption of HTML) when the last word is short (under 10 letters), so it never sits alone on a line.

Get it

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

Or with curl:

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

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

main.qwe
use typography.qweruse typography_text, typography_html from typography      // Quillo 0.6: only these

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

[dependencies]

Example

typography.qwe
// A magazine's CMS: what writers type with a keyboard, set as it would be printed, in English, French and German, in text and in HTML.////   quillo run typography.qwe      then open http://localhost:8080/typographyuse "typography.qwer"fn show(s) = replace(replace(s, "\u{a0}", "~"), "\u{202f}", "_")route GET /typography {    {        english: typography_text("\"It's the '90s -- or is it?\" she said... (c) 2026, 3 x 4 m", "en"),        nested: typography_text("He said, \"Don't call it 'smart'.\"", "en"),        french: show(typography_text("Il a dit : \"Bonjour !\" Vraiment ?", "fr")),        german: typography_text("Sie sagte: \"Guten Tag\" -- und ging.", "de"),        swiss: typography_text("\"Grüezi\"", "de-ch"),        em_dash: typography_text("Wait---what?", "en"),        html: typography_html("<p>\"Use <code>let x = \"y\"</code> here,\" she said. It's <em>\"fine\"</em>...</p>", "en"),        widows: show(typography_widows("<h1>A headline that could leave one word alone</h1><p>And a paragraph with an ending.</p>")),        czech: show(typography_text("Byl v Praze a u nás.", "cs")),    }}
$ quillo run typography.qwe$ curl 'localhost:8080/typography'

Source

typography.qwer
//! Typewriter text set as a typographer would, by language: curly quotes (“English”, « français », „Deutsch“), apostrophes, en and em dashes, ellipses, ×, ©, French spaces before ; : ! ?, and no single word left alone on a paragraph's last line; in plain text or in HTML, code left alone.//! version: 1.0.0//!//! [dependencies]const NBSP: char = '\u{A0}';const NNBSP: char = '\u{202F}';struct Style { dq: (&'static str, &'static str), sq: (&'static str, &'static str), french_spaces: bool, single_letter_nbsp: bool }fn style(lang: &str) -> Style {    let l = lang.trim().to_lowercase().replace('_', "-");    let base = l.split('-').next().unwrap_or("en").to_string();    let s = |dq, sq, fs, sl| Style { dq, sq, french_spaces: fs, single_letter_nbsp: sl };    match (base.as_str(), l.as_str()) {        (_, "de-ch") | (_, "fr-ch") => s(("«", "»"), ("‹", "›"), false, false),        ("fr", _) => s(("«\u{202F}", "\u{202F}»"), ("“", "”"), true, false),        ("de", _) | ("cs", _) | ("sk", _) => s(("„", "“"), ("‚", "‘"), false, base != "de"),        ("pl", _) => s(("„", "”"), ("«", "»"), false, true),        ("nl", _) => s(("“", "”"), ("‘", "’"), false, false),        ("es", _) | ("it", _) | ("ru", _) | ("uk", _) | ("pt", _) | ("el", _) | ("ca", _) => s(("«", "»"), ("“", "”"), false, false),        ("sv", _) | ("fi", _) => s(("”", "”"), ("’", "’"), false, false),        ("da", _) => s(("»", "«"), ("›", "‹"), false, false),        ("ja", _) | ("zh", _) => s(("「", "」"), ("『", "』"), false, false),        _ => s(("“", "”"), ("‘", "’"), false, false),    }}fn opens(prev: Option<char>) -> bool {    match prev {        None => true,        Some(c) => c.is_whitespace() || "([{–—-/«„‚“‘\u{A0}\u{202F}".contains(c),    }}// Smarten one run of text; `prev` is the character before it (from the text before a tag).fn smarten(text: &str, st: &Style, prev_in: Option<char>) -> String {    let c: Vec<char> = text.chars().collect();    let mut out = String::with_capacity(text.len() + 16);    let mut prev = prev_in;    let mut i = 0;    let push = |out: &mut String, s: &str, prev: &mut Option<char>| { out.push_str(s); *prev = s.chars().last(); };    while i < c.len() {        let ch = c[i];        let next = c.get(i + 1).copied();        match ch {            '"' => {                let q = if opens(prev) { st.dq.0 } else { st.dq.1 };                // A French closing quote replaces the space before it.                if st.french_spaces && !opens(prev) { while out.ends_with(' ') { out.pop(); } }                push(&mut out, q, &mut prev);                if st.french_spaces && q.starts_with('«') { while c.get(i + 1) == Some(&' ') { i += 1 } }            }            '\'' => {                let after_word = prev.map(|p| p.is_alphanumeric()).unwrap_or(false);                let before_digit = next.map(|n| n.is_ascii_digit()).unwrap_or(false);                if after_word || (before_digit && opens(prev)) { push(&mut out, "’", &mut prev) }                else if opens(prev) { push(&mut out, st.sq.0, &mut prev) } else { push(&mut out, st.sq.1, &mut prev) }            }            '-' if next == Some('-') && c.get(i + 2) == Some(&'-') => { push(&mut out, "—", &mut prev); i += 2 }            '-' if next == Some('-') => { push(&mut out, "–", &mut prev); i += 1 }            '-' if prev == Some(' ') && next == Some(' ') => push(&mut out, "–", &mut prev),            '.' if next == Some('.') && c.get(i + 2) == Some(&'.') => { push(&mut out, "…", &mut prev); i += 2 }            'x' | 'X' if prev == Some(' ') && next == Some(' ') && i >= 2 && c[i - 2].is_ascii_digit() && c.get(i + 2).map(|d| d.is_ascii_digit()).unwrap_or(false) => push(&mut out, "×", &mut prev),            '(' if matches!(&c[i..(i + 3).min(c.len())], ['(', 'c' | 'C', ')']) => { push(&mut out, "©", &mut prev); i += 2 }            '(' if matches!(&c[i..(i + 3).min(c.len())], ['(', 'r' | 'R', ')']) => { push(&mut out, "®", &mut prev); i += 2 }            '(' if i + 3 < c.len() && c[i + 1].eq_ignore_ascii_case(&'t') && c[i + 2].eq_ignore_ascii_case(&'m') && c[i + 3] == ')' => { push(&mut out, "™", &mut prev); i += 3 }            ';' | ':' | '!' | '?' | '%' if st.french_spaces => {                // A narrow no-break space before them, in place of a plain space.                let had_space = out.ends_with(' ');                while out.ends_with(' ') { out.pop(); }                let is_url = ch == ':' && next == Some('/');                let is_time = ch == ':' && prev.map(|p| p.is_ascii_digit()).unwrap_or(false) && next.map(|n| n.is_ascii_digit()).unwrap_or(false);                if (had_space || prev.map(|p| p.is_alphanumeric() || p == '»' || p == '\u{202F}').unwrap_or(false)) && !is_url && !is_time && !out.ends_with(NNBSP) && !out.is_empty() { out.push(NNBSP) }                out.push(ch);                prev = Some(ch);            }            ' ' if st.single_letter_nbsp && i >= 1 && c[i - 1].is_alphabetic() && (i < 2 || !c[i - 2].is_alphanumeric()) => { out.push(NBSP); prev = Some(NBSP) }            _ => { out.push(ch); prev = Some(ch) }        }        i += 1;    }    out}/// Plain `text` set by the rules of `lang` (`"en"`, `"en-gb"`, `"fr"`, `"de"`, `"de-ch"`, `"es"`, `"it"`, `"nl"`, `"pl"`, `"cs"`, `"sv"`, `"ja"`...): curly quotes and apostrophes (’90s, rock ’n’ roll),/// `--` and ` - ` as an en dash, `---` as an em dash, `...` as …, `3 x 4` as 3 × 4, (c) (r) (tm) as © ® ™; French narrow no-break spaces before ; : ! ? % and inside « »; Czech, Slovak and Polish one-letter words kept with the next.pub fn typography_text(text: &str, lang: &str) -> String {    smarten(text, &style(lang), None)}/// The same for HTML: only the text between tags is changed (`&quot;` and `&#39;` too), quotes followed across inline tags; `<code>`, `<pre>`, `<kbd>`, `<samp>`, `<script>`, `<style>` and `<textarea>` are left as they are.pub fn typography_html(html: &str, lang: &str) -> String {    let st = style(lang);    let mut out = String::with_capacity(html.len() + html.len() / 10);    let mut rest = html;    let mut skip: Option<String> = None;    let mut prev: Option<char> = None;    while !rest.is_empty() {        if rest.starts_with('<') {            let end = rest.find('>').map(|i| i + 1).unwrap_or(rest.len());            let tag = &rest[..end];            let name: String = tag[1..].chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '/').collect::<String>().to_lowercase();            if let Some(s) = &skip { if name == format!("/{}", s) { skip = None } }            else if ["code", "pre", "kbd", "samp", "script", "style", "textarea"].contains(&name.as_str()) && !tag.ends_with("/>") { skip = Some(name.clone()) }            // A block-level tag starts afresh: the next quote opens.            if ["p", "/p", "br", "br/", "li", "/li", "div", "/div", "h1", "h2", "h3", "h4", "h5", "h6", "td", "/td", "th", "/th", "blockquote", "/blockquote"].contains(&name.as_str()) { prev = None }            out.push_str(tag);            rest = &rest[end..];            continue;        }        let end = rest.find('<').unwrap_or(rest.len());        let text = &rest[..end];        if skip.is_some() { out.push_str(text) } else {            let decoded = text.replace("&quot;", "\"").replace("&#39;", "'").replace("&#x27;", "'").replace("&apos;", "'");            let done = smarten(&decoded, &st, prev);            if let Some(last) = done.chars().last() { prev = Some(last) }            out.push_str(&done);        }        rest = &rest[end..];    }    out}/// No widows: a no-break space between the last two words of each paragraph (each line of plain text, or each `<p>`, heading, list item and caption of HTML) when the last word is short (under 10 letters), so it never sits alone on a line.pub fn typography_widows(text: &str) -> String {    let fix = |para: &str| -> String {        let t = para.trim_end();        let tail = &para[t.len()..];        match t.rfind(' ') {            Some(i) if t[i + 1..].chars().count() < 10 && t[..i].trim().contains(|c: char| c.is_alphanumeric()) && !t[..i].ends_with('>') => format!("{}{}{}{}", &t[..i], NBSP, &t[i + 1..], tail),            _ => para.to_string(),        }    };    if !text.contains('<') {        return text.split('\n').map(fix).collect::<Vec<_>>().join("\n");    }    let mut out = String::new();    let mut rest = text;    let closers = ["</p>", "</h1>", "</h2>", "</h3>", "</h4>", "</h5>", "</h6>", "</li>", "</figcaption>", "</blockquote>"];    loop {        let found = closers.iter().filter_map(|c| rest.to_lowercase().find(c).map(|i| (i, c.len()))).min();        match found {            Some((i, len)) => {                let before = &rest[..i];                // The last text after the last tag inside this element.                let cut = before.rfind('>').map(|j| j + 1).unwrap_or(0);                let start_text = before[..cut].to_string();                let last_text = &before[cut..];                if last_text.trim().contains(' ') { out.push_str(&start_text); out.push_str(&fix(last_text)); } else { out.push_str(before); }                out.push_str(&rest[i..i + len]);                rest = &rest[i + len..];            }            None => { out.push_str(rest); break }        }    }    out}
HTML 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 downloadsReadability readability.qwer · 1.0.0A web page's article without its menus, ads and footer: title, author, date, clean HTML, plain text or Markdown, for read-later apps, link previews and summaries.htmlscrapingtextmarkdownimport8 views · 7 downloadsSentences sentences.qwer · 1.0.0Text split into sentences as a reader would: "Dr.", "p.m.", initials, decimals, "e.g." and quoted questions handled, with abbreviations for seven languages; positions for highlighting, and previews of whole sentences that fit a length.textwritingseolanguages7 views · 9 downloadsTemplates templates.qwer · 1.0.0Jinja templates your users can edit for emails, invoices and pages: sandboxed, escaped for HTML, with filters such as money and truncate, and checked before they are saved.templatesemailhtmltext7 views · 8 downloadsText statistics text_stats.qwer · 1.0.0Words, sentences, syllables, reading and speaking time, Flesch reading ease and grade level of a text, for editors and writing tools.textwritingseoreadability8 views · 11 downloads
Thread
710Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.