feat: add figlet banners and hyphenation behind features

This commit is contained in:
randogoth 2026-10-04 20:53:20 +03:00
parent 9e1cfed284
commit 36b868e094
13 changed files with 4282 additions and 29 deletions

431
text/src/figlet.rs Normal file
View 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
View 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));
}
}
}

View file

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

View file

@ -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:?}");
}
}