use std::collections::{HashMap, HashSet};
use crate::structure_tree::StructRole;
use crate::types::TextLine;
use super::analysis::{
bold_heading_level, calculate_font_stats, compute_heading_tiers, compute_paragraph_threshold,
detect_header_level, font_size_rarity, has_dot_leaders,
};
use super::classify::{
format_list_item, is_caption_line, is_list_item, is_monospace_font, starts_with_bullet_marker,
};
use super::postprocess::clean_markdown;
use super::preprocess::{merge_drop_caps, merge_heading_lines};
use super::MarkdownOptions;
fn detect_overused_struct_heading_levels(
lines: &[TextLine],
struct_roles: Option<
&std::collections::HashMap<u32, std::collections::HashMap<i64, StructRole>>,
>,
) -> HashSet<usize> {
let mut overused = HashSet::new();
let Some(roles) = struct_roles else {
return overused;
};
let mut level_counts: HashMap<usize, usize> = HashMap::new();
let mut total = 0usize;
for line in lines {
if let Some(role) = resolve_line_struct_role(line, roles) {
total += 1;
if let Some(level) = struct_role_heading_level(&role) {
*level_counts.entry(level).or_insert(0) += 1;
}
}
}
if total < 20 {
return overused;
}
for (&level, &count) in &level_counts {
let ratio = count as f32 / total as f32;
if ratio > 0.15 {
log::debug!(
"struct heading H{} overused: {}/{} lines ({:.0}%), suppressing",
level,
count,
total,
ratio * 100.0
);
overused.insert(level);
}
}
overused
}
fn find_isolated_lines(lines: &[TextLine], base_size: f32, para_threshold: f32) -> HashSet<usize> {
let mut set = HashSet::new();
for i in 0..lines.len() {
let line = &lines[i];
let plain = line.text();
let trimmed = plain.trim();
let word_count = trimmed.split_whitespace().count();
if !(1..=6).contains(&word_count) || trimmed.len() <= 3 {
continue;
}
let font_size = line.items.first().map(|it| it.font_size).unwrap_or(0.0);
if font_size < base_size * 0.95 {
continue;
}
if is_list_item(trimmed) || is_caption_line(trimmed) {
continue;
}
let last_char = trimmed.chars().last().unwrap_or(' ');
if last_char == '-' || last_char == ',' || last_char == ';' {
continue;
}
let last_word = trimmed.split_whitespace().last().unwrap_or("");
let continuation_words = [
"the", "a", "an", "and", "or", "of", "in", "to", "for", "with", "by", "on", "at",
"from", "as", "is", "are", "was", "were", "be", "that", "this", "their", "its", "our",
"your", "has", "have", "had", "not",
];
if continuation_words.contains(&last_word.to_lowercase().as_str()) {
continue;
}
let break_before = if i == 0 {
true
} else {
let prev = &lines[i - 1];
prev.page != line.page || (prev.y - line.y).abs() > para_threshold
};
let break_after = if i + 1 >= lines.len() {
true
} else {
let next = &lines[i + 1];
next.page != line.page || (line.y - next.y).abs() > para_threshold
};
if !break_before || !break_after {
continue;
}
set.insert(i);
}
let mut page_line_counts: HashMap<u32, (usize, usize)> = HashMap::new(); for (i, line) in lines.iter().enumerate() {
let entry = page_line_counts.entry(line.page).or_insert((0, 0));
entry.0 += 1;
if set.contains(&i) {
entry.1 += 1;
}
}
for (&page, &(total, isolated)) in &page_line_counts {
if total > 0 && isolated as f32 / total as f32 > 0.25 {
set.retain(|&i| lines[i].page != page);
}
}
set
}
fn find_wrapped_bold_paragraph_lines(
lines: &[TextLine],
base_size: f32,
para_threshold: f32,
) -> HashSet<usize> {
let mut set = HashSet::new();
let mut i = 0usize;
while i < lines.len() {
if !is_body_size_all_bold_line(&lines[i], base_size) {
i += 1;
continue;
}
let start = i;
let mut end = i;
let mut word_count = lines[i].text().split_whitespace().count();
while end + 1 < lines.len()
&& is_body_size_all_bold_line(&lines[end + 1], base_size)
&& is_wrapped_same_style_line(&lines[end], &lines[end + 1], para_threshold)
{
end += 1;
word_count += lines[end].text().split_whitespace().count();
}
let line_count = end - start + 1;
if line_count >= 3 && word_count > 20 {
for idx in start..=end {
set.insert(idx);
}
}
i = end + 1;
}
set
}
fn is_body_size_all_bold_line(line: &TextLine, base_size: f32) -> bool {
let Some(first) = line.items.first() else {
return false;
};
first.font_size >= base_size * 0.95
&& first.font_size < base_size * 1.2
&& line
.items
.iter()
.all(|item| item.is_bold && (item.font_size - first.font_size).abs() < 0.5)
}
fn is_wrapped_same_style_line(prev: &TextLine, next: &TextLine, para_threshold: f32) -> bool {
if prev.page != next.page {
return false;
}
let y_gap = prev.y - next.y;
if !(y_gap > 0.0 && y_gap <= para_threshold) {
return false;
}
let prev_x = prev.items.first().map(|item| item.x).unwrap_or(0.0);
let next_x = next.items.first().map(|item| item.x).unwrap_or(0.0);
(prev_x - next_x).abs() <= 40.0
}
fn resolve_line_struct_role(
line: &TextLine,
struct_roles: &std::collections::HashMap<u32, std::collections::HashMap<i64, StructRole>>,
) -> Option<StructRole> {
let page_roles = struct_roles.get(&line.page)?;
for item in &line.items {
if let Some(mcid) = item.mcid {
if let Some(role) = page_roles.get(&mcid) {
match role {
StructRole::Document
| StructRole::Part
| StructRole::Art
| StructRole::Sect
| StructRole::Div
| StructRole::NonStruct
| StructRole::Span
| StructRole::Private => continue,
_ => return Some(role.clone()),
}
}
}
}
None
}
fn struct_role_heading_level(role: &StructRole) -> Option<usize> {
match role {
StructRole::H => Some(1), StructRole::H1 => Some(1),
StructRole::H2 => Some(2),
StructRole::H3 => Some(3),
StructRole::H4 => Some(4),
StructRole::H5 => Some(5),
StructRole::H6 => Some(6),
_ => None,
}
}
pub(super) fn merge_continuation_tables(
page_tables: &mut std::collections::HashMap<u32, Vec<(f32, String)>>,
table_only_pages: &HashSet<u32>,
) {
let mut sorted_pages: Vec<u32> = page_tables.keys().copied().collect();
sorted_pages.sort();
if sorted_pages.len() < 2 {
return;
}
let mut i = 0;
while i < sorted_pages.len() {
let first_page = sorted_pages[i];
let first_tables = match page_tables.get(&first_page) {
Some(t) if t.len() == 1 => t,
_ => {
i += 1;
continue;
}
};
if !table_only_pages.contains(&first_page) {
i += 1;
continue;
}
let first_col_count = count_table_columns(&first_tables[0].1);
if first_col_count == 0 {
i += 1;
continue;
}
let mut continuation_pages = Vec::new();
let mut j = i + 1;
while j < sorted_pages.len() {
let next_page = sorted_pages[j];
let prev_page = if continuation_pages.is_empty() {
first_page
} else {
*continuation_pages.last().unwrap()
};
if next_page != prev_page + 1 {
break;
}
if !table_only_pages.contains(&next_page) {
break;
}
let next_tables = match page_tables.get(&next_page) {
Some(t) if t.len() == 1 => t,
_ => break,
};
let next_col_count = count_table_columns(&next_tables[0].1);
if next_col_count != first_col_count {
break;
}
continuation_pages.push(next_page);
j += 1;
}
if !continuation_pages.is_empty() {
let mut extra_rows = String::new();
for &cont_page in &continuation_pages {
if let Some(tables) = page_tables.get(&cont_page) {
let table_md = &tables[0].1;
for (line_idx, line) in table_md.lines().enumerate() {
if line_idx >= 2 {
extra_rows.push_str(line);
extra_rows.push('\n');
}
}
}
}
if let Some(tables) = page_tables.get_mut(&first_page) {
tables[0].1.push_str(&extra_rows);
}
for &cont_page in &continuation_pages {
page_tables.remove(&cont_page);
}
i = j;
} else {
i += 1;
}
}
}
fn count_table_columns(table_md: &str) -> usize {
if let Some(sep_line) = table_md.lines().nth(1) {
if sep_line.contains("---") {
let pipes = sep_line.chars().filter(|&c| c == '|').count();
return if pipes >= 2 { pipes - 1 } else { 0 };
}
}
0
}
fn flush_page_tables_and_images(
page: u32,
page_tables: &std::collections::HashMap<u32, Vec<(f32, String)>>,
page_images: &std::collections::HashMap<u32, Vec<(f32, String)>>,
inserted_tables: &mut HashSet<(u32, usize)>,
inserted_images: &mut HashSet<(u32, usize)>,
output: &mut String,
in_paragraph: &mut bool,
) {
if let Some(tables) = page_tables.get(&page) {
for (idx, (_, table_md)) in tables.iter().enumerate() {
if !inserted_tables.contains(&(page, idx)) {
if *in_paragraph {
output.push_str("\n\n");
*in_paragraph = false;
}
output.push('\n');
output.push_str(table_md);
output.push('\n');
inserted_tables.insert((page, idx));
}
}
}
if let Some(images) = page_images.get(&page) {
for (idx, (_, image_md)) in images.iter().enumerate() {
if !inserted_images.contains(&(page, idx)) {
if *in_paragraph {
output.push_str("\n\n");
*in_paragraph = false;
}
output.push('\n');
output.push_str(image_md);
output.push('\n');
inserted_images.insert((page, idx));
}
}
}
}
pub(super) fn to_markdown_from_lines_with_tables_and_images(
lines: Vec<TextLine>,
options: MarkdownOptions,
page_tables: std::collections::HashMap<u32, Vec<(f32, String)>>,
page_images: std::collections::HashMap<u32, Vec<(f32, String)>>,
band_split_pages: &HashSet<u32>,
struct_roles: Option<
&std::collections::HashMap<u32, std::collections::HashMap<i64, StructRole>>,
>,
) -> String {
if lines.is_empty() && page_tables.is_empty() && page_images.is_empty() {
return String::new();
}
let font_stats = calculate_font_stats(&lines);
let base_size = options
.base_font_size
.unwrap_or(font_stats.most_common_size);
let lines = merge_drop_caps(lines, base_size);
let heading_tiers = compute_heading_tiers(&lines, base_size);
let lines = merge_heading_lines(lines, base_size, &heading_tiers, struct_roles);
let para_threshold = compute_paragraph_threshold(&lines, base_size);
let isolated_lines = find_isolated_lines(&lines, base_size, para_threshold);
let wrapped_bold_paragraph_lines =
find_wrapped_bold_paragraph_lines(&lines, base_size, para_threshold);
let overused_heading_levels = detect_overused_struct_heading_levels(&lines, struct_roles);
let mut output = String::new();
let mut current_page = 0u32;
let mut prev_y = f32::MAX;
let mut prev_x = 0.0f32;
let mut in_list = false;
let mut in_paragraph = false;
let mut last_list_x: Option<f32> = None;
let mut in_code_block = false;
let mut prev_had_dot_leaders = false;
let mut paragraph_in_wrapped_bold_run = false;
let mut inserted_tables: HashSet<(u32, usize)> = HashSet::new();
let mut inserted_images: HashSet<(u32, usize)> = HashSet::new();
let mut all_content_pages: Vec<u32> = page_tables
.keys()
.chain(page_images.keys())
.copied()
.collect();
all_content_pages.sort();
all_content_pages.dedup();
for (line_idx, line) in lines.iter().enumerate() {
if line.page != current_page {
if current_page > 0 {
if in_code_block {
output.push_str("```\n");
in_code_block = false;
}
flush_page_tables_and_images(
current_page,
&page_tables,
&page_images,
&mut inserted_tables,
&mut inserted_images,
&mut output,
&mut in_paragraph,
);
if in_paragraph {
output.push_str("\n\n");
in_paragraph = false;
}
output.push_str("\n\n");
}
for &p in &all_content_pages {
if p <= current_page {
continue;
}
if p >= line.page {
break;
}
flush_page_tables_and_images(
p,
&page_tables,
&page_images,
&mut inserted_tables,
&mut inserted_images,
&mut output,
&mut in_paragraph,
);
if in_paragraph {
output.push_str("\n\n");
in_paragraph = false;
}
output.push_str("\n\n");
}
current_page = line.page;
prev_y = f32::MAX;
prev_x = 0.0;
paragraph_in_wrapped_bold_run = false;
if options.include_page_numbers {
output.push_str(&format!("<!-- Page {} -->\n\n", current_page));
}
}
if let Some(tables) = page_tables.get(¤t_page) {
for (idx, (table_y, table_md)) in tables.iter().enumerate() {
if *table_y > line.y && !inserted_tables.contains(&(current_page, idx)) {
if in_paragraph {
output.push_str("\n\n");
in_paragraph = false;
paragraph_in_wrapped_bold_run = false;
}
output.push('\n');
output.push_str(table_md);
output.push('\n');
inserted_tables.insert((current_page, idx));
}
}
}
if let Some(images) = page_images.get(¤t_page) {
for (idx, (image_y, image_md)) in images.iter().enumerate() {
if *image_y > line.y && !inserted_images.contains(&(current_page, idx)) {
if in_paragraph {
output.push_str("\n\n");
in_paragraph = false;
paragraph_in_wrapped_bold_run = false;
}
output.push('\n');
output.push_str(image_md);
output.push('\n');
inserted_images.insert((current_page, idx));
}
}
}
let y_gap = prev_y - line.y;
let line_x = line.items.first().map(|i| i.x).unwrap_or(0.0);
let is_para_break = y_gap.abs() > para_threshold;
let is_band_switch = band_split_pages.contains(&line.page)
&& y_gap.abs() <= para_threshold
&& (prev_x - line_x).abs() > 50.0
&& prev_y < f32::MAX;
let line_all_bold = !line.items.is_empty() && line.items.iter().all(|item| item.is_bold);
let line_in_wrapped_bold_run = wrapped_bold_paragraph_lines.contains(&line_idx);
let is_bold_to_regular_break = in_paragraph
&& paragraph_in_wrapped_bold_run
&& !line_in_wrapped_bold_run
&& !line_all_bold
&& y_gap > base_size * 1.2
&& y_gap <= para_threshold;
if (is_para_break || is_band_switch || is_bold_to_regular_break) && in_paragraph {
output.push_str("\n\n");
in_paragraph = false;
paragraph_in_wrapped_bold_run = false;
}
prev_y = line.y;
prev_x = line_x;
let text = line.text_with_formatting(options.detect_bold, options.detect_italic);
let trimmed = text.trim();
let plain_text = line.text();
let plain_trimmed = plain_text.trim();
if trimmed.is_empty() {
continue;
}
let struct_role = struct_roles.and_then(|roles| resolve_line_struct_role(line, roles));
let is_code_line = struct_role
.as_ref()
.is_some_and(|r| matches!(r, StructRole::Code))
|| (options.detect_code && line.items.iter().any(|i| is_monospace_font(&i.font)));
if in_code_block && !is_code_line {
output.push_str("```\n");
in_code_block = false;
}
if struct_role
.as_ref()
.is_some_and(|r| matches!(r, StructRole::Caption))
|| is_caption_line(plain_trimmed)
{
if in_paragraph {
output.push_str("\n\n");
in_paragraph = false;
paragraph_in_wrapped_bold_run = false;
}
output.push_str(trimmed);
output.push_str("\n\n");
continue;
}
let struct_heading = struct_role
.as_ref()
.and_then(struct_role_heading_level)
.filter(|level| !overused_heading_levels.contains(level));
let looks_like_list_continuation = in_list
&& match (last_list_x, line.items.first().map(|i| i.x)) {
(Some(list_x), Some(curr_x)) => {
let x_ok = curr_x >= list_x - 5.0 && curr_x <= list_x + 50.0;
let y_ok = y_gap >= 0.0 && y_gap <= para_threshold;
x_ok && y_ok && !is_list_item(plain_trimmed)
}
_ => false,
};
let heuristic_heading = if options.detect_headers
&& !looks_like_list_continuation
&& plain_trimmed.len() > 3
&& plain_trimmed.split_whitespace().count() <= 15
&& !starts_with_bullet_marker(plain_trimmed)
{
let line_font_size = line.items.first().map(|i| i.font_size).unwrap_or(base_size);
detect_header_level(line_font_size, base_size, &heading_tiers).or_else(|| {
if line_font_size < base_size * 0.95 {
return None;
}
let word_count = plain_trimmed.split_whitespace().count();
if !(1..=15).contains(&word_count) {
return None;
}
if wrapped_bold_paragraph_lines.contains(&line_idx) {
return None;
}
let rarity = font_size_rarity(line_font_size, &font_stats);
let all_bold = !line.items.is_empty() && line.items.iter().all(|i| i.is_bold);
let standalone = !in_paragraph;
let isolated = isolated_lines.contains(&line_idx);
let score = rarity * 0.5
+ if all_bold { 0.3 } else { 0.0 }
+ if standalone { 0.2 } else { 0.0 }
+ if isolated { 0.3 } else { 0.0 };
let has_strong_signal = all_bold || isolated || (rarity >= 0.97 && word_count <= 8);
if score >= 0.5 && standalone && word_count >= 2 && has_strong_signal {
Some(bold_heading_level(&heading_tiers))
} else {
None
}
})
} else {
None
};
if let Some(level) = struct_heading.or(heuristic_heading) {
if in_paragraph {
output.push_str("\n\n");
in_paragraph = false;
paragraph_in_wrapped_bold_run = false;
}
let prefix = "#".repeat(level);
output.push_str(&format!("{} {}\n\n", prefix, plain_trimmed));
in_list = false;
continue;
}
if struct_role
.as_ref()
.is_some_and(|r| matches!(r, StructRole::LI))
&& !is_list_item(plain_trimmed)
&& !in_list
{
if in_paragraph {
output.push_str("\n\n");
in_paragraph = false;
paragraph_in_wrapped_bold_run = false;
}
output.push_str(&format!("- {}", trimmed));
output.push('\n');
in_list = true;
last_list_x = line.items.first().map(|i| i.x);
continue;
}
if options.detect_lists && is_list_item(plain_trimmed) {
if in_paragraph {
output.push_str("\n\n");
in_paragraph = false;
paragraph_in_wrapped_bold_run = false;
}
let formatted = format_list_item(trimmed);
output.push_str(&formatted);
output.push('\n');
in_list = true;
last_list_x = line.items.first().map(|i| i.x);
continue;
} else if in_list {
let line_x = line.items.first().map(|i| i.x);
let is_continuation = if let (Some(list_x), Some(curr_x)) = (last_list_x, line_x) {
let x_ok = curr_x >= list_x - 5.0 && curr_x <= list_x + 50.0;
let y_ok = y_gap < base_size * 7.0;
x_ok && y_ok && !is_list_item(plain_trimmed) && !has_dot_leaders(plain_trimmed)
} else {
false
};
if is_continuation {
if output.ends_with('\n') {
output.pop();
output.push(' ');
}
output.push_str(trimmed);
output.push('\n');
continue;
} else {
in_list = false;
last_list_x = None;
}
}
if struct_role
.as_ref()
.is_some_and(|r| matches!(r, StructRole::BlockQuote))
{
if in_paragraph {
output.push_str("\n\n");
in_paragraph = false;
paragraph_in_wrapped_bold_run = false;
}
output.push_str(&format!("> {}\n", trimmed));
continue;
}
if is_code_line {
if in_paragraph {
output.push_str("\n\n");
in_paragraph = false;
paragraph_in_wrapped_bold_run = false;
}
if !in_code_block {
output.push_str("```\n");
in_code_block = true;
}
output.push_str(plain_trimmed);
output.push('\n');
continue;
}
let cur_dot_leaders = has_dot_leaders(plain_trimmed);
if in_paragraph {
if cur_dot_leaders || prev_had_dot_leaders {
output.push('\n');
} else {
output.push(' ');
}
}
output.push_str(trimmed);
paragraph_in_wrapped_bold_run = if in_paragraph {
paragraph_in_wrapped_bold_run || line_in_wrapped_bold_run
} else {
line_in_wrapped_bold_run
};
in_paragraph = true;
prev_had_dot_leaders = cur_dot_leaders;
}
if in_code_block {
output.push_str("```\n");
}
flush_page_tables_and_images(
current_page,
&page_tables,
&page_images,
&mut inserted_tables,
&mut inserted_images,
&mut output,
&mut in_paragraph,
);
for &p in &all_content_pages {
if p <= current_page {
continue;
}
flush_page_tables_and_images(
p,
&page_tables,
&page_images,
&mut inserted_tables,
&mut inserted_images,
&mut output,
&mut in_paragraph,
);
}
if in_paragraph {
output.push('\n');
}
clean_markdown(output, &options)
}
pub fn to_markdown_from_lines(lines: Vec<TextLine>, options: MarkdownOptions) -> String {
if lines.is_empty() {
return String::new();
}
let font_stats = calculate_font_stats(&lines);
let base_size = options
.base_font_size
.unwrap_or(font_stats.most_common_size);
let lines = merge_drop_caps(lines, base_size);
let heading_tiers = compute_heading_tiers(&lines, base_size);
let lines = merge_heading_lines(lines, base_size, &heading_tiers, None);
let para_threshold = compute_paragraph_threshold(&lines, base_size);
let isolated_lines = find_isolated_lines(&lines, base_size, para_threshold);
let wrapped_bold_paragraph_lines =
find_wrapped_bold_paragraph_lines(&lines, base_size, para_threshold);
let mut output = String::new();
let mut current_page = 0u32;
let mut prev_y = f32::MAX;
let mut in_list = false;
let mut in_paragraph = false;
let mut last_list_x: Option<f32> = None;
let mut prev_had_dot_leaders = false;
let mut paragraph_in_wrapped_bold_run = false;
for (line_idx, line) in lines.iter().enumerate() {
if line.page != current_page {
if current_page > 0 {
if in_paragraph {
output.push_str("\n\n");
in_paragraph = false;
}
output.push_str("\n\n");
}
current_page = line.page;
prev_y = f32::MAX;
in_list = false;
last_list_x = None;
prev_had_dot_leaders = false;
paragraph_in_wrapped_bold_run = false;
if options.include_page_numbers {
output.push_str(&format!("<!-- Page {} -->\n\n", current_page));
}
}
let y_gap = prev_y - line.y;
let is_para_break = y_gap.abs() > para_threshold;
let line_all_bold = !line.items.is_empty() && line.items.iter().all(|item| item.is_bold);
let line_in_wrapped_bold_run = wrapped_bold_paragraph_lines.contains(&line_idx);
let is_bold_to_regular_break = in_paragraph
&& paragraph_in_wrapped_bold_run
&& !line_in_wrapped_bold_run
&& !line_all_bold
&& y_gap > base_size * 1.2
&& y_gap <= para_threshold;
if (is_para_break || is_bold_to_regular_break) && in_paragraph {
output.push_str("\n\n");
in_paragraph = false;
paragraph_in_wrapped_bold_run = false;
}
prev_y = line.y;
let text = line.text_with_formatting(options.detect_bold, options.detect_italic);
let trimmed = text.trim();
let plain_text = line.text();
let plain_trimmed = plain_text.trim();
if trimmed.is_empty() {
continue;
}
if is_caption_line(plain_trimmed) {
if in_paragraph {
output.push_str("\n\n");
in_paragraph = false;
paragraph_in_wrapped_bold_run = false;
}
output.push_str(trimmed);
output.push_str("\n\n");
continue;
}
if options.detect_headers
&& plain_trimmed.len() > 3
&& plain_trimmed.split_whitespace().count() <= 15
{
let line_font_size = line.items.first().map(|i| i.font_size).unwrap_or(base_size);
if let Some(header_level) =
detect_header_level(line_font_size, base_size, &heading_tiers).or_else(|| {
if line_font_size < base_size * 0.95 {
return None;
}
let word_count = plain_trimmed.split_whitespace().count();
if !(1..=15).contains(&word_count) {
return None;
}
if wrapped_bold_paragraph_lines.contains(&line_idx) {
return None;
}
let rarity = font_size_rarity(line_font_size, &font_stats);
let all_bold = !line.items.is_empty() && line.items.iter().all(|i| i.is_bold);
let standalone = !in_paragraph;
let isolated = isolated_lines.contains(&line_idx);
let score = rarity * 0.5
+ if all_bold { 0.3 } else { 0.0 }
+ if standalone { 0.2 } else { 0.0 }
+ if isolated { 0.3 } else { 0.0 };
if score >= 0.5 && standalone && word_count >= 2 {
return Some(bold_heading_level(&heading_tiers));
}
None
})
{
if in_paragraph {
output.push_str("\n\n");
in_paragraph = false;
paragraph_in_wrapped_bold_run = false;
}
let prefix = "#".repeat(header_level);
output.push_str(&format!("{} {}\n\n", prefix, plain_trimmed));
in_list = false;
continue;
}
}
if options.detect_lists && is_list_item(plain_trimmed) {
if in_paragraph {
output.push_str("\n\n");
in_paragraph = false;
paragraph_in_wrapped_bold_run = false;
}
let formatted = format_list_item(trimmed);
output.push_str(&formatted);
output.push('\n');
in_list = true;
last_list_x = line.items.first().map(|i| i.x);
continue;
} else if in_list {
let line_x = line.items.first().map(|i| i.x);
let is_continuation = if let (Some(list_x), Some(curr_x)) = (last_list_x, line_x) {
let x_ok = curr_x >= list_x - 5.0 && curr_x <= list_x + 50.0;
let y_ok = y_gap < base_size * 7.0;
x_ok && y_ok && !is_list_item(plain_trimmed) && !has_dot_leaders(plain_trimmed)
} else {
false
};
if is_continuation {
if output.ends_with('\n') {
output.pop();
output.push(' ');
}
output.push_str(trimmed);
output.push('\n');
continue;
} else {
in_list = false;
last_list_x = None;
}
}
if options.detect_code {
let is_mono = line.items.iter().any(|i| is_monospace_font(&i.font));
if is_mono {
if in_paragraph {
output.push_str("\n\n");
in_paragraph = false;
paragraph_in_wrapped_bold_run = false;
}
output.push_str(&format!("```\n{}\n```\n", plain_trimmed));
continue;
}
}
let cur_dot_leaders = has_dot_leaders(plain_trimmed);
if in_paragraph {
if cur_dot_leaders || prev_had_dot_leaders {
output.push('\n');
} else {
output.push(' ');
}
}
output.push_str(trimmed);
paragraph_in_wrapped_bold_run = if in_paragraph {
paragraph_in_wrapped_bold_run || line_in_wrapped_bold_run
} else {
line_in_wrapped_bold_run
};
in_paragraph = true;
prev_had_dot_leaders = cur_dot_leaders;
}
if in_paragraph {
output.push('\n');
}
clean_markdown(output, &options)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::structure_tree::StructRole;
use crate::types::TextItem;
use std::collections::HashMap;
fn make_item(text: &str, page: u32, mcid: Option<i64>) -> TextItem {
TextItem {
text: text.to_string(),
x: 72.0,
y: 700.0,
width: 100.0,
height: 12.0,
font: "Helvetica".to_string(),
font_size: 12.0,
page,
is_bold: false,
is_italic: false,
item_type: crate::types::ItemType::Text,
mcid,
}
}
fn make_line(items: Vec<TextItem>) -> TextLine {
let y = items.first().map(|i| i.y).unwrap_or(0.0);
let page = items.first().map(|i| i.page).unwrap_or(1);
TextLine {
items,
y,
page,
adaptive_threshold: 0.10,
}
}
#[test]
fn test_struct_role_heading() {
let lines = vec![
make_line(vec![make_item("Introduction", 1, Some(0))]),
make_line(vec![{
let mut item = make_item("Body text here.", 1, Some(1));
item.y = 680.0;
item
}]),
];
let mut page_roles = HashMap::new();
page_roles.insert(0i64, StructRole::H1);
page_roles.insert(1i64, StructRole::P);
let mut roles = HashMap::new();
roles.insert(1u32, page_roles);
let md = to_markdown_from_lines_with_tables_and_images(
lines,
MarkdownOptions::default(),
HashMap::new(),
HashMap::new(),
&std::collections::HashSet::new(),
Some(&roles),
);
assert!(
md.contains("# Introduction"),
"Should have H1 heading: {md}"
);
assert!(
md.contains("Body text here."),
"Should have body text: {md}"
);
}
#[test]
fn test_struct_role_list_item() {
let lines = vec![make_line(vec![make_item("First item", 1, Some(0))])];
let mut page_roles = HashMap::new();
page_roles.insert(0i64, StructRole::LI);
let mut roles = HashMap::new();
roles.insert(1u32, page_roles);
let md = to_markdown_from_lines_with_tables_and_images(
lines,
MarkdownOptions::default(),
HashMap::new(),
HashMap::new(),
&std::collections::HashSet::new(),
Some(&roles),
);
assert!(
md.contains("- First item"),
"Should format as list item: {md}"
);
}
#[test]
fn test_struct_role_li_flat_continuation_lines_merge() {
let make = |text: &str, mcid: i64, x: f32, y: f32| {
let mut item = make_item(text, 1, Some(mcid));
item.x = x;
item.y = y;
item
};
let lines = vec![
make_line(vec![make("● First item that wraps onto", 0, 90.0, 322.0)]),
make_line(vec![make("a continuation line.", 1, 108.0, 306.0)]),
make_line(vec![make("● Second bullet also wraps", 2, 90.0, 290.0)]),
make_line(vec![make("to a second line here.", 3, 108.0, 274.0)]),
];
let mut page_roles = HashMap::new();
for mcid in 0..4 {
page_roles.insert(mcid, StructRole::LI);
}
let mut roles = HashMap::new();
roles.insert(1u32, page_roles);
let md = to_markdown_from_lines_with_tables_and_images(
lines,
MarkdownOptions::default(),
HashMap::new(),
HashMap::new(),
&std::collections::HashSet::new(),
Some(&roles),
);
assert!(
md.contains("- First item that wraps onto a continuation line."),
"continuation should merge into first bullet: {md}"
);
assert!(
md.contains("- Second bullet also wraps to a second line here."),
"continuation should merge into second bullet: {md}"
);
assert!(
!md.contains("- a continuation line."),
"continuation line should not get its own bullet: {md}"
);
assert!(
!md.contains("- to a second line here."),
"continuation line should not get its own bullet: {md}"
);
}
#[test]
fn test_struct_role_blockquote() {
let lines = vec![make_line(vec![make_item("Quoted text", 1, Some(0))])];
let mut page_roles = HashMap::new();
page_roles.insert(0i64, StructRole::BlockQuote);
let mut roles = HashMap::new();
roles.insert(1u32, page_roles);
let md = to_markdown_from_lines_with_tables_and_images(
lines,
MarkdownOptions::default(),
HashMap::new(),
HashMap::new(),
&std::collections::HashSet::new(),
Some(&roles),
);
assert!(
md.contains("> Quoted text"),
"Should format as blockquote: {md}"
);
}
#[test]
fn test_struct_role_heading_levels() {
let mcids = vec![
(StructRole::H1, "Title"),
(StructRole::H2, "Section"),
(StructRole::H3, "Subsection"),
];
let mut lines = Vec::new();
let mut page_roles = HashMap::new();
for (i, (role, text)) in mcids.iter().enumerate() {
let mut item = make_item(text, 1, Some(i as i64));
item.y = 700.0 - (i as f32 * 30.0);
lines.push(make_line(vec![item]));
page_roles.insert(i as i64, role.clone());
}
let mut roles = HashMap::new();
roles.insert(1u32, page_roles);
let md = to_markdown_from_lines_with_tables_and_images(
lines,
MarkdownOptions::default(),
HashMap::new(),
HashMap::new(),
&std::collections::HashSet::new(),
Some(&roles),
);
assert!(md.contains("# Title"), "H1 → #: {md}");
assert!(md.contains("## Section"), "H2 → ##: {md}");
assert!(md.contains("### Subsection"), "H3 → ###: {md}");
}
#[test]
fn test_no_struct_roles_falls_back_to_heuristics() {
let mut item = make_item("Big Title", 1, None);
item.font_size = 24.0;
item.height = 24.0;
let lines = vec![
make_line(vec![item]),
make_line(vec![{
let mut body = make_item("Normal body text.", 1, None);
body.y = 660.0;
body
}]),
];
let md = to_markdown_from_lines_with_tables_and_images(
lines,
MarkdownOptions::default(),
HashMap::new(),
HashMap::new(),
&std::collections::HashSet::new(),
None,
);
assert!(
md.contains("# Big Title"),
"Font heuristic should detect heading: {md}"
);
}
#[test]
fn test_resolve_line_struct_role_skips_containers() {
let mut page_roles = HashMap::new();
page_roles.insert(0i64, StructRole::Div);
page_roles.insert(1i64, StructRole::H2);
let mut roles = HashMap::new();
roles.insert(1u32, page_roles);
let line = make_line(vec![
make_item("Part ", 1, Some(0)),
make_item("Title", 1, Some(1)),
]);
let role = resolve_line_struct_role(&line, &roles);
assert_eq!(role, Some(StructRole::H2));
}
#[test]
fn test_struct_role_code() {
let lines = vec![make_line(vec![make_item("fn main() {}", 1, Some(0))])];
let mut page_roles = HashMap::new();
page_roles.insert(0i64, StructRole::Code);
let mut roles = HashMap::new();
roles.insert(1u32, page_roles);
let md = to_markdown_from_lines_with_tables_and_images(
lines,
MarkdownOptions::default(),
HashMap::new(),
HashMap::new(),
&std::collections::HashSet::new(),
Some(&roles),
);
assert!(
md.contains("```\nfn main() {}\n```"),
"Should format as code block: {md}"
);
}
#[test]
fn test_rarity_heading_requires_strong_signal() {
let mut lines = Vec::new();
for i in 0..20 {
let mut item = make_item("This is ordinary body text in a paragraph.", 1, None);
item.font_size = 10.9;
item.y = 700.0 - i as f32 * 14.0;
lines.push(make_line(vec![item]));
}
for i in 0..10 {
let mut item = make_item("Another body text line from the second column.", 1, None);
item.font_size = 11.0; item.y = 700.0 - i as f32 * 14.0;
item.x = 320.0; lines.push(make_line(vec![item]));
}
let mut heading_item = make_item("3 Philosophical Perspectives", 1, None);
heading_item.font_size = 10.9;
heading_item.is_bold = true;
heading_item.y = 200.0;
lines.push(make_line(vec![heading_item]));
let md = to_markdown_from_lines_with_tables_and_images(
lines,
MarkdownOptions::default(),
HashMap::new(),
HashMap::new(),
&std::collections::HashSet::new(),
None,
);
assert!(
md.contains("## 3 Philosophical Perspectives"),
"Bold heading should be detected: {md}"
);
let heading_count = md.lines().filter(|l| l.starts_with("##")).count();
assert!(
heading_count <= 2,
"Expected at most 2 headings but found {heading_count} in:\n{md}"
);
}
#[test]
fn test_wrapped_bold_abstract_is_not_split_into_headings() {
let make = |text: &str, y: f32, font_size: f32, bold: bool| {
let mut item = make_item(text, 1, None);
item.y = y;
item.font_size = font_size;
item.height = font_size;
item.is_bold = bold;
item
};
let lines = vec![
make_line(vec![make(
"Quantum Nature of Light Measured With a Single Detector",
747.7,
25.0,
true,
)]),
make_line(vec![make(
"Gesine A. Steudle1*, Stefan Schietinger1, David Höckel1",
651.1,
11.0,
false,
)]),
make_line(vec![make(
"Zwiller2, and Oliver Benson1",
638.5,
11.0,
false,
)]),
make_line(vec![make(
"The introduction of light quanta by Einstein in 1905 triggered strong efforts to",
607.5,
11.0,
true,
)]),
make_line(vec![make(
"demonstrate the quantum properties of light directly, without involving matter",
594.8,
11.0,
true,
)]),
make_line(vec![make(
"quantization. It however took more than seven decades for the quantum granularity",
582.2,
11.0,
true,
)]),
make_line(vec![make(
"of light to be observed in the fluorescence of single atoms. Single atoms emit",
569.5,
11.0,
true,
)]),
make_line(vec![make(
"photons one at a time, this is typically demonstrated with a Hanbury-Brown-Twiss",
556.9,
11.0,
true,
)]),
make_line(vec![make(
"Our work significantly simplifies a widely used photon-correlation technique.",
544.2,
11.0,
true,
)]),
make_line(vec![make(
"A photon is a single excitation of a mode of the electromagnetic field.",
528.7,
11.0,
false,
)]),
];
let md = to_markdown_from_lines_with_tables_and_images(
lines,
MarkdownOptions::default(),
HashMap::new(),
HashMap::new(),
&std::collections::HashSet::new(),
None,
);
assert!(
md.contains("# Quantum Nature of Light Measured With a Single Detector"),
"title should remain a heading: {md}"
);
assert!(
!md.contains("## The introduction")
&& !md.contains("## demonstrate")
&& !md.contains("## quantization"),
"bold abstract lines should not become headings: {md}"
);
assert!(
md.contains("technique.**\n\nA photon is a single excitation"),
"body paragraph should be separated from bold abstract: {md}"
);
}
#[test]
fn test_struct_role_code_multiline_accumulation() {
let mut line1 = make_item("fn main() {", 1, Some(0));
line1.y = 700.0;
let mut line2 = make_item(" println!(\"hello\");", 1, Some(1));
line2.y = 688.0;
let mut line3 = make_item("}", 1, Some(2));
line3.y = 676.0;
let lines = vec![
make_line(vec![line1]),
make_line(vec![line2]),
make_line(vec![line3]),
];
let mut page_roles = HashMap::new();
page_roles.insert(0i64, StructRole::Code);
page_roles.insert(1i64, StructRole::Code);
page_roles.insert(2i64, StructRole::Code);
let mut roles = HashMap::new();
roles.insert(1u32, page_roles);
let md = to_markdown_from_lines_with_tables_and_images(
lines,
MarkdownOptions::default(),
HashMap::new(),
HashMap::new(),
&std::collections::HashSet::new(),
Some(&roles),
);
assert!(
md.contains("```\nfn main() {\nprintln!(\"hello\");\n}\n```"),
"Should accumulate consecutive code lines into one block: {md}"
);
assert!(
!md.contains("```\n```"),
"Should not have adjacent close/open fences: {md}"
);
}
#[test]
fn test_overused_struct_heading_suppressed() {
let mut lines = Vec::new();
let mut page_roles = HashMap::new();
let mut mcid = 0i64;
for i in 0..30 {
let mut item = make_item(&format!("Line {i}"), 1, Some(mcid));
item.y = 700.0 - (i as f32 * 15.0);
lines.push(make_line(vec![item]));
let role = if i < 5 {
StructRole::H1
} else if i < 25 {
StructRole::H2
} else {
StructRole::P
};
page_roles.insert(mcid, role);
mcid += 1;
}
let mut roles = HashMap::new();
roles.insert(1u32, page_roles);
let overused = detect_overused_struct_heading_levels(&lines, Some(&roles));
assert!(
overused.contains(&2),
"H2 should be detected as overused: {:?}",
overused
);
assert!(
overused.contains(&1),
"H1 at 17% should also be suppressed: {:?}",
overused
);
}
#[test]
fn test_normal_struct_headings_not_suppressed() {
let mut lines = Vec::new();
let mut page_roles = HashMap::new();
let mut mcid = 0i64;
for i in 0..50 {
let mut item = make_item(&format!("Line {i}"), 1, Some(mcid));
item.y = 700.0 - (i as f32 * 14.0);
lines.push(make_line(vec![item]));
let role = if i % 10 == 0 {
StructRole::H1 } else {
StructRole::P
};
page_roles.insert(mcid, role);
mcid += 1;
}
let mut roles = HashMap::new();
roles.insert(1u32, page_roles);
let overused = detect_overused_struct_heading_levels(&lines, Some(&roles));
assert!(
overused.is_empty(),
"No heading level should be overused: {:?}",
overused
);
}
#[test]
fn test_wrapped_bold_lead_in_list_item_not_heading() {
let make = |text: &str, x: f32, y: f32, bold: bool| {
let mut item = make_item(text, 1, None);
item.x = x;
item.y = y;
item.is_bold = bold;
item
};
let lines = vec![
make_line(vec![
make("1. ", 72.0, 700.0, false),
make(
"Chemical and biological weapons threat model 1 (CB-1): Non-novel",
90.0,
700.0,
true,
),
]),
make_line(vec![make(
"chemical/biological weapons production capabilities: A model has CB-1",
90.0,
686.0,
true,
)]),
make_line(vec![make(
"capabilities if it has the ability to significantly help individuals.",
90.0,
672.0,
false,
)]),
];
let md = to_markdown_from_lines_with_tables_and_images(
lines,
MarkdownOptions::default(),
HashMap::new(),
HashMap::new(),
&std::collections::HashSet::new(),
None,
);
assert!(
!md.contains("#### "),
"wrapped bold lead must not become a heading: {md}"
);
assert!(
md.lines().filter(|l| l.starts_with("- ")).count() == 0,
"continuation body must not become a stray bullet: {md}"
);
assert!(
md.contains("1. ") && md.contains("A model has CB-1"),
"numbered list item should remain intact: {md}"
);
}
}