Skip to main content

pdfboss_text/
extract.rs

1//! Content-op execution with full text state (Tm/Tlm, Tf, Tc, Tw, Tz, TL,
2//! Ts), glyph advances, and form XObject recursion.
3
4use crate::font::Font;
5use crate::{Ruling, TextSpan};
6use pdfboss_core::content::{ContentOps, Op, TextItem};
7use pdfboss_core::{
8    content_stream_data_with, page_content_with, AsyncObjectSource, Dict, FastMap, Matrix, Name,
9    ObjRef, Object, OcState, Page, Point, Rect,
10};
11use std::collections::HashMap;
12use std::sync::{Arc, Mutex};
13
14/// Maximum form-XObject recursion depth.
15const MAX_FORM_DEPTH: usize = 16;
16
17/// Maximum total form-XObject invocations per page. The depth cap alone
18/// does not bound work: a chain of forms in which each level invokes the
19/// next N times fans out to N^depth executions from a tiny file.
20const MAX_FORM_INVOCATIONS: usize = 4096;
21
22/// Maximum device-space cross-axis deviation over a path segment for it to
23/// count as axis-aligned after the CTM.
24const RULING_AXIS_EPSILON: f32 = 0.5;
25
26/// Minimum device-space length of a ruling. Shorter marks (tick marks,
27/// dashes of glyph decoration) are not table structure.
28const RULING_MIN_LENGTH: f32 = 8.0;
29
30/// Maximum thin dimension of a filled rectangle that reads as a drawn line;
31/// anything fatter is a shaded box, not a ruling.
32const RULING_MAX_FILL_THICKNESS: f32 = 3.0;
33
34/// What extraction could not read. Extraction is lenient the way rendering
35/// is — content that will not fetch, decode, or parse yields no text rather
36/// than an error — and this report is what keeps that leniency accountable:
37/// an empty result with an empty report really is an empty page.
38#[derive(Debug, Clone, Default, PartialEq, Eq)]
39pub struct ExtractReport {
40    /// Every piece of content that yielded no text, in encounter order.
41    pub skipped: Vec<SkippedText>,
42    /// Content the document's optional-content configuration turns off
43    /// (ISO 32000-1 §8.11): one count per `BDC /OC` span whose own
44    /// membership evaluated hidden and per form XObject with a hidden
45    /// `/OC` entry — a counter rather than entries, so a layer-heavy page
46    /// cannot balloon `skipped`. Configured behavior, not a loss:
47    /// [`ExtractReport::is_complete`] ignores it.
48    pub hidden: u64,
49}
50
51impl ExtractReport {
52    /// True when every operator stream was fetched, parsed, and executed —
53    /// nothing the extraction saw was left out of the result. Content the
54    /// document's optional-content configuration hides (`hidden`) was read
55    /// and deliberately excluded, so it does not count against this.
56    pub fn is_complete(&self) -> bool {
57        self.skipped.is_empty()
58    }
59
60    fn record(&mut self, kind: SkippedTextKind, cause: SkipCause) {
61        self.skipped.push(SkippedText { kind, cause });
62    }
63}
64
65/// One piece of content whose text (if any) is missing from the result.
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct SkippedText {
68    pub kind: SkippedTextKind,
69    pub cause: SkipCause,
70}
71
72/// Which kind of operator stream was skipped.
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub enum SkippedTextKind {
75    /// The page's own `/Contents` — the whole page yielded no text.
76    PageContents,
77    /// A form XObject: its text and its entire subtree (nested forms
78    /// included) are absent.
79    Form,
80    /// An XObject name that resolved to nothing usable; whether it held
81    /// text cannot be known.
82    XObject,
83    /// A Type0 font whose `/Encoding` CMap did not resolve (an unknown
84    /// name, or predefined data this build does not carry): its text is
85    /// still extracted under the Identity guess, which usually reads as
86    /// U+FFFD.
87    FontEncoding,
88}
89
90impl std::fmt::Display for SkippedTextKind {
91    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92        f.write_str(match self {
93            SkippedTextKind::PageContents => "the page contents",
94            SkippedTextKind::Form => "a form XObject",
95            SkippedTextKind::XObject => "an XObject",
96            SkippedTextKind::FontEncoding => "a font's CMap encoding",
97        })
98    }
99}
100
101/// Why the stream was skipped.
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub enum SkipCause {
104    /// A `/Filter` this library cannot run — including the two passthrough
105    /// image codecs, whose still-encoded bytes no content parser may read
106    /// (ISO 32000-1 7.4.9). A stream so labelled that nonetheless holds
107    /// valid operators is skipped all the same: the label, not the bytes,
108    /// is what decides, exactly as in rendering.
109    UnsupportedFilter(String),
110    /// The stream would not fetch or decode.
111    Unreadable,
112    /// The decoded bytes did not parse as content operators.
113    Parse,
114    /// The named resource is missing, or is not a stream.
115    Missing,
116    /// Form nesting depth or the per-page invocation budget was exhausted.
117    LimitExceeded,
118}
119
120impl std::fmt::Display for SkipCause {
121    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
122        match self {
123            SkipCause::UnsupportedFilter(name) => write!(f, "unsupported filter /{name}"),
124            SkipCause::Unreadable => f.write_str("stream would not read"),
125            SkipCause::Parse => f.write_str("content would not parse"),
126            SkipCause::Missing => f.write_str("missing resource"),
127            SkipCause::LimitExceeded => f.write_str("form limit exceeded"),
128        }
129    }
130}
131
132/// Loaded fonts shared across page extractions of one document, keyed by the
133/// font dictionary's object reference.
134///
135/// The name→font binding is resource-scoped — `/F1` in one form and `/F1` in
136/// the page resources may be different fonts — so names are never keys here.
137/// The reference is: within a document (and its forks, which share the same
138/// bytes) an object reference resolves to the same dictionary every time, and
139/// loading that dictionary yields the same font. A font held as a direct
140/// dictionary has no reference and is never cached here.
141///
142/// `Send + Sync`, so one cache may serve every worker of a parallel
143/// page walk; the executor consults it at most once per page per distinct
144/// font reference.
145#[derive(Default)]
146pub struct FontCache {
147    fonts: Mutex<HashMap<ObjRef, Arc<Font>>>,
148}
149
150impl FontCache {
151    fn get(&self, r: ObjRef) -> Option<Arc<Font>> {
152        self.fonts.lock().unwrap().get(&r).cloned()
153    }
154
155    /// Stores `font` under `r`, keeping (and returning) an already-present
156    /// entry: concurrent workers may load the same font twice, and the copies
157    /// are interchangeable, so the first one in wins.
158    fn insert(&self, r: ObjRef, font: Arc<Font>) -> Arc<Font> {
159        self.fonts.lock().unwrap().entry(r).or_insert(font).clone()
160    }
161}
162
163/// Maps a fetch/decode error onto its cause, keeping the filter name — the
164/// one detail a caller can act on (the same split rendering reports).
165fn cause_for(error: &pdfboss_core::Error) -> SkipCause {
166    match error {
167        pdfboss_core::Error::UnsupportedFilter(name) => SkipCause::UnsupportedFilter(name.clone()),
168        _ => SkipCause::Unreadable,
169    }
170}
171
172/// Runs the page's content stream (and any form XObjects) and collects
173/// every shown string as a [`TextSpan`] and every axis-aligned drawn line
174/// as a [`Ruling`], each in emission order, along with the report of what
175/// could not be read.
176///
177/// Lenient like rendering: a `/Contents` that will not fetch, decode, or
178/// parse contributes no spans and one report entry, never an error — the
179/// twin of `render_page_reporting`'s blank-page-with-a-report behavior.
180///
181/// The source is taken by value so that the returned future can be `'static`;
182/// `page` is borrowed, which does not stand in the way, because a caller that
183/// owns its page creates the borrow inside its own `async move` block. See
184/// `pdfboss_core::source`'s "Signing a shared algorithm".
185pub async fn page_spans_and_rulings_with<S: AsyncObjectSource>(
186    src: S,
187    page: &Page,
188    fonts: Option<&FontCache>,
189    oc: Option<&OcState>,
190) -> (Vec<TextSpan>, Vec<Ruling>, ExtractReport) {
191    let mut report = ExtractReport::default();
192    let content = match page_content_with(&src, page).await {
193        Ok(content) => content,
194        Err(e) => {
195            report.record(SkippedTextKind::PageContents, cause_for(&e));
196            Vec::new()
197        }
198    };
199    let mut exec = Executor {
200        src: &src,
201        spans: Vec::new(),
202        rulings: Vec::new(),
203        fallback: Arc::new(Font::fallback()),
204        forms: 0,
205        report,
206        loaded: HashMap::new(),
207        shared: fonts,
208        oc,
209        categories: FastMap::default(),
210    };
211    let root = Frame::new(
212        Arc::new(content),
213        vec![Arc::new(page.resources.clone())],
214        GState::new(),
215        0,
216        (0, 0),
217    );
218    exec.run(root).await;
219    let mut spans = exec.spans;
220    for span in &mut spans {
221        span.page = page.index;
222    }
223    // The decoration pass touches only pages that draw horizontal rulings,
224    // and each span consults only the rulings inside its vertical band —
225    // sorting once keeps a page full of table borders from turning the
226    // pass into spans × rulings work.
227    let mut horizontals: Vec<&Ruling> = exec
228        .rulings
229        .iter()
230        .filter(|r| r.start.y == r.end.y)
231        .collect();
232    if !horizontals.is_empty() {
233        horizontals.sort_by(|a, b| a.start.y.total_cmp(&b.start.y));
234        for span in &mut spans {
235            mark_underline_and_strikethrough(span, &horizontals);
236        }
237    }
238    drop(horizontals);
239    (spans, exec.rulings, exec.report)
240}
241
242/// How far below the baseline (in fractions of the effective size) an
243/// underline may sit, and the slack above it for lines drawn exactly on
244/// the baseline.
245const UNDERLINE_BELOW: f32 = 0.3;
246const UNDERLINE_ABOVE: f32 = 0.05;
247
248/// The x-height band (in fractions of the effective size above the
249/// baseline) a strikethrough crosses.
250const STRIKETHROUGH_LOW: f32 = 0.15;
251const STRIKETHROUGH_HIGH: f32 = 0.6;
252
253/// The fraction of a span's width a ruling must cover to decorate it: a
254/// neighbour's underline running past a word boundary is not this span's.
255const DECORATED_MIN_OVERLAP: f32 = 0.6;
256
257/// Sets `underline`/`strikethrough` from the page's horizontal rulings
258/// (pre-sorted by y): underline when one sits just below the baseline
259/// covering most of the span, strikethrough when one crosses the x-height
260/// band. Vertical writing is left unmarked — its decorations are vertical
261/// lines beside the text, which are indistinguishable from column rules
262/// here.
263fn mark_underline_and_strikethrough(span: &mut TextSpan, horizontals: &[&Ruling]) {
264    if span.vertical || span.size <= 0.0 {
265        return;
266    }
267    let width = span.bbox.x1 - span.bbox.x0;
268    if width <= 0.0 {
269        return;
270    }
271    let low = span.y - UNDERLINE_BELOW * span.size;
272    let high = span.y + STRIKETHROUGH_HIGH * span.size;
273    let first = horizontals.partition_point(|r| r.start.y < low);
274    for r in &horizontals[first..] {
275        if r.start.y > high {
276            break;
277        }
278        let overlap = r.end.x.min(span.bbox.x1) - r.start.x.max(span.bbox.x0);
279        if overlap < DECORATED_MIN_OVERLAP * width {
280            continue;
281        }
282        let above = r.start.y - span.y;
283        if above <= UNDERLINE_ABOVE * span.size {
284            span.underline = true;
285        }
286        if above >= STRIKETHROUGH_LOW * span.size {
287            span.strikethrough = true;
288        }
289    }
290}
291
292/// The graphics-state parameters text extraction cares about. Saved and
293/// restored by `q`/`Q`; carried into form XObjects.
294#[derive(Clone)]
295struct GState {
296    ctm: Matrix,
297    char_spacing: f32,
298    word_spacing: f32,
299    /// `Tz / 100`.
300    horiz_scale: f32,
301    leading: f32,
302    rise: f32,
303    font: Option<Arc<Font>>,
304    font_name: String,
305    size: f32,
306    /// `Tr` (ISO 32000-1 Table 106); modes 3 and 7 paint nothing.
307    render_mode: i32,
308    /// Fill color as RGB; `None` inside a pattern fill.
309    fill_color: Option<(f32, f32, f32)>,
310    /// `w` and ExtGState `/LW`; scales rulings' stroke width.
311    line_width: f32,
312}
313
314impl GState {
315    fn new() -> GState {
316        GState {
317            ctm: Matrix::identity(),
318            char_spacing: 0.0,
319            word_spacing: 0.0,
320            horiz_scale: 1.0,
321            leading: 0.0,
322            rise: 0.0,
323            font: None,
324            font_name: String::new(),
325            size: 0.0,
326            render_mode: 0,
327            fill_color: Some((0.0, 0.0, 0.0)),
328            line_width: 1.0,
329        }
330    }
331}
332
333/// Reads color components by count — 1 gray, 3 RGB, 4 CMYK, clamped to
334/// `[0, 1]` — the approximation span colors carry for spaces whose
335/// transform extraction does not run. Any other count is no color.
336fn components_color(comps: &[f32]) -> Option<(f32, f32, f32)> {
337    let c = |v: f32| {
338        if v.is_finite() {
339            v.clamp(0.0, 1.0)
340        } else {
341            0.0
342        }
343    };
344    match comps {
345        [v] => Some((c(*v), c(*v), c(*v))),
346        [r, g, b] => Some((c(*r), c(*g), c(*b))),
347        [cy, m, y, k] => Some((
348            (1.0 - c(*cy)) * (1.0 - c(*k)),
349            (1.0 - c(*m)) * (1.0 - c(*k)),
350            (1.0 - c(*y)) * (1.0 - c(*k)),
351        )),
352        _ => None,
353    }
354}
355
356/// True when every matrix component is finite.
357fn finite(m: &Matrix) -> bool {
358    [m.a, m.b, m.c, m.d, m.e, m.f].iter().all(|v| v.is_finite())
359}
360
361/// Isotropic scale factor of `m`: `sqrt(|det|)`, 1.0 when degenerate — the
362/// same rule rendering uses to carry a line width into device space.
363fn ctm_scale(m: &Matrix) -> f32 {
364    let det = (m.a * m.d - m.b * m.c).abs();
365    if det.is_finite() && det > 0.0 {
366        return det.sqrt();
367    }
368    1.0
369}
370
371/// Classifies one device-space segment: `Some` when it is axis-aligned
372/// within [`RULING_AXIS_EPSILON`] and at least [`RULING_MIN_LENGTH`] long.
373fn ruling_from_segment(a: Point, b: Point, width: f32) -> Option<Ruling> {
374    if [a.x, a.y, b.x, b.y, width].iter().any(|v| !v.is_finite()) {
375        return None;
376    }
377    let dx = (b.x - a.x).abs();
378    let dy = (b.y - a.y).abs();
379    if dy <= RULING_AXIS_EPSILON && dx >= RULING_MIN_LENGTH {
380        let y = (a.y + b.y) / 2.0;
381        return Some(Ruling {
382            start: Point::new(a.x.min(b.x), y),
383            end: Point::new(a.x.max(b.x), y),
384            width,
385        });
386    }
387    if dx <= RULING_AXIS_EPSILON && dy >= RULING_MIN_LENGTH {
388        let x = (a.x + b.x) / 2.0;
389        return Some(Ruling {
390            start: Point::new(x, a.y.min(b.y)),
391            end: Point::new(x, a.y.max(b.y)),
392            width,
393        });
394    }
395    None
396}
397
398/// The centerline of a thin filled rectangle: a closed 4-vertex subpath in
399/// device space whose edges are all axis-aligned, with a thin dimension at
400/// most [`RULING_MAX_FILL_THICKNESS`] and a long dimension at least
401/// [`RULING_MIN_LENGTH`]. Width is 0.0 — a fill has no stroke width.
402fn filled_rect_ruling(device: &[Point]) -> Option<Ruling> {
403    let corners = match device {
404        [a, b, c, d] => [*a, *b, *c, *d],
405        [a, b, c, d, e]
406            if (e.x - a.x).abs() <= RULING_AXIS_EPSILON
407                && (e.y - a.y).abs() <= RULING_AXIS_EPSILON =>
408        {
409            [*a, *b, *c, *d]
410        }
411        _ => return None,
412    };
413    if corners.iter().any(|p| !p.x.is_finite() || !p.y.is_finite()) {
414        return None;
415    }
416    let axis_aligned = |a: Point, b: Point| {
417        (b.x - a.x).abs() <= RULING_AXIS_EPSILON || (b.y - a.y).abs() <= RULING_AXIS_EPSILON
418    };
419    for i in 0..4 {
420        if !axis_aligned(corners[i], corners[(i + 1) % 4]) {
421            return None;
422        }
423    }
424    let x0 = corners.iter().map(|p| p.x).fold(f32::INFINITY, f32::min);
425    let x1 = corners
426        .iter()
427        .map(|p| p.x)
428        .fold(f32::NEG_INFINITY, f32::max);
429    let y0 = corners.iter().map(|p| p.y).fold(f32::INFINITY, f32::min);
430    let y1 = corners
431        .iter()
432        .map(|p| p.y)
433        .fold(f32::NEG_INFINITY, f32::max);
434    let w = x1 - x0;
435    let h = y1 - y0;
436    if w.min(h) > RULING_MAX_FILL_THICKNESS || w.max(h) < RULING_MIN_LENGTH {
437        return None;
438    }
439    if h <= w {
440        let y = (y0 + y1) / 2.0;
441        return Some(Ruling {
442            start: Point::new(x0, y),
443            end: Point::new(x1, y),
444            width: 0.0,
445        });
446    }
447    let x = (x0 + x1) / 2.0;
448    Some(Ruling {
449        start: Point::new(x, y0),
450        end: Point::new(x, y1),
451        width: 0.0,
452    })
453}
454
455/// One subpath under construction, in the frame's untransformed user space.
456///
457/// A curve operator poisons it — glyph outlines and diagrams are not
458/// rulings — but still advances the endpoint, so a following `l` extends
459/// the poisoned subpath instead of corrupting the next one.
460struct Subpath {
461    points: Vec<Point>,
462    closed: bool,
463    poisoned: bool,
464}
465
466/// One suspended operator stream: what to execute, how far it has got, and
467/// every piece of state that stream owns.
468///
469/// This is what the recursion into a form XObject became. A recursive `async fn`
470/// has to box itself, and coercing that box to a `Send` future requires
471/// `S: Sync` — which `Immediate<&Document>` cannot supply, so boxing would cost
472/// the synchronous caller the shared implementation entirely. A stack of these
473/// uses no `dyn`, so auto traits stay inferred per instantiation: the future is
474/// `Send` over an asynchronous source and merely non-`Send` over a synchronous
475/// one, which is correct for both.
476struct Frame {
477    /// The stream's decoded bytes; operators are pull-parsed from it one at
478    /// a time, never materialized as a vector. Shared rather than owned so
479    /// a handle can be held while the frame stack is pushed onto — cloned
480    /// once per visit to the frame, never per operator.
481    content: Arc<Vec<u8>>,
482    /// Resource dictionaries, innermost first. Owned, because a form's own
483    /// `/Resources` is read out of its stream dictionary and so outlives nothing
484    /// already on the stack.
485    chain: Vec<Arc<Dict>>,
486    /// Byte offset of the next operator: the pull parser's whole state at
487    /// an operator boundary, so a suspended frame resumes from it exactly.
488    pos: usize,
489    /// Lengths of the executor's spans and rulings when this frame was
490    /// created. A stream that stops parsing mid-way contributes nothing —
491    /// exactly as it contributed nothing when the whole stream was parsed
492    /// up front — so its error truncates both back to these marks.
493    spans_mark: usize,
494    rulings_mark: usize,
495    /// Form-XObject nesting depth, checked against `MAX_FORM_DEPTH`.
496    depth: usize,
497    gs: GState,
498    /// The `q`/`Q` stack, per operator stream.
499    saved: Vec<GState>,
500    tm: Matrix,
501    tlm: Matrix,
502    /// Path accumulation for rulings, per operator stream like `tm`/`tlm`:
503    /// the last element is the active subpath. Not part of [`GState`] —
504    /// `q`/`Q` do not save or restore the path.
505    subpaths: Vec<Subpath>,
506    /// Loaded fonts, per operator stream: every form invocation starts with an
507    /// empty cache, as it did when each invocation was its own `run` call.
508    fonts: HashMap<String, Arc<Font>>,
509    /// The marked-content stack: one entry per open `BMC`/`BDC`, `true` for
510    /// a `BDC /OC` span the optional-content configuration hides. Per frame
511    /// like `tm`/`tlm` — `BMC`/`EMC` nesting is not `q`/`Q` scoped and never
512    /// crosses a stream boundary. A stray `EMC` pops nothing.
513    marks: Vec<bool>,
514}
515
516impl Frame {
517    fn new(
518        content: Arc<Vec<u8>>,
519        chain: Vec<Arc<Dict>>,
520        gs: GState,
521        depth: usize,
522        (spans_mark, rulings_mark): (usize, usize),
523    ) -> Frame {
524        Frame {
525            content,
526            chain,
527            pos: 0,
528            spans_mark,
529            rulings_mark,
530            depth,
531            gs,
532            saved: Vec::new(),
533            tm: Matrix::identity(),
534            tlm: Matrix::identity(),
535            subpaths: Vec::new(),
536            fonts: HashMap::new(),
537            marks: Vec::new(),
538        }
539    }
540
541    /// Whether the frame is inside a marked-content span the
542    /// optional-content configuration hides: state still executes, but the
543    /// span's text and rulings are excluded from the result.
544    fn suppressed(&self) -> bool {
545        self.marks.iter().any(|hidden| *hidden)
546    }
547
548    fn move_to(&mut self, x: f32, y: f32) {
549        self.subpaths.push(Subpath {
550            points: vec![Point::new(x, y)],
551            closed: false,
552            poisoned: false,
553        });
554    }
555
556    /// Extends the active subpath by one segment. Appending to a closed
557    /// subpath begins a new one at the closed subpath's starting point
558    /// (ISO 32000-1 §8.5.2.1); with no current point at all the operator
559    /// is ignored.
560    fn segment_to(&mut self, x: f32, y: f32, poisons: bool) {
561        let Some(active) = self.subpaths.last_mut() else {
562            return;
563        };
564        if active.closed {
565            let start = active.points[0];
566            self.subpaths.push(Subpath {
567                points: vec![start, Point::new(x, y)],
568                closed: false,
569                poisoned: poisons,
570            });
571            return;
572        }
573        active.points.push(Point::new(x, y));
574        if poisons {
575            active.poisoned = true;
576        }
577    }
578
579    fn close_subpath(&mut self) {
580        if let Some(active) = self.subpaths.last_mut() {
581            active.closed = true;
582        }
583    }
584
585    /// Appends `re` as the closed subpath rendering's path builder makes of
586    /// it: the `(x, y)` corner, then the three others in `re`'s own order.
587    /// Raw corners even for negative `w`/`h` — the current point a
588    /// follow-on segment continues from is `(x, y)` — normalization happens
589    /// when the committed rectangle's bounding box is measured.
590    fn rect_subpath(&mut self, x: f32, y: f32, w: f32, h: f32) {
591        self.subpaths.push(Subpath {
592            points: vec![
593                Point::new(x, y),
594                Point::new(x + w, y),
595                Point::new(x + w, y + h),
596                Point::new(x, y + h),
597            ],
598            closed: true,
599            poisoned: false,
600        });
601    }
602}
603
604struct Executor<'a, S> {
605    src: &'a S,
606    spans: Vec<TextSpan>,
607    rulings: Vec<Ruling>,
608    fallback: Arc<Font>,
609    /// Form-XObject invocations so far, checked against
610    /// `MAX_FORM_INVOCATIONS`.
611    forms: usize,
612    /// What could not be read; carried out alongside the spans.
613    report: ExtractReport,
614    /// Fonts loaded during this page walk, keyed by their dictionary's
615    /// object reference — shared across every frame the walk pushes, so a
616    /// form invoked many times loads its fonts once. Never keyed by name:
617    /// that binding is per resource scope and stays in [`Frame::fonts`].
618    loaded: HashMap<ObjRef, Arc<Font>>,
619    /// Fonts carried across page walks, when the caller extracts a whole
620    /// document and passes one [`FontCache`] to every page.
621    shared: Option<&'a FontCache>,
622    /// The document's optional-content visibility; `None` extracts every
623    /// layer.
624    oc: Option<&'a OcState>,
625    /// Resolved resource-category dictionaries, keyed by the resource
626    /// dictionary's allocation address plus a category slot. Resolving a
627    /// category hands out a deep clone of the whole dictionary, and `gs`
628    /// and `Do` used to pay that per operator — a third of a form-heavy
629    /// corpus extraction pass. `None` remembers a category the dictionary
630    /// does not carry (or that is not a dictionary). The held [`Arc`] keeps
631    /// the resource dictionary's allocation alive, so the address cannot be
632    /// reused while its entry exists. See [`MAX_CATEGORY_CACHE`].
633    categories: FastMap<(usize, u8), ResolvedCategory>,
634}
635
636/// One memoized resource category: the resource dictionary whose allocation
637/// the entry pins (its address is the cache key) and its resolved category
638/// dictionary, or `None` for a remembered absence.
639type ResolvedCategory = (Arc<Dict>, Option<Arc<Dict>>);
640
641/// Upper bound on memoized (resource dictionary, category) pairs per page
642/// walk; past it, lookups resolve uncached, so a hostile file minting
643/// resource dictionaries per form invocation caps the memo's memory.
644const MAX_CATEGORY_CACHE: usize = 4096;
645
646/// The memo slot for a resource category name, [`None`] for a category no
647/// caller looks up hot (left uncached rather than given an open-ended key).
648fn category_slot(category: &str) -> Option<u8> {
649    match category {
650        "ExtGState" => Some(0),
651        "XObject" => Some(1),
652        _ => None,
653    }
654}
655
656impl<S: AsyncObjectSource> Executor<'_, S> {
657    /// Looks up `/category/name` in the resource chain, innermost dictionary
658    /// first (ISO 32000 §7.8.3).
659    ///
660    /// A nested form's own `/Resources` shadows its caller's for the names it
661    /// defines and falls through for the ones it does not. This mirrors the
662    /// renderer's `find_res`; the two crates must agree on which resource a
663    /// name refers to, or the same file extracts different text than it
664    /// paints.
665    async fn find_res(
666        &mut self,
667        chain: &[Arc<Dict>],
668        category: &str,
669        name: &str,
670    ) -> Option<Object> {
671        let slot = category_slot(category);
672        for res in chain {
673            let key = (Arc::as_ptr(res) as usize, slot.unwrap_or(0));
674            let remembered = slot.and_then(|_| {
675                self.categories
676                    .get(&key)
677                    .map(|(_, category)| category.clone())
678            });
679            let resolved = match remembered {
680                Some(dict) => dict,
681                None => {
682                    let dict = match res.get(category) {
683                        Some(cat) => match self.src.resolve(cat).await {
684                            Ok(Object::Dict(d)) => Some(Arc::new(d)),
685                            _ => None,
686                        },
687                        None => None,
688                    };
689                    if slot.is_some() && self.categories.len() < MAX_CATEGORY_CACHE {
690                        self.categories.insert(key, (Arc::clone(res), dict.clone()));
691                    }
692                    dict
693                }
694            };
695            let Some(dict) = resolved else {
696                continue;
697            };
698            if let Some(value) = dict.get(name) {
699                if let Ok(obj) = self.src.resolve(value).await {
700                    return Some(obj);
701                }
702            }
703        }
704        None
705    }
706
707    /// Loads (with per-stream caching) the font resource `name` from the
708    /// active resource chain, falling back to a default font.
709    async fn font(
710        &mut self,
711        chain: &[Arc<Dict>],
712        name: &str,
713        cache: &mut HashMap<String, Arc<Font>>,
714    ) -> Arc<Font> {
715        if let Some(f) = cache.get(name) {
716            return f.clone();
717        }
718        let loaded = self.load_font(chain, name).await;
719        if !loaded.simple && !loaded.encoding_known {
720            self.report
721                .record(SkippedTextKind::FontEncoding, SkipCause::Missing);
722        }
723        cache.insert(name.to_string(), loaded.clone());
724        loaded
725    }
726
727    /// Resolves `name` through the chain with [`Self::find_res`]'s exact
728    /// semantics — innermost scope first, a name whose value will not resolve
729    /// falls through to the outer scopes, the first value that resolves wins
730    /// whatever it turns out to be — and loads the font it lands on.
731    ///
732    /// A value held as an indirect reference is answered from the caches
733    /// before it is even resolved: within one document a reference resolves
734    /// to the same dictionary every time, so an already-loaded font is the
735    /// same font. Anything else — a direct dictionary, a value that is no
736    /// dictionary at all (the fallback), an exhausted chain (also the
737    /// fallback) — is loaded per use, cached only under its name in the
738    /// calling frame.
739    async fn load_font(&mut self, chain: &[Arc<Dict>], name: &str) -> Arc<Font> {
740        for res in chain {
741            let Some(cat) = res.get("Font") else {
742                continue;
743            };
744            let Ok(Object::Dict(dict)) = self.src.resolve(cat).await else {
745                continue;
746            };
747            let Some(value) = dict.get(name) else {
748                continue;
749            };
750            let key = match value {
751                Object::Ref(r) => Some(*r),
752                _ => None,
753            };
754            if let Some(f) = key.and_then(|r| self.hit(r)) {
755                return f;
756            }
757            let Ok(obj) = self.src.resolve(value).await else {
758                continue;
759            };
760            let Some(font_dict) = obj.as_dict() else {
761                // The name resolved to something that is not a dictionary:
762                // the fallback font keeps the text extractable rather than
763                // failing the page.
764                return self.fallback.clone();
765            };
766            let loaded = Arc::new(Font::load(self.src, font_dict).await);
767            return match key {
768                Some(r) => self.remember(r, loaded),
769                None => loaded,
770            };
771        }
772        self.fallback.clone()
773    }
774
775    /// An already-loaded font for the dictionary `r` refers to, if any walk
776    /// of this document has loaded it.
777    fn hit(&mut self, r: ObjRef) -> Option<Arc<Font>> {
778        if let Some(f) = self.loaded.get(&r) {
779            return Some(f.clone());
780        }
781        let f = self.shared?.get(r)?;
782        self.loaded.insert(r, f.clone());
783        Some(f)
784    }
785
786    /// Records a freshly loaded font under its dictionary's reference, in
787    /// this walk's cache and in the document-wide one when present.
788    fn remember(&mut self, r: ObjRef, font: Arc<Font>) -> Arc<Font> {
789        let font = match self.shared {
790            Some(shared) => shared.insert(r, font),
791            None => font,
792        };
793        self.loaded.insert(r, font.clone());
794        font
795    }
796
797    /// Executes an operator stream and every form XObject it invokes.
798    ///
799    /// A form invocation pushes a frame and leaves the inner loop, so the form
800    /// runs to completion before its caller's next operator — the same
801    /// depth-first order the recursive version emitted, which is what keeps span
802    /// order identical. Nothing is owed on the way back out: unlike the
803    /// renderer, this executor has no state to restore after a nested stream.
804    async fn run(&mut self, root: Frame) {
805        let mut frames = vec![root];
806        // The running frame is held as a local rather than indexed in place, which
807        // costs a move per visit and saves cloning the resource chain and the
808        // graphics state on every `Do`. It is not a speed fix: reaching the frame
809        // through `frames[top]` on each operator was measured against this shape
810        // and the two are indistinguishable on `extract_text_warm_500_lines`.
811        'frames: while let Some(mut frame) = frames.pop() {
812            // Cloned once per visit rather than once per operator: the handle has
813            // to outlive the `&mut frame` borrows below.
814            let content = Arc::clone(&frame.content);
815            let mut ops = ContentOps::at(&content, frame.pos);
816            loop {
817                let op = match ops.next_op() {
818                    Ok(Some((op, _))) => op,
819                    Ok(None) => break,
820                    Err(_) => {
821                        // The stream stops parsing mid-way: it contributes
822                        // nothing, exactly as it contributed nothing when
823                        // the whole stream was parsed up front.
824                        self.spans.truncate(frame.spans_mark);
825                        self.rulings.truncate(frame.rulings_mark);
826                        let kind = if frame.depth == 0 {
827                            SkippedTextKind::PageContents
828                        } else {
829                            SkippedTextKind::Form
830                        };
831                        self.report.record(kind, SkipCause::Parse);
832                        continue 'frames;
833                    }
834                };
835                match &op {
836                    Op::SetFont(name, size) => {
837                        let loaded = self.font(&frame.chain, &name.0, &mut frame.fonts).await;
838                        frame.gs.font = Some(loaded);
839                        frame.gs.font_name = name.0.clone();
840                        frame.gs.size = *size;
841                    }
842                    Op::SetExtGState(name) => {
843                        if let Some(lw) = self.ext_gstate_line_width(&frame.chain, &name.0).await {
844                            frame.gs.line_width = lw;
845                        }
846                    }
847                    Op::XObject(name) => {
848                        // Inside a hidden span the whole invocation is part
849                        // of the span: never entered, never reported.
850                        if frame.suppressed() {
851                            continue;
852                        }
853                        let entered = self
854                            .form_frame(&name.0, &frame.chain, &frame.gs, frame.depth)
855                            .await;
856                        if let Some(child) = entered {
857                            // The caller goes back underneath its form: the form
858                            // runs to completion, then the caller resumes at the
859                            // operator after its `Do`. That is the depth-first
860                            // order the recursive version emitted.
861                            frame.pos = ops.pos();
862                            frames.push(frame);
863                            frames.push(child);
864                            continue 'frames;
865                        }
866                    }
867                    Op::BeginMarkedContentProps(tag, props) => {
868                        let hidden = self.marked_hidden(tag, props, &frame.chain).await;
869                        if hidden {
870                            self.report.hidden += 1;
871                        }
872                        frame.marks.push(hidden);
873                    }
874                    op => self.step(&mut frame, op),
875                }
876            }
877        }
878    }
879
880    /// The `/LW` entry of the named `/ExtGState` resource (ISO 32000-1
881    /// Table 58) — the one ExtGState parameter ruling extraction reads.
882    /// Negative values are ignored, matching the renderer; non-finite ones
883    /// too, because an infinite line width would otherwise silently drop
884    /// every later stroked ruling at the segment gate.
885    async fn ext_gstate_line_width(&mut self, chain: &[Arc<Dict>], name: &str) -> Option<f32> {
886        let resolved = self.find_res(chain, "ExtGState", name).await?;
887        let dict = resolved.as_dict()?;
888        let lw = self.src.resolve(dict.get("LW")?).await.ok()?.as_f64()? as f32;
889        (lw.is_finite() && lw >= 0.0).then_some(lw)
890    }
891
892    /// Applies one operator that needs no I/O — everything except `Tf`,
893    /// `gs`, and `Do`. `q`/`Q` and `cm` maintain the CTM; text operators
894    /// maintain Tm/Tlm; shown strings become spans; path operators feed the
895    /// frame's subpaths and paint operators commit them as rulings.
896    fn step(&mut self, frame: &mut Frame, op: &Op) {
897        match op {
898            Op::Save => frame.saved.push(frame.gs.clone()),
899            Op::Restore => {
900                if let Some(saved) = frame.saved.pop() {
901                    frame.gs = saved;
902                }
903            }
904            Op::Concat(m) if finite(m) => frame.gs.ctm = m.concat(frame.gs.ctm),
905            Op::BeginText => {
906                frame.tm = Matrix::identity();
907                frame.tlm = Matrix::identity();
908            }
909            Op::SetCharSpacing(v) => frame.gs.char_spacing = *v,
910            Op::SetWordSpacing(v) => frame.gs.word_spacing = *v,
911            Op::SetHorizScaling(v) => frame.gs.horiz_scale = v / 100.0,
912            Op::SetLeading(v) => frame.gs.leading = *v,
913            Op::SetTextRise(v) => frame.gs.rise = *v,
914            Op::SetTextRender(mode) => frame.gs.render_mode = *mode,
915            Op::SetFillGray(v) => frame.gs.fill_color = components_color(&[*v]),
916            Op::SetFillRGB(r, g, b) => frame.gs.fill_color = components_color(&[*r, *g, *b]),
917            Op::SetFillCMYK(c, m, y, k) => {
918                frame.gs.fill_color = components_color(&[*c, *m, *y, *k])
919            }
920            // Selecting a fill space resets the fill color to the space's
921            // initial color (ISO 32000-1 §8.6.8): black everywhere but
922            // Pattern, which has no single color.
923            Op::SetFillColorSpace(name) => {
924                frame.gs.fill_color = (name.0 != "Pattern").then_some((0.0, 0.0, 0.0));
925            }
926            Op::SetFillColor(comps) => frame.gs.fill_color = components_color(comps),
927            Op::SetFillColorN(comps, pattern) => {
928                frame.gs.fill_color = if pattern.is_some() {
929                    None
930                } else {
931                    components_color(comps)
932                };
933            }
934            Op::TextMove(tx, ty) => {
935                frame.tlm = Matrix::translate(*tx, *ty).concat(frame.tlm);
936                frame.tm = frame.tlm;
937            }
938            Op::TextMoveSetLeading(tx, ty) => {
939                frame.gs.leading = -ty;
940                frame.tlm = Matrix::translate(*tx, *ty).concat(frame.tlm);
941                frame.tm = frame.tlm;
942            }
943            Op::SetTextMatrix(m) if finite(m) => {
944                frame.tm = *m;
945                frame.tlm = *m;
946            }
947            Op::TextNextLine => {
948                frame.tlm = Matrix::translate(0.0, -frame.gs.leading).concat(frame.tlm);
949                frame.tm = frame.tlm;
950            }
951            Op::ShowText(s) => self.emit(frame, s),
952            Op::ShowTextAdjusted(items) => {
953                // In vertical writing the TJ offset moves ty, and Tz does
954                // not apply to vertical displacements (ISO 32000-1 §9.4.4).
955                let vertical = frame.gs.font.as_ref().is_some_and(|f| f.vertical);
956                for item in items {
957                    match item {
958                        TextItem::Str(s) => self.emit(frame, s),
959                        TextItem::Offset(n) => {
960                            let (tx, ty) = if vertical {
961                                (0.0, -n / 1000.0 * frame.gs.size)
962                            } else {
963                                (-n / 1000.0 * frame.gs.size * frame.gs.horiz_scale, 0.0)
964                            };
965                            if tx.is_finite() && ty.is_finite() {
966                                frame.tm = Matrix::translate(tx, ty).concat(frame.tm);
967                            }
968                        }
969                    }
970                }
971            }
972            Op::NextLineShowText(s) => {
973                frame.tlm = Matrix::translate(0.0, -frame.gs.leading).concat(frame.tlm);
974                frame.tm = frame.tlm;
975                self.emit(frame, s);
976            }
977            Op::NextLineShowTextSpaced(aw, ac, s) => {
978                frame.gs.word_spacing = *aw;
979                frame.gs.char_spacing = *ac;
980                frame.tlm = Matrix::translate(0.0, -frame.gs.leading).concat(frame.tlm);
981                frame.tm = frame.tlm;
982                self.emit(frame, s);
983            }
984            Op::SetLineWidth(w) => {
985                if w.is_finite() && *w >= 0.0 {
986                    frame.gs.line_width = *w;
987                }
988            }
989            Op::MoveTo(x, y) => frame.move_to(*x, *y),
990            Op::LineTo(x, y) => frame.segment_to(*x, *y, false),
991            Op::CurveTo(_, _, _, _, x, y) | Op::CurveToV(_, _, x, y) | Op::CurveToY(_, _, x, y) => {
992                frame.segment_to(*x, *y, true)
993            }
994            Op::ClosePath => frame.close_subpath(),
995            Op::Rect(x, y, w, h) => frame.rect_subpath(*x, *y, *w, *h),
996            Op::Stroke => self.commit_rulings(frame, true, false),
997            Op::CloseStroke => {
998                frame.close_subpath();
999                self.commit_rulings(frame, true, false);
1000            }
1001            Op::Fill | Op::FillEvenOdd => self.commit_rulings(frame, false, true),
1002            Op::FillStroke | Op::FillStrokeEvenOdd => self.commit_rulings(frame, true, true),
1003            Op::CloseFillStroke | Op::CloseFillStrokeEvenOdd => {
1004                frame.close_subpath();
1005                self.commit_rulings(frame, true, true);
1006            }
1007            // `W`/`W*` never commit by themselves: the paint operator that
1008            // must follow them does, and after a clip that operator is `n`.
1009            Op::EndPath => frame.subpaths.clear(),
1010            // Marked content: every open is pushed (hidden or not) so `EMC`
1011            // stays balanced; `BDC` needs I/O and is handled in `run`.
1012            Op::BeginMarkedContent(_) => frame.marks.push(false),
1013            Op::EndMarkedContent => {
1014                frame.marks.pop();
1015            }
1016            // Text render mode 3 (invisible) is still extracted — the
1017            // document shows that text, a viewer just paints it blank.
1018            // Optional content is the opposite species: the document
1019            // declares the layer off, so a hidden span IS skipped (see
1020            // `emit`). `Tr` and everything else is a no-op here.
1021            _ => {}
1022        }
1023    }
1024
1025    /// Whether a `BDC` opens a span the optional-content configuration
1026    /// hides: only `/OC` tags gate anything, and with no configuration (or
1027    /// anything unresolvable) every span is visible.
1028    async fn marked_hidden(&self, tag: &Name, props: &Object, chain: &[Arc<Dict>]) -> bool {
1029        let Some(oc) = self.oc else {
1030            return false;
1031        };
1032        if tag.0 != "OC" {
1033            return false;
1034        }
1035        !oc.props_visible_with(self.src, chain, props).await
1036    }
1037
1038    /// Commits the accumulated path on a painting operator and clears it.
1039    /// Stroked subpaths yield one ruling per axis-aligned segment at the
1040    /// device-space line width; filled subpaths yield the centerline of a
1041    /// thin axis-aligned rectangle. Poisoned subpaths yield nothing.
1042    fn commit_rulings(&mut self, frame: &mut Frame, stroke: bool, fill: bool) {
1043        // A hidden span's lines are configured away with its text; the
1044        // path still clears, exactly as a paint operator leaves it.
1045        if frame.suppressed() {
1046            frame.subpaths.clear();
1047            return;
1048        }
1049        let ctm = frame.gs.ctm;
1050        let width = frame.gs.line_width * ctm_scale(&ctm);
1051        for sub in frame.subpaths.drain(..) {
1052            if sub.poisoned {
1053                continue;
1054            }
1055            let device: Vec<Point> = sub.points.iter().map(|p| ctm.apply(*p)).collect();
1056            if stroke {
1057                let segments = device.windows(2).map(|pair| (pair[0], pair[1]));
1058                // A closed 2-point subpath draws one doubled edge, not two.
1059                let closing =
1060                    (sub.closed && device.len() > 2).then(|| (device[device.len() - 1], device[0]));
1061                for (a, b) in segments.chain(closing) {
1062                    if let Some(ruling) = ruling_from_segment(a, b, width) {
1063                        self.rulings.push(ruling);
1064                    }
1065                }
1066            }
1067            if fill {
1068                if let Some(ruling) = filled_rect_ruling(&device) {
1069                    self.rulings.push(ruling);
1070                }
1071            }
1072        }
1073    }
1074
1075    /// Shows one string, appending the span it produces (if any) to the
1076    /// page. Inside a hidden optional-content span the advance still runs —
1077    /// `show` moves the text matrix either way — but the text is excluded.
1078    fn emit(&mut self, frame: &mut Frame, bytes: &[u8]) {
1079        let suppressed = frame.suppressed();
1080        if let Some(span) = self.show(&frame.gs, &mut frame.tm, bytes) {
1081            if !suppressed {
1082                self.spans.push(span);
1083            }
1084        }
1085    }
1086
1087    /// Shows one string: decodes each code, advances the text matrix by
1088    /// `(w/1000·Tfs + Tc + Tw[code 32]) · Tz/100`, and returns a span whose
1089    /// origin is `(0, Ts)` under `Tm · CTM`. `None` when there is nothing worth
1090    /// recording — no decoded text, or an origin that is not finite.
1091    ///
1092    /// Returning the span rather than pushing it is what lets the active font be
1093    /// borrowed instead of cloned. `Tj` is the hottest operator on a text page and
1094    /// the handle is an `Arc` now, so cloning it there costs two atomic updates
1095    /// per shown string; borrowing `self.fallback` is only possible while nothing
1096    /// holds `&mut self.spans`.
1097    fn show(&self, gs: &GState, tm: &mut Matrix, bytes: &[u8]) -> Option<TextSpan> {
1098        let font: &Font = gs.font.as_deref().unwrap_or(&self.fallback);
1099        let start = tm.concat(gs.ctm);
1100        let origin = start.apply(Point { x: 0.0, y: gs.rise });
1101        // Device-space font size: the length of the text-space vertical
1102        // unit vector scaled by Tfs under Tm·CTM.
1103        let size = gs.size * (start.c * start.c + start.d * start.d).sqrt();
1104        // One byte per code is the floor on the decoded length, so this
1105        // reservation removes the per-glyph regrowth of typical text.
1106        let mut text = String::with_capacity(bytes.len());
1107        for cc in font.codes_in(bytes) {
1108            font.decode_into(cc, &mut text);
1109            let word = if font.is_space(cc) {
1110                gs.word_spacing
1111            } else {
1112                0.0
1113            };
1114            // Vertical writing advances ty by w1 (negative for downward),
1115            // with Tz not applied to vertical displacements (ISO 32000-1
1116            // §9.4.4); horizontal advances tx as before.
1117            let adv = if font.vertical {
1118                font.vwidth(cc) / 1000.0 * gs.size + gs.char_spacing + word
1119            } else {
1120                (font.width(cc) / 1000.0 * gs.size + gs.char_spacing + word) * gs.horiz_scale
1121            };
1122            if adv.is_finite() {
1123                let (tx, ty) = if font.vertical {
1124                    (0.0, adv)
1125                } else {
1126                    (adv, 0.0)
1127                };
1128                *tm = Matrix::translate(tx, ty).concat(*tm);
1129            }
1130        }
1131        let end = tm.concat(gs.ctm).apply(Point { x: 0.0, y: gs.rise });
1132        let size = if size.is_finite() { size } else { 0.0 };
1133        let bbox = if font.vertical {
1134            Rect {
1135                x0: origin.x - size / 2.0,
1136                y0: origin.y.min(end.y),
1137                x1: origin.x + size / 2.0,
1138                y1: origin.y.max(end.y),
1139            }
1140        } else {
1141            Rect {
1142                x0: origin.x.min(end.x),
1143                y0: origin.y + font.descent / 1000.0 * size,
1144                x1: origin.x.max(end.x),
1145                y1: origin.y + font.ascent / 1000.0 * size,
1146            }
1147        };
1148        (!text.is_empty() && origin.x.is_finite() && origin.y.is_finite()).then(|| TextSpan {
1149            text,
1150            x: origin.x,
1151            y: origin.y,
1152            end_x: end.x,
1153            size,
1154            bbox,
1155            font: gs.font_name.clone(),
1156            font_name: font.base_name.clone(),
1157            page: 0,
1158            bold: font.bold,
1159            italic: font.italic,
1160            monospace: font.monospace,
1161            serif: font.serif,
1162            rise: gs.rise,
1163            vertical: font.vertical,
1164            invisible: matches!(gs.render_mode, 3 | 7),
1165            color: gs.fill_color,
1166            underline: false,
1167            strikethrough: false,
1168        })
1169    }
1170
1171    /// Builds the frame for a form XObject invocation: its content stream, its
1172    /// own `/Resources` **prepended to** the caller's chain, and `/Matrix`
1173    /// prepended to the CTM — under a depth cap and a total-invocation budget.
1174    ///
1175    /// `None` on five ways out, each reported except the one that is normal:
1176    /// depth or budget exhausted (`LimitExceeded`); no such resource, or one
1177    /// that is not a stream (`Missing`); not a form — images and other
1178    /// XObjects carry no text, so this is silent; a fetch the chokepoint
1179    /// refuses (`UnsupportedFilter`, image codecs included) or that fails to
1180    /// decode (`Unreadable`); and content that will not parse (`Parse`). The
1181    /// invocation is counted before any of those checks, so a page of
1182    /// unreadable forms still exhausts its budget.
1183    async fn form_frame(
1184        &mut self,
1185        name: &str,
1186        chain: &[Arc<Dict>],
1187        gs: &GState,
1188        depth: usize,
1189    ) -> Option<Frame> {
1190        if depth >= MAX_FORM_DEPTH || self.forms >= MAX_FORM_INVOCATIONS {
1191            self.report
1192                .record(SkippedTextKind::Form, SkipCause::LimitExceeded);
1193            return None;
1194        }
1195        self.forms += 1;
1196        // Moved out, not cloned: `find_res` hands back an owned object, and
1197        // a form's stream carries its whole content body.
1198        let stream = match self.find_res(chain, "XObject", name).await {
1199            Some(Object::Stream(s)) => s,
1200            _ => {
1201                self.report
1202                    .record(SkippedTextKind::XObject, SkipCause::Missing);
1203                return None;
1204            }
1205        };
1206        // `/Subtype` may be indirect like any dictionary value (ISO 32000-1
1207        // 7.3.8.1): a direct name answers on the spot, a reference resolves.
1208        let is_form = match stream.dict.get("Subtype") {
1209            Some(Object::Name(n)) => n.0 == "Form",
1210            Some(indirect @ Object::Ref(_)) => self
1211                .src
1212                .resolve(indirect)
1213                .await
1214                .ok()
1215                .and_then(|o| o.as_name().map(|n| n.0 == "Form"))
1216                .unwrap_or(false),
1217            _ => false,
1218        };
1219        if !is_form {
1220            return None; // images and other XObjects carry no text
1221        }
1222        // A form with a hidden `/OC` entry is configured away with its
1223        // whole subtree: counted on the dedicated counter, never a skip.
1224        if let (Some(oc), Some(gate)) = (self.oc, stream.dict.get("OC")) {
1225            if !oc.visible_with(self.src, gate).await {
1226                self.report.hidden += 1;
1227                return None;
1228            }
1229        }
1230        // Through the content chokepoint, not raw stream_data: a form whose
1231        // trailing /Filter is an image codec holds passthrough bytes, not
1232        // operators (see `content_stream_data_with`). The refusal is a
1233        // report entry, the same accountable skip rendering records.
1234        let data = match content_stream_data_with(self.src, &stream).await {
1235            Ok(data) => data,
1236            Err(e) => {
1237                self.report.record(SkippedTextKind::Form, cause_for(&e));
1238                return None;
1239            }
1240        };
1241        // The form's own /Resources shadows the caller's for the names it
1242        // defines and falls through for the ones it does not, so it is
1243        // prepended rather than substituted. A form that declares
1244        // /Resources without a /Font (or without the /XObject naming a
1245        // nested form) still reaches the page's.
1246        let mut inner_chain: Vec<Arc<Dict>> = Vec::with_capacity(chain.len() + 1);
1247        if let Some(own) = self.own_resources(&stream.dict).await {
1248            inner_chain.push(Arc::new(own));
1249        }
1250        inner_chain.extend_from_slice(chain);
1251
1252        let mut inner = gs.clone();
1253        if let Some(m) = self.form_matrix(&stream.dict).await {
1254            inner.ctm = m.concat(inner.ctm);
1255        }
1256        Some(Frame::new(
1257            Arc::new(data),
1258            inner_chain,
1259            inner,
1260            depth + 1,
1261            (self.spans.len(), self.rulings.len()),
1262        ))
1263    }
1264
1265    /// A stream dictionary's own `/Resources`, when it has a usable one.
1266    async fn own_resources(&self, dict: &Dict) -> Option<Dict> {
1267        let obj = dict.get("Resources")?;
1268        self.src.resolve(obj).await.ok()?.as_dict().cloned()
1269    }
1270
1271    /// Reads a `/Matrix` entry (six numbers) from a form XObject dictionary.
1272    async fn form_matrix(&self, dict: &Dict) -> Option<Matrix> {
1273        let obj = self.src.resolve(dict.get("Matrix")?).await.ok()?;
1274        let arr = obj.as_array()?;
1275        let mut v = [0.0f32; 6];
1276        for (slot, item) in v.iter_mut().zip(arr.iter()) {
1277            *slot = self.src.resolve(item).await.ok()?.as_f64()? as f32;
1278        }
1279        if arr.len() < 6 {
1280            return None;
1281        }
1282        let m = Matrix {
1283            a: v[0],
1284            b: v[1],
1285            c: v[2],
1286            d: v[3],
1287            e: v[4],
1288            f: v[5],
1289        };
1290        finite(&m).then_some(m)
1291    }
1292}
1293
1294#[cfg(test)]
1295mod tests {
1296    use super::*;
1297    use pdfboss_core::{block_on, Document, Immediate};
1298    use pdfboss_testkit::doc_with_graphics;
1299
1300    /// The synchronous spans accessor. Production has no use for one — the public
1301    /// entry points in `lib.rs` wrap [`page_spans_and_rulings_with`] themselves —
1302    /// but it is the same `block_on` over `Immediate`, so every test below still
1303    /// asserts on exactly what a synchronous caller receives. The report is
1304    /// asserted complete: no test here expects to lose content.
1305    fn page_spans(doc: &Document, page: &Page) -> Vec<TextSpan> {
1306        let (spans, _, report) = extract_all(doc, page);
1307        assert!(report.is_complete(), "unexpected skips: {report:?}");
1308        spans
1309    }
1310
1311    /// The synchronous rulings accessor, the twin of [`page_spans`].
1312    fn page_rulings(doc: &Document, page: &Page) -> Vec<Ruling> {
1313        let (_, rulings, report) = extract_all(doc, page);
1314        assert!(report.is_complete(), "unexpected skips: {report:?}");
1315        rulings
1316    }
1317
1318    /// One walk with the document's own optional-content configuration —
1319    /// exactly what the `lib.rs` document-level entries drive.
1320    fn extract_all(doc: &Document, page: &Page) -> (Vec<TextSpan>, Vec<Ruling>, ExtractReport) {
1321        let oc = doc.oc_state();
1322        block_on(page_spans_and_rulings_with(
1323            Immediate(doc),
1324            page,
1325            None,
1326            oc.as_ref(),
1327        ))
1328    }
1329
1330    /// One page over two optional content groups: object 8 stays on,
1331    /// object 9 is off in the default configuration, reachable from
1332    /// content as `/Properties` entries `/V` and `/H`; `/Fx` is a form
1333    /// gated off by its own `/OC` entry.
1334    fn oc_doc(content: &[u8]) -> Document {
1335        use pdfboss_testkit::PdfBuilder;
1336        let mut b = PdfBuilder::new();
1337        b.object(
1338            1,
1339            "<< /Type /Catalog /Pages 2 0 R /OCProperties \
1340             << /OCGs [8 0 R 9 0 R] /D << /OFF [9 0 R] >> >> >>",
1341        );
1342        b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
1343        b.object(
1344            3,
1345            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
1346             /Resources << /Font << /F1 5 0 R >> \
1347             /Properties << /V 8 0 R /H 9 0 R >> \
1348             /XObject << /Fx 6 0 R >> >> /Contents 4 0 R >>",
1349        );
1350        b.stream(4, "", content);
1351        b.object(
1352            5,
1353            "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica \
1354             /Encoding /WinAnsiEncoding >>",
1355        );
1356        b.stream(
1357            6,
1358            "/Type /XObject /Subtype /Form /BBox [0 0 612 792] /OC 9 0 R",
1359            b"BT /F1 12 Tf 72 600 Td (formtext) Tj ET",
1360        );
1361        b.object(8, "<< /Type /OCG /Name (shown) >>");
1362        b.object(9, "<< /Type /OCG /Name (hidden) >>");
1363        Document::load(b.build(1)).expect("load")
1364    }
1365
1366    /// A hidden layer's text is excluded and counted once per span, while
1367    /// its advances still run: the visible text that follows starts where
1368    /// the hidden run ended, and the walk is still complete.
1369    #[test]
1370    fn hidden_layer_text_is_excluded_but_still_advances() {
1371        let doc = oc_doc(
1372            b"BT /F1 12 Tf 72 720 Td /OC /H BDC (wide hidden run) Tj EMC (kept) Tj \
1373              /OC /V BDC ( on) Tj EMC ET",
1374        );
1375        let page = doc.page(0).unwrap();
1376        let (spans, _, report) = extract_all(&doc, &page);
1377        let texts: Vec<&str> = spans.iter().map(|s| s.text.as_str()).collect();
1378        assert_eq!(texts, ["kept", " on"]);
1379        assert!(
1380            spans[0].x > 100.0,
1381            "the hidden run must still advance: x = {}",
1382            spans[0].x
1383        );
1384        assert_eq!(report.hidden, 1);
1385        assert!(report.is_complete(), "hidden is not a skip: {report:?}");
1386    }
1387
1388    /// A form whose own `/OC` entry is off contributes nothing — no spans,
1389    /// no skip entry, one count — and rulings drawn in a hidden span are
1390    /// excluded with the text.
1391    #[test]
1392    fn hidden_forms_and_rulings_are_excluded() {
1393        let doc = oc_doc(b"/Fx Do /OC /H BDC 72 700 m 272 700 l S EMC 72 650 m 272 650 l S");
1394        let page = doc.page(0).unwrap();
1395        let (spans, rulings, report) = extract_all(&doc, &page);
1396        assert_eq!(spans, vec![], "the gated form must not run");
1397        assert_eq!(rulings.len(), 1, "only the visible line survives");
1398        assert!((rulings[0].start.y - 650.0).abs() < 1e-3);
1399        assert_eq!(report.hidden, 2, "one form, one span");
1400        assert!(report.is_complete());
1401    }
1402
1403    /// `3 Tr` text is a viewer-invisible layer the document still shows —
1404    /// searchable-scan OCR — and stays extracted; an off optional-content
1405    /// layer is declared off by the document itself and is excluded. The
1406    /// two must not be conflated.
1407    #[test]
1408    fn invisible_render_mode_survives_where_hidden_layers_do_not() {
1409        let doc = oc_doc(
1410            b"BT /F1 12 Tf 3 Tr 72 720 Td (ocr) Tj ET \
1411              /OC /H BDC BT /F1 12 Tf 72 700 Td (gone) Tj ET EMC",
1412        );
1413        let page = doc.page(0).unwrap();
1414        let (spans, _, report) = extract_all(&doc, &page);
1415        let texts: Vec<&str> = spans.iter().map(|s| s.text.as_str()).collect();
1416        assert_eq!(texts, ["ocr"]);
1417        assert_eq!(report.hidden, 1);
1418    }
1419
1420    /// Without `/OCProperties` there is no configuration to be off in:
1421    /// every `/OC` span extracts and nothing is counted.
1422    #[test]
1423    fn absent_configuration_extracts_every_layer() {
1424        use pdfboss_testkit::PdfBuilder;
1425        let mut b = PdfBuilder::new();
1426        b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
1427        b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
1428        b.object(
1429            3,
1430            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
1431             /Resources << /Font << /F1 5 0 R >> \
1432             /Properties << /H 8 0 R >> >> /Contents 4 0 R >>",
1433        );
1434        b.stream(
1435            4,
1436            "",
1437            b"BT /F1 12 Tf 72 720 Td /OC /H BDC (loose) Tj EMC ET",
1438        );
1439        b.object(
1440            5,
1441            "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica \
1442             /Encoding /WinAnsiEncoding >>",
1443        );
1444        b.object(8, "<< /Type /OCG /Name (loose) >>");
1445        let doc = Document::load(b.build(1)).expect("load");
1446        let page = doc.page(0).unwrap();
1447        let (spans, _, report) = extract_all(&doc, &page);
1448        assert_eq!(spans.len(), 1);
1449        assert_eq!(spans[0].text, "loose");
1450        assert_eq!(report.hidden, 0);
1451    }
1452
1453    /// Raw spans of a one-page document with `content` as its raw content
1454    /// stream (12pt /F1 with default widths of 500).
1455    fn spans_of(content: &str) -> Vec<TextSpan> {
1456        let doc = Document::load(doc_with_graphics(content)).unwrap();
1457        let page = doc.page(0).unwrap();
1458        page_spans(&doc, &page)
1459    }
1460
1461    /// Raw rulings of a one-page document with `content` as its raw content
1462    /// stream.
1463    fn rulings_of(content: &str) -> Vec<Ruling> {
1464        let doc = Document::load(doc_with_graphics(content)).unwrap();
1465        let page = doc.page(0).unwrap();
1466        page_rulings(&doc, &page)
1467    }
1468
1469    #[track_caller]
1470    fn assert_ruling(r: &Ruling, x0: f32, y0: f32, x1: f32, y1: f32) {
1471        let close = (r.start.x - x0).abs() < 1e-3
1472            && (r.start.y - y0).abs() < 1e-3
1473            && (r.end.x - x1).abs() < 1e-3
1474            && (r.end.y - y1).abs() < 1e-3;
1475        assert!(close, "{r:?} is not ({x0},{y0})-({x1},{y1})");
1476    }
1477
1478    #[test]
1479    fn word_spacing_applies_to_code_32_only() {
1480        // 'a b' = three codes at 6.0 each; Tw 5 fires once (the space).
1481        let spans = spans_of("BT /F1 12 Tf 5 Tw 72 720 Td (a b) Tj ET");
1482        assert_eq!(spans.len(), 1);
1483        assert!((spans[0].end_x - 95.0).abs() < 1e-3, "{}", spans[0].end_x);
1484    }
1485
1486    #[test]
1487    fn cm_and_q_q_track_ctm() {
1488        let spans = spans_of(
1489            "q 1 0 0 1 100 0 cm BT /F1 12 Tf 0 720 Td (X) Tj ET Q \
1490             BT /F1 12 Tf 0 700 Td (Y) Tj ET",
1491        );
1492        assert_eq!(spans.len(), 2);
1493        assert!((spans[0].x - 100.0).abs() < 1e-3);
1494        assert!((spans[1].x - 0.0).abs() < 1e-3);
1495    }
1496
1497    #[test]
1498    fn horizontal_scaling_stretches_advances() {
1499        let spans = spans_of("BT /F1 12 Tf 200 Tz 72 720 Td (AB) Tj ET");
1500        // 2 glyphs * 6.0 * 200% = 24.
1501        assert!((spans[0].end_x - 96.0).abs() < 1e-3, "{}", spans[0].end_x);
1502    }
1503
1504    #[test]
1505    fn text_rise_shifts_baseline() {
1506        let spans = spans_of("BT /F1 12 Tf 72 720 Td 5 Ts (R) Tj ET");
1507        assert!((spans[0].y - 725.0).abs() < 1e-3);
1508    }
1509
1510    /// `T*` moves to the next line by translating Tlm by `(0, -leading)`,
1511    /// the same geometry `'` relies on to start its shown line.
1512    #[test]
1513    fn t_star_advances_tlm_by_leading() {
1514        let spans = spans_of("BT /F1 12 Tf 14 TL 72 720 Td (a) Tj T* (b) Tj ET");
1515        assert!((spans[1].y - 706.0).abs() < 1e-3);
1516    }
1517
1518    #[test]
1519    fn tm_positions_directly_and_bt_resets() {
1520        let spans = spans_of("BT /F1 12 Tf 1 0 0 1 300 100 Tm (m) Tj ET BT /F1 12 Tf (o) Tj ET");
1521        assert!((spans[0].x - 300.0).abs() < 1e-3);
1522        assert!((spans[0].y - 100.0).abs() < 1e-3);
1523        // Second BT starts from identity again.
1524        assert!((spans[1].x - 0.0).abs() < 1e-3);
1525        assert!((spans[1].y - 0.0).abs() < 1e-3);
1526    }
1527
1528    #[test]
1529    fn tm_scale_sets_device_size() {
1530        let spans = spans_of("BT /F1 1 Tf 12 0 0 12 72 720 Tm (s) Tj ET");
1531        assert!((spans[0].size - 12.0).abs() < 1e-3);
1532    }
1533
1534    #[test]
1535    fn empty_content_yields_no_spans() {
1536        assert!(spans_of("").is_empty());
1537    }
1538
1539    #[test]
1540    fn form_xobject_fanout_is_bounded() {
1541        use pdfboss_testkit::PdfBuilder;
1542        // A chain of 6 forms in which each level invokes the next 8
1543        // times: bounded only by depth this executes 8^5 = 32768 leaf
1544        // forms (and grows exponentially with chain length), so the
1545        // total-invocation budget must cut it off.
1546        let chain = 6u32;
1547        let mut b = PdfBuilder::new();
1548        b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
1549        b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
1550        b.object(
1551            3,
1552            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
1553             /Resources << /XObject << /X 10 0 R >> >> /Contents 4 0 R >>",
1554        );
1555        b.stream(4, "", b"/X Do");
1556        for i in 0..chain {
1557            let num = 10 + i;
1558            if i + 1 < chain {
1559                let dict = format!(
1560                    "/Type /XObject /Subtype /Form \
1561                     /Resources << /XObject << /X {} 0 R >> >>",
1562                    num + 1
1563                );
1564                b.stream(num, &dict, "/X Do ".repeat(8).as_bytes());
1565            } else {
1566                b.stream(
1567                    num,
1568                    "/Type /XObject /Subtype /Form",
1569                    b"BT /F1 12 Tf 72 720 Td (L) Tj ET",
1570                );
1571            }
1572        }
1573        let doc = Document::load(b.build(1)).unwrap();
1574        let page = doc.page(0).unwrap();
1575        // Raw call: exhausting the budget is this test's point, so the
1576        // report is legitimately incomplete here.
1577        let (spans, _, report) = block_on(page_spans_and_rulings_with(
1578            Immediate(&doc),
1579            &page,
1580            None,
1581            None,
1582        ));
1583        assert!(!spans.is_empty()); // nested forms still extract text
1584        assert!(
1585            spans.len() <= MAX_FORM_INVOCATIONS,
1586            "fan-out not bounded: {} spans",
1587            spans.len()
1588        );
1589        assert!(
1590            report
1591                .skipped
1592                .iter()
1593                .all(|s| s.cause == SkipCause::LimitExceeded),
1594            "only the budget may cut this page short: {report:?}"
1595        );
1596        assert!(!report.is_complete(), "the cut-off must be visible");
1597    }
1598
1599    /// Emission order is depth-first and in stream order: a form's spans land
1600    /// between the spans of the operators either side of its `Do`, at every
1601    /// level of nesting.
1602    ///
1603    /// This is what an explicit frame stack most easily gets wrong, and until now
1604    /// nothing tested it. `form_xobject_recursion` invokes its only form as the
1605    /// last operator on the page, so it cannot see a form's spans arriving late;
1606    /// `form_xobject_fanout_is_bounded` emits the same string from every leaf, so
1607    /// it cannot see siblings arriving reversed. Both stay green under a stack
1608    /// that defers children to the end.
1609    ///
1610    /// `page_spans` rather than `text_of`, because layout sorts by position and
1611    /// would hide the very thing being asserted.
1612    #[test]
1613    fn form_spans_are_emitted_where_the_do_appears() {
1614        use pdfboss_testkit::PdfBuilder;
1615        let mut b = PdfBuilder::new();
1616        b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
1617        b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
1618        b.object(
1619            3,
1620            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
1621             /Resources << /Font << /F1 5 0 R >> \
1622             /XObject << /Fa 6 0 R /Fi 7 0 R >> >> /Contents 4 0 R >>",
1623        );
1624        b.stream(
1625            4,
1626            "",
1627            b"BT /F1 12 Tf 72 720 Td (A) Tj ET /Fa Do BT /F1 12 Tf 72 660 Td (E) Tj ET",
1628        );
1629        b.object(
1630            5,
1631            "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica \
1632             /Encoding /WinAnsiEncoding >>",
1633        );
1634        // The outer form shows a string, descends, then shows another: its second
1635        // span must follow the nested form's.
1636        b.stream(
1637            6,
1638            "/Type /XObject /Subtype /Form /BBox [0 0 612 792]",
1639            b"BT /F1 12 Tf 72 700 Td (B) Tj ET /Fi Do BT /F1 12 Tf 72 680 Td (D) Tj ET",
1640        );
1641        b.stream(
1642            7,
1643            "/Type /XObject /Subtype /Form /BBox [0 0 612 792]",
1644            b"BT /F1 12 Tf 72 690 Td (C) Tj ET",
1645        );
1646        let doc = Document::load(b.build(1)).unwrap();
1647        let page = doc.page(0).unwrap();
1648        let spans = page_spans(&doc, &page);
1649        let order: Vec<&str> = spans.iter().map(|s| s.text.as_str()).collect();
1650        assert_eq!(order, ["A", "B", "C", "D", "E"]);
1651    }
1652
1653    /// A loaded font and the state that carries it must both be shareable across
1654    /// threads: the shared asynchronous implementation is driven on a runtime
1655    /// free to move its future between them, and `Arc<T>` is `Send` only when
1656    /// `T` is `Send + Sync`.
1657    ///
1658    /// Nothing in this crate has interior mutability, so this holds as soon as
1659    /// the handle is an `Arc`. The assertion exists to stop a later `Rc` or
1660    /// `RefCell` taking it away silently — which is exactly how the renderer's
1661    /// glyph cache came to block a spawnable future. [`FontCache`] is on the
1662    /// list because one instance serves every worker of a parallel page walk.
1663    #[test]
1664    fn loaded_fonts_are_shareable_across_threads() {
1665        fn assert_send_sync<T: Send + Sync>() {}
1666        assert_send_sync::<Font>();
1667        assert_send_sync::<Arc<Font>>();
1668        assert_send_sync::<GState>();
1669        assert_send_sync::<FontCache>();
1670    }
1671
1672    /// `/F1` in a form's own resources and `/F1` in the page resources are
1673    /// different fonts: the name→font binding is resource-scoped (ISO 32000
1674    /// §7.8.3). The loaded-font cache is keyed by the font dictionary's
1675    /// object reference, never by name — a cache keyed by name would hand
1676    /// the form the page's font and fail this test.
1677    #[test]
1678    fn same_name_binds_a_different_font_per_resource_scope() {
1679        use pdfboss_testkit::PdfBuilder;
1680        let mut b = PdfBuilder::new();
1681        b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
1682        b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
1683        b.object(
1684            3,
1685            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
1686             /Resources << /Font << /F1 5 0 R >> /XObject << /Fx 6 0 R >> >> \
1687             /Contents 4 0 R >>",
1688        );
1689        b.stream(4, "", b"BT /F1 12 Tf 72 720 Td (aa) Tj ET /Fx Do");
1690        b.object(
1691            5,
1692            "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica \
1693             /Encoding /WinAnsiEncoding /FirstChar 97 /LastChar 97 /Widths [500] >>",
1694        );
1695        b.stream(
1696            6,
1697            "/Type /XObject /Subtype /Form /BBox [0 0 612 792] \
1698             /Resources << /Font << /F1 7 0 R >> >>",
1699            b"BT /F1 12 Tf 72 700 Td (aa) Tj ET",
1700        );
1701        b.object(
1702            7,
1703            "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica \
1704             /Encoding /WinAnsiEncoding /FirstChar 97 /LastChar 97 /Widths [1000] >>",
1705        );
1706        let doc = Document::load(b.build(1)).unwrap();
1707        let page = doc.page(0).unwrap();
1708        let spans = page_spans(&doc, &page);
1709        assert_eq!(spans.len(), 2);
1710        let advance = |s: &TextSpan| s.end_x - s.x;
1711        assert!(
1712            (advance(&spans[0]) - 12.0).abs() < 1e-3,
1713            "page scope must use the 500-width font: {}",
1714            advance(&spans[0])
1715        );
1716        assert!(
1717            (advance(&spans[1]) - 24.0).abs() < 1e-3,
1718            "form scope must use the 1000-width font: {}",
1719            advance(&spans[1])
1720        );
1721    }
1722
1723    /// A stroked 2x2 grid: the `re` contributes its four border edges in
1724    /// construction order (bottom, right, top, left — the order rendering's
1725    /// path builder decomposes `re` into), then the two inner dividers in
1726    /// stream order, all at the default 1.0 line width.
1727    #[test]
1728    fn stroked_grid_yields_rulings_with_correct_endpoints() {
1729        let rulings = rulings_of("72 600 200 100 re S 172 600 m 172 700 l S 72 650 m 272 650 l S");
1730        assert_eq!(rulings.len(), 6, "{rulings:?}");
1731        assert_ruling(&rulings[0], 72.0, 600.0, 272.0, 600.0);
1732        assert_ruling(&rulings[1], 272.0, 600.0, 272.0, 700.0);
1733        assert_ruling(&rulings[2], 72.0, 700.0, 272.0, 700.0);
1734        assert_ruling(&rulings[3], 72.0, 600.0, 72.0, 700.0);
1735        assert_ruling(&rulings[4], 172.0, 600.0, 172.0, 700.0);
1736        assert_ruling(&rulings[5], 72.0, 650.0, 272.0, 650.0);
1737        assert!(rulings.iter().all(|r| (r.width - 1.0).abs() < 1e-3));
1738    }
1739
1740    #[test]
1741    fn w_sets_the_stroke_width() {
1742        let rulings = rulings_of("0.5 w 72 700 m 272 700 l S");
1743        assert_eq!(rulings.len(), 1);
1744        assert!((rulings[0].width - 0.5).abs() < 1e-3);
1745    }
1746
1747    /// A negative or non-finite `w` operand leaves the line width alone, the
1748    /// way the renderer treats it. The 39-digit literal lexes as a real and
1749    /// overflows `f32` to infinity; unguarded, that width would fail the
1750    /// segment gate and silently drop the stroke.
1751    #[test]
1752    fn negative_or_nonfinite_w_is_ignored() {
1753        let rulings = rulings_of("-5 w 72 700 m 272 700 l S");
1754        assert_eq!(rulings.len(), 1, "{rulings:?}");
1755        assert!((rulings[0].width - 1.0).abs() < 1e-3);
1756        let rulings = rulings_of("400000000000000000000000000000000000000 w 72 700 m 272 700 l S");
1757        assert_eq!(rulings.len(), 1, "{rulings:?}");
1758        assert!((rulings[0].width - 1.0).abs() < 1e-3);
1759    }
1760
1761    #[test]
1762    fn ext_gstate_lw_sets_the_stroke_width() {
1763        use pdfboss_testkit::PdfBuilder;
1764        let mut b = PdfBuilder::new();
1765        b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
1766        b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
1767        b.object(
1768            3,
1769            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
1770             /Resources << /ExtGState << /G1 5 0 R >> >> /Contents 4 0 R >>",
1771        );
1772        b.stream(4, "", b"/G1 gs 72 700 m 272 700 l S");
1773        b.object(5, "<< /Type /ExtGState /LW 2.5 >>");
1774        let doc = Document::load(b.build(1)).unwrap();
1775        let page = doc.page(0).unwrap();
1776        let rulings = page_rulings(&doc, &page);
1777        assert_eq!(rulings.len(), 1);
1778        assert!((rulings[0].width - 2.5).abs() < 1e-3);
1779    }
1780
1781    #[test]
1782    fn thin_filled_rect_yields_its_centerline() {
1783        let rulings = rulings_of("72 700 200 0.8 re f");
1784        assert_eq!(rulings.len(), 1, "{rulings:?}");
1785        assert_ruling(&rulings[0], 72.0, 700.4, 272.0, 700.4);
1786        assert_eq!(rulings[0].width, 0.0, "a fill has no stroke width");
1787    }
1788
1789    #[test]
1790    fn fat_filled_rect_yields_no_rulings() {
1791        assert!(rulings_of("72 600 200 40 re f").is_empty());
1792    }
1793
1794    /// Axis alignment is judged after the CTM: a 90° rotation turns a
1795    /// horizontal segment into a vertical ruling, while a 30° rotation
1796    /// leaves it diagonal and drops it.
1797    #[test]
1798    fn cm_rotation_keeps_axis_aligned_segments_only() {
1799        let rotated90 = rulings_of("q 0 1 -1 0 300 100 cm 0 0 m 100 0 l S Q");
1800        assert_eq!(rotated90.len(), 1, "{rotated90:?}");
1801        assert_ruling(&rotated90[0], 300.0, 100.0, 300.0, 200.0);
1802        let rotated30 = rulings_of("q 0.866 0.5 -0.5 0.866 0 0 cm 72 700 m 172 700 l S Q");
1803        assert!(rotated30.is_empty(), "{rotated30:?}");
1804    }
1805
1806    /// The curve poisons its own subpath — including the straight `l` that
1807    /// continues it — but not the sibling subpath committed by the same `S`.
1808    #[test]
1809    fn curves_poison_only_their_own_subpath() {
1810        let rulings =
1811            rulings_of("72 500 m 100 550 150 550 172 500 c 200 500 l 72 700 m 272 700 l S");
1812        assert_eq!(rulings.len(), 1, "{rulings:?}");
1813        assert_ruling(&rulings[0], 72.0, 700.0, 272.0, 700.0);
1814    }
1815
1816    /// `n` discards the path whether it stands alone or finishes a `W`
1817    /// clip: clipping never commits rulings.
1818    #[test]
1819    fn end_path_discards_the_accumulated_path() {
1820        assert!(rulings_of("72 700 m 272 700 l n").is_empty());
1821        assert!(rulings_of("72 600 200 100 re W n").is_empty());
1822    }
1823
1824    /// A form's `/Matrix` concatenates into the CTM its content runs under,
1825    /// so its rulings land in page space like its spans do.
1826    #[test]
1827    fn form_matrix_lands_rulings_in_page_space() {
1828        use pdfboss_testkit::PdfBuilder;
1829        let mut b = PdfBuilder::new();
1830        b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
1831        b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
1832        b.object(
1833            3,
1834            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
1835             /Resources << /XObject << /Fx 5 0 R >> >> /Contents 4 0 R >>",
1836        );
1837        b.stream(4, "", b"/Fx Do");
1838        b.stream(
1839            5,
1840            "/Type /XObject /Subtype /Form /BBox [0 0 612 792] \
1841             /Matrix [1 0 0 1 0 -20]",
1842            b"72 720 m 272 720 l S",
1843        );
1844        let doc = Document::load(b.build(1)).unwrap();
1845        let page = doc.page(0).unwrap();
1846        let rulings = page_rulings(&doc, &page);
1847        assert_eq!(rulings.len(), 1, "{rulings:?}");
1848        assert_ruling(&rulings[0], 72.0, 700.0, 272.0, 700.0);
1849    }
1850}