Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21a436ac1b | ||
|
|
d2d8e35a7b | ||
|
|
30c9dbbc72 |
+243
-2
@@ -102,12 +102,13 @@ pub fn ocr_page_to_markdown(
|
|||||||
options: &MarkdownOptions,
|
options: &MarkdownOptions,
|
||||||
) -> String {
|
) -> String {
|
||||||
let (items, _) = ocr_text_items(page);
|
let (items, _) = ocr_text_items(page);
|
||||||
to_markdown_from_items_with_rects_and_page_count(
|
let markdown = to_markdown_from_items_with_rects_and_page_count(
|
||||||
items,
|
items,
|
||||||
options.clone(),
|
options.clone(),
|
||||||
&[],
|
&[],
|
||||||
document_page_count,
|
document_page_count,
|
||||||
)
|
);
|
||||||
|
preserve_ocr_line_breaks(&markdown, page)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fuses a selective OCR run into per-page native Markdown.
|
/// Fuses a selective OCR run into per-page native Markdown.
|
||||||
@@ -176,6 +177,7 @@ pub fn fuse_ocr_pages(
|
|||||||
&[],
|
&[],
|
||||||
document_page_count,
|
document_page_count,
|
||||||
);
|
);
|
||||||
|
let ocr_markdown = preserve_ocr_line_breaks(&ocr_markdown, local);
|
||||||
let (markdown, source) = if native.markdown.trim().is_empty() || native.needs_ocr {
|
let (markdown, source) = if native.markdown.trim().is_empty() || native.needs_ocr {
|
||||||
(ocr_markdown, PageContentSource::Ocr)
|
(ocr_markdown, PageContentSource::Ocr)
|
||||||
} else {
|
} else {
|
||||||
@@ -329,6 +331,122 @@ fn image_quad_bounds(
|
|||||||
(right > left && bottom > top).then_some((left, top, right, bottom))
|
(right > left && bottom > top).then_some((left, top, right, bottom))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn preserve_ocr_line_breaks(markdown: &str, page: &RoutedOcrPage) -> String {
|
||||||
|
let spans: Vec<(&str, f32, f32, f32, f32)> = page
|
||||||
|
.ocr
|
||||||
|
.spans
|
||||||
|
.iter()
|
||||||
|
.filter_map(|span| {
|
||||||
|
let (left, top, right, bottom) = image_quad_bounds(
|
||||||
|
&span.polygon.points,
|
||||||
|
page.rendered.width(),
|
||||||
|
page.rendered.height(),
|
||||||
|
)?;
|
||||||
|
(!span.text.trim().is_empty()).then_some((span.text.trim(), left, top, right, bottom))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if spans.len() < 2 {
|
||||||
|
return markdown.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut line_heights: Vec<f32> = spans
|
||||||
|
.iter()
|
||||||
|
.map(|(_, _, top, _, bottom)| bottom - top)
|
||||||
|
.filter(|height| height.is_finite() && *height > 0.0)
|
||||||
|
.collect();
|
||||||
|
if line_heights.is_empty() {
|
||||||
|
return markdown.to_string();
|
||||||
|
}
|
||||||
|
line_heights.sort_by(f32::total_cmp);
|
||||||
|
let median_height = line_heights[line_heights.len() / 2];
|
||||||
|
|
||||||
|
// The Markdown converter owns reading order and may normalize syntax such
|
||||||
|
// as list markers. Match every span back to its unique output occurrence,
|
||||||
|
// then use Markdown order rather than imposing a second geometry sort.
|
||||||
|
// If the mapping is incomplete or ambiguous, leave the converter output
|
||||||
|
// untouched instead of risking a break at the wrong duplicate text.
|
||||||
|
let mut mapped = Vec::with_capacity(spans.len());
|
||||||
|
for (text, left, top, right, bottom) in spans {
|
||||||
|
let Some((start, end)) = unique_markdown_span(markdown, text) else {
|
||||||
|
return markdown.to_string();
|
||||||
|
};
|
||||||
|
mapped.push((start, end, left, top, right, bottom));
|
||||||
|
}
|
||||||
|
mapped.sort_by_key(|span| span.0);
|
||||||
|
if mapped
|
||||||
|
.windows(2)
|
||||||
|
.any(|pair| pair[0].1 > pair[1].0 || pair[0].0 == pair[1].0)
|
||||||
|
{
|
||||||
|
return markdown.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut replacements = Vec::new();
|
||||||
|
for pair in mapped.windows(2) {
|
||||||
|
let (_, current_end, current_left, _, current_right, current_bottom) = pair[0];
|
||||||
|
let (next_start, _, next_left, next_top, next_right, _) = pair[1];
|
||||||
|
let overlap = (current_right.min(next_right) - current_left.max(next_left)).max(0.0);
|
||||||
|
let narrowest_width = (current_right - current_left).min(next_right - next_left);
|
||||||
|
let same_text_flow = narrowest_width > 0.0 && overlap >= narrowest_width * 0.2;
|
||||||
|
let separated = next_top - current_bottom >= median_height * 0.65;
|
||||||
|
let between = &markdown[current_end..next_start];
|
||||||
|
if same_text_flow
|
||||||
|
&& separated
|
||||||
|
&& between.chars().all(char::is_whitespace)
|
||||||
|
&& !markdown_line_at(markdown, current_end).is_some_and(is_markdown_table_line)
|
||||||
|
&& !markdown_line_at(markdown, next_start).is_some_and(is_markdown_table_line)
|
||||||
|
{
|
||||||
|
replacements.push((current_end, next_start));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut output = markdown.to_string();
|
||||||
|
for (start, end) in replacements.into_iter().rev() {
|
||||||
|
output.replace_range(start..end, "\n\n");
|
||||||
|
}
|
||||||
|
output
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unique_markdown_span(markdown: &str, span_text: &str) -> Option<(usize, usize)> {
|
||||||
|
let exact: Vec<_> = markdown.match_indices(span_text).collect();
|
||||||
|
match exact.as_slice() {
|
||||||
|
[(start, matched)] => return Some((*start, *start + matched.len())),
|
||||||
|
[] => {}
|
||||||
|
_ => return None,
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalized = strip_list_marker(span_text)?;
|
||||||
|
let matches: Vec<_> = markdown.match_indices(normalized).collect();
|
||||||
|
match matches.as_slice() {
|
||||||
|
[(start, matched)] => Some((*start, *start + matched.len())),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn strip_list_marker(text: &str) -> Option<&str> {
|
||||||
|
const BULLETS: &[char] = &['•', '●', '○', '◦', '▪', '–', '—'];
|
||||||
|
let trimmed = text.trim_start();
|
||||||
|
let remainder = trimmed
|
||||||
|
.strip_prefix(BULLETS)?
|
||||||
|
.trim_start_matches(char::is_whitespace);
|
||||||
|
(!remainder.is_empty()).then_some(remainder)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn markdown_line_at(markdown: &str, offset: usize) -> Option<&str> {
|
||||||
|
if offset > markdown.len() || !markdown.is_char_boundary(offset) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let start = markdown[..offset].rfind('\n').map_or(0, |index| index + 1);
|
||||||
|
let end = markdown[offset..]
|
||||||
|
.find('\n')
|
||||||
|
.map_or(markdown.len(), |index| offset + index);
|
||||||
|
markdown.get(start..end)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_markdown_table_line(line: &str) -> bool {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
trimmed.starts_with('|') && trimmed.ends_with('|') && trimmed.matches('|').count() >= 2
|
||||||
|
}
|
||||||
|
|
||||||
fn merge_native_and_ocr(native: &str, ocr: &str) -> (String, PageContentSource) {
|
fn merge_native_and_ocr(native: &str, ocr: &str) -> (String, PageContentSource) {
|
||||||
let native_keys = comparison_units(native);
|
let native_keys = comparison_units(native);
|
||||||
let mut addition_keys = Vec::new();
|
let mut addition_keys = Vec::new();
|
||||||
@@ -638,6 +756,20 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn positioned_span(text: &str, left: f32, top: f32, right: f32, bottom: f32) -> OcrSpan {
|
||||||
|
OcrSpan {
|
||||||
|
text: text.to_string(),
|
||||||
|
polygon: ImageQuad::new([
|
||||||
|
ImagePoint::new(left, top),
|
||||||
|
ImagePoint::new(right, top),
|
||||||
|
ImagePoint::new(right, bottom),
|
||||||
|
ImagePoint::new(left, bottom),
|
||||||
|
]),
|
||||||
|
confidence: 0.9,
|
||||||
|
orientation_degrees: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn run(pages: Vec<RoutedOcrPage>) -> OcrRun {
|
fn run(pages: Vec<RoutedOcrPage>) -> OcrRun {
|
||||||
OcrRun {
|
OcrRun {
|
||||||
pages,
|
pages,
|
||||||
@@ -674,6 +806,115 @@ mod tests {
|
|||||||
assert!(!result.pages[0].provenance.hosted_recommended);
|
assert!(!result.pages[0].provenance.hosted_recommended);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ocr_assembly_preserves_well_separated_detected_rows() {
|
||||||
|
let page = routed_page(
|
||||||
|
1,
|
||||||
|
vec![
|
||||||
|
positioned_span("Column A Column B", 10.0, 10.0, 190.0, 20.0),
|
||||||
|
positioned_span("First row value", 10.0, 35.0, 190.0, 45.0),
|
||||||
|
positioned_span("Second row value", 10.0, 60.0, 190.0, 70.0),
|
||||||
|
],
|
||||||
|
Some(0.9),
|
||||||
|
);
|
||||||
|
|
||||||
|
let markdown = ocr_page_to_markdown(&page, 1, &MarkdownOptions::default());
|
||||||
|
|
||||||
|
assert!(markdown.contains("Column A Column B\n\n"), "{markdown:?}");
|
||||||
|
assert!(markdown.contains("First row value\n\n"), "{markdown:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn line_break_recovery_uses_markdown_reading_order_for_columns() {
|
||||||
|
let page = routed_page(
|
||||||
|
1,
|
||||||
|
vec![
|
||||||
|
positioned_span("Left top", 10.0, 10.0, 90.0, 20.0),
|
||||||
|
positioned_span("Right top", 110.0, 10.0, 190.0, 20.0),
|
||||||
|
positioned_span("Left bottom", 10.0, 40.0, 90.0, 50.0),
|
||||||
|
positioned_span("Right bottom", 110.0, 40.0, 190.0, 50.0),
|
||||||
|
],
|
||||||
|
Some(0.9),
|
||||||
|
);
|
||||||
|
let markdown = "Left top Left bottom\n\nRight top Right bottom";
|
||||||
|
|
||||||
|
let recovered = preserve_ocr_line_breaks(markdown, &page);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
recovered,
|
||||||
|
"Left top\n\nLeft bottom\n\nRight top\n\nRight bottom"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn line_break_recovery_preserves_tables_but_handles_page_prose() {
|
||||||
|
let page = routed_page(
|
||||||
|
1,
|
||||||
|
vec![
|
||||||
|
positioned_span("A", 10.0, 10.0, 90.0, 20.0),
|
||||||
|
positioned_span("B", 110.0, 10.0, 190.0, 20.0),
|
||||||
|
positioned_span("x", 10.0, 30.0, 90.0, 40.0),
|
||||||
|
positioned_span("y", 110.0, 30.0, 190.0, 40.0),
|
||||||
|
positioned_span("First prose", 10.0, 60.0, 190.0, 70.0),
|
||||||
|
positioned_span("Second prose", 10.0, 90.0, 190.0, 100.0),
|
||||||
|
],
|
||||||
|
Some(0.9),
|
||||||
|
);
|
||||||
|
let markdown = "| A | B |\n|---|---|\n| x | y |\n\nFirst prose Second prose";
|
||||||
|
|
||||||
|
let recovered = preserve_ocr_line_breaks(markdown, &page);
|
||||||
|
|
||||||
|
assert!(recovered.starts_with("| A | B |\n|---|---|\n| x | y |"));
|
||||||
|
assert!(recovered.ends_with("First prose\n\nSecond prose"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn line_break_recovery_accepts_normalized_list_markers() {
|
||||||
|
let page = routed_page(
|
||||||
|
1,
|
||||||
|
vec![
|
||||||
|
positioned_span("• First item", 10.0, 10.0, 190.0, 20.0),
|
||||||
|
positioned_span("Next paragraph", 10.0, 40.0, 190.0, 50.0),
|
||||||
|
],
|
||||||
|
Some(0.9),
|
||||||
|
);
|
||||||
|
|
||||||
|
let recovered = preserve_ocr_line_breaks("- First item Next paragraph", &page);
|
||||||
|
|
||||||
|
assert_eq!(recovered, "- First item\n\nNext paragraph");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn line_break_recovery_accepts_white_bullet_list_markers() {
|
||||||
|
let page = routed_page(
|
||||||
|
1,
|
||||||
|
vec![
|
||||||
|
positioned_span("◦ First item", 10.0, 10.0, 190.0, 20.0),
|
||||||
|
positioned_span("Next paragraph", 10.0, 40.0, 190.0, 50.0),
|
||||||
|
],
|
||||||
|
Some(0.9),
|
||||||
|
);
|
||||||
|
|
||||||
|
let recovered = preserve_ocr_line_breaks("- First item Next paragraph", &page);
|
||||||
|
|
||||||
|
assert_eq!(recovered, "- First item\n\nNext paragraph");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn line_break_recovery_leaves_ambiguous_duplicates_unchanged() {
|
||||||
|
let page = routed_page(
|
||||||
|
1,
|
||||||
|
vec![
|
||||||
|
positioned_span("Repeated", 10.0, 10.0, 190.0, 20.0),
|
||||||
|
positioned_span("Repeated", 10.0, 40.0, 190.0, 50.0),
|
||||||
|
],
|
||||||
|
Some(0.9),
|
||||||
|
);
|
||||||
|
let markdown = "Repeated Repeated";
|
||||||
|
|
||||||
|
assert_eq!(preserve_ocr_line_breaks(markdown, &page), markdown);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn force_mode_deduplicates_native_content() {
|
fn force_mode_deduplicates_native_content() {
|
||||||
let native = [native(0, "Hello, world!\n", false)];
|
let native = [native(0, "Hello, world!\n", false)];
|
||||||
|
|||||||
Reference in New Issue
Block a user