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}