use crate::pdf::hierarchy::SegmentData;
const SEGMENT_GAP_SPACE_RATIO: f32 = 0.15;
pub(super) fn is_cjk_char(c: char) -> bool {
let cp = c as u32;
matches!(cp,
0x4E00..=0x9FFF
| 0x3040..=0x309F
| 0x30A0..=0x30FF
| 0xAC00..=0xD7AF
| 0x3400..=0x4DBF
| 0xF900..=0xFAFF
| 0x20000..=0x2A6DF
| 0x2A700..=0x2B73F
| 0x2B740..=0x2B81F
| 0x2B820..=0x2CEAF
| 0x2CEB0..=0x2EBEF
| 0x30000..=0x3134F
| 0x31350..=0x323AF
| 0x2F800..=0x2FA1F
)
}
pub(super) fn needs_space_between(prev: &str, next: &str) -> bool {
let prev_ends_cjk = prev.chars().last().is_some_and(is_cjk_char);
let next_starts_cjk = next.chars().next().is_some_and(is_cjk_char);
!(prev_ends_cjk && next_starts_cjk)
}
pub(super) fn segments_need_space(
prev_seg: &SegmentData,
prev_word: &str,
next_seg: &SegmentData,
next_word: &str,
) -> bool {
if !needs_space_between(prev_word, next_word) {
return false;
}
let has_explicit_boundary_space = prev_seg.text.chars().last().is_some_and(char::is_whitespace)
|| next_seg.text.chars().next().is_some_and(char::is_whitespace);
if has_explicit_boundary_space {
return true;
}
if prev_seg.is_bold != next_seg.is_bold
|| prev_seg.is_italic != next_seg.is_italic
|| prev_seg.is_monospace != next_seg.is_monospace
{
return true;
}
let eff_height = next_seg.height.max(prev_seg.height).max(next_seg.font_size * 0.5);
let same_line = (prev_seg.baseline_y - next_seg.baseline_y).abs() < eff_height * 0.5;
if !same_line {
return true;
}
let prev_end_x = prev_seg.x + prev_seg.width;
let x_gap = next_seg.x - prev_end_x;
x_gap > next_seg.font_size * SEGMENT_GAP_SPACE_RATIO
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_cjk_char_basic() {
assert!(is_cjk_char('\u{4E00}'));
assert!(is_cjk_char('\u{3042}'));
assert!(is_cjk_char('\u{30A2}'));
assert!(!is_cjk_char('A'));
assert!(!is_cjk_char(' '));
}
#[test]
fn test_needs_space_between() {
assert!(needs_space_between("hello", "world"));
assert!(!needs_space_between("\u{4E00}", "\u{4E01}"));
assert!(needs_space_between("hello", "\u{4E00}"));
assert!(needs_space_between("\u{4E00}", "hello"));
}
fn segment(text: &str, x: f32, width: f32, font_size: f32, baseline_y: f32) -> SegmentData {
SegmentData {
text: text.to_string(),
x,
y: baseline_y,
width,
height: font_size,
font_size,
is_bold: false,
is_italic: false,
is_monospace: false,
baseline_y,
assigned_role: None,
}
}
#[test]
fn test_segments_need_space_kerning_split_stays_joined() {
let prev = segment("eli", 100.0, 15.0, 10.0, 700.0);
let next = segment("t", 115.0, 5.0, 10.0, 700.0);
assert!(!segments_need_space(&prev, "eli", &next, "t"));
}
#[test]
fn test_segments_need_space_explicit_whitespace_overrides_negative_overlap() {
let prev = segment("infos. ", 100.0, 35.0, 10.0, 700.0);
let next = segment("MongoKit", 133.5, 40.0, 10.0, 700.0);
assert!(segments_need_space(&prev, "infos.", &next, "MongoKit"));
}
#[test]
fn test_segments_need_space_negative_overlap_without_whitespace_stays_joined() {
let prev = segment("Mongo", 100.0, 30.0, 10.0, 700.0);
let next = segment("Kit", 129.0, 15.0, 10.0, 700.0);
assert!(!segments_need_space(&prev, "Mongo", &next, "Kit"));
}
#[test]
fn test_segments_need_space_distinct_words_insert_space() {
let prev = segment("office", 100.0, 30.0, 10.0, 700.0);
let next = segment("is", 140.0, 8.0, 10.0, 700.0);
assert!(segments_need_space(&prev, "office", &next, "is"));
}
#[test]
fn test_segments_need_space_two_point_word_gap_inserts_space() {
let prev = segment("MongoKit", 100.0, 40.0, 10.0, 700.0);
let next = segment("is", 142.0, 8.0, 10.0, 700.0);
assert!(segments_need_space(&prev, "MongoKit", &next, "is"));
}
#[test]
fn test_segments_need_space_style_change_inserts_space() {
let prev = segment("plain", 10.0, 20.0, 20.0, 100.0);
let next = {
let mut s = segment("bold", 31.0, 20.0, 20.0, 100.0);
s.is_bold = true;
s
};
assert!(segments_need_space(&prev, "plain", &next, "bold"));
}
#[test]
fn test_segments_need_space_tower_kerning_split_joins() {
let prev = segment("T", 100.0, 7.0, 10.0, 700.0);
let next = segment("ower", 106.0, 22.0, 10.0, 700.0);
assert!(!segments_need_space(&prev, "T", &next, "ower"));
}
#[test]
fn test_segments_need_space_positive_kerning_gap_stays_joined() {
let prev = segment("T", 100.0, 7.0, 10.0, 700.0);
let below_threshold = segment("ower", 108.0, 22.0, 10.0, 700.0);
let at_threshold = segment("ower", 108.5, 22.0, 10.0, 700.0);
let above_threshold = segment("ower", 108.6, 22.0, 10.0, 700.0);
assert!(!segments_need_space(&prev, "T", &below_threshold, "ower"));
assert!(!segments_need_space(&prev, "T", &at_threshold, "ower"));
assert!(segments_need_space(&prev, "T", &above_threshold, "ower"));
}
#[test]
fn test_segments_need_space_different_line_always_spaces() {
let prev = segment("end", 500.0, 20.0, 10.0, 700.0);
let next = segment("start", 40.0, 30.0, 10.0, 685.0);
assert!(segments_need_space(&prev, "end", &next, "start"));
}
#[test]
fn test_segments_need_space_cjk_adjacent_never_spaces() {
let prev = segment("\u{4E00} ", 100.0, 12.0, 12.0, 700.0);
let next = segment("\u{4E01}", 112.0, 12.0, 12.0, 700.0);
assert!(!segments_need_space(&prev, "\u{4E00}", &next, "\u{4E01}"));
}
}