Skip to main content

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(&regions, &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}