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

View file

@ -348,6 +348,12 @@ fn apply(keys: &PageKeys, settings: &mut PageSettings) -> Result<(), Error> {
if let Some(value) = keys.code_block_line_numbers {
settings.code_block_line_numbers = value;
}
if let Some(value) = keys.hyphenate {
settings.hyphenate = value;
}
if let Some(value) = &keys.hyphen_lang {
settings.hyphen_lang = value.clone();
}
if let Some(value) = keys.wrap_code_blocks {
settings.wrap_code_blocks = value;
}
@ -369,8 +375,8 @@ fn apply(keys: &PageKeys, settings: &mut PageSettings) -> Result<(), Error> {
settings.heading_styles[index] =
HeadingStyle::parse(value, default_rule).ok_or_else(|| {
Error::config(format!(
"h{}_style = '{value}' is not one of underline, underline:<char>, markers \
or plain",
"h{}_style = '{value}' is not one of underline, underline:<char>, figlet, \
figlet:<font>, markers or plain",
index + 1
))
})?;
@ -466,6 +472,11 @@ pub struct PageKeys {
/// Columns a nested list is indented by.
pub list_indent: Option<u16>,
pub code_block_line_numbers: Option<bool>,
/// Break long words at hyphenation points when wrapping. Needs a build with
/// hyphenation support; without one the setting has no effect.
pub hyphenate: Option<bool>,
/// Language whose hyphenation patterns to use, as `en-us`.
pub hyphen_lang: Option<String>,
/// Wrap over-long code lines. Off by default, because wrapping code changes
/// what it says; on, it is better than losing the end of the line.
pub wrap_code_blocks: Option<bool>,
@ -480,9 +491,20 @@ pub enum HeadingStyle {
Markers,
/// The text alone.
Plain,
/// A FIGlet banner in the named font, or the renderer's default font when
/// unnamed. Falls back to an underline when the banner would not fit, when
/// the font is unknown, or when the build has no FIGlet support, because a
/// banner that cannot be produced should not cost the heading.
Figlet(Option<String>),
}
impl HeadingStyle {
/// The built-in decoration for a heading level, used when a requested banner
/// cannot be produced.
pub fn fallback(level: u8) -> HeadingStyle {
DEFAULT_HEADING_STYLES[(level.clamp(1, 6) - 1) as usize].clone()
}
/// Parse a configured value: `underline`, `underline:<char>`, `markers` or
/// `plain`. `default_rule` is the character bare `underline` uses.
fn parse(value: &str, default_rule: char) -> Option<Self> {
@ -494,6 +516,10 @@ impl HeadingStyle {
}
"markers" => Some(HeadingStyle::Markers),
"plain" => Some(HeadingStyle::Plain),
"figlet" => {
let font = argument.trim();
Some(HeadingStyle::Figlet((!font.is_empty()).then(|| font.to_string())))
}
_ => None,
}
}
@ -528,6 +554,8 @@ pub struct PageSettings {
pub blockquote_bars: bool,
pub list_indent: u16,
pub code_block_line_numbers: bool,
pub hyphenate: bool,
pub hyphen_lang: String,
pub wrap_code_blocks: bool,
}
@ -547,6 +575,10 @@ impl Default for PageSettings {
blockquote_bars: true,
list_indent: 2,
code_block_line_numbers: true,
// Off by default: it needs a pattern dictionary, and ragged right
// edges are the convention in plain text anyway.
hyphenate: false,
hyphen_lang: "en-us".to_string(),
wrap_code_blocks: false,
}
}
@ -862,6 +894,15 @@ mod dir_config_tests {
assert_eq!(other.margin_left, 4);
}
#[test]
fn a_figlet_style_may_name_a_font() {
let config =
load("[defaults]\nh1_style = \"figlet\"\nh2_style = \"figlet:standard\"\n").unwrap();
let settings = config.settings_for("x.md").unwrap();
assert_eq!(settings.heading_styles[0], HeadingStyle::Figlet(None));
assert_eq!(settings.heading_styles[1], HeadingStyle::Figlet(Some("standard".into())));
}
#[test]
fn heading_styles_are_configurable_per_level() {
let config =
@ -884,7 +925,7 @@ mod dir_config_tests {
#[test]
fn an_unknown_heading_style_is_an_error_naming_the_alternatives() {
let config = load("[defaults]\nh1_style = \"figlet\"\n").unwrap();
let config = load("[defaults]\nh1_style = \"sparkles\"\n").unwrap();
let err = config.settings_for("x.md").unwrap_err();
assert!(err.to_string().contains("underline, underline:<char>"), "{err}");
}

View file

@ -21,12 +21,27 @@ pub fn pad(text: &str, width: usize) -> String {
out
}
/// Somewhere a word may be broken, with a hyphen added.
///
/// A hook rather than an implementation, so the pattern dictionaries a real
/// hyphenator needs stay in the crate that opts into them and out of core.
pub trait Hyphenator: Send + Sync {
/// Byte offsets inside `word` where a break is allowed, ascending. An offset
/// at or past the end of the word is ignored.
fn opportunities(&self, word: &str) -> Vec<usize>;
}
/// Break `text` into lines no wider than `width` columns.
pub fn wrap(text: &str, width: usize) -> Vec<String> {
wrap_with(text, width, None)
}
/// Break `text` into lines, optionally hyphenating words that do not fit.
///
/// Greedy: each line takes as many words as fit. A word wider than the whole
/// width is split rather than left to overflow, since the formats this serves
/// have no horizontal scroll. A width of zero means do not wrap.
pub fn wrap(text: &str, width: usize) -> Vec<String> {
pub fn wrap_with(text: &str, width: usize, hyphenator: Option<&dyn Hyphenator>) -> Vec<String> {
let text = text.trim();
if width == 0 {
return if text.is_empty() { Vec::new() } else { vec![text.to_string()] };
@ -37,7 +52,15 @@ pub fn wrap(text: &str, width: usize) -> Vec<String> {
let mut line_width = 0;
for word in text.split_whitespace() {
for piece in split_to_fit(word, width) {
// A word that does not fit the rest of the line may be split at a
// hyphenation point rather than pushed whole onto the next one.
let pieces = match hyphenator {
Some(hyphenator) if line_width > 0 => {
hyphenate_to_fit(word, width, width - line_width.min(width), hyphenator)
}
_ => split_to_fit(word, width),
};
for piece in pieces {
let piece_width = display_width(&piece);
// The +1 is the space that would join it to what is already there.
if line_width > 0 && line_width + 1 + piece_width > width {
@ -58,6 +81,35 @@ pub fn wrap(text: &str, width: usize) -> Vec<String> {
lines
}
/// Split a word at its last hyphenation point that fits `room`, adding a hyphen.
///
/// Returns the word untouched when it already fits, when no point helps, or when
/// the remainder would not fit a whole line either.
fn hyphenate_to_fit(
word: &str,
width: usize,
room: usize,
hyphenator: &dyn Hyphenator,
) -> Vec<String> {
// Strictly less, because a space has to join it to what is already there.
if display_width(word) < room {
return vec![word.to_string()];
}
for offset in hyphenator.opportunities(word).into_iter().rev() {
if offset == 0 || offset >= word.len() || !word.is_char_boundary(offset) {
continue;
}
let (head, tail) = word.split_at(offset);
// The head needs room for the joining space and the hyphen it gains.
if display_width(head) + 2 <= room && display_width(tail) <= width {
let mut pieces = vec![format!("{head}-")];
pieces.extend(split_to_fit(tail, width));
return pieces;
}
}
split_to_fit(word, width)
}
/// Split one word into chunks that each fit `width`, on column boundaries.
///
/// A word that already fits comes back whole, which is the common case.
@ -149,3 +201,49 @@ mod tests {
}
}
}
#[cfg(test)]
mod hyphenation_tests {
use super::*;
/// Breaks before every `-` marker position given at construction, so the
/// hook can be tested without a pattern dictionary.
struct At(Vec<usize>);
impl Hyphenator for At {
fn opportunities(&self, _word: &str) -> Vec<usize> {
self.0.clone()
}
}
#[test]
fn a_word_is_broken_at_a_point_that_fits() {
// "wonderful" breaks after "won", leaving "derful" for the next line.
let at = At(vec![3, 6]);
assert_eq!(wrap_with("a wonderful day", 10, Some(&at)), vec!["a wonder-", "ful day"]);
}
#[test]
fn a_word_that_already_fits_is_left_whole() {
let at = At(vec![3]);
assert_eq!(wrap_with("a word here", 20, Some(&at)), vec!["a word here"]);
}
#[test]
fn no_usable_point_leaves_the_word_for_the_next_line() {
// The only point is too late to help, so the word moves down intact.
let at = At(vec![8]);
assert_eq!(wrap_with("a wonderful", 6, Some(&at)), vec!["a", "wonder", "ful"]);
}
#[test]
fn an_offset_outside_the_word_is_ignored() {
let at = At(vec![0, 99]);
assert_eq!(wrap_with("a wonderful day", 12, Some(&at)), vec!["a wonderful", "day"]);
}
#[test]
fn without_a_hyphenator_the_result_is_unchanged() {
assert_eq!(wrap_with("a wonderful day", 10, None), wrap("a wonderful day", 10));
}
}