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
|
|
@ -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}");
|
||||
}
|
||||
|
|
|
|||
102
core/src/wrap.rs
102
core/src/wrap.rs
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue