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
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) → strPlain 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) → strThe same for HTML: only the text between tags is changed (" and ' too), quotes followed across inline tags; <code>, <pre>, <kbd>, <samp>, <script>, <style> and <textarea> are left as they are.
typography_widows(text: str) → strNo 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.qweOr with curl:
$ curl -O https://quillolang.org/plugins/files/typography.qwer$ curl -O https://quillolang.org/plugins/files/typography.qweThen, in your program, all of it or only what you call:
use typography.qweruse typography_text, typography_html from typography // Quillo 0.6: only theseIt uses these crates, which cargo fetches the first time the plugin is built:
Example
// 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
//! 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 (`"` and `'` 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(""", "\"").replace("'", "'").replace("'", "'").replace("'", "'"); 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 = ¶[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}Related
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 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.8 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.7 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.7 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.8 views · 11 downloadsThread 0 posts
On Typography: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.