Skip to main content

pdfboss_text/
lib.rs

1//! Text extraction for pdfboss: font loading, encodings, ToUnicode CMaps,
2//! and positional text spans.
3
4mod cmap;
5mod extract;
6mod font;
7mod sfnt;
8
9use pdfboss_core::{
10    block_on, AsyncObjectSource, Document, Error, Immediate, OcState, Page, Result, StructureTree,
11};
12
13pub use extract::{ExtractReport, FontCache, SkipCause, SkippedText, SkippedTextKind};
14pub use pdfboss_core::{MarkedContentId, Point, Rect};
15
16/// The order a page's text is read in. Every extraction entry point takes
17/// one; [`ReadingOrder::Content`] is the default.
18#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
19pub enum ReadingOrder {
20    /// The content stream's order: what the producer wrote, which in a
21    /// typeset document is the order it meant, each column whole before
22    /// the next. Layout corrects the streams that write across two columns
23    /// row by row and takes over on a page not written in any order.
24    #[default]
25    Content,
26    /// The structure tree's order (ISO 32000-1 §14.7) on a tagged page: the
27    /// reading order the author declared, `/MarkInfo` notwithstanding. A
28    /// page the tree does not reach reads in content order.
29    StructureTree,
30    /// Position on the page: lines top to bottom, spans left to right, a
31    /// page with a clear gutter column by column. Interleaves the columns
32    /// of a two-column page the gutter search does not find; opt in only.
33    Geometric,
34}
35
36impl ReadingOrder {
37    /// Every order, in the order they are documented.
38    pub const ALL: [ReadingOrder; 3] = [
39        ReadingOrder::Content,
40        ReadingOrder::StructureTree,
41        ReadingOrder::Geometric,
42    ];
43
44    /// The order's name, `content`, `structure-tree` or `geometric`: what
45    /// [`FromStr`](std::str::FromStr) accepts and [`Display`](std::fmt::Display) prints.
46    pub fn as_str(self) -> &'static str {
47        match self {
48            ReadingOrder::Content => "content",
49            ReadingOrder::StructureTree => "structure-tree",
50            ReadingOrder::Geometric => "geometric",
51        }
52    }
53}
54
55impl std::fmt::Display for ReadingOrder {
56    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57        f.write_str(self.as_str())
58    }
59}
60
61impl std::str::FromStr for ReadingOrder {
62    type Err = Error;
63
64    fn from_str(s: &str) -> Result<ReadingOrder> {
65        ReadingOrder::ALL
66            .into_iter()
67            .find(|order| order.as_str() == s)
68            .ok_or_else(|| {
69                Error::Other(format!(
70                    "unknown reading order {s:?}: expected 'content', 'structure-tree' or 'geometric'"
71                ))
72            })
73    }
74}
75
76/// The structure tree, loaded only when `order` reads by it.
77fn structure_for(doc: &Document, order: ReadingOrder) -> Option<StructureTree> {
78    match order {
79        ReadingOrder::StructureTree => doc.structure_tree(),
80        _ => None,
81    }
82}
83
84/// A positioned run of extracted text.
85#[derive(Debug, Clone, PartialEq)]
86pub struct TextSpan {
87    /// The decoded text.
88    pub text: String,
89    /// Device-space x coordinate of the span origin.
90    pub x: f32,
91    /// Device-space y coordinate of the span baseline.
92    pub y: f32,
93    /// Device-space x after the last glyph's advance.
94    pub end_x: f32,
95    /// Effective font size.
96    pub size: f32,
97    /// Font resource name.
98    pub font: String,
99    /// The font's `/BaseFont` name verbatim — subset prefix included —
100    /// falling back to the FontDescriptor's `/FontName`; empty when the
101    /// file names the font nowhere (a missing font resource included).
102    pub font_name: String,
103    /// 0-based index of the page the span came from.
104    pub page: usize,
105    /// Device-space box: origin to advance horizontally, the font's
106    /// `/Descent`..`/Ascent` (per-mille of the effective size) vertically.
107    /// Exact for unrotated horizontal text, an approximation under rotated
108    /// matrices; vertical writing takes the advance as its vertical extent
109    /// and half the size to each side of the baseline.
110    pub bbox: Rect,
111    /// Whether the font that produced this span is bold: FontDescriptor
112    /// `/FontWeight` >= 600, `/Flags` ForceBold, or a `/StemV` in bold
113    /// stem-width territory, else a `Bold` substring
114    /// in `/BaseFont` (ISO 32000-1 Table 123).
115    pub bold: bool,
116    /// Whether the font that produced this span is italic: FontDescriptor
117    /// `/Flags` Italic or a nonzero `/ItalicAngle`, else an `Italic` or
118    /// `Oblique` substring in `/BaseFont` (ISO 32000-1 Table 123).
119    pub italic: bool,
120    /// FontDescriptor `/Flags` FixedPitch (ISO 32000-1 Table 123 bit 1).
121    pub monospace: bool,
122    /// FontDescriptor `/Flags` Serif (ISO 32000-1 Table 123 bit 2).
123    pub serif: bool,
124    /// The text rise (`Ts`) the span was shown under, in unscaled text
125    /// space: positive above the baseline — a superscript/subscript
126    /// signal. The origin already includes the shift.
127    pub rise: f32,
128    /// Writing mode 1: the text advances downward and `bbox` takes the
129    /// advance as its vertical extent.
130    pub vertical: bool,
131    /// Shown under render mode 3 or 7 (ISO 32000-1 Table 106), which paint
132    /// nothing — the shape of an OCR text layer under a scanned image.
133    pub invisible: bool,
134    /// The fill color the span was shown with, as RGB in `[0, 1]`. Device
135    /// gray/RGB/CMYK convert exactly; other spaces' components are read by
136    /// count (1 gray, 3 RGB, 4 CMYK) without running the space's
137    /// transform. `None` for pattern fills, which have no single color.
138    pub color: Option<(f32, f32, f32)>,
139    /// A drawn ruling sits just below the baseline and covers most of the
140    /// span. PDF has no underline attribute — this is read from the page's
141    /// geometry, so a table border hugging a cell's text can read as one.
142    pub underline: bool,
143    /// A drawn ruling crosses the span's x-height band — geometry-read,
144    /// like `underline`.
145    pub strikethrough: bool,
146}
147
148/// An axis-aligned line segment a page draws, in the same y-up user space as
149/// `TextSpan`: a table border, a separator, an underline.
150///
151/// Endpoints are normalized (`start.x <= end.x`, `start.y <= end.y`) and
152/// exactly axis-aligned: the near-constant coordinate is snapped to its
153/// midpoint over the segment.
154#[derive(Debug, Clone, PartialEq)]
155pub struct Ruling {
156    pub start: Point,
157    pub end: Point,
158    /// Stroke width in device space. Zero does not say how the ruling was
159    /// drawn: a hairline stroke (`0 w`) and a thin filled rectangle's
160    /// centerline both carry 0.0.
161    pub width: f32,
162}
163
164/// Extracts the page's raw text spans (position, size and font per span) in
165/// the given [`ReadingOrder`]: as the content stream emits them for
166/// [`ReadingOrder::Content`] and [`ReadingOrder::Geometric`] (position
167/// sorting is the layout stage's work), by the structure tree for
168/// [`ReadingOrder::StructureTree`] on a tagged page.
169///
170/// Lenient the way rendering is: content that will not fetch, decode, or
171/// parse yields no spans rather than an error, so one unreadable stream
172/// never costs a caller the rest of the document. Use
173/// [`extract_spans_reporting`] to see what (if anything) was left out.
174///
175/// Content in optional-content layers the document's default configuration
176/// turns off (ISO 32000-1 §8.11) is excluded, counted in
177/// [`ExtractReport::hidden`]. The document-level entry points here read
178/// that configuration themselves; the source-generic `_with` twins take it
179/// as their `oc` parameter (`None` extracts every layer).
180pub fn extract_spans(doc: &Document, page: &Page, order: ReadingOrder) -> Result<Vec<TextSpan>> {
181    let oc = doc.oc_state();
182    let structure = structure_for(doc, order);
183    let (spans, _, _) = block_on(extract::page_spans_and_rulings_with(
184        Immediate(doc),
185        page,
186        None,
187        oc.as_ref(),
188        structure.as_ref(),
189        order,
190    ));
191    Ok(spans)
192}
193
194/// [`extract_spans`] against any object source, awaiting whatever I/O the
195/// source needs to read the page.
196///
197/// This is the shared implementation [`extract_spans`] drives over
198/// [`Immediate`] on the calling thread. `oc` is the document's
199/// optional-content visibility — `Document::oc_state` sync, the async
200/// document's `oc_state()` over a range-fetching source — and gates hidden
201/// layers exactly as the document-level entry does; `None` extracts every
202/// layer. `structure` is the document's structure tree
203/// (`Document::structure_tree`, or the async document's
204/// `structure_tree()`), read only under [`ReadingOrder::StructureTree`];
205/// `None` there reads every page in content order.
206///
207/// The source is taken by value and the page by reference. That combination is
208/// what a consumer needs to spawn the result: the future is `Send` over a source
209/// that is `Send + Sync`, and `'static` as long as the borrow of `page` is
210/// created inside the consumer's own `async move` block, which owns the page.
211/// See `pdfboss_core::source`'s "Signing a shared algorithm".
212pub async fn extract_spans_with<S: AsyncObjectSource>(
213    src: S,
214    page: &Page,
215    oc: Option<&OcState>,
216    structure: Option<&StructureTree>,
217    order: ReadingOrder,
218) -> Result<Vec<TextSpan>> {
219    let (spans, _, _) =
220        extract::page_spans_and_rulings_with(src, page, None, oc, structure, order).await;
221    Ok(spans)
222}
223
224/// [`extract_spans`] with the report of what could not be read: an
225/// [`ExtractReport`] whose entries name each skipped stream and why —
226/// unsupported filters (the passthrough image codecs included), undecodable
227/// bytes, unparseable content, missing resources, exhausted form limits.
228/// An empty span list with an empty report really is an empty page.
229pub fn extract_spans_reporting(
230    doc: &Document,
231    page: &Page,
232    order: ReadingOrder,
233) -> Result<(Vec<TextSpan>, ExtractReport)> {
234    let oc = doc.oc_state();
235    let structure = structure_for(doc, order);
236    let (spans, _, report) = block_on(extract::page_spans_and_rulings_with(
237        Immediate(doc),
238        page,
239        None,
240        oc.as_ref(),
241        structure.as_ref(),
242        order,
243    ));
244    Ok((spans, report))
245}
246
247/// [`extract_spans_reporting`] against any object source. Signed like
248/// [`extract_spans_with`], for the same reasons — `oc` gating included.
249pub async fn extract_spans_reporting_with<S: AsyncObjectSource>(
250    src: S,
251    page: &Page,
252    oc: Option<&OcState>,
253    structure: Option<&StructureTree>,
254    order: ReadingOrder,
255) -> Result<(Vec<TextSpan>, ExtractReport)> {
256    let (spans, _, report) =
257        extract::page_spans_and_rulings_with(src, page, None, oc, structure, order).await;
258    Ok((spans, report))
259}
260
261/// [`extract_spans_reporting`] with fonts cached across calls: a caller
262/// walking a whole document passes one [`FontCache`] to every page, and each
263/// font dictionary — descriptor, widths, encoding, ToUnicode and font-program
264/// parsing included — loads once for the document instead of once per page.
265/// The cache is `Send + Sync`, so a parallel page walk may share it.
266///
267/// The result is identical to calling [`extract_spans_reporting`] per page:
268/// the cache is keyed by each font dictionary's object reference, never by
269/// its resource name, and a reference resolves to the same dictionary on
270/// every page of a document.
271pub fn extract_spans_reporting_cached(
272    doc: &Document,
273    page: &Page,
274    fonts: &FontCache,
275    order: ReadingOrder,
276) -> Result<(Vec<TextSpan>, ExtractReport)> {
277    let oc = doc.oc_state();
278    let structure = structure_for(doc, order);
279    let (spans, _, report) = block_on(extract::page_spans_and_rulings_with(
280        Immediate(doc),
281        page,
282        Some(fonts),
283        oc.as_ref(),
284        structure.as_ref(),
285        order,
286    ));
287    Ok((spans, report))
288}
289
290/// [`extract_spans_reporting_cached`] against any object source. Signed like
291/// [`extract_spans_with`], for the same reasons — `oc` gating included.
292pub async fn extract_spans_reporting_cached_with<S: AsyncObjectSource>(
293    src: S,
294    page: &Page,
295    fonts: &FontCache,
296    oc: Option<&OcState>,
297    structure: Option<&StructureTree>,
298    order: ReadingOrder,
299) -> Result<(Vec<TextSpan>, ExtractReport)> {
300    let (spans, _, report) =
301        extract::page_spans_and_rulings_with(src, page, Some(fonts), oc, structure, order).await;
302    Ok((spans, report))
303}
304
305/// [`extract_spans_reporting`] plus the page's rulings: every axis-aligned
306/// segment the content strokes, and the centerline of every thin filled
307/// rectangle, in the same y-up user space as the spans. See [`Ruling`] for
308/// the normalization the returned segments carry.
309pub fn extract_spans_and_rulings_reporting(
310    doc: &Document,
311    page: &Page,
312    order: ReadingOrder,
313) -> Result<(Vec<TextSpan>, Vec<Ruling>, ExtractReport)> {
314    let oc = doc.oc_state();
315    let structure = structure_for(doc, order);
316    let (spans, rulings, report) = block_on(extract::page_spans_and_rulings_with(
317        Immediate(doc),
318        page,
319        None,
320        oc.as_ref(),
321        structure.as_ref(),
322        order,
323    ));
324    Ok((spans, rulings, report))
325}
326
327/// [`extract_spans_and_rulings_reporting`] against any object source. Signed
328/// like [`extract_spans_with`], for the same reasons — `oc` gating included.
329pub async fn extract_spans_and_rulings_reporting_with<S: AsyncObjectSource>(
330    src: S,
331    page: &Page,
332    oc: Option<&OcState>,
333    structure: Option<&StructureTree>,
334    order: ReadingOrder,
335) -> Result<(Vec<TextSpan>, Vec<Ruling>, ExtractReport)> {
336    let (spans, rulings, report) =
337        extract::page_spans_and_rulings_with(src, page, None, oc, structure, order).await;
338    Ok((spans, rulings, report))
339}
340
341/// [`extract_spans_and_rulings_reporting`] with fonts cached across calls —
342/// the rulings twin of [`extract_spans_reporting_cached`], for a caller
343/// walking a whole document page by page. Spans, rulings, and report are
344/// identical to the uncached call's, for the same reason: the cache is keyed
345/// by each font dictionary's object reference, never by its resource name,
346/// and rulings never touch fonts at all.
347pub fn extract_spans_and_rulings_reporting_cached(
348    doc: &Document,
349    page: &Page,
350    fonts: &FontCache,
351    order: ReadingOrder,
352) -> Result<(Vec<TextSpan>, Vec<Ruling>, ExtractReport)> {
353    let oc = doc.oc_state();
354    let structure = structure_for(doc, order);
355    let (spans, rulings, report) = block_on(extract::page_spans_and_rulings_with(
356        Immediate(doc),
357        page,
358        Some(fonts),
359        oc.as_ref(),
360        structure.as_ref(),
361        order,
362    ));
363    Ok((spans, rulings, report))
364}
365
366/// [`extract_spans_and_rulings_reporting_cached`] against any object source.
367/// Signed like [`extract_spans_with`], for the same reasons — `oc` gating
368/// included.
369pub async fn extract_spans_and_rulings_reporting_cached_with<S: AsyncObjectSource>(
370    src: S,
371    page: &Page,
372    fonts: &FontCache,
373    oc: Option<&OcState>,
374    structure: Option<&StructureTree>,
375    order: ReadingOrder,
376) -> Result<(Vec<TextSpan>, Vec<Ruling>, ExtractReport)> {
377    let (spans, rulings, report) =
378        extract::page_spans_and_rulings_with(src, page, Some(fonts), oc, structure, order).await;
379    Ok((spans, rulings, report))
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385    use pdfboss_core::{resolve_with, BoxFuture, ObjRef, Object, Stream};
386    use pdfboss_testkit::{simple_doc, PdfBuilder};
387    use std::future::Future;
388
389    /// A form's `/Matrix` translates the CTM under which its content runs
390    /// (ISO 32000-1 §8.10.2): the nested span's baseline lands at the
391    /// page-space position the outer text's `Td` moved to, offset by the
392    /// form's own translation, not at the form's local coordinates.
393    #[test]
394    fn form_matrix_translates_the_nested_span() {
395        let mut b = PdfBuilder::new();
396        b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
397        b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
398        b.object(
399            3,
400            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
401             /Resources << /Font << /F1 5 0 R >> /XObject << /Fx 6 0 R >> >> \
402             /Contents 4 0 R >>",
403        );
404        b.stream(4, "", b"BT /F1 12 Tf 72 720 Td (out) Tj ET /Fx Do");
405        b.object(
406            5,
407            "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica \
408             /Encoding /WinAnsiEncoding >>",
409        );
410        // No own /Resources: falls back to the page's, so /F1 resolves.
411        b.stream(
412            6,
413            "/Type /XObject /Subtype /Form /BBox [0 0 612 792] \
414             /Matrix [1 0 0 1 0 -20]",
415            b"BT /F1 12 Tf 72 720 Td (in) Tj ET",
416        );
417        let doc = Document::load(b.build(1)).unwrap();
418        let page = doc.page(0).unwrap();
419        let spans = extract_spans(&doc, &page, ReadingOrder::Content).unwrap();
420        assert_eq!(spans.len(), 2);
421        assert!((spans[1].y - 700.0).abs() < 1e-3); // form matrix applied
422    }
423
424    #[test]
425    fn extract_spans_sane_positions() {
426        let doc = Document::load(simple_doc("Hi")).unwrap();
427        let page = doc.page(0).unwrap();
428        let spans = extract_spans(&doc, &page, ReadingOrder::Content).unwrap();
429        assert_eq!(spans.len(), 1);
430        let s = &spans[0];
431        assert_eq!(s.text, "Hi");
432        assert!((s.x - 72.0).abs() < 1e-3);
433        assert!((s.y - 720.0).abs() < 1e-3);
434        assert!((s.size - 12.0).abs() < 1e-3);
435        assert_eq!(s.font, "F1");
436    }
437
438    /// The combined entry point carries the spans, the drawn rulings, and
439    /// the completeness report through in one call.
440    #[test]
441    fn extract_spans_and_rulings_reports_both() {
442        let doc = Document::load(pdfboss_testkit::doc_with_graphics(
443            "BT /F1 12 Tf 72 720 Td (Hi) Tj ET 72 700 m 272 700 l S",
444        ))
445        .unwrap();
446        let page = doc.page(0).unwrap();
447        let (spans, rulings, report) =
448            extract_spans_and_rulings_reporting(&doc, &page, ReadingOrder::Content).unwrap();
449        assert_eq!(spans.len(), 1);
450        assert_eq!(spans[0].text, "Hi");
451        assert_eq!(rulings.len(), 1);
452        assert!((rulings[0].start.y - 700.0).abs() < 1e-3);
453        assert!(report.is_complete());
454    }
455
456    #[test]
457    fn extract_spans_ordering_multi_line() {
458        let doc = Document::load(pdfboss_testkit::doc_with_graphics(
459            "BT /F1 12 Tf 72 720 Td (top) Tj 0 -40 Td (bottom) Tj ET",
460        ))
461        .unwrap();
462        let page = doc.page(0).unwrap();
463        let spans = extract_spans(&doc, &page, ReadingOrder::Content).unwrap();
464        assert_eq!(spans.len(), 2);
465        assert!(spans[0].y > spans[1].y);
466        assert_eq!(spans[0].text, "top");
467        assert_eq!(spans[1].text, "bottom");
468        assert!(spans.iter().all(|s| s.size > 0.0 && s.x >= 0.0));
469    }
470
471    /// `font_name` carries the file's `/BaseFont` verbatim — subset prefix
472    /// included — while `font` stays the resource name.
473    #[test]
474    fn span_font_name_is_the_base_font_verbatim() {
475        let mut b = PdfBuilder::new();
476        b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
477        b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
478        b.object(
479            3,
480            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
481             /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>",
482        );
483        b.stream(4, "", b"BT /F1 12 Tf 72 720 Td (x) Tj ET");
484        b.object(
485            5,
486            "<< /Type /Font /Subtype /Type1 /BaseFont /ABCDEF+Times-Roman \
487             /Encoding /WinAnsiEncoding >>",
488        );
489        let doc = Document::load(b.build(1)).unwrap();
490        let page = doc.page(0).unwrap();
491        let spans = extract_spans(&doc, &page, ReadingOrder::Content).unwrap();
492        assert_eq!(spans[0].font_name, "ABCDEF+Times-Roman");
493        assert_eq!(spans[0].font, "F1");
494    }
495
496    /// A font dictionary with no `/BaseFont` falls back to the descriptor's
497    /// `/FontName`; a missing or unloadable font resource yields an empty
498    /// name.
499    #[test]
500    fn span_font_name_falls_back_to_descriptor_font_name() {
501        let mut b = PdfBuilder::new();
502        b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
503        b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
504        b.object(
505            3,
506            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
507             /Resources << /Font << /F1 5 0 R /F2 7 0 R >> >> /Contents 4 0 R >>",
508        );
509        b.stream(4, "", b"BT /F1 12 Tf 72 720 Td (a) Tj /F2 12 Tf (b) Tj ET");
510        b.object(
511            5,
512            "<< /Type /Font /Subtype /Type1 /Encoding /WinAnsiEncoding \
513             /FontDescriptor 6 0 R >>",
514        );
515        b.object(6, "<< /Type /FontDescriptor /FontName /Nameless-Face >>");
516        b.object(
517            7,
518            "<< /Type /Font /Subtype /Type1 /Encoding /WinAnsiEncoding >>",
519        );
520        let doc = Document::load(b.build(1)).unwrap();
521        let page = doc.page(0).unwrap();
522        let spans = extract_spans(&doc, &page, ReadingOrder::Content).unwrap();
523        assert_eq!(spans[0].font_name, "Nameless-Face");
524        assert_eq!(spans[1].font_name, "");
525    }
526
527    /// Every span names the 0-based page it came from.
528    #[test]
529    fn span_carries_its_page_index() {
530        let mut b = PdfBuilder::new();
531        b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
532        b.object(2, "<< /Type /Pages /Kids [3 0 R 5 0 R] /Count 2 >>");
533        b.object(
534            3,
535            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
536             /Resources << /Font << /F1 7 0 R >> >> /Contents 4 0 R >>",
537        );
538        b.stream(4, "", b"BT /F1 12 Tf 72 720 Td (one) Tj ET");
539        b.object(
540            5,
541            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
542             /Resources << /Font << /F1 7 0 R >> >> /Contents 6 0 R >>",
543        );
544        b.stream(6, "", b"BT /F1 12 Tf 72 720 Td (two) Tj ET");
545        b.object(
546            7,
547            "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica \
548             /Encoding /WinAnsiEncoding >>",
549        );
550        let doc = Document::load(b.build(1)).unwrap();
551        for index in 0..2 {
552            let page = doc.page(index).unwrap();
553            let spans = extract_spans(&doc, &page, ReadingOrder::Content).unwrap();
554            assert_eq!(spans[0].page, index, "page {index}");
555        }
556    }
557
558    /// The bbox spans origin to advance horizontally and the descriptor's
559    /// `/Descent`..`/Ascent` vertically, both scaled by the effective size.
560    #[test]
561    fn span_bbox_uses_descriptor_metrics() {
562        let mut b = PdfBuilder::new();
563        b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
564        b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
565        b.object(
566            3,
567            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
568             /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>",
569        );
570        b.stream(4, "", b"BT /F1 12 Tf 72 720 Td (Hi) Tj ET");
571        b.object(
572            5,
573            "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica \
574             /Encoding /WinAnsiEncoding /FontDescriptor 6 0 R >>",
575        );
576        b.object(
577            6,
578            "<< /Type /FontDescriptor /FontName /Helvetica \
579             /Ascent 718 /Descent -207 >>",
580        );
581        let doc = Document::load(b.build(1)).unwrap();
582        let page = doc.page(0).unwrap();
583        let spans = extract_spans(&doc, &page, ReadingOrder::Content).unwrap();
584        let s = &spans[0];
585        assert!((s.bbox.x0 - s.x).abs() < 1e-3);
586        assert!((s.bbox.x1 - s.end_x).abs() < 1e-3);
587        assert!((s.bbox.y0 - (720.0 - 0.207 * 12.0)).abs() < 1e-3);
588        assert!((s.bbox.y1 - (720.0 + 0.718 * 12.0)).abs() < 1e-3);
589    }
590
591    /// Without a descriptor the vertical extent falls back to 0.8 em above
592    /// and 0.2 em below the baseline.
593    #[test]
594    fn span_bbox_defaults_to_em_fractions() {
595        let doc = Document::load(simple_doc("Hi")).unwrap();
596        let page = doc.page(0).unwrap();
597        let spans = extract_spans(&doc, &page, ReadingOrder::Content).unwrap();
598        let s = &spans[0];
599        assert!((s.bbox.y0 - (720.0 - 0.2 * 12.0)).abs() < 1e-3);
600        assert!((s.bbox.y1 - (720.0 + 0.8 * 12.0)).abs() < 1e-3);
601    }
602
603    /// A descriptor stating `/CapHeight` but no `/Ascent` uses it for the
604    /// upper edge.
605    #[test]
606    fn span_bbox_falls_back_to_cap_height() {
607        let mut b = PdfBuilder::new();
608        b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
609        b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
610        b.object(
611            3,
612            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
613             /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>",
614        );
615        b.stream(4, "", b"BT /F1 12 Tf 72 720 Td (Hi) Tj ET");
616        b.object(
617            5,
618            "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica \
619             /Encoding /WinAnsiEncoding /FontDescriptor 6 0 R >>",
620        );
621        b.object(
622            6,
623            "<< /Type /FontDescriptor /FontName /Helvetica /CapHeight 700 >>",
624        );
625        let doc = Document::load(b.build(1)).unwrap();
626        let page = doc.page(0).unwrap();
627        let spans = extract_spans(&doc, &page, ReadingOrder::Content).unwrap();
628        assert!((spans[0].bbox.y1 - (720.0 + 0.7 * 12.0)).abs() < 1e-3);
629        assert!((spans[0].bbox.y0 - (720.0 - 0.2 * 12.0)).abs() < 1e-3);
630    }
631
632    /// Table 123 bit 1 (FixedPitch) surfaces as `monospace`.
633    #[test]
634    fn fixed_pitch_flag_marks_monospace() {
635        let spans = flag_spans(1);
636        assert!(spans[0].monospace);
637        assert!(!spans[0].serif);
638    }
639
640    /// Table 123 bit 2 (Serif) surfaces as `serif`.
641    #[test]
642    fn serif_flag_marks_serif() {
643        let spans = flag_spans(2);
644        assert!(spans[0].serif);
645        assert!(!spans[0].monospace);
646    }
647
648    /// One page shown with a font whose descriptor states `/Flags flags`.
649    fn flag_spans(flags: u32) -> Vec<TextSpan> {
650        let mut b = PdfBuilder::new();
651        b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
652        b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
653        b.object(
654            3,
655            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
656             /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>",
657        );
658        b.stream(4, "", b"BT /F1 12 Tf 72 720 Td (x) Tj ET");
659        b.object(
660            5,
661            "<< /Type /Font /Subtype /Type1 /BaseFont /Custom \
662             /Encoding /WinAnsiEncoding /FontDescriptor 6 0 R >>",
663        );
664        b.object(
665            6,
666            &format!("<< /Type /FontDescriptor /FontName /Custom /Flags {flags} >>"),
667        );
668        let doc = Document::load(b.build(1)).unwrap();
669        let page = doc.page(0).unwrap();
670        extract_spans(&doc, &page, ReadingOrder::Content).unwrap()
671    }
672
673    /// The span records the text rise (`Ts`) it was shown under.
674    #[test]
675    fn span_carries_text_rise() {
676        let doc = Document::load(pdfboss_testkit::doc_with_graphics(
677            "BT /F1 12 Tf 72 720 Td (flat) Tj 5 Ts (up) Tj ET",
678        ))
679        .unwrap();
680        let page = doc.page(0).unwrap();
681        let spans = extract_spans(&doc, &page, ReadingOrder::Content).unwrap();
682        assert_eq!(spans[0].rise, 0.0);
683        assert_eq!(spans[1].rise, 5.0);
684    }
685
686    /// A writing-mode-1 (`Identity-V`) font marks its spans vertical.
687    #[test]
688    fn span_marks_vertical_writing() {
689        let mut b = PdfBuilder::new();
690        b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
691        b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
692        b.object(
693            3,
694            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
695             /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>",
696        );
697        b.stream(4, "", b"BT /F1 12 Tf 72 720 Td <0001> Tj ET");
698        b.object(
699            5,
700            "<< /Type /Font /Subtype /Type0 /BaseFont /X /Encoding /Identity-V \
701             /DescendantFonts [6 0 R] /ToUnicode 7 0 R >>",
702        );
703        b.object(
704            6,
705            "<< /Type /Font /Subtype /CIDFontType2 /BaseFont /X /DW 600 >>",
706        );
707        b.stream(
708            7,
709            "",
710            b"1 begincodespacerange <0000> <FFFF> endcodespacerange\n\
711              1 beginbfchar <0001> <0041> endbfchar",
712        );
713        let doc = Document::load(b.build(1)).unwrap();
714        let page = doc.page(0).unwrap();
715        let spans = extract_spans(&doc, &page, ReadingOrder::Content).unwrap();
716        assert!(spans[0].vertical);
717    }
718
719    /// Render modes 3 and 7 paint nothing (ISO 32000-1 Table 106) — the
720    /// shape of an OCR text layer — and mark the span invisible; a later
721    /// `Tr` back to a painting mode clears the mark.
722    #[test]
723    fn invisible_render_modes_mark_the_span() {
724        let doc = Document::load(pdfboss_testkit::doc_with_graphics(
725            "BT /F1 12 Tf 72 720 Td (seen) Tj 3 Tr (ocr) Tj 7 Tr (clip) Tj 0 Tr (back) Tj ET",
726        ))
727        .unwrap();
728        let page = doc.page(0).unwrap();
729        let spans = extract_spans(&doc, &page, ReadingOrder::Content).unwrap();
730        let invisible: Vec<bool> = spans.iter().map(|s| s.invisible).collect();
731        assert_eq!(invisible, [false, true, true, false]);
732    }
733
734    /// The fill color defaults to black (ISO 32000-1 §8.6.8).
735    #[test]
736    fn span_color_defaults_to_black() {
737        let doc = Document::load(simple_doc("Hi")).unwrap();
738        let page = doc.page(0).unwrap();
739        let spans = extract_spans(&doc, &page, ReadingOrder::Content).unwrap();
740        assert_eq!(spans[0].color, Some((0.0, 0.0, 0.0)));
741    }
742
743    /// `rg`, `g` and `k` set the span color, CMYK and gray converted to RGB.
744    #[test]
745    fn device_fill_colors_set_the_span_color() {
746        let doc = Document::load(pdfboss_testkit::doc_with_graphics(
747            "BT /F1 12 Tf 72 720 Td 1 0 0 rg (red) Tj 0.5 g (gray) Tj \
748             1 0 0 0 k (cyan) Tj ET",
749        ))
750        .unwrap();
751        let page = doc.page(0).unwrap();
752        let spans = extract_spans(&doc, &page, ReadingOrder::Content).unwrap();
753        assert_eq!(spans[0].color, Some((1.0, 0.0, 0.0)));
754        assert_eq!(spans[1].color, Some((0.5, 0.5, 0.5)));
755        assert_eq!(spans[2].color, Some((0.0, 1.0, 1.0)));
756    }
757
758    /// `sc`/`scn` components are read by count — 1 gray, 3 RGB, 4 CMYK —
759    /// whatever the named space, the same approximation every extractor
760    /// makes without running the space's transform.
761    #[test]
762    fn sc_components_set_the_span_color_by_count() {
763        let doc = Document::load(pdfboss_testkit::doc_with_graphics(
764            "BT /F1 12 Tf 72 720 Td /DeviceRGB cs 0 1 0 sc (green) Tj \
765             0.25 sc (dark) Tj ET",
766        ))
767        .unwrap();
768        let page = doc.page(0).unwrap();
769        let spans = extract_spans(&doc, &page, ReadingOrder::Content).unwrap();
770        assert_eq!(spans[0].color, Some((0.0, 1.0, 0.0)));
771        assert_eq!(spans[1].color, Some((0.25, 0.25, 0.25)));
772    }
773
774    /// A pattern fill has no single color: the span says so with `None`
775    /// rather than guessing.
776    #[test]
777    fn pattern_fill_leaves_color_unknown() {
778        let doc = Document::load(pdfboss_testkit::doc_with_graphics(
779            "BT /F1 12 Tf 72 720 Td /Pattern cs /P1 scn (patterned) Tj ET",
780        ))
781        .unwrap();
782        let page = doc.page(0).unwrap();
783        let spans = extract_spans(&doc, &page, ReadingOrder::Content).unwrap();
784        assert_eq!(spans[0].color, None);
785    }
786
787    /// A ruling drawn just below the baseline, covering the span, reads as
788    /// an underline.
789    #[test]
790    fn an_underline_ruling_marks_the_span() {
791        let doc = Document::load(pdfboss_testkit::doc_with_graphics(
792            "BT /F1 12 Tf 72 720 Td (Hello) Tj ET 72 718.5 m 105 718.5 l S",
793        ))
794        .unwrap();
795        let page = doc.page(0).unwrap();
796        let spans = extract_spans(&doc, &page, ReadingOrder::Content).unwrap();
797        assert!(spans[0].underline);
798        assert!(!spans[0].strikethrough);
799    }
800
801    /// A ruling crossing the x-height band reads as a strikethrough.
802    #[test]
803    fn a_strikethrough_ruling_marks_the_span() {
804        let doc = Document::load(pdfboss_testkit::doc_with_graphics(
805            "BT /F1 12 Tf 72 720 Td (Hello) Tj ET 72 723.6 m 105 723.6 l S",
806        ))
807        .unwrap();
808        let page = doc.page(0).unwrap();
809        let spans = extract_spans(&doc, &page, ReadingOrder::Content).unwrap();
810        assert!(spans[0].strikethrough);
811        assert!(!spans[0].underline);
812    }
813
814    /// A ruling far from the baseline — a table border, a separator —
815    /// decorates nothing.
816    #[test]
817    fn a_distant_ruling_marks_nothing() {
818        let doc = Document::load(pdfboss_testkit::doc_with_graphics(
819            "BT /F1 12 Tf 72 720 Td (Hello) Tj ET 72 700 m 105 700 l S",
820        ))
821        .unwrap();
822        let page = doc.page(0).unwrap();
823        let spans = extract_spans(&doc, &page, ReadingOrder::Content).unwrap();
824        assert!(!spans[0].underline);
825        assert!(!spans[0].strikethrough);
826    }
827
828    /// A ruling at underline height that barely overlaps the span — a
829    /// neighbour's underline continuing past a word boundary does not
830    /// count; the mark needs most of the span covered.
831    #[test]
832    fn an_underline_needs_most_of_the_span_covered() {
833        let doc = Document::load(pdfboss_testkit::doc_with_graphics(
834            "BT /F1 12 Tf 72 720 Td (Hello) Tj ET 100 718.5 m 130 718.5 l S",
835        ))
836        .unwrap();
837        let page = doc.page(0).unwrap();
838        let spans = extract_spans(&doc, &page, ReadingOrder::Content).unwrap();
839        assert!(!spans[0].underline);
840    }
841
842    /// The source-generic entry points take the optional-content state a
843    /// document-owning caller can read (`Document::oc_state`, or the async
844    /// document's `oc_state()`), so a hidden layer is excluded over any
845    /// source exactly as the document-level entries exclude it; `None`
846    /// still extracts every layer.
847    #[test]
848    fn the_source_generic_entry_points_honor_optional_content() {
849        let mut b = PdfBuilder::new();
850        b.object(
851            1,
852            "<< /Type /Catalog /Pages 2 0 R /OCProperties \
853             << /OCGs [6 0 R] /D << /OFF [6 0 R] >> >> >>",
854        );
855        b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
856        b.object(
857            3,
858            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
859             /Resources << /Font << /F1 5 0 R >> \
860             /Properties << /H 6 0 R >> >> /Contents 4 0 R >>",
861        );
862        b.stream(
863            4,
864            "",
865            b"BT /F1 12 Tf 72 720 Td /OC /H BDC (hidden) Tj EMC (kept) Tj ET",
866        );
867        b.object(
868            5,
869            "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica \
870             /Encoding /WinAnsiEncoding >>",
871        );
872        b.object(6, "<< /Type /OCG /Name (hidden) >>");
873        let doc = Document::load(b.build(1)).unwrap();
874        let page = doc.page(0).unwrap();
875        let oc = doc.oc_state();
876        let gated = block_on(extract_spans_with(
877            Immediate(&doc),
878            &page,
879            oc.as_ref(),
880            None,
881            ReadingOrder::Content,
882        ))
883        .unwrap();
884        let texts: Vec<&str> = gated.iter().map(|s| s.text.as_str()).collect();
885        assert_eq!(texts, ["kept"]);
886        let all = block_on(extract_spans_with(
887            Immediate(&doc),
888            &page,
889            None,
890            None,
891            ReadingOrder::Content,
892        ))
893        .unwrap();
894        let texts: Vec<&str> = all.iter().map(|s| s.text.as_str()).collect();
895        assert_eq!(texts, ["hidden", "kept"]);
896    }
897
898    /// FontDescriptor evidence: /Flags italic bit and /FontWeight.
899    /// Verify the exact bit position against ISO 32000-1 Table 123 while
900    /// implementing — bit 7 (mask 64) is Italic, bit 19 (mask 0x40000) is
901    /// ForceBold — and cite the table in the implementation comment.
902    #[test]
903    fn descriptor_flags_set_span_style() {
904        let mut b = PdfBuilder::new();
905        b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
906        b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
907        b.object(
908            3,
909            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
910             /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>",
911        );
912        b.stream(4, "", b"BT /F1 12 Tf 72 720 Td (x) Tj ET");
913        b.object(
914            5,
915            "<< /Type /Font /Subtype /Type1 /BaseFont /Custom \
916             /Encoding /WinAnsiEncoding /FontDescriptor 6 0 R >>",
917        );
918        b.object(
919            6,
920            "<< /Type /FontDescriptor /FontName /Custom /Flags 64 /FontWeight 700 >>",
921        );
922        let doc = Document::load(b.build(1)).unwrap();
923        let page = doc.page(0).unwrap();
924        let spans = extract_spans(&doc, &page, ReadingOrder::Content).unwrap();
925        assert!(spans[0].italic, "Flags bit 7 (mask 64) is Italic");
926        assert!(spans[0].bold, "FontWeight 700 >= 600 is bold");
927    }
928
929    /// Table 122 `/StemV`: a thick dominant vertical stem marks a bold face
930    /// whose descriptor carries neither a weight nor a telling name — the
931    /// URW `-Medi` faces LaTeX embeds. A regular-width stem stays regular.
932    #[test]
933    fn thick_stemv_reads_as_bold() {
934        let mut b = PdfBuilder::new();
935        b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
936        b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
937        b.object(
938            3,
939            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
940             /Resources << /Font << /F1 5 0 R /F2 7 0 R >> >> /Contents 4 0 R >>",
941        );
942        b.stream(4, "", b"BT /F1 12 Tf 72 720 Td (a) Tj /F2 12 Tf (b) Tj ET");
943        b.object(
944            5,
945            "<< /Type /Font /Subtype /Type1 /BaseFont /NimbusRomNo9L-Medi \
946             /Encoding /WinAnsiEncoding /FontDescriptor 6 0 R >>",
947        );
948        b.object(
949            6,
950            "<< /Type /FontDescriptor /FontName /NimbusRomNo9L-Medi /Flags 4 /StemV 140 >>",
951        );
952        b.object(
953            7,
954            "<< /Type /Font /Subtype /Type1 /BaseFont /NimbusRomNo9L-Regu \
955             /Encoding /WinAnsiEncoding /FontDescriptor 8 0 R >>",
956        );
957        b.object(
958            8,
959            "<< /Type /FontDescriptor /FontName /NimbusRomNo9L-Regu /Flags 4 /StemV 85 >>",
960        );
961        let doc = Document::load(b.build(1)).unwrap();
962        let page = doc.page(0).unwrap();
963        let spans = extract_spans(&doc, &page, ReadingOrder::Content).unwrap();
964        assert!(spans[0].bold, "StemV 140 is a bold stem");
965        assert!(!spans[1].bold, "StemV 85 is a regular stem");
966    }
967
968    /// BaseFont-name fallback when no descriptor exists, and ItalicAngle.
969    #[test]
970    fn basefont_name_and_italic_angle_fallbacks() {
971        let mut b = PdfBuilder::new();
972        b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
973        b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
974        b.object(
975            3,
976            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
977             /Resources << /Font << /F1 5 0 R /F2 6 0 R >> >> /Contents 4 0 R >>",
978        );
979        b.stream(4, "", b"BT /F1 12 Tf 72 720 Td (a) Tj /F2 12 Tf (b) Tj ET");
980        b.object(
981            5,
982            "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-BoldOblique \
983             /Encoding /WinAnsiEncoding >>",
984        );
985        b.object(
986            6,
987            "<< /Type /Font /Subtype /Type1 /BaseFont /Times-Roman \
988             /Encoding /WinAnsiEncoding /FontDescriptor 7 0 R >>",
989        );
990        b.object(
991            7,
992            "<< /Type /FontDescriptor /FontName /Times-Roman /ItalicAngle -12 >>",
993        );
994        let doc = Document::load(b.build(1)).unwrap();
995        let page = doc.page(0).unwrap();
996        let spans = extract_spans(&doc, &page, ReadingOrder::Content).unwrap();
997        assert!(
998            spans[0].bold && spans[0].italic,
999            "BaseFont substrings Bold+Oblique"
1000        );
1001        assert!(!spans[1].bold && spans[1].italic, "ItalicAngle != 0 alone");
1002    }
1003
1004    /// Type0: the descriptor hangs off the descendant font.
1005    #[test]
1006    fn type0_descendant_descriptor_sets_style() {
1007        let mut b = PdfBuilder::new();
1008        b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
1009        b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
1010        b.object(
1011            3,
1012            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
1013             /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>",
1014        );
1015        b.stream(4, "", b"BT /F1 12 Tf 72 720 Td <0001> Tj ET");
1016        b.object(
1017            5,
1018            "<< /Type /Font /Subtype /Type0 /BaseFont /X /Encoding /Identity-H \
1019             /DescendantFonts [6 0 R] /ToUnicode 8 0 R >>",
1020        );
1021        b.object(
1022            6,
1023            "<< /Type /Font /Subtype /CIDFontType2 /BaseFont /X /DW 600 \
1024             /FontDescriptor 7 0 R >>",
1025        );
1026        b.object(
1027            7,
1028            "<< /Type /FontDescriptor /FontName /X /Flags 64 /FontWeight 600 >>",
1029        );
1030        b.stream(
1031            8,
1032            "",
1033            b"1 begincodespacerange <0000> <FFFF> endcodespacerange\n\
1034              1 beginbfchar <0001> <0041> endbfchar",
1035        );
1036        let doc = Document::load(b.build(1)).unwrap();
1037        let page = doc.page(0).unwrap();
1038        let spans = extract_spans(&doc, &page, ReadingOrder::Content).unwrap();
1039        assert!(spans[0].bold && spans[0].italic);
1040    }
1041
1042    /// An asynchronous source that counts each reference resolution by
1043    /// object number and delegates to the document. Loading a font resolves
1044    /// its dictionary's reference exactly once, so the count makes cache
1045    /// hits observable without any instrumentation in the production code.
1046    struct Counting<'a> {
1047        inner: Immediate<&'a Document>,
1048        resolutions: std::cell::RefCell<std::collections::HashMap<u32, usize>>,
1049    }
1050
1051    impl<'a> Counting<'a> {
1052        fn new(doc: &'a Document) -> Counting<'a> {
1053            Counting {
1054                inner: Immediate(doc),
1055                resolutions: std::cell::RefCell::new(std::collections::HashMap::new()),
1056            }
1057        }
1058
1059        fn resolutions(&self, num: u32) -> usize {
1060            self.resolutions.borrow().get(&num).copied().unwrap_or(0)
1061        }
1062    }
1063
1064    impl AsyncObjectSource for Counting<'_> {
1065        fn get(&self, r: ObjRef) -> BoxFuture<'_, Result<Object>> {
1066            self.inner.get(r)
1067        }
1068
1069        fn stream_data<'b>(&'b self, s: &'b Stream) -> BoxFuture<'b, Result<Vec<u8>>> {
1070            self.inner.stream_data(s)
1071        }
1072
1073        fn resolve<'b>(&'b self, o: &'b Object) -> BoxFuture<'b, Result<Object>> {
1074            if let Object::Ref(r) = o {
1075                *self.resolutions.borrow_mut().entry(r.num).or_insert(0) += 1;
1076            }
1077            self.inner.resolve(o)
1078        }
1079    }
1080
1081    /// Two invocations of the same form used to load the form's font twice:
1082    /// every invocation started with an empty font map. The walk-level cache
1083    /// (no [`FontCache`] involved) must fetch the font dictionary once.
1084    #[test]
1085    fn a_font_reached_from_repeated_forms_loads_once_per_page() {
1086        let mut b = PdfBuilder::new();
1087        b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
1088        b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
1089        b.object(
1090            3,
1091            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
1092             /Resources << /XObject << /Fx 6 0 R >> >> /Contents 4 0 R >>",
1093        );
1094        b.stream(4, "", b"/Fx Do /Fx Do");
1095        b.object(
1096            5,
1097            "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica \
1098             /Encoding /WinAnsiEncoding >>",
1099        );
1100        b.stream(
1101            6,
1102            "/Type /XObject /Subtype /Form /BBox [0 0 612 792] \
1103             /Resources << /Font << /F1 5 0 R >> >>",
1104            b"BT /F1 12 Tf 72 700 Td (x) Tj ET",
1105        );
1106        let doc = Document::load(b.build(1)).unwrap();
1107        let page = doc.page(0).unwrap();
1108        let counting = Counting::new(&doc);
1109        let (spans, report) = block_on(extract_spans_reporting_with(
1110            &counting,
1111            &page,
1112            None,
1113            None,
1114            ReadingOrder::Content,
1115        ))
1116        .unwrap();
1117        assert!(report.is_complete(), "unexpected skips: {report:?}");
1118        assert_eq!(spans.len(), 2, "both form invocations must show text");
1119        assert_eq!(
1120            counting.resolutions(5),
1121            1,
1122            "one font dictionary resolution per page walk"
1123        );
1124    }
1125
1126    /// Repeated `gs` operators naming resources from one indirect
1127    /// `/ExtGState` category dictionary resolve that dictionary once per
1128    /// page walk, not once per operator — resolving hands out a deep clone
1129    /// of the whole category dictionary, which measured as a third of a
1130    /// form-heavy corpus extraction pass.
1131    #[test]
1132    fn a_resource_category_resolves_once_per_walk() {
1133        let mut b = PdfBuilder::new();
1134        b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
1135        b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
1136        b.object(
1137            3,
1138            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
1139             /Resources << /ExtGState 5 0 R >> /Contents 4 0 R >>",
1140        );
1141        b.stream(
1142            4,
1143            "",
1144            b"/G1 gs 10 10 m 100 10 l S \
1145              /G1 gs 10 20 m 100 20 l S \
1146              /G1 gs 10 30 m 100 30 l S",
1147        );
1148        b.object(5, "<< /G1 << /LW 2 >> >>");
1149        let doc = Document::load(b.build(1)).unwrap();
1150        let page = doc.page(0).unwrap();
1151        let counting = Counting::new(&doc);
1152        let (_, rulings, report) = block_on(extract_spans_and_rulings_reporting_with(
1153            &counting,
1154            &page,
1155            None,
1156            None,
1157            ReadingOrder::Content,
1158        ))
1159        .unwrap();
1160        assert!(report.is_complete(), "unexpected skips: {report:?}");
1161        assert_eq!(rulings.len(), 3, "all three strokes extract");
1162        assert_eq!(
1163            counting.resolutions(5),
1164            1,
1165            "one category dictionary resolution per page walk"
1166        );
1167    }
1168
1169    /// A two-page document whose pages bind the same font dictionary: with
1170    /// one [`FontCache`] passed to both extractions the dictionary is
1171    /// fetched once, and the spans are exactly the uncached call's.
1172    #[test]
1173    fn a_font_shared_across_pages_loads_once_per_document() {
1174        let mut b = PdfBuilder::new();
1175        b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
1176        b.object(2, "<< /Type /Pages /Kids [3 0 R 5 0 R] /Count 2 >>");
1177        b.object(
1178            3,
1179            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
1180             /Resources << /Font << /F1 7 0 R >> >> /Contents 4 0 R >>",
1181        );
1182        b.stream(4, "", b"BT /F1 12 Tf 72 720 Td (one) Tj ET");
1183        b.object(
1184            5,
1185            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
1186             /Resources << /Font << /F1 7 0 R >> >> /Contents 6 0 R >>",
1187        );
1188        b.stream(6, "", b"BT /F1 12 Tf 72 720 Td (two) Tj ET");
1189        b.object(
1190            7,
1191            "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica \
1192             /Encoding /WinAnsiEncoding >>",
1193        );
1194        let doc = Document::load(b.build(1)).unwrap();
1195        let fonts = FontCache::default();
1196        let counting = Counting::new(&doc);
1197        let mut cached = Vec::new();
1198        for index in 0..2 {
1199            let page = doc.page(index).unwrap();
1200            let (spans, report) = block_on(extract_spans_reporting_cached_with(
1201                &counting,
1202                &page,
1203                &fonts,
1204                None,
1205                None,
1206                ReadingOrder::Content,
1207            ))
1208            .unwrap();
1209            assert!(report.is_complete(), "unexpected skips: {report:?}");
1210            cached.push(spans);
1211        }
1212        assert_eq!(
1213            counting.resolutions(7),
1214            1,
1215            "one font dictionary resolution per document"
1216        );
1217        for (index, spans) in cached.iter().enumerate() {
1218            let page = doc.page(index).unwrap();
1219            let plain = extract_spans_reporting(&doc, &page, ReadingOrder::Content)
1220                .unwrap()
1221                .0;
1222            assert_eq!(spans, &plain, "page {index} must extract identically");
1223        }
1224    }
1225
1226    /// `/F1` on one page and `/F1` on the next may be different fonts: the
1227    /// shared cache is keyed by the font dictionary's object reference, so
1228    /// each page keeps its own binding. A cache keyed by resource name would
1229    /// hand page two the font of page one and fail here.
1230    #[test]
1231    fn a_shared_cache_keeps_the_name_binding_per_page() {
1232        let mut b = PdfBuilder::new();
1233        b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
1234        b.object(2, "<< /Type /Pages /Kids [3 0 R 5 0 R] /Count 2 >>");
1235        b.object(
1236            3,
1237            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
1238             /Resources << /Font << /F1 7 0 R >> >> /Contents 4 0 R >>",
1239        );
1240        b.stream(4, "", b"BT /F1 12 Tf 72 720 Td (aa) Tj ET");
1241        b.object(
1242            5,
1243            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
1244             /Resources << /Font << /F1 8 0 R >> >> /Contents 6 0 R >>",
1245        );
1246        b.stream(6, "", b"BT /F1 12 Tf 72 720 Td (aa) Tj ET");
1247        b.object(
1248            7,
1249            "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica \
1250             /Encoding /WinAnsiEncoding /FirstChar 97 /LastChar 97 /Widths [500] >>",
1251        );
1252        b.object(
1253            8,
1254            "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica \
1255             /Encoding /WinAnsiEncoding /FirstChar 97 /LastChar 97 /Widths [1000] >>",
1256        );
1257        let doc = Document::load(b.build(1)).unwrap();
1258        let fonts = FontCache::default();
1259        let mut advances = Vec::new();
1260        for index in 0..2 {
1261            let page = doc.page(index).unwrap();
1262            let (spans, _) =
1263                extract_spans_reporting_cached(&doc, &page, &fonts, ReadingOrder::Content).unwrap();
1264            assert_eq!(spans.len(), 1);
1265            advances.push(spans[0].end_x - spans[0].x);
1266        }
1267        assert!(
1268            (advances[0] - 12.0).abs() < 1e-3,
1269            "page one: {}",
1270            advances[0]
1271        );
1272        assert!(
1273            (advances[1] - 24.0).abs() < 1e-3,
1274            "page two: {}",
1275            advances[1]
1276        );
1277    }
1278
1279    /// An asynchronous source that answers everything with `null`.
1280    ///
1281    /// The heap field is load-bearing rather than decorative. rustc const-promotes
1282    /// a reference to a unit struct to `&'static`, so a unit stub would satisfy
1283    /// the `'static` assertion below even under a signature that assertion exists
1284    /// to reject — a test that cannot fail. A `Vec` cannot be promoted.
1285    ///
1286    /// It is also deliberately `Send + Sync`. The helpers inside the shared
1287    /// implementation borrow the source across their awaits, so the owning future
1288    /// is `Send` only when the source is `Sync`; every genuinely asynchronous
1289    /// source already is, because `resolve_with` requires it.
1290    struct NullSource {
1291        payload: Vec<u8>,
1292    }
1293
1294    impl AsyncObjectSource for NullSource {
1295        fn get(&self, _r: ObjRef) -> BoxFuture<'_, Result<Object>> {
1296            Box::pin(std::future::ready(Ok(Object::Null)))
1297        }
1298
1299        fn stream_data<'a>(&'a self, _s: &'a Stream) -> BoxFuture<'a, Result<Vec<u8>>> {
1300            Box::pin(std::future::ready(Ok(self.payload.clone())))
1301        }
1302
1303        fn resolve<'a>(&'a self, o: &'a Object) -> BoxFuture<'a, Result<Object>> {
1304            Box::pin(resolve_with(self, o))
1305        }
1306    }
1307
1308    /// The asynchronous entry point must produce a future a runtime's `spawn`
1309    /// and the Python bindings will accept, which means `Send + 'static`.
1310    ///
1311    /// The `async move` block is the shape a consumer actually writes: it owns
1312    /// the source and the page, and the borrow of the page that
1313    /// `extract_spans_with` takes is created inside it. That is what makes the
1314    /// future `'static` despite the `&Page` parameter — and asserting it here also
1315    /// pins `Page: Send + Sync`, since the block holds one across its awaits.
1316    ///
1317    /// Every other test in this crate now drives this same implementation through
1318    /// `block_on`, so behaviour is covered by the exact-string assertions above.
1319    /// What none of them can see is this type, which is the entire point of the
1320    /// exercise. The document is dropped first to show the page stands alone.
1321    #[test]
1322    fn the_async_entry_point_yields_a_spawnable_future() {
1323        fn assert_send_static<F: Future + Send + 'static>(_: &F) {}
1324
1325        let doc = Document::load(simple_doc("Hello")).unwrap();
1326        let spans_page = doc.page(0).unwrap();
1327        drop(doc);
1328
1329        let spans = async move {
1330            extract_spans_with(
1331                NullSource {
1332                    payload: Vec::new(),
1333                },
1334                &spans_page,
1335                None,
1336                None,
1337                ReadingOrder::Content,
1338            )
1339            .await
1340        };
1341        assert_send_static(&spans);
1342
1343        // A source that resolves everything to null yields a page with no
1344        // contents, so driving this only proves the wiring is reachable.
1345        assert!(block_on(spans).unwrap().is_empty());
1346    }
1347
1348    /// A one-page tagged document: the catalog names object 10 as the
1349    /// structure tree root, object 12 is the parent tree, and the page
1350    /// (object 3, `/StructParents 0`) shows `content` with `/F1` Helvetica.
1351    /// Objects 13 and 14 are two paragraphs, the left one holding marked
1352    /// content 0 and 2, the right one 1 and 3: tree order is 0, 2, 1, 3.
1353    fn tagged_doc(
1354        content: &[u8],
1355        page_extra: &str,
1356        extra: impl FnOnce(&mut PdfBuilder),
1357    ) -> Document {
1358        let mut b = PdfBuilder::new();
1359        b.object(
1360            1,
1361            "<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 10 0 R >>",
1362        );
1363        b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
1364        b.object(
1365            3,
1366            &format!(
1367                "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /StructParents 0 \
1368                 /Resources << /Font << /F1 5 0 R >> {page_extra} >> /Contents 4 0 R >>"
1369            ),
1370        );
1371        b.stream(4, "", content);
1372        b.object(
1373            5,
1374            "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>",
1375        );
1376        b.object(
1377            10,
1378            "<< /Type /StructTreeRoot /K [11 0 R] /ParentTree 12 0 R >>",
1379        );
1380        b.object(
1381            11,
1382            "<< /Type /StructElem /S /Document /P 10 0 R /K [13 0 R 14 0 R] >>",
1383        );
1384        b.object(12, "<< /Nums [0 [13 0 R 14 0 R 13 0 R 14 0 R]] >>");
1385        b.object(
1386            13,
1387            "<< /Type /StructElem /S /P /P 11 0 R /Pg 3 0 R /K [0 2] >>",
1388        );
1389        b.object(
1390            14,
1391            "<< /Type /StructElem /S /P /P 11 0 R /Pg 3 0 R /K [1 3] >>",
1392        );
1393        extra(&mut b);
1394        Document::load(b.build(1)).unwrap()
1395    }
1396
1397    /// Two columns written bottom row first: stream order L2 R2 L1 R1,
1398    /// geometry L1 R1 / L2 R2, tree L1 L2 R1 R2: three orders, three
1399    /// different answers.
1400    const TWO_COLUMNS: &[u8] = b"BT /F1 12 Tf \
1401        /P << /MCID 2 >> BDC 1 0 0 1 72 680 Tm (L2) Tj EMC \
1402        /P << /MCID 3 >> BDC 1 0 0 1 300 680 Tm (R2) Tj EMC \
1403        /P << /MCID 0 >> BDC 1 0 0 1 72 700 Tm (L1) Tj EMC \
1404        /P << /MCID 1 >> BDC 1 0 0 1 300 700 Tm (R1) Tj EMC ET";
1405
1406    fn texts(spans: &[TextSpan]) -> Vec<&str> {
1407        spans.iter().map(|s| s.text.as_str()).collect()
1408    }
1409
1410    fn ordered(doc: &Document, order: ReadingOrder) -> (Vec<String>, ReadingOrder) {
1411        let page = doc.page(0).unwrap();
1412        let (spans, report) = extract_spans_reporting(doc, &page, order).unwrap();
1413        (spans.into_iter().map(|s| s.text).collect(), report.order)
1414    }
1415
1416    #[test]
1417    fn reading_order_names_round_trip() {
1418        for order in ReadingOrder::ALL {
1419            assert_eq!(order.as_str().parse::<ReadingOrder>().unwrap(), order);
1420            assert_eq!(order.to_string(), order.as_str());
1421        }
1422        assert_eq!(ReadingOrder::default(), ReadingOrder::Content);
1423        assert!("bogus".parse::<ReadingOrder>().is_err());
1424    }
1425
1426    #[test]
1427    fn content_order_is_the_stream_as_written() {
1428        let doc = tagged_doc(TWO_COLUMNS, "", |_| {});
1429        let (spans, order) = ordered(&doc, ReadingOrder::Content);
1430        assert_eq!(spans, ["L2", "R2", "L1", "R1"]);
1431        assert_eq!(order, ReadingOrder::Content);
1432    }
1433
1434    #[test]
1435    fn geometric_order_extracts_the_stream_and_tags_the_report() {
1436        let doc = tagged_doc(TWO_COLUMNS, "", |_| {});
1437        let (spans, order) = ordered(&doc, ReadingOrder::Geometric);
1438        assert_eq!(spans, ["L2", "R2", "L1", "R1"]);
1439        assert_eq!(order, ReadingOrder::Geometric);
1440    }
1441
1442    #[test]
1443    fn structure_tree_order_follows_the_tree() {
1444        let doc = tagged_doc(TWO_COLUMNS, "", |_| {});
1445        let (spans, order) = ordered(&doc, ReadingOrder::StructureTree);
1446        assert_eq!(spans, ["L1", "L2", "R1", "R2"]);
1447        assert_eq!(order, ReadingOrder::StructureTree);
1448    }
1449
1450    #[test]
1451    fn structure_tree_order_reads_an_untagged_document_in_content_order() {
1452        let doc = Document::load(pdfboss_testkit::multi_page_doc(&["one", "two"])).unwrap();
1453        let page = doc.page(1).unwrap();
1454        let (spans, report) =
1455            extract_spans_reporting(&doc, &page, ReadingOrder::StructureTree).unwrap();
1456        assert_eq!(texts(&spans), ["two"]);
1457        assert_eq!(report.order, ReadingOrder::Content);
1458    }
1459
1460    #[test]
1461    fn a_page_the_tree_does_not_reach_reads_in_content_order() {
1462        // Marked content on the page, but the parent tree keys 0 to nothing.
1463        let doc = tagged_doc(TWO_COLUMNS, "", |b| {
1464            b.object(12, "<< /Nums [7 [13 0 R]] >>");
1465        });
1466        let (spans, order) = ordered(&doc, ReadingOrder::StructureTree);
1467        assert_eq!(spans, ["L2", "R2", "L1", "R1"]);
1468        assert_eq!(order, ReadingOrder::Content);
1469    }
1470
1471    #[test]
1472    fn untagged_content_keeps_its_place_after_the_tagged_content_before_it() {
1473        let content = b"BT /F1 12 Tf \
1474            /P << /MCID 2 >> BDC 1 0 0 1 72 680 Tm (L2) Tj EMC \
1475            /Artifact BMC 1 0 0 1 72 40 Tm (footer) Tj EMC \
1476            /P << /MCID 3 >> BDC 1 0 0 1 300 680 Tm (R2) Tj EMC \
1477            /P << /MCID 0 >> BDC 1 0 0 1 72 700 Tm (L1) Tj EMC \
1478            /P << /MCID 1 >> BDC 1 0 0 1 300 700 Tm (R1) Tj EMC ET";
1479        let doc = tagged_doc(content, "", |_| {});
1480        let (spans, _) = ordered(&doc, ReadingOrder::StructureTree);
1481        assert_eq!(spans, ["L1", "L2", "footer", "R1", "R2"]);
1482    }
1483
1484    #[test]
1485    fn named_marked_content_properties_are_read() {
1486        let content = b"BT /F1 12 Tf \
1487            /P /M2 BDC 1 0 0 1 72 680 Tm (L2) Tj EMC \
1488            /P /M3 BDC 1 0 0 1 300 680 Tm (R2) Tj EMC \
1489            /P /M0 BDC 1 0 0 1 72 700 Tm (L1) Tj EMC \
1490            /P /M1 BDC 1 0 0 1 300 700 Tm (R1) Tj EMC ET";
1491        let props = "/Properties << /M0 << /MCID 0 >> /M1 << /MCID 1 >> \
1492                     /M2 << /MCID 2 >> /M3 << /MCID 3 >> >>";
1493        let doc = tagged_doc(content, props, |_| {});
1494        let (spans, order) = ordered(&doc, ReadingOrder::StructureTree);
1495        assert_eq!(spans, ["L1", "L2", "R1", "R2"]);
1496        assert_eq!(order, ReadingOrder::StructureTree);
1497    }
1498
1499    #[test]
1500    fn a_form_files_its_marked_content_under_its_own_parents_key() {
1501        // The page holds the right column (key 0, ids 1 and 3 in element
1502        // 14); a form with `/StructParents 1` holds the left column, its
1503        // ids 0 and 1 in element 13.
1504        let content = b"BT /F1 12 Tf \
1505            /P << /MCID 3 >> BDC 1 0 0 1 300 680 Tm (R2) Tj EMC \
1506            /P << /MCID 1 >> BDC 1 0 0 1 300 700 Tm (R1) Tj EMC ET /Fx Do";
1507        let doc = tagged_doc(content, "/XObject << /Fx 6 0 R >>", |b| {
1508            b.stream(
1509                6,
1510                "/Type /XObject /Subtype /Form /BBox [0 0 612 792] /StructParents 1",
1511                b"BT /F1 12 Tf \
1512                  /P << /MCID 1 >> BDC 1 0 0 1 72 680 Tm (L2) Tj EMC \
1513                  /P << /MCID 0 >> BDC 1 0 0 1 72 700 Tm (L1) Tj EMC ET",
1514            );
1515            b.object(
1516                12,
1517                "<< /Nums [0 [null 14 0 R null 14 0 R] 1 [13 0 R 13 0 R]] >>",
1518            );
1519            b.object(
1520                13,
1521                "<< /Type /StructElem /S /P /P 11 0 R /Pg 3 0 R /K [0 1] >>",
1522            );
1523        });
1524        let (spans, order) = ordered(&doc, ReadingOrder::StructureTree);
1525        assert_eq!(spans, ["L1", "L2", "R1", "R2"]);
1526        assert_eq!(order, ReadingOrder::StructureTree);
1527    }
1528}