fix(markdown): Render image-only pages in order and collapse dot leaders
Image-only pages (e.g. scanned cover pages) were appended at the end of output instead of appearing in page order. Now flushes tables/images for intermediate pages with no text lines during page transitions. Also collapses dot-leader runs (4+ dots) into " ... " for cleaner TOC rendering, and adds debug_pages binary for inspecting per-page items. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
2549167737
commit
f77cbe9808
@@ -45,3 +45,7 @@ path = "src/bin/detect_pdf.rs"
|
|||||||
[[bin]]
|
[[bin]]
|
||||||
name = "debug_ygaps"
|
name = "debug_ygaps"
|
||||||
path = "src/bin/debug_ygaps.rs"
|
path = "src/bin/debug_ygaps.rs"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "debug_pages"
|
||||||
|
path = "src/bin/debug_pages.rs"
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
use pdf_inspector::extract_text_with_positions;
|
||||||
|
use std::env;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let args: Vec<String> = env::args().collect();
|
||||||
|
if args.len() < 2 {
|
||||||
|
eprintln!("Usage: debug_pages <pdf_path> [max_page | min-max]");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let range = args.get(2).map(|s| s.as_str()).unwrap_or("1-3");
|
||||||
|
let (min_page, max_page) = if let Some((a, b)) = range.split_once('-') {
|
||||||
|
(a.parse().unwrap_or(1), b.parse().unwrap_or(3))
|
||||||
|
} else {
|
||||||
|
(1, range.parse().unwrap_or(3))
|
||||||
|
};
|
||||||
|
|
||||||
|
let items = extract_text_with_positions(&args[1]).expect("Failed to extract");
|
||||||
|
|
||||||
|
for page in min_page..=max_page {
|
||||||
|
let page_items: Vec<_> = items.iter().filter(|i| i.page == page).collect();
|
||||||
|
println!("=== PAGE {} ({} items) ===", page, page_items.len());
|
||||||
|
for item in &page_items {
|
||||||
|
println!(
|
||||||
|
" x={:7.1} y={:7.1} w={:7.1} fs={:5.1} text={:?}",
|
||||||
|
item.x, item.y, item.width, item.font_size, item.text
|
||||||
|
);
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
}
|
||||||
+120
-67
@@ -380,6 +380,46 @@ fn count_table_columns(table_md: &str) -> usize {
|
|||||||
0
|
0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Flush any remaining tables and images for a given page
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Convert text lines to markdown, inserting tables and images at appropriate Y positions
|
/// Convert text lines to markdown, inserting tables and images at appropriate Y positions
|
||||||
fn to_markdown_from_lines_with_tables_and_images(
|
fn to_markdown_from_lines_with_tables_and_images(
|
||||||
lines: Vec<TextLine>,
|
lines: Vec<TextLine>,
|
||||||
@@ -421,45 +461,61 @@ fn to_markdown_from_lines_with_tables_and_images(
|
|||||||
let mut inserted_tables: HashSet<(u32, usize)> = HashSet::new();
|
let mut inserted_tables: HashSet<(u32, usize)> = HashSet::new();
|
||||||
let mut inserted_images: HashSet<(u32, usize)> = HashSet::new();
|
let mut inserted_images: HashSet<(u32, usize)> = HashSet::new();
|
||||||
|
|
||||||
|
// Collect all pages that have tables or images (including image-only pages)
|
||||||
|
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 in lines {
|
for line in lines {
|
||||||
// Page break
|
// Page break
|
||||||
if line.page != current_page {
|
if line.page != current_page {
|
||||||
// Before leaving the current page, insert any remaining tables and images
|
// Flush current page's remaining tables and images
|
||||||
if current_page > 0 {
|
if current_page > 0 {
|
||||||
if let Some(tables) = page_tables.get(¤t_page) {
|
flush_page_tables_and_images(
|
||||||
for (idx, (_, table_md)) in tables.iter().enumerate() {
|
current_page,
|
||||||
if !inserted_tables.contains(&(current_page, idx)) {
|
&page_tables,
|
||||||
if in_paragraph {
|
&page_images,
|
||||||
output.push_str("\n\n");
|
&mut inserted_tables,
|
||||||
in_paragraph = false;
|
&mut inserted_images,
|
||||||
}
|
&mut output,
|
||||||
output.push('\n');
|
&mut in_paragraph,
|
||||||
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_md)) in images.iter().enumerate() {
|
|
||||||
if !inserted_images.contains(&(current_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((current_page, idx));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if in_paragraph {
|
if in_paragraph {
|
||||||
output.push_str("\n\n");
|
output.push_str("\n\n");
|
||||||
in_paragraph = false;
|
in_paragraph = false;
|
||||||
}
|
}
|
||||||
output.push_str("\n\n");
|
output.push_str("\n\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Flush any intermediate pages (image-only or table-only) between
|
||||||
|
// current_page and line.page that have no text lines
|
||||||
|
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;
|
current_page = line.page;
|
||||||
prev_y = f32::MAX;
|
prev_y = f32::MAX;
|
||||||
}
|
}
|
||||||
@@ -621,45 +677,30 @@ fn to_markdown_from_lines_with_tables_and_images(
|
|||||||
in_paragraph = true;
|
in_paragraph = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert any remaining tables and images for all pages
|
// Flush current page and any remaining pages with tables/images
|
||||||
// (handles the case where all text was consumed by tables, leaving no lines to iterate)
|
// (handles table-only pages after the last text line, and trailing image-only pages)
|
||||||
let mut remaining_pages: Vec<u32> = page_tables
|
flush_page_tables_and_images(
|
||||||
.keys()
|
current_page,
|
||||||
.chain(page_images.keys())
|
&page_tables,
|
||||||
.copied()
|
&page_images,
|
||||||
.collect();
|
&mut inserted_tables,
|
||||||
remaining_pages.sort();
|
&mut inserted_images,
|
||||||
remaining_pages.dedup();
|
&mut output,
|
||||||
|
&mut in_paragraph,
|
||||||
for page in remaining_pages {
|
);
|
||||||
if let Some(tables) = page_tables.get(&page) {
|
for &p in &all_content_pages {
|
||||||
for (idx, (_, table_md)) in tables.iter().enumerate() {
|
if p <= current_page {
|
||||||
if !inserted_tables.contains(&(page, idx)) {
|
continue;
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
flush_page_tables_and_images(
|
||||||
|
p,
|
||||||
|
&page_tables,
|
||||||
|
&page_images,
|
||||||
|
&mut inserted_tables,
|
||||||
|
&mut inserted_images,
|
||||||
|
&mut output,
|
||||||
|
&mut in_paragraph,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close final paragraph
|
// Close final paragraph
|
||||||
@@ -1310,6 +1351,9 @@ fn is_monospace_font(font_name: &str) -> bool {
|
|||||||
|
|
||||||
/// Clean up markdown output with post-processing
|
/// Clean up markdown output with post-processing
|
||||||
fn clean_markdown(mut text: String, options: &MarkdownOptions) -> String {
|
fn clean_markdown(mut text: String, options: &MarkdownOptions) -> String {
|
||||||
|
// Collapse dot leaders (e.g. TOC entries: "Introduction...............................1")
|
||||||
|
text = collapse_dot_leaders(&text);
|
||||||
|
|
||||||
// Fix hyphenation first (before other processing)
|
// Fix hyphenation first (before other processing)
|
||||||
if options.fix_hyphenation {
|
if options.fix_hyphenation {
|
||||||
text = fix_hyphenation(&text);
|
text = fix_hyphenation(&text);
|
||||||
@@ -1337,6 +1381,15 @@ fn clean_markdown(mut text: String, options: &MarkdownOptions) -> String {
|
|||||||
text
|
text
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Collapse dot leaders (runs of 4+ dots) into " ... "
|
||||||
|
/// Common in tables of contents: "Introduction...............................1" -> "Introduction ... 1"
|
||||||
|
fn collapse_dot_leaders(text: &str) -> String {
|
||||||
|
use once_cell::sync::Lazy;
|
||||||
|
static DOT_LEADER_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"\.{4,}").unwrap());
|
||||||
|
|
||||||
|
DOT_LEADER_RE.replace_all(text, " ... ").to_string()
|
||||||
|
}
|
||||||
|
|
||||||
/// Fix words broken across lines with spaces before the continuation
|
/// Fix words broken across lines with spaces before the continuation
|
||||||
/// e.g., "Limoeiro do Nort e" -> "Limoeiro do Norte"
|
/// e.g., "Limoeiro do Nort e" -> "Limoeiro do Norte"
|
||||||
fn fix_hyphenation(text: &str) -> String {
|
fn fix_hyphenation(text: &str) -> String {
|
||||||
|
|||||||
Reference in New Issue
Block a user