use std::cmp::Ordering;
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, is_heading_fragment,
is_leader_continuation, is_toc_entry_line, is_toc_marker_heading, trailing_leader_dots,
};
use super::classify::{format_list_item, is_caption_line, is_list_item, starts_with_bullet_marker};
use super::heading::classify_heading_sequences;
use super::postprocess::clean_markdown;
use super::preprocess::{merge_drop_caps, merge_heading_lines};
use super::{item_is_in_chart_region, MarkdownOptions, CHART_SEPARATOR_PAD};
#[derive(Debug, Clone, Copy)]
pub(super) struct ChartProseOrder {
split_x: f32,
chart_region: (f32, f32, f32, f32),
}
impl ChartProseOrder {
pub(super) fn new(split_x: f32, chart_region: (f32, f32, f32, f32)) -> Self {
Self {
split_x,
chart_region,
}
}
}
#[derive(Debug, Clone)]
pub(super) struct PositionedMarkdown {
y: f32,
x: f32,
markdown: String,
chart_order: Option<ChartProseOrder>,
}
impl PositionedMarkdown {
pub(super) fn new(
y: f32,
x: f32,
markdown: String,
chart_order: Option<ChartProseOrder>,
) -> Self {
Self {
y,
x,
markdown,
chart_order,
}
}
}
fn chart_stream_position(
y: f32,
x: f32,
claimed_by_chart: bool,
order: ChartProseOrder,
) -> (u8, u8) {
let (_, y0, _, y1) = order.chart_region;
let low = y0.min(y1) - CHART_SEPARATOR_PAD;
let high = y0.max(y1) + CHART_SEPARATOR_PAD;
let in_chart_zone = claimed_by_chart || (y >= low && y <= high);
let zone = if in_chart_zone {
1
} else if y > high {
0
} else {
2
};
let column = if in_chart_zone || x < order.split_x {
0
} else {
1
};
(zone, column)
}
fn positioned_block_precedes_line(block: &PositionedMarkdown, line: &TextLine) -> bool {
let Some(order) = block.chart_order else {
return block.y > line.y;
};
let line_x = line.items.first().map(|item| item.x).unwrap_or(0.0);
let line_claimed_by_chart = line
.items
.iter()
.any(|item| item_is_in_chart_region(item, &[order.chart_region]));
let block_position = chart_stream_position(block.y, block.x, false, order);
let line_position = chart_stream_position(line.y, line_x, line_claimed_by_chart, order);
block_position < line_position || (block_position == line_position && block.y > line.y)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum PositionedBlockKind {
Table,
Image,
}
type PositionedBlockRef<'a> = (PositionedBlockKind, usize, &'a PositionedMarkdown);
fn compare_positioned_blocks(
(a_kind, a_idx, a): &PositionedBlockRef<'_>,
(b_kind, b_idx, b): &PositionedBlockRef<'_>,
) -> Ordering {
if let (Some(a_order), Some(b_order)) = (a.chart_order, b.chart_order) {
let a_position = chart_stream_position(a.y, a.x, false, a_order);
let b_position = chart_stream_position(b.y, b.x, false, b_order);
return a_position
.cmp(&b_position)
.then_with(|| b.y.total_cmp(&a.y))
.then_with(|| a.x.total_cmp(&b.x))
.then_with(|| a_kind.cmp(b_kind))
.then_with(|| a_idx.cmp(b_idx));
}
a_kind.cmp(b_kind).then_with(|| a_idx.cmp(b_idx))
}
fn positioned_blocks_for_page<'a>(
page: u32,
page_tables: &'a HashMap<u32, Vec<PositionedMarkdown>>,
page_images: &'a HashMap<u32, Vec<PositionedMarkdown>>,
) -> Vec<PositionedBlockRef<'a>> {
let mut blocks = Vec::new();
if let Some(tables) = page_tables.get(&page) {
blocks.extend(
tables
.iter()
.enumerate()
.map(|(idx, table)| (PositionedBlockKind::Table, idx, table)),
);
}
if let Some(images) = page_images.get(&page) {
blocks.extend(
images
.iter()
.enumerate()
.map(|(idx, image)| (PositionedBlockKind::Image, idx, image)),
);
}
blocks.sort_by(compare_positioned_blocks);
blocks
}
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 >= 10 && isolated as f32 / total as f32 > 0.25 {
set.retain(|&i| lines[i].page != page);
}
}
set
}
fn starts_with_section_number(t: &str) -> bool {
let t = t.trim_start();
let mut rest = t;
let mut groups = 0;
loop {
let digits = rest.chars().take_while(|c| c.is_ascii_digit()).count();
if digits == 0 || digits > 3 {
break;
}
groups += 1;
rest = &rest[digits..];
if let Some(r) = rest.strip_prefix('.') {
rest = r;
} else {
break;
}
}
groups >= 2
&& rest.starts_with(char::is_whitespace)
&& rest.trim_start().starts_with(|c: char| c.is_alphabetic())
}
fn merge_wrapped_bold_heading_groups(
lines: Vec<TextLine>,
base_size: f32,
para_threshold: f32,
) -> Vec<TextLine> {
let mut out: Vec<TextLine> = Vec::with_capacity(lines.len());
let mut i = 0usize;
while i < lines.len() {
if !is_body_size_all_bold_line(&lines[i], base_size) {
out.push(lines[i].clone());
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;
let gx0 = lines[start..=end]
.iter()
.flat_map(|l| l.items.iter().map(|i| i.x))
.fold(f32::INFINITY, f32::min);
let gx1 = lines[start..=end]
.iter()
.flat_map(|l| l.items.iter().map(|i| i.x + i.width))
.fold(f32::NEG_INFINITY, f32::max);
let overlaps_x = |l: &TextLine| {
let lx0 = l.items.iter().map(|i| i.x).fold(f32::INFINITY, f32::min);
let lx1 = l
.items
.iter()
.map(|i| i.x + i.width)
.fold(f32::NEG_INFINITY, f32::max);
lx0 < gx1 && lx1 > gx0
};
let page = lines[start].page;
let break_before = !lines.iter().any(|l| {
l.page == page
&& l.y > lines[start].y
&& l.y - lines[start].y <= para_threshold
&& overlaps_x(l)
});
let break_after = !lines.iter().any(|l| {
l.page == page
&& l.y < lines[end].y
&& lines[end].y - l.y <= para_threshold
&& overlaps_x(l)
});
let numbered = starts_with_section_number(&lines[start].text());
if (2..=3).contains(&line_count)
&& word_count <= 15
&& ((break_before && break_after) || numbered)
{
let mut merged = lines[start].clone();
for l in &lines[start + 1..=end] {
merged.items.extend(l.items.iter().cloned());
}
out.push(merged);
} else {
for l in &lines[start..=end] {
out.push(l.clone());
}
}
i = end + 1;
}
out
}
fn find_wrapped_bold_paragraph_lines(
lines: &[TextLine],
base_size: f32,
para_threshold: f32,
) -> (HashSet<usize>, HashSet<usize>) {
let mut set = HashSet::new();
let mut quoted = 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;
let quoted_prose =
line_count >= 2 && word_count > 20 && has_enclosing_double_quotes(&lines[start..=end]);
if (line_count >= 3 && word_count > 20) || quoted_prose {
for idx in start..=end {
set.insert(idx);
if quoted_prose {
quoted.insert(idx);
}
}
}
i = end + 1;
}
(set, quoted)
}
fn has_enclosing_double_quotes(lines: &[TextLine]) -> bool {
let text = lines
.iter()
.map(TextLine::text)
.collect::<Vec<_>>()
.join(" ");
let text = text.trim();
[('“', '”'), ('"', '"')].iter().any(|&(open, close)| {
text.strip_prefix(open)
.and_then(|body| body.split_once(close))
.is_some_and(|(body, suffix)| !body.contains(open) && is_quote_suffix(suffix))
})
}
fn is_quote_suffix(mut suffix: &str) -> bool {
loop {
suffix = suffix.trim_start();
let Some(first) = suffix.chars().next() else {
return true;
};
match first {
'.' | ',' | ';' | ':' | '!' | '?' | '…' | '*' | '†' | '‡' | '⁰' | '¹' | '²' | '³'
| '⁴' | '⁵' | '⁶' | '⁷' | '⁸' | '⁹' => {
suffix = &suffix[first.len_utf8()..];
}
'[' | '(' => {
let close = if first == '[' { ']' } else { ')' };
let Some((reference, rest)) = suffix[1..].split_once(close) else {
return false;
};
if !is_numeric_quote_reference(reference, first == '[') {
return false;
}
suffix = rest;
}
_ => return false,
}
}
}
fn is_numeric_quote_reference(mut reference: &str, allow_list: bool) -> bool {
loop {
reference = reference.trim_start();
let digits = reference.bytes().take_while(u8::is_ascii_digit).count();
if digits == 0 {
return false;
}
reference = reference[digits..].trim_start();
if reference.is_empty() {
return true;
}
let separator = reference.chars().next().unwrap();
if !allow_list || !matches!(separator, ',' | '-' | '–') {
return false;
}
reference = &reference[separator.len_utf8()..];
}
}
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<PositionedMarkdown>>,
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].markdown);
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].markdown);
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].markdown;
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].markdown.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_blocks: &HashMap<u32, Vec<PositionedBlockRef<'_>>>,
inserted_tables: &mut HashSet<(u32, usize)>,
inserted_images: &mut HashSet<(u32, usize)>,
output: &mut String,
in_paragraph: &mut bool,
) {
let Some(blocks) = page_blocks.get(&page) else {
return;
};
for &(kind, idx, block) in blocks {
let already_inserted = match kind {
PositionedBlockKind::Table => inserted_tables.contains(&(page, idx)),
PositionedBlockKind::Image => inserted_images.contains(&(page, idx)),
};
if already_inserted {
continue;
}
if *in_paragraph {
output.push_str("\n\n");
*in_paragraph = false;
}
output.push('\n');
output.push_str(&block.markdown);
output.push('\n');
match kind {
PositionedBlockKind::Table => {
inserted_tables.insert((page, idx));
}
PositionedBlockKind::Image => {
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<PositionedMarkdown>>,
page_images: std::collections::HashMap<u32, Vec<PositionedMarkdown>>,
page_chart_regions: &std::collections::HashMap<u32, Vec<(f32, f32, f32, f32)>>,
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 base_size = super::analysis::correct_base_size(&lines, base_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 lines = if std::env::var("PI_NO_MERGE").is_ok() {
lines
} else {
merge_wrapped_bold_heading_groups(lines, base_size, para_threshold)
};
let isolated_lines = find_isolated_lines(&lines, base_size, para_threshold);
let (wrapped_bold_paragraph_lines, wrapped_quoted_paragraph_lines) =
find_wrapped_bold_paragraph_lines(&lines, base_size, para_threshold);
let mut sequence_excluded_lines = wrapped_bold_paragraph_lines.clone();
for (line_idx, line) in lines.iter().enumerate() {
if page_chart_regions.get(&line.page).is_some_and(|regions| {
line.items
.iter()
.any(|item| item_is_in_chart_region(item, regions))
}) {
sequence_excluded_lines.insert(line_idx);
}
}
if let Some(roles) = struct_roles {
for (line_idx, line) in lines.iter().enumerate() {
if resolve_line_struct_role(line, roles)
.is_some_and(|role| role.is_non_heading_content())
{
sequence_excluded_lines.insert(line_idx);
}
}
}
let sequence_heading_levels = classify_heading_sequences(
&lines,
base_size,
&heading_tiers,
&isolated_lines,
&sequence_excluded_lines,
);
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;
fn flush_code_block(output: &mut String, pending_code: &mut String) {
let trimmed = pending_code.trim();
if trimmed.chars().count() < 3 {
if !trimmed.is_empty() {
output.push_str(trimmed);
output.push_str("\n\n");
}
} else {
output.push_str("```\n");
output.push_str(pending_code);
output.push_str("```\n");
}
pending_code.clear();
}
let mut in_code_block = false;
let mut pending_code = String::new();
let mut prev_had_dot_leaders = false;
let mut paragraph_in_wrapped_bold_run = false;
let mut toc_suppress_page: Option<u32> = None;
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();
let page_blocks: HashMap<u32, Vec<PositionedBlockRef<'_>>> = all_content_pages
.iter()
.map(|&page| {
(
page,
positioned_blocks_for_page(page, &page_tables, &page_images),
)
})
.collect();
for (line_idx, line) in lines.iter().enumerate() {
if line.page != current_page {
if current_page > 0 {
if in_code_block {
flush_code_block(&mut output, &mut pending_code);
in_code_block = false;
}
flush_page_tables_and_images(
current_page,
&page_blocks,
&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_blocks,
&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(blocks) = page_blocks.get(¤t_page) {
for &(kind, idx, block) in blocks {
let already_inserted = match kind {
PositionedBlockKind::Table => inserted_tables.contains(&(current_page, idx)),
PositionedBlockKind::Image => inserted_images.contains(&(current_page, idx)),
};
if positioned_block_precedes_line(block, line) && !already_inserted {
if in_code_block {
flush_code_block(&mut output, &mut pending_code);
in_code_block = false;
}
if in_paragraph {
output.push_str("\n\n");
in_paragraph = false;
paragraph_in_wrapped_bold_run = false;
}
output.push('\n');
output.push_str(&block.markdown);
output.push('\n');
match kind {
PositionedBlockKind::Table => {
inserted_tables.insert((current_page, idx));
}
PositionedBlockKind::Image => {
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;
}
let (prior_y, prior_x) = (prev_y, prev_x);
prev_y = line.y;
prev_x = line_x;
let text = line.text_with_formatting(
options.detect_bold,
options.detect_italic,
options.detect_underline,
);
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
&& (in_code_block || !in_paragraph)
&& super::classify::line_is_monospace(line));
if !in_code_block
&& !is_code_line
&& !is_para_break
&& !is_band_switch
&& is_leader_continuation(plain_trimmed)
&& extend_leader(&mut output, plain_trimmed)
{
prev_y = prior_y;
prev_x = prior_x;
continue;
}
if in_code_block && !is_code_line {
flush_code_block(&mut output, &mut pending_code);
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 non_heading_role = struct_role
.as_ref()
.is_some_and(StructRole::is_non_heading_content);
let heuristic_heading = if options.detect_headers
&& !non_heading_role
&& !wrapped_quoted_paragraph_lines.contains(&line_idx)
&& !is_code_line
&& !looks_like_list_continuation
&& plain_trimmed.len() > 3
&& plain_trimmed.split_whitespace().count() <= 15
&& !starts_with_bullet_marker(plain_trimmed)
&& !is_toc_entry_line(plain_trimmed)
&& !is_heading_fragment(plain_trimmed)
&& toc_suppress_page != Some(line.page)
{
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,
crate::markdown::analysis::line_is_mostly_bold(line),
)
.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);
let enough_words = word_count >= 2 || (all_bold && plain_trimmed.len() >= 4);
let numbered_bold = all_bold && starts_with_section_number(plain_trimmed);
if numbered_bold
|| (score >= 0.5 && standalone && enough_words && has_strong_signal)
{
Some(bold_heading_level(&heading_tiers))
} else {
None
}
})
.or_else(|| sequence_heading_levels.get(&line_idx).copied())
} 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);
let heading_text = if options.detect_underline {
line.text_with_formatting(false, false, true)
} else {
plain_text.clone()
};
output.push_str(&format!("{} {}\n\n", prefix, heading_text.trim()));
if is_toc_marker_heading(plain_trimmed) {
toc_suppress_page = Some(line.page);
}
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;
}
in_code_block = true;
pending_code.push_str(plain_trimmed);
pending_code.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 {
flush_code_block(&mut output, &mut pending_code);
}
flush_page_tables_and_images(
current_page,
&page_blocks,
&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_blocks,
&mut inserted_tables,
&mut inserted_images,
&mut output,
&mut in_paragraph,
);
}
if in_paragraph {
output.push('\n');
}
clean_markdown(output, &options)
}
const MARKUP_TOKENS: [&str; 10] = [
"**", "*", "<u>", "</u>", "<s>", "</s>", "<sup>", "</sup>", "<sub>", "</sub>",
];
fn extend_leader(output: &mut String, dots: &str) -> bool {
let body_len = output.trim_end_matches('\n').len();
if output.len() - body_len > 1 {
return false;
}
let line_start = output[..body_len].rfind('\n').map_or(0, |i| i + 1);
let last_line = &output[line_start..body_len];
let mut text_end = last_line.len();
loop {
let head = &last_line[..text_end];
let Some(stripped) = ["**", "*", "</u>", "</s>", "</sup>", "</sub>"]
.iter()
.find_map(|m| head.strip_suffix(m))
else {
break;
};
text_end = stripped.len();
}
let plain: String = MARKUP_TOKENS
.iter()
.fold(last_line[..text_end].to_string(), |acc, m| {
acc.replace(m, "")
});
if trailing_leader_dots(&plain) < 4
|| !has_dot_leaders(&plain)
|| !plain.chars().any(char::is_alphanumeric)
{
return false;
}
let tail = plain.trim_end_matches([' ', '.']).len();
let spaced = plain[tail..].trim().contains(' ');
let count = dots.chars().filter(|c| *c == '.').count();
let run = if spaced {
" .".repeat(count)
} else {
".".repeat(count)
};
output.insert_str(line_start + text_end, &run);
true
}
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 base_size = super::analysis::correct_base_size(&lines, base_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, wrapped_quoted_paragraph_lines) =
find_wrapped_bold_paragraph_lines(&lines, base_size, para_threshold);
let sequence_heading_levels = classify_heading_sequences(
&lines,
base_size,
&heading_tiers,
&isolated_lines,
&wrapped_bold_paragraph_lines,
);
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;
let mut toc_suppress_page: Option<u32> = None;
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;
}
let prior_y = prev_y;
prev_y = line.y;
let text = line.text_with_formatting(
options.detect_bold,
options.detect_italic,
options.detect_underline,
);
let trimmed = text.trim();
let plain_text = line.text();
let plain_trimmed = plain_text.trim();
if trimmed.is_empty() {
continue;
}
if !is_para_break
&& !(options.detect_code && super::classify::line_is_monospace(line))
&& is_leader_continuation(plain_trimmed)
&& extend_leader(&mut output, plain_trimmed)
{
prev_y = prior_y;
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
&& !wrapped_quoted_paragraph_lines.contains(&line_idx)
&& plain_trimmed.len() > 3
&& plain_trimmed.split_whitespace().count() <= 15
&& !is_toc_entry_line(plain_trimmed)
&& !is_heading_fragment(plain_trimmed)
&& toc_suppress_page != Some(line.page)
&& !(options.detect_code && super::classify::line_is_monospace(line))
{
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,
crate::markdown::analysis::line_is_mostly_bold(line),
)
.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 enough_words =
word_count >= 2 || (all_bold && isolated && plain_trimmed.len() >= 4);
if score >= 0.5 && standalone && enough_words {
return Some(bold_heading_level(&heading_tiers));
}
None
})
.or_else(|| sequence_heading_levels.get(&line_idx).copied())
{
if in_paragraph {
output.push_str("\n\n");
in_paragraph = false;
paragraph_in_wrapped_bold_run = false;
}
let prefix = "#".repeat(header_level);
let heading_text = if options.detect_underline {
line.text_with_formatting(false, false, true)
} else {
plain_text.clone()
};
output.push_str(&format!("{} {}\n\n", prefix, heading_text.trim()));
if is_toc_marker_heading(plain_trimmed) {
toc_suppress_page = Some(line.page);
}
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 && !in_paragraph && super::classify::line_is_monospace(line) {
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 {
#[test]
fn section_number_prefix_detection() {
assert!(starts_with_section_number(
"9.5. Adapting to the New Normal"
));
assert!(starts_with_section_number("12.3.1. Deep subsection"));
assert!(starts_with_section_number("2.1 Systems thinking"));
assert!(!starts_with_section_number("1. First item in a list"));
assert!(!starts_with_section_number("24% in October 2020."));
assert!(!starts_with_section_number("2020 was a hard year"));
assert!(!starts_with_section_number("Introduction"));
}
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_tag: String::new(),
legacy_symbol_rewrite: false,
font_size: 12.0,
page,
is_bold: false,
is_italic: false,
font_weight: None,
is_underline: false,
is_strikeout: false,
rotation: 0.0,
advance_known: true,
item_type: crate::types::ItemType::Text,
mcid,
baseline_shift: 0.0,
}
}
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,
}
}
fn line_at(text: &str, page: u32, y: f32) -> TextLine {
let mut item = make_item(text, page, None);
item.y = y;
make_line(vec![item])
}
#[test]
fn chart_page_blocks_follow_zone_and_column_stream() {
let line = |text: &str, x: f32, y: f32| {
let mut item = make_item(text, 1, None);
item.x = x;
item.y = y;
make_line(vec![item])
};
let lines = vec![
line("Left column upper prose.", 90.0, 700.0),
line("Left column lower prose.", 90.0, 500.0),
line("Right column upper prose.", 340.0, 700.0),
line("Right column lower prose.", 340.0, 500.0),
];
let order = ChartProseOrder::new(280.0, (100.0, 300.0, 500.0, 400.0));
let mut tables = HashMap::new();
tables.insert(
1,
vec![
PositionedMarkdown::new(
600.0,
340.0,
"| Right metric | Value |\n|---|---|\n| A | 1 |\n".into(),
Some(order),
),
PositionedMarkdown::new(
550.0,
90.0,
"| Left metric | Value |\n|---|---|\n| B | 2 |\n".into(),
Some(order),
),
],
);
let mut images = HashMap::new();
images.insert(
1,
vec\n".into(),
Some(order),
),
PositionedMarkdown::new(
575.0,
340.0,
"\n".into(),
Some(order),
),
],
);
let md = to_markdown_from_lines_with_tables_and_images(
lines,
MarkdownOptions::default(),
tables,
images,
&HashMap::new(),
&HashSet::from([1]),
None,
);
let positions = [
"Left column upper prose.",
"",
"| Left metric | Value |",
"Left column lower prose.",
"Right column upper prose.",
"| Right metric | Value |",
"",
"Right column lower prose.",
]
.map(|needle| {
md.find(needle)
.unwrap_or_else(|| panic!("missing {needle:?} in {md}"))
});
assert!(
positions.windows(2).all(|pair| pair[0] < pair[1]),
"blocks must follow the logical chart-page stream: {md}"
);
}
#[test]
fn isolated_lines_kept_on_sparse_pages() {
let lines = vec![
line_at("CONTENTS", 1, 700.0),
line_at("Chapter One 5", 1, 500.0),
];
let isolated = find_isolated_lines(&lines, 12.0, 20.0);
assert!(
isolated.contains(&0),
"sparse-page title must stay isolated"
);
}
#[test]
fn isolated_lines_wiped_on_dense_pages() {
let lines: Vec<TextLine> = (0..12)
.map(|i| line_at("Short column line", 1, 700.0 - i as f32 * 50.0))
.collect();
let isolated = find_isolated_lines(&lines, 12.0, 20.0);
assert!(
isolated.is_empty(),
"dense page of isolated lines must be wiped"
);
}
#[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(),
&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(),
&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(),
&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(),
&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(),
&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(),
&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(),
&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(),
&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(),
&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}"
);
}
fn quoted_prose_fixture(first: &str, last: &str, size: f32, italic: bool) -> Vec<TextLine> {
let mut lines: Vec<_> = (0..10)
.map(|i| {
line_at(
"Regular paragraph text provides the surrounding document context.",
1,
740.0 - i as f32 * 14.0,
)
})
.collect();
for (index, text) in [first, last].iter().enumerate() {
let mut item = make_item(text, 1, Some(index as i64));
item.y = 540.0 - index as f32 * 14.0;
item.font_size = size;
item.is_bold = true;
item.is_italic = italic;
lines.push(make_line(vec![item]));
}
lines.push(line_at(
"Ordinary body text follows the quotation.",
1,
480.0,
));
lines
}
#[test]
fn quoted_bold_prose_stays_one_formatted_paragraph_in_both_renderers() {
let first =
"Every member keeps a careful record of each decision and its supporting evidence";
let last = "so that anyone can review the work and understand how the result was reached.";
for (open, close) in [('“', '”'), ('"', '"')] {
for size in [12.0, 13.0] {
for italic in [false, true] {
let first = format!("{open}{first}");
let last = format!("{last}{close}");
let lines = quoted_prose_fixture(&first, &last, size, italic);
let options = MarkdownOptions {
base_font_size: Some(12.0),
..MarkdownOptions::default()
};
let outputs = [
to_markdown_from_lines(lines.clone(), options.clone()),
to_markdown_from_lines_with_tables_and_images(
lines,
options,
HashMap::new(),
HashMap::new(),
&HashMap::new(),
&HashSet::new(),
None,
),
];
for md in outputs {
let paragraph = md
.split("\n\n")
.find(|p| p.contains("Every member"))
.unwrap();
let markers = if italic { "***" } else { "**" };
assert!(
paragraph.starts_with(&format!("{markers}{open}Every")),
"{md}"
);
assert!(
paragraph.ends_with(&format!("reached.{close}{markers}")),
"{md}"
);
assert_eq!(
paragraph
.replace('*', "")
.split_whitespace()
.collect::<Vec<_>>()
.join(" "),
format!("{first} {last}")
);
assert!(!paragraph.lines().any(|line| line.starts_with('#')), "{md}");
}
}
}
}
}
#[test]
fn quoted_prose_suffixes_preserve_text_and_style_in_both_renderers() {
for (open, close) in [('“', '”'), ('"', '"')] {
for suffix in [
".",
",",
";",
":",
"!",
"?",
"…",
" [1]",
" [1, 2]",
" [1–3]",
" [1-3, 5]",
" (1)",
"¹",
"¹²",
"*",
"†",
"‡",
". [1]†",
" [1].",
] {
let first = format!(
"{open}Every member keeps a careful record of each decision and its supporting evidence"
);
let last = format!(
"so that anyone can review the work and understand how the result was reached{close}{suffix}"
);
let lines = quoted_prose_fixture(&first, &last, 13.0, true);
let options = MarkdownOptions {
base_font_size: Some(12.0),
..MarkdownOptions::default()
};
let outputs = [
to_markdown_from_lines(lines.clone(), options.clone()),
to_markdown_from_lines_with_tables_and_images(
lines,
options,
HashMap::new(),
HashMap::new(),
&HashMap::new(),
&HashSet::new(),
None,
),
];
for md in outputs {
let paragraph = md
.split("\n\n")
.find(|p| p.contains("Every member"))
.unwrap();
assert!(paragraph.starts_with(&format!("***{open}Every")), "{md}");
assert!(paragraph.ends_with(&format!("{close}{suffix}***")), "{md}");
assert_eq!(paragraph.replace("***", ""), format!("{first} {last}"));
assert!(!paragraph.lines().any(|line| line.starts_with('#')), "{md}");
}
}
}
}
#[test]
fn quoted_prose_suffixes_reject_prose_and_malformed_references() {
for suffix in [
" and another statement follows",
" 2024",
" (use this carefully)",
" (Smith, 2024)",
" [note]",
" []",
" [1,]",
" [1-]",
" [1,,2]",
" [1",
" (1, 2)",
" (1",
" [1] followed by prose",
" “Another title”",
" \"Another title\"",
] {
assert!(!is_quote_suffix(suffix), "{suffix}");
let first =
"“Every member keeps a careful record of each decision and its supporting evidence";
let last = format!(
"so that anyone can review the work and understand how the result was reached.”{suffix}"
);
let lines = quoted_prose_fixture(first, &last, 13.0, true);
let (_, quoted) = find_wrapped_bold_paragraph_lines(&lines, 12.0, 20.0);
assert!(quoted.is_empty(), "{suffix}");
}
}
#[test]
fn quoted_prose_guard_keeps_quote_and_heading_boundaries() {
let first =
"“Every member keeps a careful record of each decision and its supporting evidence";
let last = "so that anyone can review the work and understand how the result was reached.”";
for (left, right, size) in [
("“Collected Notes", "and Practical Examples”", 13.0),
(first, last, 16.0),
(first, "so that anyone can review the work and understand how the result was reached.", 13.0),
(first, "so that anyone can review the work and understand how the result was reached.\"", 13.0),
("“Every Member Keeps a Careful Record of Each Decision and Its Supporting Evidence”", "“Readers Can Review the Work and Understand How the Final Result Was Reached”", 13.0),
] {
let lines = quoted_prose_fixture(left, right, size, true);
let (_, quoted) = find_wrapped_bold_paragraph_lines(&lines, 12.0, 20.0);
assert!(quoted.is_empty(), "{left} / {right}");
}
let options = MarkdownOptions {
base_font_size: Some(12.0),
..MarkdownOptions::default()
};
for lines in [
quoted_prose_fixture("“Collected Notes", "and Practical Examples”", 13.0, false),
quoted_prose_fixture(first, last, 16.0, true),
] {
let md = to_markdown_from_lines_with_tables_and_images(
lines,
options.clone(),
HashMap::new(),
HashMap::new(),
&HashMap::new(),
&HashSet::new(),
None,
);
assert!(
md.lines()
.any(|line| line.starts_with('#') && line.contains('“')),
"{md}"
);
}
let roles = HashMap::from([(1, HashMap::from([(0, StructRole::H2), (1, StructRole::H2)]))]);
let md = to_markdown_from_lines_with_tables_and_images(
quoted_prose_fixture(first, last, 13.0, true),
options,
HashMap::new(),
HashMap::new(),
&HashMap::new(),
&HashSet::new(),
Some(&roles),
);
assert!(md.contains("## “Every member"), "{md}");
assert!(md.contains("## so that anyone"), "{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(),
&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(),
&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}"
);
}
#[test]
fn extend_leader_respects_markup_breaks_and_sentences() {
let mut out = String::from("**Total assets....**\n");
assert!(extend_leader(&mut out, ". . ."));
assert_eq!(out, "**Total assets.......**\n");
let mut out = String::from("Intro\n\nTotal assets....");
assert!(extend_leader(&mut out, ".."));
assert_eq!(out, "Intro\n\nTotal assets......");
let mut out = String::from("And then...\n");
assert!(!extend_leader(&mut out, "...."));
assert_eq!(out, "And then...\n");
let mut out = String::from("Total assets . . . .\n");
assert!(extend_leader(&mut out, ". . ."));
assert_eq!(out, "Total assets . . . . . . .\n");
let mut out = String::from("Total assets . . . .\n");
assert!(extend_leader(&mut out, "..."));
assert_eq!(out, "Total assets . . . . . . .\n");
let mut out = String::from("Wait. . . .\n");
assert!(!extend_leader(&mut out, "...."));
assert_eq!(out, "Wait. . . .\n");
let mut out = String::from("12......\n");
assert!(extend_leader(&mut out, "...."));
assert_eq!(out, "12..........\n");
let mut out = String::from("<u>........</u>\n");
assert!(!extend_leader(&mut out, "...."));
assert_eq!(out, "<u>........</u>\n");
let mut out = String::from("<sup>Note....</sup>\n");
assert!(extend_leader(&mut out, ".."));
assert_eq!(out, "<sup>Note......</sup>\n");
let mut out = String::from("<sub>Note....</sub>");
assert!(extend_leader(&mut out, ".."));
assert_eq!(out, "<sub>Note......</sub>");
let mut out = String::from("<u>Deficiency....</u>\n");
assert!(extend_leader(&mut out, "...."));
assert_eq!(out, "<u>Deficiency........</u>\n");
let mut out = String::from("Total assets....\n\n");
assert!(!extend_leader(&mut out, "...."));
assert_eq!(out, "Total assets....\n\n");
for prev in ["Introduction.\n", "............. 19.2\n", "........\n", ""] {
let mut out = String::from(prev);
assert!(!extend_leader(&mut out, "...."), "{prev:?}");
assert_eq!(out, prev);
}
}
#[test]
fn spaced_leader_tails_fold_in_the_line_style_and_collapse_cleanly() {
let lines = vec![
line_at(
"Balance of secured claims as per list \"B\" . . . . . .",
1,
700.0,
),
line_at(". . . .", 1, 688.0),
line_at("Secured creditors as per list \"B\" . . . . . .", 1, 676.0),
];
let md = to_markdown_from_lines(lines, MarkdownOptions::default());
assert!(
md.contains("Balance of secured claims as per list \"B\"..........\n"),
"{md}"
);
assert!(
!md.contains(". ....") && !md.contains(".. ."),
"no seam between spaced and solid dots:\n{md}"
);
assert!(
md.contains("Secured creditors as per list \"B\"......"),
"{md}"
);
}
#[test]
fn leader_continuation_lines_extend_the_line_before_them() {
let lines = vec![
line_at("Total assets........", 1, 700.0),
line_at("......", 1, 688.0),
line_at("Deficiency.......", 1, 676.0),
line_at("9. Real property as per list \"G\".....", 1, 664.0),
line_at("........", 1, 652.0),
line_at("10. Furniture.......", 1, 640.0),
line_at("Amount of subscribed capital....... 0,00", 1, 628.0),
line_at("....", 1, 616.0),
line_at("2 849,23", 1, 604.0),
line_at(".....................", 1, 592.0),
line_at("..........", 1, 580.0),
line_at("............. 19.2", 1, 568.0),
line_at("..........", 1, 556.0),
line_at("Introduction.", 1, 544.0),
line_at("......", 1, 532.0),
line_at("...", 1, 520.0),
line_at("Closing line", 1, 508.0),
line_at("Far leader........", 1, 496.0),
line_at("......", 1, 296.0),
];
let md = to_markdown_from_lines(lines, MarkdownOptions::default());
let orphan_runs = md
.lines()
.filter(|l| {
let t = l.trim();
t.len() >= 4 && t.chars().all(|c| c == '.')
})
.count();
assert_eq!(orphan_runs, 6, "{md}");
assert!(md.contains("Far leader........\n"), "{md}");
assert!(
!md.contains("Far leader........."),
"no fold across a paragraph gap:\n{md}"
);
assert!(
md.contains("\n... Closing line") && !md.contains("Introduction...."),
"the ellipsis survives as text and is not folded into the sentence:\n{md}"
);
assert!(md.contains("Total assets.............."), "{md}");
assert!(md.contains("as per list \"G\"............."), "{md}");
assert!(md.contains("Deficiency......."), "{md}");
assert!(md.contains("capital....... 0,00"), "{md}");
assert!(
!md.contains("0,00."),
"a closed row takes no trailing dots:\n{md}"
);
assert!(md.contains("2 849,23"), "{md}");
assert!(
!md.contains("2 849,23."),
"dots are not glued to a plain line:\n{md}"
);
assert!(md.contains("............. 19.2"), "{md}");
assert!(
!md.contains("19.2."),
"a dots-and-number fragment takes none:\n{md}"
);
assert!(md.contains("Introduction."), "{md}");
assert!(
!md.contains("Introduction.."),
"a period is not a leader:\n{md}"
);
}
}