use crate::types::{ItemType, TextItem};
use unicode_normalization::UnicodeNormalization;
pub(crate) fn is_explicit_page_number_expression(text: &str) -> bool {
let trimmed = text.trim();
if trimmed.is_empty() {
return false;
}
let is_number = |value: &str| {
!value.is_empty() && value.chars().all(|character| character.is_ascii_digit())
};
if trimmed.len() <= 4 && is_number(trimmed) {
return true;
}
if trimmed.len() >= 3 && trimmed.starts_with('-') && trimmed.ends_with('-') {
let inner = trimmed[1..trimmed.len() - 1].trim();
if is_number(inner) {
return true;
}
}
let lowercase = trimmed.to_ascii_lowercase();
if let Some(rest) = lowercase.strip_prefix("page") {
let words: Vec<&str> = rest.split_whitespace().collect();
if words.len() >= 3 && is_number(words[0]) && words[1] == "of" && is_number(words[2]) {
return true;
}
if words.len() >= 2 && words[0] == "of" && is_number(words[1]) {
return true;
}
return match words.as_slice() {
[] | ["of"] => true,
[number] => is_number(number),
["of", total] => is_number(total),
[number, "of", total] => is_number(number) && is_number(total),
_ => false,
};
}
let words: Vec<&str> = lowercase.split_whitespace().collect();
match words.as_slice() {
[number, "of", total] => is_number(number) && is_number(total),
_ => false,
}
}
pub(crate) fn is_page_number_line(text: &str) -> bool {
if is_explicit_page_number_expression(text) {
return true;
}
let lowercase = text.trim().to_ascii_lowercase();
lowercase.strip_prefix("page").is_some_and(|rest| {
let mut characters = rest.trim_start().chars().peekable();
let mut has_page_number = false;
while characters
.peek()
.is_some_and(|character| character.is_ascii_digit())
{
has_page_number = true;
characters.next();
}
has_page_number && characters.next().is_none_or(char::is_whitespace)
})
}
pub(crate) fn is_cjk_char(c: char) -> bool {
matches!(c,
'\u{1100}'..='\u{11FF}' | '\u{3000}'..='\u{303F}' | '\u{3040}'..='\u{309F}' | '\u{30A0}'..='\u{30FF}' | '\u{3130}'..='\u{318F}' | '\u{4E00}'..='\u{9FFF}' | '\u{AC00}'..='\u{D7AF}' | '\u{F900}'..='\u{FAFF}' | '\u{FF00}'..='\u{FFEF}' )
}
pub(crate) fn is_rtl_char(c: char) -> bool {
matches!(c,
'\u{0590}'..='\u{05FF}' | '\u{0600}'..='\u{06FF}' | '\u{0700}'..='\u{074F}' | '\u{0750}'..='\u{077F}' | '\u{0780}'..='\u{07BF}' | '\u{07C0}'..='\u{07FF}' | '\u{0800}'..='\u{083F}' | '\u{0840}'..='\u{085F}' | '\u{08A0}'..='\u{08FF}' | '\u{FB1D}'..='\u{FB4F}' | '\u{FB50}'..='\u{FDFF}' | '\u{FE70}'..='\u{FEFF}' )
}
pub(crate) fn is_arabic_presentation_form(c: char) -> bool {
matches!(c, '\u{FB50}'..='\u{FDFF}' | '\u{FE70}'..='\u{FEFE}')
}
pub(crate) fn is_rtl_text<I, S>(texts: I) -> bool
where
I: Iterator<Item = S>,
S: AsRef<str>,
{
let (mut rtl, mut ltr) = (0u32, 0u32);
for t in texts {
for c in t.as_ref().chars() {
if !c.is_alphabetic() || unicode_normalization::char::is_combining_mark(c) {
continue;
}
if is_rtl_char(c) {
rtl += 1;
} else if !is_cjk_char(c) {
ltr += 1;
}
}
}
rtl > 0 && rtl > ltr
}
fn is_combining_mark(c: char) -> bool {
unicode_normalization::char::is_combining_mark(c)
|| unicode_normalization::char::canonical_combining_class(c) != 0
}
pub(crate) fn sort_rtl_cell_items<T>(
items: &mut [T],
x_of: impl Fn(&T) -> f32,
y_of: impl Fn(&T) -> f32,
text_of: impl Fn(&T) -> &str,
) {
items.sort_by(|a, b| y_of(b).total_cmp(&y_of(a)));
let mut start = 0;
while start < items.len() {
let y0 = y_of(&items[start]);
let mut end = start + 1;
while end < items.len() && (y_of(&items[end]) - y0).abs() <= 2.0 {
end += 1;
}
items[start..end].sort_by(|a, b| x_of(b).total_cmp(&x_of(a)));
restore_embedded_ltr_runs(&mut items[start..end], &text_of);
start = end;
}
}
pub(crate) fn sort_line_items(items: &mut [TextItem]) {
let rtl = is_rtl_text(items.iter().map(|i| &i.text));
let mut text_runs = items
.iter()
.filter(|i| matches!(i.item_type, ItemType::Text));
let upside_down =
!rtl && text_runs.clone().next().is_some() && text_runs.all(|i| i.is_upside_down());
let key = |item: &TextItem| {
if upside_down {
-(item.x + item.width)
} else {
item.x
}
};
if rtl {
items.sort_by(|a, b| key(b).total_cmp(&key(a)));
restore_embedded_ltr_runs(items, |i| i.text.as_str());
} else {
items.sort_by(|a, b| key(a).total_cmp(&key(b)));
}
}
pub(crate) fn restore_embedded_ltr_runs<T>(items: &mut [T], text_of: impl Fn(&T) -> &str) {
let is_numeric_sep = |c: char| is_arabic_numeric_separator(c) || matches!(c, '.' | ',');
let is_arabic_number_item = |t: &T| {
let mut has_digit = false;
for c in text_of(t).chars() {
if is_arabic_indic_digit(c) {
has_digit = true;
} else if !is_numeric_sep(c) && !c.is_whitespace() {
return false;
}
}
has_digit
};
let is_number_delim_item = |t: &T| {
let mut has_sep = false;
for c in text_of(t).chars() {
if is_numeric_sep(c) {
has_sep = true;
} else if !c.is_whitespace() {
return false;
}
}
has_sep
};
let mut i = 0;
while i < items.len() {
if !is_arabic_number_item(&items[i]) {
i += 1;
continue;
}
let mut j = i + 1;
while j < items.len() {
if is_arabic_number_item(&items[j]) {
j += 1;
} else if is_number_delim_item(&items[j])
&& j + 1 < items.len()
&& is_arabic_number_item(&items[j + 1])
{
j += 2;
} else {
break;
}
}
if j - i >= 2 {
items[i..j].reverse();
}
i = j;
}
let has_rtl = |t: &T| text_of(t).chars().any(is_rtl_char);
let has_bracket = |t: &T| text_of(t).chars().any(|c| "()[]{}<>".contains(c));
let mut i = 0;
while i < items.len() {
if has_rtl(&items[i]) {
i += 1;
continue;
}
let mut j = i + 1;
while j < items.len() && !has_rtl(&items[j]) {
j += 1;
}
let alnum = |t: &T| text_of(t).chars().any(char::is_alphanumeric);
let first = items[i..j].iter().position(&alnum).map(|p| i + p);
let last = items[i..j].iter().rposition(&alnum).map(|p| i + p);
if let (Some(mut first), Some(mut last)) = (first, last) {
while first > i && has_bracket(&items[first - 1]) {
first -= 1;
}
while last + 1 < j && has_bracket(&items[last + 1]) {
last += 1;
}
if last > first {
items[first..=last].reverse();
}
}
i = j;
}
}
pub fn is_bold_font(font_name: &str) -> bool {
let lower = font_name.to_lowercase();
lower.contains("bold")
|| lower.contains("-bd")
|| lower.contains("_bd")
|| lower.contains("black")
|| lower.contains("heavy")
|| lower.contains("demibold")
|| lower.contains("semibold")
|| lower.contains("demi-bold")
|| lower.contains("semi-bold")
|| lower.contains("extrabold")
|| lower.contains("ultrabold")
|| lower.contains("medium") && !lower.contains("mediumitalic") || lower.contains("-medi") && !lower.contains("mediumital")
}
pub fn font_weight_from_name(font_name: &str) -> Option<u16> {
let name = font_name
.split_once('+')
.map_or(font_name, |(_, rest)| rest);
let tokens: Vec<Vec<&str>> = name
.split(['-', '_', ',', ' ', '.'])
.filter(|token| !token.is_empty())
.map(camel_pieces)
.collect();
let flat: Vec<(usize, usize)> = tokens
.iter()
.enumerate()
.flat_map(|(t, pieces)| (0..pieces.len()).map(move |i| (t, i)))
.collect();
let lower = |&(t, i): &(usize, usize)| tokens[t][i].to_ascii_lowercase();
let mut weight = None;
let mut k = 0;
while k < flat.len() {
let (t, i) = flat[k];
let piece = lower(&flat[k]);
let next = flat.get(k + 1).map(lower).unwrap_or_default();
let extra = matches!(piece.as_str(), "extra" | "ultra" | "ult" | "x");
let semi = matches!(piece.as_str(), "semi" | "demi" | "sm" | "dm");
let qualified = match next.as_str() {
"light" | "lt" if extra => Some(200),
"bold" | "bd" if extra => Some(800),
"black" | "blk" if extra => Some(900),
"bold" | "bd" if semi => Some(600),
_ => None,
};
let width = if qualified.is_some() { 2 } else { 1 };
let whole = qualified.or_else(|| weight_word(&piece)).or_else(|| {
(t > 0 || i > 0)
.then(|| weight_abbreviation(tokens[t][i]))
.flatten()
});
let found = if whole.is_some() {
let (last_t, last_i) = flat[k + width - 1];
let rest = &tokens[last_t][last_i + 1..];
let in_family = t == 0 && (i == 0 || !rest.iter().all(|rest| is_style_or_mark(rest)));
(!in_family).then_some(whole).flatten()
} else {
let flat_case = tokens[t][i].chars().all(|c| !c.is_lowercase())
|| tokens[t][i].chars().all(|c| !c.is_uppercase());
flat_case
.then(|| weight_word_substring(&piece, t == 0))
.flatten()
};
if found.is_some() {
weight = found;
}
k += width;
}
weight
}
fn camel_pieces(token: &str) -> Vec<&str> {
let mut pieces = Vec::new();
let mut start = 0;
let mut previous_lower = false;
for (index, c) in token.char_indices() {
if c.is_uppercase() && previous_lower && index > start {
pieces.push(&token[start..index]);
start = index;
}
previous_lower = c.is_lowercase();
}
if start < token.len() {
pieces.push(&token[start..]);
}
pieces
}
fn is_style_or_mark(piece: &str) -> bool {
if piece
.chars()
.all(|c| c.is_uppercase() || c.is_ascii_digit())
{
return true;
}
matches!(
piece.to_ascii_lowercase().as_str(),
"italic"
| "oblique"
| "it"
| "ital"
| "obl"
| "condensed"
| "cond"
| "cn"
| "cd"
| "narrow"
| "compressed"
| "extended"
| "ext"
| "expanded"
| "std"
| "pro"
)
}
fn weight_word(piece: &str) -> Option<u16> {
Some(match piece {
"thin" | "hairline" => 100,
"extralight" | "ultralight" => 200,
"light" => 300,
"regular" | "book" | "roman" | "normal" => 400,
"medium" => 500,
"semibold" | "demibold" | "demi" => 600,
"bold" => 700,
"extrabold" | "ultrabold" => 800,
"black" | "heavy" | "ultra" | "extrablack" => 900,
_ => return None,
})
}
fn weight_abbreviation(piece: &str) -> Option<u16> {
if let Some(digit) = piece.strip_prefix(['W', 'w']) {
if let Some(n) = digit.parse::<u16>().ok().filter(|n| (1..=9).contains(n)) {
return Some(n * 100);
}
}
if !piece.chars().any(char::is_lowercase) || !piece.chars().any(char::is_uppercase) {
return None;
}
Some(match piece.to_ascii_lowercase().as_str() {
"th" => 100,
"ultlt" | "xlt" => 200,
"lt" => 300,
"rg" | "reg" | "bk" => 400,
"md" | "med" | "medi" => 500,
"sb" | "sbd" | "smbd" | "dm" | "dmbd" => 600,
"bd" => 700,
"xbd" => 800,
"ult" | "blk" | "hv" | "xblk" => 900,
_ => return None,
})
}
fn weight_word_substring(piece: &str, in_family: bool) -> Option<u16> {
const COMPOUND: [(&str, u16); 6] = [
("extralight", 200),
("ultralight", 200),
("extrabold", 800),
("ultrabold", 800),
("semibold", 600),
("demibold", 600),
];
let piece = if in_family {
["psmt", "mt", "ps"]
.iter()
.find_map(|mark| piece.strip_suffix(mark))
.unwrap_or(piece)
} else {
piece
};
if let Some((_, weight)) = COMPOUND
.iter()
.find(|(word, _)| in_family && piece.ends_with(word) || !in_family && piece.contains(word))
{
return Some(*weight);
}
const WORDS: [(&str, u16); 13] = [
("black", 900),
("heavy", 900),
("ultra", 900),
("bold", 700),
("demi", 600),
("medium", 500),
("light", 300),
("regular", 400),
("roman", 400),
("book", 400),
("normal", 400),
("thin", 100),
("hairline", 100),
];
if in_family {
return WORDS
.iter()
.find(|(word, _)| piece.ends_with(word))
.map(|(_, weight)| *weight);
}
WORDS
.iter()
.filter_map(|(word, weight)| piece.rfind(word).map(|at| (at, *weight)))
.max_by_key(|(at, _)| *at)
.map(|(_, weight)| weight)
}
pub fn is_italic_font(font_name: &str) -> bool {
let lower = font_name.to_lowercase();
lower.contains("italic")
|| lower.contains("oblique")
|| lower.contains("-it")
|| lower.contains("_it")
|| lower.contains("slant")
|| lower.contains("inclined")
|| lower.contains("kursiv") }
pub(crate) fn expand_ligatures(text: &str) -> String {
let text = if text
.bytes()
.any(|b| b < 0x20 && b != b'\n' && b != b'\r' && b != b'\t')
{
text.chars()
.filter(|&c| c >= ' ' || c == '\n' || c == '\r' || c == '\t')
.collect::<String>()
} else {
text.to_string()
};
let had_presentation_forms = text.chars().any(is_arabic_presentation_form);
let text = if had_presentation_forms {
text.nfkc().collect::<String>()
} else {
text
};
let mut result = String::with_capacity(text.len());
for ch in text.chars() {
match ch {
'\u{FB00}' => result.push_str("ff"),
'\u{FB01}' => result.push_str("fi"),
'\u{FB02}' => result.push_str("fl"),
'\u{FB03}' => result.push_str("ffi"),
'\u{FB04}' => result.push_str("ffl"),
'\u{FB05}' | '\u{FB06}' => result.push_str("st"),
'\u{00AD}' => {} '\u{200B}' => {} '\u{FEFF}' => {} '\u{200C}' | '\u{200D}' => {} '\u{2060}' => {} '\u{2000}'..='\u{200A}' => result.push(' '), _ => result.push(ch),
}
}
if had_presentation_forms {
result = reverse_visual_arabic(&result);
}
result
}
fn is_arabic_indic_digit(c: char) -> bool {
matches!(c, '\u{0660}'..='\u{0669}' | '\u{06F0}'..='\u{06F9}')
}
fn is_arabic_numeric_separator(c: char) -> bool {
matches!(c, '\u{066B}' | '\u{066C}')
}
fn is_forward_alnum(c: char) -> bool {
c.is_ascii_alphanumeric() || is_arabic_indic_digit(c)
}
fn reverse_visual_arabic(text: &str) -> String {
let has_ltr = text.chars().any(is_forward_alnum);
if !has_ltr {
return reverse_keeping_marks(text);
}
let chars: Vec<char> = text.chars().collect();
let mut runs: Vec<(bool, String)> = Vec::new();
let is_ltr_punct = |chars: &[char], i: usize| {
(chars[i].is_ascii_punctuation() || is_arabic_numeric_separator(chars[i]))
&& is_adjacent_to_alnum(chars, i)
};
let mut i = 0;
while i < chars.len() {
let is_ltr = is_forward_alnum(chars[i]) || is_ltr_punct(&chars, i);
let mut run = String::new();
while i < chars.len() {
let c = chars[i];
let c_is_ltr = is_forward_alnum(c) || is_ltr_punct(&chars, i);
if c_is_ltr != is_ltr {
break;
}
run.push(c);
i += 1;
}
runs.push((is_ltr, run));
}
runs.reverse();
let mut result = String::with_capacity(text.len());
for (is_ltr, content) in &runs {
if *is_ltr {
result.push_str(content);
} else {
result.push_str(&reverse_keeping_marks(content));
}
}
result
}
fn reverse_keeping_marks(text: &str) -> String {
let chars: Vec<char> = text.chars().collect();
let mut out = String::with_capacity(text.len());
let mut end = chars.len();
let mut i = chars.len();
while i > 0 {
i -= 1;
if i == 0 || !is_combining_mark(chars[i]) {
out.extend(chars[i..end].iter().map(|&c| mirror_bracket(c)));
end = i;
}
}
out
}
fn mirror_bracket(c: char) -> char {
match c {
'(' => ')',
')' => '(',
'[' => ']',
']' => '[',
'{' => '}',
'}' => '{',
'<' => '>',
'>' => '<',
_ => c,
}
}
fn is_adjacent_to_alnum(chars: &[char], idx: usize) -> bool {
(idx > 0 && is_forward_alnum(chars[idx - 1]))
|| (idx + 1 < chars.len() && is_forward_alnum(chars[idx + 1]))
}
pub(crate) fn is_visual_rtl_candidate(text: &str) -> bool {
if text.chars().any(is_arabic_presentation_form) {
return false;
}
text.chars()
.filter(|&c| is_rtl_char(c) && !is_arabic_indic_digit(c) && !is_arabic_numeric_separator(c))
.count()
>= 2
&& is_rtl_text(std::iter::once(text))
}
pub(crate) fn fix_visual_order_rtl(items: &mut [TextItem], candidates: &[usize], logical_ops: u32) {
if candidates.is_empty() {
return;
}
let mut rightward = 0u32;
let mut leftward = logical_ops;
for pair in candidates.windows(2) {
let (a, b) = (&items[pair[0]], &items[pair[1]]);
if (a.y - b.y).abs() > a.height.max(b.height).max(1.0) * 0.5 {
continue;
}
if b.x > a.x + 0.1 {
rightward += 1;
} else if b.x < a.x - 0.1 {
leftward += 1;
}
}
if leftward > rightward {
return;
}
for &idx in candidates {
items[idx].text = reverse_visual_arabic(&items[idx].text);
}
}
pub(crate) fn decode_text_string(bytes: &[u8]) -> String {
if bytes.len() >= 2 && bytes[0] == 0xFE && bytes[1] == 0xFF {
let utf16: Vec<u16> = bytes[2..]
.as_chunks::<2>()
.0
.iter()
.map(|chunk| u16::from_be_bytes(*chunk))
.collect();
String::from_utf16_lossy(&utf16)
} else {
bytes.iter().map(|&b| b as char).collect()
}
}
pub(crate) fn effective_font_size(base_size: f32, text_matrix: &[f32; 6]) -> f32 {
let scale_x = (text_matrix[0].powi(2) + text_matrix[1].powi(2)).sqrt();
let scale_y = (text_matrix[2].powi(2) + text_matrix[3].powi(2)).sqrt();
let scale = scale_x.max(scale_y);
base_size.abs() * scale
}
pub(crate) fn effective_width(item: &TextItem) -> f32 {
if item.width > 0.0 {
item.width
} else {
item.text.chars().count() as f32 * item.font_size * 0.5
}
}
pub(crate) fn effective_height(item: &TextItem) -> f32 {
item.height
}
pub(crate) fn is_cid_font(font: &str) -> bool {
font.starts_with("C2_") || font.starts_with("C0_")
}
pub(crate) fn fix_letterspaced_items(items: &mut [TextItem]) -> f32 {
const DEFAULT: f32 = 0.10;
if items.is_empty() {
return DEFAULT;
}
fn is_letterspaced(text: &str) -> bool {
let trimmed = text.trim();
let chars: Vec<char> = trimmed.chars().collect();
if chars.len() < 3 {
return false;
}
chars
.iter()
.enumerate()
.all(|(i, &c)| if i % 2 == 0 { c != ' ' } else { c == ' ' })
}
let mut letterspaced_count = 0u32;
let mut total_text_items = 0u32;
for item in items.iter() {
let trimmed = item.text.trim();
if trimmed.is_empty() || trimmed.len() < 3 {
continue;
}
total_text_items += 1;
if is_letterspaced(&item.text) {
letterspaced_count += 1;
}
}
if total_text_items < 4 || letterspaced_count * 2 < total_text_items {
let single_char_count = items
.iter()
.filter(|i| i.text.trim().chars().count() == 1)
.count();
if items.len() >= 10 && single_char_count * 2 >= items.len() {
let threshold = compute_canva_join_threshold(items);
if threshold > 0.40 {
return threshold;
}
}
return DEFAULT;
}
let threshold = compute_canva_join_threshold(items);
for item in items.iter_mut() {
if is_letterspaced(&item.text) {
let fixed: String = item.text.chars().filter(|&c| c != ' ').collect();
item.text = fixed;
}
}
threshold
}
fn compute_canva_join_threshold(items: &[TextItem]) -> f32 {
const DEFAULT: f32 = 0.10;
const MIN_SAMPLES: usize = 8;
let ratios = collect_gap_ratios(items);
if ratios.len() < MIN_SAMPLES {
return DEFAULT;
}
let mut sorted: Vec<f32> = ratios;
sorted.sort_by(|a, b| a.total_cmp(b));
if sorted[sorted.len() - 1] < 0.40 || sorted[0] < 0.40 {
return DEFAULT;
}
let median = sorted[sorted.len() / 2];
(median * 1.55).clamp(0.50, 2.0)
}
fn collect_gap_ratios(items: &[TextItem]) -> Vec<f32> {
let mut ratios: Vec<f32> = Vec::new();
for pair in items.windows(2) {
let prev = &pair[0];
let curr = &pair[1];
let prev_c = prev.text.trim().chars().last();
let curr_c = curr.text.trim().chars().next();
if prev_c.is_some_and(is_cjk_char) || curr_c.is_some_and(is_cjk_char) {
continue;
}
if prev.width <= 0.0 || prev.font_size <= 0.0 {
continue;
}
let gap = if prev.x <= curr.x {
curr.x - (prev.x + prev.width)
} else {
prev.x - (curr.x + curr.width)
};
let ratio = gap / prev.font_size;
if (0.0..=3.0).contains(&ratio) {
ratios.push(ratio);
}
}
ratios
}
#[cfg(test)]
fn compute_single_char_join_threshold(items: &[TextItem]) -> f32 {
const DEFAULT: f32 = 0.10;
const MIN_SAMPLES: usize = 8;
let mut ratios: Vec<f32> = Vec::new();
for pair in items.windows(2) {
let prev = &pair[0];
let curr = &pair[1];
let prev_chars = prev.text.trim().chars().count();
let curr_chars = curr.text.trim().chars().count();
if prev_chars >= 5 && curr_chars >= 5 {
continue;
}
let prev_c = prev.text.trim().chars().last();
let curr_c = curr.text.trim().chars().next();
if prev_c.is_some_and(is_cjk_char) || curr_c.is_some_and(is_cjk_char) {
continue;
}
if prev.width <= 0.0 || prev.font_size <= 0.0 {
continue;
}
let gap = if prev.x <= curr.x {
curr.x - (prev.x + prev.width)
} else {
prev.x - (curr.x + curr.width)
};
let ratio = gap / prev.font_size;
if !(0.0..=3.0).contains(&ratio) {
continue;
}
ratios.push(ratio);
}
if ratios.len() < MIN_SAMPLES {
return DEFAULT;
}
ratios.sort_by(|a, b| a.total_cmp(b));
let max_ratio = ratios[ratios.len() - 1];
if max_ratio < 0.40 {
return DEFAULT;
}
if ratios[0] < 0.40 {
return DEFAULT;
}
let n = ratios.len() as f32;
let total_sum: f32 = ratios.iter().sum();
let mut best_threshold = DEFAULT;
let mut best_variance = f32::NEG_INFINITY;
let mut w0: f32 = 0.0;
let mut sum0: f32 = 0.0;
for i in 0..ratios.len() - 1 {
w0 += 1.0;
sum0 += ratios[i];
let w1 = n - w0;
if w1 == 0.0 {
break;
}
let mean0 = sum0 / w0;
let mean1 = (total_sum - sum0) / w1;
let variance = w0 * w1 * (mean0 - mean1).powi(2);
if i + 1 < ratios.len() && (ratios[i + 1] - ratios[i]).abs() < 1e-6 {
continue;
}
if variance > best_variance {
best_variance = variance;
best_threshold = (ratios[i] + ratios[i + 1]) / 2.0;
}
}
best_threshold.clamp(0.05, 2.0)
}
pub(crate) fn should_join_items(
prev_item: &TextItem,
curr_item: &TextItem,
single_char_threshold: f32,
) -> bool {
if prev_item.text.ends_with(' ') || curr_item.text.starts_with(' ') {
return false;
}
let prev_last = prev_item.text.trim_end().chars().last();
let curr_first = curr_item.text.trim_start().chars().next();
if let Some(c) = curr_first {
if matches!(c, '.' | ',' | ';' | '!' | '?' | ')' | ']' | '}' | '\'') {
return true;
}
}
if let (Some(p), Some(c)) = (prev_last, curr_first) {
if p == ':' && c.is_alphanumeric() {
return false;
}
}
if prev_item.width > 0.0
&& prev_item.advance_known
&& prev_item.is_upright()
&& curr_item.is_upright()
{
let gap = if prev_item.x <= curr_item.x {
curr_item.x - (prev_item.x + prev_item.width)
} else {
prev_item.x - (curr_item.x + curr_item.width)
};
let font_size = prev_item.font_size;
if gap > font_size * 3.0 || gap < -font_size {
return false;
}
let prev_chars = prev_item.text.trim().chars().count();
let curr_chars = curr_item.text.trim().chars().count();
let prev_last_char = prev_item.text.trim().chars().last();
let curr_first_char = curr_item.text.trim().chars().next();
let is_cjk =
prev_last_char.is_some_and(is_cjk_char) || curr_first_char.is_some_and(is_cjk_char);
if !is_cjk && gap >= 0.0 && gap < font_size * 0.01 && is_cid_font(&prev_item.font) {
let prev_word_count = prev_item.text.split_whitespace().count();
if prev_word_count >= 3 {
return gap < font_size * 0.15;
}
return false;
}
if let (Some(p), Some(c)) = (prev_last, curr_first) {
let prev_is_numeric = p.is_ascii_digit() || p == ',' || p == '.';
let curr_is_numeric = c.is_ascii_digit() || c == '%' || c == '.';
if prev_is_numeric && curr_is_numeric {
return gap > -font_size && gap < font_size * 0.3;
}
if (p == '+' || p == '-') && c.is_ascii_digit() {
return gap > -font_size && gap < font_size * 0.3;
}
}
if single_char_threshold > 0.20 {
if prev_chars == 1 {
return gap < prev_item.width * 1.25;
}
if curr_chars == 1 {
let avg_char_width = prev_item.width / prev_chars as f32;
return gap < avg_char_width * 1.25;
}
return gap < font_size * single_char_threshold;
}
if (prev_chars == 1) != (curr_chars == 1) {
return gap < font_size * 0.20;
}
if prev_chars == 1 && curr_chars == 1 {
if let (Some(p), Some(c)) = (prev_last, curr_first) {
let p_numeric = p.is_ascii_digit() || matches!(p, ',' | '.' | '%' | '+' | '-');
let c_numeric = c.is_ascii_digit() || matches!(c, ',' | '.' | '%');
if p_numeric && c_numeric {
return gap < font_size * 0.25;
}
}
return gap < font_size * single_char_threshold;
}
if prev_item.text.trim().chars().count() >= 2 && curr_item.text.trim().chars().count() >= 2
{
let prev_ends_lower = prev_item
.text
.trim()
.chars()
.last()
.is_some_and(|c| c.is_lowercase());
let curr_starts_lower = curr_item
.text
.trim()
.chars()
.next()
.is_some_and(|c| c.is_lowercase());
if prev_ends_lower && curr_starts_lower {
return gap < font_size * 0.18;
}
}
return gap < font_size * 0.15;
}
let char_width = prev_item.font_size * 0.45;
let prev_text_len = prev_item.text.chars().count() as f32;
let estimated_prev_width = prev_text_len * char_width;
let prev_end_x = prev_item.x + estimated_prev_width;
let gap = curr_item.x - prev_end_x;
if gap > char_width * 6.0 {
return false;
}
let is_cjk = prev_last.is_some_and(is_cjk_char) || curr_first.is_some_and(is_cjk_char);
if is_cjk {
return gap < char_width * 0.8;
}
match (prev_last, curr_first) {
(Some(p), Some(c)) if p.is_alphabetic() && c.is_alphabetic() => {
let same_case =
(p.is_uppercase() && c.is_uppercase()) || (p.is_lowercase() && c.is_lowercase());
if same_case {
gap < char_width * 0.8
} else if p.is_lowercase() && c.is_uppercase() {
false
} else {
gap < char_width * 0.3
}
}
_ => {
gap < char_width * 0.5
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::ItemType;
#[test]
fn font_weight_reads_full_style_words() {
let cases = [
("Helvetica", None),
("ABCDEF+Helvetica-Bold", Some(700)),
("Arial-BoldMT", Some(700)),
("Arial,BoldItalic", Some(700)),
("TimesNewRomanPSMT", Some(400)),
("TimesNewRomanPS-BoldMT", Some(700)),
("Calibri-Light", Some(300)),
("SegoeUI-Semibold", Some(600)),
("OpenSans-ExtraBold", Some(800)),
("Roboto-Black", Some(900)),
("Lato-Heavy", Some(900)),
("Montserrat-Thin", Some(100)),
("Montserrat-ExtraLightItalic", Some(200)),
("Foo-MediumItalic", Some(500)),
("ITCAvantGardeStd-Demi", Some(600)),
("Bookman-Demi", Some(600)),
("CenturyGothic-Book", Some(400)),
("Roboto-Regular", Some(400)),
("NimbusRomNo9L-Medi", Some(500)),
("ARIALBOLD", Some(700)),
("HELVETICA-BOLDOBLIQUE", Some(700)),
("AVANTGARDEDEMI", Some(600)),
("ARIALTHIN", Some(100)),
("ARIALBLACK", Some(900)),
("ARIALBOLDMT", Some(700)),
("TIMESNEWROMANPSMT", Some(400)),
("helveticaneue-ultra", Some(900)),
("FrutigerLT-Roman", Some(400)),
("Frutiger LT 45 Light", Some(300)),
("ArialBlack", Some(900)),
("Arial Black", Some(900)),
("ArialBoldMT", Some(700)),
("HelveticaNeueLightItalic", Some(300)),
("GillSansUltraBold", Some(800)),
("Foo-Extra-Light", Some(200)),
("Foo-Ultra-Light", Some(200)),
("Foo-Semi-Bold", Some(600)),
("Foo-Demi-Bold", Some(600)),
("Open Sans Extra Bold", Some(800)),
("Foo-Extra-Black", Some(900)),
];
for (name, expected) in cases {
assert_eq!(font_weight_from_name(name), expected, "{name}");
}
}
#[test]
fn font_weight_reads_style_abbreviations_after_the_family() {
let cases = [
("AAAAAB+HelveticaNeueLTStd-Md", Some(500)),
("HelveticaNeueLTStd-Lt", Some(300)),
("HelveticaNeueLTStd-Bd", Some(700)),
("HelveticaNeueLTStd-BdCn", Some(700)),
("HelveticaNeueLTStd-MdIt", Some(500)),
("HelveticaNeueLTStd-UltLt", Some(200)),
("HelveticaNeueLTStd-Hv", Some(900)),
("HelveticaNeueLTStd-Blk", Some(900)),
("HelveticaNeueLTStd-XBlk", Some(900)),
("HelveticaNeueLTStd-Th", Some(100)),
("FrutigerLTStd-Ult", Some(900)),
("ITCFranklinGothicStd-Dm", Some(600)),
("ITCFranklinGothicStd-DmCd", Some(600)),
("Foo-Sb", Some(600)),
("Foo-SBd", Some(600)),
("Foo-Smbd", Some(600)),
("Foo-XBd", Some(800)),
("Foo-XBdIt", Some(800)),
("Foo-Bk", Some(400)),
("Foo-Rg", Some(400)),
("HiraginoSans-W1", Some(100)),
("HiraginoSans-W2", Some(200)),
("HiraKakuProN-W3", Some(300)),
("HiraKakuProN-W6", Some(600)),
("KozMinPr6N-W9", Some(900)),
];
for (name, expected) in cases {
assert_eq!(font_weight_from_name(name), expected, "{name}");
}
}
#[test]
fn font_weight_ignores_width_style_and_family_words() {
let cases = [
"HelveticaNeueLTStd-Cn",
"Roboto-Condensed",
"Roboto-CondensedItalic",
"SignPainter-HouseScript",
"FZXXLB--B51-0",
"FZHTB--B51-0",
"Frutiger LT Std",
"Helvetica LT Condensed",
"Frutiger-LT",
"Foo-MT",
"THSarabunNew",
"TH-Sarabun",
"BlackadderITC",
"Bookman",
"BlackChancery",
"Black Chancery",
"BLACKCHANCERY",
"BlackOak",
"BookAntiqua",
"OldBlackLetter",
"LightRail",
"HeavyMetal",
"Foo-W95",
"HiraginoSans-W0",
"Wingdings",
"ABCDEF+Tc1",
];
for name in cases {
assert_eq!(font_weight_from_name(name), None, "{name}");
}
}
#[test]
fn bold_font_urw_medi_abbreviation() {
assert!(is_bold_font("NROFIU+NimbusRomNo9L-Medi"));
assert!(is_bold_font("NimbusRomNo9L-MediItal"));
assert!(!is_bold_font("DSSZWN+NimbusRomNo9L-Regu"));
assert!(!is_bold_font("NimbusRomNo9L-ReguItal"));
assert!(!is_bold_font("Foo-MediumItalic"));
}
#[test]
fn strip_soft_hyphen() {
assert_eq!(expand_ligatures("con\u{00AD}tent"), "content");
}
#[test]
fn strip_zero_width_space() {
assert_eq!(expand_ligatures("hello\u{200B}world"), "helloworld");
}
#[test]
fn strip_bom() {
assert_eq!(expand_ligatures("\u{FEFF}text"), "text");
}
#[test]
fn strip_zwnj_zwj_word_joiner() {
assert_eq!(expand_ligatures("a\u{200C}b\u{200D}c\u{2060}d"), "abcd");
}
#[test]
fn ligature_plus_invisible_chars() {
assert_eq!(expand_ligatures("\u{FB01}rst\u{00AD}ly"), "firstly");
}
#[test]
fn ligatures_still_expand() {
assert_eq!(expand_ligatures("\u{FB00}\u{FB01}\u{FB02}"), "fffifl");
}
#[test]
fn normalize_typographic_spaces() {
assert_eq!(expand_ligatures("•\u{2003}text"), "• text");
assert_eq!(expand_ligatures("a\u{2002}b"), "a b");
assert_eq!(expand_ligatures("x\u{2009}y"), "x y");
}
#[test]
fn nbsp_preserved() {
assert_eq!(expand_ligatures("a\u{00A0}b"), "a\u{00A0}b");
}
#[test]
fn nfkc_arabic_presentation_forms() {
let input = "\u{FEE1}\u{FEF3}"; let result = expand_ligatures(input);
assert!(
!result.chars().any(is_arabic_presentation_form),
"presentation forms should be normalized: {result:?}"
);
assert!(
result.chars().any(|c| matches!(c, '\u{0600}'..='\u{06FF}')),
"should contain base Arabic characters: {result:?}"
);
}
#[test]
fn no_reversal_for_base_arabic() {
let input = "\u{0645}\u{0631}\u{062D}\u{0628}\u{0627}"; let result = expand_ligatures(input);
assert_eq!(result, input, "base Arabic should pass through unchanged");
}
#[test]
fn latin_text_unaffected() {
assert_eq!(expand_ligatures("Hello World"), "Hello World");
}
#[test]
fn reverse_visual_arabic_pure_rtl() {
let input = "\u{0628}\u{0627}"; let result = reverse_visual_arabic(input);
assert_eq!(result, "\u{0627}\u{0628}"); }
#[test]
fn reverse_visual_arabic_with_ltr_run() {
let input = "\u{0623}123\u{0628}";
let result = reverse_visual_arabic(input);
assert_eq!(result, "\u{0628}123\u{0623}");
}
#[test]
fn arabic_presentation_form_detection() {
assert!(is_arabic_presentation_form('\u{FB50}'));
assert!(is_arabic_presentation_form('\u{FDFF}'));
assert!(is_arabic_presentation_form('\u{FE70}'));
assert!(is_arabic_presentation_form('\u{FEFE}'));
assert!(!is_arabic_presentation_form('\u{FEFF}'));
assert!(!is_arabic_presentation_form('\u{0645}'));
assert!(!is_arabic_presentation_form('A'));
}
#[test]
fn reverse_keeping_marks_attaches_combining_marks() {
let input = "\u{05D1}\u{05B8}\u{05D0}";
assert_eq!(reverse_keeping_marks(input), "\u{05D0}\u{05D1}\u{05B8}");
}
#[test]
fn reverse_keeping_marks_mirrors_brackets() {
let input = "(\u{05D0}\u{05D1})";
assert_eq!(reverse_keeping_marks(input), "(\u{05D1}\u{05D0})");
}
#[test]
fn visual_rtl_candidate_classification() {
assert!(is_visual_rtl_candidate("\u{05E9}\u{05DC}\u{05D5}\u{05DD}"));
assert!(is_visual_rtl_candidate("\u{0645}\u{0631}\u{062D}"));
assert!(!is_visual_rtl_candidate("\u{05E9}"));
assert!(!is_visual_rtl_candidate("\u{FEDF}\u{FEE0}"));
assert!(!is_visual_rtl_candidate("the word \u{05E9}\u{05DC} here"));
assert!(!is_visual_rtl_candidate("Hello"));
assert!(!is_visual_rtl_candidate("\u{0662}\u{0664}"));
assert!(!is_visual_rtl_candidate("\u{0663}\u{0665},\u{0660}"));
}
#[test]
fn rtl_text_direction_ignores_marks_on_both_sides() {
assert!(!is_rtl_text(
["AB", "\u{05D1}\u{05B8}\u{05B8}\u{05B8}\u{05B8}"].iter()
));
assert!(is_rtl_text(
["\u{05E9}\u{05B8}\u{05DC}\u{05B8}\u{05DD}\u{05B8}", "ab"].iter()
));
assert!(!is_rtl_text(
["ABC", "\u{078C}\u{07A6}\u{07A6}\u{07A6}"].iter()
));
}
#[test]
fn arabic_numeric_separators_stay_with_digits() {
let decimal = "\u{0663}\u{0665}\u{066B}\u{0660}"; assert_eq!(reverse_visual_arabic(decimal), decimal);
assert!(!is_visual_rtl_candidate(decimal));
let thousands = "\u{0661}\u{066C}\u{0660}\u{0660}\u{0660}"; assert_eq!(reverse_visual_arabic(thousands), thousands);
}
#[test]
fn reverse_visual_arabic_keeps_arabic_indic_digit_runs() {
let input = "\u{0629}\u{0639}\u{0627}\u{0633} \u{0662}\u{0664}"; let result = reverse_visual_arabic(input);
assert_eq!(
result,
"\u{0662}\u{0664} \u{0633}\u{0627}\u{0639}\u{0629}" );
let decimal = "\u{0663}\u{0665},\u{0660}"; assert_eq!(reverse_visual_arabic(decimal), decimal);
}
fn make_rtl_item(text: &str, x: f32, y: f32) -> TextItem {
TextItem {
text: text.to_string(),
x,
y,
width: 30.0,
height: 12.0,
font: "TestFont".to_string(),
font_tag: String::new(),
legacy_symbol_rewrite: false,
font_size: 12.0,
page: 1,
is_bold: false,
is_italic: false,
font_weight: None,
is_underline: false,
is_strikeout: false,
rotation: 0.0,
advance_known: true,
item_type: ItemType::Text,
mcid: None,
baseline_shift: 0.0,
}
}
#[test]
fn fix_visual_order_rtl_reverses_rightward_emission() {
let mut items = vec![
make_rtl_item("\u{05DD}\u{05DC}\u{05D5}\u{05E2}", 100.0, 700.0), make_rtl_item("\u{05DD}\u{05D5}\u{05DC}\u{05E9}", 160.0, 700.0), ];
fix_visual_order_rtl(&mut items, &[0, 1], 0);
assert_eq!(items[0].text, "\u{05E2}\u{05D5}\u{05DC}\u{05DD}"); assert_eq!(items[1].text, "\u{05E9}\u{05DC}\u{05D5}\u{05DD}"); }
#[test]
fn fix_visual_order_rtl_keeps_leftward_emission() {
let logical_a = "\u{05E9}\u{05DC}\u{05D5}\u{05DD}"; let logical_b = "\u{05E2}\u{05D5}\u{05DC}\u{05DD}"; let mut items = vec![
make_rtl_item(logical_a, 160.0, 700.0),
make_rtl_item(logical_b, 100.0, 700.0),
];
fix_visual_order_rtl(&mut items, &[0, 1], 0);
assert_eq!(items[0].text, logical_a);
assert_eq!(items[1].text, logical_b);
}
#[test]
fn fix_visual_order_rtl_defaults_to_reversal_without_votes() {
let mut items = vec![make_rtl_item(
"\u{05DD}\u{05D5}\u{05DC}\u{05E9}",
100.0,
700.0,
)];
fix_visual_order_rtl(&mut items, &[0], 0);
assert_eq!(items[0].text, "\u{05E9}\u{05DC}\u{05D5}\u{05DD}");
}
#[test]
fn fix_visual_order_rtl_logical_ops_outvote() {
let logical = "\u{05E9}\u{05DC}\u{05D5}\u{05DD}";
let mut items = vec![
make_rtl_item(logical, 100.0, 700.0),
make_rtl_item(logical, 160.0, 700.0),
];
fix_visual_order_rtl(&mut items, &[0, 1], 2);
assert_eq!(items[0].text, logical);
assert_eq!(items[1].text, logical);
}
#[test]
fn fix_visual_order_rtl_ignores_cross_line_pairs() {
let mut items = vec![
make_rtl_item("\u{05D1}\u{05D0}", 160.0, 700.0),
make_rtl_item("\u{05D3}\u{05D2}", 100.0, 650.0),
];
fix_visual_order_rtl(&mut items, &[0, 1], 0);
assert_eq!(items[0].text, "\u{05D0}\u{05D1}");
assert_eq!(items[1].text, "\u{05D2}\u{05D3}");
}
#[test]
fn restore_embedded_ltr_runs_screen_order() {
let mut items = vec![
"\u{05E9}\u{05DC}".to_string(),
")".to_string(),
"KM1".to_string(),
"(".to_string(),
"\u{05D5}\u{05DD}".to_string(),
];
restore_embedded_ltr_runs(&mut items, |s| s.as_str());
assert_eq!(items[1], "(");
assert_eq!(items[2], "KM1");
assert_eq!(items[3], ")");
}
#[test]
fn restore_embedded_ltr_runs_rejoins_split_arabic_indic_numbers() {
let mut items = vec![
"\u{0645}\u{0631}".to_string(), "\u{0664}".to_string(), "\u{0662}".to_string(), "\u{062D}\u{0628}".to_string(), ];
restore_embedded_ltr_runs(&mut items, |s| s.as_str());
assert_eq!(items[1], "\u{0662}");
assert_eq!(items[2], "\u{0664}");
}
#[test]
fn restore_embedded_ltr_runs_bridges_number_delimiters() {
let mut items = vec![
"\u{0665}".to_string(),
".".to_string(),
"\u{0662}\u{0664}".to_string(),
];
restore_embedded_ltr_runs(&mut items, |s| s.as_str());
assert_eq!(items, ["\u{0662}\u{0664}", ".", "\u{0665}"]);
let mut items = vec![
"\u{0662}\u{0664}".to_string(),
".".to_string(),
"\u{0645}\u{0631}".to_string(),
];
restore_embedded_ltr_runs(&mut items, |s| s.as_str());
assert_eq!(items[0], "\u{0662}\u{0664}");
assert_eq!(items[1], ".");
}
#[test]
fn restore_embedded_ltr_runs_leaves_edge_neutrals() {
let mut items = vec![
"\u{05DC}\u{05E9}\u{05E0}\u{05EA}".to_string(),
"2020".to_string(),
".".to_string(),
];
restore_embedded_ltr_runs(&mut items, |s| s.as_str());
assert_eq!(items[1], "2020");
assert_eq!(items[2], ".");
}
#[test]
fn sort_rtl_cell_items_respects_lines_and_jitter() {
let mut items = vec![
(100.0f32, 688.0f32, "\u{05D5}\u{05DD}".to_string()),
(160.0f32, 688.9f32, "CD".to_string()),
(100.0f32, 700.0f32, "AB".to_string()),
(160.0f32, 700.0f32, "\u{05E9}\u{05DC}".to_string()),
];
sort_rtl_cell_items(
&mut items,
|(x, _, _)| *x,
|(_, y, _)| *y,
|(_, _, t)| t.as_str(),
);
let texts: Vec<&str> = items.iter().map(|(_, _, t)| t.as_str()).collect();
assert_eq!(
texts,
["\u{05E9}\u{05DC}", "AB", "CD", "\u{05D5}\u{05DD}"],
"line 1 right-to-left, then line 2 right-to-left"
);
}
#[test]
fn restore_embedded_ltr_runs_multiword_phrase() {
let mut items = vec![
"\u{05E9}\u{05DC}".to_string(),
"Board".to_string(),
"Stability".to_string(),
"Financial".to_string(),
];
restore_embedded_ltr_runs(&mut items, |s| s.as_str());
assert_eq!(items[1], "Financial");
assert_eq!(items[2], "Stability");
assert_eq!(items[3], "Board");
}
fn make_char_item(ch: char, x: f32, width: f32, font_size: f32) -> TextItem {
TextItem {
text: ch.to_string(),
x,
y: 100.0,
width,
height: font_size,
font: "TestFont".to_string(),
font_tag: String::new(),
legacy_symbol_rewrite: false,
font_size,
page: 1,
is_bold: false,
is_italic: false,
font_weight: None,
is_underline: false,
is_strikeout: false,
rotation: 0.0,
advance_known: true,
item_type: ItemType::Text,
mcid: None,
baseline_shift: 0.0,
}
}
#[test]
fn otsu_threshold_sec_style_tight_gaps() {
let fs = 12.0;
let char_w = fs * 0.5;
let mut items = Vec::new();
for i in 0..15 {
let x = 100.0 + i as f32 * (char_w + fs * 0.01);
items.push(make_char_item('a', x, char_w, fs));
}
let word_x = items.last().unwrap().x + char_w + fs * 0.15;
items.push(make_char_item('b', word_x, char_w, fs));
for i in 1..5 {
let x = word_x + i as f32 * (char_w + fs * 0.01);
items.push(make_char_item('c', x, char_w, fs));
}
let threshold = compute_single_char_join_threshold(&items);
assert!(
(threshold - 0.10).abs() < 0.01,
"SEC-style should return default ~0.10, got {threshold}"
);
}
#[test]
fn otsu_threshold_canva_style_wide_gaps() {
let fs = 12.0;
let char_w = fs * 0.5;
let intra_gap = fs * 0.6;
let word_gap = fs * 1.2;
let mut items = Vec::new();
for i in 0..8 {
let x = 100.0 + i as f32 * (char_w + intra_gap);
items.push(make_char_item('K', x, char_w, fs));
}
let word_x = items.last().unwrap().x + char_w + word_gap;
items.push(make_char_item('T', word_x, char_w, fs));
for i in 1..7 {
let x = word_x + i as f32 * (char_w + intra_gap);
items.push(make_char_item('o', x, char_w, fs));
}
let threshold = compute_single_char_join_threshold(&items);
assert!(
threshold > 0.5 && threshold < 1.1,
"Canva-style should find threshold ~0.9, got {threshold}"
);
}
#[test]
fn otsu_threshold_few_samples_returns_default() {
let fs = 12.0;
let char_w = fs * 0.5;
let items: Vec<TextItem> = (0..5)
.map(|i| make_char_item('x', 100.0 + i as f32 * (char_w + 1.0), char_w, fs))
.collect();
let threshold = compute_single_char_join_threshold(&items);
assert!(
(threshold - 0.10).abs() < 0.01,
"few samples should return default 0.10, got {threshold}"
);
}
#[test]
fn fix_letterspaced_items_returns_adaptive_threshold() {
let fs = 12.0;
let char_w = fs * 0.5;
let letter_gap = fs * 0.6; let word_gap = fs * 1.2;
let words: Vec<&str> = vec![
"H e l l o",
"W o r l d",
"F o o",
"B a r",
"B a z",
"Q u x",
"T e s t",
"D a t a",
"M o r e",
"T e x t",
];
let mut items = Vec::new();
let mut x = 100.0;
for (wi, word) in words.iter().enumerate() {
let char_count = word.chars().filter(|c| !c.is_whitespace()).count();
let w = char_count as f32 * char_w + (char_count - 1) as f32 * letter_gap;
items.push(TextItem {
text: word.to_string(),
x,
y: 100.0,
width: w,
height: fs,
font: "TestFont".to_string(),
font_tag: String::new(),
legacy_symbol_rewrite: false,
font_size: fs,
page: 1,
is_bold: false,
is_italic: false,
font_weight: None,
is_underline: false,
is_strikeout: false,
rotation: 0.0,
advance_known: true,
item_type: ItemType::Text,
mcid: None,
baseline_shift: 0.0,
});
x += w + if wi % 3 == 2 { word_gap } else { letter_gap };
}
let threshold = fix_letterspaced_items(&mut items);
assert!(
threshold > 0.50,
"Canva page should get threshold > 0.50, got {threshold}"
);
assert_eq!(items[0].text, "Hello");
assert_eq!(items[1].text, "World");
assert_eq!(items[2].text, "Foo");
assert_eq!(items[9].text, "Text");
}
#[test]
fn canva_style_items_join_correctly() {
let fs = 12.0;
let char_w = fs * 0.5;
let intra_gap = fs * 0.6;
let word_gap = fs * 1.2;
let mut items = Vec::new();
let chars = ['H', 'e', 'l', 'l', 'o'];
for (i, &ch) in chars.iter().enumerate() {
let x = 100.0 + i as f32 * (char_w + intra_gap);
items.push(make_char_item(ch, x, char_w, fs));
}
let w_x = items.last().unwrap().x + char_w + word_gap;
items.push(make_char_item('W', w_x, char_w, fs));
let chars2 = ['o', 'r', 'l', 'd'];
for (i, &ch) in chars2.iter().enumerate() {
let x = w_x + (i + 1) as f32 * (char_w + intra_gap);
items.push(make_char_item(ch, x, char_w, fs));
}
let threshold = compute_single_char_join_threshold(&items);
assert!(
should_join_items(&items[0], &items[1], threshold),
"H+e should join with threshold {threshold}"
);
assert!(
should_join_items(&items[3], &items[4], threshold),
"l+o should join with threshold {threshold}"
);
assert!(
!should_join_items(&items[4], &items[5], threshold),
"o+W (word boundary) should NOT join with threshold {threshold}"
);
}
fn make_text_item(text: &str, x: f32, width: f32, font_size: f32) -> TextItem {
TextItem {
text: text.to_string(),
x,
y: 100.0,
width,
height: font_size,
font: "TestFont".to_string(),
font_tag: String::new(),
legacy_symbol_rewrite: false,
font_size,
page: 1,
is_bold: false,
is_italic: false,
font_weight: None,
is_underline: false,
is_strikeout: false,
rotation: 0.0,
advance_known: true,
item_type: ItemType::Text,
mcid: None,
baseline_shift: 0.0,
}
}
#[test]
fn canva_width_based_single_char_prev_join() {
let fs = 12.0;
let threshold = 0.90;
let k = make_text_item("K", 100.0, 7.9, fs);
let a = make_text_item("a", 115.9, 6.0, fs);
assert!(
should_join_items(&k, &a, threshold),
"K→a: gap/width={:.3}, should join",
(a.x - (k.x + k.width)) / k.width
);
let f = make_text_item("f", 193.0, 4.0, fs);
let k2 = make_text_item("K", 207.47, 7.9, fs);
assert!(
!should_join_items(&f, &k2, threshold),
"f→K: gap/width={:.3}, should split",
(k2.x - (f.x + f.width)) / f.width
);
}
#[test]
fn canva_width_based_multi_to_single_join() {
let fs = 12.0;
let threshold = 0.90;
let ilw = make_text_item("ilw", 320.0, 23.6, fs);
let a = make_text_item("a", 353.0, 6.0, fs);
assert!(
should_join_items(&ilw, &a, threshold),
"ilw→a: avg_ratio={:.3}, should join (intra-word 'railway')",
(a.x - (ilw.x + ilw.width)) / (ilw.width / 3.0)
);
let rich = make_text_item("rich", 229.0, 34.8, fs);
let m = make_text_item("m", 277.8, 10.7, fs);
assert!(
!should_join_items(&rich, &m, threshold),
"rich→m: avg_ratio={:.3}, should split (word boundary)",
(m.x - (rich.x + rich.width)) / (rich.width / 4.0)
);
}
#[test]
fn canva_width_based_multi_to_multi_page_threshold() {
let fs = 12.0;
let threshold = 0.90;
let rib = make_text_item("rib", 236.0, 25.0, fs);
let ib = make_text_item("ib", 268.0, 14.0, fs);
assert!(
should_join_items(&rib, &ib, threshold),
"rib→ib: ratio={:.3}, should join (intra-word)",
(ib.x - (rib.x + rib.width)) / fs
);
let ized = make_text_item("ized", 142.0, 35.9, fs);
let fo = make_text_item("fo", 191.8, 13.8, fs);
assert!(
!should_join_items(&ized, &fo, threshold),
"ized→fo: ratio={:.3}, should split (word boundary)",
(fo.x - (ized.x + ized.width)) / fs
);
}
fn geometry_item(width: f32, font_size: f32, rotation: f32) -> TextItem {
TextItem {
baseline_shift: 0.0,
text: "abcd".to_string(),
x: 0.0,
y: 0.0,
width,
height: font_size,
rotation,
advance_known: true,
font: String::new(),
font_tag: String::new(),
legacy_symbol_rewrite: false,
font_size,
page: 1,
is_bold: false,
is_italic: false,
font_weight: None,
is_underline: false,
is_strikeout: false,
item_type: ItemType::Text,
mcid: None,
}
}
#[test]
fn join_threshold_ignores_a_vertical_runs_em_width() {
let mut prev = geometry_item(10.0, 30.0, 90.0);
prev.text = "ab".to_string();
prev.x = 100.0;
prev.font_size = 10.0;
let mut curr = geometry_item(10.0, 30.0, 90.0);
curr.text = "cd".to_string();
curr.x = 112.5;
curr.font_size = 10.0;
assert!(should_join_items(&prev, &curr, 0.1));
prev.rotation = 0.0;
prev.height = 10.0;
curr.rotation = 0.0;
curr.height = 10.0;
assert!(!should_join_items(&prev, &curr, 0.1));
}
#[test]
fn upside_down_lines_sort_right_to_left() {
let mut hello = geometry_item(30.0, 10.0, 180.0);
hello.text = "HELLO".to_string();
hello.x = 300.0;
let mut world = geometry_item(30.0, 10.0, 180.0);
world.text = "WORLD".to_string();
world.x = 260.0;
let mut items = vec![world.clone(), hello.clone()];
sort_line_items(&mut items);
let texts: Vec<&str> = items.iter().map(|i| i.text.as_str()).collect();
assert_eq!(texts, ["HELLO", "WORLD"]);
items[0].rotation = 0.0;
sort_line_items(&mut items);
let texts: Vec<&str> = items.iter().map(|i| i.text.as_str()).collect();
assert_eq!(texts, ["WORLD", "HELLO"]);
let mut link = geometry_item(5.0, 10.0, 0.0);
link.text = "link".to_string();
link.x = 291.0;
link.item_type = ItemType::Link("https://example.com/".to_string());
let mut items = vec![world, link, hello];
sort_line_items(&mut items);
let texts: Vec<&str> = items.iter().map(|i| i.text.as_str()).collect();
assert_eq!(texts, ["HELLO", "link", "WORLD"]);
let mut first = geometry_item(30.0, 10.0, 180.0);
first.text = "\u{05E9}\u{05DC}\u{05D5}\u{05DD}".to_string();
first.x = 140.0;
let mut second = geometry_item(30.0, 10.0, 180.0);
second.text = "\u{05E2}\u{05D5}\u{05DC}\u{05DD}".to_string();
second.x = 100.0;
let mut items = vec![second.clone(), first.clone()];
sort_line_items(&mut items);
assert_eq!(items[0].text, first.text);
assert_eq!(items[1].text, second.text);
}
#[test]
fn extent_helpers_pass_the_box_through() {
let mut zero = geometry_item(0.0, 10.0, 0.0);
zero.text = "ab".to_string();
zero.font_size = 10.0;
assert!(zero.advance_known);
assert_eq!(
(effective_width(&zero), effective_height(&zero)),
(10.0, 10.0)
);
let mut backwards = zero.clone();
backwards.width = -1.7;
assert_eq!(effective_width(&backwards), 10.0);
let mut unmeasured = zero.clone();
unmeasured.advance_known = false;
assert_eq!(effective_width(&unmeasured), 10.0);
let mut estimated = geometry_item(20.0, 10.0, 0.0);
estimated.advance_known = false;
assert_eq!(effective_width(&estimated), 20.0);
let mut short = geometry_item(6.0, 10.0, 45.0);
short.height = 5.0;
assert_eq!(
(effective_width(&short), effective_height(&short)),
(6.0, 5.0)
);
}
}