feat: add figlet banners and hyphenation behind features
This commit is contained in:
parent
9e1cfed284
commit
36b868e094
13 changed files with 4282 additions and 29 deletions
|
|
@ -7,7 +7,21 @@ description = "Fixed-width plain text output for itsybitsy"
|
|||
license = "Apache-2.0"
|
||||
publish = false
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# FIGlet banners for headings that ask for one. Off by default: headings are
|
||||
# underlined, and the two vendored fonts are 40 KiB of the binary.
|
||||
figlet = []
|
||||
# Hyphenation when wrapping, English patterns only. Off by default: it needs an
|
||||
# embedded pattern dictionary for a setting most pages leave alone.
|
||||
hyphenation = ["dep:hyphenation", "hyphenation/embed_en-us"]
|
||||
# Every language the hyphenation crate carries, which costs a few megabytes of
|
||||
# binary; `hyphenation` alone covers the default `en-us`.
|
||||
hyphenation-all = ["hyphenation", "hyphenation/embed_all"]
|
||||
|
||||
[dependencies]
|
||||
hyphenation = { version = "0.8", default-features = false, optional = true }
|
||||
log = "0.4"
|
||||
itsybitsy-core = { path = "../core" }
|
||||
|
||||
[lints.rust]
|
||||
|
|
|
|||
10
text/fonts/README.md
Normal file
10
text/fonts/README.md
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# Vendored FIGlet fonts
|
||||
|
||||
Compiled into the binary by `include_str!` when the `figlet` feature is on, rather than loaded from disk: a renderer must not open files, which is what keeps every path check in one place.
|
||||
|
||||
| File | Font | Author |
|
||||
| --- | --- | --- |
|
||||
| `small.flf` | Small | Glenn Chappell, 4/93, based on Standard |
|
||||
| `standard.flf` | Standard | Glenn Chappell & Ian Chai, 3/93, with later contributors |
|
||||
|
||||
Both carry their own permission notice in the comment header the FIGfont format reserves for it, and both are distributed with FIGlet itself. The notices are preserved verbatim in the files; the parser skips over them, so they travel with the font rather than being stripped.
|
||||
1097
text/fonts/small.flf
Normal file
1097
text/fonts/small.flf
Normal file
File diff suppressed because it is too large
Load diff
2237
text/fonts/standard.flf
Normal file
2237
text/fonts/standard.flf
Normal file
File diff suppressed because it is too large
Load diff
431
text/src/figlet.rs
Normal file
431
text/src/figlet.rs
Normal file
|
|
@ -0,0 +1,431 @@
|
|||
//! FIGfont v2 rendering, for headings that ask for a banner.
|
||||
//!
|
||||
//! Two fonts are compiled in rather than loaded from disk, because a renderer
|
||||
//! must not open files: that invariant is what keeps every path check in one
|
||||
//! place. External fonts would have to be read at startup instead.
|
||||
//!
|
||||
//! Only horizontal layout is implemented. Vertical smushing joins stacked rows
|
||||
//! and nothing here stacks: a heading is one line of text.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// The default font: it fits 80 columns at heading lengths a page actually uses,
|
||||
/// where `standard` is half again as wide.
|
||||
pub const DEFAULT_FONT: &str = "small";
|
||||
|
||||
/// `Small by Glenn Chappell 4/93`.
|
||||
const SMALL: &str = include_str!("../fonts/small.flf");
|
||||
const STANDARD: &str = include_str!("../fonts/standard.flf");
|
||||
|
||||
/// Horizontal layout, from the font header's layout mask.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Layout {
|
||||
/// Characters placed side by side untouched.
|
||||
FullWidth,
|
||||
/// Moved together until they touch, never overlapping.
|
||||
Fitting,
|
||||
/// Overlapped where a rule permits. The mask selects which rules apply.
|
||||
Smushing(u16),
|
||||
}
|
||||
|
||||
pub struct Font {
|
||||
height: usize,
|
||||
hardblank: char,
|
||||
layout: Layout,
|
||||
/// Rows per character, padded to that character's own width.
|
||||
glyphs: HashMap<char, Vec<String>>,
|
||||
}
|
||||
|
||||
impl Font {
|
||||
/// A compiled-in font by name.
|
||||
pub fn named(name: &str) -> Option<Font> {
|
||||
let source = match name {
|
||||
"small" => SMALL,
|
||||
"standard" => STANDARD,
|
||||
_ => return None,
|
||||
};
|
||||
// A vendored font that does not parse is a bug, not bad input.
|
||||
Font::parse(source).ok()
|
||||
}
|
||||
|
||||
pub fn names() -> &'static [&'static str] {
|
||||
&["small", "standard"]
|
||||
}
|
||||
|
||||
fn parse(source: &str) -> Result<Font, &'static str> {
|
||||
let mut lines = source.lines();
|
||||
let header = lines.next().ok_or("empty font")?;
|
||||
let signature = header.strip_prefix("flf2a").ok_or("not a FIGfont v2 file")?;
|
||||
let hardblank = signature.chars().next().ok_or("no hardblank in header")?;
|
||||
|
||||
let fields: Vec<&str> = signature[hardblank.len_utf8()..].split_whitespace().collect();
|
||||
let height: usize = fields.first().ok_or("no height")?.parse().map_err(|_| "bad height")?;
|
||||
let old_layout: i32 =
|
||||
fields.get(3).ok_or("no layout")?.parse().map_err(|_| "bad layout")?;
|
||||
let comment_lines: usize =
|
||||
fields.get(4).ok_or("no comment count")?.parse().map_err(|_| "bad comment count")?;
|
||||
// `full_layout` supersedes `old_layout` when the font provides it.
|
||||
let full_layout = fields.get(6).and_then(|value| value.parse::<u16>().ok());
|
||||
let layout = resolve_layout(old_layout, full_layout);
|
||||
|
||||
for _ in 0..comment_lines {
|
||||
lines.next().ok_or("truncated comments")?;
|
||||
}
|
||||
|
||||
let mut glyphs = HashMap::new();
|
||||
// The required set is ASCII 32 to 126, in order and untagged.
|
||||
for code in 32u8..=126 {
|
||||
let rows = read_glyph(&mut lines, height).ok_or("truncated character")?;
|
||||
glyphs.insert(code as char, rows);
|
||||
}
|
||||
// Then the German characters FIGlet always includes, then code-tagged
|
||||
// ones. Both are optional here: a missing character falls back to a
|
||||
// space, which is better than refusing to render the heading.
|
||||
for _ in 0..7 {
|
||||
if read_glyph(&mut lines, height).is_none() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
while let Some(tag) = lines.next() {
|
||||
let Some(code) = parse_code_tag(tag) else { break };
|
||||
let Some(rows) = read_glyph(&mut lines, height) else { break };
|
||||
if let Some(ch) = char::from_u32(code) {
|
||||
glyphs.insert(ch, rows);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Font { height, hardblank, layout, glyphs })
|
||||
}
|
||||
|
||||
/// Render `text` as a banner, one string per row.
|
||||
///
|
||||
/// Rows are padded to a common width, as FIGlet emits them, so a caller that
|
||||
/// wants them ragged trims for itself.
|
||||
pub fn render(&self, text: &str) -> Vec<String> {
|
||||
let mut rows: Vec<Vec<char>> = vec![Vec::new(); self.height];
|
||||
for ch in text.chars() {
|
||||
let glyph = self.glyph(ch);
|
||||
self.append(&mut rows, &glyph);
|
||||
}
|
||||
|
||||
let width = rows.iter().map(Vec::len).max().unwrap_or(0);
|
||||
rows.into_iter()
|
||||
.map(|mut row| {
|
||||
row.resize(width, ' ');
|
||||
// A hardblank holds space apart during layout and is a space in
|
||||
// the result.
|
||||
row.into_iter().map(|c| if c == self.hardblank { ' ' } else { c }).collect()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The glyph for `ch`, or a blank of the font's own width.
|
||||
fn glyph(&self, ch: char) -> Vec<Vec<char>> {
|
||||
match self.glyphs.get(&ch) {
|
||||
Some(rows) => rows.iter().map(|row| row.chars().collect()).collect(),
|
||||
None => vec![vec![' ']; self.height],
|
||||
}
|
||||
}
|
||||
|
||||
/// Place a glyph to the right of what is already rendered.
|
||||
///
|
||||
/// The target column can be negative, which drops that many leading columns
|
||||
/// of the glyph. That is how the very first character loses the blank margin
|
||||
/// the font stores it with, and it is why FIGlet's own output for a single
|
||||
/// character is narrower than that character's stored width.
|
||||
fn append(&self, rows: &mut [Vec<char>], glyph: &[Vec<char>]) {
|
||||
let overlap = match self.layout {
|
||||
Layout::FullWidth => 0,
|
||||
Layout::Fitting | Layout::Smushing(_) => self.overlap(rows, glyph),
|
||||
} as isize;
|
||||
|
||||
for (row, incoming) in rows.iter_mut().zip(glyph) {
|
||||
let start = row.len() as isize - overlap;
|
||||
for (offset, ch) in incoming.iter().enumerate() {
|
||||
let index = start + offset as isize;
|
||||
if index < 0 {
|
||||
continue;
|
||||
}
|
||||
let index = index as usize;
|
||||
match row.get(index) {
|
||||
Some(existing) => row[index] = self.smush(*existing, *ch).unwrap_or(*ch),
|
||||
None => row.push(*ch),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How many columns the incoming glyph may move left.
|
||||
///
|
||||
/// Per row: the blank gap between the two parts, plus one more column when
|
||||
/// the characters that would then collide can merge. An empty row counts its
|
||||
/// gap as -1, which is what makes the first character trim rather than keep
|
||||
/// its margin. The smallest result across all rows wins, so one row's
|
||||
/// collision cannot let another row's characters run together.
|
||||
fn overlap(&self, rows: &[Vec<char>], glyph: &[Vec<char>]) -> usize {
|
||||
let mut limit = isize::MAX;
|
||||
for (row, incoming) in rows.iter().zip(glyph) {
|
||||
let trailing = trailing_blanks(row);
|
||||
let leading = leading_blanks(incoming);
|
||||
let gap = if row.is_empty() { -1 } else { trailing as isize };
|
||||
let mut amt = leading as isize + gap;
|
||||
|
||||
let left = row.len().checked_sub(trailing + 1).and_then(|index| row.get(index));
|
||||
let right = incoming.get(leading);
|
||||
let mergeable = match (left, right) {
|
||||
// Nothing on one side, so the columns can close.
|
||||
(None, _) | (_, None) => true,
|
||||
(Some(left), Some(right)) => self.smush(*left, *right).is_some(),
|
||||
};
|
||||
if mergeable {
|
||||
amt += 1;
|
||||
}
|
||||
limit = limit.min(amt);
|
||||
}
|
||||
// Never move further than the arriving glyph is wide.
|
||||
limit.clamp(0, glyph.iter().map(Vec::len).min().unwrap_or(0) as isize) as usize
|
||||
}
|
||||
|
||||
/// The character two overlapping columns combine into, or `None` if they
|
||||
/// cannot overlap at all.
|
||||
fn smush(&self, left: char, right: char) -> Option<char> {
|
||||
// A blank never obscures anything, under every layout mode.
|
||||
if left == ' ' {
|
||||
return Some(right);
|
||||
}
|
||||
if right == ' ' {
|
||||
return Some(left);
|
||||
}
|
||||
|
||||
let rules = match self.layout {
|
||||
// Fitting moves characters until they touch but never merges them.
|
||||
Layout::FullWidth | Layout::Fitting => return None,
|
||||
Layout::Smushing(rules) => rules,
|
||||
};
|
||||
|
||||
// Hardblanks only merge when the font asks for it (rule 6).
|
||||
if left == self.hardblank || right == self.hardblank {
|
||||
return (rules & 32 != 0 && left == right).then_some(self.hardblank);
|
||||
}
|
||||
|
||||
// With no rule selected, FIGlet smushes universally: the later character
|
||||
// wins.
|
||||
if rules & 63 == 0 {
|
||||
return Some(right);
|
||||
}
|
||||
|
||||
// Rule 1: two identical characters become one.
|
||||
if rules & 1 != 0 && left == right {
|
||||
return Some(left);
|
||||
}
|
||||
// Rule 2: an underscore yields to a border character.
|
||||
if rules & 2 != 0 {
|
||||
const BORDERS: &str = "|/\\[]{}()<>";
|
||||
if left == '_' && BORDERS.contains(right) {
|
||||
return Some(right);
|
||||
}
|
||||
if right == '_' && BORDERS.contains(left) {
|
||||
return Some(left);
|
||||
}
|
||||
}
|
||||
// Rule 3: the later class in this hierarchy wins.
|
||||
if rules & 4 != 0
|
||||
&& let (Some(a), Some(b)) = (hierarchy(left), hierarchy(right))
|
||||
&& a != b
|
||||
{
|
||||
return Some(if a > b { left } else { right });
|
||||
}
|
||||
// Rule 4: opposite brackets become a vertical bar.
|
||||
if rules & 8 != 0 {
|
||||
for (a, b) in [('[', ']'), ('{', '}'), ('(', ')')] {
|
||||
if (left == a && right == b) || (left == b && right == a) {
|
||||
return Some('|');
|
||||
}
|
||||
}
|
||||
}
|
||||
// Rule 5: these pairs become a wider glyph.
|
||||
if rules & 16 != 0 {
|
||||
match (left, right) {
|
||||
('/', '\\') => return Some('|'),
|
||||
('\\', '/') => return Some('Y'),
|
||||
('>', '<') => return Some('X'),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a character sits in rule 3's hierarchy, or `None` if it is not in it.
|
||||
fn hierarchy(ch: char) -> Option<u8> {
|
||||
match ch {
|
||||
'|' => Some(1),
|
||||
'/' | '\\' => Some(2),
|
||||
'[' | ']' => Some(3),
|
||||
'{' | '}' => Some(4),
|
||||
'(' | ')' => Some(5),
|
||||
'<' | '>' => Some(6),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `full_layout` when the font provides it, else the older single number.
|
||||
fn resolve_layout(old_layout: i32, full_layout: Option<u16>) -> Layout {
|
||||
if let Some(full) = full_layout {
|
||||
// Bit 8 selects smushing over fitting; bit 7 selects fitting over full
|
||||
// width. The low six bits are the smushing rules.
|
||||
if full & 128 != 0 {
|
||||
return Layout::Smushing(full & 63);
|
||||
}
|
||||
if full & 64 != 0 {
|
||||
return Layout::Fitting;
|
||||
}
|
||||
return Layout::FullWidth;
|
||||
}
|
||||
match old_layout {
|
||||
-1 => Layout::FullWidth,
|
||||
0 => Layout::Fitting,
|
||||
rules => Layout::Smushing(rules as u16 & 63),
|
||||
}
|
||||
}
|
||||
|
||||
/// One character's rows, taking the endmark off each.
|
||||
fn read_glyph<'a>(lines: &mut impl Iterator<Item = &'a str>, height: usize) -> Option<Vec<String>> {
|
||||
let mut rows = Vec::with_capacity(height);
|
||||
for _ in 0..height {
|
||||
let line = lines.next()?;
|
||||
rows.push(strip_endmark(line));
|
||||
}
|
||||
Some(rows)
|
||||
}
|
||||
|
||||
/// Drop the endmark: the last character of the line, doubled on the last row.
|
||||
fn strip_endmark(line: &str) -> String {
|
||||
let Some(endmark) = line.chars().last() else { return String::new() };
|
||||
let mut trimmed = line.trim_end_matches(endmark);
|
||||
// A row that is entirely endmarks is an empty row, not a missing one.
|
||||
if trimmed.is_empty() && !line.is_empty() {
|
||||
trimmed = "";
|
||||
}
|
||||
trimmed.to_string()
|
||||
}
|
||||
|
||||
/// The character code a code-tagged line names, in decimal, hex or octal.
|
||||
fn parse_code_tag(line: &str) -> Option<u32> {
|
||||
let token = line.split_whitespace().next()?;
|
||||
let (negative, digits) = match token.strip_prefix('-') {
|
||||
Some(rest) => (true, rest),
|
||||
None => (false, token),
|
||||
};
|
||||
let value = if let Some(hex) = digits.strip_prefix("0x").or_else(|| digits.strip_prefix("0X")) {
|
||||
u32::from_str_radix(hex, 16).ok()?
|
||||
} else if digits.len() > 1 && digits.starts_with('0') {
|
||||
u32::from_str_radix(&digits[1..], 8).ok()?
|
||||
} else {
|
||||
digits.parse().ok()?
|
||||
};
|
||||
// A negative code is a FIGlet convention for a deleted character.
|
||||
(!negative).then_some(value)
|
||||
}
|
||||
|
||||
fn leading_blanks(row: &[char]) -> usize {
|
||||
row.iter().take_while(|c| **c == ' ').count()
|
||||
}
|
||||
|
||||
fn trailing_blanks(row: &[char]) -> usize {
|
||||
row.iter().rev().take_while(|c| **c == ' ').count()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Compared against pyfiglet's own output for the same font and text, which
|
||||
/// is the oracle for this module.
|
||||
#[track_caller]
|
||||
fn assert_banner(font: &str, text: &str, expected: &[&str]) {
|
||||
let font = Font::named(font).expect("vendored font parses");
|
||||
let rows = font.render(text);
|
||||
assert_eq!(rows, expected, "\ngot:\n{}", rows.join("\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_vendored_fonts_parse() {
|
||||
for name in Font::names() {
|
||||
let font = Font::named(name).unwrap_or_else(|| panic!("{name} failed to parse"));
|
||||
assert!(font.height >= 5, "{name} height {}", font.height);
|
||||
assert!(font.glyphs.contains_key(&'A'), "{name} has no A");
|
||||
assert!(font.glyphs.contains_key(&' '), "{name} has no space");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_small_the_way_figlet_does() {
|
||||
assert_banner("small", "Hi", &[" _ _ _ ", "| || (_)", "| __ | |", "|_||_|_|", " "]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_standard_the_way_figlet_does() {
|
||||
assert_banner(
|
||||
"standard",
|
||||
"Hi",
|
||||
&[" _ _ _ ", "| | | (_)", "| |_| | |", "| _ | |", "|_| |_|_|", " "],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smushes_overlapping_characters() {
|
||||
// A and V interlock; without smushing they would sit side by side.
|
||||
assert_banner(
|
||||
"small",
|
||||
"AV",
|
||||
&[" ___ __", " /_\\ \\ / /", " / _ \\ V / ", "/_/ \\_\\_/ ", " "],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_a_whole_heading() {
|
||||
assert_banner(
|
||||
"small",
|
||||
"Heading One",
|
||||
&[
|
||||
" _ _ _ _ ___ ",
|
||||
"| || |___ __ _ __| (_)_ _ __ _ / _ \\ _ _ ___ ",
|
||||
"| __ / -_) _` / _` | | ' \\/ _` | | (_) | ' \\/ -_)",
|
||||
"|_||_\\___\\__,_\\__,_|_|_||_\\__, | \\___/|_||_\\___|",
|
||||
" |___/ ",
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_font_is_not_found_rather_than_a_panic() {
|
||||
assert!(Font::named("nonexistent").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unmapped_character_renders_as_a_blank() {
|
||||
let font = Font::named("small").unwrap();
|
||||
// Rendering must not fail on a character the font lacks.
|
||||
let rows = font.render("\u{1f600}");
|
||||
assert_eq!(rows.len(), font.height);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_row_is_padded_to_one_width() {
|
||||
let font = Font::named("standard").unwrap();
|
||||
let rows = font.render("Wide Heading");
|
||||
let width = rows[0].chars().count();
|
||||
assert!(rows.iter().all(|row| row.chars().count() == width), "{rows:#?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_tags_are_read_in_each_base() {
|
||||
assert_eq!(parse_code_tag("233 LATIN SMALL LETTER E WITH ACUTE"), Some(233));
|
||||
assert_eq!(parse_code_tag("0x0100"), Some(0x100));
|
||||
assert_eq!(parse_code_tag("0252"), Some(0o252));
|
||||
// A negative code marks a deleted character.
|
||||
assert_eq!(parse_code_tag("-1"), None);
|
||||
assert_eq!(parse_code_tag(""), None);
|
||||
}
|
||||
}
|
||||
70
text/src/hyphen.rs
Normal file
70
text/src/hyphen.rs
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
//! Hyphenation patterns, behind the `hyphenation` feature.
|
||||
//!
|
||||
//! Off by default: the pattern data is a sizeable blob, and a ragged right edge
|
||||
//! is the convention in plain text anyway. The build embeds every language the
|
||||
//! `hyphenation` crate ships, so `hyphen_lang` can name any of them.
|
||||
|
||||
use hyphenation::{Hyphenator as _, Language, Load, Standard};
|
||||
use itsybitsy_core::wrap::Hyphenator;
|
||||
|
||||
pub struct Patterns {
|
||||
dictionary: Standard,
|
||||
}
|
||||
|
||||
impl Patterns {
|
||||
/// Load the patterns for a language named as `en-us` or `en_US`.
|
||||
///
|
||||
/// Returns `None` for a language the build does not carry, which leaves the
|
||||
/// text unhyphenated rather than failing the page.
|
||||
pub fn for_language(name: &str) -> Option<Patterns> {
|
||||
let language = language_named(name)?;
|
||||
Standard::from_embedded(language).ok().map(|dictionary| Patterns { dictionary })
|
||||
}
|
||||
}
|
||||
|
||||
impl Hyphenator for Patterns {
|
||||
fn opportunities(&self, word: &str) -> Vec<usize> {
|
||||
self.dictionary.hyphenate(word).breaks
|
||||
}
|
||||
}
|
||||
|
||||
/// Match a configured language name against the crate's set, accepting either
|
||||
/// separator and any case, so `en_US` and `en-us` both work.
|
||||
fn language_named(name: &str) -> Option<Language> {
|
||||
let wanted = name.trim().replace('_', "-").to_ascii_lowercase();
|
||||
Language::try_from_code(&wanted)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn loads_english_patterns() {
|
||||
let patterns = Patterns::for_language("en-us").expect("en-us is embedded");
|
||||
// "hyphenation" has well-known break points; the exact set is the
|
||||
// dictionary's business, but there must be some.
|
||||
assert!(!patterns.opportunities("hyphenation").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_either_separator_and_any_case() {
|
||||
assert!(Patterns::for_language("en_US").is_some());
|
||||
assert!(Patterns::for_language("EN-US").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_language_is_not_found_rather_than_a_failure() {
|
||||
assert!(Patterns::for_language("not-a-language").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn breaks_are_byte_offsets_inside_the_word() {
|
||||
let patterns = Patterns::for_language("en-us").unwrap();
|
||||
let word = "hyphenation";
|
||||
for offset in patterns.opportunities(word) {
|
||||
assert!(offset > 0 && offset < word.len(), "{offset} outside {word}");
|
||||
assert!(word.is_char_boundary(offset));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
use itsybitsy_core::config::{HeadingStyle, PageSettings};
|
||||
use itsybitsy_core::ir::{Align, Block, Inline};
|
||||
use itsybitsy_core::wrap::{display_width, pad, wrap};
|
||||
use itsybitsy_core::wrap::{Hyphenator, display_width, pad, wrap_with};
|
||||
|
||||
use crate::inline::flatten;
|
||||
|
||||
|
|
@ -19,6 +19,9 @@ pub struct Layout<'a> {
|
|||
/// Columns available for content, after both margins.
|
||||
content_width: usize,
|
||||
lines: Vec<String>,
|
||||
/// Loaded once per page rather than per line. `None` when hyphenation is off,
|
||||
/// unsupported by this build, or the configured language is not carried.
|
||||
hyphenator: Option<Box<dyn Hyphenator>>,
|
||||
/// Set by a container that has just emitted its own separating blank, so its
|
||||
/// first child does not emit a second one.
|
||||
suppress_gap: bool,
|
||||
|
|
@ -31,7 +34,13 @@ impl<'a> Layout<'a> {
|
|||
let margins = settings.margin_left as usize + settings.margin_right as usize;
|
||||
// Never let margins consume the whole line; content needs somewhere to go.
|
||||
let content_width = total.saturating_sub(margins).max(8);
|
||||
Layout { settings, content_width, lines: Vec::new(), suppress_gap: false }
|
||||
Layout {
|
||||
settings,
|
||||
content_width,
|
||||
lines: Vec::new(),
|
||||
hyphenator: hyphenator(settings),
|
||||
suppress_gap: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn finish(mut self) -> Vec<u8> {
|
||||
|
|
@ -55,25 +64,9 @@ impl<'a> Layout<'a> {
|
|||
match block {
|
||||
Block::Heading { level, inline } => {
|
||||
self.gap(prefix, place);
|
||||
let text = flatten(inline);
|
||||
let index = (*level).clamp(1, 6) as usize - 1;
|
||||
match &self.settings.heading_styles[index] {
|
||||
HeadingStyle::Underline(rule) => {
|
||||
let wrapped = self.wrapped(&text, prefix, place);
|
||||
let width = wrapped.iter().map(|l| display_width(l)).max().unwrap_or(0);
|
||||
self.extend(wrapped, prefix, place);
|
||||
self.push(&rule.to_string().repeat(width), prefix, place);
|
||||
}
|
||||
HeadingStyle::Markers => {
|
||||
let marked = format!("{} {text}", "#".repeat(*level as usize));
|
||||
let wrapped = self.wrapped(&marked, prefix, place);
|
||||
self.extend(wrapped, prefix, place);
|
||||
}
|
||||
HeadingStyle::Plain => {
|
||||
let wrapped = self.wrapped(&text, prefix, place);
|
||||
self.extend(wrapped, prefix, place);
|
||||
}
|
||||
}
|
||||
let level = (*level).clamp(1, 6);
|
||||
let style = self.settings.heading_styles[level as usize - 1].clone();
|
||||
self.heading(&style, level, &flatten(inline), prefix, place);
|
||||
}
|
||||
Block::Paragraph(inline) => {
|
||||
self.gap(prefix, place);
|
||||
|
|
@ -135,6 +128,79 @@ impl<'a> Layout<'a> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Decorate one heading. A banner that cannot be produced retries with the
|
||||
/// level's built-in decoration rather than costing the heading.
|
||||
fn heading(
|
||||
&mut self,
|
||||
style: &HeadingStyle,
|
||||
level: u8,
|
||||
text: &str,
|
||||
prefix: &str,
|
||||
place: Placement,
|
||||
) {
|
||||
match style {
|
||||
HeadingStyle::Underline(rule) => {
|
||||
let wrapped = self.wrapped(text, prefix, place);
|
||||
let width = wrapped.iter().map(|line| display_width(line)).max().unwrap_or(0);
|
||||
self.extend(wrapped, prefix, place);
|
||||
self.push(&rule.to_string().repeat(width), prefix, place);
|
||||
}
|
||||
HeadingStyle::Markers => {
|
||||
let marked = format!("{} {text}", "#".repeat(level as usize));
|
||||
let wrapped = self.wrapped(&marked, prefix, place);
|
||||
self.extend(wrapped, prefix, place);
|
||||
}
|
||||
HeadingStyle::Plain => {
|
||||
let wrapped = self.wrapped(text, prefix, place);
|
||||
self.extend(wrapped, prefix, place);
|
||||
}
|
||||
HeadingStyle::Figlet(font) => match self.banner(text, font.as_deref(), prefix, place) {
|
||||
Some(rows) => self.extend(rows, prefix, place),
|
||||
None => {
|
||||
let fallback = HeadingStyle::fallback(level);
|
||||
self.heading(&fallback, level, text, prefix, place);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// A FIGlet banner, or `None` when this build has no FIGlet support, the font
|
||||
/// is unknown, or the result would be wider than the line.
|
||||
#[cfg(feature = "figlet")]
|
||||
fn banner(
|
||||
&self,
|
||||
text: &str,
|
||||
font: Option<&str>,
|
||||
prefix: &str,
|
||||
place: Placement,
|
||||
) -> Option<Vec<String>> {
|
||||
let name = font.unwrap_or(crate::figlet::DEFAULT_FONT);
|
||||
let Some(font) = crate::figlet::Font::named(name) else {
|
||||
// Named but unavailable is almost always a typo, so say so rather
|
||||
// than quietly underlining the heading.
|
||||
log::warn!(
|
||||
"unknown figlet font '{name}'; available: {}",
|
||||
crate::figlet::Font::names().join(", ")
|
||||
);
|
||||
return None;
|
||||
};
|
||||
let rows = font.render(text);
|
||||
let room = self.room(prefix, place);
|
||||
// A banner wider than the line is unreadable, so it is not used at all.
|
||||
rows.iter().all(|row| display_width(row.trim_end()) <= room).then_some(rows)
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "figlet"))]
|
||||
fn banner(
|
||||
&self,
|
||||
_text: &str,
|
||||
_font: Option<&str>,
|
||||
_prefix: &str,
|
||||
_place: Placement,
|
||||
) -> Option<Vec<String>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// One list item: the marker on the first line, continuations aligned under
|
||||
/// the text rather than under the marker.
|
||||
fn item(&mut self, blocks: &[Block], prefix: &str, marker: &str, place: Placement) {
|
||||
|
|
@ -231,7 +297,8 @@ impl<'a> Layout<'a> {
|
|||
let body =
|
||||
if numbered { format!("{:0digits$} | {line}", index + 1) } else { line.clone() };
|
||||
if self.settings.wrap_code_blocks {
|
||||
for piece in wrap(&body, self.room(prefix, place)) {
|
||||
// Code is never hyphenated: a hyphen would change what it says.
|
||||
for piece in wrap_with(&body, self.room(prefix, place), None) {
|
||||
self.push(&piece, prefix, place);
|
||||
}
|
||||
} else {
|
||||
|
|
@ -251,7 +318,7 @@ impl<'a> Layout<'a> {
|
|||
}
|
||||
|
||||
fn wrapped(&self, text: &str, prefix: &str, place: Placement) -> Vec<String> {
|
||||
wrap(text, self.room(prefix, place))
|
||||
wrap_with(text, self.room(prefix, place), self.hyphenator.as_deref())
|
||||
}
|
||||
|
||||
fn extend(&mut self, lines: Vec<String>, prefix: &str, place: Placement) {
|
||||
|
|
@ -306,6 +373,23 @@ impl<'a> Layout<'a> {
|
|||
}
|
||||
}
|
||||
|
||||
/// The hyphenator for these settings, if one applies and this build has one.
|
||||
#[cfg(feature = "hyphenation")]
|
||||
fn hyphenator(settings: &PageSettings) -> Option<Box<dyn Hyphenator>> {
|
||||
if !settings.hyphenate {
|
||||
return None;
|
||||
}
|
||||
crate::hyphen::Patterns::for_language(&settings.hyphen_lang)
|
||||
.map(|patterns| Box::new(patterns) as Box<dyn Hyphenator>)
|
||||
}
|
||||
|
||||
/// Without the feature the setting has no effect, which leaves a ragged right
|
||||
/// edge rather than failing the page.
|
||||
#[cfg(not(feature = "hyphenation"))]
|
||||
fn hyphenator(_settings: &PageSettings) -> Option<Box<dyn Hyphenator>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn cell_aligned(cell: &str, width: usize, align: Option<Align>) -> String {
|
||||
let slack = width.saturating_sub(display_width(cell));
|
||||
match align {
|
||||
|
|
|
|||
104
text/src/lib.rs
104
text/src/lib.rs
|
|
@ -8,6 +8,10 @@
|
|||
//!
|
||||
//! Unlike gemtext this wraps, because Nex and Gopher clients do not.
|
||||
|
||||
#[cfg(feature = "figlet")]
|
||||
mod figlet;
|
||||
#[cfg(feature = "hyphenation")]
|
||||
mod hyphen;
|
||||
mod inline;
|
||||
mod layout;
|
||||
|
||||
|
|
@ -286,3 +290,103 @@ mod separation_tests {
|
|||
assert_eq!(render("- a\n\n1. b\n"), "- a\n\n1. b\n");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod optional_feature_tests {
|
||||
use itsybitsy_core::config::{HeadingStyle, PageSettings};
|
||||
use itsybitsy_core::parse;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn render_with(settings: &PageSettings, width: u16, markdown: &str) -> String {
|
||||
let doc = parse::markdown(markdown);
|
||||
let ctx = RenderCtx { url: "/x", title: "T", settings, width: Some(width) };
|
||||
String::from_utf8(Text.render(&doc, &ctx).unwrap().body).unwrap()
|
||||
}
|
||||
|
||||
fn bare() -> PageSettings {
|
||||
PageSettings { margin_left: 0, margin_right: 0, ..Default::default() }
|
||||
}
|
||||
|
||||
fn figlet(font: Option<&str>) -> PageSettings {
|
||||
let mut settings = bare();
|
||||
settings.heading_styles[0] = HeadingStyle::Figlet(font.map(str::to_string));
|
||||
settings
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "figlet")]
|
||||
fn a_figlet_heading_is_bannered_when_it_fits() {
|
||||
let out = render_with(&figlet(None), 40, "# Hi\n");
|
||||
assert_eq!(out, " _ _ _\n| || (_)\n| __ | |\n|_||_|_|\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "figlet")]
|
||||
fn a_named_font_is_used() {
|
||||
let out = render_with(&figlet(Some("standard")), 40, "# Hi\n");
|
||||
assert!(out.starts_with(" _ _ _\n| | | (_)"), "{out:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "figlet")]
|
||||
fn a_banner_too_wide_for_the_line_falls_back_to_an_underline() {
|
||||
// A banner that does not fit is unreadable, so the heading is underlined
|
||||
// instead rather than wrapped into nonsense.
|
||||
let out = render_with(&figlet(None), 20, "# A Very Long Heading\n");
|
||||
assert_eq!(out, "A Very Long Heading\n===================\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "figlet")]
|
||||
fn an_unknown_font_falls_back_rather_than_failing_the_page() {
|
||||
let out = render_with(&figlet(Some("nonexistent")), 40, "# Hi\n");
|
||||
assert_eq!(out, "Hi\n==\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(not(feature = "figlet"))]
|
||||
fn without_the_feature_a_figlet_heading_is_underlined() {
|
||||
let out = render_with(&figlet(None), 40, "# Hi\n");
|
||||
assert_eq!(out, "Hi\n==\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "hyphenation")]
|
||||
fn hyphenation_breaks_a_word_that_would_not_fit() {
|
||||
let settings = PageSettings { hyphenate: true, ..bare() };
|
||||
let out = render_with(&settings, 8, "a wonderful time\n");
|
||||
assert!(out.lines().next().unwrap().ends_with('-'), "{out:?}");
|
||||
// The word survives the break; only a hyphen is added.
|
||||
assert!(out.replace("-\n", "").contains("wonderful"), "{out:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "hyphenation")]
|
||||
fn an_unknown_language_leaves_the_text_unhyphenated() {
|
||||
let settings = PageSettings { hyphenate: true, hyphen_lang: "xx-yy".into(), ..bare() };
|
||||
let plain = PageSettings { hyphenate: false, ..bare() };
|
||||
let source = "a wonderful time\n";
|
||||
assert_eq!(render_with(&settings, 8, source), render_with(&plain, 8, source));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hyphenation_off_leaves_a_ragged_edge() {
|
||||
let settings = PageSettings { hyphenate: false, ..bare() };
|
||||
assert!(!render_with(&settings, 8, "a wonderful time\n").contains('-'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_is_never_hyphenated_even_when_wrapping_is_asked_for() {
|
||||
// A hyphen inserted into code would change what it says.
|
||||
let settings = PageSettings {
|
||||
hyphenate: true,
|
||||
wrap_code_blocks: true,
|
||||
code_block_line_numbers: false,
|
||||
..bare()
|
||||
};
|
||||
let long = "identifier_that_is_quite_long_indeed";
|
||||
let out = render_with(&settings, 20, &format!("```\n{long}\n```\n"));
|
||||
assert!(!out.contains("- "), "{out:?}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue