Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dfe64b80e7 | ||
|
|
2401a77c5f | ||
|
|
7a374a3867 | ||
|
|
0027b048ce | ||
|
|
fb45d37dfe | ||
|
|
5eb6a13860 | ||
|
|
74ebce430c | ||
|
|
cf4e42b91c | ||
|
|
d390d402a5 | ||
|
|
84789459b1 |
+150
-12
@@ -403,8 +403,15 @@ pub(crate) fn detect_from_document(
|
||||
&& !(analysis.has_decodable_text_fonts && analysis.text_operator_count >= 10);
|
||||
let looks_like_scan =
|
||||
analysis.image_count <= 1 && analysis.text_operator_count < 50 && alphanum_low;
|
||||
// A template-image page below the `pages_with_text` floor is
|
||||
// a scan with incidental chrome (masthead, stamp, date line)
|
||||
// even when that chrome is diverse, decodable text — keep
|
||||
// this in sync with `page_ocr_signals`.
|
||||
let sparse_text_over_scan = analysis.has_template_image
|
||||
&& analysis.text_operator_count < config.min_text_ops_per_page.max(10);
|
||||
if (analysis.has_template_image && looks_like_scan)
|
||||
|| analysis.has_vector_text
|
||||
|| sparse_text_over_scan
|
||||
|| (analysis.text_operator_count < config.min_text_ops_per_page
|
||||
&& analysis.has_images)
|
||||
{
|
||||
@@ -1795,17 +1802,24 @@ pub(crate) fn analyze_page_images(doc: &Document, page_id: ObjectId) -> (bool, u
|
||||
/// low alphanumeric diversity in raw string operands (unless decodable
|
||||
/// CID/ToUnicode fonts explain that away) — the gate used for
|
||||
/// `pages_with_template_images` and Mixed-type per-page routing.
|
||||
/// 2. Insufficient real text volume, using `DetectionConfig::default()`'s
|
||||
/// `min_text_ops_per_page` (3) — the same threshold Mixed-type per-page
|
||||
/// routing applies via `text_operator_count < config.min_text_ops_per_page
|
||||
/// && has_images` (simplified here since a template image implies
|
||||
/// `has_images`). Deliberately *not* the higher `effective_min_ops`
|
||||
/// floor (`min_text_ops_per_page.max(10)`) that whole-document
|
||||
/// `PdfType::ImageBased`/`Scanned` classification uses for
|
||||
/// `pages_with_text` — that's a cross-page aggregate decision this
|
||||
/// per-page function has no way to replicate exactly, and the lower
|
||||
/// per-page threshold is the one a single page's own signals can
|
||||
/// actually agree with.
|
||||
/// 2. Insufficient real text volume, using the same `effective_min_ops`
|
||||
/// floor (`min_text_ops_per_page.max(10)`) that `pages_with_text`
|
||||
/// applies to image-bearing pages. That floor is a per-page judgment,
|
||||
/// not part of the cross-page aggregate: classification counts a
|
||||
/// template-image page with fewer ops as textless and routes it to OCR,
|
||||
/// so this function must agree. The lower bare threshold (3) let a
|
||||
/// full-page scan carrying a small native masthead — a newspaper
|
||||
/// header, stamp, or date line of ~4 diverse, decodable text ops —
|
||||
/// extract as "a text page" here while whole-document classification
|
||||
/// called the same page scanned, silently dropping the page body from
|
||||
/// OCR routing. `alphanum_low` can't catch that case: masthead chrome
|
||||
/// is real text, so its byte diversity is high.
|
||||
///
|
||||
/// This function always evaluates against `DetectionConfig::default()` —
|
||||
/// it has no config parameter, and the per-page extraction path that calls
|
||||
/// it never carries one. A caller passing a custom `min_text_ops_per_page`
|
||||
/// to `detect_from_document` affects whole-document detection only; the
|
||||
/// two paths agree under the default configuration.
|
||||
///
|
||||
/// `has_vector_text` is true when a page has vector-outlined text (glyphs
|
||||
/// drawn as paths rather than shown via text-showing operators) —
|
||||
@@ -1827,7 +1841,7 @@ pub(crate) fn page_ocr_signals(doc: &Document, page_id: ObjectId) -> (bool, bool
|
||||
let looks_like_scan =
|
||||
analysis.image_count <= 1 && analysis.text_operator_count < 50 && alphanum_low;
|
||||
let insufficient_text =
|
||||
analysis.text_operator_count < DetectionConfig::default().min_text_ops_per_page;
|
||||
analysis.text_operator_count < DetectionConfig::default().min_text_ops_per_page.max(10);
|
||||
looks_like_scan || insufficient_text
|
||||
};
|
||||
|
||||
@@ -2995,6 +3009,130 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- masthead-over-scan tests: template image + sparse chrome ----------
|
||||
|
||||
/// Builds a page whose only image is a full-page scan inside a Form
|
||||
/// XObject, plus `masthead_ops` native text-show ops of diverse,
|
||||
/// decodable chrome (newspaper masthead / date line style).
|
||||
fn masthead_scan_page(masthead_lines: &[&str]) -> (Document, ObjectId) {
|
||||
use lopdf::dictionary;
|
||||
let mut doc = Document::with_version("1.4");
|
||||
let pages_id = doc.new_object_id();
|
||||
let page_id = doc.new_object_id();
|
||||
|
||||
let image_id = doc.add_object(Object::Stream(lopdf::Stream::new(
|
||||
dictionary! {
|
||||
"Type" => "XObject",
|
||||
"Subtype" => Object::Name(b"Image".to_vec()),
|
||||
"Width" => Object::Integer(1500),
|
||||
"Height" => Object::Integer(2383),
|
||||
},
|
||||
Vec::new(),
|
||||
)));
|
||||
let form_id = doc.add_object(Object::Stream(lopdf::Stream::new(
|
||||
dictionary! {
|
||||
"Type" => "XObject",
|
||||
"Subtype" => Object::Name(b"Form".to_vec()),
|
||||
"Resources" => dictionary! {
|
||||
"XObject" => dictionary! {
|
||||
"Im0" => Object::Reference(image_id),
|
||||
},
|
||||
},
|
||||
},
|
||||
b"1500 0 0 2383 0 0 cm /Im0 Do".to_vec(),
|
||||
)));
|
||||
let font_id = doc.add_object(dictionary! {
|
||||
"Type" => "Font",
|
||||
"Subtype" => Object::Name(b"Type1".to_vec()),
|
||||
"BaseFont" => Object::Name(b"Helvetica".to_vec()),
|
||||
});
|
||||
|
||||
let mut content = b"q /Fm0 Do Q BT /F1 12 Tf ".to_vec();
|
||||
for line in masthead_lines {
|
||||
content.extend_from_slice(format!("({line}) Tj ").as_bytes());
|
||||
}
|
||||
content.extend_from_slice(b"ET");
|
||||
let content_id =
|
||||
doc.add_object(Object::Stream(lopdf::Stream::new(dictionary! {}, content)));
|
||||
|
||||
doc.objects.insert(
|
||||
page_id,
|
||||
Object::Dictionary(dictionary! {
|
||||
"Type" => "Page",
|
||||
"Parent" => Object::Reference(pages_id),
|
||||
"MediaBox" => vec![0.into(), 0.into(), 1500.into(), 2383.into()],
|
||||
"Resources" => dictionary! {
|
||||
"Font" => dictionary! { "F1" => Object::Reference(font_id) },
|
||||
"XObject" => dictionary! { "Fm0" => Object::Reference(form_id) },
|
||||
},
|
||||
"Contents" => Object::Reference(content_id),
|
||||
}),
|
||||
);
|
||||
doc.objects.insert(
|
||||
pages_id,
|
||||
Object::Dictionary(dictionary! {
|
||||
"Type" => "Pages",
|
||||
"Kids" => vec![Object::Reference(page_id)],
|
||||
"Count" => Object::Integer(1),
|
||||
}),
|
||||
);
|
||||
(doc, page_id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_masthead_over_form_wrapped_scan_needs_ocr() {
|
||||
// A full-page scan wrapped in a Form XObject with ~4 ops of real,
|
||||
// diverse masthead text. `alphanum_low` can't flag it (the chrome is
|
||||
// genuine text), so the sparse-text floor must: without OCR the page
|
||||
// body is silently lost while classification calls the page scanned.
|
||||
let (doc, page_id) = masthead_scan_page(&[
|
||||
"18",
|
||||
"FINANCIAL EXPRESS",
|
||||
"WWW.FINANCIALEXPRESS.COM",
|
||||
"FRIDAY, DECEMBER 13, 2024",
|
||||
]);
|
||||
let analysis = analyze_page_content(&doc, page_id);
|
||||
assert!(
|
||||
analysis.has_template_image,
|
||||
"sanity: full-page image inside the form must be found"
|
||||
);
|
||||
assert!(
|
||||
analysis.unique_alphanum_chars >= 10,
|
||||
"sanity: masthead text is diverse, alphanum_low cannot fire"
|
||||
);
|
||||
let (needs_ocr, _) = page_ocr_signals(&doc, page_id);
|
||||
assert!(
|
||||
needs_ocr,
|
||||
"template image + text below the pages_with_text floor is a scan"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_text_page_over_background_image_stays_native() {
|
||||
// Counterpart: a real text page over a full-page background image
|
||||
// (letterhead/watermark) has enough text ops to clear the
|
||||
// `pages_with_text` floor and must NOT be routed to OCR.
|
||||
let lines: Vec<String> = (0..12)
|
||||
.map(|i| format!("Paragraph line {i} with ordinary body text"))
|
||||
.collect();
|
||||
let refs: Vec<&str> = lines.iter().map(String::as_str).collect();
|
||||
let (doc, page_id) = masthead_scan_page(&refs);
|
||||
let analysis = analyze_page_content(&doc, page_id);
|
||||
assert!(
|
||||
analysis.has_template_image,
|
||||
"sanity: background image found"
|
||||
);
|
||||
assert!(
|
||||
analysis.text_operator_count >= 10,
|
||||
"sanity: body text clears the floor"
|
||||
);
|
||||
let (needs_ocr, _) = page_ocr_signals(&doc, page_id);
|
||||
assert!(
|
||||
!needs_ocr,
|
||||
"a text page with a background image must stay native"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- P2 tests: Form XObject font traversal ----------
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -561,7 +561,11 @@ pub(crate) fn extract_page_text_items(
|
||||
y,
|
||||
width,
|
||||
height: rendered_size,
|
||||
font: current_font.clone(),
|
||||
font: crate::extractor::fonts::item_font_name(
|
||||
¤t_font,
|
||||
base_font,
|
||||
)
|
||||
.to_string(),
|
||||
font_size: rendered_size,
|
||||
page: page_num,
|
||||
is_bold: is_bold_font(base_font) || desc_bold,
|
||||
@@ -745,7 +749,11 @@ pub(crate) fn extract_page_text_items(
|
||||
y,
|
||||
width,
|
||||
height: rendered_size,
|
||||
font: current_font.clone(),
|
||||
font: crate::extractor::fonts::item_font_name(
|
||||
¤t_font,
|
||||
base_font,
|
||||
)
|
||||
.to_string(),
|
||||
font_size: rendered_size,
|
||||
page: page_num,
|
||||
is_bold: is_bold_font(base_font) || desc_bold,
|
||||
@@ -852,7 +860,11 @@ pub(crate) fn extract_page_text_items(
|
||||
y,
|
||||
width,
|
||||
height: rendered_size,
|
||||
font: current_font.clone(),
|
||||
font: crate::extractor::fonts::item_font_name(
|
||||
¤t_font,
|
||||
base_font,
|
||||
)
|
||||
.to_string(),
|
||||
font_size: rendered_size,
|
||||
page: page_num,
|
||||
is_bold: is_bold_font(base_font) || desc_bold,
|
||||
@@ -1005,7 +1017,11 @@ pub(crate) fn extract_page_text_items(
|
||||
y,
|
||||
width,
|
||||
height: rendered_size,
|
||||
font: current_font.clone(),
|
||||
font: crate::extractor::fonts::item_font_name(
|
||||
¤t_font,
|
||||
base_font,
|
||||
)
|
||||
.to_string(),
|
||||
font_size: rendered_size,
|
||||
page: page_num,
|
||||
is_bold: is_bold_font(base_font) || desc_bold,
|
||||
|
||||
@@ -225,6 +225,26 @@ pub(crate) fn build_type3_scales(
|
||||
scales
|
||||
}
|
||||
|
||||
/// The name a `TextItem` carries for its font: the `/BaseFont` family name
|
||||
/// ("ABCDEF+CMMI10"), which identifies the actual face, rather than the
|
||||
/// arbitrary per-page resource tag ("F2").
|
||||
///
|
||||
/// Exception: resource names using Distiller's CID convention (`C2_0`,
|
||||
/// `C0_1`) are kept as-is — `text_utils::is_cid_font` keys on that prefix
|
||||
/// for micro-gap joining, and the family name carries no CID marker to
|
||||
/// replace it. This is a known, deliberate wart: `TextItem::font` is the
|
||||
/// face name except for this one producer convention. The clean fix is an
|
||||
/// explicit CID flag on `TextItem`, which touches its ~29 construction
|
||||
/// sites; do that migration when `TextItem` next changes shape, and delete
|
||||
/// this carve-out with it.
|
||||
pub(crate) fn item_font_name<'a>(resource_name: &'a str, base_font: &'a str) -> &'a str {
|
||||
if crate::text_utils::is_cid_font(resource_name) {
|
||||
resource_name
|
||||
} else {
|
||||
base_font
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse font widths from a font dictionary, dispatching by Subtype
|
||||
pub(crate) fn parse_font_widths(
|
||||
doc: &Document,
|
||||
@@ -1664,6 +1684,17 @@ fn score_text(text: &str) -> i32 {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
#[test]
|
||||
fn item_font_name_prefers_family_over_resource_tag() {
|
||||
use super::item_font_name;
|
||||
assert_eq!(item_font_name("F2", "ABCDEF+CMMI10"), "ABCDEF+CMMI10");
|
||||
assert_eq!(item_font_name("T22", "Times-Roman"), "Times-Roman");
|
||||
// Distiller CID-convention resources keep the resource name:
|
||||
// is_cid_font keys on the C2_/C0_ prefix for micro-gap joining.
|
||||
assert_eq!(item_font_name("C2_0", "ABCDEE+SimSun"), "C2_0");
|
||||
assert_eq!(item_font_name("C0_1", "ABCDEE+MSMincho"), "C0_1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type3_scale_resolves_indirect_matrix_and_bbox_numbers() {
|
||||
use lopdf::{dictionary, Document, Object};
|
||||
|
||||
+954
-62
File diff suppressed because it is too large
Load Diff
@@ -620,7 +620,11 @@ fn extract_form_xobject_text_inner(
|
||||
y,
|
||||
width,
|
||||
height: rendered_size,
|
||||
font: current_font.clone(),
|
||||
font: crate::extractor::fonts::item_font_name(
|
||||
¤t_font,
|
||||
base_font,
|
||||
)
|
||||
.to_string(),
|
||||
font_size: rendered_size,
|
||||
page: page_num,
|
||||
is_bold: is_bold_font(base_font) || desc_bold,
|
||||
@@ -775,7 +779,11 @@ fn extract_form_xobject_text_inner(
|
||||
y,
|
||||
width,
|
||||
height: rendered_size,
|
||||
font: current_font.clone(),
|
||||
font: crate::extractor::fonts::item_font_name(
|
||||
¤t_font,
|
||||
base_font,
|
||||
)
|
||||
.to_string(),
|
||||
font_size: rendered_size,
|
||||
page: page_num,
|
||||
is_bold: is_bold_font(base_font) || desc_bold,
|
||||
|
||||
@@ -203,9 +203,42 @@ pub(crate) fn is_code_like(text: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// True when a line's text is essentially all monospace (≥90% by character
|
||||
/// count). Code lines are wholly monospace; anything less is prose carrying
|
||||
/// mono-styled fragments — a URL sidebar, or a sentence quoting an inline
|
||||
/// code literal — and fencing it would split paragraphs mid-sentence.
|
||||
/// Any-item matching was safe only while items carried opaque font resource
|
||||
/// names that never matched the monospace patterns; items now carry real
|
||||
/// family names.
|
||||
pub(crate) fn line_is_monospace(line: &crate::types::TextLine) -> bool {
|
||||
let mut monospace_chars = 0usize;
|
||||
let mut total_chars = 0usize;
|
||||
for item in &line.items {
|
||||
let text = item.text.trim();
|
||||
let chars = text.chars().count();
|
||||
total_chars += chars;
|
||||
// Hyperlinks and underlined text set in a mono face are link
|
||||
// styling, not code — a URL sidebar must not fence lyric lines.
|
||||
let looks_like_link = item.is_underline
|
||||
|| matches!(item.item_type, crate::types::ItemType::Link(_))
|
||||
|| text.contains("://")
|
||||
|| text.starts_with("www.");
|
||||
if is_monospace_font(&item.font) && !looks_like_link {
|
||||
monospace_chars += chars;
|
||||
}
|
||||
}
|
||||
total_chars > 0 && monospace_chars * 10 >= total_chars * 9
|
||||
}
|
||||
|
||||
/// Check if font name indicates monospace
|
||||
pub(crate) fn is_monospace_font(font_name: &str) -> bool {
|
||||
let lower = font_name.to_lowercase();
|
||||
// "Monotype" is a foundry prefix on proportional faces (Monotype
|
||||
// Corsiva, Monotype Garamond) — it must not satisfy the generic "mono"
|
||||
// token below.
|
||||
if lower.contains("monotype") {
|
||||
return false;
|
||||
}
|
||||
let patterns = [
|
||||
"courier",
|
||||
"consolas",
|
||||
@@ -230,6 +263,17 @@ pub(crate) fn is_monospace_font(font_name: &str) -> bool {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn monotype_foundry_faces_are_not_monospace() {
|
||||
// "Monotype" is a foundry prefix on proportional faces; the generic
|
||||
// "mono" token must not classify them as code fonts.
|
||||
assert!(!is_monospace_font("MonotypeCorsiva"));
|
||||
assert!(!is_monospace_font("ABCDEF+Monotype-Garamond"));
|
||||
assert!(is_monospace_font("RobotoMono-Regular"));
|
||||
assert!(is_monospace_font("PTMono"));
|
||||
assert!(is_monospace_font("Courier"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_list_item_plain_bullet() {
|
||||
assert_eq!(format_list_item("● Item"), "- Item");
|
||||
|
||||
+52
-28
@@ -11,9 +11,7 @@ use super::analysis::{
|
||||
detect_header_level, font_size_rarity, has_dot_leaders, is_heading_fragment, is_toc_entry_line,
|
||||
is_toc_marker_heading,
|
||||
};
|
||||
use super::classify::{
|
||||
format_list_item, is_caption_line, is_list_item, is_monospace_font, starts_with_bullet_marker,
|
||||
};
|
||||
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};
|
||||
@@ -771,7 +769,27 @@ pub(super) fn to_markdown_from_lines_with_tables_and_images(
|
||||
let mut in_list = false;
|
||||
let mut in_paragraph = false;
|
||||
let mut last_list_x: Option<f32> = None;
|
||||
// Code lines accumulate here and the fence is emitted only when the
|
||||
// block flushes with content — an empty ``` ``` pair can never appear.
|
||||
fn flush_code_block(output: &mut String, pending_code: &mut String) {
|
||||
let trimmed = pending_code.trim();
|
||||
// A fragment too short to be code — a lone ® or stray glyph set in
|
||||
// a mono face — reads better as plain text than as a fenced block.
|
||||
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;
|
||||
@@ -805,7 +823,7 @@ pub(super) fn to_markdown_from_lines_with_tables_and_images(
|
||||
// Flush current page's remaining tables and images
|
||||
if current_page > 0 {
|
||||
if in_code_block {
|
||||
output.push_str("```\n");
|
||||
flush_code_block(&mut output, &mut pending_code);
|
||||
in_code_block = false;
|
||||
}
|
||||
flush_page_tables_and_images(
|
||||
@@ -867,6 +885,14 @@ pub(super) fn to_markdown_from_lines_with_tables_and_images(
|
||||
PositionedBlockKind::Image => inserted_images.contains(&(current_page, idx)),
|
||||
};
|
||||
if positioned_block_precedes_line(block, line) && !already_inserted {
|
||||
// Code lines buffer until their block closes; flush them
|
||||
// first so this block cannot jump ahead of code that
|
||||
// precedes it in reading order. A code line after the
|
||||
// block reopens a new fence naturally.
|
||||
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;
|
||||
@@ -937,15 +963,22 @@ pub(super) fn to_markdown_from_lines_with_tables_and_images(
|
||||
// These should be on their own line followed by a paragraph break
|
||||
let struct_role = struct_roles.and_then(|roles| resolve_line_struct_role(line, roles));
|
||||
|
||||
// Determine if this line is code (struct-tree or font-based) for block accumulation
|
||||
// Determine if this line is code (struct-tree or font-based) for
|
||||
// block accumulation. Font-based detection only opens a block at a
|
||||
// paragraph boundary: a mono-set line that continues an open prose
|
||||
// paragraph is the producer smearing an inline code literal's style
|
||||
// across a wrapped line (HTML-to-PDF exports do this), and fencing
|
||||
// it would cut the sentence in three.
|
||||
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)));
|
||||
|| (options.detect_code
|
||||
&& (in_code_block || !in_paragraph)
|
||||
&& super::classify::line_is_monospace(line));
|
||||
|
||||
// Close code block when transitioning to non-code
|
||||
if in_code_block && !is_code_line {
|
||||
output.push_str("```\n");
|
||||
flush_code_block(&mut output, &mut pending_code);
|
||||
in_code_block = false;
|
||||
}
|
||||
|
||||
@@ -1179,12 +1212,9 @@ pub(super) fn to_markdown_from_lines_with_tables_and_images(
|
||||
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');
|
||||
in_code_block = true;
|
||||
pending_code.push_str(plain_trimmed);
|
||||
pending_code.push('\n');
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1209,7 +1239,7 @@ pub(super) fn to_markdown_from_lines_with_tables_and_images(
|
||||
|
||||
// Close any trailing code block
|
||||
if in_code_block {
|
||||
output.push_str("```\n");
|
||||
flush_code_block(&mut output, &mut pending_code);
|
||||
}
|
||||
|
||||
// Flush current page and any remaining pages with tables/images
|
||||
@@ -1370,7 +1400,7 @@ pub fn to_markdown_from_lines(lines: Vec<TextLine>, options: MarkdownOptions) ->
|
||||
&& !is_toc_entry_line(plain_trimmed)
|
||||
&& !is_heading_fragment(plain_trimmed)
|
||||
&& toc_suppress_page != Some(line.page)
|
||||
&& !(options.detect_code && line.items.iter().any(|i| is_monospace_font(&i.font)))
|
||||
&& !(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(
|
||||
@@ -1471,19 +1501,13 @@ pub fn to_markdown_from_lines(lines: Vec<TextLine>, options: MarkdownOptions) ->
|
||||
}
|
||||
}
|
||||
|
||||
// Detect code blocks by font
|
||||
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;
|
||||
}
|
||||
// Use plain text for code blocks
|
||||
output.push_str(&format!("```\n{}\n```\n", plain_trimmed));
|
||||
continue;
|
||||
}
|
||||
// Detect code blocks by font. Only at a paragraph boundary — a
|
||||
// mono-set line continuing an open prose paragraph is an inline
|
||||
// code literal's style smeared across a wrapped line, not code.
|
||||
if options.detect_code && !in_paragraph && super::classify::line_is_monospace(line) {
|
||||
// Use plain text for code blocks
|
||||
output.push_str(&format!("```\n{}\n```\n", plain_trimmed));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Regular text - join lines within same paragraph with space
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+48
-8
@@ -9,6 +9,7 @@
|
||||
pub(crate) mod analysis;
|
||||
mod classify;
|
||||
mod convert;
|
||||
mod furniture;
|
||||
mod heading;
|
||||
mod postprocess;
|
||||
mod preprocess;
|
||||
@@ -634,7 +635,12 @@ fn is_parallel_prose_table(table: &crate::tables::Table) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
let is_parallel = !has_compact_header
|
||||
// A compact header row is evidence for a real table — unless cross-row
|
||||
// prose continuations outnumber the rows, which no genuine table
|
||||
// produces: the "header" is then just two short line fragments at the
|
||||
// top of parallel prose columns.
|
||||
let header_blocks = has_compact_header && continuation_fragments <= table.cells.len();
|
||||
let is_parallel = !header_blocks
|
||||
&& non_empty >= 5
|
||||
// Independent prose columns have asynchronous line/paragraph breaks;
|
||||
// a fully populated grid is positive evidence for a real descriptive
|
||||
@@ -1149,7 +1155,7 @@ pub(crate) fn strip_repeated_header_footer_lines(
|
||||
lines: Vec<crate::types::TextLine>,
|
||||
page_count: u32,
|
||||
) -> Vec<crate::types::TextLine> {
|
||||
preprocess::strip_repeated_lines(lines, page_count)
|
||||
furniture::strip_header_footer_lines(lines, page_count)
|
||||
}
|
||||
|
||||
/// Convert positioned text items to markdown with structure detection
|
||||
@@ -1374,7 +1380,6 @@ pub(crate) fn to_markdown_from_items_with_rects_and_lines(
|
||||
chart_page_prose_column_split(&page_layout_items)
|
||||
.filter(|&split_x| chart_spans_prose_split(region, split_x))
|
||||
});
|
||||
let chart_prose_columns = chart_prose_split.is_some();
|
||||
|
||||
// Check for side-by-side table layout using the original items. Sparse
|
||||
// numeric cells need table context before they can be distinguished
|
||||
@@ -1615,10 +1620,16 @@ pub(crate) fn to_markdown_from_items_with_rects_and_lines(
|
||||
if subset_items.len() < min_items {
|
||||
return;
|
||||
}
|
||||
// Keep body-font detection available on chart pages: a real
|
||||
// table can share the prose anchors. Reject only candidates
|
||||
// whose cells prove they are parallel prose fragments.
|
||||
let reject_parallel_prose = chart_prose_columns && !was_split;
|
||||
// Reject candidates whose cells prove they are parallel
|
||||
// prose fragments — the shape produced when the body-font
|
||||
// pass projects a multi-column text page onto one table
|
||||
// grid (two-column reference sections are the classic
|
||||
// case). The check needs internal transition evidence
|
||||
// (unterminated cells flowing into lowercase starts in
|
||||
// the same column), so genuine tables with long cells
|
||||
// pass. Band-split retries stay exempt: they exist for
|
||||
// tables that only assemble after recombining bands.
|
||||
let reject_parallel_prose = !was_split;
|
||||
let tables = detect_tables_with_page_width(
|
||||
subset_items,
|
||||
base_size,
|
||||
@@ -2106,7 +2117,7 @@ pub(crate) fn to_markdown_from_items_with_rects_and_lines(
|
||||
|
||||
// Strip repeated headers/footers before conversion
|
||||
let lines = if options.strip_headers_footers {
|
||||
preprocess::strip_repeated_lines(lines, document_page_count)
|
||||
furniture::strip_header_footer_lines(lines, document_page_count)
|
||||
} else {
|
||||
lines
|
||||
};
|
||||
@@ -2673,6 +2684,35 @@ mod tests {
|
||||
);
|
||||
assert!(!is_parallel_prose_table(&data));
|
||||
|
||||
// A compact header row atop parallel prose columns: cross-row prose
|
||||
// continuations outnumber the rows, so the header cannot save the
|
||||
// candidate — this is page prose with two short fragments on top.
|
||||
let headed_parallel_prose = crate::tables::Table::new(
|
||||
vec![90.0, 340.0],
|
||||
vec![340.0, 320.0, 300.0, 280.0, 260.0],
|
||||
vec![
|
||||
vec!["June 2023".into(), "Page 5".into()],
|
||||
vec![
|
||||
"the committee reviewed the proposal and decided that the".into(),
|
||||
"funding for the second phase would continue subject to the".into(),
|
||||
],
|
||||
vec![
|
||||
"implementation schedule should be extended by another".into(),
|
||||
"quarterly reviews established during the first phase of the".into(),
|
||||
],
|
||||
vec![
|
||||
"six months to accommodate the revised procurement rules".into(),
|
||||
"".into(),
|
||||
],
|
||||
vec![
|
||||
"adopted at the previous meeting of the governing board".into(),
|
||||
"participating institutions across the partner regions".into(),
|
||||
],
|
||||
],
|
||||
(0..10).collect(),
|
||||
);
|
||||
assert!(is_parallel_prose_table(&headed_parallel_prose));
|
||||
|
||||
let headed_text_table = crate::tables::Table::new(
|
||||
vec![90.0, 340.0],
|
||||
vec![320.0, 300.0, 280.0],
|
||||
|
||||
+1
-384
@@ -1,6 +1,6 @@
|
||||
//! Line preprocessing: heading merging, drop cap handling, and repeated line removal.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::structure_tree::StructRole;
|
||||
use crate::types::{TextItem, TextLine};
|
||||
@@ -229,342 +229,6 @@ pub(crate) fn merge_drop_caps(lines: Vec<TextLine>, base_size: f32) -> Vec<TextL
|
||||
result
|
||||
}
|
||||
|
||||
/// Normalize whitespace in a string for comparison: trim and collapse internal runs of whitespace.
|
||||
fn normalize_whitespace(s: &str) -> String {
|
||||
s.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
/// Normalize text for frequency comparison: collapse whitespace and strip leading/trailing
|
||||
/// digit sequences (page numbers). E.g., "Chapter 3 — Page 5" and "Chapter 3 — Page 6"
|
||||
/// both normalize to "Chapter 3 — Page".
|
||||
fn normalize_for_comparison(s: &str) -> String {
|
||||
let ws = normalize_whitespace(s);
|
||||
let trimmed = ws
|
||||
.trim_start_matches(|c: char| c.is_ascii_digit())
|
||||
.trim_start();
|
||||
let trimmed = trimmed
|
||||
.trim_end_matches(|c: char| c.is_ascii_digit())
|
||||
.trim_end();
|
||||
trimmed.to_string()
|
||||
}
|
||||
|
||||
/// Returns true if the line looks like a list item or heading (should not be stripped).
|
||||
fn is_structural_line(text: &str) -> bool {
|
||||
let t = text.trim_start();
|
||||
t.starts_with('#')
|
||||
|| t.starts_with("- ")
|
||||
|| t.starts_with("* ")
|
||||
|| t.starts_with("• ")
|
||||
|| t.chars()
|
||||
.next()
|
||||
.map(|c| c.is_ascii_digit())
|
||||
.unwrap_or(false)
|
||||
&& (t.contains(". ") || t.contains(") "))
|
||||
}
|
||||
|
||||
/// Returns true if a line consists entirely of a single repeated character
|
||||
/// (e.g., "----------", "**************", "============").
|
||||
fn is_decorative_separator(text: &str) -> bool {
|
||||
let mut chars = text.chars();
|
||||
let first = match chars.next() {
|
||||
Some(c) => c,
|
||||
None => return false,
|
||||
};
|
||||
chars.all(|c| c == first)
|
||||
}
|
||||
|
||||
/// Strip lines that repeat on many distinct pages (running headers/footers).
|
||||
///
|
||||
/// A line is considered a repeated header/footer if:
|
||||
/// 1. Its normalized text appears on `>= max(3, page_count * 30%)` distinct pages
|
||||
/// 2. It is at least 10 characters long
|
||||
/// 3. It doesn't look like a structural element (heading, list item)
|
||||
/// 4. It consistently appears in the top or bottom N distinct Y positions
|
||||
/// 5. Its Y positions across pages have low variance (consistent placement),
|
||||
/// distinguishing true headers/footers from table content that happens to
|
||||
/// land near page margins
|
||||
/// 6. It is not a decorative separator (repeated single character)
|
||||
///
|
||||
/// Additionally, TextLines at the same Y position on a page are grouped into
|
||||
/// "Y-bands." When any member of a Y-band is stripped, all siblings in that
|
||||
/// band are also stripped. This handles split column headers where individual
|
||||
/// fragments may not independently meet the frequency threshold.
|
||||
///
|
||||
/// Page numbers are stripped from line text before comparison, so headers like
|
||||
/// "Chapter 3 — Page 5" and "Chapter 3 — Page 6" are treated as the same text.
|
||||
pub(crate) fn strip_repeated_lines(lines: Vec<TextLine>, page_count: u32) -> Vec<TextLine> {
|
||||
if lines.is_empty() || page_count < 3 {
|
||||
return lines;
|
||||
}
|
||||
|
||||
// Compute Y range per page (min_y, max_y)
|
||||
let mut page_y_range: HashMap<u32, (f32, f32)> = HashMap::new();
|
||||
for line in &lines {
|
||||
let entry = page_y_range.entry(line.page).or_insert((line.y, line.y));
|
||||
if line.y < entry.0 {
|
||||
entry.0 = line.y;
|
||||
}
|
||||
if line.y > entry.1 {
|
||||
entry.1 = line.y;
|
||||
}
|
||||
}
|
||||
|
||||
// Build sorted Y values per page, so we can check line rank (position from edge)
|
||||
let mut page_sorted_ys: HashMap<u32, Vec<f32>> = HashMap::new();
|
||||
for line in &lines {
|
||||
page_sorted_ys.entry(line.page).or_default().push(line.y);
|
||||
}
|
||||
for ys in page_sorted_ys.values_mut() {
|
||||
ys.sort_by(|a, b| a.total_cmp(b));
|
||||
ys.dedup();
|
||||
}
|
||||
|
||||
// A line is in the page margin if it's among the first or last N distinct
|
||||
// Y positions on that page. This is more robust than a percentage-based zone
|
||||
// because it catches actual edge lines regardless of how much content fills
|
||||
// the page. N=5 accommodates multi-line headers/footers and repeated form
|
||||
// column headers (e.g., 5-row IRS form headers) that sit just inside the
|
||||
// page margin.
|
||||
const EDGE_LINE_COUNT: usize = 5;
|
||||
|
||||
/// Returns true if the given Y position is among the first or last N distinct
|
||||
/// Y positions on the specified page.
|
||||
fn is_y_at_edge(y: f32, page: u32, page_sorted_ys: &HashMap<u32, Vec<f32>>, n: usize) -> bool {
|
||||
let ys = match page_sorted_ys.get(&page) {
|
||||
Some(ys) => ys,
|
||||
None => return false,
|
||||
};
|
||||
if ys.len() <= n * 2 {
|
||||
// Page has very few lines — everything is near the edge
|
||||
return true;
|
||||
}
|
||||
// Check if this Y is among the first or last N
|
||||
let pos = match ys.iter().position(|&py| (py - y).abs() < 0.1) {
|
||||
Some(p) => p,
|
||||
None => return false,
|
||||
};
|
||||
pos < n || pos >= ys.len() - n
|
||||
}
|
||||
|
||||
// Average page span for normalizing Y variance
|
||||
let avg_span = {
|
||||
let total: f32 = page_y_range.values().map(|(lo, hi)| hi - lo).sum();
|
||||
if page_y_range.is_empty() {
|
||||
1.0
|
||||
} else {
|
||||
(total / page_y_range.len() as f32).max(1.0)
|
||||
}
|
||||
};
|
||||
|
||||
// Build Y-bands: group line indices by (page, quantized_y).
|
||||
// Lines at the same Y position (within ~0.1pt) on the same page form a band.
|
||||
let mut y_bands: HashMap<(u32, i32), Vec<usize>> = HashMap::new();
|
||||
for (idx, line) in lines.iter().enumerate() {
|
||||
let y_bucket = (line.y * 10.0).round() as i32;
|
||||
y_bands.entry((line.page, y_bucket)).or_default().push(idx);
|
||||
}
|
||||
|
||||
// Build frequency maps using normalize_for_comparison.
|
||||
// Individual line text -> distinct pages
|
||||
let mut freq: HashMap<String, HashSet<u32>> = HashMap::new();
|
||||
let mut y_positions: HashMap<String, Vec<f32>> = HashMap::new();
|
||||
for line in &lines {
|
||||
if !is_y_at_edge(line.y, line.page, &page_sorted_ys, EDGE_LINE_COUNT) {
|
||||
continue;
|
||||
}
|
||||
let text = line.text();
|
||||
let normalized = normalize_for_comparison(&text);
|
||||
if normalized.len() < 10 || is_decorative_separator(&normalized) {
|
||||
continue;
|
||||
}
|
||||
freq.entry(normalized.clone())
|
||||
.or_default()
|
||||
.insert(line.page);
|
||||
y_positions.entry(normalized).or_default().push(line.y);
|
||||
}
|
||||
|
||||
// Coalesced row text -> distinct pages (for multi-member Y-bands).
|
||||
// This catches split column headers where individual fragments don't meet
|
||||
// the frequency threshold but the combined row does.
|
||||
let mut band_freq: HashMap<String, HashSet<u32>> = HashMap::new();
|
||||
let mut band_y_positions: HashMap<String, Vec<f32>> = HashMap::new();
|
||||
for (&(page, _), indices) in &y_bands {
|
||||
if indices.len() < 2 {
|
||||
continue; // single-line bands are already in the individual map
|
||||
}
|
||||
let band_y = lines[indices[0]].y;
|
||||
if !is_y_at_edge(band_y, page, &page_sorted_ys, EDGE_LINE_COUNT) {
|
||||
continue;
|
||||
}
|
||||
let mut sorted_indices = indices.clone();
|
||||
sorted_indices.sort();
|
||||
let coalesced: String = sorted_indices
|
||||
.iter()
|
||||
.map(|&i| lines[i].text())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let normalized = normalize_for_comparison(&coalesced);
|
||||
if normalized.len() < 10 || is_decorative_separator(&normalized) {
|
||||
continue;
|
||||
}
|
||||
band_freq
|
||||
.entry(normalized.clone())
|
||||
.or_default()
|
||||
.insert(page);
|
||||
band_y_positions.entry(normalized).or_default().push(band_y);
|
||||
}
|
||||
|
||||
// Compute threshold
|
||||
let threshold = 3u32.max(page_count * 30 / 100);
|
||||
|
||||
// Check Y-position consistency: headers/footers appear at the same position
|
||||
// on every page, table content varies. Require normalized stddev < 5% of
|
||||
// average page span.
|
||||
let has_consistent_y = |text: &str, positions: &HashMap<String, Vec<f32>>| -> bool {
|
||||
let pos = match positions.get(text) {
|
||||
Some(p) if p.len() >= 2 => p,
|
||||
_ => return true, // single occurrence — allow
|
||||
};
|
||||
let n = pos.len() as f32;
|
||||
let mean = pos.iter().sum::<f32>() / n;
|
||||
let variance = pos.iter().map(|y| (y - mean).powi(2)).sum::<f32>() / n;
|
||||
let stddev = variance.sqrt();
|
||||
stddev / avg_span < 0.05
|
||||
};
|
||||
|
||||
// Identify candidates from individual frequency map
|
||||
let candidates: HashSet<String> = freq
|
||||
.into_iter()
|
||||
.filter(|(text, pages)| {
|
||||
pages.len() as u32 >= threshold
|
||||
&& !is_structural_line(text)
|
||||
&& has_consistent_y(text, &y_positions)
|
||||
})
|
||||
.map(|(text, _)| text)
|
||||
.collect();
|
||||
|
||||
// Identify candidates from coalesced band frequency map
|
||||
let band_candidates: HashSet<String> = band_freq
|
||||
.into_iter()
|
||||
.filter(|(text, pages)| {
|
||||
pages.len() as u32 >= threshold
|
||||
&& !is_structural_line(text)
|
||||
&& has_consistent_y(text, &band_y_positions)
|
||||
})
|
||||
.map(|(text, _)| text)
|
||||
.collect();
|
||||
|
||||
if candidates.is_empty() && band_candidates.is_empty() {
|
||||
return lines;
|
||||
}
|
||||
|
||||
// Build removal set.
|
||||
// A line is removed if it's at an edge position and:
|
||||
// (a) its individual text matches a candidate, OR
|
||||
// (b) its Y-band's coalesced text matches a band candidate, OR
|
||||
// (c) any sibling in its Y-band was removed (propagation).
|
||||
//
|
||||
// The first occurrence (lowest page number) of each repeated header/footer
|
||||
// is kept so that document titles, column headers, etc. appear once.
|
||||
let mut removal_set: HashSet<usize> = HashSet::new();
|
||||
|
||||
// Track which page first shows each candidate (to preserve first occurrence)
|
||||
let mut first_page_individual: HashMap<String, u32> = HashMap::new();
|
||||
for (idx, line) in lines.iter().enumerate() {
|
||||
if !is_y_at_edge(line.y, line.page, &page_sorted_ys, EDGE_LINE_COUNT) {
|
||||
continue;
|
||||
}
|
||||
let text = line.text();
|
||||
let normalized = normalize_for_comparison(&text);
|
||||
if candidates.contains(&normalized) {
|
||||
let first = first_page_individual.entry(normalized).or_insert(line.page);
|
||||
if line.page > *first {
|
||||
removal_set.insert(idx);
|
||||
} else if line.page == *first {
|
||||
// Keep this occurrence (first page)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Track first page for band candidates
|
||||
let mut first_page_band: HashMap<String, u32> = HashMap::new();
|
||||
// First pass: find first page for each band candidate
|
||||
for (&(page, _), indices) in &y_bands {
|
||||
if indices.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
let band_y = lines[indices[0]].y;
|
||||
if !is_y_at_edge(band_y, page, &page_sorted_ys, EDGE_LINE_COUNT) {
|
||||
continue;
|
||||
}
|
||||
let mut sorted_indices = indices.clone();
|
||||
sorted_indices.sort();
|
||||
let coalesced: String = sorted_indices
|
||||
.iter()
|
||||
.map(|&i| lines[i].text())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let normalized = normalize_for_comparison(&coalesced);
|
||||
if band_candidates.contains(&normalized) {
|
||||
let first = first_page_band.entry(normalized).or_insert(page);
|
||||
if page < *first {
|
||||
*first = page;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Second pass: mark for removal (skip first page)
|
||||
for (&(page, _), indices) in &y_bands {
|
||||
if indices.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
let band_y = lines[indices[0]].y;
|
||||
if !is_y_at_edge(band_y, page, &page_sorted_ys, EDGE_LINE_COUNT) {
|
||||
continue;
|
||||
}
|
||||
let mut sorted_indices = indices.clone();
|
||||
sorted_indices.sort();
|
||||
let coalesced: String = sorted_indices
|
||||
.iter()
|
||||
.map(|&i| lines[i].text())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let normalized = normalize_for_comparison(&coalesced);
|
||||
if band_candidates.contains(&normalized) {
|
||||
let first = first_page_band.get(&normalized).copied().unwrap_or(0);
|
||||
if page > first {
|
||||
for &idx in &sorted_indices {
|
||||
removal_set.insert(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// (c) Y-band sibling propagation: if any member is removed, remove all
|
||||
// members (provided the band is at an edge position).
|
||||
for (&(page, _), indices) in &y_bands {
|
||||
let band_y = lines[indices[0]].y;
|
||||
if !is_y_at_edge(band_y, page, &page_sorted_ys, EDGE_LINE_COUNT) {
|
||||
continue;
|
||||
}
|
||||
if indices.iter().any(|idx| removal_set.contains(idx)) {
|
||||
for &idx in indices {
|
||||
removal_set.insert(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if removal_set.is_empty() {
|
||||
return lines;
|
||||
}
|
||||
|
||||
lines
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.filter(|(idx, _)| !removal_set.contains(idx))
|
||||
.map(|(_, line)| line)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -679,53 +343,6 @@ mod tests {
|
||||
assert_eq!(result.len(), 2, "should merge font-based heading lines");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_repeated_keeps_first_occurrence() {
|
||||
// Simulate a repeated page header on 10 pages.
|
||||
// Each page has a running header at y=750 and many unique body lines.
|
||||
let mut lines = Vec::new();
|
||||
for page in 1..=10u32 {
|
||||
// Header at top
|
||||
lines.push(make_line(
|
||||
"VOICE OF SOUTH MARION May fifteen twenty twenty five",
|
||||
10.0,
|
||||
page,
|
||||
750.0,
|
||||
None,
|
||||
));
|
||||
// Body content — unique text per line per page (no digits to strip)
|
||||
for j in 0..20u32 {
|
||||
lines.push(make_line(
|
||||
&format!(
|
||||
"parcel r-{:04}-{:03} owner smith address oak street",
|
||||
page * 100 + j,
|
||||
page
|
||||
),
|
||||
10.0,
|
||||
page,
|
||||
600.0 - j as f32 * 15.0,
|
||||
None,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let result = strip_repeated_lines(lines, 10);
|
||||
|
||||
// The header should appear exactly once (page 1)
|
||||
let header_count = result
|
||||
.iter()
|
||||
.filter(|l| l.text().contains("VOICE OF SOUTH MARION"))
|
||||
.count();
|
||||
assert_eq!(header_count, 1, "repeated header should be kept once");
|
||||
|
||||
// First occurrence should be on page 1
|
||||
let first_header = result
|
||||
.iter()
|
||||
.find(|l| l.text().contains("VOICE OF SOUTH MARION"))
|
||||
.unwrap();
|
||||
assert_eq!(first_header.page, 1, "first occurrence should be on page 1");
|
||||
}
|
||||
|
||||
fn make_bold_line(text: &str, page: u32, y: f32) -> TextLine {
|
||||
let mut item = make_item(text, 12.0, None);
|
||||
item.is_bold = true;
|
||||
|
||||
@@ -237,6 +237,13 @@ pub trait OcrEngine: Send + Sync {
|
||||
pages: &[RenderedPage],
|
||||
options: &OcrOptions,
|
||||
) -> Result<Vec<OcrPage>, Self::Error>;
|
||||
|
||||
/// Number of pages this engine can process concurrently in one
|
||||
/// `recognize` call. The pipeline sizes its page batches from this so a
|
||||
/// parallel engine is not starved by small chunks; `1` means sequential.
|
||||
fn preferred_page_concurrency(&self) -> usize {
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+429
-36
@@ -1,11 +1,14 @@
|
||||
//! PP-OCRv6 Small implementation backed by OAR and ONNX Runtime.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use image::RgbImage;
|
||||
use oar_ocr::core::config::onnx::OrtSessionConfig;
|
||||
use oar_ocr::oarocr::{OAROCRBuilder, OAROCR};
|
||||
use oar_ocr::domain::tasks::TextDetectionConfig;
|
||||
use oar_ocr::oarocr::{EdgeProcessor, TextCroppingProcessor};
|
||||
use oar_ocr::predictors::{TextDetectionPredictor, TextRecognitionPredictor};
|
||||
use oar_ocr::processors::BoundingBox;
|
||||
use thiserror::Error;
|
||||
|
||||
@@ -70,17 +73,170 @@ pub enum OarOcrError {
|
||||
Backend(#[from] oar_ocr::core::OCRError),
|
||||
}
|
||||
|
||||
/// CPU PP-OCRv6 Small engine using OAR's detection and recognition pipeline.
|
||||
/// Standard detection input cap. PP-OCR detection resizes each page so its
|
||||
/// longest side fits this before inference; it is the PaddleOCR default and
|
||||
/// is sufficient for ordinary body text at 150 DPI.
|
||||
const DETECTION_LIMIT_STANDARD: u32 = 960;
|
||||
|
||||
/// Escalated detection input cap for dense fine-print pages. Beyond this the
|
||||
/// measured recall plateaus while inference cost keeps growing.
|
||||
const DETECTION_LIMIT_ESCALATED: u32 = 2560;
|
||||
|
||||
/// Hard ceiling protecting detection from out-of-memory on giant renders.
|
||||
const DETECTION_MAXIMUM_SIDE: u32 = 4000;
|
||||
|
||||
/// Escalate only for pages dense with small text: at least this many detected
|
||||
/// regions in the standard pass...
|
||||
const ESCALATION_MINIMUM_REGIONS: usize = 80;
|
||||
|
||||
/// ...whose median height, at detection scale, is below this. Calibrated at
|
||||
/// `unclip_ratio` 2.0 (the expansion inflates measured heights, so this
|
||||
/// constant is coupled to [`detection_config`]): dense fine-print pages that
|
||||
/// gain from escalation measure 12.0–14.2 px with 144+ regions; the nearest
|
||||
/// non-gaining page above the region gate (an engineering drawing) measures
|
||||
/// 15.7 px, and prose/typewriter pages measure 14.5 px+ with too few
|
||||
/// regions to qualify at all.
|
||||
const ESCALATION_MAXIMUM_MEDIAN_HEIGHT: f32 = 15.0;
|
||||
|
||||
/// One worker's model sessions: a standard-limit detector plus a recognizer,
|
||||
/// and that worker's own lazily built escalated-limit detector.
|
||||
/// Staged (detect, crop, recognize as separate calls) rather than OAROCR's
|
||||
/// combined `predict` so an escalated page replaces only its detection pass —
|
||||
/// recognition runs exactly once, on the final region set.
|
||||
struct OcrWorker {
|
||||
detector: TextDetectionPredictor,
|
||||
recognizer: TextRecognitionPredictor,
|
||||
/// Built on this worker's first dense fine-print page. `None` inside the
|
||||
/// cell records a failed build so it is not retried per page.
|
||||
escalated: std::sync::OnceLock<Option<TextDetectionPredictor>>,
|
||||
}
|
||||
|
||||
/// CPU PP-OCRv6 Small engine using OAR's detection and recognition components.
|
||||
///
|
||||
/// Construction accepts only [`ModelPaths`] that have already passed
|
||||
/// pdf-inspector's manifest size and SHA-256 verification. OAR's independent
|
||||
/// model auto-download feature is deliberately not enabled.
|
||||
#[derive(Debug)]
|
||||
pub struct OarOcrEngine {
|
||||
pipeline: OAROCR,
|
||||
workers: Vec<OcrWorker>,
|
||||
detection_path: PathBuf,
|
||||
intra_threads: usize,
|
||||
/// Present only when more than one worker exists; sized to match.
|
||||
pool: Option<rayon::ThreadPool>,
|
||||
model: ModelIdentity,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for OarOcrEngine {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("OarOcrEngine")
|
||||
.field("workers", &self.workers.len())
|
||||
.field("parallel", &self.pool.is_some())
|
||||
.field("model", &self.model)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Pages processed concurrently: one OAROCR pipeline (and its ONNX sessions)
|
||||
/// per worker, because oar-ocr serializes each session behind a mutex.
|
||||
/// Measured on CPU: workers beyond 3 stop scaling (memory-bandwidth bound)
|
||||
/// and each worker is fastest with 2 intra-op threads.
|
||||
fn pipeline_concurrency() -> usize {
|
||||
let cores = std::thread::available_parallelism()
|
||||
.map(std::num::NonZeroUsize::get)
|
||||
.unwrap_or(1);
|
||||
(cores / 4).clamp(1, 3)
|
||||
}
|
||||
|
||||
fn intra_threads_per_pipeline(concurrency: usize) -> usize {
|
||||
let cores = std::thread::available_parallelism()
|
||||
.map(std::num::NonZeroUsize::get)
|
||||
.unwrap_or(1);
|
||||
if concurrency > 1 {
|
||||
2
|
||||
} else {
|
||||
cores.min(4)
|
||||
}
|
||||
}
|
||||
|
||||
/// True when a standard-limit detection pass over a downscaled page shows
|
||||
/// dense, small text: the page deserves a second pass at the escalated limit.
|
||||
fn should_escalate_detection(
|
||||
median_detection_height: f32,
|
||||
region_count: usize,
|
||||
downscale: f32,
|
||||
) -> bool {
|
||||
downscale < 1.0
|
||||
&& region_count >= ESCALATION_MINIMUM_REGIONS
|
||||
&& median_detection_height < ESCALATION_MAXIMUM_MEDIAN_HEIGHT
|
||||
}
|
||||
|
||||
/// Median detected-region height in detection-input pixels: original-image
|
||||
/// heights multiplied by the downscale detection applied.
|
||||
fn median_detection_height(heights: &mut [f32], downscale: f32) -> f32 {
|
||||
if heights.is_empty() {
|
||||
return f32::MAX;
|
||||
}
|
||||
heights.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||
let middle = heights.len() / 2;
|
||||
let median = if heights.len().is_multiple_of(2) {
|
||||
(heights[middle - 1] + heights[middle]) / 2.0
|
||||
} else {
|
||||
heights[middle]
|
||||
};
|
||||
median * downscale
|
||||
}
|
||||
|
||||
/// Detection preprocessing config at a given input cap.
|
||||
///
|
||||
/// Supplying an explicit config suppresses OAROCR's "general" text-type
|
||||
/// overrides, so every field the override would have set must be pinned
|
||||
/// here to match what the combined pipeline ran with before the staged
|
||||
/// split: score 0.3 and box 0.6 (equal to [`TextDetectionConfig`]'s
|
||||
/// defaults) and unclip 2.0 (the default is 1.5 — leaving it would
|
||||
/// silently shrink detection-box expansion and risk clipping edge glyphs).
|
||||
fn detection_config(detection_limit: u32) -> TextDetectionConfig {
|
||||
TextDetectionConfig {
|
||||
limit_side_len: Some(detection_limit),
|
||||
limit_type: Some(oar_ocr::processors::LimitType::Max),
|
||||
max_side_len: Some(DETECTION_MAXIMUM_SIDE),
|
||||
unclip_ratio: 2.0,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn build_detector(
|
||||
detection: &std::path::Path,
|
||||
detection_limit: u32,
|
||||
intra_threads: usize,
|
||||
) -> Result<TextDetectionPredictor, OarOcrError> {
|
||||
Ok(TextDetectionPredictor::builder()
|
||||
.with_config(detection_config(detection_limit))
|
||||
.with_ort_config(ocr_session_config(intra_threads))
|
||||
.build(detection)?)
|
||||
}
|
||||
|
||||
fn build_workers(
|
||||
detection: &std::path::Path,
|
||||
recognition: &std::path::Path,
|
||||
dictionary: &std::path::Path,
|
||||
count: usize,
|
||||
intra_threads: usize,
|
||||
) -> Result<Vec<OcrWorker>, OarOcrError> {
|
||||
let mut workers = Vec::with_capacity(count);
|
||||
for _ in 0..count {
|
||||
let detector = build_detector(detection, DETECTION_LIMIT_STANDARD, intra_threads)?;
|
||||
let recognizer = TextRecognitionPredictor::builder()
|
||||
.dict_path(dictionary)
|
||||
.with_ort_config(ocr_session_config(intra_threads))
|
||||
.build(recognition)?;
|
||||
workers.push(OcrWorker {
|
||||
detector,
|
||||
recognizer,
|
||||
escalated: std::sync::OnceLock::new(),
|
||||
});
|
||||
}
|
||||
Ok(workers)
|
||||
}
|
||||
|
||||
impl OarOcrEngine {
|
||||
/// Loads PP-OCRv6 Small from a resolved, verified model set.
|
||||
pub fn from_models(models: &ModelPaths) -> Result<Self, OarOcrError> {
|
||||
@@ -89,36 +245,169 @@ impl OarOcrEngine {
|
||||
let recognition = required_model(models, ModelArtifactKind::TextRecognition)?;
|
||||
let dictionary = required_model(models, ModelArtifactKind::CharacterDictionary)?;
|
||||
|
||||
let pipeline = OAROCRBuilder::new(detection, recognition, dictionary)
|
||||
.ort_session(ocr_session_config())
|
||||
// Document line crops often have very different widths. Keeping
|
||||
// CPU recognition batches at one avoids padding every crop to the
|
||||
// widest line, reducing both inference work and peak memory.
|
||||
.region_batch_size(1)
|
||||
.build()?;
|
||||
let concurrency = pipeline_concurrency();
|
||||
let intra_threads = intra_threads_per_pipeline(concurrency);
|
||||
let workers = build_workers(
|
||||
detection,
|
||||
recognition,
|
||||
dictionary,
|
||||
concurrency,
|
||||
intra_threads,
|
||||
)?;
|
||||
let pool = if concurrency > 1 {
|
||||
rayon::ThreadPoolBuilder::new()
|
||||
.num_threads(concurrency)
|
||||
.build()
|
||||
.ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let model = ModelIdentity::new(models.manifest_id(), models.revision());
|
||||
Ok(Self { pipeline, model })
|
||||
Ok(Self {
|
||||
workers,
|
||||
detection_path: detection.to_path_buf(),
|
||||
intra_threads,
|
||||
pool,
|
||||
model,
|
||||
})
|
||||
}
|
||||
|
||||
/// This worker's escalated-limit detector, built on first use.
|
||||
fn escalated_detector<'w>(&self, worker: &'w OcrWorker) -> Option<&'w TextDetectionPredictor> {
|
||||
worker
|
||||
.escalated
|
||||
.get_or_init(|| {
|
||||
match build_detector(
|
||||
&self.detection_path,
|
||||
DETECTION_LIMIT_ESCALATED,
|
||||
self.intra_threads,
|
||||
) {
|
||||
Ok(detector) => Some(detector),
|
||||
Err(error) => {
|
||||
log::warn!(
|
||||
"escalated OCR detection unavailable, keeping standard pass: {error}"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
.as_ref()
|
||||
}
|
||||
|
||||
/// Detects text regions for one page: a standard-limit pass first, then —
|
||||
/// for pages the standard limit demonstrably under-resolves — a second
|
||||
/// pass at the escalated limit whose boxes replace the first. Pages whose
|
||||
/// render dwarfs even the escalated limit skip the standard pass outright.
|
||||
fn detect_boxes(
|
||||
&self,
|
||||
page: &RenderedPage,
|
||||
image: &Arc<RgbImage>,
|
||||
worker: &OcrWorker,
|
||||
) -> Result<Vec<BoundingBox>, OarOcrError> {
|
||||
let longest_side = page.width().max(page.height()) as f32;
|
||||
|
||||
// A page more than twice the standard limit loses over half its
|
||||
// resolution before detection even runs; go straight to the escalated
|
||||
// detector instead of paying a doomed standard pass.
|
||||
if longest_side > (DETECTION_LIMIT_STANDARD * 2) as f32 {
|
||||
if let Some(escalated) = self.escalated_detector(worker) {
|
||||
log::debug!(
|
||||
"page {}: direct escalated detection (render {longest_side}px)",
|
||||
page.page(),
|
||||
);
|
||||
match detect_with(escalated, image, page.page()) {
|
||||
Ok(boxes) => return Ok(boxes),
|
||||
Err(error) => {
|
||||
// Same degradation as the adaptive branch below: a
|
||||
// failing escalated pass falls back to standard
|
||||
// detection instead of failing the page outright.
|
||||
// Return the standard boxes directly — the adaptive
|
||||
// trigger would only re-invoke the detector that
|
||||
// just failed (repeating an OOM on a dense page).
|
||||
log::warn!(
|
||||
"page {}: direct escalated detection failed, using standard pass: {error}",
|
||||
page.page()
|
||||
);
|
||||
return detect_with(&worker.detector, image, page.page());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let detections = detect_with(&worker.detector, image, page.page())?;
|
||||
|
||||
// Dense fine-print pages (broadsheets, pricing sheets) lose most of
|
||||
// their text when detection downscales them to the standard limit.
|
||||
// When the standard pass shows many regions of tiny detection-scale
|
||||
// height, rerun detection at the escalated limit.
|
||||
let downscale = (DETECTION_LIMIT_STANDARD as f32 / longest_side).min(1.0);
|
||||
let mut heights: Vec<f32> = detections.iter().map(polygon_height).collect();
|
||||
let median = median_detection_height(&mut heights, downscale);
|
||||
log::trace!(
|
||||
"page {}: standard pass {} regions, median height {:.1}px at detection scale",
|
||||
page.page(),
|
||||
detections.len(),
|
||||
median
|
||||
);
|
||||
if should_escalate_detection(median, detections.len(), downscale) {
|
||||
log::debug!(
|
||||
"page {}: escalating detection ({} regions, median height {:.1}px at detection scale)",
|
||||
page.page(),
|
||||
detections.len(),
|
||||
median
|
||||
);
|
||||
if let Some(escalated) = self.escalated_detector(worker) {
|
||||
match detect_with(escalated, image, page.page()) {
|
||||
Ok(escalated_boxes) => return Ok(escalated_boxes),
|
||||
Err(error) => {
|
||||
log::warn!(
|
||||
"page {}: escalated detection failed, keeping standard pass: {error}",
|
||||
page.page()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(detections)
|
||||
}
|
||||
|
||||
fn recognize_page(
|
||||
&self,
|
||||
page: &RenderedPage,
|
||||
options: &OcrOptions,
|
||||
worker: usize,
|
||||
) -> Result<OcrPage, OarOcrError> {
|
||||
let started = Instant::now();
|
||||
let image = rendered_page_to_rgb(page)?;
|
||||
let result = self
|
||||
.pipeline
|
||||
.predict(vec![image])?
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or(OarOcrError::MissingPageResult { page: page.page() })?;
|
||||
let worker = &self.workers[worker % self.workers.len()];
|
||||
let image = Arc::new(rendered_page_to_rgb(page)?);
|
||||
let boxes = self.detect_boxes(page, &image, worker)?;
|
||||
// Reading order, matching what the combined pipeline produced.
|
||||
let boxes = oar_ocr::processors::sort_quad_boxes(&boxes);
|
||||
|
||||
let mut spans = Vec::with_capacity(result.text_regions.len());
|
||||
// Same rotation-aware cropping the combined pipeline uses.
|
||||
let crops =
|
||||
TextCroppingProcessor::new(true).process((Arc::clone(&image), boxes.clone()))?;
|
||||
drop(image);
|
||||
|
||||
let recognizer = &worker.recognizer;
|
||||
let mut spans = Vec::with_capacity(boxes.len());
|
||||
let mut invalid_geometry = 0usize;
|
||||
let mut missing_recognition = 0usize;
|
||||
for region in result.text_regions {
|
||||
let (Some(text), Some(confidence)) = (region.text, region.confidence) else {
|
||||
for (bounding_box, crop) in boxes.iter().zip(crops) {
|
||||
let Some(crop) = crop else {
|
||||
invalid_geometry += 1;
|
||||
continue;
|
||||
};
|
||||
// One crop per call: document line crops often have very
|
||||
// different widths, and batching pads every crop to the widest
|
||||
// line. Measured on CPU, batched recognition (even width-sorted)
|
||||
// is 2–3× slower than per-crop calls.
|
||||
let crop = Arc::try_unwrap(crop).unwrap_or_else(|shared| (*shared).clone());
|
||||
let recognized = recognizer.predict(vec![crop])?;
|
||||
let (Some(text), Some(confidence)) = (
|
||||
recognized.texts.into_iter().next(),
|
||||
recognized.scores.into_iter().next(),
|
||||
) else {
|
||||
missing_recognition += 1;
|
||||
continue;
|
||||
};
|
||||
@@ -131,16 +420,21 @@ impl OarOcrEngine {
|
||||
continue;
|
||||
}
|
||||
|
||||
let polygon = region.dt_poly.as_ref().unwrap_or(®ion.bounding_box);
|
||||
let Some(polygon) = bounding_box_to_quad(polygon, page.width(), page.height()) else {
|
||||
let Some(polygon) = bounding_box_to_quad(bounding_box, page.width(), page.height())
|
||||
else {
|
||||
invalid_geometry += 1;
|
||||
continue;
|
||||
};
|
||||
spans.push(OcrSpan {
|
||||
text: text.to_string(),
|
||||
text,
|
||||
polygon,
|
||||
confidence,
|
||||
orientation_degrees: region.orientation_angle,
|
||||
// The combined pipeline's orientation_angle came from the
|
||||
// text-line-orientation classifier, a model this engine has
|
||||
// never loaded — it was structurally None before the staged
|
||||
// split too (the staged/combined A/B was byte-identical).
|
||||
// Region rotation is still carried by the polygon itself.
|
||||
orientation_degrees: None,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -174,12 +468,42 @@ impl OarOcrEngine {
|
||||
}
|
||||
}
|
||||
|
||||
fn ocr_session_config() -> OrtSessionConfig {
|
||||
let available = std::thread::available_parallelism()
|
||||
.map(std::num::NonZeroUsize::get)
|
||||
.unwrap_or(1);
|
||||
/// Runs one detector over one page image and returns its region polygons.
|
||||
fn detect_with(
|
||||
detector: &TextDetectionPredictor,
|
||||
image: &Arc<RgbImage>,
|
||||
page_number: u32,
|
||||
) -> Result<Vec<BoundingBox>, OarOcrError> {
|
||||
let mut result = detector.predict(vec![(**image).clone()])?;
|
||||
if result.detections.is_empty() {
|
||||
return Err(OarOcrError::MissingPageResult { page: page_number });
|
||||
}
|
||||
Ok(result
|
||||
.detections
|
||||
.swap_remove(0)
|
||||
.into_iter()
|
||||
.map(|detection| detection.bbox)
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Vertical extent of a detection polygon in original-image pixels.
|
||||
fn polygon_height(polygon: &BoundingBox) -> f32 {
|
||||
let mut min_y = f32::MAX;
|
||||
let mut max_y = f32::MIN;
|
||||
for point in &polygon.points {
|
||||
min_y = min_y.min(point.y);
|
||||
max_y = max_y.max(point.y);
|
||||
}
|
||||
if max_y > min_y {
|
||||
max_y - min_y
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
fn ocr_session_config(intra_threads: usize) -> OrtSessionConfig {
|
||||
OrtSessionConfig::new()
|
||||
.with_intra_threads(available.min(4))
|
||||
.with_intra_threads(intra_threads.max(1))
|
||||
.with_inter_threads(1)
|
||||
.with_parallel_execution(false)
|
||||
}
|
||||
@@ -226,10 +550,33 @@ impl OcrEngine for OarOcrEngine {
|
||||
) -> Result<Vec<OcrPage>, Self::Error> {
|
||||
validate_options(options)?;
|
||||
|
||||
pages
|
||||
.iter()
|
||||
.map(|page| self.recognize_page(page, options))
|
||||
.collect()
|
||||
let Some(pool) = self.pool.as_ref().filter(|_| pages.len() > 1) else {
|
||||
return pages
|
||||
.iter()
|
||||
.map(|page| self.recognize_page(page, options, 0))
|
||||
.collect();
|
||||
};
|
||||
pool.install(|| {
|
||||
use rayon::prelude::*;
|
||||
pages
|
||||
.par_iter()
|
||||
.map(|page| {
|
||||
let worker = rayon::current_thread_index().unwrap_or(0);
|
||||
self.recognize_page(page, options, worker)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
fn preferred_page_concurrency(&self) -> usize {
|
||||
// Without a pool, recognition runs sequentially regardless of worker
|
||||
// count — report that honestly so the pipeline doesn't render
|
||||
// oversized page batches for parallelism that isn't there.
|
||||
if self.pool.is_some() {
|
||||
self.workers.len()
|
||||
} else {
|
||||
1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -376,10 +723,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn cpu_session_budget_is_bounded_for_small_ocr_models() {
|
||||
let config = ocr_session_config();
|
||||
let concurrency = pipeline_concurrency();
|
||||
let config = ocr_session_config(intra_threads_per_pipeline(concurrency));
|
||||
assert!((1..=4).contains(&config.intra_threads.unwrap()));
|
||||
assert_eq!(config.inter_threads, Some(1));
|
||||
assert_eq!(config.parallel_execution, Some(false));
|
||||
// Zero requests are clamped so a session always has a thread.
|
||||
assert_eq!(ocr_session_config(0).intra_threads, Some(1));
|
||||
}
|
||||
|
||||
fn page(format: RenderPixelFormat, stride: usize, pixels: Vec<u8>) -> RenderedPage {
|
||||
@@ -493,4 +843,47 @@ mod tests {
|
||||
)
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escalation_fires_for_dense_fine_print_pages() {
|
||||
// Measured cases (at unclip 2.0) that gain from escalation: dense
|
||||
// tiled ad pages (12.3–14.2px, 158–286 regions) and a dense pricing
|
||||
// sheet (12.0px, 144 regions), all downscaled by the standard limit.
|
||||
assert!(should_escalate_detection(14.2, 186, 0.55));
|
||||
assert!(should_escalate_detection(13.1, 286, 0.55));
|
||||
assert!(should_escalate_detection(12.3, 158, 0.55));
|
||||
assert!(should_escalate_detection(12.0, 144, 0.55));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escalation_skips_ordinary_pages() {
|
||||
// Academic prose: too few regions (and tall enough at unclip 2.0).
|
||||
assert!(!should_escalate_detection(14.5, 47, 0.58));
|
||||
// Engineering drawing: many regions but tall enough text.
|
||||
assert!(!should_escalate_detection(15.7, 205, 0.58));
|
||||
// Typewriter scan: tall text, few regions.
|
||||
assert!(!should_escalate_detection(17.5, 77, 0.55));
|
||||
// Page not downscaled at all: escalation cannot add pixels.
|
||||
assert!(!should_escalate_detection(9.0, 300, 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn median_detection_height_scales_and_handles_empty() {
|
||||
let mut heights = vec![30.0, 10.0, 20.0];
|
||||
assert_eq!(median_detection_height(&mut heights, 0.5), 10.0);
|
||||
// Even counts average the two middle values instead of picking the
|
||||
// upper one, so borderline pages don't skew away from escalation.
|
||||
let mut even = vec![10.0, 12.0, 14.0, 30.0];
|
||||
assert_eq!(median_detection_height(&mut even, 1.0), 13.0);
|
||||
let mut empty: Vec<f32> = Vec::new();
|
||||
assert_eq!(median_detection_height(&mut empty, 0.5), f32::MAX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrency_derivations_stay_in_bounds() {
|
||||
let concurrency = pipeline_concurrency();
|
||||
assert!((1..=3).contains(&concurrency));
|
||||
assert!(intra_threads_per_pipeline(2) == 2);
|
||||
assert!((1..=4).contains(&intra_threads_per_pipeline(1)));
|
||||
}
|
||||
}
|
||||
|
||||
+25
-1
@@ -31,6 +31,17 @@ use super::{
|
||||
/// Bounds live rendered-page memory while preserving small OCR batches.
|
||||
const OCR_PAGE_CHUNK_SIZE: usize = 4;
|
||||
|
||||
/// Pages rendered and held in memory per OCR batch. A parallel engine gets
|
||||
/// three waves of work per batch so its workers are not starved at chunk
|
||||
/// barriers; a sequential engine keeps the small memory-bounding default.
|
||||
/// Engine-reported concurrency is a trait hook, so it is clamped before
|
||||
/// sizing anything from it — this helper exists to bound rendered-page
|
||||
/// memory and must not let an engine inflate it arbitrarily.
|
||||
fn ocr_page_chunk_size(engine_concurrency: usize) -> usize {
|
||||
const MAX_ENGINE_CONCURRENCY: usize = 8;
|
||||
(engine_concurrency.clamp(1, MAX_ENGINE_CONCURRENCY) * 3).max(OCR_PAGE_CHUNK_SIZE)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct OcrEngineCacheKey {
|
||||
model_root: PathBuf,
|
||||
@@ -443,7 +454,7 @@ where
|
||||
let mut render_time_ms = 0u64;
|
||||
let mut ocr_time_ms = 0u64;
|
||||
|
||||
for chunk in routed_pages.chunks(OCR_PAGE_CHUNK_SIZE) {
|
||||
for chunk in routed_pages.chunks(ocr_page_chunk_size(engine.preferred_page_concurrency())) {
|
||||
let native_chunk = chunk
|
||||
.iter()
|
||||
.map(|page_number| {
|
||||
@@ -927,6 +938,19 @@ pub enum OcrPipelineError {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn chunk_size_scales_with_engine_concurrency() {
|
||||
// Sequential engines keep the memory-bounding default.
|
||||
assert_eq!(ocr_page_chunk_size(1), OCR_PAGE_CHUNK_SIZE);
|
||||
// Parallel engines get three waves of work per batch.
|
||||
assert_eq!(ocr_page_chunk_size(3), 9);
|
||||
assert_eq!(ocr_page_chunk_size(2), 6);
|
||||
// Engine-reported concurrency is untrusted: clamp before sizing so a
|
||||
// misbehaving engine cannot inflate rendered-page memory or overflow.
|
||||
assert_eq!(ocr_page_chunk_size(0), OCR_PAGE_CHUNK_SIZE);
|
||||
assert_eq!(ocr_page_chunk_size(usize::MAX), 24);
|
||||
}
|
||||
|
||||
struct TrackingRenderer {
|
||||
batches: Mutex<Vec<Vec<u32>>>,
|
||||
}
|
||||
|
||||
@@ -2,9 +2,19 @@
|
||||
|
||||
# BePriced?
|
||||
|
||||
*Commercial real estate pricing* **C O M M E R C I A L R E A L E S T A T E** pricingisliketheweather:everyonetalks *needs disciplined and systematic*about it, but few understand it. Most observers base “appropriate” real estate *analysis of the data.* pricing on historical norms. The cap rate—anindicatorofvaluerelativetosta- bilized net operating income (NOI) before capital expenditures, tenant improvement,andleasingcommissions— isthemostcommonlyusedmetricofreal estate pricing. But cap rates have been largelyunresponsivetoalternativeratesof return available to investors, with the **P E T E R L I N N E M A N** exception of BBB bonds, throughout
|
||||
*Commercial real estate pricing*
|
||||
|
||||
8 4 Z E L L / L U R I E R E A L E S T A T E C E N T E R
|
||||
*needs disciplined and systematic*
|
||||
|
||||
*analysis of the data.*
|
||||
|
||||
**C O M M E R C I A L R E A L E S T A T E** pricingisliketheweather:everyonetalks about it, but few understand it. Most observers base “appropriate” real estate pricing on historical norms. The cap rate—anindicatorofvaluerelativetosta- bilized net operating income (NOI) before capital expenditures, tenant improvement,andleasingcommissions— isthemostcommonlyusedmetricofreal estate pricing. But cap rates have been largelyunresponsivetoalternativeratesof return available to investors, with the exception of BBB bonds, throughout
|
||||
|
||||
C E N T E R
|
||||
|
||||
**P E T E R L I N N E M A N**
|
||||
|
||||
8 4 Z E L L / L U R I E R E A L E S T A T E
|
||||
|
||||
**Table I:** Cap rate correlations **Cap Rate Correlation With:*** **BBB Corp** **10-Year Bond Yield S&P Dividend** **Treasury (10-15 yr) Yield** Multifamily 0.187 0.771 0.068 Industrial-0.221 0.748-0.307 CBD Office-0.449 0.694-0.458 Retail-0.181 0.649-02.58
|
||||
|
||||
@@ -13,9 +23,11 @@
|
||||
12 10 8 Percent 6 4 2 1982 1986 1990 1994 1998 2002 2006
|
||||
Apartment Retail ndustrial 10-yr reasury CBD Office
|
||||
|
||||
most of the past twenty-five years (Table presented in Figure 2 with an eighteen-
|
||||
most of the past twenty-five years (Table
|
||||
|
||||
I). Such a relationship defies investment theory,asrealestatepricingshouldchange as property risks and the returns of alter- nativeinvestmentschange. Figure1displaysNCREIFcapratesby property type compared to the ten-year Treasury yield. Because the National Council of Real Estate Investment Fiduciaries (NCREIF) cap rate data is seriouslyflawedduetoappraisallags,itis
|
||||
presented in Figure 2 with an eighteen- monthlag.Thisdataprovidesanoverview ofthepricingofinstitutionalqualityreal estate.Figure2reflectsthesecapratesnet of the ten-year Treasury yield. Since cap rate spreads are highly correlated across propertytypes(TableII),wecanspeakof “cap rates” without reference to property type with little loss of insight. Cap rate spreadswerenegativeintheearlytomid- 1980s, when purchasing real estate was
|
||||
|
||||
I). Such a relationship defies investment monthlag.Thisdataprovidesanoverview theory,asrealestatepricingshouldchange ofthepricingofinstitutionalqualityreal as property risks and the returns of alter-estate.Figure2reflectsthesecapratesnet nativeinvestmentschange. of the ten-year Treasury yield. Since cap Figure1displaysNCREIFcapratesby rate spreads are highly correlated across property type compared to the ten-year propertytypes(TableII),wecanspeakof Treasury yield. Because the National “cap rates” without reference to property Council of Real Estate Investment type with little loss of insight. Cap rate Fiduciaries (NCREIF) cap rate data is spreadswerenegativeintheearlytomid- seriouslyflawedduetoappraisallags,itis 1980s, when purchasing real estate was
|
||||
R E V I E W 8 5
|
||||
|
||||
**Figure 2:** Capratespreadsover10-yearTreasury
|
||||
|
||||
Reference in New Issue
Block a user