use rustyfi_backend::{
break_into_lines, Context, FontKey, FontMetrics, HorzBox, HyphenLang, Length, PureHorzBox,
VertBox,
};
use rustyfi_lang::eval::Interp;
use rustyfi_lang::hyphenation::hyphenate_word;
use rustyfi_lang::primitives;
use rustyfi_lang::quoted::IText;
use rustyfi_lang::value::Env;
struct Mono;
impl FontMetrics for Mono {
fn advance(&self, _f: FontKey, _c: char, size: Length) -> Option<Length> {
Some(size * 0.5)
}
fn ascender(&self, _f: FontKey, size: Length) -> Length {
size * 0.75
}
fn descender(&self, _f: FontKey, size: Length) -> Length {
size * 0.25
}
}
const WORD: &str = "hyphenation";
fn boxes_for_word(ctx: &Context, word: &str) -> Vec<HorzBox> {
let mono = Mono;
let mut interp = Interp::new(&mono);
let elems = vec![IText::Text(word.to_string())];
let mut boxes = primitives::read_inline(&mut interp, ctx, &elems, &Env::root())
.expect("read_inline should succeed");
boxes.push(HorzBox::Pure(PureHorzBox::OuterFil));
boxes
}
fn boxes_for(ctx: &Context) -> Vec<HorzBox> {
boxes_for_word(ctx, WORD)
}
#[test]
fn no_dictionary_installed_yields_a_single_unsplit_inner_string() {
let mut ctx = Context::initial(Length::pt(400.0));
assert_eq!(
ctx.hyphen_dictionary,
Some(HyphenLang::EnglishUS),
"Context::initial must default to English, matching upstream"
);
ctx.hyphen_dictionary = None;
let boxes = boxes_for(&ctx);
assert_eq!(
boxes.len(),
2,
"expected InnerString + trailing fil only: {boxes:?}"
);
match &boxes[0] {
HorzBox::Pure(PureHorzBox::InnerString { text, .. }) => assert_eq!(text, WORD),
other => panic!("expected a single InnerString, got {other:?}"),
}
}
#[test]
fn dictionary_installed_splits_the_word_into_fragments_and_discretionaries() {
let mut ctx = Context::initial(Length::pt(400.0));
ctx.hyphen_dictionary = Some(HyphenLang::EnglishUS);
let boxes = boxes_for(&ctx);
let expected_breaks = hyphenate_word(HyphenLang::EnglishUS, WORD, 3, 2);
assert!(
!expected_breaks.is_empty(),
"expected {WORD:?} to actually hyphenate"
);
let mut fragments = Vec::new();
let mut disc_count = 0usize;
for hb in &boxes {
match hb {
HorzBox::Pure(PureHorzBox::InnerString { text, .. }) => fragments.push(text.clone()),
HorzBox::Pure(PureHorzBox::Discretionary {
penalty,
pre_break,
post_break,
no_break,
}) => {
disc_count += 1;
assert_eq!(*penalty, 100, "default Context::hyphen_badness");
assert!(post_break.is_empty(), "post_break must be empty (D2)");
assert!(no_break.is_empty(), "no_break must be empty (D2)");
assert_eq!(pre_break.len(), 1);
match &pre_break[0] {
PureHorzBox::InnerString { text, .. } => assert_eq!(text, "-"),
other => panic!("expected the hyphen glyph, got {other:?}"),
}
}
HorzBox::Pure(PureHorzBox::OuterFil) => {}
other => panic!("unexpected box kind: {other:?}"),
}
}
assert_eq!(
disc_count,
expected_breaks.len(),
"one Discretionary per accepted hyphenation break"
);
assert_eq!(
fragments.join(""),
WORD,
"fragments must rejoin to the original word (width-identity, D2/§6)"
);
}
#[test]
fn narrow_column_forces_a_mid_word_break_with_a_trailing_hyphen() {
let mut ctx = Context::initial(Length::pt(40.0));
ctx.hyphen_dictionary = Some(HyphenLang::EnglishUS);
let boxes = boxes_for(&ctx);
let lines = break_into_lines(&ctx, boxes);
assert!(
lines.len() >= 2,
"expected {WORD:?} to wrap across >=2 lines at a 40pt column: {lines:?}"
);
let mut saw_trailing_hyphen = false;
let mut rejoined = String::new();
for vb in &lines {
if let VertBox::Line { contents, .. } = vb {
for (_, b) in contents {
if let PureHorzBox::InnerString { text, .. } = b {
rejoined.push_str(text);
}
}
if let Some((_, PureHorzBox::InnerString { text, .. })) = contents.last() {
if text == "-" {
saw_trailing_hyphen = true;
}
}
}
}
assert!(
saw_trailing_hyphen,
"expected a wrapped line ending in the hyphen glyph: {lines:?}"
);
assert_eq!(
rejoined.replace('-', ""),
WORD,
"the word's letters must still all be present, in order, once the injected hyphen \
glyphs are stripped out"
);
}
#[test]
fn a_huge_hyphen_penalty_disables_the_break_even_though_the_line_overflows() {
let mut ctx = Context::initial(Length::pt(40.0));
ctx.hyphen_dictionary = Some(HyphenLang::EnglishUS);
ctx.hyphen_badness = 100_000;
let boxes = boxes_for(&ctx);
let lines = break_into_lines(&ctx, boxes);
assert_eq!(
lines.len(),
2,
"expected the hyphenated break (the only too-long edge on offer): {lines:?}"
);
let VertBox::Line { contents, .. } = &lines[0] else {
panic!("expected a Line, got {:?}", lines[0]);
};
let joined: String = contents
.iter()
.filter_map(|(_, b)| match b {
PureHorzBox::InnerString { text, .. } => Some(text.clone()),
_ => None,
})
.collect();
assert_eq!(
joined, "hyphen-",
"the taken break prints its hyphen on line 1"
);
}
#[test]
fn explicit_soft_hyphen_wins_over_dictionary_breaks_and_is_not_rendered() {
let mut ctx = Context::initial(Length::pt(400.0));
ctx.hyphen_dictionary = Some(HyphenLang::EnglishUS);
let word_with_shy = "hy\u{ad}phenation";
let boxes = boxes_for_word(&ctx, word_with_shy);
let mut fragments = Vec::new();
let mut disc_count = 0usize;
for hb in &boxes {
match hb {
HorzBox::Pure(PureHorzBox::InnerString { text, .. }) => {
assert!(
!text.contains('\u{ad}'),
"the soft hyphen marker itself must never appear in a rendered \
fragment: {text:?}"
);
fragments.push(text.clone());
}
HorzBox::Pure(PureHorzBox::Discretionary {
pre_break,
post_break,
no_break,
..
}) => {
disc_count += 1;
assert!(post_break.is_empty());
assert!(no_break.is_empty());
assert_eq!(pre_break.len(), 1);
match &pre_break[0] {
PureHorzBox::InnerString { text, .. } => assert_eq!(text, "-"),
other => panic!("expected the hyphen glyph, got {other:?}"),
}
}
HorzBox::Pure(PureHorzBox::OuterFil) => {}
other => panic!("unexpected box kind: {other:?}"),
}
}
assert_eq!(
disc_count, 1,
"exactly one break, at the authored soft hyphen"
);
assert_eq!(
fragments,
vec!["hy".to_string(), "phenation".to_string()],
"split exactly at the soft hyphen's position, not a dictionary-derived point"
);
}
#[test]
fn soft_hyphen_with_no_dictionary_installed_is_untouched_by_this_slice() {
let mut ctx = Context::initial(Length::pt(400.0));
ctx.hyphen_dictionary = None;
let word_with_shy = "hy\u{ad}phenation";
let boxes = boxes_for_word(&ctx, word_with_shy);
assert_eq!(
boxes.len(),
4,
"InnerString + Discretionary + InnerString + fil: {boxes:?}"
);
match &boxes[0] {
HorzBox::Pure(PureHorzBox::InnerString { text, .. }) => {
assert_eq!(
text, "hy\u{ad}",
"leading fragment keeps the raw soft hyphen char"
)
}
other => panic!("expected an InnerString, got {other:?}"),
}
match &boxes[1] {
HorzBox::Pure(PureHorzBox::Discretionary {
penalty,
pre_break,
post_break,
no_break,
}) => {
assert_eq!(
*penalty, 0,
"UAX#14 Allowed break, not a hyphenation-penalty one"
);
assert!(
pre_break.is_empty(),
"no injected hyphen glyph on the untouched path"
);
assert!(post_break.is_empty());
assert!(no_break.is_empty());
}
other => panic!("expected a Discretionary, got {other:?}"),
}
match &boxes[2] {
HorzBox::Pure(PureHorzBox::InnerString { text, .. }) => assert_eq!(text, "phenation"),
other => panic!("expected an InnerString, got {other:?}"),
}
}
#[test]
fn hyphen_glyph_uses_the_run_own_font_not_a_hardcoded_default() {
fn hyphen_font_for(font: FontKey) -> FontKey {
let mut ctx = Context::initial(Length::pt(400.0));
ctx.hyphen_dictionary = Some(HyphenLang::EnglishUS);
ctx.font = font;
let boxes = boxes_for(&ctx);
let mut fonts = Vec::new();
for hb in &boxes {
if let HorzBox::Pure(PureHorzBox::Discretionary { pre_break, .. }) = hb {
for b in pre_break {
if let PureHorzBox::InnerString { text, info, .. } = b {
assert_eq!(text, "-");
fonts.push(info.font);
}
}
}
}
assert!(
!fonts.is_empty(),
"expected at least one injected hyphen glyph"
);
assert!(
fonts.iter().all(|&f| f == font),
"every hyphen glyph must carry this run's font {font:?}, got {fonts:?}"
);
font
}
let bold_like = hyphen_font_for(FontKey(11));
let regular_like = hyphen_font_for(FontKey(22));
assert_ne!(
bold_like, regular_like,
"hyphen glyphs from two differently-fonted runs must carry different font keys \
(proves the font is read from the run, not a fixed constant)"
);
}
#[test]
fn min_fragment_override_from_the_live_context_is_respected() {
fn fragments_for(left_min: i64, right_min: i64) -> Vec<String> {
let mut ctx = Context::initial(Length::pt(400.0));
ctx.hyphen_dictionary = Some(HyphenLang::EnglishUS);
ctx.left_hyphen_min = left_min;
ctx.right_hyphen_min = right_min;
let boxes = boxes_for(&ctx);
boxes
.iter()
.filter_map(|hb| match hb {
HorzBox::Pure(PureHorzBox::InnerString { text, .. }) => Some(text.clone()),
_ => None,
})
.collect()
}
let default_fragments = fragments_for(3, 2);
assert_eq!(default_fragments, vec!["hyphen", "a", "tion"]);
let left_overridden = fragments_for(7, 2);
assert_eq!(
left_overridden,
vec!["hyphena", "tion"],
"a stricter left_hyphen_min must remove the break too close to the start"
);
let right_overridden = fragments_for(3, 5);
assert_eq!(
right_overridden,
vec!["hyphen", "ation"],
"a stricter right_hyphen_min must remove the break too close to the end"
);
assert_ne!(default_fragments, left_overridden);
assert_ne!(default_fragments, right_overridden);
}