use crate::types::{ItemType, TextItem};
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_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
}
pub(crate) fn rtl_line_base<T>(
items: &[T],
item_of: impl Fn(&T) -> &TextItem,
page_rtl: bool,
) -> bool {
if is_rtl_text(items.iter().map(|item| &item_of(item).text)) {
return true;
}
if !page_rtl
|| !items
.iter()
.any(|item| item_of(item).text.chars().any(is_rtl_char))
{
return false;
}
let is_letter =
|c: &char| c.is_alphabetic() && !unicode_normalization::char::is_combining_mark(*c);
let leftmost = items
.iter()
.map(&item_of)
.filter(|i| i.text.chars().any(|c| is_letter(&c)))
.min_by(|a, b| a.x.total_cmp(&b.x))
.and_then(|i| i.text.chars().find(is_letter));
let rightmost = items
.iter()
.map(&item_of)
.filter(|i| i.text.chars().any(|c| is_letter(&c)))
.max_by(|a, b| (a.x + a.width).total_cmp(&(b.x + b.width)))
.and_then(|i| i.text.chars().rev().find(is_letter));
!matches!((leftmost, rightmost), (Some(l), Some(r)) if !is_rtl_char(l) && !is_rtl_char(r))
}
pub(crate) fn reorder_bidi_line<T: Clone>(
items: &mut [T],
item_of: impl Fn(&T) -> &TextItem,
rtl_base: bool,
) {
let order = crate::bidi::logical_line_order(
items,
|item| item_of(item).text.as_str(),
|item| {
let item = item_of(item);
(item.x, item.width)
},
|item| item_of(item).font_size,
|_| false,
None,
rtl_base,
);
let source: Vec<T> = items.to_vec();
for (slot, (index, _)) in order.into_iter().enumerate() {
items[slot] = source[index].clone();
}
}
pub(crate) fn sort_rtl_cell_items<T: Clone>(items: &mut [T], item_of: impl Fn(&T) -> &TextItem) {
items.sort_by(|a, b| item_of(b).line_y().total_cmp(&item_of(a).line_y()));
let mut start = 0;
while start < items.len() {
let y0 = item_of(&items[start]).line_y();
let mut end = start + 1;
while end < items.len() && (item_of(&items[end]).line_y() - y0).abs() <= 2.0 {
end += 1;
}
items[start..end].sort_by(|a, b| item_of(a).x.total_cmp(&item_of(b).x));
reorder_bidi_line(&mut items[start..end], &item_of, true);
start = end;
}
}
pub(crate) fn sort_line_items(items: &mut [TextItem], page_rtl: bool) {
if items.iter().any(|i| i.text.chars().any(is_rtl_char)) {
let rtl_base = rtl_line_base(items, |i| i, page_rtl);
items.sort_by(|a, b| a.x.total_cmp(&b.x));
reorder_bidi_line(items, |i| i, rtl_base);
return;
}
let mut text_runs = items
.iter()
.filter(|i| matches!(i.item_type, ItemType::Text));
let upside_down = 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
}
};
items.sort_by(|a, b| key(a).total_cmp(&key(b)));
}
pub fn is_bold_font(font_name: &str) -> bool {
if font_weight_from_name(font_name).is_some_and(|weight| weight >= 600) {
return true;
}
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 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),
}
}
result
}
pub(crate) fn is_visual_rtl_candidate(text: &str) -> bool {
text.chars().any(|c| {
c.is_alphabetic() && !unicode_normalization::char::is_combining_mark(c) && is_rtl_char(c)
})
}
pub(crate) fn is_visual_rtl_run(text: &str) -> bool {
text.chars()
.filter(|&c| {
c.is_alphabetic()
&& !unicode_normalization::char::is_combining_mark(c)
&& is_rtl_char(c)
})
.nth(1)
.is_some()
}
pub(crate) fn render_mode_paints(mode: i32) -> bool {
!matches!(mode, 3 | 7)
}
pub(crate) fn white_fill_hides(mode: i32, fill_is_white: bool) -> bool {
fill_is_white && matches!(mode, 0 | 4 | 7)
}
fn stored_in_visual_order(
items: &[TextItem],
candidates: &[usize],
logical_ops: u32,
visual_ops: u32,
) -> bool {
if candidates.is_empty() {
return false;
}
let mut rightward = visual_ops;
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;
}
}
leftward <= rightward
}
pub(crate) fn fix_visual_order_rtl(
items: &mut [TextItem],
candidates: &[usize],
logical_ops: u32,
visual_ops: u32,
logical_text_items: &[usize],
) -> bool {
let page_rtl = is_rtl_text(items.iter().map(|i| &i.text));
if !stored_in_visual_order(items, candidates, logical_ops, visual_ops) {
for (index, item) in items.iter_mut().enumerate() {
if logical_text_items.contains(&index)
|| !item.text.chars().any(crate::bidi::is_presentation_form)
{
continue;
}
let rtl = page_rtl || is_rtl_text(std::iter::once(&item.text));
let visual: Vec<crate::bidi::VisualChar> = item
.text
.chars()
.map(|ch| crate::bidi::VisualChar { ch, item: None })
.collect();
item.text = crate::bidi::visual_to_logical(&visual, rtl)
.into_iter()
.map(|v| v.ch)
.collect();
}
return false;
}
for &index in logical_text_items {
let Some(item) = items.get_mut(index) else {
continue;
};
if !item.text.chars().any(is_rtl_char) {
continue;
}
let rtl = page_rtl || is_rtl_text(std::iter::once(&item.text));
item.text = crate::bidi::logical_to_visual(&item.text, rtl);
}
true
}
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()
}
}
fn pdf_doc_encoding_char(byte: u8) -> char {
const ACCENTS: [char; 8] = [
'\u{02D8}', '\u{02C7}', '\u{02C6}', '\u{02D9}', '\u{02DD}', '\u{02DB}', '\u{02DA}',
'\u{02DC}',
];
const HIGH: [char; 31] = [
'\u{2022}', '\u{2020}', '\u{2021}', '\u{2026}', '\u{2014}', '\u{2013}', '\u{0192}',
'\u{2044}', '\u{2039}', '\u{203A}', '\u{2212}', '\u{2030}', '\u{201E}', '\u{201C}',
'\u{201D}', '\u{2018}', '\u{2019}', '\u{201A}', '\u{2122}', '\u{FB01}', '\u{FB02}',
'\u{0141}', '\u{0152}', '\u{0160}', '\u{0178}', '\u{017D}', '\u{0131}', '\u{0142}',
'\u{0153}', '\u{0161}', '\u{017E}',
];
match byte {
0x18..=0x1F => ACCENTS[usize::from(byte - 0x18)],
0x80..=0x9E => HIGH[usize::from(byte - 0x80)],
0xA0 => '\u{20AC}',
_ => char::from(byte),
}
}
fn utf16_text(bytes: &[u8], unit: fn([u8; 2]) -> u16) -> String {
let units: Vec<u16> = bytes
.as_chunks::<2>()
.0
.iter()
.map(|pair| unit(*pair))
.collect();
String::from_utf16_lossy(&units)
}
fn strip_language_escapes(text: String) -> String {
if !text.contains('\u{1B}') {
return text;
}
let mut out = String::with_capacity(text.len());
let mut rest = text.as_str();
while let Some(start) = rest.find('\u{1B}') {
out.push_str(&rest[..start]);
let after = &rest[start + 1..];
match after.find('\u{1B}') {
Some(end) if (1..=2).contains(&after[..end].chars().count()) => {
rest = &after[end + 1..];
}
_ => {
out.push('\u{1B}');
rest = after;
}
}
}
out.push_str(rest);
out
}
pub(crate) fn decode_pdf_text_string(bytes: &[u8]) -> String {
let mut text = if let Some(rest) = bytes.strip_prefix(&[0xFE, 0xFF]) {
strip_language_escapes(utf16_text(rest, u16::from_be_bytes))
} else if let Some(rest) = bytes.strip_prefix(&[0xFF, 0xFE]) {
strip_language_escapes(utf16_text(rest, u16::from_le_bytes))
} else if let Some(rest) = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]) {
String::from_utf8_lossy(rest).into_owned()
} else {
match std::str::from_utf8(bytes) {
Ok(utf8) if !utf8.is_ascii() => utf8.to_owned(),
_ => bytes.iter().map(|&b| pdf_doc_encoding_char(b)).collect(),
}
};
let end = text.trim_end_matches('\0').len();
text.truncate(end);
text
}
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_reads_the_weight_parsers_style_words_and_abbreviations() {
for name in [
"Bookman-Demi",
"ITCAvantGardeStd-Demi",
"ABCDEF+Face-Demi",
"helveticaneue-ultra",
"FrutigerLTStd-Ult",
"Lato-Heavy",
"Roboto-Black",
"HelveticaNeueLTStd-Hv",
"HelveticaNeueLTStd-Blk",
"HelveticaNeueLTStd-XBlk",
"Foo-XBd",
"Foo-XBdIt",
"Foo-Sb",
"Foo-SBd",
"Foo-Smbd",
"ITCFranklinGothicStd-Dm",
"ITCFranklinGothicStd-DmCd",
"HiraKakuProN-W6",
"KozMinPr6N-W9",
"Open Sans Extra Bold",
"Foo-Semi-Bold",
] {
assert!(is_bold_font(name), "{name}");
}
for name in [
"Bookman",
"BookAntiqua",
"Frutiger-LT",
"Frutiger LT Std",
"HelveticaNeueLTStd-Lt",
"HelveticaNeueLTStd-UltLt",
"HelveticaNeueLTStd-Md",
"Montserrat-ExtraLight",
"Foo-Semi",
"Hvar",
"Foo-Hvar",
"THSarabunNew",
"HiraKakuProN-W3",
"Roboto-Regular",
"Wingdings",
] {
assert!(!is_bold_font(name), "{name}");
}
}
#[test]
fn bold_font_urw_medi_abbreviation() {
assert!(is_bold_font("Arial-Medium"));
assert_eq!(font_weight_from_name("Arial-Medium"), Some(500));
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 presentation_forms_pass_through_expansion() {
let input = "\u{FEE1}\u{FEF3}";
assert_eq!(expand_ligatures(input), input);
let input = "\u{0645}\u{0631}\u{062D}\u{0628}\u{0627}"; assert_eq!(expand_ligatures(input), input);
}
#[test]
fn latin_text_unaffected() {
assert_eq!(expand_ligatures("Hello World"), "Hello World");
}
#[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{FEDF}\u{FEE0}"));
assert!(is_visual_rtl_candidate("\u{05E9}"));
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}"));
assert!(!is_visual_rtl_candidate("\u{0663}\u{0665}\u{066B}\u{0660}"));
assert!(!is_visual_rtl_candidate("\u{05B4}"));
assert!(!is_visual_rtl_candidate("\u{064E}\u{0651}"));
assert!(!is_visual_rtl_candidate("\u{FEFF}"));
}
#[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()
));
}
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,
bold_source: None,
fixed_pitch: None,
fill_color: None,
stroke_color: None,
render_mode: 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 rightward_emission_reads_as_visual_storage() {
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), ];
assert!(fix_visual_order_rtl(&mut items, &[0, 1], 0, 0, &[]));
assert_eq!(items[0].text, "\u{05DD}\u{05DC}\u{05D5}\u{05E2}");
}
#[test]
fn leftward_emission_reads_as_logical_storage() {
let mut items = vec![
make_rtl_item("\u{05E9}\u{05DC}\u{05D5}\u{05DD}", 160.0, 700.0),
make_rtl_item("\u{05E2}\u{05D5}\u{05DC}\u{05DD}", 100.0, 700.0),
];
assert!(!fix_visual_order_rtl(&mut items, &[0, 1], 0, 0, &[]));
}
#[test]
fn visible_runs_painted_forwards_outvote_a_leftward_walk() {
let mut items = vec![
make_rtl_item("\u{05DD}\u{05D5}\u{05DC}\u{05E9}", 160.0, 700.0), make_rtl_item("\u{05DD}\u{05DC}\u{05D5}\u{05E2}", 100.0, 700.0), ];
assert!(fix_visual_order_rtl(&mut items, &[0, 1], 0, 2, &[]));
assert!(!fix_visual_order_rtl(&mut items, &[0, 1], 0, 0, &[]));
}
#[test]
fn a_visual_rtl_run_holds_two_letters() {
assert!(is_visual_rtl_run("\u{05E9}\u{05DC}"));
assert!(is_visual_rtl_run("see \u{05E9}\u{05DC}\u{05D5}\u{05DD} 12"));
assert!(!is_visual_rtl_run("\u{05E9}"));
assert!(!is_visual_rtl_run("\u{0661}\u{0662}"));
assert!(!is_visual_rtl_run("\u{05E9}\u{05B0}"));
assert!(render_mode_paints(0) && render_mode_paints(2) && render_mode_paints(4));
assert!(!render_mode_paints(3) && !render_mode_paints(7));
assert!(white_fill_hides(0, true) && white_fill_hides(4, true));
assert!(white_fill_hides(7, true) && !white_fill_hides(7, false));
assert!(!white_fill_hides(1, true) && !white_fill_hides(2, true));
assert!(!white_fill_hides(5, true) && !white_fill_hides(0, false));
}
#[test]
fn single_run_defaults_to_visual_storage() {
let mut items = vec![make_rtl_item(
"\u{05DD}\u{05D5}\u{05DC}\u{05E9}",
100.0,
700.0,
)];
assert!(fix_visual_order_rtl(&mut items, &[0], 0, 0, &[]));
assert!(!fix_visual_order_rtl(&mut items, &[], 0, 0, &[]));
}
#[test]
fn logical_ops_outvote_rightward_emission() {
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),
];
assert!(!fix_visual_order_rtl(&mut items, &[0, 1], 2, 0, &[]));
}
#[test]
fn cross_line_pairs_carry_no_vote() {
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),
];
assert!(fix_visual_order_rtl(&mut items, &[0, 1], 0, 0, &[]));
}
#[test]
fn single_glyph_ops_vote_too() {
assert!(is_visual_rtl_candidate("\u{05E9}"));
let mut items = vec![
make_rtl_item("\u{05DD}", 100.0, 700.0),
make_rtl_item("\u{05D5}", 106.0, 700.0),
make_rtl_item("\u{05DC}", 112.0, 700.0),
make_rtl_item("\u{05E9}", 118.0, 700.0),
];
assert!(fix_visual_order_rtl(&mut items, &[0, 1, 2, 3], 0, 0, &[]));
items.reverse();
assert!(!fix_visual_order_rtl(&mut items, &[0, 1, 2, 3], 0, 0, &[]));
}
#[test]
fn shaped_runs_on_a_logical_order_page_are_read_back_one_by_one() {
let mut items = vec![
make_rtl_item("\u{FEE6}\u{FEFB}", 160.0, 700.0), make_rtl_item("\u{FEF3}\u{FEE1}", 100.0, 700.0), ];
assert!(!fix_visual_order_rtl(&mut items, &[0, 1], 0, 0, &[]));
assert_eq!(items[0].text, "\u{FEFB}\u{FEE6}");
assert_eq!(items[1].text, "\u{FEE1}\u{FEF3}");
}
#[test]
fn actual_text_is_turned_into_display_order_on_visual_pages() {
let mut items = vec![
make_rtl_item("\u{05DD}\u{05DC}\u{05D5}\u{05E2}", 100.0, 700.0),
make_rtl_item("\u{05E9}\u{05DC}\u{05D5}\u{05DD} 12", 160.0, 700.0),
];
assert!(fix_visual_order_rtl(&mut items, &[0], 0, 0, &[1]));
assert_eq!(items[1].text, "12 \u{05DD}\u{05D5}\u{05DC}\u{05E9}");
}
#[test]
fn sort_line_items_reads_embedded_latin_forwards() {
let mut items = vec![
make_rtl_item("\u{05D1}", 100.0, 700.0),
make_rtl_item("Financial", 120.0, 700.0),
make_rtl_item("Stability", 180.0, 700.0),
make_rtl_item("Board", 240.0, 700.0),
make_rtl_item("\u{05E9}\u{05DC}", 290.0, 700.0),
];
items.reverse();
sort_line_items(&mut items, true);
let texts: Vec<&str> = items.iter().map(|i| i.text.as_str()).collect();
assert_eq!(
texts,
[
"\u{05E9}\u{05DC}",
"Financial",
"Stability",
"Board",
"\u{05D1}"
]
);
sort_line_items(&mut items, false);
let texts: Vec<&str> = items.iter().map(|i| i.text.as_str()).collect();
assert_eq!(
texts,
[
"\u{05D1}",
"Financial",
"Stability",
"Board",
"\u{05E9}\u{05DC}"
]
);
}
#[test]
fn sort_line_items_takes_the_page_direction_for_latin_led_lines() {
let mut items = vec![
make_rtl_item("\u{05D4}\u{05D9}\u{05D0}", 100.0, 700.0),
make_rtl_item("IBM", 140.0, 700.0),
];
sort_line_items(&mut items, false);
assert_eq!(items[0].text, "\u{05D4}\u{05D9}\u{05D0}");
sort_line_items(&mut items, true);
assert_eq!(items[0].text, "IBM");
assert_eq!(items[1].text, "\u{05D4}\u{05D9}\u{05D0}");
}
#[test]
fn sort_rtl_cell_items_respects_lines_and_jitter() {
let mut items = vec![
make_rtl_item("\u{05D5}\u{05DD}", 100.0, 688.0),
make_rtl_item("CD", 160.0, 688.9),
make_rtl_item("AB", 100.0, 700.0),
make_rtl_item("\u{05E9}\u{05DC}", 160.0, 700.0),
];
sort_rtl_cell_items(&mut items, |item| item);
let texts: Vec<&str> = items.iter().map(|i| i.text.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"
);
}
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,
bold_source: None,
fixed_pitch: None,
fill_color: None,
stroke_color: None,
render_mode: 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,
bold_source: None,
fixed_pitch: None,
fill_color: None,
stroke_color: None,
render_mode: 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,
bold_source: None,
fixed_pitch: None,
fill_color: None,
stroke_color: None,
render_mode: 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,
bold_source: None,
fixed_pitch: None,
fill_color: None,
stroke_color: None,
render_mode: 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, false);
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, false);
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, false);
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, false);
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)
);
}
#[test]
fn pdf_text_strings_decode_from_pdfdocencoding() {
assert_eq!(decode_pdf_text_string(b"Annual Report"), "Annual Report");
assert_eq!(decode_pdf_text_string(b"Caf\xE9 \xA0 5"), "Café € 5");
assert_eq!(
decode_pdf_text_string(b"\x8Dq\x8E \x84 \x80 \x92 \x93 \x97 \x9E"),
"\u{201C}q\u{201D} \u{2014} \u{2022} \u{2122} \u{FB01} \u{0160} \u{017E}"
);
assert_eq!(decode_pdf_text_string(b"\x18\x1F"), "\u{02D8}\u{02DC}");
assert_eq!(
decode_pdf_text_string(b"\x7F\x9F\xAD"),
"\u{7F}\u{9F}\u{AD}"
);
}
#[test]
fn pdf_text_strings_decode_from_unicode_with_a_byte_order_mark() {
let mut utf16 = vec![0xFE, 0xFF];
for unit in "Größe 日本 🙂".encode_utf16() {
utf16.extend_from_slice(&unit.to_be_bytes());
}
assert_eq!(decode_pdf_text_string(&utf16), "Größe 日本 🙂");
assert_eq!(decode_pdf_text_string(b"\xFE\xFF\x00A\x00"), "A");
assert_eq!(
decode_pdf_text_string(b"\xFE\xFF\xD8\x00\x00A"),
"\u{FFFD}A"
);
assert_eq!(decode_pdf_text_string(b"\xFF\xFEA\x00\xE9\x00"), "Aé");
assert_eq!(decode_pdf_text_string("\u{FEFF}Größe".as_bytes()), "Größe");
let mut tagged = vec![0xFE, 0xFF, 0x00, 0x1B, b'e', b'n', 0x00, 0x1B];
tagged.extend_from_slice(&[0x00, b'H', 0x00, b'i']);
tagged.extend_from_slice(&[0x00, 0x1B, b'd', b'e', b'D', b'E', 0x00, 0x1B]);
tagged.extend_from_slice(&[0x00, b'!']);
assert_eq!(decode_pdf_text_string(&tagged), "Hi!");
assert_eq!(decode_pdf_text_string(b"\xFE\xFF\x00\x1B\x00A"), "\u{1B}A");
}
#[test]
fn pdf_text_strings_written_as_utf8_without_a_mark_read_as_utf8() {
assert_eq!(
decode_pdf_text_string("Größe – ≥ 5".as_bytes()),
"Größe – ≥ 5"
);
assert_eq!(decode_pdf_text_string(b"Gr\xF6\xDFe"), "Größe");
assert_eq!(decode_pdf_text_string(b"Title\0\0"), "Title");
assert_eq!(decode_pdf_text_string(b"\xFE\xFF\x00A\x00\x00"), "A");
assert_eq!(decode_pdf_text_string(b""), "");
}
}