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
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