Skip to main content

oxideav_pdf/reader/
layout.rs

1//! Reading-order layout pass for Tagged PDFs (round 29).
2//!
3//! Plain raster (content-stream) order does not give logical reading
4//! order for multi-column / multi-block layouts: the painter would
5//! lay column 1's first row, column 2's first row, then column 1's
6//! second row, etc., as it raster-scanned the page from top to bottom.
7//! Tagged PDF (ISO 32000-1 §14.8) factors logical structure out of
8//! visual layout: the catalog's `/StructTreeRoot` carries a tree of
9//! `/StructElem`s (sections, paragraphs, list items, table rows…)
10//! whose leaves are `MarkedContentReference`s (MCIDs) — integers that
11//! cross-reference the page's `/Span <</MCID n>> BDC … EMC`-bracketed
12//! content-stream slices. Walking the tree in document order and
13//! resolving each MCID to its painted text run gives us the
14//! author-intended reading order, regardless of where the runs
15//! actually appear on paper.
16//!
17//! This module's [`read_in_logical_order`] performs that walk:
18//!
19//! 1. Open the catalog → find `/StructTreeRoot` (if absent, return a
20//!    `Raster`-tagged result that delegates to
21//!    [`crate::reader::extract_text`]).
22//! 2. Walk every page in document order, run the round-22 text walker
23//!    with MCID tracking enabled (round-29 addition), and bucket each
24//!    [`crate::reader::text::MarkedTextRun`] by `(page_obj_num, mcid)`.
25//! 3. Recurse the StructTreeRoot's `/K` tree. For every leaf that's
26//!    either a bare integer (MCID into the parent's `/Pg` page) or a
27//!    `<</Type /MCR /Pg n /MCID m>>` dict (MCID into the named page),
28//!    look up the corresponding bucket and emit its runs in
29//!    accumulation order. For every kid that's a `<</Type /StructElem
30//!    …>>` (or an indirect ref to one), recurse into its `/K`.
31//!
32//! The walker is permissive — unknown `/S` (structure-type) names are
33//! recursed into anyway (they're decorative — the spec encourages
34//! user-defined types — and any text under them still belongs in
35//! logical order). `/OBJR` (object reference) leaves are skipped:
36//! they reference annotations, not content, so they carry no text.
37//!
38//! ## Provenance
39//!
40//! ISO 32000-1:2008 §14.6 (Marked Content), §14.7 (Logical Structure),
41//! §14.8 (Tagged PDF). No third-party PDF library was consulted.
42
43use std::collections::{HashMap, HashSet};
44
45use crate::error::PdfError;
46use crate::objects::{Object, ObjectId};
47use crate::reader::document::DocumentReader;
48use crate::reader::text::{extract_text, extract_text_marked, TextRun};
49
50// ────────────────────────── public surface ──────────────────────────
51
52/// Which path produced the [`ReadingOrderText`] runs.
53#[derive(Clone, Copy, Debug, PartialEq, Eq)]
54pub enum LayoutMode {
55    /// The document carries a `/StructTreeRoot` whose `/K` tree was
56    /// walked to produce logical reading order. Multi-column / table
57    /// layouts come out in author-intended sequence.
58    Tagged,
59    /// The document does not have a structure tree (or had one that
60    /// was empty / unwalkable); the runs are the same raster-order
61    /// runs [`crate::reader::extract_text`] would have produced.
62    Raster,
63}
64
65/// Output of [`read_in_logical_order`]: the run sequence plus the
66/// flag that tells the caller which path produced them.
67#[derive(Clone, Debug, PartialEq)]
68pub struct ReadingOrderText {
69    pub mode: LayoutMode,
70    pub runs: Vec<TextRun>,
71}
72
73impl ReadingOrderText {
74    /// Concatenate every run's text with a space between them — the
75    /// reading-order analogue of
76    /// [`crate::reader::PdfTextExtraction::flat_text`].
77    pub fn flat_text(&self) -> String {
78        self.runs
79            .iter()
80            .map(|r| r.text.as_str())
81            .collect::<Vec<_>>()
82            .join(" ")
83    }
84}
85
86impl<'a> DocumentReader<'a> {
87    /// Round-29: extract every text run in *logical* reading order
88    /// per the document's `/StructTreeRoot` walk (ISO 32000-1 §14.8).
89    /// See [`read_in_logical_order`] for the full contract.
90    pub fn read_in_logical_order(&mut self) -> Result<ReadingOrderText, PdfError> {
91        read_in_logical_order(self)
92    }
93}
94
95/// Walk the document's logical structure tree and emit text runs in
96/// reading order. Falls back to raster order when no `/StructTreeRoot`
97/// is present.
98pub fn read_in_logical_order(
99    reader: &mut DocumentReader<'_>,
100) -> Result<ReadingOrderText, PdfError> {
101    // Resolve catalog → StructTreeRoot (if any).
102    let root_id = reader.xref().root()?;
103    let catalog_obj = reader.resolve(root_id)?;
104    let Object::Dict(catalog) = catalog_obj else {
105        // Malformed catalog — fall back to raster.
106        let runs = extract_text(reader)?.runs;
107        return Ok(ReadingOrderText {
108            mode: LayoutMode::Raster,
109            runs,
110        });
111    };
112    let str_root_obj = catalog
113        .entries()
114        .iter()
115        .find(|(k, _)| k == "StructTreeRoot")
116        .map(|(_, v)| v.clone());
117    let str_root_obj = match str_root_obj {
118        Some(o) => o,
119        None => {
120            let runs = extract_text(reader)?.runs;
121            return Ok(ReadingOrderText {
122                mode: LayoutMode::Raster,
123                runs,
124            });
125        }
126    };
127    let str_root = reader.deref(str_root_obj)?;
128    let Object::Dict(str_root_dict) = str_root else {
129        let runs = extract_text(reader)?.runs;
130        return Ok(ReadingOrderText {
131            mode: LayoutMode::Raster,
132            runs,
133        });
134    };
135
136    // Bucket every MCID-tagged text run by (page_obj_num, mcid).
137    // Runs that have NO MCID (decorative `BMC … EMC` blocks, or shows
138    // outside any marked-content bracket) are dropped: the spec
139    // promises every Tagged-PDF text-show is inside a marked-content
140    // sequence, and untagged paint outside the structure tree wouldn't
141    // have a logical position to slot into anyway.
142    let marked = extract_text_marked(reader)?;
143    let mut buckets: HashMap<(u32, u32), Vec<TextRun>> = HashMap::new();
144    for mr in marked.runs {
145        if let Some(mcid) = mr.mcid {
146            buckets
147                .entry((mr.page_obj_num, mcid))
148                .or_default()
149                .push(mr.run);
150        }
151    }
152
153    // Walk the structure tree.
154    let mut out = Vec::new();
155    let mut visited = HashSet::new();
156    let mut ctx = StructWalkCtx {
157        out: &mut out,
158        buckets: &buckets,
159        visited: &mut visited,
160        cur_page: None,
161        depth: 0,
162    };
163    walk_struct_node(reader, &str_root_dict, &mut ctx)?;
164
165    // If the tree walk produced *no* runs but we did see tagged runs,
166    // the structure tree is empty / malformed — fall back to raster
167    // order rather than returning empty output.
168    if out.is_empty() && !buckets.is_empty() {
169        let runs = extract_text(reader)?.runs;
170        return Ok(ReadingOrderText {
171            mode: LayoutMode::Raster,
172            runs,
173        });
174    }
175
176    Ok(ReadingOrderText {
177        mode: LayoutMode::Tagged,
178        runs: out,
179    })
180}
181
182// ────────────────────────── walker ──────────────────────────
183
184struct StructWalkCtx<'a> {
185    out: &'a mut Vec<TextRun>,
186    buckets: &'a HashMap<(u32, u32), Vec<TextRun>>,
187    visited: &'a mut HashSet<ObjectId>,
188    /// Current `/Pg` in scope. Inherited from ancestor StructElem when
189    /// a child MCR doesn't override it. `None` when no ancestor has
190    /// declared `/Pg` yet.
191    cur_page: Option<u32>,
192    /// Recursion-depth guard so a malformed cycle (which `visited`
193    /// should already prevent for indirect refs) can't blow the stack
194    /// via inline anonymous dicts.
195    depth: u32,
196}
197
198const MAX_STRUCT_DEPTH: u32 = 64;
199
200fn walk_struct_node(
201    reader: &mut DocumentReader<'_>,
202    node: &crate::objects::Dict,
203    ctx: &mut StructWalkCtx<'_>,
204) -> Result<(), PdfError> {
205    if ctx.depth > MAX_STRUCT_DEPTH {
206        return Ok(());
207    }
208    ctx.depth += 1;
209
210    // Pick up the node's own /Pg if it has one (StructElem-only field;
211    // StructTreeRoot doesn't carry /Pg per ISO 32000-1 §14.7.2 Table
212    // 322, but inheriting from any ancestor that does is the spec
213    // contract for resolving bare-integer MCID kids).
214    let saved_page = ctx.cur_page;
215    if let Some(Object::Reference(pg_id)) = node
216        .entries()
217        .iter()
218        .find(|(k, _)| k == "Pg")
219        .map(|(_, v)| v.clone())
220    {
221        ctx.cur_page = Some(pg_id.number);
222    }
223
224    // Visit every kid in `/K` order. `/K` is one of:
225    //   (a) an integer literal (an MCID into ctx.cur_page)
226    //   (b) a dict (MCR / OBJR / nested StructElem)
227    //   (c) an indirect reference to (b)
228    //   (d) an array of (a)/(b)/(c)
229    if let Some(k_obj) = node
230        .entries()
231        .iter()
232        .find(|(k, _)| k == "K")
233        .map(|(_, v)| v.clone())
234    {
235        visit_k(reader, k_obj, ctx)?;
236    }
237
238    ctx.cur_page = saved_page;
239    ctx.depth -= 1;
240    Ok(())
241}
242
243fn visit_k(
244    reader: &mut DocumentReader<'_>,
245    kid: Object,
246    ctx: &mut StructWalkCtx<'_>,
247) -> Result<(), PdfError> {
248    match kid {
249        Object::Integer(mcid) => {
250            // Bare-integer MCID into ctx.cur_page.
251            if let (Some(pg), Ok(mcid_u)) = (ctx.cur_page, u32::try_from(mcid)) {
252                if let Some(runs) = ctx.buckets.get(&(pg, mcid_u)) {
253                    ctx.out.extend(runs.iter().cloned());
254                }
255            }
256            Ok(())
257        }
258        Object::Array(items) => {
259            for item in items {
260                visit_k(reader, item, ctx)?;
261            }
262            Ok(())
263        }
264        Object::Reference(id) => {
265            if !ctx.visited.insert(id) {
266                // Cycle guard.
267                return Ok(());
268            }
269            let resolved = reader.resolve(id)?;
270            // Don't re-visit the same indirect again from elsewhere
271            // in the tree (would also be a cycle).
272            visit_k(reader, resolved, ctx)?;
273            Ok(())
274        }
275        Object::Dict(d) => {
276            // Inspect /Type — could be:
277            //   /MCR   — marked-content reference (leaf)
278            //   /OBJR  — object reference (annotation; no text)
279            //   /StructElem (or no /Type) — recurse into nested element
280            let ty = d
281                .entries()
282                .iter()
283                .find(|(k, _)| k == "Type")
284                .and_then(|(_, v)| match v {
285                    Object::Name(s) => Some(s.as_str()),
286                    _ => None,
287                });
288            match ty {
289                Some("MCR") => {
290                    // Optional /Pg overrides ancestor.
291                    let pg = match d
292                        .entries()
293                        .iter()
294                        .find(|(k, _)| k == "Pg")
295                        .map(|(_, v)| v.clone())
296                    {
297                        Some(Object::Reference(id)) => Some(id.number),
298                        _ => ctx.cur_page,
299                    };
300                    let mcid = d
301                        .entries()
302                        .iter()
303                        .find(|(k, _)| k == "MCID")
304                        .and_then(|(_, v)| match v {
305                            Object::Integer(n) => u32::try_from(*n).ok(),
306                            _ => None,
307                        });
308                    if let (Some(pg), Some(mcid)) = (pg, mcid) {
309                        if let Some(runs) = ctx.buckets.get(&(pg, mcid)) {
310                            ctx.out.extend(runs.iter().cloned());
311                        }
312                    }
313                    Ok(())
314                }
315                Some("OBJR") => {
316                    // Object reference to an annotation — no text.
317                    Ok(())
318                }
319                _ => {
320                    // /StructElem (or a non-spec dict — recurse anyway).
321                    walk_struct_node(reader, &d, ctx)
322                }
323            }
324        }
325        _ => Ok(()),
326    }
327}
328
329// ────────────────────────── tests ──────────────────────────
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334
335    #[test]
336    fn flat_text_joins_runs_with_spaces() {
337        let r = ReadingOrderText {
338            mode: LayoutMode::Tagged,
339            runs: vec![
340                TextRun {
341                    text: "Hello".into(),
342                    position: (0.0, 0.0),
343                    font_name: "F0".into(),
344                    font_size: 12.0,
345                    render_mode: crate::reader::text::TextRenderMode::Fill,
346                    text_rise: 0.0,
347                },
348                TextRun {
349                    text: "World".into(),
350                    position: (40.0, 0.0),
351                    font_name: "F0".into(),
352                    font_size: 12.0,
353                    render_mode: crate::reader::text::TextRenderMode::Fill,
354                    text_rise: 0.0,
355                },
356            ],
357        };
358        assert_eq!(r.flat_text(), "Hello World");
359    }
360}