xberg_native_pdf/structured.rs
1//! Structured per-page extraction (`extract_structured`).
2//!
3//! `PdfDocument::extract_structured(page)` returns a [`StructuredPage`]: the
4//! page's text grouped into typed [`StructuredRegion`]s (body blocks, headings,
5//! header/footer/page-number chrome, marginal labels) in reading order, with a
6//! best-effort `column_index` for multi-column bodies.
7//!
8//! This is an **additive aggregation layer** over signals the extractor already
9//! attaches to every [`TextSpan`](crate::layout::TextSpan):
10//!
11//! * `artifact_type` ([`crate::extractors::text::ArtifactType`]) →
12//! header / footer / page-number / artifact roles, per ISO 32000-1:2008
13//! §14.8.2.2 ("Real Content and Artifacts"). For a tagged PDF these come from
14//! the `/Artifact` marked-content sequences (§14.6.2); they are honoured
15//! for free.
16//! * `heading_level` → [`RegionRole::StructuralHeading`]. Populated from the
17//! structure tree (`H1`..`H6`, §14.7.2) when the PDF is tagged, or from a
18//! font-size heuristic when it is not.
19//! * span geometry → column assignment per §14.8.2.3.1 ("Page Content Order":
20//! multi-column layouts read column to column).
21//!
22//! Because the role signals already ride on the spans, a trustworthy
23//! `/StructTreeRoot` (see [`crate::document::PdfDocument::prefers_structure_reading_order`])
24//! drives the region roles automatically; untagged PDFs fall back to the
25//! geometric/heuristic signals.
26
27use crate::extractors::text::{ArtifactType, PaginationSubtype};
28use crate::geometry::Rect;
29use crate::layout::TextSpan;
30
31/// A single page decomposed into typed regions in reading order.
32#[derive(Debug, Clone, serde::Serialize)]
33pub struct StructuredPage {
34 /// Zero-based page index.
35 pub page_index: usize,
36 /// Page width in PDF points.
37 pub page_width: f32,
38 /// Page height in PDF points.
39 pub page_height: f32,
40 /// Regions in reading order (column-by-column per ISO 32000-1 §14.8.2.3.1).
41 pub regions: Vec<StructuredRegion>,
42}
43
44/// A contiguous run of same-role spans, optionally tagged with a column index.
45#[derive(Debug, Clone, serde::Serialize)]
46pub struct StructuredRegion {
47 /// The semantic role of this region.
48 pub kind: RegionRole,
49 /// The region's text (spans joined with single spaces / newlines).
50 pub text: String,
51 /// Union bounding box of the region's spans.
52 pub bbox: Rect,
53 /// The underlying spans that make up this region.
54 pub spans: Vec<TextSpan>,
55 /// Column index for multi-column bodies: `Some(0)` = leftmost column,
56 /// `Some(1)` = next column, … `None` for full-width content, headings,
57 /// or chrome.
58 pub column_index: Option<usize>,
59 /// Logical section index from the nearest `Sect`/`Art`/`Part` structure
60 /// ancestor (ISO 32000-1:2008 §14.8.4.2) — the spec-authoritative,
61 /// page-independent grouping, so a chapter that continues across pages
62 /// keeps one `section_id` (§5/§6). `None` for untagged PDFs or content
63 /// outside any section.
64 #[serde(skip_serializing_if = "Option::is_none", default)]
65 pub section_id: Option<usize>,
66}
67
68/// Per-MCID structure-tree facts that `extract_structured` surfaces into region
69/// roles (ISO 32000-1:2008 §14.8.4) — empty for untagged PDFs. Keyed by MCID
70/// (page-scoped, which is unique within a single page's structured extraction).
71#[derive(Debug, Default, Clone)]
72pub(crate) struct McidStructInfo {
73 /// MCIDs whose nearest structure ancestor is `Lbl` (§14.8.4.3.3) — a
74 /// label/numeral that distinguishes an item (a verse / list number).
75 pub lbl: std::collections::HashSet<u32>,
76 /// MCID → logical section index (nearest `Sect`/`Art`/`Part` ancestor).
77 pub section: std::collections::HashMap<u32, usize>,
78}
79
80/// The semantic role of a [`StructuredRegion`].
81#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
82pub enum RegionRole {
83 /// Ordinary body text.
84 BodyBlock,
85 /// A document heading (ISO 32000-1 §14.7.2 `H1`..`H6`).
86 StructuralHeading {
87 /// Heading level, 1–6.
88 level: u8,
89 },
90 /// A short verse / section numeral sitting in a narrow column indent.
91 MarginalLabel,
92 /// Running header (§14.8.2.2 Pagination / Header).
93 Header,
94 /// Running footer (§14.8.2.2 Pagination / Footer).
95 Footer,
96 /// Page-number folio (§14.8.2.2 Pagination / page number).
97 PageNumber,
98 /// Any other artifact (watermark, layout, background; §14.8.2.2).
99 Artifact,
100}
101
102/// Map a span's `artifact_type` / `heading_level` to a [`RegionRole`].
103fn role_for_span(span: &TextSpan) -> RegionRole {
104 if let Some(at) = &span.artifact_type {
105 return match at {
106 ArtifactType::Pagination(PaginationSubtype::Header) => RegionRole::Header,
107 ArtifactType::Pagination(PaginationSubtype::Footer) => RegionRole::Footer,
108 ArtifactType::Pagination(PaginationSubtype::PageNumber) => RegionRole::PageNumber,
109 // Watermark / Other pagination, plus Layout / Page / Background. ~keep
110 _ => RegionRole::Artifact,
111 };
112 }
113 if let Some(level) = span.heading_level {
114 return RegionRole::StructuralHeading { level };
115 }
116 if is_marginal_label(&span.text) {
117 return RegionRole::MarginalLabel;
118 }
119 RegionRole::BodyBlock
120}
121
122/// A conservative marginal-label test: a short, standalone numeric or
123/// lowercase-roman token (a verse / section numeral). When unsure we return
124/// `false` so the span folds into the adjacent body block — reading order is
125/// correct either way.
126fn is_marginal_label(text: &str) -> bool {
127 let t = text.trim();
128 if t.is_empty() || t.chars().count() > 4 {
129 return false;
130 }
131 let is_arabic = t.chars().all(|c| c.is_ascii_digit());
132 let is_roman = !t.is_empty() && t.chars().all(|c| matches!(c, 'i' | 'v' | 'x' | 'l' | 'c' | 'd' | 'm'));
133 is_arabic || is_roman
134}
135
136/// Union of two rectangles (corner-based).
137fn rect_union(a: &Rect, b: &Rect) -> Rect {
138 let x0 = a.x.min(b.x);
139 let y0 = a.y.min(b.y);
140 let x1 = (a.x + a.width).max(b.x + b.width);
141 let y1 = (a.y + a.height).max(b.y + b.height);
142 Rect::new(x0, y0, x1 - x0, y1 - y0)
143}
144
145/// A column-body line spans at most ≈ half the content width (one column minus
146/// the gutter); anything wider is cross-column chrome. The threshold is set a
147/// little above half to tolerate uneven columns and ragged right edges.
148const COLUMN_BRIDGE_FRACTION: f32 = 0.6;
149
150/// Best-effort single-gutter detector for body spans.
151///
152/// Returns the gutter X (page coordinate) when the body spans split into two
153/// columns separated by a vertical whitespace corridor that **no span crosses**,
154/// else `None`. Conservative by design: a page with no clear two-column body
155/// yields `None` and every body region gets `column_index == None`.
156///
157/// Detection is **edge-based** (a valley in the horizontal projection of span
158/// extents), not center-of-mass based. A span-center histogram collapses on the
159/// layouts that need column routing most — short, ragged lines (Bible verses,
160/// reference editions) and word-level spans pack centres densely across each
161/// column, leaving no single wide centre-gap even though the columns are
162/// visually obvious. The empty corridor between the left column's right edge and
163/// the right column's left edge survives all of that: inter-word gaps inside a
164/// line are crossed by other lines at different y, so only a true column gutter
165/// forms a page-spanning empty band. Because a real gutter can be narrow
166/// (≈8–20pt) the width gate is an absolute minimum, not a fraction of the page —
167/// the "no span crosses it" + "substantial body on both sides" + "near the page
168/// middle" conditions are what guard against false positives.
169///
170/// Spans wider than [`COLUMN_BRIDGE_FRACTION`] of the body content width are
171/// dropped before the sweep: a full-width book title, running head, or
172/// horizontal rule literally spans both columns and the gutter, so its extent
173/// bridges the corridor in the 1-D projection and hides an otherwise obvious
174/// split (KJF reference Bible, where the merged book-title line
175/// `"Le Troisième Livre de Moïse Appelé GENÈSE"` collapsed the gutter to
176/// nothing). A column-body line never exceeds roughly half the content width;
177/// only cross-column chrome does, so excluding it is safe and is what lets the
178/// edge corridor survive for short, ragged verse columns.
179fn detect_gutter_x(body: &[&TextSpan], page_width: f32) -> Option<f32> {
180 /// A column gutter is a true empty channel; ordinary inter-word/-glyph gaps
181 /// are both narrower and crossed by other lines, so they never survive.
182 const MIN_GUTTER_PT: f32 = 8.0;
183 /// Each side must hold at least a small line or two — a single off-margin
184 /// token is not a column. The empty-corridor + near-middle gates do the real
185 /// false-positive rejection.
186 const MIN_SIDE_SPANS: usize = 2;
187
188 if body.len() < 4 || page_width <= 0.0 {
189 return None;
190 }
191 let all_extents: Vec<(f32, f32)> = body
192 .iter()
193 .filter(|s| s.bbox.width > 0.0 && s.bbox.x.is_finite() && s.bbox.width.is_finite() && !s.text.trim().is_empty())
194 .map(|s| (s.bbox.x, s.bbox.x + s.bbox.width))
195 .collect();
196 if all_extents.len() < 4 {
197 return None;
198 }
199
200 let content_min = all_extents.iter().map(|b| b.0).fold(f32::INFINITY, f32::min);
201 let content_max = all_extents.iter().map(|b| b.1).fold(f32::NEG_INFINITY, f32::max);
202 let content_w = content_max - content_min;
203 if content_w < page_width * 0.25 {
204 return None; // body too narrow to hold two columns ~keep
205 }
206
207 // Drop cross-column chrome (full-width titles/rules) so it cannot bridge the
208 // gutter in projection; a real column-body line stays well under half-width. ~keep
209 let bridge_w = content_w * COLUMN_BRIDGE_FRACTION;
210 let mut boxes: Vec<(f32, f32)> = all_extents.into_iter().filter(|(l, r)| r - l <= bridge_w).collect();
211 if boxes.len() < 4 {
212 return None;
213 }
214 boxes.sort_by(|a, b| crate::utils::safe_float_cmp(a.0, b.0));
215
216 // Sweep-merge the extents left-to-right; the widest forward jump between the
217 // running right edge and the next span's left edge is the widest empty
218 // corridor no span crosses. Sorted by left edge, every span entirely left of
219 // a clean corridor precedes every span entirely right of it, so the span
220 // index at the jump is the left-column count. ~keep
221 let mut cover_right = boxes[0].1;
222 let mut best_gap = 0.0_f32;
223 let mut best_mid = 0.0_f32;
224 let mut left_count = 0usize;
225 for i in 1..boxes.len() {
226 let gap = boxes[i].0 - cover_right;
227 if gap > best_gap {
228 best_gap = gap;
229 best_mid = (cover_right + boxes[i].0) * 0.5;
230 left_count = i;
231 }
232 cover_right = cover_right.max(boxes[i].1);
233 }
234
235 let rel = best_mid / page_width;
236 let right_count = boxes.len() - left_count;
237 if best_gap >= MIN_GUTTER_PT
238 && (0.3..=0.7).contains(&rel)
239 && left_count >= MIN_SIDE_SPANS
240 && right_count >= MIN_SIDE_SPANS
241 {
242 Some(best_mid)
243 } else {
244 None
245 }
246}
247
248/// Build a [`StructuredPage`] from reading-order spans + page dimensions.
249/// Column-detection mode for `build_structured_page_with_mode` (Fix 3). The
250/// geometric column split is heuristic — ISO 32000-1:2008 defines
251/// no reading order for untagged content (§14.8.2.3) — so a consumer who knows
252/// their layout may override the heuristic. This override applies only to the
253/// geometric (Tier-3) path; it never overrides a trustworthy structure tree.
254#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
255pub enum ColumnMode {
256 /// Detect two-column bodies heuristically (the default).
257 #[default]
258 Auto,
259 /// Force a two-column split: use the detected gutter, or the page midpoint
260 /// when no clean gutter is found. For consumers who know the page is
261 /// two-column (e.g. reference editions whose short, ragged lines the
262 /// heuristic is conservative about).
263 Two,
264 /// Treat the whole page as a single column — suppress all column detection
265 /// (`column_index` stays `None`).
266 Single,
267}
268
269/// Build a [`StructuredPage`], detecting columns per [`ColumnMode::Auto`].
270///
271/// Test-only convenience over [`build_structured_page_full`]; production callers
272/// go through [`crate::document::PdfDocument::extract_structured`], which
273/// supplies tagged-structure facts.
274#[cfg(test)]
275pub(crate) fn build_structured_page(
276 page_index: usize,
277 page_width: f32,
278 page_height: f32,
279 spans: Vec<TextSpan>,
280) -> StructuredPage {
281 build_structured_page_full(
282 page_index,
283 page_width,
284 page_height,
285 spans,
286 ColumnMode::Auto,
287 &McidStructInfo::default(),
288 )
289}
290
291/// Build a [`StructuredPage`] with an explicit column-detection [`ColumnMode`]
292/// (Fix 3). `Auto` runs the gutter heuristic; `Two` forces a split
293/// (detected gutter, else page midpoint); `Single` suppresses columns.
294///
295/// Test-only convenience over [`build_structured_page_full`].
296#[cfg(test)]
297pub(crate) fn build_structured_page_with_mode(
298 page_index: usize,
299 page_width: f32,
300 page_height: f32,
301 spans: Vec<TextSpan>,
302 column_mode: ColumnMode,
303) -> StructuredPage {
304 build_structured_page_full(
305 page_index,
306 page_width,
307 page_height,
308 spans,
309 column_mode,
310 &McidStructInfo::default(),
311 )
312}
313
314/// Build a [`StructuredPage`] with both a column [`ColumnMode`] and tagged
315/// structure facts ([`McidStructInfo`]): `Lbl` MCIDs become `MarginalLabel`
316/// regions and each region carries its `Sect`/`Art`/`Part` section index
317/// (ISO 32000-1:2008 §14.8.4 — §4/§5/§6). For untagged PDFs the
318/// info is empty and this behaves exactly like the geometric path.
319pub(crate) fn build_structured_page_full(
320 page_index: usize,
321 page_width: f32,
322 page_height: f32,
323 spans: Vec<TextSpan>,
324 column_mode: ColumnMode,
325 struct_info: &McidStructInfo,
326) -> StructuredPage {
327 let assigner = ColumnAssigner::new(&spans, page_width, column_mode);
328
329 let mut regions: Vec<StructuredRegion> = Vec::new();
330 for span in spans {
331 if span.text.trim().is_empty() {
332 continue;
333 }
334 // Tagged-structure roles (ISO 32000-1:2008 §14.8.4) take precedence over
335 // the geometric/textual heuristics: an `Lbl` marked-content sequence is
336 // authoritatively a label/numeral (§14.8.4.3.3 — §4). ~keep
337 let mcid = span.mcid;
338 let is_lbl = mcid.is_some_and(|m| struct_info.lbl.contains(&m));
339 let kind = if is_lbl {
340 RegionRole::MarginalLabel
341 } else {
342 role_for_span(&span)
343 };
344 let col = match kind {
345 RegionRole::BodyBlock | RegionRole::MarginalLabel => assigner.column_of(&span),
346 _ => None,
347 };
348 // Nearest Sect/Art/Part section (§14.8.4.2 — §5/§6). ~keep
349 let section = mcid.and_then(|m| struct_info.section.get(&m).copied());
350
351 let merge_idx = merge_target(®ions, &kind, col, section);
352 if let Some(i) = merge_idx {
353 let r = &mut regions[i];
354 r.text.push(' ');
355 r.text.push_str(span.text.trim());
356 r.bbox = rect_union(&r.bbox, &span.bbox);
357 r.spans.push(span);
358 continue;
359 }
360 regions.push(StructuredRegion {
361 kind,
362 text: span.text.trim().to_string(),
363 bbox: span.bbox,
364 column_index: col,
365 section_id: section,
366 spans: vec![span],
367 });
368 }
369
370 StructuredPage {
371 page_index,
372 page_width,
373 page_height,
374 regions,
375 }
376}
377
378/// Two-column geometry for one page: the gutter X and the width above which a
379/// span is treated as cross-column chrome rather than column content.
380struct ColumnAssigner {
381 gutter: Option<f32>,
382 bridge_w: f32,
383}
384
385impl ColumnAssigner {
386 fn new(spans: &[TextSpan], page_width: f32, column_mode: ColumnMode) -> Self {
387 // Column assignment is computed over body spans only (chrome/headings are
388 // full-width by convention). ~keep
389 let body_refs: Vec<&TextSpan> = spans
390 .iter()
391 .filter(|s| matches!(role_for_span(s), RegionRole::BodyBlock | RegionRole::MarginalLabel))
392 .collect();
393 let gutter = match column_mode {
394 ColumnMode::Auto => detect_gutter_x(&body_refs, page_width),
395 // Forced two-column: prefer the detected gutter, else the page midpoint. ~keep
396 ColumnMode::Two => detect_gutter_x(&body_refs, page_width).or(Some(page_width * 0.5)),
397 ColumnMode::Single => None,
398 };
399
400 // Body content width over the same finite, non-empty spans the detector
401 // uses, so a full-width title/rule (a gutter-bridging span) is assigned no
402 // column rather than being forced to one side by its centre. ~keep
403 let (content_min, content_max) = body_refs
404 .iter()
405 .filter(|s| {
406 s.bbox.width > 0.0 && s.bbox.x.is_finite() && s.bbox.width.is_finite() && !s.text.trim().is_empty()
407 })
408 .fold((f32::INFINITY, f32::NEG_INFINITY), |(lo, hi), s| {
409 (lo.min(s.bbox.x), hi.max(s.bbox.x + s.bbox.width))
410 });
411 Self {
412 gutter,
413 bridge_w: (content_max - content_min) * COLUMN_BRIDGE_FRACTION,
414 }
415 }
416
417 fn column_of(&self, span: &TextSpan) -> Option<usize> {
418 let g = self.gutter?;
419 if span.bbox.width > self.bridge_w {
420 return None; // cross-column chrome spans both columns ~keep
421 }
422 let center = span.bbox.x + span.bbox.width * 0.5;
423 Some(if center < g { 0 } else { 1 })
424 }
425}
426
427/// Index of the region this span should merge into, if any.
428///
429/// A column-tagged body span (`col` is `Some`) merges into the FIRST region of
430/// the same role + column (+ section) so a two-column page yields one region per
431/// column — left column whole, then right — instead of interleaved per-line
432/// regions (Fix 1). The per-column spans arrive in reading (y) order, so each
433/// region's text is the column read top-to-bottom. A section change forces a new
434/// region so chapters stay separate. Full-width / untagged content keeps
435/// adjacent-only coalescing, so distinct blocks (separate headings, paragraphs)
436/// stay separate. ~keep
437fn merge_target(
438 regions: &[StructuredRegion],
439 kind: &RegionRole,
440 col: Option<usize>,
441 section: Option<usize>,
442) -> Option<usize> {
443 if col.is_some() {
444 return regions
445 .iter()
446 .position(|r| r.kind == *kind && r.column_index == col && r.section_id == section);
447 }
448 match regions.last() {
449 Some(r) if r.kind == *kind && r.column_index == col && r.section_id == section => Some(regions.len() - 1),
450 _ => None,
451 }
452}
453
454#[cfg(test)]
455mod tests {
456 use super::*;
457
458 fn span(text: &str, x: f32, y: f32, w: f32) -> TextSpan {
459 TextSpan {
460 text: text.to_string(),
461 bbox: Rect::new(x, y, w, 12.0),
462 ..Default::default()
463 }
464 }
465
466 /// Body span carrying a marked-content id (one logical unit, e.g. a verse).
467 fn span_mcid(text: &str, x: f32, y: f32, w: f32, mcid: u32) -> TextSpan {
468 TextSpan {
469 mcid: Some(mcid),
470 ..span(text, x, y, w)
471 }
472 }
473
474 /// Faithful reproduction of the KJF reference-Bible page 11
475 /// (Genesis 1), 432 pt wide. Left column = verses 1–13 at x≈57.6, right
476 /// column = verses 14–25 at x≈230.5, with a ≈30 pt gutter. Distinguishing
477 /// features that the existing word-level synthetic test does NOT have, taken
478 /// from the issue's own span dump:
479 /// * spans are *line-level* (one wide span per wrapped verse line), and
480 /// * a *full-width* book-title line ("Le Troisième Livre…") and a
481 /// gutter-centred page number get merged into the body — either of
482 /// which bridges the gutter in a 1-D x-projection and defeats
483 /// `detect_gutter_x`.
484 ///
485 /// Per-verse MCIDs are present (verse 1 = mcid 5, verse 14 = mcid 18, …),
486 /// which is the signal the spec-aligned fix uses to vote per logical unit.
487 #[test]
488 fn issue_734_kjf_reference_columns_get_indices() {
489 let pw = 432.0;
490 // Left column occupies x∈[57.6,197]; right column x∈[230.5,396];
491 // gutter ≈ [197,230]. Line-level spans (≈140 pt wide left lines). ~keep
492 let mut spans = Vec::new();
493 // Full-width book title + running header merged into body flow. ~keep
494 spans.push(span("Le Troisième Livre de Moïse Appelé GENÈSE", 57.6, 720.0, 339.0));
495 // Gutter-centred page number that escaped artifact classification. ~keep
496 spans.push(span("1", 224.6, 706.0, 8.0));
497
498 let mut y = 690.0;
499 // Left column: verses 1..=6, two wrapped line-spans each, per-verse MCID. ~keep
500 for v in 1..=6u32 {
501 spans.push(span_mcid(
502 &format!("{v} Au commencement Dieu créa le ciel et la"),
503 57.6,
504 y,
505 139.0,
506 4 + v, // verse 1 → mcid 5 ~keep
507 ));
508 spans.push(span_mcid("terre.", 57.6, y - 14.0, 28.0, 4 + v));
509 y -= 30.0;
510 }
511 // Right column: verses 14..=19, two wrapped line-spans each, per-verse MCID. ~keep
512 y = 690.0;
513 for v in 14..=19u32 {
514 spans.push(span_mcid(
515 &format!("{v} ¶ Et Dieu dit, Qu'il y ait des lumières dans le"),
516 230.5,
517 y,
518 165.0,
519 4 + v, // verse 14 → mcid 18 ~keep
520 ));
521 spans.push(span_mcid(
522 "firmament du ciel pour séparer le jour.",
523 230.5,
524 y - 14.0,
525 160.0,
526 4 + v,
527 ));
528 y -= 30.0;
529 }
530
531 let page = build_structured_page(0, pw, 792.0, spans);
532 let cols: Vec<Option<usize>> = page.regions.iter().map(|r| r.column_index).collect();
533 assert!(
534 cols.contains(&Some(0)),
535 "left column (0) must be assigned for the KJF layout: {cols:?}"
536 );
537 assert!(
538 cols.contains(&Some(1)),
539 "right column (1) must be assigned for the KJF layout: {cols:?}"
540 );
541
542 // The full-width book-title line spans both columns: it must carry no
543 // column index, not be forced to one side by its centre. ~keep
544 let title = page
545 .regions
546 .iter()
547 .find(|r| r.text.contains("Troisième Livre"))
548 .expect("title region present");
549 assert_eq!(
550 title.column_index, None,
551 "gutter-bridging title must not be assigned a column"
552 );
553 }
554
555 #[test]
556 fn marginal_label_detects_short_numerals() {
557 assert!(is_marginal_label("12"));
558 assert!(is_marginal_label("iv"));
559 assert!(!is_marginal_label("Genesis"));
560 assert!(!is_marginal_label("12345"));
561 }
562
563 #[test]
564 fn heading_and_body_roles_assigned() {
565 let mut h = span("Title", 100.0, 700.0, 80.0);
566 h.heading_level = Some(1);
567 let b = span("Body text here", 100.0, 680.0, 120.0);
568 let page = build_structured_page(0, 612.0, 792.0, vec![h, b]);
569 assert_eq!(page.regions.len(), 2);
570 assert_eq!(page.regions[0].kind, RegionRole::StructuralHeading { level: 1 });
571 assert_eq!(page.regions[1].kind, RegionRole::BodyBlock);
572 }
573
574 #[test]
575 fn two_column_body_gets_column_indices() {
576 // Left column at x≈60, right column at x≈360 on a 612-wide page. ~keep
577 let spans = vec![
578 span("left one", 60.0, 700.0, 120.0),
579 span("left two", 60.0, 680.0, 120.0),
580 span("right one", 360.0, 700.0, 120.0),
581 span("right two", 360.0, 680.0, 120.0),
582 ];
583 let page = build_structured_page(0, 612.0, 792.0, spans);
584 let cols: Vec<Option<usize>> = page.regions.iter().map(|r| r.column_index).collect();
585 assert!(cols.contains(&Some(0)), "a left column (0) must be assigned: {cols:?}");
586 assert!(cols.contains(&Some(1)), "a right column (1) must be assigned: {cols:?}");
587 }
588
589 /// A reference-edition layout (Bible verses): two narrow columns with a
590 /// narrow gutter, short ragged verse lines, word-level spans, and marginal
591 /// verse numerals at each column's left edge. The old center-of-mass gap
592 /// detector returned `None` here (the gutter is far under 12 % of the page
593 /// width and word centres pack each column densely); the edge-based corridor
594 /// detector must still split the columns.
595 #[test]
596 fn narrow_gutter_short_line_columns_get_indices() {
597 // Page 432pt wide. Left column x∈[36,206], right column x∈[226,396],
598 // gutter ≈ [206,226] (20pt, ~4.6 % of width). Word-level spans. ~keep
599 let pw = 432.0;
600 let mut spans = Vec::new();
601 let mut y = 700.0;
602 for row in 0..6 {
603 spans.push(span(&format!("{}", row + 1), 36.0, y, 8.0));
604 spans.push(span("Au", 52.0, y, 26.0));
605 spans.push(span("commencement", 84.0, y, 110.0));
606 spans.push(span(&format!("{}", row + 14), 226.0, y, 12.0));
607 spans.push(span("Et", 244.0, y, 22.0));
608 spans.push(span("Dieu", 272.0, y, 40.0));
609 y -= 14.0;
610 }
611 let page = build_structured_page(0, pw, 792.0, spans);
612 let cols: Vec<Option<usize>> = page.regions.iter().map(|r| r.column_index).collect();
613 assert!(cols.contains(&Some(0)), "left column (0) not assigned: {cols:?}");
614 assert!(cols.contains(&Some(1)), "right column (1) not assigned: {cols:?}");
615 }
616
617 /// `ColumnMode::Two` forces a two-column split even on a layout the
618 /// conservative `Auto` heuristic rejects (here: too few spans to detect a
619 /// gutter). The split falls back to the page midpoint (Fix 3).
620 #[test]
621 fn column_mode_two_forces_split_when_auto_rejects() {
622 let pw = 432.0;
623 // Two spans only — below the detector's `body.len() < 4` floor, so Auto
624 // yields no gutter; Two must still split at the page midpoint (216). ~keep
625 let spans = vec![
626 span("left body", 40.0, 700.0, 100.0), // centre 90 < 216 → col 0 ~keep
627 span("right body", 250.0, 700.0, 100.0), // centre 300 > 216 → col 1 ~keep
628 ];
629
630 let auto = build_structured_page_with_mode(0, pw, 792.0, spans.clone(), ColumnMode::Auto);
631 assert!(
632 auto.regions.iter().all(|r| r.column_index.is_none()),
633 "Auto must not split this sparse layout: {:?}",
634 auto.regions.iter().map(|r| r.column_index).collect::<Vec<_>>()
635 );
636
637 let two = build_structured_page_with_mode(0, pw, 792.0, spans, ColumnMode::Two);
638 let cols: Vec<Option<usize>> = two.regions.iter().map(|r| r.column_index).collect();
639 assert!(cols.contains(&Some(0)), "Two must assign a left column: {cols:?}");
640 assert!(cols.contains(&Some(1)), "Two must assign a right column: {cols:?}");
641 }
642
643 /// `ColumnMode::Single` suppresses column detection on a layout `Auto` would
644 /// split — every region gets `column_index == None` (Fix 3).
645 #[test]
646 fn column_mode_single_suppresses_clear_columns() {
647 let pw = 612.0;
648 let spans = vec![
649 span("left one", 60.0, 700.0, 120.0),
650 span("left two", 60.0, 680.0, 120.0),
651 span("right one", 360.0, 700.0, 120.0),
652 span("right two", 360.0, 680.0, 120.0),
653 ];
654 // Sanity: Auto splits this layout (mirrors two_column_body_gets_column_indices). ~keep
655 let auto = build_structured_page_with_mode(0, pw, 792.0, spans.clone(), ColumnMode::Auto);
656 assert!(auto.regions.iter().any(|r| r.column_index == Some(1)));
657
658 let single = build_structured_page_with_mode(0, pw, 792.0, spans, ColumnMode::Single);
659 assert!(
660 single.regions.iter().all(|r| r.column_index.is_none()),
661 "Single must suppress all columns: {:?}",
662 single.regions.iter().map(|r| r.column_index).collect::<Vec<_>>()
663 );
664 }
665
666 /// A single-column page of ordinary prose (lines spanning most of the body
667 /// width at varying right edges) must NOT be split into columns — there is
668 /// no empty corridor for any line to leave clear.
669 #[test]
670 fn single_column_prose_has_no_gutter() {
671 let pw = 612.0;
672 let mut spans = Vec::new();
673 let mut y = 700.0;
674 let widths = [430.0, 460.0, 410.0, 470.0, 440.0, 455.0, 425.0, 465.0];
675 for w in widths {
676 spans.push(span("a single column prose line of body text", 80.0, y, w));
677 y -= 14.0;
678 }
679 let page = build_structured_page(0, pw, 792.0, spans);
680 let cols: Vec<Option<usize>> = page.regions.iter().map(|r| r.column_index).collect();
681 assert!(
682 cols.iter().all(|c| c.is_none()),
683 "single-column prose wrongly split into columns: {cols:?}"
684 );
685 }
686}