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