mod html;
mod markdown;
mod plain_text;
pub mod toc_detector;
pub use html::HtmlOutputConverter;
pub use markdown::MarkdownOutputConverter;
pub use plain_text::PlainTextConverter;
pub use toc_detector::{TocDetector, TocEntry};
use crate::error::Result;
use crate::layout::TextSpan;
use crate::pipeline::{OrderedTextSpan, StructRole, TextPipelineConfig};
use crate::structure::table_extractor::Table;
const BULLET_CHARS: &[char] = &[
'►', '•', '▪', '▸', '‣', '◦', '●', '■', '◆', '○', '□', '❍', '❖', '✓', '✔', '➢', '➤', '\x7f',
];
pub(crate) fn is_bullet_span(text: &str) -> bool {
let t = text.trim();
let mut chars = t.chars();
matches!((chars.next(), chars.next()), (Some(c), None) if BULLET_CHARS.contains(&c))
}
pub(crate) fn starts_with_bullet(text: &str) -> bool {
text.trim_start()
.chars()
.next()
.is_some_and(|c| BULLET_CHARS.contains(&c))
}
pub(crate) fn is_ordered_list_marker(text: &str) -> Option<u32> {
let t = text.trim_start();
let bytes = t.as_bytes();
if bytes.is_empty() {
return None;
}
let mut idx = 0;
while idx < bytes.len() && bytes[idx].is_ascii_digit() && idx < 3 {
idx += 1;
}
let numeric_n = if idx > 0 {
std::str::from_utf8(&bytes[..idx])
.ok()
.and_then(|s| s.parse::<u32>().ok())
} else {
None
};
if idx == 0 && bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() {
let mut roman_end = 0;
while roman_end < bytes.len().min(4)
&& matches!(bytes[roman_end], b'i' | b'v' | b'x' | b'I' | b'V' | b'X')
{
roman_end += 1;
}
if roman_end >= 1 && bytes.len() > roman_end {
let punct = bytes[roman_end];
if matches!(punct, b'.' | b')') && bytes.get(roman_end + 1).copied() == Some(b' ') {
return Some(1);
}
}
if bytes.len() >= 3 && matches!(bytes[1], b'.' | b')') && bytes[2] == b' ' {
return Some(1);
}
return None;
}
if idx > 0 && bytes.len() > idx {
let punct = bytes[idx];
if matches!(punct, b'.' | b')') && bytes.get(idx + 1).copied() == Some(b' ') {
return numeric_n;
}
}
None
}
pub(crate) fn base_heading_font_size(spans: &[&OrderedTextSpan], detect_headings: bool) -> f32 {
if !detect_headings {
return 12.0;
}
let mut size_counts: std::collections::HashMap<u32, usize> = std::collections::HashMap::new();
for s in spans {
let sz = s.span.font_size;
if sz < 9.0 {
continue;
}
*size_counts.entry((sz * 2.0).round() as u32).or_insert(0) += 1;
}
size_counts
.into_iter()
.max_by(|a, b| a.1.cmp(&b.1).then_with(|| b.0.cmp(&a.0)))
.map(|(bucket, _)| bucket as f32 / 2.0)
.unwrap_or(12.0)
.min(12.0)
}
pub(crate) fn is_safe_link_uri(uri: &str) -> bool {
let lower = uri.trim_start().to_ascii_lowercase();
[
"http://", "https://", "mailto:", "tel:", "ftp://", "ftps://",
]
.iter()
.any(|scheme| lower.starts_with(scheme))
}
pub(crate) fn is_list_item_role(role: Option<StructRole>) -> bool {
matches!(
role,
Some(StructRole::ListItem | StructRole::ListItemLabel | StructRole::ListItemBody)
)
}
pub trait OutputConverter: Send + Sync {
fn convert(&self, spans: &[OrderedTextSpan], config: &TextPipelineConfig) -> Result<String>;
fn convert_with_tables(
&self,
spans: &[OrderedTextSpan],
tables: &[Table],
config: &TextPipelineConfig,
) -> Result<String> {
let _ = tables;
self.convert(spans, config)
}
fn name(&self) -> &'static str;
fn mime_type(&self) -> &'static str;
}
fn is_cjk_char(c: char) -> bool {
matches!(c,
'\u{3040}'..='\u{309F}' | '\u{30A0}'..='\u{30FF}' | '\u{4E00}'..='\u{9FFF}' | '\u{AC00}'..='\u{D7AF}' | '\u{3400}'..='\u{4DBF}' | '\u{20000}'..='\u{2A6DF}' )
}
fn is_fullwidth_or_math_op(c: char) -> bool {
matches!(c,
'\u{FF0B}' | '\u{FF0D}' | '\u{FF1A}' | '\u{FF1B}' | '\u{FF1C}'..='\u{FF1E}' | '\u{2260}' | '\u{2248}' | '\u{2264}'..='\u{2265}' | '\u{00B5}' | '\u{03BC}' | '\u{00B1}' | '\u{00D7}' | '\u{00F7}' )
}
fn is_rtl_char(c: char) -> bool {
matches!(c as u32,
0x0590..=0x05FF | 0x0600..=0x06FF | 0x0700..=0x074F | 0x0750..=0x077F | 0x0780..=0x07BF | 0x07C0..=0x07FF | 0x08A0..=0x08FF | 0xFB1D..=0xFDFF | 0xFE70..=0xFEFF )
}
pub(crate) fn is_reference_marker_boundary(prev: &TextSpan, current: &TextSpan) -> bool {
if (prev.rotation_degrees - current.rotation_degrees).abs() > 0.5
|| has_horizontal_gap(prev, current)
{
return false;
}
if prev.is_italic || current.is_italic {
return false;
}
if prev.font_name == current.font_name
|| current.font_size >= prev.font_size * 0.85
|| current.font_size <= 0.0
{
return false;
}
let em = prev.font_size.max(current.font_size).max(1.0);
let gap = current.bbox.x - (prev.bbox.x + prev.bbox.width);
if gap <= 0.5 || gap >= em * 3.0 {
return false;
}
let base = prev.text.trim_end();
let tail: String = base
.chars()
.rev()
.take_while(|c| c.is_alphabetic())
.collect();
if tail.chars().count() < 3 || !base.ends_with(|c: char| c.is_ascii_lowercase()) {
return false;
}
let head: String = current
.text
.trim_start()
.chars()
.take_while(|c| {
c.is_ascii_digit() || matches!(c, ',' | '-' | '\u{2013}' | '\u{2014}' | '\u{2212}')
})
.collect();
!head.is_empty()
&& head.chars().next().is_some_and(|c| c.is_ascii_digit())
&& head.chars().any(|c| c.is_ascii_digit())
}
pub(crate) fn reads_as_a_sentence_fragment(text: &str) -> bool {
let trimmed = text.trim();
if trimmed.ends_with('.') {
if let Some(first) = trimmed.chars().find(|c| c.is_alphabetic()) {
if first.is_lowercase() {
return true;
}
}
}
let words: Vec<&str> = trimmed.split_whitespace().collect();
if words.len() < 3 {
return false;
}
let last: String = words[words.len() - 1]
.chars()
.filter(|c| c.is_alphabetic())
.flat_map(|c| c.to_lowercase())
.collect();
matches!(
last.as_str(),
"a" | "an"
| "the"
| "of"
| "in"
| "on"
| "at"
| "to"
| "for"
| "with"
| "from"
| "by"
| "into"
| "onto"
| "upon"
| "over"
| "under"
| "between"
| "among"
| "through"
| "during"
| "against"
| "about"
| "than"
| "without"
| "within"
| "and"
| "or"
| "but"
| "nor"
| "that"
| "which"
)
}
fn is_strong_ltr_char(c: char) -> bool {
c.is_alphabetic() && !is_rtl_char(c)
}
pub(crate) fn has_horizontal_gap(prev: &TextSpan, current: &TextSpan) -> bool {
if (prev.rotation_degrees - current.rotation_degrees).abs() > 0.5 {
return true;
}
let font_size = prev.font_size.max(current.font_size).max(1.0);
let prev_end_x = prev.bbox.x + prev.bbox.width;
let gap = current.bbox.x - prev_end_x;
let threshold = font_size * 0.15;
let backward_em = prev.font_size.max(current.font_size).max(6.0) * 20.0;
let steps_backward = (current.bbox.x + current.bbox.width <= prev.bbox.x || gap < -backward_em)
&& !prev.text.chars().any(is_rtl_char)
&& !current.text.chars().any(is_rtl_char)
&& (prev.text.chars().any(is_strong_ltr_char)
|| current.text.chars().any(is_strong_ltr_char));
if gap <= threshold && !steps_backward {
return false;
}
let prev_last = prev.text.chars().next_back();
let curr_first = current.text.chars().next();
if let (Some(p), Some(c)) = (prev_last, curr_first) {
let p_cjk = is_cjk_char(p);
let c_cjk = is_cjk_char(c);
if (p_cjk || is_fullwidth_or_math_op(p)) && (c_cjk || is_fullwidth_or_math_op(c)) {
if p_cjk || c_cjk {
return false;
}
}
}
true
}
pub(crate) fn spans_are_stacked(prev: &TextSpan, current: &TextSpan) -> bool {
let font_size = prev.font_size.max(current.font_size).max(1.0);
(current.bbox.y - prev.bbox.y).abs() >= font_size * 0.5
}
pub(crate) fn span_in_table(span: &OrderedTextSpan, tables: &[Table]) -> Option<usize> {
let sx = span.span.bbox.x;
let sy = span.span.bbox.y;
let mut undecided: Vec<usize> = Vec::with_capacity(tables.len());
for (i, table) in tables.iter().enumerate() {
let table_has_mcids = table
.rows
.iter()
.any(|r| r.cells.iter().any(|c| !c.mcids.is_empty()));
if table_has_mcids {
if let Some(mcid) = span.span.mcid {
if table
.rows
.iter()
.any(|r| r.cells.iter().any(|c| c.mcids.contains(&mcid)))
{
return Some(i);
}
continue;
}
}
let table_has_spans = table
.rows
.iter()
.any(|r| r.cells.iter().any(|c| !c.spans.is_empty()));
if table_has_spans {
let sw = span.span.bbox.width;
let s_end = sx + sw;
let band = span.span.font_size.max(1.0) * 0.5;
let mut covered = 0.0f32;
for row in &table.rows {
for cell in &row.cells {
for member in &cell.spans {
if (member.bbox.y - sy).abs() > band {
continue;
}
let m_end = member.bbox.x + member.bbox.width;
let lo = sx.max(member.bbox.x);
let hi = s_end.min(m_end);
if hi > lo {
covered += hi - lo;
}
}
}
}
if sw > 0.0 && covered >= sw * 0.5 {
return Some(i);
}
continue;
}
undecided.push(i);
}
for i in undecided {
let table = &tables[i];
let Some(ref bbox) = table.bbox else { continue };
let tolerance = 2.0;
let in_outer_bbox = sx >= bbox.x - tolerance
&& sx <= bbox.x + bbox.width + tolerance
&& sy >= bbox.y - tolerance
&& sy <= bbox.y + bbox.height + tolerance;
if !in_outer_bbox {
continue;
}
let has_any_cell_bbox = table
.rows
.iter()
.any(|row| row.cells.iter().any(|c| c.bbox.is_some()));
if !has_any_cell_bbox {
return Some(i);
}
let span_owned = table.rows.iter().any(|row| {
row.cells.iter().any(|cell| {
let Some(cb) = cell.bbox else { return false };
sx >= cb.x - tolerance
&& sx <= cb.x + cb.width + tolerance
&& sy >= cb.y - tolerance
&& sy <= cb.y + cb.height + tolerance
})
});
if span_owned {
return Some(i);
}
}
None
}
pub(crate) fn merge_key_value_pairs(text: &str) -> String {
let lines: Vec<&str> = text.lines().collect();
if lines.len() < 2 {
return text.to_string();
}
fn is_value_line(line: &str) -> bool {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.len() > 30 {
return false;
}
if is_ordered_list_marker(trimmed).is_some() || starts_with_bullet(trimmed) {
return false;
}
let mut chars = trimmed.chars();
let first = chars.next().unwrap();
match first {
'0'..='9' | '$' | '€' | '£' | '¥' | '(' => true,
'-' | '.' => !matches!(chars.next(), Some(' ') | None),
_ => false,
}
}
fn is_label_line(line: &str) -> bool {
let trimmed = line.trim();
if trimmed.is_empty() {
return false;
}
if is_value_line(line) {
return false;
}
let last = trimmed.chars().next_back().unwrap();
last.is_alphanumeric() || last == ')' || last == ':'
}
let mut result = String::with_capacity(text.len());
let mut i = 0;
while i < lines.len() {
if i + 1 < lines.len() && is_label_line(lines[i]) && is_value_line(lines[i + 1]) {
result.push_str(lines[i].trim_end());
result.push(' ');
result.push_str(lines[i + 1].trim_start());
result.push('\n');
i += 2;
}
else if i + 2 < lines.len()
&& is_label_line(lines[i])
&& lines[i + 1].trim().is_empty()
&& is_value_line(lines[i + 2])
{
result.push_str(lines[i].trim_end());
result.push(' ');
result.push_str(lines[i + 2].trim_start());
result.push('\n');
i += 3;
} else {
result.push_str(lines[i]);
result.push('\n');
i += 1;
}
}
let orig_trailing_newlines = text.chars().rev().take_while(|&c| c == '\n').count();
while result.ends_with('\n') {
result.pop();
}
for _ in 0..orig_trailing_newlines {
result.push('\n');
}
result
}
pub fn create_converter(format: &str) -> Option<Box<dyn OutputConverter>> {
match format.to_lowercase().as_str() {
"markdown" | "md" => Some(Box::new(MarkdownOutputConverter::new())),
"html" => Some(Box::new(HtmlOutputConverter::new())),
"text" | "plain" | "txt" => Some(Box::new(PlainTextConverter::new())),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_create_converter_markdown() {
let converter = create_converter("markdown").unwrap();
assert_eq!(converter.name(), "MarkdownOutputConverter");
assert_eq!(converter.mime_type(), "text/markdown");
}
#[test]
fn test_create_converter_html() {
let converter = create_converter("html").unwrap();
assert_eq!(converter.name(), "HtmlOutputConverter");
assert_eq!(converter.mime_type(), "text/html");
}
#[test]
fn test_create_converter_text() {
let converter = create_converter("text").unwrap();
assert_eq!(converter.name(), "PlainTextConverter");
assert_eq!(converter.mime_type(), "text/plain");
}
#[test]
fn test_create_converter_unknown() {
assert!(create_converter("unknown").is_none());
}
#[test]
fn test_key_value_pair_merging_basic() {
let input = "Grand Total\n$750.00\nNet Amount\n$250.00\n";
let expected = "Grand Total $750.00\nNet Amount $250.00\n";
assert_eq!(merge_key_value_pairs(input), expected);
}
#[test]
fn test_key_value_pair_merging_no_false_positive_on_sentences() {
let input = "This is a sentence.\n$100.00\n";
assert_eq!(merge_key_value_pairs(input), input);
}
#[test]
fn test_key_value_pair_merging_negative_numbers() {
let input = "Balance Due\n-$42.50\n";
let expected = "Balance Due -$42.50\n";
assert_eq!(merge_key_value_pairs(input), expected);
}
#[test]
fn test_key_value_pair_merging_plain_numbers() {
let input = "Account Number\n434508032\n";
let expected = "Account Number 434508032\n";
assert_eq!(merge_key_value_pairs(input), expected);
}
#[test]
fn test_is_safe_link_uri_allows_navigable_and_rejects_active_content() {
for ok in [
"https://example.com",
"http://x",
"mailto:a@b.com",
"tel:+15551234",
"FTP://h",
] {
assert!(is_safe_link_uri(ok), "{ok} should be allowed");
}
for bad in [
"javascript:alert(1)",
" javascript:alert(1)",
"JavaScript:alert(1)",
"data:text/html,<script>",
"vbscript:msgbox",
"file:///etc/passwd",
"relative/path",
"",
] {
assert!(!is_safe_link_uri(bad), "{bad} should be rejected");
}
}
#[test]
fn test_key_value_merge_does_not_glue_list_items_to_headings() {
let bullet = "## Highlights\n- Revenue grew steadily.\n";
assert_eq!(merge_key_value_pairs(bullet), bullet, "bullet item glued");
let ordered = "## Next Steps\n1. Finalize the budget.\n";
assert_eq!(merge_key_value_pairs(ordered), ordered, "ordered item glued");
}
#[test]
fn test_key_value_pair_merging_skips_long_values() {
let input = "Introduction\nThis is a full paragraph of text that continues.\n";
assert_eq!(merge_key_value_pairs(input), input);
}
#[test]
fn test_key_value_pair_merging_preserves_blank_lines() {
let input = "Section A\n\nTotal\n$100\n";
let expected = "Section A\n\nTotal $100\n";
assert_eq!(merge_key_value_pairs(input), expected);
}
#[test]
fn test_key_value_pair_merging_consecutive_pairs() {
let input = "Subtotal\n$200.00\nTax\n$18.00\nTotal\n$218.00\n";
let expected = "Subtotal $200.00\nTax $18.00\nTotal $218.00\n";
assert_eq!(merge_key_value_pairs(input), expected);
}
#[test]
fn test_key_value_pair_merging_euro_and_pound() {
let input = "Price\n€49.99\nShipping\n£5.00\n";
let expected = "Price €49.99\nShipping £5.00\n";
assert_eq!(merge_key_value_pairs(input), expected);
}
#[test]
fn test_key_value_pair_merging_parenthesized_negative() {
let input = "Net Loss\n(1,234.56)\n";
let expected = "Net Loss (1,234.56)\n";
assert_eq!(merge_key_value_pairs(input), expected);
}
#[test]
fn test_key_value_pair_merging_no_merge_value_value() {
let input = "$100\n$200\n";
assert_eq!(merge_key_value_pairs(input), input);
}
#[test]
fn test_key_value_pair_merging_empty_input() {
assert_eq!(merge_key_value_pairs(""), "");
assert_eq!(merge_key_value_pairs("single line\n"), "single line\n");
}
fn make_span(x: f32, w: f32, text: &str) -> crate::layout::TextSpan {
crate::layout::TextSpan {
text: text.to_string(),
bbox: crate::geometry::Rect::new(x, 0.0, w, 10.0),
font_size: 10.0,
..Default::default()
}
}
#[test]
fn test_heading_in_another_script_is_untouched() {
for heading in [
"\u{7b2c}\u{4e00}\u{7ae0}",
"\u{627}\u{644}\u{645}\u{642}\u{62f}\u{645}\u{629}",
"\u{41f}\u{440}\u{435}\u{434}\u{438}\u{441}\u{43b}\u{43e}\u{432}\u{438}\u{435}",
"\u{7d50}\u{8ad6}\u{3068}\u{8003}\u{5bdf}",
] {
assert!(
!reads_as_a_sentence_fragment(heading),
"{heading:?} must not be rejected by an English-shaped rule"
);
}
}
fn rtl_span(x: f32, w: f32, fs: f32, text: &str) -> crate::layout::TextSpan {
crate::layout::TextSpan {
text: text.to_string(),
bbox: crate::geometry::Rect::new(x, 0.0, w, fs),
font_size: fs,
..Default::default()
}
}
#[test]
fn arabic_glyphs_stepping_leftward_are_not_separated() {
let prev = rtl_span(497.37, 0.0, 22.0, "\u{629}");
let curr = rtl_span(386.46, 0.0, 22.0, "\u{632}");
assert!(
!has_horizontal_gap(&prev, &curr),
"an RTL continuation is not a reading discontinuity"
);
}
#[test]
fn hebrew_glyphs_stepping_leftward_are_not_separated() {
let prev = rtl_span(300.0, 0.0, 12.0, "\u{5e9}");
let curr = rtl_span(288.0, 0.0, 12.0, "\u{5dc}");
assert!(!has_horizontal_gap(&prev, &curr), "Hebrew advances right-to-left too");
}
#[test]
fn test_latin_backward_step_is_still_a_discontinuity() {
let prev = rtl_span(300.0, 20.0, 12.0, "the");
let curr = rtl_span(100.0, 12.0, 12.0, "It");
assert!(
has_horizontal_gap(&prev, &curr),
"a Latin run starting 200pt back is a discontinuity"
);
}
#[test]
fn adjacent_latin_kerning_is_still_not_a_gap() {
let prev = rtl_span(100.0, 18.0, 12.0, "Effi");
let curr = rtl_span(118.1, 26.0, 12.0, "ciency");
assert!(!has_horizontal_gap(&prev, &curr), "a sub-em gap is inter-glyph kerning");
}
#[test]
fn test_date_in_a_right_to_left_run_is_not_split_at_its_separators() {
let date = [
(176.54, 11.28, "19"),
(174.02, 2.48, "/"),
(162.74, 11.28, "09"),
(160.22, 2.48, "/"),
(137.90, 22.42, "1403"),
];
for pair in date.windows(2) {
let (px, pw, pt) = pair[0];
let (cx, cw, ct) = pair[1];
let prev = rtl_span(px, pw, 12.0, pt);
let curr = rtl_span(cx, cw, 12.0, ct);
assert!(
!has_horizontal_gap(&prev, &curr),
"a date must not gain a break between {pt:?} and {ct:?}"
);
}
}
#[test]
fn test_percentage_in_a_right_to_left_run_keeps_its_sign() {
let prev = rtl_span(202.49, 5.63, 12.0, "5");
let curr = rtl_span(197.21, 5.24, 12.0, "%");
assert!(
!has_horizontal_gap(&prev, &curr),
"a percent sign must not be separated from its number"
);
}
#[test]
fn test_latin_backward_step_with_letters_still_separates() {
let prev = rtl_span(176.54, 11.28, 12.0, "is");
let curr = rtl_span(160.22, 11.28, 12.0, "the");
assert!(
has_horizontal_gap(&prev, &curr),
"a backward step between Latin words is still a discontinuity"
);
}
#[test]
fn test_has_horizontal_gap_cjk_cjk_suppressed() {
let prev = make_span(0.0, 10.0, "数"); let curr = make_span(12.0, 10.0, "学"); assert!(!has_horizontal_gap(&prev, &curr), "CJK→CJK should suppress space insertion");
}
#[test]
fn test_has_horizontal_gap_cjk_fullwidth_suppressed() {
let prev = make_span(0.0, 10.0, "Q"); let prev_cjk = make_span(0.0, 10.0, "量");
let curr = make_span(12.0, 10.0, "<"); assert!(
!has_horizontal_gap(&prev_cjk, &curr),
"CJK→fullwidth-op should suppress space insertion"
);
let _ = prev; }
#[test]
fn test_has_horizontal_gap_fullwidth_cjk_suppressed() {
let prev = make_span(0.0, 10.0, "≤"); let curr = make_span(12.0, 10.0, "Q"); let curr_cjk = make_span(12.0, 10.0, "量");
assert!(
!has_horizontal_gap(&prev, &curr_cjk),
"fullwidth-op→CJK should suppress space insertion"
);
let _ = curr; }
#[test]
fn test_has_horizontal_gap_latin_latin_unchanged() {
let prev = make_span(0.0, 10.0, "hello");
let curr = make_span(12.0, 10.0, "world"); assert!(
has_horizontal_gap(&prev, &curr),
"Latin→Latin with gap > threshold should still insert space"
);
}
#[test]
fn test_has_horizontal_gap_latin_latin_no_gap() {
let prev = make_span(0.0, 10.0, "hello");
let curr = make_span(11.0, 10.0, "world"); assert!(
!has_horizontal_gap(&prev, &curr),
"Latin→Latin below threshold should not insert space"
);
}
#[test]
fn test_has_horizontal_gap_two_pure_math_ops_unchanged() {
let prev = make_span(0.0, 10.0, "≤");
let curr = make_span(12.0, 10.0, "≥"); assert!(
has_horizontal_gap(&prev, &curr),
"math-op→math-op (no CJK) should still apply gap-based logic"
);
}
fn make_table_no_cells(x: f32, y: f32, width: f32, height: f32) -> Table {
let mut t = Table::new();
t.bbox = Some(crate::geometry::Rect::new(x, y, width, height));
t
}
fn make_table_with_cell(
table_bbox: (f32, f32, f32, f32),
cell_bbox: (f32, f32, f32, f32),
) -> Table {
use crate::structure::table_extractor::{TableCell, TableRow};
let mut t = Table::new();
t.bbox = Some(crate::geometry::Rect::new(
table_bbox.0,
table_bbox.1,
table_bbox.2,
table_bbox.3,
));
let mut row = TableRow::new(false);
let mut cell = TableCell::new(String::new(), false);
cell.bbox =
Some(crate::geometry::Rect::new(cell_bbox.0, cell_bbox.1, cell_bbox.2, cell_bbox.3));
row.cells.push(cell);
t.rows.push(row);
t.col_count = 1;
t
}
fn make_ordered_span(x: f32, y: f32) -> crate::pipeline::OrderedTextSpan {
let span = crate::layout::TextSpan {
text: "test".to_string(),
bbox: crate::geometry::Rect::new(x, y, 5.0, 10.0),
font_size: 10.0,
..Default::default()
};
crate::pipeline::OrderedTextSpan::new(span, 0)
}
#[test]
fn span_in_table_no_cells_legacy_passthrough() {
let table = make_table_no_cells(10.0, 50.0, 200.0, 100.0);
let span = make_ordered_span(50.0, 70.0); assert_eq!(
span_in_table(&span, &[table]),
Some(0),
"no-cell Table preserves legacy outer-bbox contract"
);
}
#[test]
fn span_in_table_owned_by_cell() {
let table = make_table_with_cell(
(10.0, 50.0, 200.0, 100.0), (40.0, 60.0, 100.0, 20.0), );
let span = make_ordered_span(50.0, 70.0); assert_eq!(span_in_table(&span, &[table]), Some(0));
}
#[test]
fn span_in_table_outer_bbox_only_returns_none() {
let table = make_table_with_cell(
(10.0, 50.0, 200.0, 100.0), (10.0, 50.0, 50.0, 100.0), );
let span = make_ordered_span(150.0, 70.0);
assert_eq!(
span_in_table(&span, &[table]),
None,
"span outside every cell must NOT be marked in_table — \
paragraph flow needs to pick it up instead of dropping"
);
}
#[test]
fn span_in_table_outside_all_tables() {
let table = make_table_with_cell((10.0, 50.0, 200.0, 100.0), (40.0, 60.0, 100.0, 20.0));
let span = make_ordered_span(500.0, 500.0);
assert_eq!(span_in_table(&span, &[table]), None);
}
fn marker_span(x: f32, w: f32, fs: f32, font: &str, text: &str) -> crate::layout::TextSpan {
crate::layout::TextSpan {
text: text.to_string(),
bbox: crate::geometry::Rect::new(x, 0.0, w, fs),
font_size: fs,
font_name: font.to_string(),
..Default::default()
}
}
#[test]
fn test_footnote_marker_after_a_word_is_a_boundary() {
let word = marker_span(124.60, 189.36, 9.96, "TWJDIY+SFRM1000", "phosphorylation");
let marker = marker_span(314.95, 6.0, 6.97, "MIOKXQ+SFRM0700", "55.");
assert!(
!has_horizontal_gap(&word, &marker),
"precondition: the gap rule must decline this, or the test proves nothing"
);
assert!(
is_reference_marker_boundary(&word, &marker),
"a numeral set smaller after a prose word is a reference callout"
);
}
#[test]
fn test_maths_subscript_is_not_a_boundary() {
for (base, sub) in [
("W", "2"),
("H", "2"),
("ADP", "3"),
("SO", "4"),
("x", "2"),
] {
let b = marker_span(100.0, 8.0, 11.96, "BODY+Font", base);
let s = marker_span(109.63, 4.0, 6.97, "SUB+Font", sub);
assert!(
!is_reference_marker_boundary(&b, &s),
"{base}+{sub} is a subscript on a symbol host and must stay joined"
);
}
}
#[test]
fn test_italic_base_is_not_a_prose_word() {
let mut base = marker_span(100.0, 30.0, 10.0, "BODY+Font", "alpha");
base.is_italic = true;
let marker = marker_span(131.0, 5.0, 6.5, "SUB+Font", "2");
assert!(!is_reference_marker_boundary(&base, &marker));
}
#[test]
fn test_letter_continuation_is_not_a_marker() {
let base = marker_span(100.0, 30.0, 10.0, "BODY+Font", "ultrasonographi");
let cont = marker_span(131.0, 20.0, 10.0, "BODY+Font", "cally");
assert!(!is_reference_marker_boundary(&base, &cont));
}
#[test]
fn test_same_font_resource_is_not_a_boundary() {
let base = marker_span(100.0, 30.0, 10.0, "SAME+Font", "phosphorylation");
let marker = marker_span(131.0, 5.0, 6.5, "SAME+Font", "55");
assert!(!is_reference_marker_boundary(&base, &marker));
}
}
#[cfg(test)]
mod span_ownership_tests {
use super::*;
use crate::layout::TextSpan;
use crate::pipeline::ordered_span::OrderedTextSpan;
use crate::structure::table_extractor::{Table, TableCell, TableRow};
fn span_at(text: &str, x: f32, y: f32, width: f32) -> TextSpan {
TextSpan {
text: text.to_string(),
bbox: crate::geometry::Rect {
x,
y,
width,
height: 10.0,
},
font_size: 10.0,
..Default::default()
}
}
fn table_with_a_placeholder_cell() -> Table {
let mut populated = TableCell::new("Region".to_string(), false);
populated.bbox = Some(crate::geometry::Rect {
x: 100.0,
y: 700.0,
width: 60.0,
height: 12.0,
});
populated.spans = vec![span_at("Region", 102.0, 701.0, 34.0)];
let mut placeholder = TableCell::new(String::new(), false);
placeholder.bbox = Some(crate::geometry::Rect {
x: 160.0,
y: 700.0,
width: 60.0,
height: 12.0,
});
let mut row = TableRow::new(false);
row.add_cell(populated);
row.add_cell(placeholder);
let mut table = Table::new();
table.add_row(row);
table.bbox = Some(crate::geometry::Rect {
x: 100.0,
y: 700.0,
width: 120.0,
height: 12.0,
});
table
}
#[test]
fn test_span_no_cell_renders_is_left_to_prose() {
let table = table_with_a_placeholder_cell();
let orphan = OrderedTextSpan::new(span_at("footnote", 165.0, 703.0, 40.0), 0);
assert_eq!(
span_in_table(&orphan, std::slice::from_ref(&table)),
None,
"a span sitting in an empty lattice square was suppressed from prose \
and is rendered by nobody, so it is lost from every surface"
);
}
#[test]
fn test_span_a_cell_renders_is_claimed_by_it() {
let table = table_with_a_placeholder_cell();
let owned = OrderedTextSpan::new(span_at("Region", 102.0, 701.0, 34.0), 0);
assert_eq!(
span_in_table(&owned, std::slice::from_ref(&table)),
Some(0),
"a span the cell renders was also emitted into prose, so it appears twice"
);
}
#[test]
fn marked_content_decides_a_tagged_table_both_ways() {
let mut cell = TableCell::new("Region".to_string(), false);
cell.mcids = vec![7];
cell.bbox = Some(crate::geometry::Rect {
x: 100.0,
y: 700.0,
width: 60.0,
height: 12.0,
});
let mut row = TableRow::new(false);
row.add_cell(cell);
let mut table = Table::new();
table.add_row(row);
table.bbox = Some(crate::geometry::Rect {
x: 100.0,
y: 700.0,
width: 120.0,
height: 12.0,
});
let mut inside = span_at("Region", 102.0, 701.0, 34.0);
inside.mcid = Some(7);
assert_eq!(
span_in_table(&OrderedTextSpan::new(inside, 0), std::slice::from_ref(&table)),
Some(0)
);
let mut other = span_at("caption", 102.0, 701.0, 34.0);
other.mcid = Some(9);
assert_eq!(
span_in_table(&OrderedTextSpan::new(other, 0), std::slice::from_ref(&table)),
None,
"a span the table's marked content does not claim must reach prose, \
even though it sits inside the table's bbox"
);
}
#[test]
fn test_bare_table_still_uses_the_geometric_rule() {
let mut table = Table::new();
table.add_row(TableRow::new(false));
table.bbox = Some(crate::geometry::Rect {
x: 100.0,
y: 700.0,
width: 120.0,
height: 12.0,
});
let inside = OrderedTextSpan::new(span_at("Region", 102.0, 701.0, 34.0), 0);
assert_eq!(span_in_table(&inside, std::slice::from_ref(&table)), Some(0));
}
}