fix(pdf-inspector): recover wrapped key-value tables (#99)
* fix(pdf-inspector): recover wrapped key-value tables * fix(pdf-inspector): satisfy clippy
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@firecrawl/pdf-inspector",
|
"name": "@firecrawl/pdf-inspector",
|
||||||
"version": "1.9.3",
|
"version": "1.9.4",
|
||||||
"description": "Fast PDF classification and text extraction. Detect text-based vs scanned PDFs, extract text by region with quality checks. Native Rust performance via napi-rs.",
|
"description": "Fast PDF classification and text extraction. Detect text-based vs scanned PDFs, extract text by region with quality checks. Native Rust performance via napi-rs.",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"types": "index.d.ts",
|
"types": "index.d.ts",
|
||||||
|
|||||||
+569
-51
@@ -566,7 +566,7 @@ pub(crate) fn try_build_key_value_table_from_rows(items: &[TextItem], page: u32)
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
if page_items.len() < 4 {
|
if page_items.len() < 2 {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -575,15 +575,12 @@ pub(crate) fn try_build_key_value_table_from_rows(items: &[TextItem], page: u32)
|
|||||||
.max(1.0);
|
.max(1.0);
|
||||||
let y_tol = (median_font_size * 0.75).clamp(4.0, 9.0);
|
let y_tol = (median_font_size * 0.75).clamp(4.0, 9.0);
|
||||||
let rows = group_key_value_visual_rows(page_items, y_tol);
|
let rows = group_key_value_visual_rows(page_items, y_tol);
|
||||||
if rows.len() < 2 || rows.len() > 80 {
|
if rows.is_empty() || rows.len() > 80 {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let split_x = infer_key_value_split_x(&rows, median_font_size)?;
|
let split_x = infer_key_value_split_x(&rows, median_font_size)?;
|
||||||
let mut kv_rows: Vec<KeyValueRow> = Vec::new();
|
let mut kv_rows: Vec<KeyValueRow> = Vec::new();
|
||||||
let mut paired_rows = 0usize;
|
|
||||||
let mut section_rows = 0usize;
|
|
||||||
let mut left_label_like = 0usize;
|
|
||||||
let mut left_starts = Vec::new();
|
let mut left_starts = Vec::new();
|
||||||
let mut right_starts = Vec::new();
|
let mut right_starts = Vec::new();
|
||||||
|
|
||||||
@@ -609,18 +606,12 @@ pub(crate) fn try_build_key_value_table_from_rows(items: &[TextItem], page: u32)
|
|||||||
item_indices.dedup();
|
item_indices.dedup();
|
||||||
|
|
||||||
if !left.is_empty() && !right.is_empty() {
|
if !left.is_empty() && !right.is_empty() {
|
||||||
paired_rows += 1;
|
|
||||||
if looks_like_key_value_label(&left) {
|
|
||||||
left_label_like += 1;
|
|
||||||
}
|
|
||||||
if let Some(x) = left_items.first().map(|ri| ri.item.x) {
|
if let Some(x) = left_items.first().map(|ri| ri.item.x) {
|
||||||
left_starts.push(x);
|
left_starts.push(x);
|
||||||
}
|
}
|
||||||
if let Some(x) = right_items.first().map(|ri| ri.item.x) {
|
if let Some(x) = right_items.first().map(|ri| ri.item.x) {
|
||||||
right_starts.push(x);
|
right_starts.push(x);
|
||||||
}
|
}
|
||||||
} else if !left.is_empty() {
|
|
||||||
section_rows += 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
kv_rows.push(KeyValueRow {
|
kv_rows.push(KeyValueRow {
|
||||||
@@ -631,11 +622,75 @@ pub(crate) fn try_build_key_value_table_from_rows(items: &[TextItem], page: u32)
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if kv_rows.len() < 2 || paired_rows < 2 {
|
if kv_rows.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let raw_left_only_rows = kv_rows
|
||||||
|
.iter()
|
||||||
|
.filter(|row| !row.left.is_empty() && row.right.is_empty())
|
||||||
|
.count();
|
||||||
|
let raw_right_only_rows = kv_rows
|
||||||
|
.iter()
|
||||||
|
.filter(|row| row.left.is_empty() && !row.right.is_empty())
|
||||||
|
.count();
|
||||||
|
let edgar_tag_rows = key_value_rows_look_like_edgar_tags(&kv_rows);
|
||||||
|
if edgar_tag_rows {
|
||||||
|
kv_rows.retain(|row| !row.right.is_empty() || !is_edgar_table_boundary_cell(&row.left));
|
||||||
|
}
|
||||||
|
let header_inferred = !edgar_tag_rows && key_value_first_pair_is_header(&kv_rows);
|
||||||
|
kv_rows = normalize_key_value_rows(kv_rows, header_inferred);
|
||||||
|
|
||||||
|
let paired_rows = kv_rows
|
||||||
|
.iter()
|
||||||
|
.filter(|row| !row.left.is_empty() && !row.right.is_empty())
|
||||||
|
.count();
|
||||||
|
let section_rows = kv_rows
|
||||||
|
.iter()
|
||||||
|
.filter(|row| !row.left.is_empty() && row.right.is_empty())
|
||||||
|
.count();
|
||||||
|
let dangling_right_rows = kv_rows
|
||||||
|
.iter()
|
||||||
|
.filter(|row| row.left.is_empty() && !row.right.is_empty())
|
||||||
|
.count();
|
||||||
|
let left_label_like = kv_rows
|
||||||
|
.iter()
|
||||||
|
.filter(|row| !row.left.is_empty() && !row.right.is_empty())
|
||||||
|
.filter(|row| looks_like_key_value_label(&row.left))
|
||||||
|
.count();
|
||||||
|
if paired_rows < 1 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if dangling_right_rows > 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let left_x = median_f32(left_starts).unwrap_or_else(|| {
|
||||||
|
rows.iter()
|
||||||
|
.flat_map(|row| row.items.iter().map(|ri| ri.item.x))
|
||||||
|
.fold(f32::INFINITY, f32::min)
|
||||||
|
});
|
||||||
|
let right_x = median_f32(right_starts).unwrap_or(split_x);
|
||||||
|
if !left_x.is_finite() || !right_x.is_finite() || right_x - left_x < 40.0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let single_pair_allowed = key_value_single_pair_allowed(
|
||||||
|
KeyValueSinglePairStats {
|
||||||
|
paired_rows,
|
||||||
|
section_rows,
|
||||||
|
raw_left_only_rows,
|
||||||
|
raw_right_only_rows,
|
||||||
|
},
|
||||||
|
&kv_rows,
|
||||||
|
header_inferred,
|
||||||
|
left_x,
|
||||||
|
right_x,
|
||||||
|
);
|
||||||
|
if (kv_rows.len() < 2 || paired_rows < 2) && !single_pair_allowed {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let header_inferred = key_value_first_pair_is_header(&kv_rows);
|
|
||||||
let data_pairs = if header_inferred {
|
let data_pairs = if header_inferred {
|
||||||
paired_rows.saturating_sub(1)
|
paired_rows.saturating_sub(1)
|
||||||
} else {
|
} else {
|
||||||
@@ -645,7 +700,7 @@ pub(crate) fn try_build_key_value_table_from_rows(items: &[TextItem], page: u32)
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
if section_rows > paired_rows * 2 + 2 {
|
if section_rows > paired_rows * 2 + 2 && !single_pair_allowed {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -659,28 +714,25 @@ pub(crate) fn try_build_key_value_table_from_rows(items: &[TextItem], page: u32)
|
|||||||
} else {
|
} else {
|
||||||
left_label_like
|
left_label_like
|
||||||
};
|
};
|
||||||
if label_rows_for_score >= 2 && label_like_for_score * 2 < label_rows_for_score {
|
if !header_inferred
|
||||||
return None;
|
&& !edgar_tag_rows
|
||||||
}
|
&& label_rows_for_score >= 2
|
||||||
|
&& label_like_for_score * 2 < label_rows_for_score
|
||||||
let left_x = median_f32(left_starts).unwrap_or_else(|| {
|
|
||||||
rows.iter()
|
|
||||||
.flat_map(|row| row.items.iter().map(|ri| ri.item.x))
|
|
||||||
.fold(f32::INFINITY, f32::min)
|
|
||||||
});
|
|
||||||
let right_x = median_f32(right_starts).unwrap_or(split_x);
|
|
||||||
if !left_x.is_finite() || !right_x.is_finite() || right_x - left_x < 40.0 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let right_cluster_count = significant_side_x_clusters(&rows, split_x, false);
|
|
||||||
let marker_rows = marker_matrix_value_rows(&kv_rows);
|
|
||||||
if (right_cluster_count >= 5 && paired_rows >= 3)
|
|
||||||
|| (right_cluster_count >= 3 && marker_rows >= 3 && marker_rows * 2 >= paired_rows)
|
|
||||||
{
|
{
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
if key_value_rows_look_like_prose(&kv_rows, header_inferred) {
|
let right_cluster_count = significant_side_x_clusters(&rows, split_x, false);
|
||||||
|
let marker_rows = marker_matrix_value_rows(&kv_rows);
|
||||||
|
if !single_pair_allowed
|
||||||
|
&& !edgar_tag_rows
|
||||||
|
&& ((right_cluster_count >= 5 && paired_rows >= 3)
|
||||||
|
|| (right_cluster_count >= 3 && marker_rows >= 3 && marker_rows * 2 >= paired_rows))
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !edgar_tag_rows && key_value_rows_look_like_prose(&kv_rows, header_inferred) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -765,6 +817,82 @@ struct KeyValueRow {
|
|||||||
item_indices: Vec<usize>,
|
item_indices: Vec<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
struct KeyValueSinglePairStats {
|
||||||
|
paired_rows: usize,
|
||||||
|
section_rows: usize,
|
||||||
|
raw_left_only_rows: usize,
|
||||||
|
raw_right_only_rows: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_key_value_rows(rows: Vec<KeyValueRow>, header_inferred: bool) -> Vec<KeyValueRow> {
|
||||||
|
let mut normalized: Vec<KeyValueRow> = Vec::with_capacity(rows.len());
|
||||||
|
|
||||||
|
for row in rows {
|
||||||
|
if row.left.is_empty() && row.right.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if row.left.is_empty() && !row.right.is_empty() {
|
||||||
|
if let Some(last) = normalized.last_mut() {
|
||||||
|
if !last.right.is_empty() {
|
||||||
|
append_key_value_text(&mut last.right, &row.right);
|
||||||
|
last.item_indices.extend(row.item_indices);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
normalized.push(row);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !row.left.is_empty() && row.right.is_empty() {
|
||||||
|
let normalized_len = normalized.len();
|
||||||
|
if let Some(last) = normalized.last_mut() {
|
||||||
|
let last_is_header = header_inferred && normalized_len == 1;
|
||||||
|
if !last_is_header
|
||||||
|
&& !last.left.is_empty()
|
||||||
|
&& !last.right.is_empty()
|
||||||
|
&& key_value_left_continuation_allowed(&last.left, &row.left)
|
||||||
|
{
|
||||||
|
append_key_value_text(&mut last.left, &row.left);
|
||||||
|
last.item_indices.extend(row.item_indices);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append_key_value_text(target: &mut String, addition: &str) {
|
||||||
|
let addition = addition.trim();
|
||||||
|
if addition.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if !target.trim().is_empty() {
|
||||||
|
target.push(' ');
|
||||||
|
}
|
||||||
|
target.push_str(addition);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key_value_left_continuation_allowed(previous_left: &str, continuation: &str) -> bool {
|
||||||
|
let trimmed = continuation.trim();
|
||||||
|
if trimmed.is_empty() || looks_like_key_value_section_label(trimmed) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let previous = previous_left.trim_end();
|
||||||
|
let continuation_chars = trimmed.chars().count();
|
||||||
|
let continuation_words = word_count_simple(trimmed);
|
||||||
|
previous.ends_with(['-', '/', ',', ';', ':'])
|
||||||
|
|| first_alpha_is_lowercase(trimmed)
|
||||||
|
|| continuation_chars > 28
|
||||||
|
|| continuation_words > 4
|
||||||
|
}
|
||||||
|
|
||||||
fn group_key_value_visual_rows(mut items: Vec<RowItem>, y_tol: f32) -> Vec<VisualRow> {
|
fn group_key_value_visual_rows(mut items: Vec<RowItem>, y_tol: f32) -> Vec<VisualRow> {
|
||||||
items.sort_by(|a, b| {
|
items.sort_by(|a, b| {
|
||||||
b.item
|
b.item
|
||||||
@@ -828,6 +956,13 @@ fn infer_key_value_split_x(rows: &[VisualRow], median_font_size: f32) -> Option<
|
|||||||
}
|
}
|
||||||
|
|
||||||
if splits.len() < 2 {
|
if splits.len() < 2 {
|
||||||
|
let paired_visual_rows = rows.iter().filter(|row| row.items.len() >= 2).count();
|
||||||
|
if splits.len() == 1
|
||||||
|
&& paired_visual_rows == 1
|
||||||
|
&& (rows.len() == 1 || rows.iter().all(|row| row.items.len() <= 2))
|
||||||
|
{
|
||||||
|
return splits.into_iter().next();
|
||||||
|
}
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -902,16 +1037,169 @@ fn looks_like_key_value_label(cell: &str) -> bool {
|
|||||||
trimmed.chars().any(|c| c.is_alphabetic())
|
trimmed.chars().any(|c| c.is_alphabetic())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn key_value_rows_look_like_edgar_tags(rows: &[KeyValueRow]) -> bool {
|
||||||
|
let paired_rows = rows
|
||||||
|
.iter()
|
||||||
|
.filter(|row| !row.left.is_empty() && !row.right.is_empty())
|
||||||
|
.count();
|
||||||
|
if paired_rows < 2 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let tag_pairs = rows
|
||||||
|
.iter()
|
||||||
|
.filter(|row| !row.left.is_empty() && !row.right.is_empty())
|
||||||
|
.filter(|row| is_edgar_tag_cell(&row.left))
|
||||||
|
.count();
|
||||||
|
let first_marker = rows.first().is_some_and(|row| {
|
||||||
|
row.left.eq_ignore_ascii_case("<S>") && row.right.eq_ignore_ascii_case("<C>")
|
||||||
|
});
|
||||||
|
|
||||||
|
tag_pairs >= 3 || (first_marker && tag_pairs >= 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_edgar_tag_cell(cell: &str) -> bool {
|
||||||
|
let trimmed = cell.trim();
|
||||||
|
let Some(inner) = trimmed.strip_prefix('<').and_then(|s| s.strip_suffix('>')) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
!inner.is_empty()
|
||||||
|
&& inner.len() <= 48
|
||||||
|
&& inner
|
||||||
|
.chars()
|
||||||
|
.all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit() || matches!(ch, '-' | '_'))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_edgar_table_boundary_cell(cell: &str) -> bool {
|
||||||
|
let trimmed = cell.trim();
|
||||||
|
trimmed.eq_ignore_ascii_case("<TABLE>") || trimmed.eq_ignore_ascii_case("</TABLE>")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key_value_single_pair_allowed(
|
||||||
|
stats: KeyValueSinglePairStats,
|
||||||
|
rows: &[KeyValueRow],
|
||||||
|
header_inferred: bool,
|
||||||
|
left_x: f32,
|
||||||
|
right_x: f32,
|
||||||
|
) -> bool {
|
||||||
|
if header_inferred || stats.paired_rows != 1 || stats.section_rows != 0 || rows.len() != 1 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if right_x - left_x < 60.0 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(row) = rows
|
||||||
|
.iter()
|
||||||
|
.find(|row| !row.left.is_empty() && !row.right.is_empty())
|
||||||
|
else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let left_chars = row.left.chars().count();
|
||||||
|
let right_chars = row.right.chars().count();
|
||||||
|
if !(2..=120).contains(&left_chars) || right_chars == 0 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if key_value_cell_looks_like_sentence(&row.left) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if stats.raw_left_only_rows == 0
|
||||||
|
&& stats.raw_right_only_rows >= 2
|
||||||
|
&& left_chars <= 70
|
||||||
|
&& right_chars <= 1_500
|
||||||
|
&& looks_like_key_value_label(&row.left)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if right_chars > 80 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if key_value_cell_looks_like_sentence(&row.right) && !compact_key_value_scalar(&row.right) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
(looks_like_key_value_label(&row.left) || left_chars <= 90)
|
||||||
|
&& compact_key_value_scalar(&row.right)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compact_key_value_scalar(cell: &str) -> bool {
|
||||||
|
let trimmed = cell.trim();
|
||||||
|
let chars = trimmed.chars().count();
|
||||||
|
let words = word_count_simple(trimmed);
|
||||||
|
if trimmed.is_empty() || chars > 60 || words > 6 || trimmed.ends_with(['.', '!', '?']) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let lower = trimmed.to_ascii_lowercase();
|
||||||
|
trimmed.chars().any(|ch| ch.is_ascii_digit())
|
||||||
|
|| matches!(
|
||||||
|
lower.as_str(),
|
||||||
|
"yes" | "no" | "true" | "false" | "none" | "n/a" | "na"
|
||||||
|
)
|
||||||
|
|| words <= 4
|
||||||
|
}
|
||||||
|
|
||||||
|
fn looks_like_key_value_section_label(cell: &str) -> bool {
|
||||||
|
let trimmed = cell.trim();
|
||||||
|
let chars = trimmed.chars().count();
|
||||||
|
let words = word_count_simple(trimmed);
|
||||||
|
if !(1..=5).contains(&words) || !(2..=48).contains(&chars) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if trimmed.ends_with(['.', ',', ';', ':']) || first_alpha_is_lowercase(trimmed) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if trimmed
|
||||||
|
.chars()
|
||||||
|
.any(|ch| matches!(ch, '.' | ',' | ';' | '(' | ')' | '[' | ']'))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
trimmed.chars().any(|ch| ch.is_alphabetic())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn first_alpha_is_lowercase(cell: &str) -> bool {
|
||||||
|
cell.chars()
|
||||||
|
.find(|ch| ch.is_alphabetic())
|
||||||
|
.is_some_and(|ch| ch.is_lowercase())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key_value_cell_looks_like_sentence(cell: &str) -> bool {
|
||||||
|
let trimmed = cell.trim();
|
||||||
|
let chars = trimmed.chars().count();
|
||||||
|
chars > 90
|
||||||
|
|| word_count_simple(trimmed) > 12
|
||||||
|
|| (chars > 42 && trimmed.ends_with(['.', '!', '?']))
|
||||||
|
}
|
||||||
|
|
||||||
fn key_value_rows_look_like_prose(rows: &[KeyValueRow], header_inferred: bool) -> bool {
|
fn key_value_rows_look_like_prose(rows: &[KeyValueRow], header_inferred: bool) -> bool {
|
||||||
let mut long_sentence_cells = 0usize;
|
let mut left_cells = 0usize;
|
||||||
let mut total_cells = 0usize;
|
let mut left_prose_cells = 0usize;
|
||||||
let mut total_chars = 0usize;
|
let mut left_label_like = 0usize;
|
||||||
|
let mut total_left_chars = 0usize;
|
||||||
let mut paired_rows = 0usize;
|
let mut paired_rows = 0usize;
|
||||||
|
let mut paired_sentence_rows = 0usize;
|
||||||
let mut solo_prose_rows = 0usize;
|
let mut solo_prose_rows = 0usize;
|
||||||
|
|
||||||
for row in rows.iter().skip(usize::from(header_inferred)) {
|
for row in rows.iter().skip(usize::from(header_inferred)) {
|
||||||
if !row.left.is_empty() && !row.right.is_empty() {
|
if !row.left.is_empty() && !row.right.is_empty() {
|
||||||
paired_rows += 1;
|
paired_rows += 1;
|
||||||
|
let left = row.left.trim();
|
||||||
|
let right = row.right.trim();
|
||||||
|
let left_prose = key_value_cell_looks_like_sentence(left);
|
||||||
|
let right_prose = key_value_cell_looks_like_sentence(right);
|
||||||
|
left_cells += 1;
|
||||||
|
total_left_chars += left.chars().count();
|
||||||
|
if looks_like_key_value_label(left) {
|
||||||
|
left_label_like += 1;
|
||||||
|
}
|
||||||
|
if left_prose {
|
||||||
|
left_prose_cells += 1;
|
||||||
|
}
|
||||||
|
if left_prose && right_prose {
|
||||||
|
paired_sentence_rows += 1;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
let solo = if row.left.is_empty() {
|
let solo = if row.left.is_empty() {
|
||||||
row.right.trim()
|
row.right.trim()
|
||||||
@@ -925,30 +1213,23 @@ fn key_value_rows_look_like_prose(rows: &[KeyValueRow], header_inferred: bool) -
|
|||||||
solo_prose_rows += 1;
|
solo_prose_rows += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for cell in [&row.left, &row.right] {
|
|
||||||
let trimmed = cell.trim();
|
|
||||||
if trimmed.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
total_cells += 1;
|
|
||||||
total_chars += trimmed.chars().count();
|
|
||||||
if trimmed.chars().count() > 100
|
|
||||||
|| (trimmed.chars().count() > 55 && trimmed.ends_with(['.', '!', '?']))
|
|
||||||
{
|
|
||||||
long_sentence_cells += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if paired_rows < 1 || total_cells == 0 {
|
if paired_rows < 1 || left_cells == 0 {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if solo_prose_rows >= 3 {
|
if solo_prose_rows >= 3 {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if paired_rows >= 2 && paired_sentence_rows * 2 >= paired_rows {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if !header_inferred && left_prose_cells * 2 >= left_cells {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
let avg_chars = total_chars as f32 / total_cells as f32;
|
let avg_left_chars = total_left_chars as f32 / left_cells as f32;
|
||||||
avg_chars > 75.0 || long_sentence_cells * 2 >= total_cells
|
!header_inferred && avg_left_chars > 70.0 && left_label_like * 2 < left_cells
|
||||||
}
|
}
|
||||||
|
|
||||||
fn marker_matrix_value_rows(rows: &[KeyValueRow]) -> usize {
|
fn marker_matrix_value_rows(rows: &[KeyValueRow]) -> usize {
|
||||||
@@ -1354,6 +1635,243 @@ mod tests {
|
|||||||
assert!(md.contains("|Engine Code|1ZR-FAE|"), "{md}");
|
assert!(md.contains("|Engine Code|1ZR-FAE|"), "{md}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_key_value_builder_merges_wrapped_value_continuations() {
|
||||||
|
let items = vec![
|
||||||
|
make_char("Storage", 80.0, 700.0, 9.0, 42.0),
|
||||||
|
make_char(
|
||||||
|
"Store under normal conditions in dry rooms.",
|
||||||
|
250.0,
|
||||||
|
700.0,
|
||||||
|
9.0,
|
||||||
|
210.0,
|
||||||
|
),
|
||||||
|
make_char(
|
||||||
|
"Protect from heat and humidity in the original packaging material.",
|
||||||
|
250.0,
|
||||||
|
686.0,
|
||||||
|
9.0,
|
||||||
|
315.0,
|
||||||
|
),
|
||||||
|
make_char("Shelf Life", 80.0, 668.0, 9.0, 48.0),
|
||||||
|
make_char(
|
||||||
|
"To obtain best performance use within 24 months.",
|
||||||
|
250.0,
|
||||||
|
668.0,
|
||||||
|
9.0,
|
||||||
|
255.0,
|
||||||
|
),
|
||||||
|
make_char("Technical Information", 80.0, 650.0, 9.0, 104.0),
|
||||||
|
make_char(
|
||||||
|
"The product is designed for repeated industrial use and long service life.",
|
||||||
|
250.0,
|
||||||
|
650.0,
|
||||||
|
9.0,
|
||||||
|
340.0,
|
||||||
|
),
|
||||||
|
make_char(
|
||||||
|
"Additional details are provided for compatibility and installation planning.",
|
||||||
|
250.0,
|
||||||
|
636.0,
|
||||||
|
9.0,
|
||||||
|
350.0,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
let table = try_build_key_value_table_from_rows(&items, 1).unwrap();
|
||||||
|
let md = table_to_markdown(&table);
|
||||||
|
|
||||||
|
assert!(md.contains("|Field|Value|"), "{md}");
|
||||||
|
assert!(
|
||||||
|
md.contains(
|
||||||
|
"|Storage|Store under normal conditions in dry rooms. Protect from heat and humidity in the original packaging material.|"
|
||||||
|
),
|
||||||
|
"{md}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
md.contains(
|
||||||
|
"|Technical Information|The product is designed for repeated industrial use and long service life. Additional details are provided for compatibility and installation planning.|"
|
||||||
|
),
|
||||||
|
"{md}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_key_value_builder_merges_wrapped_left_labels() {
|
||||||
|
let items = vec![
|
||||||
|
make_char("Title/Description", 76.0, 700.0, 9.0, 86.0),
|
||||||
|
make_char("Instances", 350.0, 700.0, 9.0, 48.0),
|
||||||
|
make_char("RE: Homes Gerald Ford lived in.", 76.0, 682.0, 9.0, 150.0),
|
||||||
|
make_char("Box 7", 350.0, 682.0, 9.0, 28.0),
|
||||||
|
make_char(
|
||||||
|
"Grand Rapids Remembers Gerald R. Ford issue. Grand",
|
||||||
|
76.0,
|
||||||
|
664.0,
|
||||||
|
9.0,
|
||||||
|
245.0,
|
||||||
|
),
|
||||||
|
make_char("Box 7", 350.0, 664.0, 9.0, 28.0),
|
||||||
|
make_char(
|
||||||
|
"Rapids Magazine, September 1987, p. 65.",
|
||||||
|
76.0,
|
||||||
|
650.0,
|
||||||
|
9.0,
|
||||||
|
196.0,
|
||||||
|
),
|
||||||
|
make_char(
|
||||||
|
"A Workhorse not a show horse: Gerald Ford remembered as humble.",
|
||||||
|
76.0,
|
||||||
|
632.0,
|
||||||
|
9.0,
|
||||||
|
290.0,
|
||||||
|
),
|
||||||
|
make_char("Box 7", 350.0, 632.0, 9.0, 28.0),
|
||||||
|
make_char(
|
||||||
|
"not flashy during his public life.",
|
||||||
|
76.0,
|
||||||
|
618.0,
|
||||||
|
9.0,
|
||||||
|
150.0,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
let table = try_build_key_value_table_from_rows(&items, 1).unwrap();
|
||||||
|
let md = table_to_markdown(&table);
|
||||||
|
|
||||||
|
assert!(md.starts_with("|Title/Description|Instances|"), "{md}");
|
||||||
|
assert!(
|
||||||
|
md.contains(
|
||||||
|
"|Grand Rapids Remembers Gerald R. Ford issue. Grand Rapids Magazine, September 1987, p. 65.|Box 7|"
|
||||||
|
),
|
||||||
|
"{md}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
md.contains(
|
||||||
|
"|A Workhorse not a show horse: Gerald Ford remembered as humble. not flashy during his public life.|Box 7|"
|
||||||
|
),
|
||||||
|
"{md}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_key_value_builder_allows_tiny_two_cell_region() {
|
||||||
|
let items = vec![
|
||||||
|
make_char(
|
||||||
|
"3M E-A-R Classic Small Earplug Uncorded",
|
||||||
|
80.0,
|
||||||
|
700.0,
|
||||||
|
9.0,
|
||||||
|
210.0,
|
||||||
|
),
|
||||||
|
make_char("02/05/24", 360.0, 700.0, 9.0, 42.0),
|
||||||
|
];
|
||||||
|
|
||||||
|
let table = try_build_key_value_table_from_rows(&items, 1).unwrap();
|
||||||
|
let md = table_to_markdown(&table);
|
||||||
|
|
||||||
|
assert!(md.contains("|Field|Value|"), "{md}");
|
||||||
|
assert!(
|
||||||
|
md.contains("|3M E-A-R Classic Small Earplug Uncorded|02/05/24|"),
|
||||||
|
"{md}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_key_value_builder_allows_single_wrapped_value_region() {
|
||||||
|
let items = vec![
|
||||||
|
make_char("Intrinsic Safety", 42.0, 174.0, 9.0, 60.0),
|
||||||
|
make_char(
|
||||||
|
"The powered air purifying respirator has been tested and classified",
|
||||||
|
311.0,
|
||||||
|
174.0,
|
||||||
|
9.0,
|
||||||
|
260.0,
|
||||||
|
),
|
||||||
|
make_char(
|
||||||
|
"for intrinsic safety in hazardous locations by Underwriters Laboratory",
|
||||||
|
311.0,
|
||||||
|
160.0,
|
||||||
|
9.0,
|
||||||
|
270.0,
|
||||||
|
),
|
||||||
|
make_char(
|
||||||
|
"for the following classes, divisions, groups, and temperature ratings.",
|
||||||
|
311.0,
|
||||||
|
146.0,
|
||||||
|
9.0,
|
||||||
|
275.0,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
let table = try_build_key_value_table_from_rows(&items, 1).unwrap();
|
||||||
|
let md = table_to_markdown(&table);
|
||||||
|
|
||||||
|
assert!(md.contains("|Field|Value|"), "{md}");
|
||||||
|
assert!(
|
||||||
|
md.contains(
|
||||||
|
"|Intrinsic Safety|The powered air purifying respirator has been tested and classified for intrinsic safety in hazardous locations by Underwriters Laboratory for the following classes, divisions, groups, and temperature ratings.|"
|
||||||
|
),
|
||||||
|
"{md}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_key_value_builder_rejects_leading_value_only_prose() {
|
||||||
|
let items = vec![
|
||||||
|
make_char(
|
||||||
|
"3rd Party Authorization documenting the reason for the hardship.",
|
||||||
|
260.0,
|
||||||
|
714.0,
|
||||||
|
9.0,
|
||||||
|
310.0,
|
||||||
|
),
|
||||||
|
make_char("Borrower", 80.0, 696.0, 9.0, 44.0),
|
||||||
|
make_char(
|
||||||
|
"Homeowner has adequate income to support modified payments.",
|
||||||
|
260.0,
|
||||||
|
696.0,
|
||||||
|
9.0,
|
||||||
|
300.0,
|
||||||
|
),
|
||||||
|
make_char("Servicer", 80.0, 678.0, 9.0, 42.0),
|
||||||
|
make_char(
|
||||||
|
"Collects documentation and reviews hardship status.",
|
||||||
|
260.0,
|
||||||
|
678.0,
|
||||||
|
9.0,
|
||||||
|
260.0,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
assert!(try_build_key_value_table_from_rows(&items, 1).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_key_value_builder_recovers_edgar_tag_value_rows() {
|
||||||
|
let items = vec![
|
||||||
|
make_char("<S>", 70.0, 700.0, 9.0, 18.0),
|
||||||
|
make_char("<C>", 240.0, 700.0, 9.0, 18.0),
|
||||||
|
make_char("<PERIOD-TYPE>", 70.0, 684.0, 9.0, 78.0),
|
||||||
|
make_char("3-MOS", 240.0, 684.0, 9.0, 30.0),
|
||||||
|
make_char("<FISCAL-YEAR-END>", 70.0, 668.0, 9.0, 104.0),
|
||||||
|
make_char("DEC-31-2000", 240.0, 668.0, 9.0, 66.0),
|
||||||
|
make_char("<PERIOD-END>", 70.0, 652.0, 9.0, 76.0),
|
||||||
|
make_char("MAR-31-2000", 240.0, 652.0, 9.0, 66.0),
|
||||||
|
make_char("<CASH>", 70.0, 636.0, 9.0, 38.0),
|
||||||
|
make_char("214", 240.0, 636.0, 9.0, 18.0),
|
||||||
|
make_char("</TABLE>", 70.0, 620.0, 9.0, 46.0),
|
||||||
|
];
|
||||||
|
|
||||||
|
let table = try_build_key_value_table_from_rows(&items, 1).unwrap();
|
||||||
|
let md = table_to_markdown(&table);
|
||||||
|
|
||||||
|
assert!(md.starts_with("|Field|Value|"), "{md}");
|
||||||
|
assert!(md.contains("|<S>|<C>|"), "{md}");
|
||||||
|
assert!(md.contains("|<FISCAL-YEAR-END>|DEC-31-2000|"), "{md}");
|
||||||
|
assert!(md.contains("|<CASH>|214|"), "{md}");
|
||||||
|
assert!(!md.contains("</TABLE>"), "{md}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_key_value_builder_rejects_split_prose() {
|
fn test_key_value_builder_rejects_split_prose() {
|
||||||
let items = vec![
|
let items = vec![
|
||||||
|
|||||||
Reference in New Issue
Block a user