use super::NativeDocument;
use super::span_geometry::{
has_same_rotation, is_horizontal_ltr, is_ltr_writing_mode, is_unrotated, upright_advance_extent,
upright_cross_extent,
};
use crate::core::config::{ExtractionConfig, PageConfig};
use crate::pdf::error::{PdfError, Result};
use crate::pdf::metadata::PdfExtractionMetadata;
use crate::pdf::structure::constants::{COALESCE_THRESHOLD, MAX_GLYPH_JITTER_PT, MIN_DISORDER_COUNT};
use crate::pdf::text::{contains_html_markup, fix_pdf_control_chars};
use crate::types::{PageBoundary, PageContent};
use std::borrow::Cow;
use xberg_native_pdf::document::ReadingOrder;
type PageProvenanceCounts = Option<Vec<(usize, usize)>>;
type PdfTextExtractionResult = (
String,
Option<Vec<PageBoundary>>,
Option<Vec<PageContent>>,
PageProvenanceCounts,
);
const DEFAULT_TOP_MARGIN_FRACTION: f32 = 0.0;
const DEFAULT_BOTTOM_MARGIN_FRACTION: f32 = 0.0;
#[derive(Debug, Clone, Copy)]
pub(crate) struct PageMarginFractions {
pub(crate) top: f32,
pub(crate) bottom: f32,
}
impl Default for PageMarginFractions {
fn default() -> Self {
Self {
top: DEFAULT_TOP_MARGIN_FRACTION,
bottom: DEFAULT_BOTTOM_MARGIN_FRACTION,
}
}
}
impl PageMarginFractions {
pub(crate) fn from_extraction_config(config: Option<&ExtractionConfig>) -> Self {
let defaults = Self::default();
let top = config
.and_then(|config| config.pdf_options.as_ref())
.and_then(|pdf| pdf.top_margin_fraction)
.unwrap_or(defaults.top);
let bottom = config
.and_then(|config| config.pdf_options.as_ref())
.and_then(|pdf| pdf.bottom_margin_fraction)
.unwrap_or(defaults.bottom);
let include_headers = config
.and_then(|config| config.content_filter.as_ref())
.is_some_and(|filter| filter.include_headers);
let include_footers = config
.and_then(|config| config.content_filter.as_ref())
.is_some_and(|filter| filter.include_footers);
Self {
top: if include_headers { 0.0 } else { top },
bottom: if include_footers { 0.0 } else { bottom },
}
}
}
pub type NativeUnifiedExtractionResult = (
String,
Option<Vec<PageBoundary>>,
Option<Vec<PageContent>>,
PdfExtractionMetadata,
);
pub(crate) fn extract_text_and_metadata(
doc: &mut NativeDocument,
extraction_config: Option<&ExtractionConfig>,
) -> Result<NativeUnifiedExtractionResult> {
let page_config = extraction_config.and_then(|c| c.pages.as_ref());
let margins = PageMarginFractions::from_extraction_config(extraction_config);
let (text, boundaries, page_contents, provenance_counts) =
extract_text_from_native_document(doc, page_config, extraction_config, margins)?;
let scanned_min_confidence = extraction_config
.map(|c| c.ocr_strategy.effective_min_confidence())
.unwrap_or(crate::core::config::DEFAULT_SCANNED_MIN_CONFIDENCE);
let ocr_quality_thresholds = extraction_config
.and_then(|c| c.ocr.as_ref())
.and_then(|o| o.quality_thresholds.clone())
.unwrap_or_default();
let metadata = super::metadata::extract_metadata_from_native_document(
doc,
boundaries.as_deref(),
&text,
scanned_min_confidence,
&ocr_quality_thresholds,
provenance_counts.as_deref(),
)?;
Ok((text, boundaries, page_contents, metadata))
}
#[cfg(feature = "layout-detection")]
pub(crate) fn extract_spans_from_page(
doc: &mut xberg_native_pdf::PdfDocument,
page_index: usize,
margins: PageMarginFractions,
) -> Result<(Vec<crate::extractors::pdf::rotation::TextSpan>, bool)> {
use xberg_native_pdf::document::ReadingOrder;
let mut page_text_data = super::guard_native_panic(
|| {
doc.extract_page_text_with_options(page_index, ReadingOrder::ColumnAware)
.map_err(|e| PdfError::TextExtractionFailed(format!("Failed to extract page text: {}", e)))
},
|panic| PdfError::TextExtractionFailed(format!("Page text extraction panicked in xberg_native_pdf: {}", panic)),
)?;
let (page_bottom, page_top) = page_vertical_bounds(doc, page_index)?;
retain_spans_inside_page_margins(&mut page_text_data.spans, page_bottom, page_top, margins);
let reordered_sparse_columns = reorder_sparse_two_column_page(&mut page_text_data.spans, page_text_data.page_width);
let spans = page_text_data.spans.iter().map(rotation_span).collect();
Ok((spans, reordered_sparse_columns))
}
pub(crate) fn extract_text_from_native_document(
doc: &mut NativeDocument,
page_config: Option<&PageConfig>,
extraction_config: Option<&ExtractionConfig>,
margins: PageMarginFractions,
) -> Result<PdfTextExtractionResult> {
let needs_boundaries =
extraction_config.is_some_and(|c| c.force_ocr_pages.as_ref().is_some_and(|p| !p.is_empty()) || c.ocr.is_some());
if let Some(config) = page_config {
extract_text_with_tracking(doc, config, margins)
} else if needs_boundaries {
let default_config = PageConfig::default();
extract_text_with_tracking(doc, &default_config, margins)
} else {
extract_text_fast_path(doc, margins)
}
}
pub(crate) const PAGE_SEPARATOR: &str = "\n\n";
fn extract_one_page_text(
doc: &xberg_native_pdf::PdfDocument,
page_index: usize,
excluded_layers: &std::collections::HashSet<String>,
margins: PageMarginFractions,
) -> Result<(String, Option<(usize, usize)>)> {
let (page_text, provenance_counts) = extract_page_text_column_aware(doc, page_index, excluded_layers, margins)?;
Ok((apply_text_cleanup(&page_text).into_owned(), provenance_counts))
}
fn extract_all_page_texts(
doc: &xberg_native_pdf::PdfDocument,
margins: PageMarginFractions,
) -> Result<(Vec<String>, PageProvenanceCounts)> {
let page_count = doc
.page_count()
.map_err(|e| PdfError::TextExtractionFailed(format!("Failed to get page count: {}", e)))?;
let excluded_layers = xberg_native_pdf::optional_content::compute_default_off_ocgs(doc);
let mut provenance_counts = excluded_layers.is_empty().then(|| Vec::with_capacity(page_count));
let mut texts = Vec::with_capacity(page_count);
for page_idx in 0..page_count {
let (text, counts) = extract_one_page_text(doc, page_idx, &excluded_layers, margins)?;
texts.push(text);
if let Some(collected) = provenance_counts.as_mut() {
collected
.push(counts.expect("extract_one_page_text must report provenance counts when no layers are excluded"));
}
}
Ok((texts, provenance_counts))
}
fn extract_text_fast_path(doc: &NativeDocument, margins: PageMarginFractions) -> Result<PdfTextExtractionResult> {
let (page_texts, provenance_counts) = extract_all_page_texts(&doc.doc, margins)?;
let separators = page_texts.len().saturating_sub(1) * PAGE_SEPARATOR.len();
let mut content = String::with_capacity(page_texts.iter().map(String::len).sum::<usize>() + separators);
for (page_idx, page_text) in page_texts.iter().enumerate() {
if page_idx > 0 {
content.push_str(PAGE_SEPARATOR);
}
content.push_str(page_text);
}
Ok((content, None, None, provenance_counts))
}
fn extract_text_with_tracking(
doc: &NativeDocument,
config: &PageConfig,
margins: PageMarginFractions,
) -> Result<PdfTextExtractionResult> {
let (page_texts, provenance_counts) = extract_all_page_texts(&doc.doc, margins)?;
let page_count = page_texts.len();
let markers: Vec<String> = if config.insert_page_markers {
(1..=page_count)
.map(|page_number| config.marker_format.replace("{page_num}", &page_number.to_string()))
.collect()
} else {
Vec::new()
};
let separators: usize = if config.insert_page_markers {
markers.iter().map(String::len).sum()
} else {
page_count.saturating_sub(1) * PAGE_SEPARATOR.len()
};
let mut content = String::with_capacity(page_texts.iter().map(String::len).sum::<usize>() + separators);
let mut boundaries = Vec::with_capacity(page_count);
let mut page_contents = if config.extract_pages {
Some(Vec::with_capacity(page_count))
} else {
None
};
for (page_idx, cleaned) in page_texts.into_iter().enumerate() {
let page_number = page_idx + 1;
if config.insert_page_markers {
content.push_str(&markers[page_idx]);
} else if page_idx > 0 {
content.push_str(PAGE_SEPARATOR);
}
let byte_start = content.len();
content.push_str(&cleaned);
let byte_end = content.len();
boundaries.push(PageBoundary {
byte_start,
byte_end,
page_number: page_number as u32,
});
if let Some(ref mut pages) = page_contents {
let is_blank = Some(crate::extraction::blank_detection::is_page_text_blank(&cleaned));
pages.push(PageContent {
page_number: page_number as u32,
content: cleaned,
tables: Vec::new(),
image_indices: Vec::new(),
image_preprocessing: None,
hierarchy: None,
is_blank,
layout_regions: None,
speaker_notes: None,
section_name: None,
sheet_name: None,
ocr_confidence: None,
});
}
}
Ok((content, Some(boundaries), page_contents, provenance_counts))
}
fn collect_widget_field_values(doc: &xberg_native_pdf::PdfDocument, page_index: usize) -> Vec<(f64, String)> {
let annotations = match doc.get_annotations(page_index) {
Ok(a) => a,
Err(e) => {
tracing::debug!(
page = page_index,
"xberg_native_pdf: could not read annotations for widget values: {e}"
);
return Vec::new();
}
};
let mut widgets: Vec<(f64, String)> = annotations
.into_iter()
.filter(|a| a.subtype_enum == xberg_native_pdf::AnnotationSubtype::Widget)
.filter_map(|a| {
let value = a.field_value?.trim().to_string();
if value.is_empty() {
return None;
}
let mid_y = a.rect.map_or(f64::NEG_INFINITY, |r| (r[1] + r[3]) / 2.0);
Some((mid_y, value))
})
.collect();
widgets.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
widgets
}
fn append_missing_widget_values(text: &mut String, widgets: &[(f64, String)]) {
for (_, value) in widgets {
if !text.contains(value.as_str()) {
if !text.is_empty() && !text.ends_with('\n') {
text.push('\n');
}
text.push_str(value);
}
}
}
fn is_fragmented_span_list(spans: &[xberg_native_pdf::layout::TextSpan]) -> bool {
let mut disorder_count = 0;
for window in spans.windows(2) {
let prev = &window[0];
let cur = &window[1];
if prev.text.chars().count() > 3 || cur.text.chars().count() > 3 {
continue;
}
let y_gap = (prev.bbox.y - cur.bbox.y).abs();
let eff_height = prev.bbox.height.max(cur.bbox.height);
let same_line = if eff_height > 0.0 {
y_gap < eff_height * 0.5
} else {
y_gap <= MAX_GLYPH_JITTER_PT
};
if same_line && cur.bbox.x < prev.bbox.x - prev.font_size {
disorder_count += 1;
if disorder_count >= MIN_DISORDER_COUNT {
return true;
}
}
}
false
}
fn rebuild_text_from_fragmented_spans(spans: &[xberg_native_pdf::layout::TextSpan]) -> String {
if spans.is_empty() {
return String::new();
}
let mut sorted: Vec<&xberg_native_pdf::layout::TextSpan> = spans.iter().collect();
sorted.sort_by(|a, b| b.bbox.y.partial_cmp(&a.bbox.y).unwrap_or(std::cmp::Ordering::Equal));
let mut groups: Vec<Vec<&xberg_native_pdf::layout::TextSpan>> = Vec::new();
for span in sorted {
let belongs = groups.last().is_some_and(|g| {
let prev_y = g.last().unwrap().bbox.y;
(span.bbox.y - prev_y).abs() <= COALESCE_THRESHOLD
});
if belongs {
groups.last_mut().unwrap().push(span);
} else {
groups.push(vec![span]);
}
}
let mut result = String::new();
for (gi, group) in groups.iter_mut().enumerate() {
group.sort_by(|a, b| a.bbox.x.partial_cmp(&b.bbox.x).unwrap_or(std::cmp::Ordering::Equal));
if gi > 0 {
result.push('\n');
}
let font_size = group.iter().map(|s| s.font_size).fold(0.0_f32, f32::max);
let space_threshold = font_size * 0.5;
let mut prev_end_x = f32::NEG_INFINITY;
for span in group.iter() {
if prev_end_x.is_finite() && span.bbox.x - prev_end_x > space_threshold {
result.push(' ');
}
result.push_str(&span.text);
prev_end_x = span.bbox.x + span.bbox.width;
}
}
result
}
const INLINE_FRAGMENT_GAP_RATIO: f32 = 0.1;
const MAX_INLINE_FRAGMENT_ANCHOR_LOOKBACK: usize = 256;
const ROW_RESET_MIN_BACKTRACK_EMS: f32 = 4.0;
#[derive(Clone, Copy)]
struct OrderedSpan<'a> {
span: &'a xberg_native_pdf::layout::TextSpan,
glue_to_previous: bool,
}
fn spans_overlap_on_cross_axis(
first: &xberg_native_pdf::layout::TextSpan,
second: &xberg_native_pdf::layout::TextSpan,
) -> bool {
let (first_low, first_high) = upright_cross_extent(first);
let (second_low, second_high) = upright_cross_extent(second);
first_high.min(second_high) > first_low.max(second_low)
}
fn is_short_inline_fragment(span: &xberg_native_pdf::layout::TextSpan) -> bool {
let mut chars = span.text.chars();
let Some(first) = chars.next() else {
return false;
};
let char_count = 1 + chars.count();
if char_count > 3 || span.text.chars().all(char::is_whitespace) {
return false;
}
!(char_count == 1 && matches!(first, 'a' | 'A' | 'I'))
}
fn has_rtl_or_bidi_content(text: &str) -> bool {
text.chars()
.any(|character| xberg_native_pdf::text::is_rtl_text(character as u32))
}
fn find_inline_fragment_anchor(
index: usize,
spans: &[xberg_native_pdf::layout::TextSpan],
anchors: &[Option<usize>],
) -> Option<usize> {
let span = &spans[index];
if span.split_boundary_before
|| !is_short_inline_fragment(span)
|| !is_ltr_writing_mode(span)
|| has_rtl_or_bidi_content(&span.text)
{
return None;
}
let (span_start, _) = upright_advance_extent(span);
let search_start = index.saturating_sub(MAX_INLINE_FRAGMENT_ANCHOR_LOOKBACK);
(search_start..index)
.filter(|candidate_index| anchors[*candidate_index].is_none())
.filter_map(|candidate_index| {
let candidate = &spans[candidate_index];
if !is_ltr_writing_mode(candidate)
|| has_rtl_or_bidi_content(&candidate.text)
|| !has_same_rotation(candidate, span)
|| !spans_overlap_on_cross_axis(candidate, span)
{
return None;
}
let (_, candidate_end) = upright_advance_extent(candidate);
let gap = span_start - candidate_end;
let tolerance = candidate.font_size.max(span.font_size) * INLINE_FRAGMENT_GAP_RATIO;
(gap >= -tolerance && gap <= tolerance).then_some((candidate_index, gap.abs()))
})
.min_by(|(_, first_gap), (_, second_gap)| {
first_gap.partial_cmp(second_gap).unwrap_or(std::cmp::Ordering::Equal)
})
.map(|(candidate_index, _)| candidate_index)
}
fn order_spans_with_inline_fragments(spans: &[xberg_native_pdf::layout::TextSpan]) -> Vec<OrderedSpan<'_>> {
let mut anchors = vec![None; spans.len()];
for index in 0..spans.len() {
anchors[index] = find_inline_fragment_anchor(index, spans, &anchors);
}
let mut children = vec![Vec::new(); spans.len()];
for (index, anchor) in anchors.iter().enumerate() {
if let Some(anchor) = anchor {
children[*anchor].push(index);
}
}
for attached in &mut children {
attached.sort_by(|first, second| {
let (first_start, _) = upright_advance_extent(&spans[*first]);
let (second_start, _) = upright_advance_extent(&spans[*second]);
first_start
.partial_cmp(&second_start)
.unwrap_or(std::cmp::Ordering::Equal)
});
}
let mut ordered = Vec::with_capacity(spans.len());
for (index, span) in spans.iter().enumerate() {
if anchors[index].is_some() {
continue;
}
ordered.push(OrderedSpan {
span,
glue_to_previous: false,
});
ordered.extend(children[index].iter().map(|child| OrderedSpan {
span: &spans[*child],
glue_to_previous: true,
}));
}
ordered
}
fn append_span_separator(
text: &mut String,
previous: &xberg_native_pdf::layout::TextSpan,
current: OrderedSpan<'_>,
paragraph_gap_threshold: f32,
allow_ltr_row_resets: bool,
) {
if current.glue_to_previous {
return;
}
let span = current.span;
if !has_same_rotation(previous, span) {
text.push_str("\n\n");
return;
}
let (previous_start, previous_end) = upright_advance_extent(previous);
let (span_start, _) = upright_advance_extent(span);
let (previous_baseline, _) = upright_cross_extent(previous);
let (span_baseline, _) = upright_cross_extent(span);
let baseline_gap = (previous_baseline - span_baseline).abs();
let reset_threshold = previous.font_size.max(span.font_size) * ROW_RESET_MIN_BACKTRACK_EMS;
let is_ltr_pair = is_ltr_writing_mode(previous)
&& is_ltr_writing_mode(span)
&& !has_rtl_or_bidi_content(&previous.text)
&& !has_rtl_or_bidi_content(&span.text);
if allow_ltr_row_resets && is_ltr_pair && span_start < previous_start - reset_threshold {
if baseline_gap > paragraph_gap_threshold {
text.push_str("\n\n");
} else {
text.push('\n');
}
return;
}
if span.split_boundary_before {
if !previous.text.ends_with(char::is_whitespace) && !span.text.starts_with(char::is_whitespace) {
text.push(' ');
}
return;
}
let effective_height = span.bbox.height.max(previous.bbox.height).max(span.font_size * 0.5);
if baseline_gap < effective_height * 0.5 {
if span_start - previous_end > span.font_size * 0.15 {
text.push(' ');
}
} else if baseline_gap > paragraph_gap_threshold {
text.push_str("\n\n");
} else {
text.push('\n');
}
}
fn assemble_page_text(spans: &[xberg_native_pdf::layout::TextSpan]) -> String {
let mut heights: Vec<f32> = spans.iter().map(|span| span.bbox.height).collect();
heights.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let median_height = if heights.is_empty() {
1.0
} else {
heights[heights.len() / 2]
};
let paragraph_gap_threshold = median_height * 1.5;
tracing::debug!(
span_count = spans.len(),
median_height,
paragraph_gap_threshold,
"paragraph break detection initialized"
);
let ordered = order_spans_with_inline_fragments(spans);
let allow_ltr_row_resets = !spans
.iter()
.any(|span| span.rtl_draw_logical || has_rtl_or_bidi_content(&span.text));
let mut text = String::with_capacity(spans.len() * 20);
let mut prev_span: Option<&xberg_native_pdf::layout::TextSpan> = None;
for current in ordered {
let span = current.span;
if let Some(prev) = prev_span {
append_span_separator(&mut text, prev, current, paragraph_gap_threshold, allow_ltr_row_resets);
}
text.push_str(&span.text);
prev_span = Some(span);
}
text
}
const MIN_SPARSE_COLUMN_GUTTER_FRACTION: f32 = 0.05;
const MIN_SPARSE_COLUMN_GUTTER_PTS: f32 = 15.0;
const MIN_SPARSE_COLUMN_CONTENT_WIDTH_PTS: f32 = 144.0;
const MIN_SPARSE_COLUMN_WORDS: usize = 2;
const MIN_SPARSE_COLUMN_WORDS_PER_SIDE: usize = 6;
const MIN_SPARSE_COLUMN_ALPHA_CHARS: usize = 8;
const MIN_SPARSE_COLUMN_ALPHA_RATIO: f32 = 0.55;
const MIN_SPARSE_COLUMN_VERTICAL_OVERLAP: f32 = 0.5;
const XY_CUT_MIN_SPANS_FOR_SPLIT: usize = 5;
fn is_sparse_column_prose(span: &xberg_native_pdf::layout::TextSpan) -> bool {
let alpha_chars = span.text.chars().filter(|character| character.is_alphabetic()).count();
let non_whitespace_chars = span.text.chars().filter(|character| !character.is_whitespace()).count();
let word_count = span.text.split_whitespace().count();
let geometry_is_valid = span.bbox.x.is_finite()
&& span.bbox.y.is_finite()
&& span.bbox.width.is_finite()
&& span.bbox.height.is_finite()
&& span.bbox.width > 0.0;
geometry_is_valid
&& !span.is_monospace
&& is_horizontal_ltr(span)
&& !has_rtl_or_bidi_content(&span.text)
&& !span.text.contains(':')
&& word_count >= MIN_SPARSE_COLUMN_WORDS
&& alpha_chars >= MIN_SPARSE_COLUMN_ALPHA_CHARS
&& alpha_chars as f32 / non_whitespace_chars.max(1) as f32 >= MIN_SPARSE_COLUMN_ALPHA_RATIO
}
fn sparse_columns_overlap(
left: &[&xberg_native_pdf::layout::TextSpan],
right: &[&xberg_native_pdf::layout::TextSpan],
) -> bool {
let extent = |side: &[&xberg_native_pdf::layout::TextSpan]| {
side.iter()
.map(|span| span.bbox.y)
.fold((f32::INFINITY, f32::NEG_INFINITY), |(low, high), y| {
(low.min(y), high.max(y))
})
};
let (left_low, left_high) = extent(left);
let (right_low, right_high) = extent(right);
let overlap = (left_high.min(right_high) - left_low.max(right_low)).max(0.0);
let shorter_extent = (left_high - left_low).min(right_high - right_low);
shorter_extent > 0.0 && overlap / shorter_extent >= MIN_SPARSE_COLUMN_VERTICAL_OVERLAP
}
fn sparse_columns_continue_one_sentence(
left: &[&xberg_native_pdf::layout::TextSpan],
right: &[&xberg_native_pdf::layout::TextSpan],
) -> bool {
let mut left_by_y = left.to_vec();
let mut right_by_y = right.to_vec();
left_by_y.sort_by(|first, second| second.bbox.y.total_cmp(&first.bbox.y));
right_by_y.sort_by(|first, second| second.bbox.y.total_cmp(&first.bbox.y));
let starts_lowercase = |span: &&xberg_native_pdf::layout::TextSpan| {
span.text
.chars()
.find(|character| character.is_alphabetic())
.is_some_and(char::is_lowercase)
};
let starts_uppercase = |span: &&xberg_native_pdf::layout::TextSpan| {
span.text
.chars()
.find(|character| character.is_alphabetic())
.is_some_and(char::is_uppercase)
};
let has_terminal = |span: &&xberg_native_pdf::layout::TextSpan| span.text.trim_end().ends_with(['.', '!', '?']);
let continuations = [&left_by_y[1], &right_by_y[0], &right_by_y[1]];
let all_spans = left_by_y.iter().chain(&right_by_y);
starts_uppercase(&left_by_y[0])
&& continuations.into_iter().all(starts_lowercase)
&& all_spans.clone().filter(|span| has_terminal(span)).count() == 1
&& has_terminal(&right_by_y[1])
}
fn is_sparse_column_split(spans: &[xberg_native_pdf::layout::TextSpan], split_x: f32, min_gutter: f32) -> bool {
let left: Vec<_> = spans.iter().filter(|span| span.bbox.x < split_x).collect();
let right: Vec<_> = spans.iter().filter(|span| span.bbox.x >= split_x).collect();
if left.len() != 2 || right.len() != 2 {
return false;
}
let word_count = |side: &[&xberg_native_pdf::layout::TextSpan]| {
side.iter()
.map(|span| span.text.split_whitespace().count())
.sum::<usize>()
};
if word_count(&left) < MIN_SPARSE_COLUMN_WORDS_PER_SIDE || word_count(&right) < MIN_SPARSE_COLUMN_WORDS_PER_SIDE {
return false;
}
let left_right = left
.iter()
.map(|span| span.bbox.x + span.bbox.width)
.fold(f32::NEG_INFINITY, f32::max);
split_x - left_right >= min_gutter
&& sparse_columns_overlap(&left, &right)
&& sparse_columns_continue_one_sentence(&left, &right)
}
fn sparse_column_split(spans: &[xberg_native_pdf::layout::TextSpan], page_width: f32) -> Option<f32> {
let has_sparse_prose_shape =
spans.len() == XY_CUT_MIN_SPANS_FOR_SPLIT - 1 && spans.iter().all(is_sparse_column_prose);
let content_left = spans.iter().map(|span| span.bbox.x).fold(f32::INFINITY, f32::min);
let content_right = spans
.iter()
.map(|span| span.bbox.x + span.bbox.width)
.fold(f32::NEG_INFINITY, f32::max);
if !has_sparse_prose_shape || content_right - content_left < MIN_SPARSE_COLUMN_CONTENT_WIDTH_PTS {
return None;
}
let min_gutter = (page_width * MIN_SPARSE_COLUMN_GUTTER_FRACTION).max(MIN_SPARSE_COLUMN_GUTTER_PTS);
let mut starts: Vec<f32> = spans.iter().map(|span| span.bbox.x).collect();
starts.sort_by(f32::total_cmp);
starts.dedup_by(|left, right| (*left - *right).abs() <= f32::EPSILON);
starts
.into_iter()
.find(|&split_x| is_sparse_column_split(spans, split_x, min_gutter))
}
pub(crate) fn reorder_sparse_two_column_page(
spans: &mut [xberg_native_pdf::layout::TextSpan],
page_width: f32,
) -> bool {
let Some(split_x) = sparse_column_split(spans, page_width) else {
return false;
};
spans.sort_by(|left, right| {
let left_column = usize::from(left.bbox.x >= split_x);
let right_column = usize::from(right.bbox.x >= split_x);
left_column
.cmp(&right_column)
.then_with(|| right.bbox.y.total_cmp(&left.bbox.y))
.then_with(|| left.bbox.x.total_cmp(&right.bbox.x))
});
true
}
const MIN_DENSE_COLUMN_CONTENT_WIDTH_PTS: f32 = 200.0;
const MIN_DENSE_COLUMN_GUTTER_FRACTION: f32 = 0.02;
const MIN_DENSE_COLUMN_GUTTER_PTS: f32 = 10.0;
const MAX_DENSE_COLUMN_GUTTER_FRACTION: f32 = 0.25;
const MIN_DENSE_COLUMN_SPANS_PER_SIDE: usize = 6;
const MAX_DENSE_COLUMN_SPLIT_SNAP_SPAN_FRACTION: f32 = 0.06;
const DENSE_COLUMN_SPLIT_SNAP_X_TOLERANCE_PTS: f32 = 1.0;
const MAX_DENSE_COLUMN_SPLIT_SNAP_PASSES: usize = 4;
const FULL_WIDTH_FURNITURE_FRACTION: f32 = 0.55;
const LINE_Y_TOLERANCE_PTS: f32 = 0.5;
const MIN_DENSE_COLUMN_SPLIT_LINES: usize = MIN_DENSE_COLUMN_SPANS_PER_SIDE;
const MAX_REDIRECT_DISTANCE_FRACTION: f32 = 0.25;
const MAX_GUTTER_CROSSING_LINES: usize = 1;
const MAX_CROSS_GUTTER_ROW_PAIRING_FRACTION: f32 = 0.5;
const MIN_PANEL_VALUE_COLUMN_NUMERIC_FRACTION: f32 = 0.8;
const MAX_PANEL_LABEL_COLUMN_NUMERIC_FRACTION: f32 = 0.2;
const MIN_PANEL_SPLIT_COLUMNS: usize = 4;
const MIN_COLUMNS_PER_PANEL: usize = 2;
const MIN_GRID_ROW_COLUMN_ALIGNMENT_FRACTION: f32 = 0.4;
type SpanLine = Vec<usize>;
fn spans_sorted_top_to_bottom(spans: &[xberg_native_pdf::layout::TextSpan]) -> Vec<usize> {
let mut order: Vec<usize> = (0..spans.len()).collect();
order.sort_by(|&a, &b| {
spans[b]
.bbox
.y
.total_cmp(&spans[a].bbox.y)
.then_with(|| spans[a].bbox.x.total_cmp(&spans[b].bbox.x))
});
order
}
fn group_into_lines(spans: &[xberg_native_pdf::layout::TextSpan], order: &[usize]) -> Vec<SpanLine> {
let mut lines: Vec<SpanLine> = Vec::new();
let mut anchor_y = f32::NAN;
for &index in order {
let y = spans[index].bbox.y;
if lines.is_empty() || (anchor_y - y).abs() > LINE_Y_TOLERANCE_PTS {
anchor_y = y;
lines.push(Vec::new());
}
lines.last_mut().expect("just pushed above").push(index);
}
for line in &mut lines {
line.sort_by(|&a, &b| spans[a].bbox.x.total_cmp(&spans[b].bbox.x));
}
lines
}
fn widest_gap_midpoint(mut edges: impl Iterator<Item = (f32, f32)>, min_gutter: f32, max_gutter: f32) -> Option<f32> {
let (_, mut running_right) = edges.next()?;
let mut best_gap = 0.0_f32;
let mut best_split = None;
for (left, right) in edges {
let gap = left - running_right;
if gap > best_gap {
best_gap = gap;
best_split = Some((running_right + left) / 2.0);
}
running_right = running_right.max(right);
}
if best_gap < min_gutter || best_gap > max_gutter {
None
} else {
best_split
}
}
fn span_has_ink(span: &xberg_native_pdf::layout::TextSpan) -> bool {
!span.text.trim().is_empty()
}
fn line_has_width_furniture(
spans: &[xberg_native_pdf::layout::TextSpan],
line: &SpanLine,
furniture_width: f32,
) -> bool {
line.iter().any(|&index| spans[index].bbox.width >= furniture_width)
}
const MIN_GRID_ROW_GAP_COUNT: usize = 4;
fn line_has_grid_row_gaps(spans: &[xberg_native_pdf::layout::TextSpan], line: &SpanLine, min_gutter: f32) -> bool {
let mut edges: Vec<(f32, f32)> = line
.iter()
.filter(|&&index| span_has_ink(&spans[index]))
.map(|&index| (spans[index].bbox.left(), spans[index].bbox.right()))
.collect();
edges.sort_by(|a, b| a.0.total_cmp(&b.0));
let mut edges = edges.into_iter();
let Some((_, mut running_right)) = edges.next() else {
return false;
};
let mut gap_count = 0usize;
for (left, right) in edges {
if left - running_right >= min_gutter {
gap_count += 1;
}
running_right = running_right.max(right);
}
gap_count >= MIN_GRID_ROW_GAP_COUNT
}
const MIN_GRID_COLUMNS_FOR_CELL_GAP_VOTES: usize = 2;
fn vote_is_a_grid_cell_gap(
spans: &[xberg_native_pdf::layout::TextSpan],
line: &SpanLine,
columns: &[f32],
midpoint: f32,
) -> bool {
if columns.len() < MIN_GRID_COLUMNS_FOR_CELL_GAP_VOTES {
return false;
}
let (Some(&first), Some(&last)) = (columns.first(), columns.last()) else {
return false;
};
midpoint > first && midpoint < last && row_follows_column_grid(spans, line, columns)
}
const MIN_TABLE_ROW_INTERNAL_GAP_COUNT: usize = 2;
fn line_has_internal_gap_at(
spans: &[xberg_native_pdf::layout::TextSpan],
line: &SpanLine,
min_gutter: f32,
x: f32,
) -> bool {
let mut edges: Vec<(f32, f32)> = line
.iter()
.filter(|&&index| span_has_ink(&spans[index]))
.map(|&index| (spans[index].bbox.left(), spans[index].bbox.right()))
.collect();
edges.sort_by(|a, b| a.0.total_cmp(&b.0));
let mut edges = edges.into_iter();
let Some((_, mut running_right)) = edges.next() else {
return false;
};
let mut gap_count = 0usize;
let mut x_in_a_gap = false;
for (left, right) in edges {
if left - running_right >= min_gutter {
gap_count += 1;
if x >= running_right && x <= left {
x_in_a_gap = true;
}
}
running_right = running_right.max(right);
}
gap_count >= MIN_TABLE_ROW_INTERNAL_GAP_COUNT && x_in_a_gap
}
fn lines_with_internal_gap_at(
spans: &[xberg_native_pdf::layout::TextSpan],
lines: &[SpanLine],
furniture_width: f32,
min_gutter: f32,
x: f32,
) -> usize {
lines
.iter()
.filter(|&line| !line_has_width_furniture(spans, line, furniture_width))
.filter(|&line| line_has_internal_gap_at(spans, line, min_gutter, x))
.count()
}
fn band_lines_around_table_gap_split(
spans: &[xberg_native_pdf::layout::TextSpan],
lines: &[SpanLine],
furniture_width: f32,
min_gutter: f32,
split_x: f32,
) -> Option<Vec<SpanLine>> {
let mut bands: Vec<(usize, Vec<SpanLine>)> = vec![(0, Vec::new())];
for line in lines {
let (population, band) = bands.last_mut().expect("bands always holds the band being accumulated");
if line_is_boundary(spans, line, furniture_width, split_x) {
band.push(line.clone());
bands.push((0, vec![line.clone()]));
continue;
}
*population += usize::from(line_has_internal_gap_at(spans, line, min_gutter, split_x));
band.push(line.clone());
}
bands
.into_iter()
.max_by_key(|&(population, _)| population)
.filter(|&(population, _)| population >= MIN_DENSE_COLUMN_SPLIT_LINES)
.map(|(_, band)| band)
}
fn detect_split_x(spans: &[xberg_native_pdf::layout::TextSpan], lines: &[SpanLine], page_width: f32) -> Option<f32> {
let min_gutter = (page_width * MIN_DENSE_COLUMN_GUTTER_FRACTION).max(MIN_DENSE_COLUMN_GUTTER_PTS);
let max_gutter = page_width * MAX_DENSE_COLUMN_GUTTER_FRACTION;
let furniture_width = page_width * FULL_WIDTH_FURNITURE_FRACTION;
let voting_lines = || {
lines
.iter()
.filter(|&line| !line_has_width_furniture(spans, line, furniture_width))
.filter(|&line| line.iter().any(|&index| span_has_ink(&spans[index])))
};
let grid_rows: Vec<SpanLine> = voting_lines()
.filter(|&line| line_has_grid_row_gaps(spans, line, min_gutter))
.cloned()
.collect();
let grid_columns = strong_column_edges(spans, &grid_rows);
let mut midpoints: Vec<f32> = voting_lines()
.filter(|&line| !line_has_grid_row_gaps(spans, line, min_gutter))
.filter_map(|line| {
let edges = line
.iter()
.map(|&index| (spans[index].bbox.left(), spans[index].bbox.right()));
let midpoint = widest_gap_midpoint(edges, min_gutter, max_gutter)?;
(!vote_is_a_grid_cell_gap(spans, line, &grid_columns, midpoint)).then_some(midpoint)
})
.collect();
if midpoints.len() < MIN_DENSE_COLUMN_SPLIT_LINES {
return None;
}
midpoints.sort_by(f32::total_cmp);
let mid = midpoints.len() / 2;
Some(if midpoints.len().is_multiple_of(2) {
(midpoints[mid - 1] + midpoints[mid]) / 2.0
} else {
midpoints[mid]
})
}
fn redirect_split_out_of_content(
spans: &[xberg_native_pdf::layout::TextSpan],
lines: &[SpanLine],
page_width: f32,
split_x: f32,
) -> f32 {
let cuts_a_span = spans
.iter()
.any(|span| span.bbox.left() < split_x && span.bbox.right() > split_x);
let furniture_width = page_width * FULL_WIDTH_FURNITURE_FRACTION;
let min_gutter = (page_width * MIN_DENSE_COLUMN_GUTTER_FRACTION).max(MIN_DENSE_COLUMN_GUTTER_PTS);
let split_inside_a_table_gap =
lines_with_internal_gap_at(spans, lines, furniture_width, min_gutter, split_x) >= MIN_DENSE_COLUMN_SPLIT_LINES;
if !cuts_a_span && !split_inside_a_table_gap {
return split_x;
}
let band = band_lines_around_table_gap_split(spans, lines, furniture_width, min_gutter, split_x);
let search_lines: &[SpanLine] = band.as_deref().unwrap_or(lines);
let max_redirect_distance = page_width * MAX_REDIRECT_DISTANCE_FRACTION;
let widest_within_reach = |corridors: Vec<(f32, f32)>| {
corridors
.into_iter()
.max_by(|a, b| (a.1 - a.0).total_cmp(&(b.1 - b.0)))
.map(|(left, right)| (left + right) / 2.0)
.filter(|candidate| (candidate - split_x).abs() <= max_redirect_distance)
};
if let Some(candidate) = widest_within_reach(page_whitespace_corridors(
spans,
search_lines,
furniture_width,
min_gutter,
)) {
return candidate;
}
if lines_crossing(spans, search_lines, furniture_width, split_x) < MIN_DENSE_COLUMN_SPLIT_LINES
&& !split_inside_a_table_gap
{
return split_x;
}
let max_label_width = page_width * MAX_DENSE_COLUMN_SPLIT_SNAP_SPAN_FRACTION;
let corridors = page_low_occupancy_corridors(
spans,
search_lines,
furniture_width,
min_gutter,
MAX_GUTTER_CROSSING_LINES,
)
.into_iter()
.filter(|&corridor| !corridor_is_hanging_label_indent(spans, search_lines, max_label_width, corridor))
.filter(|&(left, right)| both_sides_are_columns(spans, search_lines, furniture_width, (left + right) / 2.0))
.collect();
widest_within_reach(corridors).unwrap_or(split_x)
}
fn both_sides_are_columns(
spans: &[xberg_native_pdf::layout::TextSpan],
lines: &[SpanLine],
furniture_width: f32,
x: f32,
) -> bool {
let indices: Vec<usize> = lines
.iter()
.filter(|&line| !line_has_width_furniture(spans, line, furniture_width))
.flat_map(|line| line.iter().copied())
.filter(|&index| span_has_ink(&spans[index]))
.collect();
let (left, right): (Vec<usize>, Vec<usize>) = indices.iter().copied().partition(|&index| spans[index].bbox.x < x);
let left_reorderable = xberg_native_pdf::layout::classify_region(spans, &left).is_reorderable_column();
let right_reorderable = xberg_native_pdf::layout::classify_region(spans, &right).is_reorderable_column();
if left_reorderable && right_reorderable {
return true;
}
if !left_reorderable && !right_reorderable {
return false;
}
cross_gutter_row_pairing_fraction(spans, &indices, x) <= MAX_CROSS_GUTTER_ROW_PAIRING_FRACTION
}
fn lines_crossing(
spans: &[xberg_native_pdf::layout::TextSpan],
lines: &[SpanLine],
furniture_width: f32,
x: f32,
) -> usize {
lines
.iter()
.filter(|&line| !line_has_width_furniture(spans, line, furniture_width))
.filter(|line| {
line.iter().any(|&index| {
let span = &spans[index];
span_has_ink(span) && span.bbox.left() < x && span.bbox.right() > x
})
})
.count()
}
fn page_low_occupancy_corridors(
spans: &[xberg_native_pdf::layout::TextSpan],
lines: &[SpanLine],
furniture_width: f32,
min_gutter: f32,
max_crossing_lines: usize,
) -> Vec<(f32, f32)> {
let extents: Vec<(f32, f32, usize)> = lines
.iter()
.enumerate()
.filter(|(_, line)| !line_has_width_furniture(spans, line, furniture_width))
.flat_map(|(line_index, line)| line.iter().map(move |&index| (index, line_index)))
.filter(|&(index, _)| span_has_ink(&spans[index]))
.map(|(index, line_index)| (spans[index].bbox.left(), spans[index].bbox.right(), line_index))
.filter(|(left, right, _)| left.is_finite() && right.is_finite() && right > left)
.collect();
if extents.is_empty() {
return Vec::new();
}
let mut edges: Vec<f32> = extents.iter().flat_map(|&(left, right, _)| [left, right]).collect();
edges.sort_by(f32::total_cmp);
edges.dedup();
let mut corridors: Vec<(f32, f32)> = Vec::new();
let mut open: Option<f32> = None;
let mut crossing_lines: Vec<usize> = Vec::new();
for window in edges.windows(2) {
let (lo, hi) = (window[0], window[1]);
crossing_lines.clear();
for &(left, right, line) in &extents {
if left < hi && right > lo && !crossing_lines.contains(&line) {
crossing_lines.push(line);
if crossing_lines.len() > max_crossing_lines {
break;
}
}
}
if crossing_lines.len() <= max_crossing_lines {
open.get_or_insert(lo);
} else if let Some(start) = open.take()
&& lo - start >= min_gutter
{
corridors.push((start, lo));
}
}
if let Some(start) = open {
let end = *edges.last().expect("edges is non-empty when extents is");
if end - start >= min_gutter {
corridors.push((start, end));
}
}
corridors
}
fn line_has_far_side_successor(spans: &[xberg_native_pdf::layout::TextSpan], line: &SpanLine, far_wall: f32) -> bool {
line.iter()
.any(|&index| span_has_ink(&spans[index]) && spans[index].bbox.left() >= far_wall)
}
fn corridor_is_hanging_label_indent(
spans: &[xberg_native_pdf::layout::TextSpan],
lines: &[SpanLine],
max_label_width: f32,
corridor: (f32, f32),
) -> bool {
let wall = corridor.0;
let mut left_edges: Vec<f32> = lines
.iter()
.filter(|line| line_has_far_side_successor(spans, line, corridor.1))
.filter_map(|line| {
line.iter()
.filter_map(|&index| {
let bbox = &spans[index].bbox;
(bbox.width > 0.0
&& bbox.width <= max_label_width
&& bbox.right() <= wall + DENSE_COLUMN_SPLIT_SNAP_X_TOLERANCE_PTS
&& bbox.right() >= wall - max_label_width)
.then_some(bbox.left())
})
.min_by(f32::total_cmp)
})
.collect();
left_edges.sort_by(f32::total_cmp);
left_edges.iter().enumerate().any(|(start, &left_edge)| {
left_edges[start..]
.iter()
.take_while(|&&candidate| candidate - left_edge <= DENSE_COLUMN_SPLIT_SNAP_X_TOLERANCE_PTS)
.count()
>= MIN_DENSE_COLUMN_SPLIT_LINES
})
}
fn page_whitespace_corridors(
spans: &[xberg_native_pdf::layout::TextSpan],
lines: &[SpanLine],
furniture_width: f32,
min_gutter: f32,
) -> Vec<(f32, f32)> {
let mut extents: Vec<(f32, f32)> = lines
.iter()
.filter(|&line| !line_has_width_furniture(spans, line, furniture_width))
.flat_map(|line| line.iter())
.filter(|&&index| span_has_ink(&spans[index]))
.map(|&index| (spans[index].bbox.left(), spans[index].bbox.right()))
.filter(|(left, right)| left.is_finite() && right.is_finite())
.collect();
extents.sort_by(|a, b| a.0.total_cmp(&b.0));
let mut corridors = Vec::new();
let mut running_right = match extents.first() {
Some(&(_, right)) => right,
None => return corridors,
};
for (left, right) in extents {
if left - running_right >= min_gutter {
corridors.push((running_right, left));
}
running_right = running_right.max(right);
}
corridors
}
fn snap_split_left_of_hanging_labels(
spans: &[xberg_native_pdf::layout::TextSpan],
lines: &[SpanLine],
page_width: f32,
mut split_x: f32,
) -> f32 {
let max_snap_width = page_width * MAX_DENSE_COLUMN_SPLIT_SNAP_SPAN_FRACTION;
let min_gutter = (page_width * MIN_DENSE_COLUMN_GUTTER_FRACTION).max(MIN_DENSE_COLUMN_GUTTER_PTS);
for _ in 0..MAX_DENSE_COLUMN_SPLIT_SNAP_PASSES {
let Some(left_edge) = aligned_hanging_label_left_edge(spans, lines, max_snap_width, min_gutter, split_x) else {
break;
};
split_x = left_edge;
}
split_x
}
fn aligned_hanging_label_left_edge(
spans: &[xberg_native_pdf::layout::TextSpan],
lines: &[SpanLine],
max_snap_width: f32,
min_gutter: f32,
split_x: f32,
) -> Option<f32> {
let mut left_edges = lines
.iter()
.filter(|&line| !line_has_grid_row_gaps(spans, line, min_gutter))
.filter_map(|line| {
line.iter()
.filter_map(|&index| {
let bbox = &spans[index].bbox;
(bbox.width > 0.0
&& bbox.width <= max_snap_width
&& bbox.left() < split_x
&& bbox.right() > split_x)
.then_some(bbox.left())
})
.min_by(f32::total_cmp)
})
.collect::<Vec<_>>();
left_edges.sort_by(f32::total_cmp);
for (start, &left_edge) in left_edges.iter().enumerate() {
let aligned_count = left_edges[start..]
.iter()
.take_while(|&&candidate| candidate - left_edge <= DENSE_COLUMN_SPLIT_SNAP_X_TOLERANCE_PTS)
.count();
if aligned_count >= MIN_DENSE_COLUMN_SPLIT_LINES {
return Some(left_edge);
}
}
None
}
enum Band {
Content(Vec<usize>),
Boundary(SpanLine),
}
fn line_is_boundary(
spans: &[xberg_native_pdf::layout::TextSpan],
line: &SpanLine,
furniture_width: f32,
split_x: f32,
) -> bool {
line.iter().any(|&index| {
let bbox = &spans[index].bbox;
bbox.width >= furniture_width || (bbox.left() < split_x && bbox.right() > split_x)
})
}
fn build_bands(
spans: &[xberg_native_pdf::layout::TextSpan],
lines: &[SpanLine],
furniture_width: f32,
split_x: f32,
) -> Vec<Band> {
let mut bands = Vec::new();
let mut current: Vec<usize> = Vec::new();
for line in lines {
if !line_is_boundary(spans, line, furniture_width, split_x) {
current.extend(line.iter().copied());
continue;
}
if !current.is_empty() {
bands.push(Band::Content(std::mem::take(&mut current)));
}
bands.push(Band::Boundary(line.clone()));
}
if !current.is_empty() {
bands.push(Band::Content(current));
}
bands
}
fn reorder_band_columns(
spans: &[xberg_native_pdf::layout::TextSpan],
band: &[usize],
split_x: f32,
) -> Option<Vec<usize>> {
let (left, right): (Vec<usize>, Vec<usize>) =
band.iter().copied().partition(|&index| spans[index].bbox.x < split_x);
let left_ink = left.iter().filter(|&&index| span_has_ink(&spans[index])).count();
let right_ink = right.iter().filter(|&&index| span_has_ink(&spans[index])).count();
if left_ink < MIN_DENSE_COLUMN_SPANS_PER_SIDE || right_ink < MIN_DENSE_COLUMN_SPANS_PER_SIDE {
return None;
}
let left_reorderable = xberg_native_pdf::layout::classify_region(spans, &left).is_reorderable_column();
let right_reorderable = xberg_native_pdf::layout::classify_region(spans, &right).is_reorderable_column();
if !left_reorderable && !right_reorderable {
return None;
}
if !(left_reorderable && right_reorderable)
&& cross_gutter_row_pairing_fraction(spans, band, split_x) > MAX_CROSS_GUTTER_ROW_PAIRING_FRACTION
{
return None;
}
let left = if left_reorderable {
left
} else {
order_region_by_panels(spans, left)
};
let right = if right_reorderable {
right
} else {
order_region_by_panels(spans, right)
};
Some(left.into_iter().chain(right).collect())
}
fn region_rows(spans: &[xberg_native_pdf::layout::TextSpan], region: &[usize]) -> Vec<SpanLine> {
let mut order = region.to_vec();
order.sort_by(|&a, &b| {
spans[b]
.bbox
.y
.total_cmp(&spans[a].bbox.y)
.then_with(|| spans[a].bbox.x.total_cmp(&spans[b].bbox.x))
});
group_into_lines(spans, &order)
}
fn cross_gutter_row_pairing_fraction(
spans: &[xberg_native_pdf::layout::TextSpan],
band: &[usize],
split_x: f32,
) -> f32 {
let rows = region_rows(spans, band);
if rows.is_empty() {
return 0.0;
}
let paired = rows
.iter()
.filter(|row| {
row.iter()
.any(|&index| span_has_ink(&spans[index]) && spans[index].bbox.x < split_x)
&& row
.iter()
.any(|&index| span_has_ink(&spans[index]) && spans[index].bbox.x >= split_x)
})
.count();
paired as f32 / rows.len() as f32
}
fn is_numeric_cell(text: &str) -> bool {
let trimmed = text.trim();
!trimmed.is_empty()
&& trimmed
.chars()
.all(|character| character.is_ascii_digit() || matches!(character, '.' | ',' | '%'))
}
fn strong_column_edges(spans: &[xberg_native_pdf::layout::TextSpan], rows: &[SpanLine]) -> Vec<f32> {
let mut edges: Vec<(f32, usize)> = rows
.iter()
.enumerate()
.flat_map(|(row_index, row)| row.iter().map(move |&index| (index, row_index)))
.map(|(index, row_index)| (spans[index].bbox.left(), row_index))
.collect();
edges.sort_by(|a, b| a.0.total_cmp(&b.0));
let mut columns = Vec::new();
let mut cluster: Vec<(f32, usize)> = Vec::new();
for edge in edges {
let split = cluster
.first()
.is_some_and(|&(first, _)| edge.0 - first > DENSE_COLUMN_SPLIT_SNAP_X_TOLERANCE_PTS);
if split {
push_supported_column(&mut columns, &cluster);
cluster.clear();
}
cluster.push(edge);
}
push_supported_column(&mut columns, &cluster);
columns
}
fn push_supported_column(columns: &mut Vec<f32>, cluster: &[(f32, usize)]) {
let Some(&(first, _)) = cluster.first() else {
return;
};
let mut supporting: Vec<usize> = cluster.iter().map(|&(_, row)| row).collect();
supporting.sort_unstable();
supporting.dedup();
if supporting.len() >= MIN_DENSE_COLUMN_SPLIT_LINES {
columns.push(first);
}
}
fn column_index_for_x(columns: &[f32], x: f32) -> Option<usize> {
columns
.iter()
.rposition(|&column| x >= column - DENSE_COLUMN_SPLIT_SNAP_X_TOLERANCE_PTS)
}
fn panel_boundary_columns(
spans: &[xberg_native_pdf::layout::TextSpan],
rows: &[SpanLine],
columns: &[f32],
) -> Vec<usize> {
let mut totals = vec![0usize; columns.len()];
let mut numeric = vec![0usize; columns.len()];
for &index in rows.iter().flatten() {
if let Some(column) = column_index_for_x(columns, spans[index].bbox.left()) {
totals[column] += 1;
numeric[column] += usize::from(is_numeric_cell(&spans[index].text));
}
}
let fraction = |column: usize| {
if totals[column] == 0 {
return None;
}
Some(numeric[column] as f32 / totals[column] as f32)
};
(0..columns.len().saturating_sub(1))
.filter(|&column| {
let (Some(value), Some(label)) = (fraction(column), fraction(column + 1)) else {
return false;
};
value >= MIN_PANEL_VALUE_COLUMN_NUMERIC_FRACTION && label <= MAX_PANEL_LABEL_COLUMN_NUMERIC_FRACTION
})
.map(|column| column + 1)
.collect()
}
fn panels_are_wide_enough(boundaries: &[usize], column_count: usize) -> bool {
let mut start = 0usize;
for &boundary in boundaries {
if boundary.saturating_sub(start) < MIN_COLUMNS_PER_PANEL {
return false;
}
start = boundary;
}
column_count.saturating_sub(start) >= MIN_COLUMNS_PER_PANEL
}
fn row_follows_column_grid(spans: &[xberg_native_pdf::layout::TextSpan], row: &SpanLine, columns: &[f32]) -> bool {
if row.is_empty() {
return false;
}
let aligned = row
.iter()
.filter(|&&index| {
let left = spans[index].bbox.left();
columns
.iter()
.any(|&column| (left - column).abs() <= DENSE_COLUMN_SPLIT_SNAP_X_TOLERANCE_PTS)
})
.count();
aligned as f32 / row.len() as f32 >= MIN_GRID_ROW_COLUMN_ALIGNMENT_FRACTION
}
fn order_region_by_panels(spans: &[xberg_native_pdf::layout::TextSpan], region: Vec<usize>) -> Vec<usize> {
let rows = region_rows(spans, ®ion);
let columns = strong_column_edges(spans, &rows);
if columns.len() < MIN_PANEL_SPLIT_COLUMNS {
return region;
}
let boundaries = panel_boundary_columns(spans, &rows, &columns);
if boundaries.is_empty() || !panels_are_wide_enough(&boundaries, columns.len()) {
return region;
}
let leading = rows
.iter()
.take_while(|row| !row_follows_column_grid(spans, row, &columns))
.count();
if rows[leading..]
.iter()
.any(|row| !row_follows_column_grid(spans, row, &columns))
{
return region;
}
let panel_of = |index: usize| {
let column = column_index_for_x(&columns, spans[index].bbox.left());
boundaries
.iter()
.filter(|&&boundary| column.is_some_and(|column| column >= boundary))
.count()
};
let mut ordered: Vec<usize> = rows[..leading].iter().flatten().copied().collect();
for panel in 0..=boundaries.len() {
for row in &rows[leading..] {
ordered.extend(row.iter().copied().filter(|&index| panel_of(index) == panel));
}
}
ordered
}
fn emit_band_order(spans: &[xberg_native_pdf::layout::TextSpan], bands: Vec<Band>, split_x: f32) -> Option<Vec<usize>> {
let mut any_reordered = false;
let mut order = Vec::new();
for band in bands {
match band {
Band::Boundary(line) => order.extend(line),
Band::Content(indices) => match reorder_band_columns(spans, &indices, split_x) {
Some(reordered) => {
any_reordered = true;
order.extend(reordered);
}
None => order.extend(indices),
},
}
}
any_reordered.then_some(order)
}
fn apply_span_order(spans: &mut [xberg_native_pdf::layout::TextSpan], order: &[usize]) {
let mut taken: Vec<Option<xberg_native_pdf::layout::TextSpan>> =
spans.iter_mut().map(|span| Some(std::mem::take(span))).collect();
for (slot, &source) in spans.iter_mut().zip(order) {
*slot = taken[source].take().expect("each source index is used exactly once");
}
}
pub(crate) fn reorder_dense_two_column_page(spans: &mut [xberg_native_pdf::layout::TextSpan], page_width: f32) -> bool {
let content_left = spans.iter().map(|span| span.bbox.x).fold(f32::INFINITY, f32::min);
let content_right = spans
.iter()
.map(|span| span.bbox.x + span.bbox.width)
.fold(f32::NEG_INFINITY, f32::max);
if spans.len() < 2 || content_right - content_left < MIN_DENSE_COLUMN_CONTENT_WIDTH_PTS {
return false;
}
let order = spans_sorted_top_to_bottom(spans);
let lines = group_into_lines(spans, &order);
let Some(detected_split_x) = detect_split_x(spans, &lines, page_width) else {
return false;
};
let split_x = snap_split_left_of_hanging_labels(spans, &lines, page_width, detected_split_x);
let split_x = redirect_split_out_of_content(spans, &lines, page_width, split_x);
let furniture_width = page_width * FULL_WIDTH_FURNITURE_FRACTION;
let bands = build_bands(spans, &lines, furniture_width, split_x);
let Some(final_order) = emit_band_order(spans, bands, split_x) else {
return false;
};
tracing::debug!(
target: "xberg::pdf::column_split",
detected_split_x,
final_split_x = split_x,
page_width,
span_count = spans.len(),
"dense two-column page reordered"
);
apply_span_order(spans, &final_order);
true
}
fn page_text_with_options_excluding_layers(
doc: &xberg_native_pdf::PdfDocument,
page_index: usize,
excluded_layers: &std::collections::HashSet<String>,
) -> xberg_native_pdf::error::Result<xberg_native_pdf::layout::PageText> {
if excluded_layers.is_empty() {
return doc.extract_page_text_with_options(page_index, ReadingOrder::ColumnAware);
}
let spans = doc.extract_spans_filtered_with_reading_order(
page_index,
ReadingOrder::ColumnAware,
excluded_layers.clone(),
Default::default(),
)?;
let chars: Vec<xberg_native_pdf::layout::TextChar> = spans.iter().flat_map(|s| s.to_chars()).collect();
let (_, _, page_width, page_height) = doc.get_page_media_box(page_index)?;
Ok(xberg_native_pdf::layout::PageText {
spans,
chars,
page_width,
page_height,
})
}
fn page_vertical_bounds(doc: &xberg_native_pdf::PdfDocument, page_index: usize) -> Result<(f32, f32)> {
let (_, lower_y, _, upper_y) = doc.get_page_media_box(page_index).map_err(|error| {
PdfError::TextExtractionFailed(format!(
"Failed to read page {} media box for margin filtering: {error}",
page_index + 1
))
})?;
Ok((lower_y.min(upper_y), lower_y.max(upper_y)))
}
pub(crate) fn baseline_is_inside_page_margins(
baseline_y: f32,
page_bottom: f32,
page_top: f32,
margins: PageMarginFractions,
) -> bool {
let page_height = page_top - page_bottom;
if !page_height.is_finite() || page_height <= 0.0 {
return true;
}
let bottom_cutoff = page_bottom + page_height * margins.bottom;
let top_cutoff = page_top - page_height * margins.top;
baseline_y >= bottom_cutoff && baseline_y <= top_cutoff
}
fn span_page_y_extent(span: &xberg_native_pdf::layout::TextSpan) -> (f32, f32) {
let (sin, cos) = span.rotation_degrees.to_radians().sin_cos();
let origin = span.bbox.y;
let advance = span.bbox.width * sin;
let cross = span.bbox.height * cos;
let corners = [origin, origin + advance, origin + cross, origin + advance + cross];
corners
.iter()
.fold((f32::INFINITY, f32::NEG_INFINITY), |(low, high), corner| {
(low.min(*corner), high.max(*corner))
})
}
fn span_is_inside_page_margins(
span: &xberg_native_pdf::layout::TextSpan,
page_bottom: f32,
page_top: f32,
margins: PageMarginFractions,
) -> bool {
if is_unrotated(span) {
return baseline_is_inside_page_margins(span.bbox.y, page_bottom, page_top, margins);
}
let (low, high) = span_page_y_extent(span);
baseline_is_inside_page_margins((low + high) / 2.0, page_bottom, page_top, margins)
}
fn retain_spans_inside_page_margins(
spans: &mut Vec<xberg_native_pdf::layout::TextSpan>,
page_bottom: f32,
page_top: f32,
margins: PageMarginFractions,
) {
spans.retain(|span| span_is_inside_page_margins(span, page_bottom, page_top, margins));
}
fn extract_page_text_column_aware(
doc: &xberg_native_pdf::PdfDocument,
page_index: usize,
excluded_layers: &std::collections::HashSet<String>,
margins: PageMarginFractions,
) -> Result<(String, Option<(usize, usize)>)> {
let (page_bottom, page_top) = page_vertical_bounds(doc, page_index)?;
let mut widgets = collect_widget_field_values(doc, page_index);
widgets
.retain(|(baseline_y, _)| baseline_is_inside_page_margins(*baseline_y as f32, page_bottom, page_top, margins));
let mut page_text_data = super::guard_native_panic(
|| {
page_text_with_options_excluding_layers(doc, page_index, excluded_layers).map_err(|e| {
PdfError::TextExtractionFailed(format!("Page {} text extraction failed: {}", page_index + 1, e))
})
},
|panic| {
PdfError::TextExtractionFailed(format!(
"Page {} text extraction panicked in xberg_native_pdf: {}",
page_index + 1,
panic
))
},
)?;
let provenance_counts = excluded_layers
.is_empty()
.then(|| crate::pdf::scan_detect::fabricated_char_counts(&page_text_data.spans));
retain_spans_inside_page_margins(&mut page_text_data.spans, page_bottom, page_top, margins);
reorder_sparse_two_column_page(&mut page_text_data.spans, page_text_data.page_width);
reorder_dense_two_column_page(&mut page_text_data.spans, page_text_data.page_width);
let rotation_spans = page_text_data.spans.iter().map(rotation_span).collect::<Vec<_>>();
let mut text = if let Some(repaired) = crate::extractors::pdf::rotation::repair_rotated_page_text(&rotation_spans) {
repaired
} else if is_fragmented_span_list(&page_text_data.spans) {
tracing::debug!(
span_count = page_text_data.spans.len(),
"glyph fragmentation detected — rebuilding text from span positions (#962)"
);
rebuild_text_from_fragmented_spans(&page_text_data.spans)
} else {
assemble_page_text(&page_text_data.spans)
};
append_missing_widget_values(&mut text, &widgets);
Ok((text, provenance_counts))
}
fn rotation_span(span: &xberg_native_pdf::layout::TextSpan) -> crate::extractors::pdf::rotation::TextSpan {
crate::extractors::pdf::rotation::TextSpan {
text: span.text.clone(),
x: span.bbox.x,
y: span.bbox.y,
width: span.bbox.width,
height: span.bbox.height,
rotation_degrees: span.rotation_degrees,
}
}
fn apply_text_cleanup(text: &str) -> Cow<'_, str> {
let cleaned = fix_pdf_control_chars(text);
#[cfg(feature = "html")]
if contains_html_markup(&cleaned) {
return Cow::Owned(crate::pdf::text::convert_html_page_text(&cleaned));
}
#[cfg(not(feature = "html"))]
let _ = contains_html_markup(&cleaned);
cleaned
}
#[cfg(test)]
mod tests {
use super::*;
use xberg_native_pdf::geometry::Rect;
use xberg_native_pdf::layout::TextSpan;
fn span(text: &str, x: f32, y: f32, height: f32, font_size: f32) -> TextSpan {
span_with_width(text, x, y, font_size * 0.6, height, font_size)
}
fn span_with_width(text: &str, x: f32, y: f32, width: f32, height: f32, font_size: f32) -> TextSpan {
TextSpan {
text: text.to_string(),
bbox: Rect { x, y, width, height },
font_size,
..TextSpan::default()
}
}
#[test]
fn should_exclude_native_spans_by_configured_page_margins() {
let mut spans = vec![
span("header", 20.0, 950.0, 10.0, 10.0),
span("top boundary", 20.0, 900.0, 10.0, 10.0),
span("body", 20.0, 400.0, 10.0, 10.0),
span("bottom boundary", 20.0, 100.0, 10.0, 10.0),
span("footer", 20.0, 40.0, 10.0, 10.0),
];
retain_spans_inside_page_margins(
&mut spans,
0.0,
1000.0,
PageMarginFractions {
top: 0.10,
bottom: 0.10,
},
);
assert_eq!(
spans.iter().map(|span| span.text.as_str()).collect::<Vec<_>>(),
["top boundary", "body", "bottom boundary"]
);
}
#[test]
fn should_keep_a_rotated_side_stamp_that_reaches_out_of_the_footer_band() {
let mut stamp = span_with_width("side stamp", 60.0, 18.0, 112.0, 11.0, 9.0);
stamp.rotation_degrees = 90.0;
let mut confined = span_with_width("rotated footer", 300.0, 18.0, 12.0, 11.0, 9.0);
confined.rotation_degrees = 90.0;
let mut spans = vec![stamp, confined];
retain_spans_inside_page_margins(
&mut spans,
0.0,
792.0,
PageMarginFractions {
top: 0.06,
bottom: 0.05,
},
);
assert_eq!(
spans.iter().map(|span| span.text.as_str()).collect::<Vec<_>>(),
["side stamp"],
"a rotated run reaching into the body must survive while one confined to the band is dropped"
);
}
#[test]
fn should_not_filter_by_default_margins() {
let mut spans = vec![
span("header", 20.0, 860.0, 10.0, 10.0),
span("body", 20.0, 500.0, 10.0, 10.0),
span("footer", 20.0, 130.0, 10.0, 10.0),
];
retain_spans_inside_page_margins(&mut spans, 100.0, 900.0, PageMarginFractions::default());
assert_eq!(
spans.iter().map(|span| span.text.as_str()).collect::<Vec<_>>(),
["header", "body", "footer"]
);
}
#[test]
fn should_resolve_configured_margins_and_account_for_non_zero_page_origin() {
let mut spans = vec![
span("header", 20.0, 860.0, 10.0, 10.0),
span("body", 20.0, 500.0, 10.0, 10.0),
span("footer", 20.0, 130.0, 10.0, 10.0),
];
retain_spans_inside_page_margins(
&mut spans,
100.0,
900.0,
PageMarginFractions {
top: 0.06,
bottom: 0.05,
},
);
assert_eq!(
spans.iter().map(|span| span.text.as_str()).collect::<Vec<_>>(),
["body"]
);
}
#[test]
fn should_resolve_no_margins_for_a_default_config() {
let margins = PageMarginFractions::from_extraction_config(None);
assert_eq!(margins.top, 0.0);
assert_eq!(margins.bottom, 0.0);
let margins = PageMarginFractions::from_extraction_config(Some(&ExtractionConfig::default()));
assert_eq!(margins.top, 0.0);
assert_eq!(margins.bottom, 0.0);
let config = ExtractionConfig {
pdf_options: Some(crate::core::config::PdfConfig::default()),
..ExtractionConfig::default()
};
let margins = PageMarginFractions::from_extraction_config(Some(&config));
assert_eq!(margins.top, 0.0);
assert_eq!(margins.bottom, 0.0);
}
#[test]
fn should_disable_respective_pdf_margin_when_content_filter_includes_furniture() {
let mut config = ExtractionConfig {
pdf_options: Some(crate::core::config::PdfConfig {
top_margin_fraction: Some(0.25),
bottom_margin_fraction: Some(0.20),
..crate::core::config::PdfConfig::default()
}),
..ExtractionConfig::default()
};
config.content_filter = Some(crate::core::config::ContentFilterConfig {
include_headers: true,
..crate::core::config::ContentFilterConfig::default()
});
let margins = PageMarginFractions::from_extraction_config(Some(&config));
assert_eq!(margins.top, 0.0);
assert_eq!(margins.bottom, 0.20);
config.content_filter = Some(crate::core::config::ContentFilterConfig {
include_footers: true,
..crate::core::config::ContentFilterConfig::default()
});
let margins = PageMarginFractions::from_extraction_config(Some(&config));
assert_eq!(margins.top, 0.25);
assert_eq!(margins.bottom, 0.0);
}
fn disorder_spans(count: usize) -> Vec<TextSpan> {
let font_size = 12.0_f32;
let mut spans = Vec::with_capacity(count + 1);
let mut x = 300.0_f32;
for _i in 0..=count {
spans.push(span("A", x, 700.0, 0.0, font_size));
x = x - font_size - 1.0;
}
spans
}
#[test]
fn fragmentation_detected_at_threshold() {
let spans = disorder_spans(MIN_DISORDER_COUNT);
assert!(
is_fragmented_span_list(&spans),
"should detect fragmentation at exactly MIN_DISORDER_COUNT ({MIN_DISORDER_COUNT}) events"
);
}
#[test]
fn fragmentation_not_detected_below_threshold() {
let spans = disorder_spans(MIN_DISORDER_COUNT - 1);
assert!(
!is_fragmented_span_list(&spans),
"must NOT detect fragmentation with {} events (threshold is {MIN_DISORDER_COUNT})",
MIN_DISORDER_COUNT - 1
);
}
#[test]
fn long_spans_never_count_toward_disorder() {
let font_size = 12.0_f32;
let mut spans = Vec::new();
let mut x = 500.0_f32;
for _ in 0..20 {
spans.push(span("word", x, 700.0, 0.0, font_size));
x = x - font_size - 1.0;
}
assert!(
!is_fragmented_span_list(&spans),
"word-level spans (> 3 chars) must never trigger fragmentation detection"
);
}
#[test]
fn large_y_gap_not_classified_as_same_line() {
let spans = vec![span("A", 300.0, 700.0, 0.0, 12.0), span("B", 50.0, 686.0, 0.0, 12.0)];
assert!(
!is_fragmented_span_list(&spans),
"14 pt y-gap must not be classified as same-line (MAX_GLYPH_JITTER_PT={MAX_GLYPH_JITTER_PT})"
);
}
#[test]
fn empty_spans_returns_false() {
assert!(!is_fragmented_span_list(&[]));
}
#[test]
fn single_span_returns_false() {
assert!(!is_fragmented_span_list(&[span("A", 100.0, 700.0, 0.0, 12.0)]));
}
#[test]
fn detached_subscripts_are_reinserted_into_chemical_formula() {
let spans = vec![
span_with_width("H", 100.0, 100.0, 6.0, 10.0, 10.0),
span_with_width("SO", 108.0, 100.0, 12.0, 10.0, 10.0),
span_with_width("solution", 124.0, 100.0, 36.0, 10.0, 10.0),
span_with_width("2", 106.0, 96.0, 2.0, 6.0, 6.0),
span_with_width("4", 120.0, 96.0, 2.0, 6.0, 6.0),
];
assert_eq!(assemble_page_text(&spans), "H2SO4 solution");
}
#[test]
fn detached_phone_suffix_is_reinserted_without_space() {
let spans = vec![
span_with_width("273.879.750", 100.0, 100.0, 60.0, 10.0, 10.0),
span_with_width("Population", 100.0, 75.0, 45.0, 10.0, 10.0),
span_with_width("1", 160.0, 103.0, 3.0, 6.0, 6.0),
];
assert_eq!(assemble_page_text(&spans), "273.879.7501\n\nPopulation");
}
#[test]
fn detached_final_glyph_is_reinserted_into_word() {
let spans = vec![
span_with_width("eli", 100.0, 100.0, 15.0, 10.0, 10.0),
span_with_width("Table", 40.0, 75.0, 25.0, 10.0, 10.0),
span_with_width("t", 115.0, 100.0, 5.0, 10.0, 10.0),
];
assert_eq!(assemble_page_text(&spans), "elit\n\nTable");
}
#[test]
fn far_left_reset_starts_new_row_even_when_vertical_bands_overlap() {
let spans = vec![
span_with_width("1.000", 500.0, 100.0, 30.0, 10.0, 10.0),
span_with_width("002", 30.0, 99.0, 18.0, 10.0, 10.0),
];
assert_eq!(assemble_page_text(&spans), "1.000\n002");
}
#[test]
fn far_left_reset_does_not_split_rtl_text() {
let mut next = span_with_width("العالم", 430.0, 100.0, 35.0, 10.0, 10.0);
next.split_boundary_before = true;
let spans = vec![span_with_width("مرحبا", 500.0, 100.0, 30.0, 10.0, 10.0), next];
assert_eq!(assemble_page_text(&spans), "مرحبا العالم");
}
#[test]
fn far_left_reset_respects_rtl_span_metadata_for_ascii_text() {
let mut previous = span_with_width("first", 500.0, 100.0, 30.0, 10.0, 10.0);
previous.rtl_draw_logical = true;
let mut next = span_with_width("second", 430.0, 100.0, 35.0, 10.0, 10.0);
next.rtl_draw_logical = true;
next.split_boundary_before = true;
assert_eq!(assemble_page_text(&[previous, next]), "first second");
}
#[test]
fn far_left_reset_does_not_split_ascii_numbers_on_rtl_page() {
let mut number = span_with_width("123", 500.0, 100.0, 20.0, 10.0, 10.0);
number.split_boundary_before = true;
let mut next_number = span_with_width("456", 430.0, 100.0, 20.0, 10.0, 10.0);
next_number.split_boundary_before = true;
let spans = vec![
span_with_width("مرحبا", 570.0, 100.0, 30.0, 10.0, 10.0),
number,
next_number,
];
assert_eq!(assemble_page_text(&spans), "مرحبا 123 456");
}
#[test]
fn moderate_math_backtrack_does_not_start_new_row() {
let mut denominator = span_with_width("denominator", 65.0, 96.0, 55.0, 10.0, 10.0);
denominator.split_boundary_before = true;
let spans = vec![
span_with_width("numerator", 100.0, 104.0, 45.0, 10.0, 10.0),
denominator,
];
assert_eq!(assemble_page_text(&spans), "numerator denominator");
}
#[test]
fn far_left_reset_does_not_split_rotated_text() {
let mut previous = span_with_width("first", 500.0, 100.0, 30.0, 10.0, 10.0);
previous.rotation_degrees = 90.0;
let mut next = span_with_width("second", 430.0, 100.0, 35.0, 10.0, 10.0);
next.rotation_degrees = 90.0;
next.split_boundary_before = true;
assert_eq!(assemble_page_text(&[previous, next]), "first second");
}
fn rotated_span(text: &str, x: f32, y: f32, width: f32, height: f32, rotation_degrees: f32) -> TextSpan {
let mut span = span_with_width(text, x, y, width, height, height);
span.rotation_degrees = rotation_degrees;
span
}
#[test]
fn should_rejoin_detached_fragment_of_rotated_word_when_rotation_matches() {
let spans = vec![
rotated_span("Motorcraf", 400.0, 100.0, 45.0, 10.0, 90.0),
rotated_span("Premium", 400.0, 155.0, 40.0, 10.0, 90.0),
rotated_span("t", 400.0, 145.0, 5.0, 10.0, 90.0),
];
assert_eq!(assemble_page_text(&spans), "Motorcraft Premium");
}
#[test]
fn should_not_anchor_fragment_across_differing_rotations() {
let spans = vec![
span_with_width("Motorcraf", 400.0, 100.0, 45.0, 10.0, 10.0),
rotated_span("t", 445.0, 100.0, 5.0, 10.0, 90.0),
];
assert_eq!(find_inline_fragment_anchor(1, &spans, &[None, None]), None);
}
#[test]
fn should_read_rotated_table_rows_along_their_own_axis() {
let spans = vec![
rotated_span("Engine", 400.0, 100.0, 30.0, 10.0, 90.0),
rotated_span("coolant", 400.0, 132.0, 32.0, 10.0, 90.0),
rotated_span("18.6", 388.0, 100.0, 22.0, 10.0, 90.0),
rotated_span("quarts", 388.0, 124.0, 30.0, 10.0, 90.0),
];
assert_eq!(assemble_page_text(&spans), "Engine coolant\n18.6 quarts");
}
#[test]
fn should_read_rotated_body_and_upright_footer_on_same_page() {
let spans = vec![
rotated_span("Engine", 400.0, 100.0, 30.0, 10.0, 90.0),
rotated_span("coolant", 400.0, 132.0, 32.0, 10.0, 90.0),
rotated_span("18.6", 388.0, 100.0, 22.0, 10.0, 90.0),
rotated_span("quarts", 388.0, 124.0, 30.0, 10.0, 90.0),
span_with_width("Page", 60.0, 40.0, 25.0, 10.0, 10.0),
span_with_width("264", 88.0, 40.0, 15.0, 10.0, 10.0),
];
assert_eq!(assemble_page_text(&spans), "Engine coolant\n18.6 quarts\n\nPage 264");
}
#[test]
fn should_not_change_upright_page_assembly() {
let spans = vec![
span_with_width("Engine", 60.0, 700.0, 30.0, 10.0, 10.0),
span_with_width("coolant", 92.0, 700.0, 32.0, 10.0, 10.0),
span_with_width("18.6", 60.0, 688.0, 22.0, 10.0, 10.0),
span_with_width("quarts", 84.0, 688.0, 30.0, 10.0, 10.0),
span_with_width("Next", 60.0, 640.0, 25.0, 10.0, 10.0),
];
assert_eq!(assemble_page_text(&spans), "Engine coolant\n18.6 quarts\n\nNext");
}
#[test]
fn inline_fragment_anchor_rejects_non_ltr_geometry() {
let mut anchor = span_with_width("word", 100.0, 100.0, 30.0, 10.0, 10.0);
anchor.rtl_draw_logical = true;
let mut fragment = span_with_width("2", 130.0, 100.0, 3.0, 6.0, 6.0);
fragment.rtl_draw_logical = true;
let spans = vec![anchor, fragment];
assert_eq!(find_inline_fragment_anchor(1, &spans, &[None, None]), None);
}
#[test]
fn inline_fragment_anchor_search_is_local() {
let mut spans = vec![span_with_width("anchor", 100.0, 100.0, 30.0, 10.0, 10.0)];
spans.extend(
(0..=MAX_INLINE_FRAGMENT_ANCHOR_LOOKBACK)
.map(|index| span_with_width("filler", 300.0, index as f32, 30.0, 10.0, 10.0)),
);
spans.push(span_with_width("2", 130.0, 100.0, 3.0, 6.0, 6.0));
let anchors = vec![None; spans.len()];
assert_eq!(find_inline_fragment_anchor(spans.len() - 1, &spans, &anchors), None);
}
#[test]
fn split_boundary_before_forces_space_between_adjacent_spans() {
let mut next = span_with_width("002", 130.0, 100.0, 18.0, 10.0, 10.0);
next.split_boundary_before = true;
let spans = vec![span_with_width("1.000", 100.0, 100.0, 30.0, 10.0, 10.0), next];
assert_eq!(assemble_page_text(&spans), "1.000 002");
}
#[test]
fn line_local_repair_preserves_column_aware_order() {
let spans = vec![
span_with_width("left-top", 40.0, 100.0, 40.0, 10.0, 10.0),
span_with_width("left-bottom", 40.0, 80.0, 50.0, 10.0, 10.0),
span_with_width("right-top", 300.0, 100.0, 45.0, 10.0, 10.0),
span_with_width("right-bottom", 300.0, 80.0, 55.0, 10.0, 10.0),
];
assert_eq!(
assemble_page_text(&spans),
"left-top\n\nleft-bottom\n\nright-top\n\nright-bottom"
);
}
#[test]
fn sparse_two_column_prose_reorders_by_column() {
let mut spans = vec![
span_with_width("The committee reviewed the annual", 60.0, 712.0, 175.0, 11.0, 11.0),
span_with_width("approved the budget for the", 330.0, 712.0, 145.0, 11.0, 11.0),
span_with_width("report and", 60.0, 698.0, 52.0, 11.0, 11.0),
span_with_width("coming fiscal year.", 330.0, 698.0, 92.0, 11.0, 11.0),
];
assert!(reorder_sparse_two_column_page(&mut spans, 612.0));
assert_eq!(
spans.iter().map(|span| span.text.as_str()).collect::<Vec<_>>(),
[
"The committee reviewed the annual",
"report and",
"approved the budget for the",
"coming fiscal year."
]
);
}
#[test]
fn sparse_two_column_table_keeps_row_order() {
let mut spans = vec![
span_with_width(
"Regional revenue for the northern market.",
60.0,
712.0,
210.0,
11.0,
11.0,
),
span_with_width("Annual total for the current period.", 330.0, 712.0, 190.0, 11.0, 11.0),
span_with_width(
"Operating expense for the northern market.",
60.0,
698.0,
220.0,
11.0,
11.0,
),
span_with_width(
"Quarterly total for the current period.",
330.0,
698.0,
200.0,
11.0,
11.0,
),
];
let original = spans.iter().map(|span| span.text.clone()).collect::<Vec<_>>();
assert!(!reorder_sparse_two_column_page(&mut spans, 612.0));
assert_eq!(
spans.iter().map(|span| span.text.as_str()).collect::<Vec<_>>(),
original.iter().map(String::as_str).collect::<Vec<_>>()
);
}
#[test]
fn sparse_verbose_form_keeps_row_order() {
let mut spans = vec![
span_with_width(
"Account holder full legal name appears here:",
60.0,
712.0,
215.0,
11.0,
11.0,
),
span_with_width(
"Mailing address for all official correspondence:",
330.0,
712.0,
225.0,
11.0,
11.0,
),
span_with_width(
"Emergency contact relationship and telephone number:",
60.0,
698.0,
235.0,
11.0,
11.0,
),
span_with_width(
"Preferred delivery method for annual notices:",
330.0,
698.0,
215.0,
11.0,
11.0,
),
];
let original = spans.iter().map(|span| span.text.clone()).collect::<Vec<_>>();
assert!(!reorder_sparse_two_column_page(&mut spans, 612.0));
assert_eq!(
spans.iter().map(|span| span.text.as_str()).collect::<Vec<_>>(),
original.iter().map(String::as_str).collect::<Vec<_>>()
);
}
#[test]
fn sparse_lowercase_table_keeps_row_order() {
let mut spans = vec![
span_with_width(
"regional revenue for the northern market",
60.0,
712.0,
210.0,
11.0,
11.0,
),
span_with_width("annual total for the current period", 330.0, 712.0, 190.0, 11.0, 11.0),
span_with_width(
"operating expense for the northern market",
60.0,
698.0,
220.0,
11.0,
11.0,
),
span_with_width(
"quarterly total for the current period.",
330.0,
698.0,
200.0,
11.0,
11.0,
),
];
let original = spans.iter().map(|span| span.text.clone()).collect::<Vec<_>>();
assert!(!reorder_sparse_two_column_page(&mut spans, 612.0));
assert_eq!(
spans.iter().map(|span| span.text.as_str()).collect::<Vec<_>>(),
original.iter().map(String::as_str).collect::<Vec<_>>()
);
}
fn dense_two_column_spans() -> Vec<TextSpan> {
const LEFT_X: f32 = 60.0;
const RIGHT_X: f32 = 320.0;
let left_heading = span_with_width("Funding", LEFT_X, 830.0, 70.0, 11.0, 11.0);
let right_heading = span_with_width("References", RIGHT_X, 830.0, 90.0, 11.0, 11.0);
let left_body = [
"The committee reviewed annual budget totals",
"and approved new funding for the coming year",
"after several rounds of careful review by",
"senior staff members from every department",
"who evaluated priorities across the whole",
"organization before reaching a final decision",
"that reflected both short and long term goals",
"for sustainable growth across all programs",
];
let right_body = [
"Numerous studies have examined similar",
"programs across comparable institutions",
"using consistent methodology and controls",
"for measuring outcomes over multiple years",
"researchers found consistent positive trends",
"supporting continued investment going forward",
"additional citations appear in the appendix",
"for readers seeking further detail here",
];
let mut spans = vec![left_heading, right_heading];
for (row, (left_line, right_line)) in left_body.iter().copied().zip(right_body.iter().copied()).enumerate() {
let y = 816.0 - row as f32 * 14.0;
spans.push(span_with_width(left_line, LEFT_X, y, 200.0, 11.0, 11.0));
spans.push(span_with_width(right_line, RIGHT_X, y, 190.0, 11.0, 11.0));
}
spans
}
#[test]
fn dense_two_column_prose_reorders_by_column() {
let mut spans = dense_two_column_spans();
assert!(reorder_dense_two_column_page(&mut spans, 612.0));
let texts = spans.iter().map(|span| span.text.as_str()).collect::<Vec<_>>();
assert_eq!(
texts,
[
"Funding",
"The committee reviewed annual budget totals",
"and approved new funding for the coming year",
"after several rounds of careful review by",
"senior staff members from every department",
"who evaluated priorities across the whole",
"organization before reaching a final decision",
"that reflected both short and long term goals",
"for sustainable growth across all programs",
"References",
"Numerous studies have examined similar",
"programs across comparable institutions",
"using consistent methodology and controls",
"for measuring outcomes over multiple years",
"researchers found consistent positive trends",
"supporting continued investment going forward",
"additional citations appear in the appendix",
"for readers seeking further detail here",
]
);
}
const GH1484_PAGE_WIDTH: f32 = 595.0;
const GH1484_RIGHT_NUMBER_X: f32 = 304.87;
fn dense_two_column_hanging_number_spans() -> Vec<TextSpan> {
const LEFT_NUMBER_X: f32 = 36.0;
const LEFT_TEXT_X: f32 = 64.34;
const LEFT_TEXT_WIDTH: f32 = 226.8;
const RIGHT_TEXT_X: f32 = 333.19;
const ROW_COUNT: usize = 12;
let mut spans = Vec::new();
for row in 0..ROW_COUNT {
let y = 816.0 - row as f32 * 14.0;
if row.is_multiple_of(2) {
spans.push(span_with_width(
&format!("15.{}", row / 2 + 1),
LEFT_NUMBER_X,
y,
17.84,
11.0,
11.0,
));
}
spans.push(span_with_width(
&format!("The left clause line {row} continues with ordinary agreement terms"),
LEFT_TEXT_X,
y,
LEFT_TEXT_WIDTH,
11.0,
11.0,
));
if row.is_multiple_of(2) {
spans.push(span_with_width(
&format!("16.{}", row / 2 + 5),
GH1484_RIGHT_NUMBER_X,
y,
17.84,
11.0,
11.0,
));
}
spans.push(span_with_width(
&format!("The right clause line {row} continues with ordinary agreement terms"),
RIGHT_TEXT_X,
y,
220.0,
11.0,
11.0,
));
}
spans
}
#[test]
fn dense_two_column_hanging_numbers_reorder_by_column() {
let mut spans = dense_two_column_hanging_number_spans();
let expected = spans
.iter()
.filter(|span| span.bbox.x < GH1484_RIGHT_NUMBER_X)
.chain(spans.iter().filter(|span| span.bbox.x >= GH1484_RIGHT_NUMBER_X))
.map(|span| span.text.clone())
.collect::<Vec<_>>();
let order = spans_sorted_top_to_bottom(&spans);
let lines = group_into_lines(&spans, &order);
let detected_split = detect_split_x(&spans, &lines, GH1484_PAGE_WIDTH).expect("numbered page has a gutter");
assert!(detected_split > GH1484_RIGHT_NUMBER_X && detected_split < GH1484_RIGHT_NUMBER_X + 17.84);
assert_eq!(
snap_split_left_of_hanging_labels(&spans, &lines, GH1484_PAGE_WIDTH, detected_split),
GH1484_RIGHT_NUMBER_X
);
assert!(reorder_dense_two_column_page(&mut spans, GH1484_PAGE_WIDTH));
assert_eq!(
spans.iter().map(|span| span.text.as_str()).collect::<Vec<_>>(),
expected.iter().map(String::as_str).collect::<Vec<_>>()
);
}
#[test]
fn dense_two_column_unnumbered_control_keeps_detected_split() {
const PAGE_WIDTH: f32 = 612.0;
let spans = dense_two_column_spans();
let order = spans_sorted_top_to_bottom(&spans);
let lines = group_into_lines(&spans, &order);
let detected_split = detect_split_x(&spans, &lines, PAGE_WIDTH).expect("control has a gutter");
assert_eq!(
snap_split_left_of_hanging_labels(&spans, &lines, PAGE_WIDTH, detected_split),
detected_split
);
}
#[test]
fn dense_column_split_does_not_snap_to_furniture_or_one_off_label() {
const PAGE_WIDTH: f32 = 595.0;
const SPLIT_X: f32 = 300.0;
let mut furniture = (0..MIN_DENSE_COLUMN_SPLIT_LINES)
.map(|row| span_with_width("centred furniture", 250.0, 800.0 - row as f32 * 14.0, 100.0, 11.0, 11.0))
.collect::<Vec<_>>();
furniture.push(span_with_width("note", 295.0, 700.0, 20.0, 11.0, 11.0));
let order = spans_sorted_top_to_bottom(&furniture);
let lines = group_into_lines(&furniture, &order);
assert_eq!(
snap_split_left_of_hanging_labels(&furniture, &lines, PAGE_WIDTH, SPLIT_X),
SPLIT_X
);
}
#[test]
fn dense_column_split_repeats_when_first_snap_reveals_another_fragment() {
const PAGE_WIDTH: f32 = 595.0;
const INITIAL_SPLIT_X: f32 = 305.64;
const FIRST_FRAGMENT_LEFT: f32 = 304.87;
const SECOND_FRAGMENT_LEFT: f32 = 300.0;
let mut spans = Vec::new();
for row in 0..MIN_DENSE_COLUMN_SPLIT_LINES {
let y = 800.0 - row as f32 * 14.0;
spans.push(span_with_width("prefix", SECOND_FRAGMENT_LEFT, y, 5.2, 11.0, 11.0));
spans.push(span_with_width("number", FIRST_FRAGMENT_LEFT, y, 17.84, 11.0, 11.0));
}
let order = spans_sorted_top_to_bottom(&spans);
let lines = group_into_lines(&spans, &order);
let max_snap_width = PAGE_WIDTH * MAX_DENSE_COLUMN_SPLIT_SNAP_SPAN_FRACTION;
let min_gutter = (PAGE_WIDTH * MIN_DENSE_COLUMN_GUTTER_FRACTION).max(MIN_DENSE_COLUMN_GUTTER_PTS);
assert_eq!(
aligned_hanging_label_left_edge(&spans, &lines, max_snap_width, min_gutter, INITIAL_SPLIT_X),
Some(FIRST_FRAGMENT_LEFT)
);
assert_eq!(
aligned_hanging_label_left_edge(&spans, &lines, max_snap_width, min_gutter, FIRST_FRAGMENT_LEFT),
Some(SECOND_FRAGMENT_LEFT)
);
assert_eq!(
snap_split_left_of_hanging_labels(&spans, &lines, PAGE_WIDTH, INITIAL_SPLIT_X),
SECOND_FRAGMENT_LEFT
);
}
#[test]
fn dense_two_column_prose_assembles_without_interleaving_or_heading_weld() {
let mut spans = dense_two_column_spans();
assert!(reorder_dense_two_column_page(&mut spans, 612.0));
assert_eq!(
assemble_page_text(&spans),
"Funding\n\
The committee reviewed annual budget totals\n\
and approved new funding for the coming year\n\
after several rounds of careful review by\n\
senior staff members from every department\n\
who evaluated priorities across the whole\n\
organization before reaching a final decision\n\
that reflected both short and long term goals\n\
for sustainable growth across all programs\n\n\
References\n\
Numerous studies have examined similar\n\
programs across comparable institutions\n\
using consistent methodology and controls\n\
for measuring outcomes over multiple years\n\
researchers found consistent positive trends\n\
supporting continued investment going forward\n\
additional citations appear in the appendix\n\
for readers seeking further detail here"
);
}
#[test]
fn dense_two_column_table_keeps_row_order() {
const LEFT_X: f32 = 60.0;
const RIGHT_X: f32 = 320.0;
let left_body = [
"The committee reviewed annual budget totals",
"and approved new funding for the coming year",
"after several rounds of careful review by",
"senior staff members from every department",
"who evaluated priorities across the whole",
"organization before reaching a final decision",
"that reflected both short and long term goals",
"for sustainable growth across all programs",
];
let right_cells = ["12.3", "45.6", "78.9", "10.1", "21.2", "33.4", "45.5", "67.8"];
let mut spans = Vec::new();
for (row, (left_line, right_cell)) in left_body.iter().copied().zip(right_cells.iter().copied()).enumerate() {
let y = 816.0 - row as f32 * 14.0;
spans.push(span_with_width(left_line, LEFT_X, y, 200.0, 11.0, 11.0));
spans.push(span_with_width(right_cell, RIGHT_X, y, 30.0, 11.0, 11.0));
}
let original = spans.iter().map(|span| span.text.clone()).collect::<Vec<_>>();
assert!(!reorder_dense_two_column_page(&mut spans, 612.0));
assert_eq!(
spans.iter().map(|span| span.text.as_str()).collect::<Vec<_>>(),
original.iter().map(String::as_str).collect::<Vec<_>>()
);
}
#[test]
fn dense_two_column_prose_reorders_around_header_and_footer() {
let mut spans = vec![span_with_width(
"Quarterly Report - Internal Distribution Only",
60.0,
850.0,
497.0,
11.0,
11.0,
)];
spans.extend(dense_two_column_spans());
spans.push(span_with_width("Page 1 of 12", 60.0, 700.0, 497.0, 11.0, 11.0));
assert!(reorder_dense_two_column_page(&mut spans, 612.0));
let texts = spans.iter().map(|span| span.text.as_str()).collect::<Vec<_>>();
assert_eq!(
texts,
[
"Quarterly Report - Internal Distribution Only",
"Funding",
"The committee reviewed annual budget totals",
"and approved new funding for the coming year",
"after several rounds of careful review by",
"senior staff members from every department",
"who evaluated priorities across the whole",
"organization before reaching a final decision",
"that reflected both short and long term goals",
"for sustainable growth across all programs",
"References",
"Numerous studies have examined similar",
"programs across comparable institutions",
"using consistent methodology and controls",
"for measuring outcomes over multiple years",
"researchers found consistent positive trends",
"supporting continued investment going forward",
"additional citations appear in the appendix",
"for readers seeking further detail here",
"Page 1 of 12",
]
);
}
#[test]
fn dense_two_column_prose_keeps_midpage_heading_above_both_columns() {
let mut spans = vec![span_with_width(
"Annual Committee Findings",
60.0,
840.0,
497.0,
11.0,
11.0,
)];
spans.extend(dense_two_column_spans());
assert!(reorder_dense_two_column_page(&mut spans, 612.0));
let texts = spans.iter().map(|span| span.text.as_str()).collect::<Vec<_>>();
assert_eq!(texts[0], "Annual Committee Findings");
let heading_index = 0;
let funding_index = texts.iter().position(|&text| text == "Funding").unwrap();
let references_index = texts.iter().position(|&text| text == "References").unwrap();
assert!(heading_index < funding_index && heading_index < references_index);
}
fn two_column_band(row_count: usize, y_start: f32, label: &str) -> Vec<TextSpan> {
const LEFT_X: f32 = 60.0;
const RIGHT_X: f32 = 320.0;
let mut spans = Vec::with_capacity(row_count * 2);
for row in 0..row_count {
let y = y_start - row as f32 * 14.0;
let left_text = format!("The {label} left column continues with sentence number {row} of the report");
let right_text = format!("The {label} right column continues with sentence number {row} of the report");
spans.push(span_with_width(&left_text, LEFT_X, y, 200.0, 11.0, 11.0));
spans.push(span_with_width(&right_text, RIGHT_X, y, 190.0, 11.0, 11.0));
}
spans
}
fn assert_bands_reordered_around_furniture(spans: &mut [TextSpan], furniture_text: &str, rows_per_band: usize) {
assert!(reorder_dense_two_column_page(spans, 612.0));
let texts = spans.iter().map(|span| span.text.as_str()).collect::<Vec<_>>();
let furniture_index = texts.iter().position(|&text| text == furniture_text).unwrap();
assert_eq!(
furniture_index,
rows_per_band * 2,
"furniture must land strictly after the whole band above it"
);
assert_eq!(texts.len(), rows_per_band * 4 + 1);
for row in 0..rows_per_band {
assert_eq!(
texts[row],
format!("The first left column continues with sentence number {row} of the report")
);
assert_eq!(
texts[rows_per_band + row],
format!("The first right column continues with sentence number {row} of the report")
);
}
let below_start = furniture_index + 1;
for row in 0..rows_per_band {
assert_eq!(
texts[below_start + row],
format!("The second left column continues with sentence number {row} of the report")
);
assert_eq!(
texts[below_start + rows_per_band + row],
format!("The second right column continues with sentence number {row} of the report")
);
}
}
#[test]
fn dense_two_column_prose_reorders_around_midpage_banner() {
const ROWS_PER_BAND: usize = 7;
const PAGE_WIDTH: f32 = 612.0;
const BANNER_TEXT: &str = "Quarterly Report - Company Wide Distribution Banner";
let band_above = two_column_band(ROWS_PER_BAND, 830.0, "first");
let band_below = two_column_band(ROWS_PER_BAND, 830.0 - ROWS_PER_BAND as f32 * 14.0 - 20.0, "second");
let banner_y = 830.0 - (ROWS_PER_BAND as f32 - 1.0) * 14.0 - 10.0;
let banner_width = PAGE_WIDTH * 0.66;
let banner_x = (PAGE_WIDTH - banner_width) / 2.0;
let mut spans = band_above;
spans.push(span_with_width(
BANNER_TEXT,
banner_x,
banner_y,
banner_width,
11.0,
11.0,
));
spans.extend(band_below);
assert_bands_reordered_around_furniture(&mut spans, BANNER_TEXT, ROWS_PER_BAND);
}
#[test]
fn dense_two_column_prose_reorders_around_narrow_gutter_crossing_rule() {
const ROWS_PER_BAND: usize = 7;
const PAGE_WIDTH: f32 = 612.0;
const RULE_TEXT: &str = "----------";
let band_above = two_column_band(ROWS_PER_BAND, 830.0, "first");
let band_below = two_column_band(ROWS_PER_BAND, 830.0 - ROWS_PER_BAND as f32 * 14.0 - 20.0, "second");
let rule_y = 830.0 - (ROWS_PER_BAND as f32 - 1.0) * 14.0 - 10.0;
let rule_width = PAGE_WIDTH * 0.30;
let mut spans = band_above;
spans.push(span_with_width(RULE_TEXT, 200.0, rule_y, rule_width, 2.0, 2.0));
spans.extend(band_below);
assert_bands_reordered_around_furniture(&mut spans, RULE_TEXT, ROWS_PER_BAND);
}
#[test]
fn single_column_page_with_wide_and_narrow_lines_is_not_split() {
const COLUMN_X: f32 = 60.0;
let lines: [(&str, f32); 8] = [
("This is a long justified line of body text filling", 470.0),
("the page width almost completely from margin", 470.0),
("to margin, as ordinary single-column prose does", 470.0),
("Short line.", 90.0),
("Another full-width line of ordinary body text here", 470.0),
("Brief.", 90.0),
("A further wide line completing this single paragraph", 470.0),
("End.", 90.0),
];
let mut spans = Vec::new();
for (row, (text, width)) in lines.iter().enumerate() {
let y = 800.0 - row as f32 * 14.0;
spans.push(span_with_width(text, COLUMN_X, y, *width, 11.0, 11.0));
}
let original = spans.iter().map(|span| span.text.clone()).collect::<Vec<_>>();
assert!(!reorder_dense_two_column_page(&mut spans, 612.0));
assert_eq!(
spans.iter().map(|span| span.text.as_str()).collect::<Vec<_>>(),
original.iter().map(String::as_str).collect::<Vec<_>>()
);
}
#[test]
#[expect(
clippy::too_many_lines,
reason = "one span literal per pdftotext word, transcribed verbatim for fidelity"
)]
fn gh1545_table_beside_prose_emits_table_then_prose() {
const PAGE_WIDTH: f32 = 595.0;
const IDEAL_TABLE_PROSE_SPLIT_X: f32 = 295.0;
const TABLE_RIGHT_EDGE_X: f32 = 285.622;
const PROSE_LEFT_EDGE_X: f32 = 303.600;
const PANEL_B_LEFT_EDGE_X: f32 = 164.803;
const TITLE_ROW_Y: f32 = 730.0;
#[rustfmt::skip]
let mut spans = vec![
span_with_width("Table", 47.700, 735.026, 17.507, 6.475, 6.475),
span_with_width("1", 67.153, 735.026, 3.892, 6.475, 6.475),
span_with_width("Sample", 72.991, 735.026, 23.730, 6.475, 6.475),
span_with_width("characteristics", 98.667, 735.026, 44.730, 6.475, 6.475),
span_with_width("of", 145.343, 735.026, 5.838, 6.475, 6.475),
span_with_width("the", 153.127, 735.026, 9.730, 6.475, 6.475),
span_with_width("Northfield", 164.803, 735.026, 29.953, 6.475, 6.475),
span_with_width("and", 196.702, 735.026, 11.676, 6.475, 6.475),
span_with_width("Eastgate", 210.324, 735.026, 27.629, 6.475, 6.475),
span_with_width("cohorts.", 239.899, 735.026, 24.899, 6.475, 6.475),
span_with_width("Sex", 47.700, 718.926, 12.061, 6.475, 6.475),
span_with_width("Female", 47.700, 710.876, 23.338, 6.475, 6.475),
span_with_width("Male", 47.700, 702.826, 15.169, 6.475, 6.475),
span_with_width("Age", 47.700, 694.776, 12.453, 6.475, 6.475),
span_with_width("18-24", 62.099, 694.776, 17.899, 6.475, 6.475),
span_with_width("25-34", 47.700, 686.726, 17.899, 6.475, 6.475),
span_with_width("35-44", 47.700, 678.676, 17.899, 6.475, 6.475),
span_with_width("45-54", 47.700, 670.626, 17.899, 6.475, 6.475),
span_with_width("55-64", 47.700, 662.576, 17.899, 6.475, 6.475),
span_with_width("Ethnicity", 47.700, 654.526, 26.453, 6.475, 6.475),
span_with_width("Group", 47.700, 646.476, 19.453, 6.475, 6.475),
span_with_width("one", 69.099, 646.476, 11.676, 6.475, 6.475),
span_with_width("Group", 47.700, 638.426, 19.453, 6.475, 6.475),
span_with_width("two", 69.099, 638.426, 10.892, 6.475, 6.475),
span_with_width("Group", 47.700, 630.376, 19.453, 6.475, 6.475),
span_with_width("three", 69.099, 630.376, 15.953, 6.475, 6.475),
span_with_width("Group", 47.700, 622.326, 19.453, 6.475, 6.475),
span_with_width("four", 69.099, 622.326, 12.061, 6.475, 6.475),
span_with_width("Group", 47.700, 614.276, 19.453, 6.475, 6.475),
span_with_width("five", 69.099, 614.276, 10.892, 6.475, 6.475),
span_with_width("Living", 47.700, 606.226, 18.284, 6.475, 6.475),
span_with_width("location", 67.930, 606.226, 24.122, 6.475, 6.475),
span_with_width("City", 47.700, 598.176, 12.054, 6.475, 6.475),
span_with_width("Suburb", 47.700, 590.126, 22.568, 6.475, 6.475),
span_with_width("Town", 47.700, 582.076, 17.115, 6.475, 6.475),
span_with_width("Rural", 47.700, 574.026, 16.723, 6.475, 6.475),
span_with_width("Highest", 47.700, 565.976, 23.730, 6.475, 6.475),
span_with_width("education", 73.376, 565.976, 30.352, 6.475, 6.475),
span_with_width("No", 47.700, 557.926, 8.946, 6.475, 6.475),
span_with_width("qualifications", 58.592, 557.926, 40.460, 6.475, 6.475),
span_with_width("Secondary", 47.700, 549.876, 33.460, 6.475, 6.475),
span_with_width("school", 83.106, 549.876, 20.230, 6.475, 6.475),
span_with_width("Diploma", 47.700, 541.826, 25.669, 6.475, 6.475),
span_with_width("Undergraduate", 47.700, 533.776, 46.690, 6.475, 6.475),
span_with_width("degree", 96.336, 533.776, 21.791, 6.475, 6.475),
span_with_width("Postgraduate", 47.700, 525.726, 41.636, 6.475, 6.475),
span_with_width("degree", 91.282, 525.726, 21.791, 6.475, 6.475),
span_with_width("Employment", 47.700, 517.676, 38.899, 6.475, 6.475),
span_with_width("status", 88.545, 517.676, 18.676, 6.475, 6.475),
span_with_width("Full-time", 47.700, 509.626, 26.831, 6.475, 6.475),
span_with_width("employed", 76.477, 509.626, 30.345, 6.475, 6.475),
span_with_width("Part-time", 47.700, 501.576, 28.392, 6.475, 6.475),
span_with_width("employed", 78.038, 501.576, 30.345, 6.475, 6.475),
span_with_width("Retired", 47.700, 493.526, 22.561, 6.475, 6.475),
span_with_width("Not", 47.700, 485.476, 10.892, 6.475, 6.475),
span_with_width("employed", 60.538, 485.476, 30.345, 6.475, 6.475),
span_with_width("%", 148.100, 718.926, 6.223, 6.475, 6.475),
span_with_width("Sex", 165.000, 718.926, 12.061, 6.475, 6.475),
span_with_width("51.5", 148.100, 710.876, 13.622, 6.475, 6.475),
span_with_width("Female", 165.000, 710.876, 23.338, 6.475, 6.475),
span_with_width("48.2", 148.100, 702.826, 13.622, 6.475, 6.475),
span_with_width("Male", 165.000, 702.826, 15.169, 6.475, 6.475),
span_with_width("11.1", 148.100, 694.776, 13.622, 6.475, 6.475),
span_with_width("Age", 165.000, 694.776, 12.453, 6.475, 6.475),
span_with_width("18-24", 179.399, 694.776, 17.899, 6.475, 6.475),
span_with_width("19.2", 148.100, 686.726, 13.622, 6.475, 6.475),
span_with_width("25-34", 165.000, 686.726, 17.899, 6.475, 6.475),
span_with_width("20.6", 148.100, 678.676, 13.622, 6.475, 6.475),
span_with_width("35-44", 165.000, 678.676, 17.899, 6.475, 6.475),
span_with_width("15.9", 148.100, 670.626, 13.622, 6.475, 6.475),
span_with_width("45-54", 165.000, 670.626, 17.899, 6.475, 6.475),
span_with_width("21.0", 148.100, 662.576, 13.622, 6.475, 6.475),
span_with_width("55-64", 165.000, 662.576, 17.899, 6.475, 6.475),
span_with_width("%", 272.000, 718.926, 6.223, 6.475, 6.475),
span_with_width("51.7", 272.000, 710.876, 13.622, 6.475, 6.475),
span_with_width("48.3", 272.000, 702.826, 13.622, 6.475, 6.475),
span_with_width("12.1", 272.000, 694.776, 13.622, 6.475, 6.475),
span_with_width("18.8", 272.000, 686.726, 13.622, 6.475, 6.475),
span_with_width("17.4", 272.000, 678.676, 13.622, 6.475, 6.475),
span_with_width("20.2", 272.000, 670.626, 13.622, 6.475, 6.475),
span_with_width("17.2", 272.000, 662.576, 13.622, 6.475, 6.475),
span_with_width("17.3", 148.100, 646.476, 13.622, 6.475, 6.475),
span_with_width("Group", 165.000, 646.476, 19.453, 6.475, 6.475),
span_with_width("one", 186.399, 646.476, 11.676, 6.475, 6.475),
span_with_width("1.9", 148.100, 638.426, 9.730, 6.475, 6.475),
span_with_width("Group", 165.000, 638.426, 19.453, 6.475, 6.475),
span_with_width("two", 186.399, 638.426, 10.892, 6.475, 6.475),
span_with_width("0.3", 148.100, 630.376, 9.730, 6.475, 6.475),
span_with_width("Group", 165.000, 630.376, 19.453, 6.475, 6.475),
span_with_width("three", 186.399, 630.376, 15.953, 6.475, 6.475),
span_with_width("0.4", 148.100, 622.326, 9.730, 6.475, 6.475),
span_with_width("Group", 165.000, 622.326, 19.453, 6.475, 6.475),
span_with_width("four", 186.399, 622.326, 12.061, 6.475, 6.475),
span_with_width("3.2", 148.100, 614.276, 9.730, 6.475, 6.475),
span_with_width("Group", 165.000, 614.276, 19.453, 6.475, 6.475),
span_with_width("five", 186.399, 614.276, 10.892, 6.475, 6.475),
span_with_width("14.2", 272.000, 646.476, 13.622, 6.475, 6.475),
span_with_width("2.4", 272.000, 638.426, 9.730, 6.475, 6.475),
span_with_width("0.6", 272.000, 630.376, 9.730, 6.475, 6.475),
span_with_width("1.1", 272.000, 622.326, 9.730, 6.475, 6.475),
span_with_width("2.8", 272.000, 614.276, 9.730, 6.475, 6.475),
span_with_width("24.5", 148.100, 598.176, 13.622, 6.475, 6.475),
span_with_width("City", 165.000, 598.176, 12.054, 6.475, 6.475),
span_with_width("18.1", 148.100, 590.126, 13.622, 6.475, 6.475),
span_with_width("Suburb", 165.000, 590.126, 22.568, 6.475, 6.475),
span_with_width("26.8", 148.100, 582.076, 13.622, 6.475, 6.475),
span_with_width("Town", 165.000, 582.076, 17.115, 6.475, 6.475),
span_with_width("28.8", 148.100, 574.026, 13.622, 6.475, 6.475),
span_with_width("Rural", 165.000, 574.026, 16.723, 6.475, 6.475),
span_with_width("26.1", 272.000, 598.176, 13.622, 6.475, 6.475),
span_with_width("19.4", 272.000, 590.126, 13.622, 6.475, 6.475),
span_with_width("24.9", 272.000, 582.076, 13.622, 6.475, 6.475),
span_with_width("29.6", 272.000, 574.026, 13.622, 6.475, 6.475),
span_with_width("1.2", 148.100, 557.926, 9.730, 6.475, 6.475),
span_with_width("No", 165.000, 557.926, 8.946, 6.475, 6.475),
span_with_width("qualifications", 175.892, 557.926, 40.460, 6.475, 6.475),
span_with_width("6.4", 148.100, 549.876, 9.730, 6.475, 6.475),
span_with_width("Secondary", 165.000, 549.876, 33.460, 6.475, 6.475),
span_with_width("school", 200.406, 549.876, 20.230, 6.475, 6.475),
span_with_width("22.5", 148.100, 541.826, 13.622, 6.475, 6.475),
span_with_width("Diploma", 165.000, 541.826, 25.669, 6.475, 6.475),
span_with_width("19.8", 148.100, 533.776, 13.622, 6.475, 6.475),
span_with_width("Undergraduate", 165.000, 533.776, 46.690, 6.475, 6.475),
span_with_width("degree", 213.636, 533.776, 21.791, 6.475, 6.475),
span_with_width("27.9", 148.100, 525.726, 13.622, 6.475, 6.475),
span_with_width("Postgraduate", 165.000, 525.726, 41.636, 6.475, 6.475),
span_with_width("degree", 208.582, 525.726, 21.791, 6.475, 6.475),
span_with_width("1.8", 272.000, 557.926, 9.730, 6.475, 6.475),
span_with_width("7.1", 272.000, 549.876, 9.730, 6.475, 6.475),
span_with_width("21.8", 272.000, 541.826, 13.622, 6.475, 6.475),
span_with_width("20.4", 272.000, 533.776, 13.622, 6.475, 6.475),
span_with_width("26.3", 272.000, 525.726, 13.622, 6.475, 6.475),
span_with_width("43.3", 148.100, 509.626, 13.622, 6.475, 6.475),
span_with_width("Full-time", 165.000, 509.626, 26.831, 6.475, 6.475),
span_with_width("employed", 193.777, 509.626, 30.345, 6.475, 6.475),
span_with_width("15.7", 148.100, 501.576, 13.622, 6.475, 6.475),
span_with_width("Part-time", 165.000, 501.576, 28.392, 6.475, 6.475),
span_with_width("employed", 195.338, 501.576, 30.345, 6.475, 6.475),
span_with_width("15.0", 148.100, 493.526, 13.622, 6.475, 6.475),
span_with_width("Retired", 165.000, 493.526, 22.561, 6.475, 6.475),
span_with_width("8.4", 148.100, 485.476, 9.730, 6.475, 6.475),
span_with_width("Not", 165.000, 485.476, 10.892, 6.475, 6.475),
span_with_width("employed", 177.838, 485.476, 30.345, 6.475, 6.475),
span_with_width("41.9", 272.000, 509.626, 13.622, 6.475, 6.475),
span_with_width("16.4", 272.000, 501.576, 13.622, 6.475, 6.475),
span_with_width("14.6", 272.000, 493.526, 13.622, 6.475, 6.475),
span_with_width("9.2", 272.000, 485.476, 9.730, 6.475, 6.475),
span_with_width("Participants", 303.600, 736.103, 44.404, 7.862, 7.862),
span_with_width("in", 350.367, 736.103, 6.613, 7.862, 7.862),
span_with_width("the", 359.343, 736.103, 11.815, 7.862, 7.862),
span_with_width("Northfield", 373.521, 736.103, 36.371, 7.862, 7.862),
span_with_width("cohort", 412.255, 736.103, 23.622, 7.862, 7.862),
span_with_width("who", 438.240, 736.103, 15.589, 7.862, 7.862),
span_with_width("reported", 456.192, 736.103, 31.654, 7.862, 7.862),
span_with_width("low", 490.209, 736.103, 12.750, 7.862, 7.862),
span_with_width("confidence", 303.600, 725.653, 41.106, 7.863, 7.863),
span_with_width("in", 347.069, 725.653, 6.613, 7.863, 7.863),
span_with_width("the", 356.045, 725.653, 11.815, 7.863, 7.863),
span_with_width("programme", 370.223, 725.653, 43.452, 7.863, 7.863),
span_with_width("were,", 416.038, 725.653, 20.782, 7.863, 7.863),
span_with_width("compared", 439.183, 725.653, 37.791, 7.863, 7.863),
span_with_width("with", 479.337, 725.653, 15.113, 7.863, 7.863),
span_with_width("those", 496.813, 725.653, 20.791, 7.863, 7.863),
span_with_width("who", 303.600, 715.203, 15.589, 7.863, 7.863),
span_with_width("reported", 321.552, 715.203, 31.654, 7.863, 7.863),
span_with_width("high", 355.569, 715.203, 16.065, 7.863, 7.863),
span_with_width("confidence,", 373.997, 715.203, 43.469, 7.863, 7.863),
span_with_width("more", 419.829, 715.203, 19.363, 7.863, 7.863),
span_with_width("likely", 441.555, 715.203, 18.887, 7.863, 7.863),
span_with_width("to", 462.805, 715.203, 7.089, 7.863, 7.863),
span_with_width("be", 472.257, 715.203, 9.452, 7.863, 7.863),
span_with_width("aged", 484.072, 715.203, 18.904, 7.863, 7.863),
span_with_width("35", 505.339, 715.203, 9.452, 7.863, 7.863),
span_with_width("to", 303.600, 704.753, 7.089, 7.862, 7.862),
span_with_width("44", 313.052, 704.753, 9.452, 7.862, 7.862),
span_with_width("years,", 324.867, 704.753, 23.145, 7.862, 7.862),
span_with_width("to", 350.375, 704.753, 7.089, 7.862, 7.862),
span_with_width("live", 359.827, 704.753, 12.750, 7.862, 7.862),
span_with_width("in", 374.940, 704.753, 6.613, 7.862, 7.862),
span_with_width("a", 383.916, 704.753, 4.726, 7.862, 7.862),
span_with_width("city,", 391.005, 704.753, 15.113, 7.862, 7.862),
span_with_width("to", 408.481, 704.753, 7.089, 7.862, 7.862),
span_with_width("hold", 417.933, 704.753, 16.065, 7.862, 7.862),
span_with_width("no", 436.361, 704.753, 9.452, 7.862, 7.862),
span_with_width("post-school", 448.176, 704.753, 43.461, 7.862, 7.862),
span_with_width("qualification,", 303.600, 694.303, 47.243, 7.863, 7.863),
span_with_width("and", 353.206, 694.303, 14.178, 7.863, 7.863),
span_with_width("to", 369.747, 694.303, 7.089, 7.863, 7.863),
span_with_width("report", 379.199, 694.303, 22.202, 7.863, 7.863),
span_with_width("that", 403.764, 694.303, 14.178, 7.863, 7.863),
span_with_width("they", 420.305, 694.303, 16.065, 7.863, 7.863),
span_with_width("had", 438.733, 694.303, 14.178, 7.863, 7.863),
span_with_width("not", 455.274, 694.303, 11.815, 7.863, 7.863),
span_with_width("voted", 469.452, 694.303, 20.791, 7.863, 7.863),
span_with_width("at", 492.606, 694.303, 7.089, 7.863, 7.863),
span_with_width("the", 303.600, 683.853, 11.815, 7.863, 7.863),
span_with_width("most", 317.778, 683.853, 18.419, 7.863, 7.863),
span_with_width("recent", 338.560, 683.853, 23.622, 7.863, 7.863),
span_with_width("municipal", 364.545, 683.853, 35.895, 7.863, 7.863),
span_with_width("election.", 402.803, 683.853, 31.654, 7.863, 7.863),
span_with_width("The", 436.820, 683.853, 14.646, 7.863, 7.863),
span_with_width("same", 453.829, 683.853, 20.782, 7.863, 7.863),
span_with_width("pattern", 476.974, 683.853, 26.461, 7.863, 7.863),
span_with_width("was", 505.798, 683.853, 15.113, 7.863, 7.863),
span_with_width("not", 303.600, 673.403, 11.815, 7.862, 7.862),
span_with_width("observed", 317.778, 673.403, 34.960, 7.862, 7.862),
span_with_width("in", 355.101, 673.403, 6.613, 7.862, 7.862),
span_with_width("the", 364.077, 673.403, 11.815, 7.862, 7.862),
span_with_width("Eastgate", 378.255, 673.403, 33.550, 7.862, 7.862),
span_with_width("cohort,", 414.168, 673.403, 25.984, 7.862, 7.862),
span_with_width("where", 442.515, 673.403, 23.146, 7.862, 7.862),
span_with_width("the", 468.024, 673.403, 11.815, 7.862, 7.862),
span_with_width("strongest", 482.202, 673.403, 34.961, 7.862, 7.862),
span_with_width("association", 303.600, 662.953, 42.517, 7.863, 7.863),
span_with_width("was", 348.480, 662.953, 15.113, 7.863, 7.863),
span_with_width("with", 365.956, 662.953, 15.113, 7.863, 7.863),
span_with_width("employment", 383.432, 662.953, 46.291, 7.863, 7.863),
span_with_width("status", 432.086, 662.953, 22.678, 7.863, 7.863),
span_with_width("rather", 457.127, 662.953, 22.202, 7.863, 7.863),
span_with_width("than", 481.692, 662.953, 16.541, 7.863, 7.863),
span_with_width("with", 500.596, 662.953, 15.113, 7.863, 7.863),
span_with_width("age", 303.600, 652.503, 14.178, 7.862, 7.862),
span_with_width("or", 320.141, 652.503, 7.556, 7.862, 7.862),
span_with_width("education.", 330.060, 652.503, 39.219, 7.862, 7.862),
span_with_width("Full", 371.642, 652.503, 13.694, 7.862, 7.862),
span_with_width("model", 387.699, 652.503, 23.145, 7.862, 7.862),
span_with_width("output", 413.207, 652.503, 23.630, 7.862, 7.862),
span_with_width("for", 439.200, 652.503, 9.920, 7.862, 7.862),
span_with_width("both", 451.483, 652.503, 16.541, 7.862, 7.862),
span_with_width("cohorts", 470.387, 652.503, 27.872, 7.862, 7.862),
span_with_width("is", 500.622, 652.503, 6.137, 7.862, 7.862),
span_with_width("given", 303.600, 642.053, 20.315, 7.863, 7.863),
span_with_width("in", 326.278, 642.053, 6.613, 7.863, 7.863),
span_with_width("Tables", 335.254, 642.053, 25.508, 7.863, 7.863),
span_with_width("2", 363.125, 642.053, 4.726, 7.863, 7.863),
span_with_width("and", 370.214, 642.053, 14.178, 7.863, 7.863),
span_with_width("3.", 386.755, 642.053, 7.089, 7.863, 7.863),
span_with_width("Percentages", 396.207, 642.053, 47.719, 7.863, 7.863),
span_with_width("in", 446.289, 642.053, 6.613, 7.863, 7.863),
span_with_width("Table", 455.265, 642.053, 21.259, 7.863, 7.863),
span_with_width("1", 478.887, 642.053, 4.726, 7.863, 7.863),
span_with_width("are", 485.976, 642.053, 12.283, 7.863, 7.863),
span_with_width("column", 303.600, 631.603, 27.395, 7.863, 7.863),
span_with_width("percentages", 333.358, 631.603, 46.776, 7.863, 7.863),
span_with_width("and", 382.497, 631.603, 14.178, 7.863, 7.863),
span_with_width("may", 399.038, 631.603, 16.056, 7.863, 7.863),
span_with_width("not", 417.457, 631.603, 11.815, 7.863, 7.863),
span_with_width("sum", 431.635, 631.603, 16.057, 7.863, 7.863),
span_with_width("to", 450.055, 631.603, 7.089, 7.863, 7.863),
span_with_width("one", 459.507, 631.603, 14.178, 7.863, 7.863),
span_with_width("hundred", 476.048, 631.603, 31.187, 7.863, 7.863),
span_with_width("where", 509.598, 631.603, 23.146, 7.863, 7.863),
span_with_width("a", 303.600, 621.153, 4.726, 7.862, 7.862),
span_with_width("category", 310.689, 621.153, 32.597, 7.862, 7.862),
span_with_width("was", 345.649, 621.153, 15.113, 7.862, 7.862),
span_with_width("left", 363.125, 621.153, 11.339, 7.862, 7.862),
span_with_width("blank", 376.827, 621.153, 20.315, 7.862, 7.862),
span_with_width("by", 399.505, 621.153, 8.976, 7.862, 7.862),
span_with_width("the", 410.844, 621.153, 11.815, 7.862, 7.862),
span_with_width("respondent.", 425.022, 621.153, 44.889, 7.862, 7.862),
span_with_width("Weighting", 472.274, 621.153, 37.791, 7.862, 7.862),
span_with_width("was", 303.600, 610.703, 15.113, 7.863, 7.863),
span_with_width("applied", 321.076, 610.703, 27.404, 7.863, 7.863),
span_with_width("to", 350.843, 610.703, 7.089, 7.863, 7.863),
span_with_width("the", 360.295, 610.703, 11.815, 7.863, 7.863),
span_with_width("age", 374.473, 610.703, 14.178, 7.863, 7.863),
span_with_width("and", 391.014, 610.703, 14.178, 7.863, 7.863),
span_with_width("sex", 407.555, 610.703, 13.226, 7.863, 7.863),
span_with_width("margins", 423.144, 610.703, 30.226, 7.863, 7.863),
span_with_width("of", 455.733, 610.703, 7.089, 7.863, 7.863),
span_with_width("each", 465.185, 610.703, 18.428, 7.863, 7.863),
span_with_width("cohort", 485.976, 610.703, 23.622, 7.863, 7.863),
span_with_width("separately,", 303.600, 600.253, 41.573, 7.862, 7.862),
span_with_width("using", 347.536, 600.253, 20.315, 7.862, 7.862),
span_with_width("the", 370.214, 600.253, 11.815, 7.862, 7.862),
span_with_width("published", 384.392, 600.253, 36.380, 7.862, 7.862),
span_with_width("municipal", 423.135, 600.253, 35.896, 7.862, 7.862),
span_with_width("register", 461.394, 600.253, 28.339, 7.862, 7.862),
span_with_width("as", 492.096, 600.253, 8.976, 7.862, 7.862),
span_with_width("the", 303.600, 589.803, 11.815, 7.863, 7.863),
span_with_width("reference", 317.778, 589.803, 35.904, 7.863, 7.863),
span_with_width("distribution", 356.045, 589.803, 41.097, 7.863, 7.863),
span_with_width("for", 399.505, 589.803, 9.920, 7.863, 7.863),
span_with_width("both.", 411.788, 589.803, 18.904, 7.863, 7.863),
span_with_width("Respondents", 433.055, 589.803, 50.082, 7.863, 7.863),
span_with_width("who", 485.500, 589.803, 15.589, 7.863, 7.863),
span_with_width("completed", 303.600, 579.353, 39.210, 7.863, 7.863),
span_with_width("fewer", 345.173, 579.353, 20.783, 7.863, 7.863),
span_with_width("than", 368.319, 579.353, 16.541, 7.863, 7.863),
span_with_width("half", 387.223, 579.353, 13.702, 7.863, 7.863),
span_with_width("of", 403.288, 579.353, 7.089, 7.863, 7.863),
span_with_width("the", 412.740, 579.353, 11.815, 7.863, 7.863),
span_with_width("items", 426.918, 579.353, 20.306, 7.863, 7.863),
span_with_width("were", 449.587, 579.353, 18.420, 7.863, 7.863),
span_with_width("excluded", 470.370, 579.353, 34.017, 7.863, 7.863),
span_with_width("before", 303.600, 568.903, 24.097, 7.863, 7.863),
span_with_width("weighting,", 330.060, 568.903, 38.267, 7.863, 7.863),
span_with_width("which", 370.690, 568.903, 21.726, 7.863, 7.863),
span_with_width("removed", 394.779, 568.903, 33.065, 7.863, 7.863),
span_with_width("a", 430.207, 568.903, 4.726, 7.863, 7.863),
span_with_width("small", 437.296, 568.903, 19.831, 7.863, 7.863),
span_with_width("number", 459.490, 568.903, 28.815, 7.863, 7.863),
span_with_width("of", 490.668, 568.903, 7.089, 7.863, 7.863),
span_with_width("cases", 500.120, 568.903, 22.202, 7.863, 7.863),
span_with_width("from", 303.600, 558.453, 17.000, 7.862, 7.862),
span_with_width("each", 322.963, 558.453, 18.428, 7.862, 7.862),
span_with_width("cohort", 343.754, 558.453, 23.621, 7.862, 7.862),
span_with_width("and", 369.738, 558.453, 14.178, 7.862, 7.862),
span_with_width("did", 386.279, 558.453, 11.339, 7.862, 7.862),
span_with_width("not", 399.981, 558.453, 11.815, 7.862, 7.862),
span_with_width("change", 414.159, 558.453, 27.880, 7.862, 7.862),
span_with_width("the", 444.402, 558.453, 11.815, 7.862, 7.862),
span_with_width("direction", 458.580, 558.453, 32.122, 7.862, 7.862),
span_with_width("of", 493.065, 558.453, 7.089, 7.862, 7.862),
span_with_width("any", 502.517, 558.453, 13.702, 7.862, 7.862),
span_with_width("reported", 303.600, 548.003, 31.654, 7.863, 7.863),
span_with_width("association.", 337.617, 548.003, 44.880, 7.863, 7.863),
span_with_width("The", 384.860, 548.003, 14.645, 7.863, 7.863),
span_with_width("analysis", 401.868, 548.003, 30.702, 7.863, 7.863),
span_with_width("was", 434.933, 548.003, 15.113, 7.863, 7.863),
span_with_width("pre-registered.", 452.409, 548.003, 55.267, 7.863, 7.863),
];
let prose_order_before: Vec<String> = spans
.iter()
.filter(|span| span.bbox.x >= IDEAL_TABLE_PROSE_SPLIT_X)
.map(|span| span.text.clone())
.collect();
let order = spans_sorted_top_to_bottom(&spans);
let lines = group_into_lines(&spans, &order);
let detected = detect_split_x(&spans, &lines, PAGE_WIDTH).expect("a split is detectable");
let snapped = snap_split_left_of_hanging_labels(&spans, &lines, PAGE_WIDTH, detected);
assert!(
spans
.iter()
.any(|span| span.bbox.left() < snapped && span.bbox.right() > snapped),
"the per-line median is expected to still cut a word here ({snapped}); if it no longer \
does, this fixture has stopped exercising the redirect"
);
let split_x = redirect_split_out_of_content(&spans, &lines, PAGE_WIDTH, snapped);
assert!(
split_x > TABLE_RIGHT_EDGE_X && split_x < PROSE_LEFT_EDGE_X,
"split must land in the real table/prose gutter, got {split_x}"
);
assert!(
reorder_dense_two_column_page(&mut spans, PAGE_WIDTH),
"a table beside a prose column must be reordered, not left in full-width Y order"
);
let table_spans = spans
.iter()
.filter(|span| span.bbox.x < IDEAL_TABLE_PROSE_SPLIT_X)
.count();
let first_prose = spans
.iter()
.position(|span| span.bbox.x >= IDEAL_TABLE_PROSE_SPLIT_X)
.expect("the prose column survives the reorder");
assert_eq!(
first_prose, table_spans,
"every table span must be emitted before every prose span"
);
let prose_order_after: Vec<&str> = spans
.iter()
.filter(|span| span.bbox.x >= IDEAL_TABLE_PROSE_SPLIT_X)
.map(|span| span.text.as_str())
.collect();
assert_eq!(
prose_order_after, prose_order_before,
"the prose column's own reading order must be untouched"
);
let prose = prose_order_after.join(" ");
assert!(
prose.contains("more likely to be aged 35 to 44 years"),
"the sentence must not be spliced by table rows, got: {prose}"
);
let panel_of_row = |span: &xberg_native_pdf::layout::TextSpan| {
(span.bbox.y < TITLE_ROW_Y && span.bbox.x < IDEAL_TABLE_PROSE_SPLIT_X)
.then(|| usize::from(span.bbox.x >= PANEL_B_LEFT_EDGE_X))
};
let last_panel_a = spans.iter().rposition(|span| panel_of_row(span) == Some(0));
let first_panel_b = spans.iter().position(|span| panel_of_row(span) == Some(1));
let (Some(last_panel_a), Some(first_panel_b)) = (last_panel_a, first_panel_b) else {
panic!("both table panels must survive the reorder");
};
assert!(
last_panel_a < first_panel_b,
"panel A must be emitted whole before panel B, but panel A's last span sits at \
{last_panel_a} and panel B's first at {first_panel_b}"
);
}
const GH1603_PAGE_WIDTH: f32 = 595.32;
const GH1603_TRUE_GUTTER_SPLIT_X: f32 = 293.92;
const GH1603_LEFT_INDENT_MID_X: f32 = 63.84;
fn gh1603_narrow_gutter_hanging_number_spans() -> Vec<TextSpan> {
let mut spans = Vec::new();
for row in 0..4 {
let y = 900.0 - row as f32 * 14.0;
let left_text_width = if row == 3 { 222.0 } else { 210.0 };
spans.push(span_with_width(&format!("{row}.1"), 36.0, y, 17.84, 11.0, 11.0));
spans.push(span_with_width(
&format!("The left clause line {row} continues with ordinary agreement terms"),
73.84,
y,
left_text_width,
11.0,
11.0,
));
spans.push(span_with_width(&format!("{row}.2"), 304.0, y, 17.84, 11.0, 11.0));
spans.push(span_with_width(
&format!("The right clause line {row} continues with ordinary agreement terms"),
336.84,
y,
220.0,
11.0,
11.0,
));
}
for row in 4..8 {
let y = 900.0 - row as f32 * 14.0;
spans.push(span_with_width(&format!("{row}.1"), 36.0, y, 17.84, 11.0, 11.0));
spans.push(span_with_width(
&format!("The left clause line {row} continues with ordinary agreement terms"),
73.84,
y,
210.0,
11.0,
11.0,
));
spans.push(span_with_width(
&format!("The right continuation line {row} follows the previous clause"),
336.84,
y - 1.32,
220.0,
11.0,
11.0,
));
}
for row in 8..12 {
let y = 900.0 - row as f32 * 14.0;
spans.push(span_with_width(
&format!("The left continuation line {row} follows the previous clause"),
73.84,
y,
210.0,
11.0,
11.0,
));
spans.push(span_with_width(&format!("{row}.2"), 304.0, y - 1.32, 17.84, 11.0, 11.0));
spans.push(span_with_width(
&format!("The right clause line {row} continues with ordinary agreement terms"),
336.84,
y - 1.32,
220.0,
11.0,
11.0,
));
}
spans
}
#[test]
fn redirect_split_out_of_content_must_not_relocate_into_a_hanging_number_indent_gh1603() {
let spans = gh1603_narrow_gutter_hanging_number_spans();
let order = spans_sorted_top_to_bottom(&spans);
let lines = group_into_lines(&spans, &order);
let detected = detect_split_x(&spans, &lines, GH1603_PAGE_WIDTH).expect("hanging-number page has a gutter");
assert!(
(detected - GH1603_TRUE_GUTTER_SPLIT_X).abs() < 0.01,
"detect_split_x must find the true gutter, got {detected}"
);
let snapped = snap_split_left_of_hanging_labels(&spans, &lines, GH1603_PAGE_WIDTH, detected);
assert_eq!(
snapped, detected,
"no narrow span straddles the true gutter, so the snap must leave it alone"
);
assert!(
spans
.iter()
.any(|span| span.bbox.left() < snapped && span.bbox.right() > snapped),
"row 3's outlier line is expected to still straddle the split ({snapped}); if it no \
longer does, this fixture has stopped exercising the redirect"
);
let redirected = redirect_split_out_of_content(&spans, &lines, GH1603_PAGE_WIDTH, snapped);
assert!(
(redirected - GH1603_LEFT_INDENT_MID_X).abs() > 1.0,
"redirect must not relocate the split into the hanging-number indent at \
{GH1603_LEFT_INDENT_MID_X}, got {redirected}"
);
assert_eq!(
redirected, snapped,
"with both hanging-number indents excluded there is no legitimate replacement \
corridor, so the split must fall back to the true gutter, got {redirected}"
);
}
#[test]
fn dense_two_column_hanging_numbers_survive_narrow_gutter_redirect_gh1603() {
let mut spans = gh1603_narrow_gutter_hanging_number_spans();
assert!(
reorder_dense_two_column_page(&mut spans, GH1603_PAGE_WIDTH),
"a hanging-number two-column page must still be reordered"
);
let mut checked = 0;
for (index, span) in spans.iter().enumerate() {
let Some((row_str, side)) = span.text.split_once('.') else {
continue;
};
if side != "1" && side != "2" {
continue;
}
let Ok(row) = row_str.parse::<usize>() else {
continue;
};
let expected_prefix = if side == "1" {
format!("The left clause line {row}")
} else {
format!("The right clause line {row}")
};
let next = spans.get(index + 1).map(|s| s.text.as_str()).unwrap_or("");
assert!(
next.starts_with(&expected_prefix),
"clause number {:?} at position {index} must be immediately followed by its own \
clause text, found {next:?} -- numbers must never be hoisted ahead of their clauses",
span.text
);
checked += 1;
}
assert_eq!(
checked, 16,
"all 16 clause numbers in the fixture must have been checked"
);
}
const CORRIDOR_PAGE_WIDTH: f32 = 595.32;
const CORRIDOR_TRUE_GUTTER_MID_X: f32 = (292.51 + 304.87) / 2.0;
fn corridor_closed_by_one_footer_line_spans(with_footer: bool) -> Vec<TextSpan> {
let mut spans = Vec::new();
spans.push(span_with_width(
"Article 1 Applicability",
36.0,
920.0,
160.0,
11.0,
11.0,
));
spans.push(span_with_width(
"Article 3 Price and payment",
304.87,
920.0,
130.0,
11.0,
11.0,
));
for row in 0..15 {
let y = 900.0 - row as f32 * 14.0;
let cross = row % 4 == 1 && row < 16; let left_width = if cross { 208.96 } else { 228.17 }; spans.push(span_with_width(&format!("1.{}", row + 1), 36.0, y, 11.4, 11.0, 11.0));
spans.push(span_with_width(
&format!("left clause line {row} continues with ordinary agreement terms"),
64.34,
y,
left_width,
11.0,
11.0,
));
let right_y = if cross { y } else { y - 1.32 };
spans.push(span_with_width(
&format!("3.{}", row + 1),
304.87,
right_y,
13.4,
11.0,
11.0,
));
spans.push(span_with_width(
&format!("right clause line {row} continues with ordinary agreement terms"),
333.19,
right_y,
220.0,
11.0,
11.0,
));
}
if with_footer {
spans.push(span_with_width(
"takes precedence. \u{a9} 2020 NLdigital",
222.89,
60.0,
149.57,
9.0,
9.0,
));
}
spans
}
fn corridor_fixture_lines(spans: &[TextSpan]) -> Vec<SpanLine> {
let order = spans_sorted_top_to_bottom(spans);
group_into_lines(spans, &order)
}
#[test]
fn split_inside_a_column_is_moved_to_a_gutter_one_footer_line_crosses() {
let spans = corridor_closed_by_one_footer_line_spans(true);
let lines = corridor_fixture_lines(&spans);
let furniture_width = CORRIDOR_PAGE_WIDTH * FULL_WIDTH_FURNITURE_FRACTION;
let min_gutter = (CORRIDOR_PAGE_WIDTH * MIN_DENSE_COLUMN_GUTTER_FRACTION).max(MIN_DENSE_COLUMN_GUTTER_PTS);
let detected = detect_split_x(&spans, &lines, CORRIDOR_PAGE_WIDTH).expect("a split is detected");
assert!(
detected < 292.51 && detected > 280.0,
"the fixture must put the median inside the left column's longest lines, got {detected}"
);
let snapped = snap_split_left_of_hanging_labels(&spans, &lines, CORRIDOR_PAGE_WIDTH, detected);
assert_eq!(snapped, detected, "no narrow span straddles the median");
assert!(
lines_crossing(&spans, &lines, furniture_width, snapped) >= MIN_DENSE_COLUMN_SPLIT_LINES,
"the long left lines must run through the split, or the fixture no longer exercises the search"
);
assert!(
page_whitespace_corridors(&spans, &lines, furniture_width, min_gutter).is_empty(),
"the footer and the two headings must close every whitespace corridor"
);
let low_occupancy = page_low_occupancy_corridors(&spans, &lines, furniture_width, min_gutter, 1);
assert!(
low_occupancy
.iter()
.any(|&(l, r)| (l - 292.51).abs() < 0.01 && (r - 304.87).abs() < 0.01),
"the gutter must come back once one crossing line is tolerated, got {low_occupancy:?}"
);
let max_label_width = CORRIDOR_PAGE_WIDTH * MAX_DENSE_COLUMN_SPLIT_SNAP_SPAN_FRACTION;
let kept: Vec<(f32, f32)> = low_occupancy
.iter()
.copied()
.filter(|&c| !corridor_is_hanging_label_indent(&spans, &lines, max_label_width, c))
.collect();
assert_eq!(
kept.len(),
1,
"both number indents are label-walled and must be excluded, leaving the gutter: {kept:?}"
);
assert!(
both_sides_are_columns(&spans, &lines, furniture_width, CORRIDOR_TRUE_GUTTER_MID_X),
"two clause columns must read as columns on both sides of the gutter"
);
let redirected = redirect_split_out_of_content(&spans, &lines, CORRIDOR_PAGE_WIDTH, snapped);
assert!(
(redirected - CORRIDOR_TRUE_GUTTER_MID_X).abs() < 0.01,
"the split must land mid-gutter at {CORRIDOR_TRUE_GUTTER_MID_X}, got {redirected}"
);
let mut reordered = spans.clone();
assert!(reorder_dense_two_column_page(&mut reordered, CORRIDOR_PAGE_WIDTH));
let last_left = reordered.iter().rposition(|s| s.text.starts_with("1.")).unwrap();
let first_right = reordered.iter().position(|s| s.text.starts_with("3.")).unwrap();
assert!(
last_left < first_right,
"the left column must be emitted before the right one; the last left clause \
number sits at {last_left}, the first right one at {first_right}"
);
}
#[test]
fn split_inside_a_column_still_takes_the_whitespace_corridor_when_nothing_crosses_it() {
let spans = corridor_closed_by_one_footer_line_spans(false);
let lines = corridor_fixture_lines(&spans);
let detected = detect_split_x(&spans, &lines, CORRIDOR_PAGE_WIDTH).expect("a split is detected");
let redirected = redirect_split_out_of_content(&spans, &lines, CORRIDOR_PAGE_WIDTH, detected);
assert!(
(redirected - CORRIDOR_TRUE_GUTTER_MID_X).abs() < 0.01,
"the whitespace corridor must still win on its own, got {redirected}"
);
}
#[test]
fn split_crossed_by_one_heading_is_not_redirected() {
let mut spans = Vec::new();
spans.push(span_with_width(
"A heading set across both columns",
150.0,
920.0,
300.0,
11.0,
11.0,
));
for row in 0..8 {
let y = 900.0 - row as f32 * 14.0;
spans.push(span_with_width("left body text of the row", 36.0, y, 240.0, 11.0, 11.0));
spans.push(span_with_width(
"right body text of the row",
306.0,
y,
240.0,
11.0,
11.0,
));
}
let lines = corridor_fixture_lines(&spans);
let furniture_width = CORRIDOR_PAGE_WIDTH * FULL_WIDTH_FURNITURE_FRACTION;
let detected = detect_split_x(&spans, &lines, CORRIDOR_PAGE_WIDTH).expect("a split is detected");
assert_eq!(
lines_crossing(&spans, &lines, furniture_width, detected),
1,
"only the heading crosses"
);
let redirected = redirect_split_out_of_content(&spans, &lines, CORRIDOR_PAGE_WIDTH, detected);
assert_eq!(redirected, detected, "one crossing line is not grounds for a search");
}
#[test]
fn split_inside_a_table_column_is_not_moved_to_the_cell_gap() {
let mut spans = Vec::new();
spans.push(span_with_width(
"Product list valid for all regions and partners",
36.0,
920.0,
380.0,
11.0,
11.0,
));
for row in 0..12 {
let y = 900.0 - row as f32 * 14.0;
spans.push(span_with_width(&format!("V-{row:03}"), 36.0, y, 40.0, 11.0, 11.0));
spans.push(span_with_width(
"software subscription licence per channel for the analytics platform",
90.0,
y,
300.0,
11.0,
11.0,
));
spans.push(span_with_width("$120", 420.0, y, 30.0, 11.0, 11.0));
spans.push(span_with_width("$1,200", 480.0, y, 40.0, 11.0, 11.0));
}
let lines = corridor_fixture_lines(&spans);
let furniture_width = CORRIDOR_PAGE_WIDTH * FULL_WIDTH_FURNITURE_FRACTION;
let split_x = 250.0;
assert!(lines_crossing(&spans, &lines, furniture_width, split_x) >= MIN_DENSE_COLUMN_SPLIT_LINES);
assert!(
!both_sides_are_columns(&spans, &lines, furniture_width, 405.0),
"the price cells must not read as a column"
);
let redirected = redirect_split_out_of_content(&spans, &lines, CORRIDOR_PAGE_WIDTH, split_x);
assert_eq!(
redirected, split_x,
"the cell gap at ~405 is not a gutter; the split must stay"
);
}
#[test]
fn low_occupancy_corridors_open_nothing_inside_a_table() {
let mut spans = Vec::new();
for row in 0..8 {
let y = 900.0 - row as f32 * 14.0;
spans.push(span_with_width("cell one", 36.0, y, 100.0, 11.0, 11.0));
spans.push(span_with_width(
"cell two spanning the middle of the page",
150.0,
y,
300.0,
11.0,
11.0,
));
spans.push(span_with_width("cell three", 470.0, y, 80.0, 11.0, 11.0));
}
let lines = corridor_fixture_lines(&spans);
let furniture_width = CORRIDOR_PAGE_WIDTH * FULL_WIDTH_FURNITURE_FRACTION;
let min_gutter = (CORRIDOR_PAGE_WIDTH * MIN_DENSE_COLUMN_GUTTER_FRACTION).max(MIN_DENSE_COLUMN_GUTTER_PTS);
let strict = page_whitespace_corridors(&spans, &lines, furniture_width, min_gutter);
let tolerant = page_low_occupancy_corridors(&spans, &lines, furniture_width, min_gutter, 1);
assert_eq!(
strict, tolerant,
"the two cell gaps are corridors either way; nothing new opens"
);
assert_eq!(strict.len(), 2);
}
const GH1742_PAGE_WIDTH: f32 = 595.0;
const GH1742_LEFT_X: f32 = 38.0;
const GH1742_RIGHT_X: f32 = 307.0;
const GH1742_LEFT_WIDTH: f32 = 250.0;
const GH1742_RIGHT_WIDTH: f32 = 240.0;
const GH1742_TRUE_GUTTER_MID_X: f32 = (288.0 + 307.0) / 2.0;
const GH1742_TABLE_COLUMNS: [(f32, f32); 5] =
[(38.0, 30.0), (84.0, 30.0), (130.0, 30.0), (176.0, 30.0), (222.0, 10.0)];
const GH1742_TABLE_ROWS: usize = 10;
const GH1742_TABLE_ROW_HEIGHT: f32 = 9.0;
fn gh1742_two_column_page_with_lower_table_and_page_number() -> Vec<TextSpan> {
let mut spans = Vec::new();
for row in 0..MIN_DENSE_COLUMN_SPLIT_LINES {
let y = 900.0 - row as f32 * 14.0;
spans.push(span_with_width(
&format!("left column body text for row {row}"),
GH1742_LEFT_X,
y,
GH1742_LEFT_WIDTH,
11.0,
11.0,
));
spans.push(span_with_width(
&format!("right column body text for row {row}"),
GH1742_RIGHT_X,
y,
GH1742_RIGHT_WIDTH,
11.0,
11.0,
));
}
let table_top = 900.0 - MIN_DENSE_COLUMN_SPLIT_LINES as f32 * 14.0 - 6.0;
for row in 0..GH1742_TABLE_ROWS {
let y = table_top - row as f32 * GH1742_TABLE_ROW_HEIGHT;
for (column, &(x, width)) in GH1742_TABLE_COLUMNS.iter().enumerate() {
spans.push(span_with_width(&format!("c{column}"), x, y, width, 6.5, 6.5));
}
}
let page_number_width = 4.5;
let page_number_x = GH1742_TRUE_GUTTER_MID_X - page_number_width / 2.0;
spans.push(span_with_width("6", page_number_x, 40.0, page_number_width, 8.0, 8.0));
spans
}
#[test]
fn detect_split_x_ignores_table_grid_row_votes_gh1742() {
let spans = gh1742_two_column_page_with_lower_table_and_page_number();
let order = spans_sorted_top_to_bottom(&spans);
let lines = group_into_lines(&spans, &order);
let detected = detect_split_x(&spans, &lines, GH1742_PAGE_WIDTH)
.expect("six paired prose rows meet the quorum on their own");
assert!(
(detected - GH1742_TRUE_GUTTER_MID_X).abs() < 0.5,
"the table's grid rows must not pollute the vote; expected the true gutter \
at {GH1742_TRUE_GUTTER_MID_X}, got {detected}"
);
}
#[test]
fn dense_two_column_page_with_lower_table_and_page_number_reorders_by_column_gh1742() {
let mut spans = gh1742_two_column_page_with_lower_table_and_page_number();
assert!(
reorder_dense_two_column_page(&mut spans, GH1742_PAGE_WIDTH),
"a two-column page with a table in the lower half of one column and a \
page number in the gutter must still be reordered"
);
let mut expected: Vec<String> = (0..MIN_DENSE_COLUMN_SPLIT_LINES)
.map(|row| format!("left column body text for row {row}"))
.collect();
for _row in 0..GH1742_TABLE_ROWS {
expected.extend((0..GH1742_TABLE_COLUMNS.len()).map(|column| format!("c{column}")));
}
expected.extend((0..MIN_DENSE_COLUMN_SPLIT_LINES).map(|row| format!("right column body text for row {row}")));
expected.push("6".to_string());
assert_eq!(
spans.iter().map(|span| span.text.as_str()).collect::<Vec<_>>(),
expected.iter().map(String::as_str).collect::<Vec<_>>(),
"left column (prose then table) must precede the right column, with the \
page number trailing as its own boundary line"
);
}
const GH1742_P6_TABLE_ROWS: usize = 8;
const GH1742_P6_TABLE_COLUMNS: [(f32, f32); 4] = [(38.0, 30.0), (84.0, 30.0), (130.0, 30.0), (176.0, 30.0)];
const GH1742_P6_STRADDLE_COLUMN_X: f32 = 283.0;
const GH1742_P6_STRADDLE_COLUMN_WIDTH: f32 = 32.0;
fn gh1742_full_width_table_above_two_column_prose() -> Vec<TextSpan> {
let mut spans = Vec::new();
for row in 0..GH1742_P6_TABLE_ROWS {
let y = 950.0 - row as f32 * 9.0;
for (column, &(x, width)) in GH1742_P6_TABLE_COLUMNS.iter().enumerate() {
spans.push(span_with_width(&format!("c{column}"), x, y, width, 6.5, 6.5));
}
spans.push(span_with_width(
"269,533",
GH1742_P6_STRADDLE_COLUMN_X,
y,
GH1742_P6_STRADDLE_COLUMN_WIDTH,
6.5,
6.5,
));
}
let prose_top = 950.0 - GH1742_P6_TABLE_ROWS as f32 * 9.0 - 20.0;
for row in 0..MIN_DENSE_COLUMN_SPLIT_LINES {
let y = prose_top - row as f32 * 14.0;
spans.push(span_with_width(
&format!("left column body text for row {row}"),
GH1742_LEFT_X,
y,
GH1742_LEFT_WIDTH,
11.0,
11.0,
));
spans.push(span_with_width(
&format!("right column body text for row {row}"),
GH1742_RIGHT_X,
y,
GH1742_RIGHT_WIDTH,
11.0,
11.0,
));
}
spans
}
#[test]
fn detect_split_x_ignores_full_width_table_grid_row_votes_gh1742() {
let spans = gh1742_full_width_table_above_two_column_prose();
let order = spans_sorted_top_to_bottom(&spans);
let lines = group_into_lines(&spans, &order);
let detected = detect_split_x(&spans, &lines, GH1742_PAGE_WIDTH)
.expect("six paired prose rows meet the quorum on their own");
assert!(
(detected - GH1742_TRUE_GUTTER_MID_X).abs() < 0.5,
"the full-width table's grid rows must not pollute the vote; expected the \
true gutter at {GH1742_TRUE_GUTTER_MID_X}, got {detected}"
);
let snapped = snap_split_left_of_hanging_labels(&spans, &lines, GH1742_PAGE_WIDTH, detected);
assert_eq!(
snapped, detected,
"the straddling numeric column must not be read as a hanging-label stack"
);
}
#[test]
fn dense_two_column_page_with_full_width_table_above_reorders_table_then_prose_gh1742() {
let mut spans = gh1742_full_width_table_above_two_column_prose();
assert!(
reorder_dense_two_column_page(&mut spans, GH1742_PAGE_WIDTH),
"a full-width table above a two-column prose body must still be reordered"
);
let mut expected = Vec::new();
for _row in 0..GH1742_P6_TABLE_ROWS {
expected.extend((0..GH1742_P6_TABLE_COLUMNS.len()).map(|column| format!("c{column}")));
expected.push("269,533".to_string());
}
expected.extend((0..MIN_DENSE_COLUMN_SPLIT_LINES).map(|row| format!("left column body text for row {row}")));
expected.extend((0..MIN_DENSE_COLUMN_SPLIT_LINES).map(|row| format!("right column body text for row {row}")));
assert_eq!(
spans.iter().map(|span| span.text.as_str()).collect::<Vec<_>>(),
expected.iter().map(String::as_str).collect::<Vec<_>>(),
"the table must stay in its own row order, followed by the reordered \
left-then-right prose columns"
);
}
const GH1742_P4_NORMAL_ROWS: usize = 10;
const GH1742_P4_COLLAPSED_ROWS: usize = 6;
const GH1742_P4_TABLE_COLUMNS: [(f32, f32); 3] = [(38.0, 147.0), (200.0, 30.0), (245.0, 45.0)];
const GH1742_P4_TABLE_ROW_HEIGHT: f32 = 9.0;
const GH1742_P4_TABLE_Y_OFFSET: f32 = 0.7;
fn gh1742_full_height_table_column_with_page_number() -> Vec<TextSpan> {
let mut spans = Vec::new();
for row in 0..GH1742_P4_NORMAL_ROWS {
let y = 900.0 - row as f32 * GH1742_P4_TABLE_ROW_HEIGHT - GH1742_P4_TABLE_Y_OFFSET;
for (column, &(x, width)) in GH1742_P4_TABLE_COLUMNS.iter().enumerate() {
spans.push(span_with_width(&format!("t{column}"), x, y, width, 6.5, 6.5));
}
}
for row in 0..GH1742_P4_COLLAPSED_ROWS {
let y =
900.0 - (GH1742_P4_NORMAL_ROWS + row) as f32 * GH1742_P4_TABLE_ROW_HEIGHT - GH1742_P4_TABLE_Y_OFFSET;
let (col0_x, col0_width) = GH1742_P4_TABLE_COLUMNS[0];
let (col2_x, col2_width) = GH1742_P4_TABLE_COLUMNS[2];
spans.push(span_with_width("t0", col0_x, y, col0_width, 6.5, 6.5));
spans.push(span_with_width(
"wide",
col0_x + col0_width,
y,
col2_x + col2_width - (col0_x + col0_width),
6.5,
6.5,
));
}
for row in 0..(GH1742_P4_NORMAL_ROWS + GH1742_P4_COLLAPSED_ROWS) {
let y = 900.0 - row as f32 * 14.0;
spans.push(span_with_width(
&format!("right column body text for row {row}"),
GH1742_RIGHT_X,
y,
GH1742_RIGHT_WIDTH,
11.0,
11.0,
));
}
let page_number_width = 4.5;
let page_number_x = GH1742_TRUE_GUTTER_MID_X - page_number_width / 2.0;
spans.push(span_with_width("6", page_number_x, 40.0, page_number_width, 8.0, 8.0));
spans
}
#[test]
fn dense_two_column_page_with_full_height_table_column_reorders_table_then_prose_gh1742() {
let mut spans = gh1742_full_height_table_column_with_page_number();
assert!(
reorder_dense_two_column_page(&mut spans, GH1742_PAGE_WIDTH),
"a full-height table column beside ordinary prose, with a page number in \
the gutter, must still be reordered"
);
let mut expected = Vec::new();
for _row in 0..GH1742_P4_NORMAL_ROWS {
expected.extend((0..GH1742_P4_TABLE_COLUMNS.len()).map(|column| format!("t{column}")));
}
for _row in 0..GH1742_P4_COLLAPSED_ROWS {
expected.push("t0".to_string());
expected.push("wide".to_string());
}
expected.extend(
(0..(GH1742_P4_NORMAL_ROWS + GH1742_P4_COLLAPSED_ROWS))
.map(|row| format!("right column body text for row {row}")),
);
expected.push("6".to_string());
assert_eq!(
spans.iter().map(|span| span.text.as_str()).collect::<Vec<_>>(),
expected.iter().map(String::as_str).collect::<Vec<_>>(),
"the table column must precede the prose column, with the page number \
trailing as its own boundary line"
);
}
const GH1742_P4B_INTRO_WIDTH: f32 = 200.0;
fn gh1742_full_height_table_column_no_crossing_spans() -> Vec<TextSpan> {
let mut spans = Vec::new();
let (col0_x, _) = GH1742_P4_TABLE_COLUMNS[0];
spans.push(span_with_width(
"an introductory paragraph precedes the table on its own line",
col0_x,
910.0,
GH1742_P4B_INTRO_WIDTH,
8.0,
8.0,
));
for row in 0..GH1742_P4_NORMAL_ROWS {
let y = 900.0 - row as f32 * GH1742_P4_TABLE_ROW_HEIGHT - GH1742_P4_TABLE_Y_OFFSET;
for (column, &(x, width)) in GH1742_P4_TABLE_COLUMNS.iter().enumerate() {
spans.push(span_with_width(&format!("t{column}"), x, y, width, 6.5, 6.5));
}
}
for row in 0..GH1742_P4_NORMAL_ROWS {
let y = 900.0 - row as f32 * 14.0;
spans.push(span_with_width(
&format!("right column body text for row {row}"),
GH1742_RIGHT_X,
y,
GH1742_RIGHT_WIDTH,
11.0,
11.0,
));
}
let page_number_width = 4.5;
let page_number_x = GH1742_TRUE_GUTTER_MID_X - page_number_width / 2.0;
spans.push(span_with_width("6", page_number_x, 40.0, page_number_width, 8.0, 8.0));
spans
}
#[test]
fn dense_two_column_page_with_table_column_and_no_crossing_spans_reorders_table_then_prose_gh1742() {
let spans = gh1742_full_height_table_column_no_crossing_spans();
let order = spans_sorted_top_to_bottom(&spans);
let lines = group_into_lines(&spans, &order);
let detected = detect_split_x(&spans, &lines, GH1742_PAGE_WIDTH).expect("table rows vote for a split");
let snapped = snap_split_left_of_hanging_labels(&spans, &lines, GH1742_PAGE_WIDTH, detected);
let furniture_width = GH1742_PAGE_WIDTH * FULL_WIDTH_FURNITURE_FRACTION;
assert_eq!(
lines_crossing(&spans, &lines, furniture_width, snapped),
1,
"only the intro line may cross the wrong split, or this fixture no longer \
exercises the no-span-crossing mechanism"
);
let mut spans = spans;
assert!(
reorder_dense_two_column_page(&mut spans, GH1742_PAGE_WIDTH),
"a table column beside ordinary prose, with the wrong split crossed by only \
one line, must still be reordered"
);
let mut expected = vec!["an introductory paragraph precedes the table on its own line".to_string()];
for _row in 0..GH1742_P4_NORMAL_ROWS {
expected.extend((0..GH1742_P4_TABLE_COLUMNS.len()).map(|column| format!("t{column}")));
}
expected.extend((0..GH1742_P4_NORMAL_ROWS).map(|row| format!("right column body text for row {row}")));
expected.push("6".to_string());
assert_eq!(
spans.iter().map(|span| span.text.as_str()).collect::<Vec<_>>(),
expected.iter().map(String::as_str).collect::<Vec<_>>(),
"the intro line and table column must precede the prose column, with the \
page number trailing as its own boundary line"
);
}
const GH1742_P1_PAGE_WIDTH: f32 = 595.28;
fn gh1742_p1_real_reproducer_spans() -> Vec<TextSpan> {
#[rustfmt::skip]
let spans = vec![
span_with_width("2.1. Lorem ipsum dolor sit amet", 38.00, 770.00, 133.05, 9.50, 9.50),
span_with_width("incididunt ut labore et dolore magna aliqua enim ad minim veniam", 38.00, 759.55, 247.55, 8.50, 8.50),
span_with_width("ut labore et dolore magna aliqua enim ad minim veniam quis", 38.00, 749.10, 227.23, 8.50, 8.50),
span_with_width("labore et dolore magna aliqua enim ad minim veniam quis nostrud", 38.00, 738.65, 248.49, 8.50, 8.50),
span_with_width("et dolore magna aliqua enim ad minim veniam quis nostrud", 38.00, 728.20, 222.50, 8.50, 8.50),
span_with_width("dolore magna aliqua enim ad minim veniam quis nostrud", 38.00, 717.75, 213.05, 8.50, 8.50),
span_with_width("magna aliqua enim ad minim veniam quis nostrud exercitation", 38.00, 707.30, 232.89, 8.50, 8.50),
span_with_width("aliqua enim ad minim veniam quis nostrud exercitation ullamco", 38.00, 696.85, 236.19, 8.50, 8.50),
span_with_width("enim ad minim veniam quis nostrud exercitation ullamco laboris", 38.00, 686.40, 238.54, 8.50, 8.50),
span_with_width("ut labore et dolore magna aliqua enim ad minim veniam quis", 307.00, 686.40, 227.23, 8.50, 8.50),
span_with_width("labore et dolore magna aliqua enim ad minim veniam quis nostrud", 307.00, 675.95, 248.49, 8.50, 8.50),
span_with_width("2.2. Consectetur adipiscing elit", 38.00, 665.50, 129.36, 9.50, 9.50),
span_with_width("velit esse cillum fugiat nulla pariatur excepteur sint occaecat", 38.00, 655.05, 225.81, 8.50, 8.50),
span_with_width("esse cillum fugiat nulla pariatur excepteur sint occaecat cupidatat", 38.00, 644.60, 245.19, 8.50, 8.50),
span_with_width("cillum fugiat nulla pariatur excepteur sint occaecat cupidatat non", 38.00, 634.15, 241.42, 8.50, 8.50),
span_with_width("fugiat nulla pariatur excepteur sint occaecat cupidatat non proident", 38.00, 623.70, 250.41, 8.50, 8.50),
span_with_width("nulla pariatur excepteur sint occaecat cupidatat non proident sunt", 38.00, 613.25, 245.68, 8.50, 8.50),
span_with_width("pariatur excepteur sint occaecat cupidatat non proident sunt culpa", 38.00, 602.80, 248.05, 8.50, 8.50),
span_with_width("minim veniam quis nostrud exercitation ullamco laboris nisi aliquip", 307.00, 602.80, 247.99, 8.50, 8.50),
span_with_width("veniam quis nostrud exercitation ullamco laboris nisi aliquip ex ea", 307.00, 592.35, 246.12, 8.50, 8.50),
span_with_width("Table 1", 38.00, 581.90, 23.34, 7.00, 7.00),
span_with_width("Lorem ipsum dolor sit amet in consectetur adipiscing elit.", 38.00, 573.85, 175.84, 7.00, 7.00),
span_with_width("Pars", 92.00, 563.80, 14.39, 7.00, 7.00),
span_with_width("Ex anno", 140.00, 563.80, 25.68, 7.00, 7.00),
span_with_width("Valor", 185.00, 563.80, 16.34, 7.00, 7.00),
span_with_width("Dolor", 44.00, 555.75, 16.72, 7.00, 7.00),
span_with_width("91%", 92.00, 555.75, 14.01, 7.00, 7.00),
span_with_width("Anno 1983", 140.00, 555.75, 33.86, 7.00, 7.00),
span_with_width("Praedictum", 185.00, 555.75, 35.40, 7.00, 7.00),
span_with_width("Sit amet", 44.00, 547.70, 25.68, 7.00, 7.00),
span_with_width("80.5%", 92.00, 547.70, 19.85, 7.00, 7.00),
span_with_width("Anno 2023", 140.00, 547.70, 33.86, 7.00, 7.00),
span_with_width("Exclusio", 185.00, 547.70, 26.06, 7.00, 7.00),
span_with_width("Lorem", 44.00, 539.65, 19.84, 7.00, 7.00),
span_with_width("40-70%", 92.00, 539.65, 24.12, 7.00, 7.00),
span_with_width("Anno 2016", 140.00, 539.65, 33.86, 7.00, 7.00),
span_with_width("Conflictus", 185.00, 539.65, 30.73, 7.00, 7.00),
span_with_width("Ipsum", 44.00, 531.60, 19.06, 7.00, 7.00),
span_with_width("10-20%", 92.00, 531.60, 24.12, 7.00, 7.00),
span_with_width("Nulla", 140.00, 531.60, 15.95, 7.00, 7.00),
span_with_width("Nullum", 185.00, 531.60, 21.78, 7.00, 7.00),
span_with_width("Magna", 44.00, 523.55, 21.40, 7.00, 7.00),
span_with_width("10-15%", 92.00, 523.55, 24.12, 7.00, 7.00),
span_with_width("Anno 2001", 140.00, 523.55, 33.86, 7.00, 7.00),
span_with_width("Peior", 185.00, 523.55, 16.34, 7.00, 7.00),
span_with_width("Aliqua", 44.00, 515.50, 19.45, 7.00, 7.00),
span_with_width("50-79%", 92.00, 515.50, 24.12, 7.00, 7.00),
span_with_width("Anno 2019", 140.00, 515.50, 33.86, 7.00, 7.00),
span_with_width("Melior", 185.00, 515.50, 19.05, 7.00, 7.00),
span_with_width("Veniam", 44.00, 507.45, 23.73, 7.00, 7.00),
span_with_width("5-55%", 92.00, 507.45, 20.23, 7.00, 7.00),
span_with_width("Anno 1983", 140.00, 507.45, 33.86, 7.00, 7.00),
span_with_width("Praedictum", 185.00, 507.45, 35.40, 7.00, 7.00),
span_with_width("Nostrud", 44.00, 499.40, 24.51, 7.00, 7.00),
span_with_width("62%", 92.00, 499.40, 14.01, 7.00, 7.00),
span_with_width("Anno 2023", 140.00, 499.40, 33.86, 7.00, 7.00),
span_with_width("Exclusio", 185.00, 499.40, 26.06, 7.00, 7.00),
span_with_width("Ullamco", 44.00, 491.35, 25.28, 7.00, 7.00),
span_with_width("33%", 92.00, 491.35, 14.01, 7.00, 7.00),
span_with_width("Anno 2016", 140.00, 491.35, 33.86, 7.00, 7.00),
span_with_width("Conflictus", 185.00, 491.35, 30.73, 7.00, 7.00),
span_with_width("Laboris", 44.00, 483.30, 22.95, 7.00, 7.00),
span_with_width("17%", 92.00, 483.30, 14.01, 7.00, 7.00),
span_with_width("Nulla", 140.00, 483.30, 15.95, 7.00, 7.00),
span_with_width("Nullum", 185.00, 483.30, 21.78, 7.00, 7.00),
span_with_width("Nisi", 44.00, 475.25, 11.66, 7.00, 7.00),
span_with_width("91%", 92.00, 475.25, 14.01, 7.00, 7.00),
span_with_width("Anno 2001", 140.00, 475.25, 33.86, 7.00, 7.00),
span_with_width("Peior", 185.00, 475.25, 16.34, 7.00, 7.00),
span_with_width("Aliquip", 44.00, 467.20, 21.01, 7.00, 7.00),
span_with_width("80.5%", 92.00, 467.20, 19.85, 7.00, 7.00),
span_with_width("Anno 2019", 140.00, 467.20, 33.86, 7.00, 7.00),
span_with_width("Melior", 185.00, 467.20, 19.05, 7.00, 7.00),
span_with_width("Commodo", 44.00, 459.15, 32.28, 7.00, 7.00),
span_with_width("40-70%", 92.00, 459.15, 24.12, 7.00, 7.00),
span_with_width("Anno 1983", 140.00, 459.15, 33.86, 7.00, 7.00),
span_with_width("Praedictum", 185.00, 459.15, 35.40, 7.00, 7.00),
span_with_width("Duis", 44.00, 451.10, 14.00, 7.00, 7.00),
span_with_width("10-20%", 92.00, 451.10, 24.12, 7.00, 7.00),
span_with_width("Anno 2023", 140.00, 451.10, 33.86, 7.00, 7.00),
span_with_width("Exclusio", 185.00, 451.10, 26.06, 7.00, 7.00),
span_with_width("Aute", 44.00, 443.05, 14.40, 7.00, 7.00),
span_with_width("10-15%", 92.00, 443.05, 24.12, 7.00, 7.00),
span_with_width("Anno 2016", 140.00, 443.05, 33.86, 7.00, 7.00),
span_with_width("Conflictus", 185.00, 443.05, 30.73, 7.00, 7.00),
span_with_width("Irure", 44.00, 435.00, 14.39, 7.00, 7.00),
span_with_width("50-79%", 92.00, 435.00, 24.12, 7.00, 7.00),
span_with_width("Nulla", 140.00, 435.00, 15.95, 7.00, 7.00),
span_with_width("Nullum", 185.00, 435.00, 21.78, 7.00, 7.00),
span_with_width("Velit", 44.00, 426.95, 13.62, 7.00, 7.00),
span_with_width("5-55%", 92.00, 426.95, 20.23, 7.00, 7.00),
span_with_width("Anno 2001", 140.00, 426.95, 33.86, 7.00, 7.00),
span_with_width("Peior", 185.00, 426.95, 16.34, 7.00, 7.00),
span_with_width("Esse", 44.00, 418.90, 15.56, 7.00, 7.00),
span_with_width("62%", 92.00, 418.90, 14.01, 7.00, 7.00),
span_with_width("Anno 2019", 140.00, 418.90, 33.86, 7.00, 7.00),
span_with_width("Melior", 185.00, 418.90, 19.05, 7.00, 7.00),
span_with_width("Cillum", 44.00, 410.85, 19.44, 7.00, 7.00),
span_with_width("33%", 92.00, 410.85, 14.01, 7.00, 7.00),
span_with_width("Anno 1983", 140.00, 410.85, 33.86, 7.00, 7.00),
span_with_width("Praedictum", 185.00, 410.85, 35.40, 7.00, 7.00),
span_with_width("Fugiat", 44.00, 402.80, 19.45, 7.00, 7.00),
span_with_width("17%", 92.00, 402.80, 14.01, 7.00, 7.00),
span_with_width("Anno 2023", 140.00, 402.80, 33.86, 7.00, 7.00),
span_with_width("Exclusio", 185.00, 402.80, 26.06, 7.00, 7.00),
span_with_width("Nulla", 44.00, 394.75, 15.95, 7.00, 7.00),
span_with_width("91%", 92.00, 394.75, 14.01, 7.00, 7.00),
span_with_width("Anno 2016", 140.00, 394.75, 33.86, 7.00, 7.00),
span_with_width("Conflictus", 185.00, 394.75, 30.73, 7.00, 7.00),
span_with_width("Sint", 44.00, 386.70, 12.06, 7.00, 7.00),
span_with_width("80.5%", 92.00, 386.70, 19.85, 7.00, 7.00),
span_with_width("Nulla", 140.00, 386.70, 15.95, 7.00, 7.00),
span_with_width("Nullum", 185.00, 386.70, 21.78, 7.00, 7.00),
span_with_width("Culpa", 44.00, 378.65, 18.28, 7.00, 7.00),
span_with_width("40-70%", 92.00, 378.65, 24.12, 7.00, 7.00),
span_with_width("Anno 2001", 140.00, 378.65, 33.86, 7.00, 7.00),
span_with_width("Peior", 185.00, 378.65, 16.34, 7.00, 7.00),
span_with_width("Officia", 44.00, 370.60, 19.84, 7.00, 7.00),
span_with_width("10-20%", 92.00, 370.60, 24.12, 7.00, 7.00),
span_with_width("Anno 2019", 140.00, 370.60, 33.86, 7.00, 7.00),
span_with_width("Melior", 185.00, 370.60, 19.05, 7.00, 7.00),
span_with_width("Mollit", 44.00, 362.55, 16.33, 7.00, 7.00),
span_with_width("10-15%", 92.00, 362.55, 24.12, 7.00, 7.00),
span_with_width("Anno 1983", 140.00, 362.55, 33.86, 7.00, 7.00),
span_with_width("Praedictum", 185.00, 362.55, 35.40, 7.00, 7.00),
span_with_width("Anim", 44.00, 354.50, 15.95, 7.00, 7.00),
span_with_width("50-79%", 92.00, 354.50, 24.12, 7.00, 7.00),
span_with_width("Anno 2023", 140.00, 354.50, 33.86, 7.00, 7.00),
span_with_width("Exclusio", 185.00, 354.50, 26.06, 7.00, 7.00),
span_with_width("2.3. Sed do eiusmod tempor", 307.00, 770.00, 119.34, 9.50, 9.50),
span_with_width("adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore", 307.00, 759.55, 251.34, 8.50, 8.50),
span_with_width("elit sed do eiusmod tempor incididunt ut labore et dolore magna", 307.00, 749.10, 239.53, 8.50, 8.50),
span_with_width("sed do eiusmod tempor incididunt ut labore et dolore magna aliqua", 307.00, 738.65, 251.34, 8.50, 8.50),
span_with_width("do eiusmod tempor incididunt ut labore et dolore magna aliqua", 307.00, 728.20, 235.28, 8.50, 8.50),
span_with_width("eiusmod tempor incididunt ut labore et dolore magna aliqua enim", 307.00, 717.75, 244.25, 8.50, 8.50),
span_with_width("tempor incididunt ut labore et dolore magna aliqua enim ad minim", 307.00, 707.30, 246.60, 8.50, 8.50),
span_with_width("incididunt ut labore et dolore magna aliqua enim ad minim veniam", 307.00, 696.85, 247.55, 8.50, 8.50),
span_with_width("et dolore magna aliqua enim ad minim veniam quis nostrud", 307.00, 665.50, 222.50, 8.50, 8.50),
span_with_width("dolore magna aliqua enim ad minim veniam quis nostrud", 307.00, 655.05, 213.05, 8.50, 8.50),
span_with_width("magna aliqua enim ad minim veniam quis nostrud exercitation", 307.00, 644.60, 232.89, 8.50, 8.50),
span_with_width("aliqua enim ad minim veniam quis nostrud exercitation ullamco", 307.00, 634.15, 236.19, 8.50, 8.50),
span_with_width("enim ad minim veniam quis nostrud exercitation ullamco laboris", 307.00, 623.70, 238.54, 8.50, 8.50),
span_with_width("ad minim veniam quis nostrud exercitation ullamco laboris nisi", 307.00, 613.25, 232.87, 8.50, 8.50),
span_with_width("quis nostrud exercitation ullamco laboris nisi aliquip ex ea", 307.00, 581.90, 216.36, 8.50, 8.50),
span_with_width("nostrud exercitation ullamco laboris nisi aliquip ex ea commodo", 307.00, 571.45, 238.08, 8.50, 8.50),
span_with_width("Cura", 262.00, 563.80, 15.17, 7.00, 7.00),
span_with_width("exercitation ullamco laboris nisi aliquip ex ea commodo consequat", 307.00, 561.00, 248.96, 8.50, 8.50),
span_with_width("ignotum", 262.00, 555.75, 24.90, 7.00, 7.00),
span_with_width("ullamco laboris nisi aliquip ex ea commodo consequat duis aute", 307.00, 550.55, 239.99, 8.50, 8.50),
span_with_width("ignotum", 262.00, 547.70, 24.90, 7.00, 7.00),
span_with_width("nullum", 262.00, 539.65, 20.62, 7.00, 7.00),
span_with_width("laboris nisi aliquip ex ea commodo consequat duis aute irure in", 307.00, 540.10, 236.68, 8.50, 8.50),
span_with_width("ignotum", 262.00, 531.60, 24.90, 7.00, 7.00),
span_with_width("nisi aliquip ex ea commodo consequat duis aute irure in", 307.00, 529.65, 209.29, 8.50, 8.50),
span_with_width("peius", 262.00, 523.55, 16.73, 7.00, 7.00),
span_with_width("aliquip ex ea commodo consequat duis aute irure in reprehenderit", 307.00, 519.20, 247.09, 8.50, 8.50),
span_with_width("ignotum", 262.00, 515.50, 24.90, 7.00, 7.00),
span_with_width("ex ea commodo consequat duis aute irure in reprehenderit", 307.00, 508.75, 220.16, 8.50, 8.50),
span_with_width("melius", 262.00, 507.45, 20.22, 7.00, 7.00),
span_with_width("ignotum", 262.00, 499.40, 24.90, 7.00, 7.00),
span_with_width("ea commodo consequat duis aute irure in reprehenderit voluptate", 307.00, 498.30, 245.68, 8.50, 8.50),
span_with_width("ignotum", 262.00, 491.35, 24.90, 7.00, 7.00),
span_with_width("nullum", 262.00, 483.30, 20.62, 7.00, 7.00),
span_with_width("2.4. Ut labore et dolore magna", 307.00, 477.40, 128.32, 9.50, 9.50),
span_with_width("ignotum", 262.00, 475.25, 24.90, 7.00, 7.00),
span_with_width("peius", 262.00, 467.20, 16.73, 7.00, 7.00),
span_with_width("excepteur sint occaecat cupidatat non proident sunt culpa qui", 307.00, 466.95, 230.57, 8.50, 8.50),
span_with_width("ignotum", 262.00, 459.15, 24.90, 7.00, 7.00),
span_with_width("sint occaecat cupidatat non proident sunt culpa qui officia deserunt", 307.00, 456.50, 250.89, 8.50, 8.50),
span_with_width("melius", 262.00, 451.10, 20.22, 7.00, 7.00),
span_with_width("occaecat cupidatat non proident sunt culpa qui officia deserunt", 307.00, 446.05, 235.30, 8.50, 8.50),
span_with_width("ignotum", 262.00, 443.05, 24.90, 7.00, 7.00),
span_with_width("ignotum", 262.00, 435.00, 24.90, 7.00, 7.00),
span_with_width("cupidatat non proident sunt culpa qui officia deserunt mollit anim id", 307.00, 435.60, 250.87, 8.50, 8.50),
span_with_width("nullum", 262.00, 426.95, 20.62, 7.00, 7.00),
span_with_width("non proident sunt culpa qui officia deserunt mollit anim id est", 307.00, 425.15, 227.72, 8.50, 8.50),
span_with_width("ignotum", 262.00, 418.90, 24.90, 7.00, 7.00),
span_with_width("proident sunt culpa qui officia deserunt mollit anim id est laborum", 307.00, 414.70, 244.24, 8.50, 8.50),
span_with_width("peius", 262.00, 410.85, 16.73, 7.00, 7.00),
span_with_width("sunt culpa qui officia deserunt mollit anim id est laborum lorem", 307.00, 404.25, 234.78, 8.50, 8.50),
span_with_width("ignotum", 262.00, 402.80, 24.90, 7.00, 7.00),
span_with_width("melius", 262.00, 394.75, 20.22, 7.00, 7.00),
span_with_width("culpa qui officia deserunt mollit anim id est laborum lorem ipsum", 307.00, 393.80, 241.38, 8.50, 8.50),
span_with_width("ignotum", 262.00, 386.70, 24.90, 7.00, 7.00),
span_with_width("qui officia deserunt mollit anim id est laborum lorem ipsum dolor sit", 307.00, 383.35, 250.83, 8.50, 8.50),
span_with_width("ignotum", 262.00, 378.65, 24.90, 7.00, 7.00),
span_with_width("nullum", 262.00, 370.60, 20.62, 7.00, 7.00),
span_with_width("officia deserunt mollit anim id est laborum lorem ipsum dolor sit", 307.00, 372.90, 237.12, 8.50, 8.50),
span_with_width("ignotum", 262.00, 362.55, 24.90, 7.00, 7.00),
span_with_width("deserunt mollit anim id est laborum lorem ipsum dolor sit amet", 307.00, 362.45, 233.82, 8.50, 8.50),
span_with_width("peius", 262.00, 354.50, 16.73, 7.00, 7.00),
span_with_width("mollit anim id est laborum lorem ipsum dolor sit amet consectetur", 307.00, 352.00, 244.68, 8.50, 8.50),
span_with_width("anim id est laborum lorem ipsum dolor sit amet consectetur", 307.00, 341.55, 222.49, 8.50, 8.50),
span_with_width("id est laborum lorem ipsum dolor sit amet consectetur adipiscing", 307.00, 331.10, 241.86, 8.50, 8.50),
span_with_width("est laborum lorem ipsum dolor sit amet consectetur adipiscing elit", 307.00, 320.65, 246.11, 8.50, 8.50),
span_with_width("laborum lorem ipsum dolor sit amet consectetur adipiscing elit sed", 307.00, 310.20, 248.47, 8.50, 8.50),
span_with_width("lorem ipsum dolor sit amet consectetur adipiscing elit sed do", 307.00, 299.75, 227.22, 8.50, 8.50),
span_with_width("ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod", 307.00, 289.30, 238.09, 8.50, 8.50),
span_with_width("dolor sit amet consectetur adipiscing elit sed do eiusmod tempor", 307.00, 278.85, 241.88, 8.50, 8.50),
span_with_width("sit amet consectetur adipiscing elit sed do eiusmod tempor", 307.00, 268.40, 220.62, 8.50, 8.50),
span_with_width("amet consectetur adipiscing elit sed do eiusmod tempor incididunt", 307.00, 257.95, 248.02, 8.50, 8.50),
span_with_width("consectetur adipiscing elit sed do eiusmod tempor incididunt ut", 307.00, 247.50, 236.21, 8.50, 8.50),
span_with_width("adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore", 307.00, 237.05, 251.34, 8.50, 8.50),
span_with_width("elit sed do eiusmod tempor incididunt ut labore et dolore magna", 307.00, 226.60, 239.53, 8.50, 8.50),
span_with_width("sed do eiusmod tempor incididunt ut labore et dolore magna aliqua", 307.00, 216.15, 251.34, 8.50, 8.50),
span_with_width("do eiusmod tempor incididunt ut labore et dolore magna aliqua", 307.00, 205.70, 235.28, 8.50, 8.50),
span_with_width("eiusmod tempor incididunt ut labore et dolore magna aliqua enim", 307.00, 195.25, 244.25, 8.50, 8.50),
span_with_width("tempor incididunt ut labore et dolore magna aliqua enim ad minim", 307.00, 184.80, 246.60, 8.50, 8.50),
span_with_width("incididunt ut labore et dolore magna aliqua enim ad minim veniam", 307.00, 174.35, 247.55, 8.50, 8.50),
span_with_width("1", 295.42, 30.00, 4.45, 8.00, 8.00),
];
spans
}
#[test]
fn corridor_is_hanging_label_indent_rejects_a_table_edge_column_gh1742() {
let spans = gh1742_p1_real_reproducer_spans();
let order = spans_sorted_top_to_bottom(&spans);
let lines = group_into_lines(&spans, &order);
let page_width = GH1742_P1_PAGE_WIDTH;
let max_label_width = page_width * MAX_DENSE_COLUMN_SPLIT_SNAP_SPAN_FRACTION;
let corridor = (286.899, 307.0);
assert!(
!corridor_is_hanging_label_indent(&spans, &lines, max_label_width, corridor),
"a table's edge column must not disqualify the page's real gutter as a \
hanging-label indent"
);
}
#[test]
fn dense_two_column_page_reorders_by_column_on_real_reproducer_page_one_gh1742() {
let mut spans = gh1742_p1_real_reproducer_spans();
let original_order: Vec<String> = spans.iter().map(|span| span.text.clone()).collect();
assert!(
reorder_dense_two_column_page(&mut spans, GH1742_P1_PAGE_WIDTH),
"a two-column page with a five-column table in the lower half of one \
column and a page number in the gutter must still be reordered"
);
let reordered: Vec<String> = spans.iter().map(|span| span.text.clone()).collect();
assert_ne!(
reordered, original_order,
"the page must not be left in its original interleaved order"
);
let heading_index = reordered
.iter()
.position(|text| text == "2.1. Lorem ipsum dolor sit amet")
.expect("left column's first heading must survive the reorder");
assert_eq!(
reordered[heading_index + 1],
"incididunt ut labore et dolore magna aliqua enim ad minim veniam",
"the left column's heading must be followed by its own paragraph, not by \
the right column's heading or a table cell"
);
let last_table_cell_index = reordered
.iter()
.position(|text| text == "Anim")
.expect("the table's last row must survive the reorder");
let right_heading_index = reordered
.iter()
.position(|text| text == "2.3. Sed do eiusmod tempor")
.expect("the right column's heading must survive the reorder");
assert!(
last_table_cell_index < right_heading_index,
"the left column, table included, must be emitted before the right column"
);
}
const GH1756_PAGE_WIDTH: f32 = 595.3;
const GH1756_GUTTER_CORRIDOR: (f32, f32) = (291.0, 306.6);
const GH1756_GRID_ROW: [(f32, f32); 5] = [
(312.6, 30.0),
(397.2, 20.0),
(441.0, 20.0),
(488.4, 21.1),
(534.7, 22.0),
];
const GH1756_TOP_GRID_ROW_Y: f32 = 700.3;
const GH1756_FIRST_BODY_GRID_ROW_Y: f32 = 675.0;
const GH1756_BODY_GRID_ROW_COUNT: usize = 26;
const GH1756_GRID_ROW_LEADING: f32 = 14.0;
const GH1756_SPARSE_TABLE_LINES: [(f32, &[(f32, f32)]); 6] = [
(691.7, &[(441.0, 34.8), (488.4, 34.0)]),
(683.1, &[(441.0, 14.0), (488.4, 22.0)]),
(652.9, &[(397.2, 12.6), (488.4, 20.0)]),
(
627.2,
&[
(312.6, 20.0),
(334.0, 6.0),
(341.0, 45.0),
(397.2, 25.0),
(441.0, 28.0),
(488.4, 21.1),
(534.7, 22.0),
],
),
(
601.5,
&[(312.6, 77.4), (397.2, 0.0), (441.0, 0.0), (488.4, 0.0), (534.7, 22.0)],
),
(327.3, &[(320.0, 30.0), (397.2, 12.6), (488.4, 20.0)]),
];
const GH1756_P3_SPARSE_TABLE_LINES: [usize; 2] = [2, 5];
const GH1756_PROSE_VOTE_LINES: [(f32, &[(f32, f32)]); 5] = [
(668.0, &[(37.6, 211.4), (261.0, 30.0)]),
(640.0, &[(37.6, 232.4), (282.0, 9.0)]),
(760.0, &[(37.6, 253.4), (306.6, 235.0)]),
(746.0, &[(37.6, 253.4), (318.6, 223.0)]),
(720.0, &[(306.6, 46.4), (377.0, 60.0)]),
];
const GH1756_REORDERING_BAND_TOP_Y: f32 = 297.0;
const GH1756_REORDERING_BAND_LEADING: f32 = 7.0;
const GH1756_REORDERING_BAND_LINES: usize = 2 * MIN_DENSE_COLUMN_SPANS_PER_SIDE;
const GH1756_BAND_PROSE_TEXT: &str = "left column body text continuing past the table";
const GH1756_BAND_PROSE_X: f32 = 37.6;
const GH1756_BAND_PROSE_WIDTH: f32 = 200.0;
const GH1756_BAND_CELL_X: f32 = 488.4;
const GH1756_BAND_CELL_WIDTH: f32 = 60.0;
fn gh1756_push_line(spans: &mut Vec<TextSpan>, y: f32, cells: &[(f32, f32)]) {
for &(x, width) in cells {
let text = if width == 0.0 { "\u{200b}" } else { "cell" };
spans.push(span_with_width(text, x, y, width, 7.0, 7.0));
}
}
fn gh1756_push_grid_rows(spans: &mut Vec<TextSpan>) {
gh1756_push_line(spans, GH1756_TOP_GRID_ROW_Y, &GH1756_GRID_ROW);
for row in 0..GH1756_BODY_GRID_ROW_COUNT {
let y = GH1756_FIRST_BODY_GRID_ROW_Y - row as f32 * GH1756_GRID_ROW_LEADING;
gh1756_push_line(spans, y, &GH1756_GRID_ROW);
}
}
fn gh1756_push_reordering_band(spans: &mut Vec<TextSpan>) {
for line in 0..GH1756_REORDERING_BAND_LINES {
let y = GH1756_REORDERING_BAND_TOP_Y - line as f32 * GH1756_REORDERING_BAND_LEADING;
if line.is_multiple_of(2) {
spans.push(span_with_width(
GH1756_BAND_PROSE_TEXT,
GH1756_BAND_PROSE_X,
y,
GH1756_BAND_PROSE_WIDTH,
8.5,
8.5,
));
} else {
spans.push(span_with_width(
"cell",
GH1756_BAND_CELL_X,
y,
GH1756_BAND_CELL_WIDTH,
7.0,
7.0,
));
}
}
}
fn gh1756_page_with_sparse_lines(sparse_lines: &[usize]) -> Vec<TextSpan> {
let mut spans = Vec::new();
gh1756_push_grid_rows(&mut spans);
for &line in sparse_lines {
let (y, cells) = GH1756_SPARSE_TABLE_LINES[line];
gh1756_push_line(&mut spans, y, cells);
}
for &(y, cells) in &GH1756_PROSE_VOTE_LINES {
gh1756_push_line(&mut spans, y, cells);
}
gh1756_push_reordering_band(&mut spans);
spans
}
fn gh1756_sparse_table_rows_page() -> Vec<TextSpan> {
gh1756_page_with_sparse_lines(&[0, 1, 2, 3, 4, 5])
}
fn gh1756_two_sparse_table_rows_page() -> Vec<TextSpan> {
gh1756_page_with_sparse_lines(&GH1756_P3_SPARSE_TABLE_LINES)
}
fn gh1756_prose_beside_a_full_grid_table() -> Vec<TextSpan> {
let mut spans = Vec::new();
gh1756_push_grid_rows(&mut spans);
for line in 0..MIN_DENSE_COLUMN_SPLIT_LINES {
let y = 668.0 - line as f32 * GH1756_GRID_ROW_LEADING;
gh1756_push_line(&mut spans, y, &[(37.6, 253.4), (306.6, 235.0)]);
}
spans
}
fn gh1756_detect_split_x(spans: &[TextSpan]) -> Option<f32> {
let order = spans_sorted_top_to_bottom(spans);
let lines = group_into_lines(spans, &order);
detect_split_x(spans, &lines, GH1756_PAGE_WIDTH)
}
#[test]
fn detect_split_x_declines_when_sparse_table_rows_outvote_the_gutter_gh1756() {
let spans = gh1756_sparse_table_rows_page();
assert_eq!(
gh1756_detect_split_x(&spans),
None,
"the table's sparse lines must not be gutter evidence: with only the five \
genuine prose votes left the vote is below quorum and must decline rather \
than place the split at 449.1, inside the right column"
);
}
#[test]
fn detect_column_gutter_also_declines_on_the_sparse_table_rows_pages_gh1756() {
let sparse = gh1756_sparse_table_rows_page();
let two_sparse = gh1756_two_sparse_table_rows_page();
assert_eq!(
xberg_native_pdf::pipeline::reading_order::detect_column_gutter(&sparse),
None,
"detect_column_gutter must not place a gutter inside the table's own grid"
);
assert_eq!(
xberg_native_pdf::pipeline::reading_order::detect_column_gutter(&two_sparse),
None,
"detect_column_gutter must not place a gutter inside the table's own grid"
);
}
#[test]
fn detect_split_x_declines_on_two_sparse_table_rows_gh1756() {
let spans = gh1756_two_sparse_table_rows_page();
assert_eq!(
gh1756_detect_split_x(&spans),
None,
"both surviving sparse lines sit on the table's own column grid, so the \
five prose votes are all that is left and the vote is below quorum"
);
}
fn gh1756_grid_columns(spans: &[TextSpan]) -> Vec<f32> {
let order = spans_sorted_top_to_bottom(spans);
let lines = group_into_lines(spans, &order);
let min_gutter = (GH1756_PAGE_WIDTH * MIN_DENSE_COLUMN_GUTTER_FRACTION).max(MIN_DENSE_COLUMN_GUTTER_PTS);
let furniture_width = GH1756_PAGE_WIDTH * FULL_WIDTH_FURNITURE_FRACTION;
let grid_rows: Vec<SpanLine> = lines
.iter()
.filter(|&line| !line_has_width_furniture(spans, line, furniture_width))
.filter(|&line| line.iter().any(|&index| span_has_ink(&spans[index])))
.filter(|&line| line_has_grid_row_gaps(spans, line, min_gutter))
.cloned()
.collect();
strong_column_edges(spans, &grid_rows)
}
fn gh1756_line_at(spans: &[TextSpan], y: f32) -> SpanLine {
let order = spans_sorted_top_to_bottom(spans);
group_into_lines(spans, &order)
.into_iter()
.find(|line| (spans[line[0]].bbox.y - y).abs() < LINE_Y_TOLERANCE_PTS)
.expect("the fixture must carry a line on this baseline")
}
#[test]
fn only_a_grid_row_voting_for_its_own_cell_gap_is_excluded_gh1756() {
let spans = gh1756_sparse_table_rows_page();
let columns = gh1756_grid_columns(&spans);
assert_eq!(
columns,
GH1756_GRID_ROW.iter().map(|&(x, _)| x).collect::<Vec<_>>(),
"the full rows the gap-count filter already excluded must establish the \
table's own column grid"
);
assert!(
vote_is_a_grid_cell_gap(&spans, &gh1756_line_at(&spans, 652.9), &columns, 449.1),
"the SD line under `Age, y` sits on the grid and votes for the gap between \
the table's own second and fourth columns"
);
assert!(
!vote_is_a_grid_cell_gap(&spans, &gh1756_line_at(&spans, 720.0), &columns, 365.0),
"a prose line whose gap falls across the table's columns, but whose spans \
start nowhere near their edges, must keep its vote"
);
assert!(
!vote_is_a_grid_cell_gap(&spans, &gh1756_line_at(&spans, 760.0), &columns, 298.8),
"a line voting for the page's real gutter, left of the table entirely, must \
keep its vote"
);
}
#[test]
fn detect_split_x_keeps_prose_votes_beside_a_table_gh1756() {
let spans = gh1756_prose_beside_a_full_grid_table();
let detected = gh1756_detect_split_x(&spans).expect("six paired prose lines meet the quorum on their own");
assert!(
detected > GH1756_GUTTER_CORRIDOR.0 && detected < GH1756_GUTTER_CORRIDOR.1,
"prose beside a table must keep its gutter vote; expected a split inside \
{GH1756_GUTTER_CORRIDOR:?}, got {detected}"
);
}
#[test]
fn dense_two_column_page_with_sparse_table_rows_declines_the_repair_gh1756() {
let mut spans = gh1756_sparse_table_rows_page();
let original: Vec<(String, f32, f32)> = spans
.iter()
.map(|span| (span.text.clone(), span.bbox.x, span.bbox.y))
.collect();
assert!(
!reorder_dense_two_column_page(&mut spans, GH1756_PAGE_WIDTH),
"a page whose only remaining gutter evidence is five prose lines must \
decline the dense two-column repair rather than split inside a column"
);
let after: Vec<(String, f32, f32)> = spans
.iter()
.map(|span| (span.text.clone(), span.bbox.x, span.bbox.y))
.collect();
assert_eq!(after, original, "a declined repair must leave the page untouched");
}
const GH1762_PAGE_WIDTH: f32 = 595.28;
const GH1762_GUTTER_CORRIDOR: (f32, f32) = (288.15894, 307.0);
const GH1762_TABLE_2_BOTTOM_Y: f32 = 644.15;
fn gh1762_p1_real_reproducer_spans() -> Vec<TextSpan> {
#[rustfmt::skip]
let spans = vec![
span_with_width("Table 2", 38.00, 795.00, 23.34, 7.00, 7.00),
span_with_width("consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore magna aliqua enim ad minim veniam quis nostrud exercitation ullamco laboris nisi", 38.00, 786.95, 499.92, 7.00, 7.00),
span_with_width("Comparatio", 44.00, 776.90, 36.18, 7.00, 7.00),
span_with_width("Numerus", 120.00, 776.90, 28.39, 7.00, 7.00),
span_with_width("Summa superior", 205.00, 776.90, 51.35, 7.00, 7.00),
span_with_width("Summa inferior", 400.00, 776.90, 47.45, 7.00, 7.00),
span_with_width("Lorem vs. ipsum", 44.00, 768.85, 51.35, 7.00, 7.00),
span_with_width("80", 120.00, 768.85, 7.78, 7.00, 7.00),
span_with_width("lorem ipsum dolor sit amet consectetur adipiscing elit sed", 205.00, 768.85, 177.39, 7.00, 7.00),
span_with_width("lorem ipsum dolor sit amet", 400.00, 768.85, 82.07, 7.00, 7.00),
span_with_width("ipsum dolor sit amet consectetur adipiscing elit sed do", 205.00, 760.80, 167.68, 7.00, 7.00),
span_with_width("ipsum dolor sit amet consectetur", 400.00, 760.80, 100.76, 7.00, 7.00),
span_with_width("dolor sit amet consectetur adipiscing elit sed do eiusmod", 205.00, 752.75, 175.46, 7.00, 7.00),
span_with_width("dolor sit amet consectetur", 400.00, 752.75, 80.14, 7.00, 7.00),
span_with_width("Lorem vs. ipsum", 44.00, 741.70, 51.35, 7.00, 7.00),
span_with_width("69", 120.00, 741.70, 7.78, 7.00, 7.00),
span_with_width("elit sed do eiusmod tempor incididunt ut labore et dolore", 205.00, 741.70, 173.91, 7.00, 7.00),
span_with_width("do eiusmod tempor incididunt ut", 400.00, 741.70, 99.21, 7.00, 7.00),
span_with_width("sed do eiusmod tempor incididunt ut labore et dolore", 205.00, 733.65, 163.02, 7.00, 7.00),
span_with_width("eiusmod tempor incididunt ut", 400.00, 733.65, 89.48, 7.00, 7.00),
span_with_width("do eiusmod tempor incididunt ut labore et dolore magna", 205.00, 725.60, 173.14, 7.00, 7.00),
span_with_width("tempor incididunt ut labore et", 400.00, 725.60, 90.26, 7.00, 7.00),
span_with_width("Lorem vs. ipsum", 44.00, 714.55, 51.35, 7.00, 7.00),
span_with_width("58", 120.00, 714.55, 7.78, 7.00, 7.00),
span_with_width("labore et dolore magna aliqua enim ad minim veniam quis", 205.00, 714.55, 179.35, 7.00, 7.00),
span_with_width("aliqua enim ad minim veniam quis", 400.00, 714.55, 105.42, 7.00, 7.00),
span_with_width("et dolore magna aliqua enim ad minim veniam quis nostrud", 205.00, 706.50, 183.24, 7.00, 7.00),
span_with_width("enim ad minim veniam quis", 400.00, 706.50, 84.80, 7.00, 7.00),
span_with_width("dolore magna aliqua enim ad minim veniam quis nostrud", 205.00, 698.45, 175.45, 7.00, 7.00),
span_with_width("ad minim veniam quis nostrud", 400.00, 698.45, 92.97, 7.00, 7.00),
span_with_width("Lorem vs. ipsum", 44.00, 687.40, 51.35, 7.00, 7.00),
span_with_width("47", 120.00, 687.40, 7.78, 7.00, 7.00),
span_with_width("minim veniam quis nostrud exercitation ullamco laboris nisi", 205.00, 687.40, 182.05, 7.00, 7.00),
span_with_width("laboris nisi aliquip ex ea commodo", 400.00, 687.40, 106.98, 7.00, 7.00),
span_with_width("veniam quis nostrud exercitation ullamco laboris nisi aliquip", 205.00, 679.35, 183.62, 7.00, 7.00),
span_with_width("nisi aliquip ex ea commodo", 400.00, 679.35, 84.42, 7.00, 7.00),
span_with_width("quis nostrud exercitation ullamco laboris nisi aliquip ex ea", 205.00, 671.30, 178.18, 7.00, 7.00),
span_with_width("aliquip ex ea commodo consequat", 400.00, 671.30, 106.22, 7.00, 7.00),
span_with_width("Lorem vs. ipsum", 44.00, 660.25, 51.35, 7.00, 7.00),
span_with_width("36", 120.00, 660.25, 7.78, 7.00, 7.00),
span_with_width("nisi aliquip ex ea commodo consequat duis aute irure in", 205.00, 660.25, 172.35, 7.00, 7.00),
span_with_width("irure in reprehenderit voluptate velit", 400.00, 660.25, 109.71, 7.00, 7.00),
span_with_width("aliquip ex ea commodo consequat duis aute irure in", 205.00, 652.20, 159.91, 7.00, 7.00),
span_with_width("in reprehenderit voluptate velit", 400.00, 652.20, 93.76, 7.00, 7.00),
span_with_width("ex ea commodo consequat duis aute irure in reprehenderit", 205.00, 644.15, 181.31, 7.00, 7.00),
span_with_width("reprehenderit voluptate velit esse", 400.00, 644.15, 103.10, 7.00, 7.00),
span_with_width("Table 3", 38.00, 613.25, 23.34, 7.00, 7.00),
span_with_width("4.1. Lorem ipsum dolor sit amet", 307.00, 613.25, 133.05, 9.50, 9.50),
span_with_width("ut labore et dolore magna aliqua enim ad minim veniam quis nostrud exercitation", 38.00, 605.20, 250.16, 7.00, 7.00),
span_with_width("aliquip ex ea commodo consequat duis aute irure in reprehenderit voluptate velit", 38.00, 597.15, 248.23, 7.00, 7.00),
span_with_width("veniam quis nostrud exercitation ullamco laboris nisi aliquip ex ea", 307.00, 592.35, 246.12, 8.50, 8.50),
span_with_width("Designatio", 44.00, 587.10, 31.12, 7.00, 7.00),
span_with_width("Primus", 190.00, 587.10, 21.78, 7.00, 7.00),
span_with_width("Secundus Tertius", 228.00, 587.10, 55.39, 7.00, 7.00),
span_with_width("quis nostrud exercitation ullamco laboris nisi aliquip ex ea", 307.00, 581.90, 216.36, 8.50, 8.50),
span_with_width("lorem ipsum dolor sit amet consectetur", 44.00, 579.05, 120.20, 7.00, 7.00),
span_with_width("21.00", 190.00, 579.05, 17.51, 7.00, 7.00),
span_with_width("20.48", 228.00, 579.05, 17.51, 7.00, 7.00),
span_with_width("18.23", 262.00, 579.05, 17.51, 7.00, 7.00),
span_with_width("nostrud exercitation ullamco laboris nisi aliquip ex ea commodo", 307.00, 571.45, 238.08, 8.50, 8.50),
span_with_width("sit amet consectetur adipiscing elit sed do", 44.00, 571.00, 129.56, 7.00, 7.00),
span_with_width("18.96", 190.00, 571.00, 17.51, 7.00, 7.00),
span_with_width("18.32", 228.00, 571.00, 17.51, 7.00, 7.00),
span_with_width("21.04", 262.00, 571.00, 17.51, 7.00, 7.00),
span_with_width("adipiscing elit sed do eiusmod tempor", 44.00, 562.95, 117.10, 7.00, 7.00),
span_with_width("20.85", 190.00, 562.95, 17.51, 7.00, 7.00),
span_with_width("19.10", 228.00, 562.95, 17.51, 7.00, 7.00),
span_with_width("6.10", 262.00, 562.95, 13.62, 7.00, 7.00),
span_with_width("exercitation ullamco laboris nisi aliquip ex ea commodo consequat", 307.00, 561.00, 248.96, 8.50, 8.50),
span_with_width("do eiusmod tempor incididunt ut labore et", 44.00, 554.90, 128.39, 7.00, 7.00),
span_with_width("20.48", 190.00, 554.90, 17.51, 7.00, 7.00),
span_with_width("18.23", 228.00, 554.90, 17.51, 7.00, 7.00),
span_with_width("20.27", 262.00, 554.90, 17.51, 7.00, 7.00),
span_with_width("ullamco laboris nisi aliquip ex ea commodo consequat duis aute", 307.00, 550.55, 239.99, 8.50, 8.50),
span_with_width("incididunt ut labore et dolore magna aliqua", 44.00, 546.85, 131.90, 7.00, 7.00),
span_with_width("18.32", 190.00, 546.85, 17.51, 7.00, 7.00),
span_with_width("21.04", 228.00, 546.85, 17.51, 7.00, 7.00),
span_with_width("19.61", 262.00, 546.85, 17.51, 7.00, 7.00),
span_with_width("laboris nisi aliquip ex ea commodo consequat duis aute irure in", 307.00, 540.10, 236.68, 8.50, 8.50),
span_with_width("et dolore magna aliqua enim ad minim", 44.00, 538.80, 118.66, 7.00, 7.00),
span_with_width("19.10", 190.00, 538.80, 17.51, 7.00, 7.00),
span_with_width("6.10", 228.00, 538.80, 13.62, 7.00, 7.00),
span_with_width("19.24", 262.00, 538.80, 17.51, 7.00, 7.00),
span_with_width("aliqua enim ad minim veniam quis nostrud", 44.00, 530.75, 130.71, 7.00, 7.00),
span_with_width("18.23", 190.00, 530.75, 17.51, 7.00, 7.00),
span_with_width("20.27", 228.00, 530.75, 17.51, 7.00, 7.00),
span_with_width("16.63", 262.00, 530.75, 17.51, 7.00, 7.00),
span_with_width("nisi aliquip ex ea commodo consequat duis aute irure in", 307.00, 529.65, 209.29, 8.50, 8.50),
span_with_width("minim veniam quis nostrud exercitation", 44.00, 522.70, 120.98, 7.00, 7.00),
span_with_width("21.04", 190.00, 522.70, 17.51, 7.00, 7.00),
span_with_width("19.61", 228.00, 522.70, 17.51, 7.00, 7.00),
span_with_width("21.42", 262.00, 522.70, 17.51, 7.00, 7.00),
span_with_width("aliquip ex ea commodo consequat duis aute irure in reprehenderit", 307.00, 519.20, 247.09, 8.50, 8.50),
span_with_width("nostrud exercitation ullamco laboris nisi", 44.00, 514.65, 122.15, 7.00, 7.00),
span_with_width("6.10", 190.00, 514.65, 13.62, 7.00, 7.00),
span_with_width("19.24", 228.00, 514.65, 17.51, 7.00, 7.00),
span_with_width("5.81", 262.00, 514.65, 13.62, 7.00, 7.00),
span_with_width("ex ea commodo consequat duis aute irure in reprehenderit", 307.00, 508.75, 220.16, 8.50, 8.50),
span_with_width("laboris nisi aliquip ex ea commodo", 44.00, 506.60, 106.98, 7.00, 7.00),
span_with_width("20.27", 190.00, 506.60, 17.51, 7.00, 7.00),
span_with_width("16.63", 228.00, 506.60, 17.51, 7.00, 7.00),
span_with_width("21.00", 262.00, 506.60, 17.51, 7.00, 7.00),
span_with_width("ex ea commodo consequat duis aute irure", 44.00, 498.55, 130.34, 7.00, 7.00),
span_with_width("19.61", 190.00, 498.55, 17.51, 7.00, 7.00),
span_with_width("21.42", 228.00, 498.55, 17.51, 7.00, 7.00),
span_with_width("18.96", 262.00, 498.55, 17.51, 7.00, 7.00),
span_with_width("ea commodo consequat duis aute irure in reprehenderit voluptate", 307.00, 498.30, 245.68, 8.50, 8.50),
span_with_width("consequat duis aute irure in reprehenderit", 44.00, 490.50, 129.56, 7.00, 7.00),
span_with_width("19.24", 190.00, 490.50, 17.51, 7.00, 7.00),
span_with_width("5.81", 228.00, 490.50, 13.62, 7.00, 7.00),
span_with_width("20.85", 262.00, 490.50, 17.51, 7.00, 7.00),
span_with_width("commodo consequat duis aute irure in reprehenderit voluptate velit", 307.00, 487.85, 251.34, 8.50, 8.50),
span_with_width("irure in reprehenderit voluptate velit esse", 44.00, 482.45, 126.44, 7.00, 7.00),
span_with_width("16.63", 190.00, 482.45, 17.51, 7.00, 7.00),
span_with_width("21.00", 228.00, 482.45, 17.51, 7.00, 7.00),
span_with_width("20.48", 262.00, 482.45, 17.51, 7.00, 7.00),
span_with_width("consequat duis aute irure in reprehenderit voluptate velit esse", 307.00, 477.40, 231.97, 8.50, 8.50),
span_with_width("voluptate velit esse cillum fugiat nulla", 44.00, 474.40, 115.16, 7.00, 7.00),
span_with_width("21.42", 190.00, 474.40, 17.51, 7.00, 7.00),
span_with_width("18.96", 228.00, 474.40, 17.51, 7.00, 7.00),
span_with_width("18.32", 262.00, 474.40, 17.51, 7.00, 7.00),
span_with_width("duis aute irure in reprehenderit voluptate velit esse cillum fugiat", 307.00, 466.95, 237.63, 8.50, 8.50),
span_with_width("cillum fugiat nulla pariatur excepteur sint", 44.00, 466.35, 124.88, 7.00, 7.00),
span_with_width("5.81", 190.00, 466.35, 13.62, 7.00, 7.00),
span_with_width("20.85", 228.00, 466.35, 17.51, 7.00, 7.00),
span_with_width("19.10", 262.00, 466.35, 17.51, 7.00, 7.00),
span_with_width("aute irure in reprehenderit voluptate velit esse cillum fugiat nulla", 307.00, 456.50, 239.99, 8.50, 8.50),
span_with_width("irure in reprehenderit voluptate velit esse cillum fugiat nulla pariatur", 307.00, 446.05, 252.26, 8.50, 8.50),
span_with_width("in reprehenderit voluptate velit esse cillum fugiat nulla pariatur", 307.00, 435.60, 232.90, 8.50, 8.50),
span_with_width("reprehenderit voluptate velit esse cillum fugiat nulla pariatur", 307.00, 425.15, 223.92, 8.50, 8.50),
span_with_width("voluptate velit esse cillum fugiat nulla pariatur excepteur sint", 307.00, 414.70, 226.29, 8.50, 8.50),
span_with_width("velit esse cillum fugiat nulla pariatur excepteur sint occaecat", 307.00, 404.25, 225.81, 8.50, 8.50),
span_with_width("esse cillum fugiat nulla pariatur excepteur sint occaecat cupidatat", 307.00, 393.80, 245.19, 8.50, 8.50),
span_with_width("cillum fugiat nulla pariatur excepteur sint occaecat cupidatat non", 307.00, 383.35, 241.42, 8.50, 8.50),
span_with_width("fugiat nulla pariatur excepteur sint occaecat cupidatat non proident", 307.00, 372.90, 250.41, 8.50, 8.50),
];
spans
}
fn gh1762_p1_with_second_table_below_spans() -> Vec<TextSpan> {
let mut spans = gh1762_p1_real_reproducer_spans();
const TABLE_4_ROWS: [(&str, &str, &str, &str); 6] = [
(
"Quartus vs. quintus",
"25",
"lorem ipsum dolor sit amet consectetur adipiscing elit sed",
"lorem ipsum dolor sit amet",
),
(
"Quartus vs. quintus",
"31",
"ipsum dolor sit amet consectetur adipiscing elit sed do",
"ipsum dolor sit amet consectetur",
),
(
"Quartus vs. quintus",
"44",
"dolor sit amet consectetur adipiscing elit sed do eiusmod",
"dolor sit amet consectetur",
),
(
"Quartus vs. quintus",
"52",
"elit sed do eiusmod tempor incididunt ut labore et dolore",
"do eiusmod tempor incididunt ut",
),
(
"Quartus vs. quintus",
"67",
"sed do eiusmod tempor incididunt ut labore et dolore",
"eiusmod tempor incididunt ut",
),
(
"Quartus vs. quintus",
"73",
"do eiusmod tempor incididunt ut labore et dolore magna",
"tempor incididunt ut labore et",
),
];
spans.push(span_with_width("Table 4", 38.00, 345.00, 23.34, 7.00, 7.00));
let mut y = 335.95;
for (label, number, wide_cell, right_cell) in TABLE_4_ROWS {
spans.push(span_with_width(label, 44.00, y, 51.35, 7.00, 7.00));
spans.push(span_with_width(number, 120.00, y, 7.78, 7.00, 7.00));
spans.push(span_with_width(wide_cell, 205.00, y, 177.39, 7.00, 7.00));
spans.push(span_with_width(right_cell, 400.00, y, 82.07, 7.00, 7.00));
y -= 8.05;
}
spans
}
#[test]
fn redirect_survives_a_second_table_closing_the_band_from_below_gh1762() {
let spans = gh1762_p1_with_second_table_below_spans();
let order = spans_sorted_top_to_bottom(&spans);
let lines = group_into_lines(&spans, &order);
let snapped = 216.7825_f32;
let min_gutter = (GH1762_PAGE_WIDTH * MIN_DENSE_COLUMN_GUTTER_FRACTION).max(MIN_DENSE_COLUMN_GUTTER_PTS);
let furniture_width = GH1762_PAGE_WIDTH * FULL_WIDTH_FURNITURE_FRACTION;
let band = band_lines_around_table_gap_split(&spans, &lines, furniture_width, min_gutter, snapped)
.expect("the band under Table 3's own internal boundary and above Table 4 still carries the quorum");
let boundary_lines_in_band = band
.iter()
.filter(|line| line_is_boundary(&spans, line, furniture_width, snapped))
.count();
assert_eq!(
boundary_lines_in_band, 2,
"the band must now carry two boundary lines: the pre-existing one above \
it and Table 4's opening row below it"
);
let corridors =
page_low_occupancy_corridors(&spans, &band, furniture_width, min_gutter, MAX_GUTTER_CROSSING_LINES);
assert_eq!(
corridors,
[(175.9, 190.0), (286.22998, 307.0)],
"the true gutter corridor must still be found: the pre-existing boundary \
line ends exactly at 286.22998 and contributes no crossing there, so \
Table 4's one genuine crossing stays within MAX_GUTTER_CROSSING_LINES"
);
let redirected = redirect_split_out_of_content(&spans, &lines, GH1762_PAGE_WIDTH, snapped);
assert!(
redirected > GH1762_GUTTER_CORRIDOR.0 && redirected < GH1762_GUTTER_CORRIDOR.1,
"a second full-width table below Table 3 must not regress the redirect: \
expected a split inside {GH1762_GUTTER_CORRIDOR:?}, got {redirected}"
);
}
fn gh1762_snapped_split(spans: &[TextSpan]) -> (Vec<SpanLine>, f32) {
let order = spans_sorted_top_to_bottom(spans);
let lines = group_into_lines(spans, &order);
let detected = detect_split_x(spans, &lines, GH1762_PAGE_WIDTH)
.expect("the left table's own cell gaps meet the quorum on their own");
let snapped = snap_split_left_of_hanging_labels(spans, &lines, GH1762_PAGE_WIDTH, detected);
(lines, snapped)
}
#[test]
fn redirect_escapes_a_table_when_another_bands_table_closes_the_gutter_gh1762() {
let spans = gh1762_p1_real_reproducer_spans();
let (lines, snapped) = gh1762_snapped_split(&spans);
let redirected = redirect_split_out_of_content(&spans, &lines, GH1762_PAGE_WIDTH, snapped);
assert!(
redirected > GH1762_GUTTER_CORRIDOR.0 && redirected < GH1762_GUTTER_CORRIDOR.1,
"a split sitting inside a table's own cell gap must be redirected to the \
gutter of the band it will be applied to; expected a split inside \
{GH1762_GUTTER_CORRIDOR:?}, got {redirected} (incoming {snapped})"
);
}
#[test]
fn the_page_wide_corridor_search_still_finds_nothing_gh1762() {
let spans = gh1762_p1_real_reproducer_spans();
let (lines, snapped) = gh1762_snapped_split(&spans);
let min_gutter = (GH1762_PAGE_WIDTH * MIN_DENSE_COLUMN_GUTTER_FRACTION).max(MIN_DENSE_COLUMN_GUTTER_PTS);
let furniture_width = GH1762_PAGE_WIDTH * FULL_WIDTH_FURNITURE_FRACTION;
assert_eq!(
snapped, 216.7825,
"the four-column table's own cell gap still wins the vote"
);
assert!(
page_whitespace_corridors(&spans, &lines, furniture_width, min_gutter).is_empty(),
"Table 2's rows close every page-wide whitespace corridor"
);
assert!(
page_low_occupancy_corridors(&spans, &lines, furniture_width, min_gutter, MAX_GUTTER_CROSSING_LINES)
.is_empty(),
"Table 2 crosses the gutter on far more than MAX_GUTTER_CROSSING_LINES lines"
);
}
#[test]
fn the_corridor_search_is_scoped_to_the_band_under_the_full_width_table_gh1762() {
let spans = gh1762_p1_real_reproducer_spans();
let (lines, snapped) = gh1762_snapped_split(&spans);
let min_gutter = (GH1762_PAGE_WIDTH * MIN_DENSE_COLUMN_GUTTER_FRACTION).max(MIN_DENSE_COLUMN_GUTTER_PTS);
let furniture_width = GH1762_PAGE_WIDTH * FULL_WIDTH_FURNITURE_FRACTION;
let band = band_lines_around_table_gap_split(&spans, &lines, furniture_width, min_gutter, snapped)
.expect("one band carries every line the split sits inside the cell gap of");
assert_eq!(
band.len(),
37,
"the band is the 36 lines under Table 3's caption, plus the boundary line above them"
);
assert!(
band.iter()
.flatten()
.all(|&index| spans[index].bbox.y < GH1762_TABLE_2_BOTTOM_Y),
"Table 2's own rows must be out of scope: they are a band of their own, \
separated from this one by boundary lines"
);
assert_eq!(
page_whitespace_corridors(&spans, &band, furniture_width, min_gutter),
[(286.22998, 307.0)],
"inside that band the page's real gutter is the one and only empty corridor"
);
}
#[test]
fn dense_two_column_page_reorders_below_a_full_width_table_gh1762() {
let mut spans = gh1762_p1_real_reproducer_spans();
assert!(
reorder_dense_two_column_page(&mut spans, GH1762_PAGE_WIDTH),
"a two-column band under a full-width table must still be reordered"
);
let order: Vec<&str> = spans.iter().map(|span| span.text.as_str()).collect();
let position = |text: &str| {
order
.iter()
.position(|&candidate| candidate == text)
.unwrap_or_else(|| panic!("`{text}` must survive the reorder"))
};
let heading = position("4.1. Lorem ipsum dolor sit amet");
assert!(
position("Table 3") < heading,
"Table 3's caption belongs to the left column and must precede the right \
column's heading"
);
assert!(
position("Secundus Tertius") < heading,
"Table 3's own column labels must stay in the left column, not be pushed \
past the right column's heading by a split inside the table"
);
assert!(
position("5.81") < heading,
"the last cell of Table 3's last row must precede the right column's heading"
);
assert_eq!(
order[heading + 1],
"veniam quis nostrud exercitation ullamco laboris nisi aliquip ex ea",
"the right column's heading must be followed by its own first body line, \
not by a table cell"
);
}
const GH1655_PAGE_WIDTH: f32 = 595.28;
const GH1655_NUMBER_X: f32 = 45.22;
const GH1655_TITLE_X: f32 = 80.68;
struct Gh1655Row {
number: &'static str,
number_width: f32,
gap_to_title: f32,
title: &'static str,
title_width: f32,
}
const GH1655_ROWS: [Gh1655Row; 5] = [
Gh1655Row {
number: "6",
number_width: 6.6,
gap_to_title: 25.2,
title: "INBEDRIJFSTELLEN VAN HET TOESTEL",
title_width: 200.0,
},
Gh1655Row {
number: "6.1",
number_width: 17.0,
gap_to_title: 18.4,
title: "Vullen en ontluchten van het cv-systeem",
title_width: 210.0,
},
Gh1655Row {
number: "6.1.1",
number_width: 20.0,
gap_to_title: 12.4,
title: "CV-systeem",
title_width: 70.0,
},
Gh1655Row {
number: "6.1.2",
number_width: 20.0,
gap_to_title: 12.4,
title: "Warmwatervoorziening",
title_width: 140.0,
},
Gh1655Row {
number: "6.1.3",
number_width: 20.0,
gap_to_title: 12.4,
title: "Gastoevoer",
title_width: 70.0,
},
];
fn gh1655_hanging_number_heading_spans(include_space_spans: bool) -> Vec<TextSpan> {
let mut spans = Vec::new();
for (row, entry) in GH1655_ROWS.iter().enumerate() {
let y = 900.0 - row as f32 * 14.0;
spans.push(span_with_width(
entry.number,
GH1655_NUMBER_X,
y,
entry.number_width,
11.0,
11.0,
));
if include_space_spans {
let space_left = GH1655_NUMBER_X + entry.number_width;
let space_right = GH1655_TITLE_X - entry.gap_to_title;
spans.push(span_with_width(
" ",
space_left,
y,
space_right - space_left,
11.0,
11.0,
));
}
spans.push(span_with_width(
entry.title,
GH1655_TITLE_X,
y,
entry.title_width,
11.0,
11.0,
));
}
if include_space_spans {
spans.push(span_with_width(" ", 36.0, 830.0, 11.4, 11.0, 11.0));
spans.push(span_with_width(" ", 377.0, 830.0, 20.0, 11.0, 11.0));
}
spans.push(span_with_width("Intergas Verwarming BV", 43.38, 60.0, 126.15, 9.0, 9.0));
spans.push(span_with_width("30", 543.59, 60.0, 10.03, 9.0, 9.0));
spans
}
#[test]
fn gh1655_hanging_number_headings_with_tab_spans_and_footer_are_not_reordered() {
let spans = gh1655_hanging_number_heading_spans(true);
let order = spans_sorted_top_to_bottom(&spans);
let lines = group_into_lines(&spans, &order);
assert_eq!(
lines.len(),
7,
"fixture must produce exactly the reporter's 7 voting lines"
);
assert_eq!(
detect_split_x(&spans, &lines, GH1655_PAGE_WIDTH),
None,
"only 5 of the 7 lines are genuine gutter evidence, below the quorum of \
MIN_DENSE_COLUMN_SPLIT_LINES -- if this is Some, the ink filter or the \
max-gutter cap has regressed"
);
let mut spans = spans;
let original = spans.iter().map(|span| span.text.clone()).collect::<Vec<_>>();
assert!(
!reorder_dense_two_column_page(&mut spans, GH1655_PAGE_WIDTH),
"a single-column page of hanging-number headings must not be read as two columns"
);
assert_eq!(
spans.iter().map(|span| span.text.as_str()).collect::<Vec<_>>(),
original.iter().map(String::as_str).collect::<Vec<_>>(),
"span order must be unchanged when the page is correctly left alone"
);
}
#[test]
fn gh1655_hanging_number_control_page_without_space_spans_is_not_reordered() {
let spans = gh1655_hanging_number_heading_spans(false);
let order = spans_sorted_top_to_bottom(&spans);
let lines = group_into_lines(&spans, &order);
assert_eq!(lines.len(), 6, "5 headings plus the footer, with no blank line");
assert_eq!(
detect_split_x(&spans, &lines, GH1655_PAGE_WIDTH),
None,
"the footer's gap must not count toward the quorum -- if this is Some, \
MAX_DENSE_COLUMN_GUTTER_FRACTION has regressed"
);
let mut spans = spans;
let original = spans.iter().map(|span| span.text.clone()).collect::<Vec<_>>();
assert!(!reorder_dense_two_column_page(&mut spans, GH1655_PAGE_WIDTH));
assert_eq!(
spans.iter().map(|span| span.text.as_str()).collect::<Vec<_>>(),
original.iter().map(String::as_str).collect::<Vec<_>>()
);
}
#[test]
fn widest_gap_midpoint_rejects_a_gap_wider_than_the_maximum_plausible_gutter() {
let min_gutter = (GH1655_PAGE_WIDTH * MIN_DENSE_COLUMN_GUTTER_FRACTION).max(MIN_DENSE_COLUMN_GUTTER_PTS);
let max_gutter = GH1655_PAGE_WIDTH * MAX_DENSE_COLUMN_GUTTER_FRACTION;
let footer_edges = [(43.38_f32, 169.53_f32), (543.59_f32, 553.62_f32)];
assert_eq!(
widest_gap_midpoint(footer_edges.into_iter(), min_gutter, max_gutter),
None,
"a 374pt gap on a 595.28pt page must not be accepted as a gutter"
);
}
#[test]
fn widest_gap_midpoint_accepts_an_ordinary_two_column_gutter() {
const PAGE_WIDTH: f32 = 612.0;
let min_gutter = (PAGE_WIDTH * MIN_DENSE_COLUMN_GUTTER_FRACTION).max(MIN_DENSE_COLUMN_GUTTER_PTS);
let max_gutter = PAGE_WIDTH * MAX_DENSE_COLUMN_GUTTER_FRACTION;
let edges = [(60.0_f32, 260.0_f32), (320.0_f32, 510.0_f32)];
assert_eq!(
widest_gap_midpoint(edges.into_iter(), min_gutter, max_gutter),
Some(290.0),
"a 60pt gutter on a 612pt page must still be accepted"
);
}
#[test]
fn reorder_band_columns_ignores_whitespace_spans_toward_the_density_gate() {
const SPLIT_X: f32 = 300.0;
const LEFT_X: f32 = 60.0;
const RIGHT_X: f32 = 320.0;
let left_lines = [
"The committee reviewed annual budget totals",
"and approved new funding for the coming year",
"after several rounds of careful review by",
"senior staff members from every department",
"who evaluated priorities across the whole",
];
let right_lines = [
"Numerous studies have examined similar",
"programs across comparable institutions",
"using consistent methodology and controls",
"for measuring outcomes over multiple years",
"researchers found consistent positive trends",
"supporting continued investment going forward",
];
let mut spans = Vec::new();
for (row, text) in left_lines.iter().enumerate() {
spans.push(span_with_width(
text,
LEFT_X,
830.0 - row as f32 * 14.0,
200.0,
11.0,
11.0,
));
}
spans.push(span_with_width(" ", LEFT_X, 830.0 - 5.0 * 14.0, 5.0, 11.0, 11.0));
spans.push(span_with_width(" ", LEFT_X, 830.0 - 6.0 * 14.0, 5.0, 11.0, 11.0));
for (row, text) in right_lines.iter().enumerate() {
spans.push(span_with_width(
text,
RIGHT_X,
830.0 - row as f32 * 14.0,
200.0,
11.0,
11.0,
));
}
let band: Vec<usize> = (0..spans.len()).collect();
let left_raw = band.iter().filter(|&&index| spans[index].bbox.x < SPLIT_X).count();
assert_eq!(
left_raw, 7,
"fixture must meet the raw per-side quorum by index count alone"
);
assert!(
reorder_band_columns(&spans, &band, SPLIT_X).is_none(),
"only 5 of the left side's 7 spans carry ink; the density gate must still refuse the band"
);
}
fn build_paged_text_pdf(page_count: usize, rows: usize) -> Vec<u8> {
let font_obj = 3 + 2 * page_count;
let mut pdf = b"%PDF-1.4\n".to_vec();
let mut offsets: Vec<usize> = Vec::new();
offsets.push(pdf.len());
pdf.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
offsets.push(pdf.len());
let kids: String = (0..page_count).map(|i| format!("{} 0 R ", 3 + i)).collect();
pdf.extend_from_slice(
format!(
"2 0 obj\n<< /Type /Pages /Kids [{}] /Count {} >>\nendobj\n",
kids.trim_end(),
page_count
)
.as_bytes(),
);
for page in 0..page_count {
offsets.push(pdf.len());
pdf.extend_from_slice(
format!(
"{} 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
/Contents {} 0 R /Resources << /Font << /F1 {} 0 R >> >> >>\nendobj\n",
3 + page,
3 + page_count + page,
font_obj
)
.as_bytes(),
);
}
const COLUMN_X: [f32; 4] = [40.0, 180.0, 320.0, 460.0];
for page in 0..page_count {
let mut stream = String::new();
for row in 0..rows {
let y = 760.0 - (row as f32) * 14.0;
for (col, x) in COLUMN_X.iter().enumerate() {
let text = if row == 0 && col == 0 {
format!("PAGEMARK{:04}", page + 1)
} else {
format!("p{}r{}c{} lorem ipsum", page + 1, row, col)
};
stream.push_str(&format!("BT /F1 10 Tf {:.1} {:.1} Td ({}) Tj ET\n", x, y, text));
}
}
offsets.push(pdf.len());
pdf.extend_from_slice(
format!(
"{} 0 obj\n<< /Length {} >>\nstream\n{}\nendstream\nendobj\n",
3 + page_count + page,
stream.len(),
stream
)
.as_bytes(),
);
}
offsets.push(pdf.len());
pdf.extend_from_slice(
format!(
"{} 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica \
/Encoding /WinAnsiEncoding >>\nendobj\n",
font_obj
)
.as_bytes(),
);
let xref_pos = pdf.len();
let total_objs = offsets.len() + 1;
pdf.extend_from_slice(format!("xref\n0 {}\n", total_objs).as_bytes());
pdf.extend_from_slice(b"0000000000 65535 f\r\n");
for &off in &offsets {
pdf.extend_from_slice(format!("{off:010} 00000 n\r\n").as_bytes());
}
pdf.extend_from_slice(
format!(
"trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{}\n%%EOF\n",
total_objs, xref_pos
)
.as_bytes(),
);
pdf
}
fn pagemark_sequence(text: &str) -> Vec<usize> {
text.match_indices("PAGEMARK")
.filter_map(|(at, _)| text.get(at + "PAGEMARK".len()..at + "PAGEMARK".len() + 4))
.filter_map(|digits| digits.parse::<usize>().ok())
.collect()
}
#[test]
fn native_page_text_preserves_page_order_and_content() {
let page_count = 24;
let pdf = build_paged_text_pdf(page_count, 10);
let mut doc = NativeDocument::open_bytes(&pdf).expect("fixture must open");
let margins = PageMarginFractions::default();
let excluded_layers = xberg_native_pdf::optional_content::compute_default_off_ocgs(&doc.doc);
let sequential: Vec<String> = (0..page_count)
.map(|page_idx| {
extract_one_page_text(&doc.doc, page_idx, &excluded_layers, margins)
.expect("page must extract")
.0
})
.collect();
assert!(
sequential.iter().all(|page| !page.trim().is_empty()),
"fixture pages must carry text, otherwise this test proves nothing"
);
let (collected, _) = extract_all_page_texts(&doc.doc, margins).expect("collecting every page must succeed");
assert_eq!(
collected, sequential,
"the collected page texts must match a page-by-page run, page for page"
);
let (content, _, _, _) =
extract_text_from_native_document(&mut doc, None, None, margins).expect("fast path must succeed");
assert_eq!(
content,
sequential.join("\n\n"),
"fast path must concatenate pages in order, separated by one blank line"
);
assert_eq!(
pagemark_sequence(&content),
(1..=page_count).collect::<Vec<_>>(),
"page markers must appear in ascending page order"
);
let page_config = PageConfig {
extract_pages: true,
..PageConfig::default()
};
let (tracked, boundaries, pages, _) =
extract_text_from_native_document(&mut doc, Some(&page_config), None, margins)
.expect("tracking path must succeed");
let boundaries = boundaries.expect("tracking path must report boundaries");
let pages = pages.expect("tracking path must report page contents");
assert_eq!(boundaries.len(), page_count);
assert_eq!(pages.len(), page_count);
for (page_idx, boundary) in boundaries.iter().enumerate() {
assert_eq!(boundary.page_number, (page_idx + 1) as u32);
let slice = &tracked[boundary.byte_start..boundary.byte_end];
assert_eq!(
slice,
sequential[page_idx],
"boundary {} must slice out its own page",
page_idx + 1
);
assert_eq!(pages[page_idx].content, sequential[page_idx]);
assert_eq!(pages[page_idx].page_number, (page_idx + 1) as u32);
}
}
#[test]
#[serial_test::serial]
fn provenance_is_not_read_a_second_time_for_a_document_with_no_excluded_layers() {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../test_documents/pdf/non_ascii_text.pdf");
let bytes = std::fs::read(&path).expect("corpus document must read");
let thresholds = crate::core::config::OcrQualityThresholds::default();
let baseline_doc = NativeDocument::open_bytes(&bytes).expect("corpus document must open");
let expected_fabricated: Vec<u32> = crate::pdf::scan_detect::fabricated_provenance_page_indices(
&baseline_doc.doc,
thresholds.min_provenance_fallback_ratio,
thresholds.min_total_non_whitespace,
)
.into_iter()
.map(|index| index as u32 + 1)
.collect();
assert!(
!expected_fabricated.is_empty(),
"fixture must fabricate at least one page for this test to mean anything"
);
crate::pdf::scan_detect::FABRICATED_PROVENANCE_SECOND_PASS_CALLS
.with(|counter| counter.store(0, std::sync::atomic::Ordering::SeqCst));
let mut doc = NativeDocument::open_bytes(&bytes).expect("corpus document must open a second time");
let (_, _, _, metadata) = extract_text_and_metadata(&mut doc, None).expect("extraction must succeed");
assert_eq!(
crate::pdf::scan_detect::FABRICATED_PROVENANCE_SECOND_PASS_CALLS
.with(|counter| counter.load(std::sync::atomic::Ordering::SeqCst)),
0,
"extract_text_and_metadata must not read every page's text a second time for provenance (issue #1744)"
);
assert_eq!(
metadata.pdf_specific.fabricated_text_pages,
Some(expected_fabricated),
"fabricated_text_pages must match the pre-#1744 page-by-page computation exactly"
);
}
}