fix: replace partial_cmp with total_cmp to prevent NaN sort panics (#20)

* fix sort panics on NaN values from bogus PDF font metrics

Replace all `partial_cmp(...).unwrap_or(Ordering::Equal)` and bare
`partial_cmp(...).unwrap()` with `total_cmp()` across the codebase.

`partial_cmp` returns `None` for NaN, and mapping that to `Equal`
violates total ordering: `a == NaN` and `NaN == b` but `a != b`.
Rust 1.81+ detects this and panics in sort_by. `total_cmp` handles
NaN deterministically (sorts to end) and guarantees total ordering.

The critical crash was in `extract_text_in_regions` (lib.rs:478)
where PDFs with bogus font ascent/descent values produced NaN in
text item coordinates, causing process abort via NAPI.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix missed partial_cmp in layout.rs and restore napi exports

- Convert two remaining b.y.partial_cmp(&a.y) calls to total_cmp
  in group_single_column and column layout sorting
- Restore missing napi exports: detectPdf, extractText,
  extractTextWithPositions, processPdf

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Abimael Martell
2026-04-02 14:39:42 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 732b1b1359
commit fba0a644ef
13 changed files with 137 additions and 137 deletions
+17 -17
View File
@@ -144,7 +144,7 @@ fn split_wide_cluster(
// Build sorted list of X-intervals (x_left, x_right) from each rect
let mut intervals: Vec<(f32, f32)> = rects.iter().map(|&(x, _, w, _)| (x, x + w)).collect();
intervals.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
intervals.sort_by(|a, b| a.0.total_cmp(&b.0));
// Merge overlapping intervals to find contiguous X-bands
let mut merged: Vec<(f32, f32)> = Vec::new();
@@ -262,7 +262,7 @@ pub fn detect_tables_from_rects(
// background fills stand out clearly.
if page_rects.len() >= 6 {
let mut widths: Vec<f32> = page_rects.iter().map(|&(_, _, w, _)| w).collect();
widths.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
widths.sort_by(|a, b| a.total_cmp(b));
let median_width = widths[widths.len() / 2];
let width_threshold = median_width * 10.0;
let before = page_rects.len();
@@ -333,7 +333,7 @@ pub fn detect_tables_from_rects(
// cluster they overlap, so grid detection still has their edges.
let is_page_bg = {
let mut heights: Vec<f32> = page_rects.iter().map(|&(_, _, _, h)| h).collect();
heights.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
heights.sort_by(|a, b| a.total_cmp(b));
let median_height = heights[heights.len() / 2];
let height_threshold = median_height * 20.0;
let flags: Vec<bool> = page_rects
@@ -621,7 +621,7 @@ fn merge_overlapping_hints(mut hints: Vec<RectHintRegion>) -> Vec<RectHintRegion
return hints;
}
loop {
hints.sort_by(|a, b| a.x_left.partial_cmp(&b.x_left).unwrap());
hints.sort_by(|a, b| a.x_left.total_cmp(&b.x_left));
let mut merged: Vec<RectHintRegion> = Vec::new();
let mut any_merged = false;
for hint in &hints {
@@ -685,7 +685,7 @@ fn extract_hint_region(group_rects: &[(f32, f32, f32, f32)]) -> Option<RectHintR
// Compute median height to identify cell-sized rects
let mut heights: Vec<f32> = group_rects.iter().map(|&(_, _, _, h)| h).collect();
heights.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
heights.sort_by(|a, b| a.total_cmp(b));
let median_h = heights[heights.len() / 2];
// Keep only cell-sized rects (height ≤ 4× median)
@@ -857,9 +857,9 @@ fn try_build_grid(
// Sort column edges left-to-right, row edges top-to-bottom (highest Y first for PDF)
let mut col_edges = x_edges;
col_edges.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
col_edges.sort_by(|a, b| a.total_cmp(b));
let mut row_edges = y_edges;
row_edges.sort_by(|a, b| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal));
row_edges.sort_by(|a, b| b.total_cmp(a));
let num_cols = col_edges.len() - 1;
let num_rows = row_edges.len() - 1;
@@ -1048,7 +1048,7 @@ fn try_build_grid(
/// Deduplicate nearby edge values within a tolerance, returning sorted unique edges.
pub(crate) fn snap_edges(values: &[f32], tolerance: f32) -> Vec<f32> {
let mut sorted: Vec<f32> = values.to_vec();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
sorted.sort_by(|a, b| a.total_cmp(b));
let mut snapped: Vec<f32> = Vec::new();
for &v in &sorted {
@@ -1204,7 +1204,7 @@ fn is_row_stripe_pattern(rects: &[(f32, f32, f32, f32)]) -> bool {
}
let mut widths: Vec<f32> = rects.iter().map(|&(_, _, w, _)| w).collect();
widths.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
widths.sort_by(|a, b| a.total_cmp(b));
let median_width = widths[widths.len() / 2];
// Must be page-spanning (>200pt)
@@ -1252,7 +1252,7 @@ fn detect_row_stripe_table(
// Sort row edges top-to-bottom (highest Y first for PDF)
let mut row_edges = y_edges;
row_edges.sort_by(|a, b| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal));
row_edges.sort_by(|a, b| b.total_cmp(a));
// Compute the bounding box of the stripe region for filtering items
let y_top = row_edges[0];
@@ -1476,7 +1476,7 @@ fn detect_row_stripe_table_from_cell_rects(
// bounding box to scope items and derive rows from text Y-positions.
let row_edges = if y_edges.len() >= 4 {
let mut edges = y_edges;
edges.sort_by(|a, b| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal));
edges.sort_by(|a, b| b.total_cmp(a));
edges
} else {
// Fall back: gather items in the rect region and cluster by Y
@@ -1508,11 +1508,11 @@ fn detect_row_stripe_table_from_cell_rects(
// Cluster Y positions using median font height as threshold
let median_h = {
let mut hs: Vec<f32> = region_items.iter().map(|i| i.height).collect();
hs.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
hs.sort_by(|a, b| a.total_cmp(b));
hs[hs.len() / 2]
};
let mut ys: Vec<f32> = region_items.iter().map(|i| i.y).collect();
ys.sort_by(|a, b| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal));
ys.sort_by(|a, b| b.total_cmp(a));
let mut edges = Vec::new();
let threshold = median_h * 0.8;
let mut cluster_start = ys[0];
@@ -1536,7 +1536,7 @@ fn detect_row_stripe_table_from_cell_rects(
edges.push(center - median_h * 0.5);
let _ = cluster_start; // suppress unused warning
edges = snap_edges(&edges, 3.0);
edges.sort_by(|a, b| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal));
edges.sort_by(|a, b| b.total_cmp(a));
if edges.len() < 4 {
return None;
}
@@ -1546,7 +1546,7 @@ fn detect_row_stripe_table_from_cell_rects(
// Compute bounding box from non-full-page rects
let median_h = {
let mut heights: Vec<f32> = group_rects.iter().map(|&(_, _, _, h)| h).collect();
heights.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
heights.sort_by(|a, b| a.total_cmp(b));
heights[heights.len() / 2]
};
let content_rects: Vec<_> = group_rects
@@ -1724,7 +1724,7 @@ fn detect_merged_cluster_table(
}
let mut row_edges = y_edges;
row_edges.sort_by(|a, b| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal));
row_edges.sort_by(|a, b| b.total_cmp(a));
// Bounding box of all rects
let y_top = row_edges[0];
@@ -1890,7 +1890,7 @@ fn detect_merged_cluster_table(
/// (no need for anti-paragraph safeguards).
fn cluster_x_positions(items: &[(usize, &TextItem)], min_threshold: f32) -> Vec<f32> {
let mut x_positions: Vec<f32> = items.iter().map(|(_, i)| i.x).collect();
x_positions.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
x_positions.sort_by(|a, b| a.total_cmp(b));
if x_positions.is_empty() {
return vec![];