Skip to main content

pdfrum_page/
build.rs

1//! `build_page`: the fold from operators to page objects.
2//!
3//! Pure with respect to its inputs, and the only mutable state is the build
4//! context's three caches, passed down by `&mut`.
5//!
6//! # Path assembly has three silent repairs
7//!
8//! Adding a point to a path is not the append it looks like:
9//!
10//! - a `MoveTo` identical to a preceding open `MoveTo` is **dropped**;
11//! - a `MoveTo` following an open `MoveTo` **overwrites** it, so `m m m`
12//!   collapses to the last one;
13//! - a non-`MoveTo` point with **no path started at all is discarded**, so an
14//!   `l` before any `m` vanishes.
15//!
16//! # A single-point path is a special case
17//!
18//! With a clip pending it produces an **empty clip** that blanks everything
19//! after it. Without one it draws nothing at all — *unless* the point is a
20//! closed `MoveTo` and the line cap is round, which is the round-dot case.
21//!
22//! # The form guard is buffer identity, not depth
23//!
24//! Recursion is refused when more than forty parses are in flight **or when
25//! the same content buffer is already being parsed**. The second half is the
26//! real cycle guard: a form that re-invokes itself is refused however
27//! shallow it is, while two sequential `Do`s of the same form both work.
28//! Refusal consumes the stream and **succeeds with zero objects**.
29
30use crate::color::{ColorSpace, ColorSpaceCache};
31use crate::function::FunctionCache;
32use crate::image::{ImageCache, RequestedSize, decode_image};
33use crate::names;
34use crate::ops::{FillRule, LineCap, Op, TextItem, TextRenderMode};
35use crate::page::{
36    Content, FormObject, ImageObject, Page, PageObject, PathObject, ShadingObject, TextObject,
37    TextSegment,
38};
39use crate::pattern::{Pattern, TilingPattern};
40use crate::resources::Resources;
41use crate::shading::{Shading, ShadingSource};
42use crate::state::{
43    ClipRule, ContentMarks, GraphicsState, StateStack, TextClipRun, TextCursor, apply_ext_gstate,
44    glyph_matrix, kerning_shift,
45};
46use crate::transparency::Transparency;
47use kurbo::{Affine, BezPath, Point, Rect};
48use pdfrum_common::{DiagKind, Diagnostics, Limits, Operation, Severity};
49use pdfrum_font::{Font, FontCache};
50use pdfrum_object::{Dict, Name, Object, Resolve};
51use std::any::Any;
52use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
53use std::sync::Arc;
54
55/// The most form parses that may be in flight at once.
56///
57/// Compared with `>`, so **forty-one** nested forms are allowed and the
58/// forty-second is refused.
59pub const MAX_FORM_LEVEL: usize = 40;
60
61/// The caches and guards a build shares across the whole page.
62///
63/// Owned by the caller and passed down by `&mut` — no globals, no interior
64/// mutability.
65#[derive(Debug, Default)]
66pub struct BuildContext {
67    /// Colour spaces, keyed on the reference that named them.
68    pub colorspaces: ColorSpaceCache,
69    /// Functions, likewise.
70    pub functions: FunctionCache,
71    /// Decoded images, keyed on `(reference, requested size)`.
72    pub images: ImageCache,
73    /// How much resolution this build's images are wanted at.
74    ///
75    /// A **hint**: a codec that cannot reduce returns full resolution and the
76    /// image reports the size it actually decoded at. `Full` — the default —
77    /// asks for every sample, which is what a build with no render target
78    /// behind it wants.
79    ///
80    /// One value per build rather than one per image: the size that matters
81    /// is the render target's, so the question a decode hint answers is how
82    /// much bigger an image is than the whole page bitmap, not how small a
83    /// rectangle it lands in.
84    pub decode_target: RequestedSize,
85    /// Fonts: the loaded-font cache and the font-identity counter.
86    ///
87    /// Shared by `Arc` rather than owned, so every session over one document
88    /// — every worker of a parallel render, and a text run and a render run
89    /// alike — loads each font once between them. A context built with
90    /// [`BuildContext::new`] gets a fresh one, which is the right answer for
91    /// a caller with no document to hang it on; the facade hands its own
92    /// document-owned cache to every session it makes.
93    pub fonts: Arc<FontCache>,
94    /// How a non-embedded font finds a face to draw with.
95    ///
96    /// Carried here rather than passed in per call because every font load
97    /// under one document must make the same choice: a substitution that
98    /// varied between two `Tf` operators naming the same resource would give
99    /// one line of text different metrics from the next. Defaults to the
100    /// built-in faces alone, which is what keeps tests hermetic; the tool
101    /// fills it from `--font-dir` and `--croscore-font-names`.
102    pub substitution: pdfrum_font::SubstitutionOptions,
103    /// The interactive form's default-resource faces, keyed on the object
104    /// that declares them.
105    ///
106    /// # Why the value is erased
107    ///
108    /// The faces are `pdfrum_doc::ap::FormFonts`, and this crate is *below*
109    /// `pdfrum-doc` — it cannot name the type. The alternative was to thread
110    /// a second per-document cache through `annot_render::overlay_with`,
111    /// which already carries eight arguments, and onward through the facade's
112    /// render path, `RenderSession` and `FormSession`: a public API change
113    /// across four crates to pass state that is *already* being threaded
114    /// here, beside the font, colour-space, function and image caches this
115    /// exists to hold.
116    ///
117    /// So the slot is erased and the layer above supplies the type through
118    /// [`Self::form_fonts`]. This is storage erasure, not a polymorphism
119    /// seam: nothing is ever *dispatched* through the `Any`, it is
120    /// downcast straight back to the one type that put it there.
121    ///
122    /// # Why it is memoized at all
123    ///
124    /// Building it walks the AcroForm `/DR /Font` dictionary and fully
125    /// constructs every font in it — encoding tables, `/Differences`, the
126    /// substitution ladder — then loads a fallback and the second faces a
127    /// charset outside the `/DA` font needs. That is a pure function of the
128    /// `/AcroForm` dictionary, which does not change between renders of one
129    /// document, and the annotation overlay ran it **once per page per
130    /// render**. On a form document whose `/DR` fonts are embedded it was
131    /// measured at 78 ms against an appearance generation of under 1 ms, and
132    /// it was paid by every document carrying any annotation, not only by
133    /// forms.
134    ///
135    /// # Why it is keyed
136    ///
137    /// On a reference, for the same reason
138    /// [`font_instances`](Self::font_instances) is keyed on the reference
139    /// that named a font: one context may legitimately be threaded through
140    /// two documents, and a slot keyed on nothing would hand the second
141    /// document the first one's faces. [`FormFontsKey`] says which of its
142    /// four cases a catalog is in, and only the last is uncached.
143    form_fonts: HashMap<FormFontsKey, Arc<dyn Any + Send + Sync>>,
144    /// The content buffers currently being parsed, which is the form guard.
145    in_flight: HashSet<BufferId>,
146    /// How many Type 3 glyph procedures are being interpreted above the
147    /// current one (`kMaxType3FormLevel`).
148    ///
149    /// A glyph procedure may itself show text in a Type 3 font, so
150    /// interpreting one can reach another; the buffer-identity guard catches
151    /// a procedure that invokes *itself*, but not a pair that invoke each
152    /// other through two distinct streams, which is what
153    /// [`MAX_TYPE3_DEPTH`](pdfrum_font::MAX_TYPE3_DEPTH) bounds.
154    type3_depth: u32,
155}
156
157/// Which interactive form a set of cached form faces belongs to.
158///
159/// # What the faces actually depend on
160///
161/// Not the `/AcroForm` dictionary: the **`/DR /Font` dictionary inside it**,
162/// and nothing else. Everything `FormFonts` builds is either a face named
163/// there or one of two constants — the fallback Helvetica and the second
164/// face a substitutable charset needs, both from dictionaries written in the
165/// source. So that is what the key names, and a form declaring no `/DR
166/// /Font` has faces indistinguishable from a document with no form at all.
167///
168/// # The four cases
169///
170/// - **an indirect `/AcroForm`**, which is what a real form is written as.
171///   The reference is the document-scoped identity every other cache on
172///   [`BuildContext`] keys on, so the faces are cached under it.
173/// - **no `/DR /Font` to load from** — no `/AcroForm`, or one that declares
174///   no default resources. The faces then depend on *nothing* from the
175///   document, so one slot serves every such document a context is threaded
176///   through. This is `None`, and it is the common case: most documents have
177///   no form, and an empty `<</Fields[]>>` written directly into the catalog
178///   is common enough that six of this corpus's 44 files carry one.
179/// - **a direct `/AcroForm` whose `/DR /Font` is a reference**, which is the
180///   ordinary spelling of an unusual one. The font dictionary's reference is
181///   as good an identity as the form's own would have been, so it is cached
182///   under that instead.
183/// - **a direct `/AcroForm` with a direct `/DR /Font`**, which is legal and
184///   genuinely rare. There is no reference anywhere to key on and the
185///   content is document-specific, so it is not cached.
186#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
187pub enum FormFontsKey {
188    /// The `/AcroForm` the given object holds.
189    Form(pdfrum_object::ObjRef),
190    /// No `/DR /Font` for the faces to depend on.
191    None,
192    /// A direct `/AcroForm` whose `/DR /Font` is the given object.
193    DirectResources(pdfrum_object::ObjRef),
194    /// A direct `/AcroForm` with a direct `/DR /Font`.
195    Direct,
196}
197
198/// A content buffer's identity: the object that holds it, and its extent.
199///
200/// The C++ keys its guard on a raw pointer to the decoded bytes; this is the
201/// same identity in a representation Rust can hold safely.
202#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
203struct BufferId {
204    reference: Option<pdfrum_object::ObjRef>,
205    len: usize,
206    /// A hash of the first and last bytes, so two distinct inline buffers of
207    /// the same length are not confused.
208    fingerprint: u64,
209}
210
211impl BufferId {
212    fn new(reference: Option<pdfrum_object::ObjRef>, data: &[u8]) -> Self {
213        let mut fingerprint = 0xcbf2_9ce4_8422_2325u64;
214        for b in data.iter().take(64).chain(data.iter().rev().take(64)) {
215            fingerprint ^= u64::from(*b);
216            fingerprint = fingerprint.wrapping_mul(0x100_0000_01b3);
217        }
218        Self {
219            reference,
220            len: data.len(),
221            fingerprint,
222        }
223    }
224}
225
226impl BuildContext {
227    /// An empty context.
228    #[must_use]
229    pub fn new() -> Self {
230        Self::default()
231    }
232
233    /// An empty context whose non-embedded fonts resolve through `options`.
234    ///
235    /// The caches start empty either way; this only fixes how substitution
236    /// will answer, which must be settled before the first font loads.
237    #[must_use]
238    pub fn with_substitution(options: pdfrum_font::SubstitutionOptions) -> Self {
239        Self {
240            substitution: options,
241            ..Self::default()
242        }
243    }
244
245    /// The interactive form's faces for `key`, built by `load` on the first
246    /// ask and handed back from the cache on every later one.
247    ///
248    /// `load` is a closure, not a value, so a hit costs nothing to build.
249    /// [`FormFontsKey::Direct`] is never cached and always calls `load`: it
250    /// is the one case with no reference anywhere to key on, and reusing one
251    /// document's faces for another's would be wrong.
252    // Erased in storage and downcast back on the way out. A cached value whose
253    // type does not match — which cannot happen, since one caller owns the type
254    // — is treated as a miss and rebuilt rather than reported.
255    pub fn form_fonts<T: Any + Send + Sync>(
256        &mut self,
257        key: FormFontsKey,
258        load: impl FnOnce(&mut Self) -> T,
259    ) -> Arc<T> {
260        if key == FormFontsKey::Direct {
261            crate::renderprofile::form_font_miss();
262            return Arc::new(load(self));
263        }
264        if let Some(cached) = self.form_fonts.get(&key)
265            && let Ok(hit) = Arc::clone(cached).downcast::<T>()
266        {
267            return hit;
268        }
269        // Counted here rather than at the call site, because this is the one
270        // place that knows a miss happened: the caller asks the same way for
271        // both, and a slot that fills once per document against one that
272        // fills once per page per render is the difference the count exists
273        // to show.
274        crate::renderprofile::form_font_miss();
275        let built = Arc::new(load(self));
276        self.form_fonts
277            .insert(key, Arc::clone(&built) as Arc<dyn Any + Send + Sync>);
278        built
279    }
280
281    /// How many form parses are in flight.
282    #[must_use]
283    pub fn forms_in_flight(&self) -> usize {
284        self.in_flight.len()
285    }
286
287    /// Enter a Type 3 glyph procedure, or refuse when the cap is reached.
288    ///
289    /// Returns `false` at the cap, in which case the caller must **not** call
290    /// [`Self::leave_type3`]. Compared with `>=`, so four levels nest and the
291    /// fifth is refused.
292    pub(crate) fn enter_type3(&mut self) -> bool {
293        if self.type3_depth >= pdfrum_font::MAX_TYPE3_DEPTH {
294            return false;
295        }
296        self.type3_depth += 1;
297        true
298    }
299
300    /// Leave a Type 3 glyph procedure entered through [`Self::enter_type3`].
301    pub(crate) fn leave_type3(&mut self) {
302        self.type3_depth = self.type3_depth.saturating_sub(1);
303    }
304}
305
306/// Where each `/Contents` element's operators begin, within one flat operator
307/// list.
308///
309/// A page's content is the concatenation of its `/Contents` streams, and the
310/// interpreter reads it as one run — a `q` in one element is closed by the `Q`
311/// in the next, which is legal and common. The editor still needs to know
312/// which element each object came from, so the boundaries travel alongside the
313/// operators rather than being recovered from them.
314///
315/// `starts[i]` is the index of the first operator belonging to element `i`.
316/// An empty record means a single unsplit stream: everything is element 0.
317#[derive(Debug, Clone, Default, PartialEq, Eq)]
318pub struct StreamBounds {
319    starts: Vec<usize>,
320}
321
322impl StreamBounds {
323    /// The boundaries for content split into elements of the given operator
324    /// counts.
325    #[must_use]
326    pub fn from_counts(counts: impl IntoIterator<Item = usize>) -> Self {
327        let mut starts = Vec::new();
328        let mut at = 0usize;
329        for count in counts {
330            starts.push(at);
331            at = at.saturating_add(count);
332        }
333        Self { starts }
334    }
335
336    /// The boundaries for content joined from elements ending at the given
337    /// byte offsets.
338    ///
339    /// `ends[i]` is one past the last byte of element `i`, counting the
340    /// separator a join inserts. A single element — or none — yields the
341    /// default, where everything is element 0.
342    // Each element is parsed on its own and its operators counted, which is
343    // exact rather than approximate because of that separating space: it
344    // terminates whatever token the element ended on, so no operator can span a
345    // boundary. The last element takes whatever the joined list has left over,
346    // which absorbs any disagreement rather than dropping objects off the end.
347    #[must_use]
348    pub fn from_joined(bytes: &[u8], total_ops: usize, ends: &[usize], limits: &Limits) -> Self {
349        if ends.len() <= 1 {
350            return Self::default();
351        }
352        let mut counts = Vec::with_capacity(ends.len());
353        let mut start = 0usize;
354        let mut consumed = 0usize;
355        for (index, end) in ends.iter().enumerate() {
356            if index.saturating_add(1) == ends.len() {
357                counts.push(total_ops.saturating_sub(consumed));
358                break;
359            }
360            let element = bytes.get(start..*end).unwrap_or_default();
361            // The diagnostics these parses raise are the ones the joined parse
362            // already recorded, so they are discarded rather than doubled.
363            let mut ignored = Diagnostics::default();
364            let count = crate::parse_content(element, limits, &mut ignored).len();
365            consumed = consumed.saturating_add(count);
366            counts.push(count);
367            start = *end;
368        }
369        Self::from_counts(counts)
370    }
371
372    /// Which element the operator at `op_index` belongs to.
373    ///
374    /// The last element whose start is at or before the operator — so an
375    /// operator past every recorded start belongs to the final element, and a
376    /// record with no starts at all answers `0`.
377    #[must_use]
378    pub fn stream_of(&self, op_index: usize) -> usize {
379        self.starts
380            .partition_point(|start| *start <= op_index)
381            .saturating_sub(1)
382    }
383
384    /// How many elements the content was split into.
385    #[must_use]
386    pub fn len(&self) -> usize {
387        self.starts.len()
388    }
389
390    /// Whether the content was never split.
391    #[must_use]
392    pub fn is_empty(&self) -> bool {
393        self.starts.is_empty()
394    }
395}
396
397/// Build a page from its operators.
398///
399/// `resources` is what named lookups consult, and `initial` is the state the
400/// content begins in — the identity transform and opaque black for a page,
401/// the caller's state for a form.
402///
403/// ```
404/// use pdfrum_common::{Diagnostics, Limits};
405/// use pdfrum_object::NoResolve;
406/// use pdfrum_page::{BuildContext, PageObject, Resources, build_page, parse_content};
407///
408/// let mut diags = Diagnostics::default();
409/// let limits = Limits::default();
410/// let ops = parse_content(b"0 0 10 10 re f", &limits, &mut diags);
411///
412/// let mut ctx = BuildContext::new();
413/// let page = build_page(
414///     &ops,
415///     &Resources::default(),
416///     &NoResolve,
417///     &mut ctx,
418///     &limits,
419///     &mut diags,
420/// );
421/// assert_eq!(page.objects.len(), 1);
422/// assert!(matches!(page.objects[0], PageObject::Path(_)));
423/// ```
424#[must_use]
425pub fn build_page<R: Resolve>(
426    ops: &[Op],
427    resources: &Resources,
428    r: &R,
429    ctx: &mut BuildContext,
430    limits: &Limits,
431    diags: &mut Diagnostics,
432) -> Page {
433    let objects = interpret(
434        ops,
435        resources,
436        &GraphicsState::default(),
437        Affine::IDENTITY,
438        r,
439        ctx,
440        limits,
441        diags,
442    );
443    Page {
444        objects,
445        resources: resources.chosen.clone(),
446        ..Page::empty()
447    }
448}
449
450/// Build a page from its operators and its dictionary.
451///
452/// The dictionary supplies the boxes, the rotation and the transparency
453/// group; `inherited` answers for a key the page tree may hold further up.
454#[expect(
455    clippy::too_many_arguments,
456    reason = "a page needs its operators, dictionary, inherited attributes, \
457              resources and the usual resolver/context/limits/diagnostics"
458)]
459#[must_use]
460pub fn build_page_from_dict<R: Resolve>(
461    ops: &[Op],
462    dict: &Dict,
463    inherited: impl Fn(&Name) -> Option<Object>,
464    resources: &Resources,
465    r: &R,
466    ctx: &mut BuildContext,
467    limits: &Limits,
468    diags: &mut Diagnostics,
469) -> Page {
470    build_page_streams(
471        ops,
472        &StreamBounds::default(),
473        dict,
474        inherited,
475        resources,
476        r,
477        ctx,
478        limits,
479        diags,
480    )
481}
482
483/// Build a page whose `/Contents` boundaries are known, so every object
484/// records which element it came from.
485///
486/// This is [`build_page_from_dict`] plus the two facts only an editor needs:
487/// each object's content-stream index, and the transform each element leaves
488/// behind. A caller that will only render or extract text wants
489/// [`build_page_from_dict`], which pays for neither.
490#[expect(
491    clippy::too_many_arguments,
492    reason = "as `build_page_from_dict`, plus the stream boundaries"
493)]
494#[must_use]
495pub fn build_page_streams<R: Resolve>(
496    ops: &[Op],
497    bounds: &StreamBounds,
498    dict: &Dict,
499    inherited: impl Fn(&Name) -> Option<Object>,
500    resources: &Resources,
501    r: &R,
502    ctx: &mut BuildContext,
503    limits: &Limits,
504    diags: &mut Diagnostics,
505) -> Page {
506    let (media_box, crop_box) = crate::page::derive_boxes(dict, &inherited, r, diags);
507    let rotate = crate::page::Rotation::from_degrees(
508        dict.int(names::ROTATE, r)
509            .or_else(|| inherited(names::ROTATE).and_then(|o| o.as_int()))
510            .unwrap_or(0),
511    );
512    let transparency = Transparency::for_page(dict.dict(names::GROUP, r).as_ref(), r);
513    let (objects, stream_ctms) = interpret_streams(
514        ops,
515        bounds,
516        resources,
517        &GraphicsState::default(),
518        Affine::IDENTITY,
519        r,
520        ctx,
521        limits,
522        diags,
523    );
524    Page {
525        objects,
526        media_box,
527        crop_box,
528        rotate,
529        transparency,
530        resources: resources.chosen.clone(),
531        dirty_streams: BTreeSet::new(),
532        stream_ctms,
533    }
534}
535
536/// Build one form `XObject` as a standalone page object, placed by `matrix`.
537///
538/// This is the `Do` handler's body reached from outside a content stream,
539/// which is what an **annotation appearance** needs: the annotation matrix is
540/// handed in directly rather than built up by operators, and the result is
541/// appended to the page's own object list.
542///
543/// The form's `/Matrix` composes with `matrix` exactly as it would inside a
544/// `Do`, and a missing `/BBox` is **no clip at all** rather than an empty one.
545/// `resources` is the fallback for a form that declares none; an annotation's
546/// appearance is not part of the page's content stream, so the page's own
547/// resources are what it inherits.
548#[must_use]
549pub fn build_form_object<R: Resolve>(
550    stream: &pdfrum_object::Stream,
551    matrix: Affine,
552    resources: &Resources,
553    r: &R,
554    ctx: &mut BuildContext,
555    limits: &Limits,
556    diags: &mut Diagnostics,
557) -> Option<PageObject> {
558    build_form_object_with(stream, matrix, resources, r, ctx, limits, diags, false)
559}
560
561/// The same build, told whether the appearance is a **live edit's**.
562///
563/// [`build_form_object`] is this with `live_edit = false`, which is every
564/// appearance the file itself carries. A form session hands `true` for the one
565/// field it is currently editing, and that flag lands on
566/// [`FormObject::live_edit`] for a renderer to read — see its documentation for
567/// why the distinction has to travel with the object rather than with the
568/// render call.
569#[must_use]
570#[expect(
571    clippy::too_many_arguments,
572    reason = "one more than `build_form_object`, which is already at the \
573              limit; grouping the resolver, context, limits and sink into a \
574              struct is a change to every builder entry point in this crate \
575              and not this function's to make"
576)]
577pub fn build_form_object_with<R: Resolve>(
578    stream: &pdfrum_object::Stream,
579    matrix: Affine,
580    resources: &Resources,
581    r: &R,
582    ctx: &mut BuildContext,
583    limits: &Limits,
584    diags: &mut Diagnostics,
585    live_edit: bool,
586) -> Option<PageObject> {
587    let content = pdfrum_filters::decode_chain(stream, 0, r, limits, diags).data;
588    let form_matrix = stream.dict.matrix(names::MATRIX, r);
589    let placed = matrix * form_matrix;
590
591    let mut state = GraphicsState {
592        ctm: placed,
593        ..GraphicsState::default()
594    };
595
596    let transparency = Transparency::from_group(stream.dict.dict(names::GROUP, r).as_ref(), r);
597    if transparency.group {
598        state.general.enter_transparency_group();
599    }
600
601    let bbox = stream
602        .dict
603        .array(names::BBOX, r)
604        .filter(|a| a.len() == 4)
605        .map(|a| a.as_rect());
606    // The `/BBox` is a *clip*, and here it has to be pushed onto the state
607    // rather than left as a field: an appearance form reached from a content
608    // stream inherits the enclosing `q`/`Q` clip, but one reached from an
609    // annotation has no enclosing anything, so nothing else would ever apply
610    // it. Without this an ink annotation whose `/InkList` runs outside its
611    // `/Rect` paints strokes the oracle clips away entirely — which is what
612    // `ink_annot.in`'s all-white golden says.
613    if let Some(rect) = bbox {
614        let mut path = kurbo::BezPath::new();
615        path.move_to((rect.x0, rect.y0));
616        path.line_to((rect.x1, rect.y0));
617        path.line_to((rect.x1, rect.y1));
618        path.line_to((rect.x0, rect.y1));
619        path.close_path();
620        state.clip.push_path(placed * path, ClipRule::Winding);
621    }
622
623    let inner = Resources::choose(
624        stream.dict.dict(names::RESOURCES, r),
625        resources.chosen.clone(),
626        resources.page.clone(),
627    );
628
629    let ops = crate::parse_content(&content, limits, diags);
630    let objects = interpret(&ops, &inner, &state, placed, r, ctx, limits, diags);
631
632    Some(PageObject::Form(Box::new(Content {
633        object: FormObject {
634            objects,
635            matrix: placed,
636            bbox,
637            transparency,
638            oc: stream.dict.dict(names::OC, r).map(Arc::new),
639            // An annotation appearance is reached from `/AP`, not from a
640            // resource dictionary, so there is no `/XObject` name for it.
641            source: None,
642            live_edit,
643        },
644        state,
645        marks: ContentMarks::default(),
646        // An annotation appearance is not part of the page's content stream,
647        // so it has no index in one. The dump numbers streams from zero and
648        // the oracle counts an annotation's form as belonging to none.
649        content_stream: None,
650        // An appearance is drawn into the page graph but is not page content:
651        // it is never regenerated into `/Contents`, and marking it dirty
652        // would make an ordinary render rewrite the page.
653        dirty: false,
654        active: true,
655    })))
656}
657
658/// The fold's mutable working set, kept together so no function needs a
659/// dozen parameters.
660struct Interp<'a, R: Resolve> {
661    state: GraphicsState,
662    stack: StateStack,
663    marks: ContentMarks,
664    cursor: TextCursor,
665    /// The path being assembled, as points with their kinds.
666    points: Vec<PathPoint>,
667    /// The rule a pending `W`/`W*` will clip with, once a painting operator
668    /// consumes it.
669    pending_clip: FillRule,
670    /// Where the current subpath began, for `h`.
671    subpath_start: Point,
672    /// The current point.
673    current: Point,
674    /// Runs a clipping text mode has accumulated since the last `ET`.
675    text_clip: Vec<TextClipRun>,
676    resources: &'a Resources,
677    /// The form's or page's coordinate system, which patterns anchor to —
678    /// **not** the current transform.
679    parent_matrix: Affine,
680    resolver: &'a R,
681    objects: Vec<PageObject>,
682    /// Which `/Contents` element the operator being applied came from, which
683    /// every object it produces records (see [`crate::mutate`]).
684    stream: usize,
685    /// The transform each element leaves in force at its end, recorded only
686    /// where it changed.
687    stream_ctms: BTreeMap<usize, Affine>,
688}
689
690/// How a path point continues the path.
691///
692/// The three PDF segment types and nothing else — upstream's
693/// `CFX_Path::Point::Type` (`core/fxcrt/fx_coordinates.h`) has exactly these
694/// and carries "closes the subpath" in a *separate* `close_figure_` flag
695/// (`cpdf_streamcontentparser.cpp:979`). [`PathPoint`] keeps that split.
696#[derive(Debug, Clone, Copy, PartialEq, Eq)]
697enum PointKind {
698    Move,
699    Line,
700    Curve,
701}
702
703/// One point of the path under construction: how it continues the path, and
704/// whether it also closes the subpath.
705///
706/// The two are independent, and conflating them is a bug rather than a
707/// simplification. `h` on a subpath whose current point already equals its
708/// start marks the point it lands on as closing
709/// (`cpdf_streamcontentparser.cpp:976-980`) — and that point is very often
710/// the **third control point of a curve**, since a glyph outline traced in
711/// Béziers ends where it began. Encoding "closes" by rewriting the kind
712/// turns that curve into a straight line and, worse, strands its first two
713/// control points in [`build_path`]'s pending buffer, where the *next*
714/// subpath's first curve point completes a bogus triple: a curve reaching
715/// back to the previous glyph. On `vector_en_system.pdf` that painted a long
716/// diagonal stroke between every pair of glyph outlines.
717#[derive(Debug, Clone, Copy, PartialEq)]
718struct PathPoint {
719    at: Point,
720    kind: PointKind,
721    /// `h`'s `close_figure_`: this point ends its subpath, which a stroke
722    /// closes back to the subpath's start.
723    closes: bool,
724}
725
726impl PathPoint {
727    /// A point that continues the path without closing it.
728    const fn new(at: Point, kind: PointKind) -> Self {
729        Self {
730            at,
731            kind,
732            closes: false,
733        }
734    }
735
736    /// A `Line` to `at` that also closes the subpath — `h`'s explicit closing
737    /// segment, and the closing edge `re` writes itself.
738    const fn closing_line(at: Point) -> Self {
739        Self {
740            at,
741            kind: PointKind::Line,
742            closes: true,
743        }
744    }
745}
746
747/// Interpret a run of operators into page objects.
748///
749/// Every object records content stream 0, which is what a form, a pattern and
750/// a glyph procedure want: they are one stream, and their objects are never
751/// separately regenerated.
752#[expect(
753    clippy::too_many_arguments,
754    reason = "the interpreter needs its operators, resources, initial state, \
755              parent matrix, resolver, context, limits and diagnostics"
756)]
757fn interpret<R: Resolve>(
758    ops: &[Op],
759    resources: &Resources,
760    initial: &GraphicsState,
761    parent_matrix: Affine,
762    r: &R,
763    ctx: &mut BuildContext,
764    limits: &Limits,
765    diags: &mut Diagnostics,
766) -> Vec<PageObject> {
767    interpret_streams(
768        ops,
769        &StreamBounds::default(),
770        resources,
771        initial,
772        parent_matrix,
773        r,
774        ctx,
775        limits,
776        diags,
777    )
778    .0
779}
780
781/// How many operators the interpreter applies between deadline checks: a
782/// batch, so the clock is read a few hundred times on the largest content
783/// streams and not once per operator.
784const DEADLINE_STRIDE: usize = 256;
785
786/// Interpret a run of operators whose `/Contents` boundaries are known.
787///
788/// Each object records the element it came from, and the transform each
789/// element leaves behind is returned alongside — the two facts a regenerated
790/// page needs and a rendered one does not.
791///
792/// Stops at `limits.deadline`, checked every [`DEADLINE_STRIDE`] operators:
793/// the objects built so far are returned and
794/// [`DiagKind::TimeLimitReached`] records that the rest were not.
795#[expect(
796    clippy::too_many_arguments,
797    reason = "as `interpret`, plus the stream boundaries the editor needs"
798)]
799fn interpret_streams<R: Resolve>(
800    ops: &[Op],
801    bounds: &StreamBounds,
802    resources: &Resources,
803    initial: &GraphicsState,
804    parent_matrix: Affine,
805    r: &R,
806    ctx: &mut BuildContext,
807    limits: &Limits,
808    diags: &mut Diagnostics,
809) -> (Vec<PageObject>, BTreeMap<usize, Affine>) {
810    let mut interp = Interp {
811        state: initial.clone(),
812        stack: StateStack::new(),
813        marks: ContentMarks::new(),
814        cursor: TextCursor::default(),
815        points: Vec::new(),
816        pending_clip: FillRule::None,
817        subpath_start: Point::ZERO,
818        current: Point::ZERO,
819        text_clip: Vec::new(),
820        resources,
821        parent_matrix,
822        resolver: r,
823        objects: Vec::new(),
824        stream: 0,
825        stream_ctms: BTreeMap::new(),
826    };
827    for (index, op) in ops.iter().enumerate() {
828        if index.is_multiple_of(DEADLINE_STRIDE)
829            && limits.check_deadline(Operation::Interpret).is_err()
830        {
831            diags.record(Severity::Suspicious, DiagKind::TimeLimitReached, None);
832            break;
833        }
834        interp.stream = bounds.stream_of(index);
835        interp.apply(op, ctx, limits, diags);
836    }
837    (interp.objects, interp.stream_ctms)
838}
839
840impl<R: Resolve> Interp<'_, R> {
841    /// Apply one operator.
842    #[expect(
843        clippy::too_many_lines,
844        reason = "the operator dispatch is a flat table by design: one arm \
845                  per operator, each a few lines, and splitting it would \
846                  hide which operator does what"
847    )]
848    fn apply(&mut self, op: &Op, ctx: &mut BuildContext, limits: &Limits, diags: &mut Diagnostics) {
849        match op {
850            // ---- Graphics state ----
851            Op::SaveState() => self.stack.push(&self.state),
852            Op::RestoreState() => {
853                if !self.stack.pop(&mut self.state) {
854                    diags.record(Severity::Suspicious, DiagKind::UnbalancedRestore, None);
855                }
856                // A `Q` restores a transform as surely as a `cm` sets one, and
857                // an unbalanced one carries the change into the next stream.
858                self.record_ctm();
859            }
860            // A **pre**-concatenation: the new matrix applies first.
861            Op::Concat(m) => {
862                self.state.ctm *= *m;
863                self.record_ctm();
864            }
865            Op::SetLineWidth(w) => self.state.stroke_params.width = *w,
866            Op::SetLineCap(c) => self.state.stroke_params.cap = *c,
867            Op::SetLineJoin(j) => self.state.stroke_params.join = *j,
868            Op::SetMiterLimit(m) => self.state.stroke_params.miter_limit = *m,
869            Op::SetDash(d) => {
870                // A non-array first operand makes the operator a no-op.
871                if d.valid {
872                    self.state.stroke_params.dash.clone_from(&d.array);
873                    self.state.stroke_params.dash_phase = d.phase;
874                }
875            }
876            Op::SetFlatness(f) => self.state.general.flatness = *f,
877            Op::SetExtGState(name) => self.apply_ext_gstate(name, ctx, limits, diags),
878
879            // ---- Path construction ----
880            Op::MoveTo(p) => {
881                self.add_point(PathPoint::new(*p, PointKind::Move));
882                self.subpath_start = *p;
883            }
884            Op::LineTo(p) => self.add_point(PathPoint::new(*p, PointKind::Line)),
885            Op::CurveTo(a, b, c) => {
886                self.add_point(PathPoint::new(*a, PointKind::Curve));
887                self.add_point(PathPoint::new(*b, PointKind::Curve));
888                self.add_point(PathPoint::new(*c, PointKind::Curve));
889            }
890            // The first control point is the current point.
891            Op::CurveToV(b, c) => {
892                let start = self.current;
893                self.add_point(PathPoint::new(start, PointKind::Curve));
894                self.add_point(PathPoint::new(*b, PointKind::Curve));
895                self.add_point(PathPoint::new(*c, PointKind::Curve));
896            }
897            // The last point is duplicated as the second control point.
898            Op::CurveToY(a, c) => {
899                self.add_point(PathPoint::new(*a, PointKind::Curve));
900                self.add_point(PathPoint::new(*c, PointKind::Curve));
901                self.add_point(PathPoint::new(*c, PointKind::Curve));
902            }
903            Op::ClosePath() => self.close_path(),
904            Op::Rectangle(x, y, w, h) => {
905                let (x, y, w, h) = (f64::from(*x), f64::from(*y), f64::from(*w), f64::from(*h));
906                self.add_point(PathPoint::new(Point::new(x, y), PointKind::Move));
907                self.add_point(PathPoint::new(Point::new(x + w, y), PointKind::Line));
908                self.add_point(PathPoint::new(Point::new(x + w, y + h), PointKind::Line));
909                self.add_point(PathPoint::new(Point::new(x, y + h), PointKind::Line));
910                self.add_point(PathPoint::closing_line(Point::new(x, y)));
911                self.subpath_start = Point::new(x, y);
912            }
913
914            // ---- Path painting ----
915            Op::Stroke() => self.paint(FillRule::None, true),
916            Op::CloseStroke() => {
917                self.close_path();
918                self.paint(FillRule::None, true);
919            }
920            Op::Fill() | Op::FillObsolete() => self.paint(FillRule::Winding, false),
921            Op::FillEvenOdd() => self.paint(FillRule::EvenOdd, false),
922            Op::FillStroke() => self.paint(FillRule::Winding, true),
923            Op::FillStrokeEvenOdd() => self.paint(FillRule::EvenOdd, true),
924            Op::CloseFillStroke() => {
925                self.close_path();
926                self.paint(FillRule::Winding, true);
927            }
928            // Unlike `b`, this appends the closing segment **unconditionally**
929            // rather than only when the current point differs from the start.
930            Op::CloseFillStrokeEvenOdd() => {
931                let start = self.subpath_start;
932                self.current = start;
933                if !self.points.is_empty() {
934                    self.points.push(PathPoint::closing_line(start));
935                }
936                self.paint(FillRule::EvenOdd, true);
937            }
938            Op::EndPath() => self.paint(FillRule::None, false),
939
940            // ---- Clipping ----
941            Op::Clip() => self.pending_clip = FillRule::Winding,
942            Op::ClipEvenOdd() => self.pending_clip = FillRule::EvenOdd,
943
944            // ---- Text ----
945            Op::BeginText() => {
946                // `BT` resets the matrices but does **not** clear the text
947                // clip list; that is `ET`'s job.
948                self.cursor.set_matrix(Affine::IDENTITY);
949            }
950            Op::EndText() => {
951                // `Handle_EndText` (`cpdf_streamcontentparser.cpp:921-931`)
952                // re-reads the render mode **at `ET`**, not the one each run
953                // was shown under. A `BT … 7 Tr (x) Tj 0 Tr ET` therefore
954                // clips with nothing at all: the runs were collected, and the
955                // mode that decides whether to keep them has since changed.
956                // Either way the list is cleared, so they do not survive into
957                // the next text object.
958                let runs = std::mem::take(&mut self.text_clip);
959                if !runs.is_empty() && self.state.text.render_mode.clips() {
960                    let _ = self.state.clip.push_text(runs);
961                }
962            }
963            Op::TextMove(tx, ty) => {
964                self.cursor.move_line(f64::from(*tx), f64::from(*ty));
965            }
966            Op::TextMoveSetLeading(tx, ty) => {
967                self.cursor.move_line(f64::from(*tx), f64::from(*ty));
968                // The **negated** y offset.
969                self.state.text.leading = -*ty;
970            }
971            Op::SetTextMatrix(m) => self.cursor.set_matrix(*m),
972            Op::TextNextLine() => {
973                self.cursor.next_line(f64::from(self.state.text.leading));
974            }
975            Op::SetLeading(l) => self.state.text.leading = *l,
976            Op::SetTextRise(rise) => self.state.text.rise = *rise,
977            // Stored as a fraction: `150 Tz` becomes 1.5.
978            Op::SetHorzScale(z) => self.state.text.horz_scale = *z / 100.0,
979            Op::SetCharSpace(c) => self.state.text.char_space = *c,
980            Op::SetWordSpace(w) => self.state.text.word_space = *w,
981            Op::SetFont(name, size) => {
982                // The size is **always** set; the font only when it resolves.
983                let font = self.find_font(name, ctx, limits, diags);
984                let source = self.resources.find_ref(names::FONT, name, self.resolver);
985                match (font, self.state.text.font.take()) {
986                    (Some(f), _) => {
987                        self.state.text.font = Some((f, *size));
988                        self.state.text.font_source = source;
989                    }
990                    // A name that did not resolve leaves the standing font —
991                    // and therefore the resource naming it — in place.
992                    (None, Some((old, _))) => self.state.text.font = Some((old, *size)),
993                    (None, None) => {}
994                }
995            }
996            Op::SetTextRenderMode(mode) => match TextRenderMode::from_int(*mode) {
997                Some(m) => self.state.text.render_mode = m,
998                // Out of range leaves the mode **unchanged**.
999                None => {
1000                    diags.record(Severity::Suspicious, DiagKind::BadTextRenderMode, None);
1001                }
1002            },
1003            Op::ShowText(s) => self.show_text(&[(s.bytes.clone(), 0.0)], 0.0, ctx, limits, diags),
1004            Op::NextLineShowText(s) => {
1005                self.cursor.next_line(f64::from(self.state.text.leading));
1006                self.show_text(&[(s.bytes.clone(), 0.0)], 0.0, ctx, limits, diags);
1007            }
1008            Op::SetSpacingShowText(word, char_space, s) => {
1009                self.state.text.word_space = *word;
1010                self.state.text.char_space = *char_space;
1011                self.cursor.next_line(f64::from(self.state.text.leading));
1012                self.show_text(&[(s.bytes.clone(), 0.0)], 0.0, ctx, limits, diags);
1013            }
1014            Op::ShowTextAdjusted(array) => {
1015                if array.valid {
1016                    self.show_adjusted(&array.items, ctx, limits, diags);
1017                }
1018            }
1019
1020            // ---- Colour ----
1021            Op::SetStrokeColorSpace(name) => {
1022                self.set_color_space(name, true, ctx, limits, diags);
1023            }
1024            Op::SetFillColorSpace(name) => {
1025                self.set_color_space(name, false, ctx, limits, diags);
1026            }
1027            Op::SetStrokeColor(c) => {
1028                let _ = self.state.stroke.set_components(&c.0);
1029            }
1030            Op::SetFillColor(c) => {
1031                let _ = self.state.fill.set_components(&c.0);
1032            }
1033            Op::SetStrokeColorN(c) => self.set_color_n(c, true, ctx, limits, diags),
1034            Op::SetFillColorN(c) => self.set_color_n(c, false, ctx, limits, diags),
1035            Op::SetStrokeGray(g) => {
1036                self.state.stroke.set_stock(ColorSpace::DeviceGray, &[*g]);
1037            }
1038            Op::SetFillGray(g) => self.state.fill.set_stock(ColorSpace::DeviceGray, &[*g]),
1039            Op::SetStrokeRgb(r, g, b) => {
1040                self.state
1041                    .stroke
1042                    .set_stock(ColorSpace::DeviceRgb, &[*r, *g, *b]);
1043            }
1044            Op::SetFillRgb(r, g, b) => {
1045                self.state
1046                    .fill
1047                    .set_stock(ColorSpace::DeviceRgb, &[*r, *g, *b]);
1048            }
1049            Op::SetStrokeCmyk(c, m, y, k) => {
1050                self.state
1051                    .stroke
1052                    .set_stock(ColorSpace::DeviceCmyk, &[*c, *m, *y, *k]);
1053            }
1054            Op::SetFillCmyk(c, m, y, k) => {
1055                self.state
1056                    .fill
1057                    .set_stock(ColorSpace::DeviceCmyk, &[*c, *m, *y, *k]);
1058            }
1059
1060            // ---- XObjects and shading ----
1061            Op::DoXObject(name) => self.do_xobject(name, ctx, limits, diags),
1062            Op::ShadeFill(name) => self.shade_fill(name, ctx, limits, diags),
1063            Op::InlineImage(image) => self.inline_image(image, ctx, limits, diags),
1064
1065            // ---- Marked content ----
1066            Op::BeginMarkedContent(tag) => self.marks.push(tag.clone()),
1067            Op::BeginMarkedContentDict(tag, props) => {
1068                if let Some(properties) = &props.0 {
1069                    let resources = self.resources;
1070                    let resolver = self.resolver;
1071                    self.marks
1072                        .push_with_properties(tag.clone(), properties, |name| {
1073                            resources
1074                                .find(names::PROPERTIES, name, resolver)
1075                                .and_then(|o| o.as_dict().cloned())
1076                        });
1077                }
1078                // A null or wrong-typed property list pushes nothing at all.
1079            }
1080            Op::EndMarkedContent() => {
1081                if !self.marks.pop() {
1082                    diags.record(
1083                        Severity::Suspicious,
1084                        DiagKind::UnbalancedMarkedContent,
1085                        None,
1086                    );
1087                }
1088            }
1089
1090            // ---- Operators that produce no page object ----
1091            //
1092            // Each for its own reason: `d0` and `d1` are Type 3 glyph metrics
1093            // the font layer consumes; `ri` is discarded outright, since only
1094            // the `/ExtGState` `/RI` is stored; `MP` and `DP` are
1095            // marked-content *points*, which carry no scope; `BI`, `ID` and
1096            // `EI` reach dispatch only when the tokenizer abandoned an inline
1097            // image or met a stray keyword; `BX` and `EX` are compatibility
1098            // brackets; and an unknown keyword had its operands cleared by
1099            // the caller.
1100            Op::Type3Width(..)
1101            | Op::Type3WidthBBox(..)
1102            | Op::SetRenderIntent(_)
1103            | Op::MarkPoint(_)
1104            | Op::MarkPointDict(..)
1105            | Op::BeginInlineImage()
1106            | Op::InlineImageData()
1107            | Op::EndInlineImage()
1108            | Op::BeginCompat()
1109            | Op::EndCompat()
1110            | Op::Unknown(_) => {}
1111        }
1112    }
1113
1114    /// The three path repairs from the module docs.
1115    fn add_point(&mut self, point: PathPoint) {
1116        self.current = point.at;
1117        match self.points.last() {
1118            // A `Move` onto an open `Move`: drop the duplicate, or overwrite.
1119            Some(previous) if previous.kind == PointKind::Move && point.kind == PointKind::Move => {
1120                if previous.at == point.at {
1121                    return;
1122                }
1123                if let Some(last) = self.points.last_mut() {
1124                    *last = point;
1125                }
1126                return;
1127            }
1128            // A non-`Move` with nothing started is discarded.
1129            None if point.kind != PointKind::Move => return,
1130            _ => {}
1131        }
1132        self.points.push(point);
1133    }
1134
1135    /// `h`: close the current subpath.
1136    ///
1137    /// `cpdf_streamcontentparser.cpp:971-981` verbatim: a current point that
1138    /// has already returned to the subpath's start needs no closing segment,
1139    /// so the point it landed on is *flagged* as closing — upstream's
1140    /// `path_points_.back().close_figure_ = true` — and its own segment type
1141    /// is left alone. Rewriting the type instead is what turned a glyph's
1142    /// final curve into a line and stranded its control points; see
1143    /// [`PathPoint`].
1144    fn close_path(&mut self) {
1145        if self.points.is_empty() {
1146            return;
1147        }
1148        if self.current == self.subpath_start {
1149            if let Some(last) = self.points.last_mut() {
1150                last.closes = true;
1151            }
1152        } else {
1153            let start = self.subpath_start;
1154            self.points.push(PathPoint::closing_line(start));
1155            self.current = start;
1156        }
1157    }
1158
1159    /// Take the pending path and paint it.
1160    fn paint(&mut self, fill_rule: FillRule, stroke: bool) {
1161        let points = std::mem::take(&mut self.points);
1162        // The pending clip is consumed by whichever painting operator comes
1163        // next, `n` included.
1164        let clip_rule = std::mem::replace(&mut self.pending_clip, FillRule::None);
1165
1166        if points.is_empty() {
1167            // The clip is discarded along with the path.
1168            return;
1169        }
1170        let matrix = self.state.ctm;
1171
1172        // A single point is a special case in both directions.
1173        if points.len() == 1 {
1174            if clip_rule != FillRule::None {
1175                // An empty clip, which blanks everything after it.
1176                self.state.clip.push_empty();
1177                return;
1178            }
1179            let point = points
1180                .first()
1181                .copied()
1182                .unwrap_or(PathPoint::new(Point::ZERO, PointKind::Move));
1183            // Only a closed move under a round cap draws anything: a dot.
1184            if !point.closes || self.state.stroke_params.cap != LineCap::Round {
1185                return;
1186            }
1187            let mut path = BezPath::new();
1188            path.move_to(point.at);
1189            path.line_to(point.at);
1190            path.close_path();
1191            self.emit_path(path, matrix, fill_rule, stroke, clip_rule);
1192            return;
1193        }
1194
1195        // A trailing open `Move` contributes nothing and is dropped.
1196        let mut points = points;
1197        if matches!(points.last(), Some(last) if last.kind == PointKind::Move) {
1198            points.pop();
1199        }
1200        if points.is_empty() {
1201            return;
1202        }
1203        let path = build_path(&points);
1204        self.emit_path(path, matrix, fill_rule, stroke, clip_rule);
1205    }
1206
1207    /// Emit a path object and apply any pending clip.
1208    fn emit_path(
1209        &mut self,
1210        path: BezPath,
1211        matrix: Affine,
1212        fill_rule: FillRule,
1213        stroke: bool,
1214        clip_rule: FillRule,
1215    ) {
1216        // `n` with no clip produces nothing at all.
1217        if stroke || fill_rule != FillRule::None {
1218            let object = PathObject {
1219                path: path.clone(),
1220                matrix,
1221                fill_rule,
1222                stroke,
1223            };
1224            self.push(PageObject::Path(Box::new(self.content(object))));
1225        }
1226        if clip_rule != FillRule::None {
1227            // The clip path is transformed; the drawn path keeps its matrix
1228            // separately.
1229            let clipped = if matrix == Affine::IDENTITY {
1230                path
1231            } else {
1232                matrix * path
1233            };
1234            self.state.clip.push_path(
1235                clipped,
1236                match clip_rule {
1237                    FillRule::EvenOdd => ClipRule::EvenOdd,
1238                    _ => ClipRule::Winding,
1239                },
1240            );
1241        }
1242    }
1243
1244    /// Wrap an object with the state and marks in force.
1245    fn content<T>(&self, object: T) -> Content<T> {
1246        Content {
1247            object,
1248            state: self.state.clone(),
1249            marks: self.marks.clone(),
1250            content_stream: Some(self.stream),
1251            // Parsed objects describe bytes that already exist, so nothing
1252            // needs rewriting until a caller changes one.
1253            dirty: false,
1254            active: true,
1255        }
1256    }
1257
1258    /// Record the transform this stream leaves in force, after an operator
1259    /// changed it.
1260    fn record_ctm(&mut self) {
1261        self.stream_ctms.insert(self.stream, self.state.ctm);
1262    }
1263
1264    fn push(&mut self, object: PageObject) {
1265        self.objects.push(object);
1266    }
1267
1268    /// `Tj` and friends: one text object from a set of segments.
1269    fn show_text(
1270        &mut self,
1271        segments: &[(Box<[u8]>, f32)],
1272        initial_kerning: f32,
1273        ctx: &mut BuildContext,
1274        limits: &Limits,
1275        diags: &mut Diagnostics,
1276    ) {
1277        // With no font nothing is produced, which only happens when `Tf` was
1278        // never issued.
1279        let Some((font, size)) = self.state.text.font.clone() else {
1280            return;
1281        };
1282        let vertical = font.is_vertical();
1283
1284        // The initial adjustment moves the position **before** the object,
1285        // and applies even when the object turns out empty.
1286        if initial_kerning != 0.0 {
1287            let shift = -kerning_shift(initial_kerning, size, self.state.text.horz_scale, vertical);
1288            self.cursor.advance(shift, vertical);
1289        }
1290        let segments: Vec<TextSegment> = segments
1291            .iter()
1292            .filter(|(codes, _)| !codes.is_empty())
1293            .map(|(codes, kerning)| TextSegment {
1294                codes: codes.clone(),
1295                kerning: *kerning,
1296            })
1297            .collect();
1298        if segments.is_empty() {
1299            return;
1300        }
1301
1302        // A Type 3 font is forced to fill mode whatever `Tr` said.
1303        let render_mode = if font.type3().is_some() {
1304            TextRenderMode::Fill
1305        } else {
1306            self.state.text.render_mode
1307        };
1308
1309        let position = self
1310            .cursor
1311            .device_position(self.state.text.rise, self.state.ctm);
1312        let matrix = glyph_matrix(
1313            self.state.text.horz_scale,
1314            self.cursor.matrix,
1315            self.state.ctm,
1316        );
1317        let advance = self.advance_for(&segments, &font, size);
1318        let type3_metrics = self.type3_metrics_for(&segments, &font, ctx, limits, diags);
1319
1320        let object = TextObject {
1321            segments: segments.into(),
1322            position,
1323            matrix,
1324            font: Some((Arc::clone(&font), size)),
1325            font_source: self.state.text.font_source,
1326            render_mode,
1327            type3_metrics,
1328        };
1329        // A run in a clipping mode is held for the `ET` that closes the text
1330        // object, which is where it reaches the clip stack. It is *also*
1331        // pushed as an ordinary page object: upstream appends to
1332        // `clip_text_list_` and then to the object holder from the same block
1333        // (`cpdf_streamcontentparser.cpp:1359-1362`), because `Tr 4` fills
1334        // and clips, and even `Tr 7` still runs the paint pass — it simply
1335        // paints nothing.
1336        if render_mode.clips() {
1337            self.text_clip.push(TextClipRun {
1338                object: object.clone(),
1339                char_space: self.state.text.char_space,
1340                word_space: self.state.text.word_space,
1341            });
1342        }
1343        // The CTM is snapshotted onto this object, not the live text state:
1344        // a later fill-mode `Tj` must still carry identity, matching the
1345        // oracle writing into the object's `ctm_` and leaving `cur_states_`
1346        // untouched.
1347        let mut content = self.content(object);
1348        if render_mode.strokes() {
1349            content.state.text.stroke_ctm = stroke_ctm_of(self.state.ctm);
1350        }
1351        self.push(PageObject::Text(Box::new(content)));
1352        self.cursor.advance(advance, vertical);
1353    }
1354
1355    /// What each shown character's Type 3 glyph procedure declares.
1356    ///
1357    /// Empty for every other kind of font. Reading it here rather than
1358    /// downstream is what lets a consumer measure a Type 3 glyph at all: the
1359    /// numbers live inside content streams only the interpreter opens.
1360    fn type3_metrics_for(
1361        &self,
1362        segments: &[TextSegment],
1363        font: &Font,
1364        ctx: &mut BuildContext,
1365        limits: &Limits,
1366        diags: &mut Diagnostics,
1367    ) -> std::collections::BTreeMap<u32, crate::type3::Type3Metrics> {
1368        let mut out = std::collections::BTreeMap::new();
1369        let Some(type3) = font.type3() else {
1370            return out;
1371        };
1372        for segment in segments {
1373            for item in font.decode(&segment.codes) {
1374                if out.contains_key(&item.code.0) {
1375                    continue;
1376                }
1377                if let Some(m) = crate::type3::metrics(
1378                    type3,
1379                    item.code,
1380                    self.resources.page.as_ref(),
1381                    self.resolver,
1382                    ctx,
1383                    limits,
1384                    diags,
1385                ) {
1386                    out.insert(item.code.0, m);
1387                }
1388            }
1389        }
1390        out
1391    }
1392
1393    /// The advance a run produces, in text space.
1394    fn advance_for(&self, segments: &[TextSegment], font: &Font, size: f32) -> f64 {
1395        let vertical = font.is_vertical();
1396        let mut total = 0.0f64;
1397        for segment in segments {
1398            for item in font.decode(&segment.codes) {
1399                let mut width = f64::from(item.width) * f64::from(size) / 1000.0;
1400                // Word spacing applies to a single-byte space only.
1401                if item.code.0 == 0x20 && item.cid.is_none() {
1402                    width += f64::from(self.state.text.word_space);
1403                }
1404                width += f64::from(self.state.text.char_space);
1405                total += width;
1406            }
1407            total -= kerning_shift(segment.kerning, size, 1.0, vertical);
1408        }
1409        if vertical {
1410            total
1411        } else {
1412            total * f64::from(self.state.text.horz_scale)
1413        }
1414    }
1415
1416    /// `TJ`: split the array into segments and their accumulated kernings.
1417    fn show_adjusted(
1418        &mut self,
1419        items: &[TextItem],
1420        ctx: &mut BuildContext,
1421        limits: &Limits,
1422        diags: &mut Diagnostics,
1423    ) {
1424        let strings = items
1425            .iter()
1426            .filter(|i| matches!(i, TextItem::Show(_)))
1427            .count();
1428        let vertical = self
1429            .state
1430            .text
1431            .font
1432            .as_ref()
1433            .is_some_and(|(f, _)| f.is_vertical());
1434
1435        // With no strings at all the array is pure kerning, and **only x
1436        // moves — even for a vertical font**, which is an inconsistency with
1437        // the other branch that files rely on.
1438        if strings == 0 {
1439            let Some((_, size)) = self.state.text.font.clone() else {
1440                return;
1441            };
1442            for item in items {
1443                let TextItem::Adjust(k) = item else { continue };
1444                if *k != 0.0 {
1445                    let shift = -kerning_shift(*k, size, self.state.text.horz_scale, false);
1446                    self.cursor.pos.x += shift;
1447                }
1448            }
1449            let _ = vertical;
1450            return;
1451        }
1452
1453        let mut segments: Vec<(Box<[u8]>, f32)> = Vec::new();
1454        let mut initial = 0.0f32;
1455        for item in items {
1456            match item {
1457                TextItem::Show(codes) => {
1458                    if !codes.is_empty() {
1459                        segments.push((codes.clone(), 0.0));
1460                    }
1461                }
1462                // Adjacent adjustments **accumulate**.
1463                TextItem::Adjust(k) => match segments.last_mut() {
1464                    Some((_, kerning)) => *kerning += *k,
1465                    None => initial += *k,
1466                },
1467            }
1468        }
1469        self.show_text(&segments, initial, ctx, limits, diags);
1470    }
1471
1472    /// Look a font up, falling back to Helvetica so `Tf` with a bad name
1473    /// still renders text.
1474    fn find_font(
1475        &self,
1476        name: &Name,
1477        ctx: &mut BuildContext,
1478        limits: &Limits,
1479        diags: &mut Diagnostics,
1480    ) -> Option<Arc<Font>> {
1481        // An indirect resource is cached under its reference, so every `Tf`
1482        // naming it shares one instance — across pages and across sessions,
1483        // because the cache is the document's. A resource written inline has
1484        // no reference and is loaded afresh: two inline copies genuinely are
1485        // two fonts.
1486        let fonts = Arc::clone(&ctx.fonts);
1487        let substitution = &ctx.substitution;
1488        let mut load = || {
1489            let dict = self
1490                .resources
1491                .find(names::FONT, name, self.resolver)
1492                .and_then(|o| o.as_dict().cloned());
1493            match dict {
1494                Some(d) => pdfrum_font::load_with_options(
1495                    &d,
1496                    self.resolver,
1497                    &fonts,
1498                    substitution,
1499                    limits,
1500                    diags,
1501                ),
1502                // A name that resolves to nothing yields the stock font
1503                // rather than nothing at all.
1504                None => Some(Font::load_standard(
1505                    pdfrum_font::StandardFont::Helvetica,
1506                    &fonts,
1507                )),
1508            }
1509        };
1510        match self.resources.find_ref(names::FONT, name, self.resolver) {
1511            Some(reference) => fonts.get_or_load(reference, load),
1512            None => load().map(Arc::new),
1513        }
1514    }
1515
1516    /// `cs` and `CS`: install a colour space, resetting the colour.
1517    fn set_color_space(
1518        &mut self,
1519        name: &Name,
1520        stroking: bool,
1521        ctx: &mut BuildContext,
1522        limits: &Limits,
1523        diags: &mut Diagnostics,
1524    ) {
1525        let Some(space) = self.load_named_colorspace(name, ctx, limits, diags) else {
1526            // A name that will not resolve makes the operator a no-op.
1527            return;
1528        };
1529        let target = if stroking {
1530            &mut self.state.stroke
1531        } else {
1532            &mut self.state.fill
1533        };
1534        target.set_space(Arc::new(space));
1535    }
1536
1537    /// The `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` substitution
1538    /// applies **only** to the three fully spelled device names, and only
1539    /// through this path.
1540    fn load_named_colorspace(
1541        &self,
1542        name: &Name,
1543        ctx: &mut BuildContext,
1544        limits: &Limits,
1545        diags: &mut Diagnostics,
1546    ) -> Option<ColorSpace> {
1547        if name.as_bytes() == b"Pattern" {
1548            return Some(ColorSpace::Pattern(Box::default()));
1549        }
1550        let colorspaces = self.resources.color_spaces(self.resolver);
1551        crate::color::load_colorspace(
1552            &Object::Name(name.clone()),
1553            colorspaces.as_ref(),
1554            self.resolver,
1555            &mut ctx.functions,
1556            limits,
1557            diags,
1558        )
1559    }
1560
1561    /// `scn` and `SCN`.
1562    ///
1563    /// A trailing name installs a pattern, loaded against the **parent
1564    /// matrix** so it stays anchored to the space it was declared in.
1565    fn set_color_n(
1566        &mut self,
1567        c: &crate::ops::PatternComponents,
1568        stroking: bool,
1569        ctx: &mut BuildContext,
1570        limits: &Limits,
1571        diags: &mut Diagnostics,
1572    ) {
1573        if let Some(name) = &c.pattern {
1574            let found = load_pattern(
1575                name,
1576                self.resources,
1577                self.parent_matrix,
1578                &self.state.general,
1579                self.resolver,
1580                ctx,
1581                limits,
1582                diags,
1583            );
1584            // Only a name the resources do not define at all makes the
1585            // operator a no-op; a pattern that exists but will not load still
1586            // installs a pattern colour, which paints nothing.
1587            let loaded = match found {
1588                FoundPattern::Loaded(p) => Some(p),
1589                FoundPattern::Unusable => None,
1590                FoundPattern::Missing => return,
1591            };
1592            let target = if stroking {
1593                &mut self.state.stroke
1594            } else {
1595                &mut self.state.fill
1596            };
1597            // The loaded pattern rides with the colour, so `q`/`Q` save and
1598            // restore it for free and every object painted under it carries
1599            // the cell or the shading itself. Re-resolving the name at paint
1600            // time would need the resources and the resolver the renderer no
1601            // longer has.
1602            target.set_pattern(name.clone(), &c.values, loaded);
1603            return;
1604        }
1605        let target = if stroking {
1606            &mut self.state.stroke
1607        } else {
1608            &mut self.state.fill
1609        };
1610        let _ = target.set_components(&c.values);
1611    }
1612
1613    /// `gs`.
1614    fn apply_ext_gstate(
1615        &mut self,
1616        name: &Name,
1617        ctx: &mut BuildContext,
1618        limits: &Limits,
1619        diags: &mut Diagnostics,
1620    ) {
1621        let Some(ext) = self
1622            .resources
1623            .find(names::EXT_G_STATE, name, self.resolver)
1624            .and_then(|o| o.as_dict().cloned())
1625        else {
1626            return;
1627        };
1628        let resources = self.resources;
1629        let resolver = self.resolver;
1630        let fonts = &ctx.fonts;
1631        let substitution = &ctx.substitution;
1632        // The `/Font` array's first element, both ways round. Table 58's own
1633        // form is an indirect reference to a font dictionary, so that is
1634        // tried first; a name is the oracle's spelling, kept as tolerance
1635        // because files written against PDFium use it. See the
1636        // `[oracle-bug]` note in `state::extgstate`.
1637        let find_font = |first: Option<&Object>| -> Option<Arc<Font>> {
1638            // Two of the three spellings name a font by reference, and that
1639            // reference is the same document-scoped identity `Tf` keys on —
1640            // so an `/ExtGState` font and a `Tf` font that are the same
1641            // object share one loaded instance rather than each loading it.
1642            let (reference, dict) = match first? {
1643                // Spec (table 58): an indirect reference to a font dict.
1644                Object::Ref(reference) => (
1645                    Some(*reference),
1646                    Object::Ref(*reference)
1647                        .resolve(resolver)
1648                        .ok()?
1649                        .as_dict()
1650                        .cloned()?,
1651                ),
1652                // Tolerance: the oracle's name-in-the-resources reading.
1653                Object::Name(name) => (
1654                    resources.find_ref(names::FONT, name, resolver),
1655                    resources
1656                        .find(names::FONT, name, resolver)
1657                        .and_then(|o| o.as_dict().cloned())?,
1658                ),
1659                // A direct dictionary is neither spelling, but there is
1660                // nothing else it could mean and refusing it would lose a
1661                // font a file plainly named. It has no reference, so it is
1662                // not cached.
1663                Object::Dict(d) => (None, d.clone()),
1664                _ => return None,
1665            };
1666            let load = || {
1667                pdfrum_font::load_with_options(
1668                    &dict,
1669                    resolver,
1670                    fonts,
1671                    substitution,
1672                    limits,
1673                    &mut Diagnostics::with_limit(0),
1674                )
1675            };
1676            match reference {
1677                Some(reference) => fonts.get_or_load(reference, load),
1678                None => load().map(Arc::new),
1679            }
1680        };
1681        apply_ext_gstate(
1682            &mut self.state,
1683            &ext,
1684            find_font,
1685            self.resolver,
1686            &mut ctx.functions,
1687            limits,
1688            diags,
1689        );
1690        self.expand_soft_mask_group(ctx, limits, diags);
1691    }
1692
1693    /// Interpret a newly installed soft mask's `/G` group into page objects.
1694    ///
1695    /// The group is a form `XObject` and what it paints is what the mask *is*,
1696    /// so it has to be interpreted before the mask can mean anything — and
1697    /// only the interpreter has the resolver and the recursion guard a form
1698    /// parse needs, which is why this runs here rather than in `SoftMask::
1699    /// load`.
1700    ///
1701    /// The group renders from a **clean state**, not the installing object's:
1702    /// `LoadSMask` builds its status with `Initialize(null, null)`, so the
1703    /// mask's own content is unaffected by the alpha, blend or colour in force
1704    /// where the `/ExtGState` appeared. Inheriting them instead would make a
1705    /// mask under `/ca 0.5` fade *itself* and then fade the object again.
1706    fn expand_soft_mask_group(
1707        &mut self,
1708        ctx: &mut BuildContext,
1709        limits: &Limits,
1710        diags: &mut Diagnostics,
1711    ) {
1712        let Some(mask) = self.state.general.soft_mask.as_ref() else {
1713            return;
1714        };
1715        if !mask.objects.is_empty() {
1716            return;
1717        }
1718        let group = mask.group.clone();
1719        let matrix = mask.matrix;
1720        let content = pdfrum_filters::decode_chain(&group, 0, self.resolver, limits, diags).data;
1721        let id = BufferId::new(None, &content);
1722        if ctx.in_flight.len() > MAX_FORM_LEVEL || ctx.in_flight.contains(&id) {
1723            diags.record(Severity::Recovered, DiagKind::FormRecursionRefused, None);
1724            return;
1725        }
1726        // The group's `/Matrix` composes with the transform the `/ExtGState`
1727        // was applied under, which is what places the mask on the page.
1728        let form_matrix = group.dict.matrix(names::MATRIX, self.resolver);
1729        let inner = GraphicsState {
1730            ctm: matrix * form_matrix,
1731            ..GraphicsState::default()
1732        };
1733        // A soft mask's group sees the **page's** resources when it declares
1734        // none of its own, not the enclosing form's: `LoadSMask` builds the
1735        // form with `PAGE resources`.
1736        let resources = Resources::choose(
1737            group.dict.dict(names::RESOURCES, self.resolver),
1738            self.resources.page.clone(),
1739            self.resources.page.clone(),
1740        );
1741        ctx.in_flight.insert(id);
1742        let ops = crate::parse_content(&content, limits, diags);
1743        let objects = interpret(
1744            &ops,
1745            &resources,
1746            &inner,
1747            inner.ctm,
1748            self.resolver,
1749            ctx,
1750            limits,
1751            diags,
1752        );
1753        ctx.in_flight.remove(&id);
1754        if let Some(mask) = self.state.general.soft_mask.as_mut() {
1755            Arc::make_mut(mask).objects = objects;
1756        }
1757    }
1758
1759    /// `Do`: a form or an image.
1760    fn do_xobject(
1761        &mut self,
1762        name: &Name,
1763        ctx: &mut BuildContext,
1764        limits: &Limits,
1765        diags: &mut Diagnostics,
1766    ) {
1767        let Some(object) = self.resources.find(names::XOBJECT, name, self.resolver) else {
1768            return;
1769        };
1770        // Not a stream: nothing happens.
1771        let Some(stream) = object.as_stream() else {
1772            return;
1773        };
1774        let reference = self
1775            .resources
1776            .holder(names::XOBJECT, self.resolver)
1777            .and_then(|h| h.reference(name));
1778        match stream
1779            .dict
1780            .byte_string(names::SUBTYPE, self.resolver)
1781            .as_deref()
1782        {
1783            Some(b"Form") => self.add_form(stream, reference, ctx, limits, diags),
1784            Some(b"Image") => self.add_image(stream, reference, ctx, limits, diags),
1785            // Any other subtype, `PS` and a missing one included, does
1786            // nothing at all.
1787            _ => {}
1788        }
1789    }
1790
1791    /// A form `XObject`, with the buffer-identity guard.
1792    fn add_form(
1793        &mut self,
1794        stream: &pdfrum_object::Stream,
1795        reference: Option<pdfrum_object::ObjRef>,
1796        ctx: &mut BuildContext,
1797        limits: &Limits,
1798        diags: &mut Diagnostics,
1799    ) {
1800        let content = pdfrum_filters::decode_chain(stream, 0, self.resolver, limits, diags).data;
1801        // The identity is the *stream object*, not its bytes. Upstream keys
1802        // its recursion set on the decoded buffer's address
1803        // (`cpdf_streamcontentparser.cpp:1652-1660`), which is per stream
1804        // object and distinct for two objects that happen to hold the same
1805        // bytes. Hashing the content alone makes a chain of forms that each
1806        // say `/X1 Do` — thirty-five of them on `bug_972999.in` — look like
1807        // one form calling itself, and the second is refused.
1808        let id = BufferId::new(reference, &content);
1809        // More than forty in flight, or this very buffer already in flight:
1810        // consume the stream and produce nothing, successfully.
1811        if ctx.in_flight.len() > MAX_FORM_LEVEL || ctx.in_flight.contains(&id) {
1812            diags.record(Severity::Recovered, DiagKind::FormRecursionRefused, None);
1813            return;
1814        }
1815
1816        // The form's `/Matrix` composes with the current transform.
1817        let form_matrix = stream.dict.matrix(names::MATRIX, self.resolver);
1818        let matrix = self.state.ctm * form_matrix;
1819
1820        let mut inner = self.state.clone();
1821        inner.ctm = matrix;
1822        // The clip is deliberately **not** inherited: the form gets its own
1823        // from its `/BBox`.
1824        inner.clip = crate::state::ClipStack::new();
1825
1826        let transparency = Transparency::from_group(
1827            stream.dict.dict(names::GROUP, self.resolver).as_ref(),
1828            self.resolver,
1829        );
1830        if transparency.group {
1831            // Group isolation: a group starts from a clean compositing slate.
1832            inner.general.enter_transparency_group();
1833        }
1834
1835        // A missing `/BBox` means **no clip at all** — the form is unbounded.
1836        let bbox = stream
1837            .dict
1838            .array(names::BBOX, self.resolver)
1839            .filter(|a| a.len() == 4)
1840            .map(|a| a.as_rect());
1841
1842        let resources = Resources::choose(
1843            stream.dict.dict(names::RESOURCES, self.resolver),
1844            self.resources.chosen.clone(),
1845            self.resources.page.clone(),
1846        );
1847
1848        ctx.in_flight.insert(id);
1849        let ops = crate::parse_content(&content, limits, diags);
1850        let objects = interpret(
1851            &ops,
1852            &resources,
1853            &inner,
1854            // Patterns inside the form anchor to the form's own space.
1855            matrix,
1856            self.resolver,
1857            ctx,
1858            limits,
1859            diags,
1860        );
1861        ctx.in_flight.remove(&id);
1862
1863        let object = FormObject {
1864            objects,
1865            matrix,
1866            bbox,
1867            transparency,
1868            oc: stream.dict.dict(names::OC, self.resolver).map(Arc::new),
1869            source: reference,
1870            // A `Do` inside a content stream is the file drawing its own form,
1871            // never a session's live edit.
1872            live_edit: false,
1873        };
1874        self.push(PageObject::Form(Box::new(self.content(object))));
1875    }
1876
1877    /// An image `XObject`, through the session cache.
1878    fn add_image(
1879        &mut self,
1880        stream: &pdfrum_object::Stream,
1881        reference: Option<pdfrum_object::ObjRef>,
1882        ctx: &mut BuildContext,
1883        limits: &Limits,
1884        diags: &mut Diagnostics,
1885    ) {
1886        let size = ctx.decode_target;
1887        if size == RequestedSize::NoSamples {
1888            return;
1889        }
1890        let cached = reference.and_then(|id| ctx.images.get(id, size));
1891        let image = if let Some(hit) = cached {
1892            hit
1893        } else {
1894            let decoded = decode_image(
1895                stream,
1896                // Only an *inline* image sees form resources.
1897                None,
1898                self.resources.page.as_ref(),
1899                size,
1900                self.resolver,
1901                &mut ctx.functions,
1902                limits,
1903                diags,
1904            );
1905            // A codec that refused the image paints nothing.
1906            let Ok(image) = decoded else {
1907                return;
1908            };
1909            let image = Arc::new(image);
1910            if let Some(id) = reference {
1911                ctx.images.insert(id, size, Arc::clone(&image));
1912            }
1913            image
1914        };
1915        let is_mask = image.samples.is_stencil();
1916        let object = ImageObject {
1917            image,
1918            // The unit square transformed by the current matrix.
1919            matrix: self.state.ctm,
1920            is_mask,
1921            oc: stream.dict.dict(names::OC, self.resolver).map(Arc::new),
1922            source: reference,
1923        };
1924        self.push(PageObject::Image(Box::new(self.content(object))));
1925    }
1926
1927    /// An inline image, which carries its own bytes.
1928    fn inline_image(
1929        &mut self,
1930        image: &crate::ops::InlineImage,
1931        ctx: &mut BuildContext,
1932        limits: &Limits,
1933        diags: &mut Diagnostics,
1934    ) {
1935        let stream = pdfrum_object::Stream::new(
1936            crate::inline_image::as_xobject_dict(image),
1937            pdfrum_object::ByteSpan::from(image.data.to_vec()),
1938        );
1939        let decoded = decode_image(
1940            &stream,
1941            // Inline images are the **only** ones that see form resources.
1942            self.resources.chosen.as_ref(),
1943            self.resources.page.as_ref(),
1944            // An inline image reaches `CPDF_DIB::StartLoadDIBBase` with the
1945            // same `max_size_required` an XObject does — being inline changes
1946            // which resource dictionary it sees, not how much of it is decoded.
1947            ctx.decode_target,
1948            self.resolver,
1949            &mut ctx.functions,
1950            limits,
1951            diags,
1952        );
1953        let Ok(data) = decoded else {
1954            return;
1955        };
1956        let is_mask = data.samples.is_stencil();
1957        let object = ImageObject {
1958            image: Arc::new(data),
1959            matrix: self.state.ctm,
1960            is_mask,
1961            // An inline image has no XObject dictionary to carry `/OC`; only
1962            // an enclosing marked-content sequence can hide it.
1963            oc: None,
1964            // Nor any indirect object to name, so a regenerated stream cannot
1965            // write it and drops it.
1966            source: None,
1967        };
1968        self.push(PageObject::Image(Box::new(self.content(object))));
1969    }
1970
1971    /// `sh`: paint a shading across the clip.
1972    fn shade_fill(
1973        &mut self,
1974        name: &Name,
1975        ctx: &mut BuildContext,
1976        limits: &Limits,
1977        diags: &mut Diagnostics,
1978    ) {
1979        let Some(object) = self.resources.find(names::SHADING, name, self.resolver) else {
1980            return;
1981        };
1982        let colorspaces = self.resources.color_spaces(self.resolver);
1983        let Some(shading) = Shading::load(
1984            &object,
1985            colorspaces.as_ref(),
1986            ShadingSource::ShadingOperator,
1987            self.resolver,
1988            &mut ctx.functions,
1989            limits,
1990            diags,
1991        ) else {
1992            return;
1993        };
1994        // The clip when there is one, else the whole page.
1995        let mut bounds = self
1996            .state
1997            .clip
1998            .bounds()
1999            .unwrap_or(crate::page::DEFAULT_MEDIA_BOX);
2000        // A mesh additionally bounds itself by its own extent.
2001        if let crate::shading::Geometry::Mesh { mesh, .. } = &shading.geometry
2002            && let Some(extent) = mesh.bounds()
2003        {
2004            bounds = bounds.intersect(self.state.ctm.transform_rect_bbox(extent));
2005        }
2006        let object = ShadingObject {
2007            shading: Arc::new(shading),
2008            matrix: self.state.ctm,
2009            bounds,
2010        };
2011        self.push(PageObject::Shading(Box::new(self.content(object))));
2012    }
2013}
2014
2015/// The 2×2 linear part of `ctm`, stored as `[a, c, b, d]`.
2016///
2017/// A stroking `Tj` records this on the object's text state; the renderer
2018/// folds it from the text matrix into the device matrix so line width stays
2019/// in user space (ISO 32000-1 §8.4.3.2). The transposition is the four-float
2020/// slot the split consumes: `a, c, b, d`, not `a, b, c, d`.
2021fn stroke_ctm_of(ctm: Affine) -> [f32; 4] {
2022    let [a, b, c, d, _, _] = ctm.as_coeffs();
2023    #[expect(
2024        clippy::cast_possible_truncation,
2025        reason = "the stored slot is f32, matching the graphics state's other text scalars"
2026    )]
2027    {
2028        [a as f32, c as f32, b as f32, d as f32]
2029    }
2030}
2031
2032/// Turn the point list into a path.
2033///
2034/// A point's `closes` flag is applied **after** its own segment is emitted,
2035/// which is the whole distinction the flag exists for: a curve that closes
2036/// its subpath is still a curve, and its two control points must reach
2037/// [`BezPath::curve_to`] rather than sit in `pending` waiting for a third
2038/// that the next subpath then supplies.
2039fn build_path(points: &[PathPoint]) -> BezPath {
2040    let mut path = BezPath::new();
2041    let mut pending: Vec<Point> = Vec::new();
2042    let mut open = false;
2043    // A subpath's segments are only emitted while it is open, and every
2044    // subpath boundary — a `Move`, or a point that closes — drops whatever
2045    // control points the previous one left incomplete. A partial curve is
2046    // not geometry that any later subpath may borrow.
2047    for point in points {
2048        match point.kind {
2049            PointKind::Move => {
2050                pending.clear();
2051                path.move_to(point.at);
2052                open = true;
2053            }
2054            PointKind::Line => {
2055                if open {
2056                    path.line_to(point.at);
2057                }
2058            }
2059            PointKind::Curve => {
2060                pending.push(point.at);
2061                if pending.len() == 3 {
2062                    if open
2063                        && let (Some(a), Some(b), Some(c)) =
2064                            (pending.first(), pending.get(1), pending.get(2))
2065                    {
2066                        path.curve_to(*a, *b, *c);
2067                    }
2068                    pending.clear();
2069                }
2070            }
2071        }
2072        // `h`'s `close_figure_`: the subpath ends here, closed back to its
2073        // start. Anything the point's own segment did not consume goes with
2074        // it.
2075        if point.closes && open {
2076            path.close_path();
2077            pending.clear();
2078            open = false;
2079        }
2080    }
2081    path
2082}
2083
2084/// What `scn` found when it named a pattern.
2085///
2086/// The two halves are separate because finding the resource and loading the
2087/// pattern fail differently. Finding it checks only that the resource exists
2088/// and is a dictionary or a stream; **that** is what decides whether `scn`
2089/// installs a pattern colour at all. Whether the pattern is *usable* — a
2090/// `/PatternType` it recognises, a shading it can validate, steps it can tile
2091/// with — is answered later, at draw time, and a failure there means the
2092/// object paints **nothing**.
2093///
2094/// Collapsing the two makes an `scn` naming an unusable pattern a no-op, so
2095/// the object keeps whatever colour was current and paints solid. On a
2096/// page-sized rectangle over the default black that is an entirely black
2097/// page, which is what four of the corpus's fuzz files produced.
2098#[derive(Debug, Clone)]
2099pub enum FoundPattern {
2100    /// The resource exists and the pattern loaded.
2101    Loaded(Arc<Pattern>),
2102    /// The resource exists but the pattern is unusable: a pattern colour is
2103    /// still installed, and it paints nothing.
2104    Unusable,
2105    /// No such resource, or it is neither a dictionary nor a stream. `scn` is
2106    /// a no-op and the previous colour stands.
2107    Missing,
2108}
2109
2110/// A pattern named in a colour value, looked up through the resources.
2111///
2112/// `parent_matrix` anchors it, not the current transform — patterns live in
2113/// the space they were declared in. `general` is the painting object's general
2114/// state, which a tiling pattern's cell inherits wholesale — alpha, blend mode
2115/// and soft mask — while taking *default* colour, text and path state, so
2116/// `/ca 0.5` on the filling object fades the tiles.
2117///
2118/// See [`FoundPattern`] for why "the resource exists" and "the pattern loads"
2119/// are two answers rather than one.
2120// That state asymmetry is the whole reason a pattern is loaded where it is
2121// installed rather than where the resource is declared.
2122#[expect(
2123    clippy::too_many_arguments,
2124    reason = "looking a pattern up needs its name, resources, anchor matrix, \
2125              the painting object's general state, and the usual four"
2126)]
2127#[must_use]
2128pub fn load_pattern<R: Resolve>(
2129    name: &Name,
2130    resources: &Resources,
2131    parent_matrix: Affine,
2132    general: &crate::state::GeneralState,
2133    r: &R,
2134    ctx: &mut BuildContext,
2135    limits: &Limits,
2136    diags: &mut Diagnostics,
2137) -> FoundPattern {
2138    let Some(object) = resources.find(names::PATTERN, name, r) else {
2139        return FoundPattern::Missing;
2140    };
2141    // The resource must be a dictionary or a stream.
2142    if !matches!(object, Object::Dict(_) | Object::Stream(_)) {
2143        return FoundPattern::Missing;
2144    }
2145    let colorspaces = resources.color_spaces(r);
2146    let loaded = Pattern::load(
2147        &object,
2148        parent_matrix,
2149        colorspaces.as_ref(),
2150        r,
2151        &mut ctx.functions,
2152        limits,
2153        diags,
2154    );
2155    let Some(mut pattern) = loaded else {
2156        return FoundPattern::Unusable;
2157    };
2158    if let Pattern::Tiling(tiling) = &mut pattern
2159        && let Some(stream) = object.as_stream()
2160    {
2161        tiling.objects =
2162            expand_tiling_cell(tiling, stream, general, resources, r, ctx, limits, diags);
2163    }
2164    FoundPattern::Loaded(Arc::new(pattern))
2165}
2166
2167/// Interpret a tiling pattern's cell into the objects one tile paints.
2168///
2169/// Three things about the state it starts from are load-bearing:
2170///
2171/// - **The general state comes from the painting object**, so a pattern fill
2172///   under `/ca 0.5` paints half-transparent tiles.
2173/// - **Colour, text and path state are default.** A cell that never sets a
2174///   colour paints black, whatever the page was using.
2175/// - **The form matrix is the pattern's own** — `pattern_to_form` composed
2176///   with the parent — so nested patterns inside the cell anchor to the
2177///   cell's space rather than the page's.
2178///
2179/// The same buffer-identity guard forms use applies: a cell whose content is
2180/// already being interpreted higher up produces nothing rather than
2181/// recursing.
2182#[expect(
2183    clippy::too_many_arguments,
2184    reason = "expanding a cell needs the pattern, its stream, the inherited \
2185              state, resources, resolver and the usual three"
2186)]
2187fn expand_tiling_cell<R: Resolve>(
2188    tiling: &TilingPattern,
2189    stream: &pdfrum_object::Stream,
2190    general: &crate::state::GeneralState,
2191    outer: &Resources,
2192    r: &R,
2193    ctx: &mut BuildContext,
2194    limits: &Limits,
2195    diags: &mut Diagnostics,
2196) -> Vec<PageObject> {
2197    let content = pdfrum_filters::decode_chain(stream, 0, r, limits, diags).data;
2198    let id = BufferId::new(None, &content);
2199    if ctx.in_flight.len() > MAX_FORM_LEVEL || ctx.in_flight.contains(&id) {
2200        diags.record(Severity::Recovered, DiagKind::FormRecursionRefused, None);
2201        return Vec::new();
2202    }
2203    // Default colour, text and path state; the painting object's general one.
2204    let mut initial = GraphicsState {
2205        general: general.clone(),
2206        ctm: tiling.matrix,
2207        ..GraphicsState::default()
2208    };
2209    // The cell clips to its own `/BBox`, which is what stops a tile's content
2210    // bleeding into its neighbours.
2211    if tiling.bbox.width() > 0.0 && tiling.bbox.height() > 0.0 {
2212        initial.clip.push_path(
2213            tiling.matrix * kurbo::Shape::to_path(&tiling.bbox, 0.1),
2214            ClipRule::Winding,
2215        );
2216    }
2217    let resources = Resources::choose(
2218        tiling.resources.clone(),
2219        outer.chosen.clone(),
2220        outer.page.clone(),
2221    );
2222    ctx.in_flight.insert(id);
2223    let ops = crate::parse_content(&content, limits, diags);
2224    let objects = interpret(
2225        &ops,
2226        &resources,
2227        &initial,
2228        tiling.matrix,
2229        r,
2230        ctx,
2231        limits,
2232        diags,
2233    );
2234    ctx.in_flight.remove(&id);
2235    objects
2236}
2237
2238/// The clip-elimination post-pass a page runs after interpretation.
2239///
2240/// An object whose clip is a single rectangle that already contains the
2241/// object's own bounds has that clip **dropped entirely**. It changes no
2242/// pixels beyond anti-aliased clip edges, but it changes the clip counts a
2243/// structure dump reports, so it is not optional.
2244pub fn eliminate_redundant_clips(
2245    objects: &mut [PageObject],
2246    bounds_of: impl Fn(&PageObject) -> Rect,
2247) {
2248    for object in objects.iter_mut() {
2249        // Shadings are excluded: their clip is what bounds them.
2250        if matches!(object, PageObject::Shading(_)) {
2251            continue;
2252        }
2253        let rect = bounds_of(object);
2254        let state = match object {
2255            PageObject::Path(c) => &mut c.state,
2256            PageObject::Text(c) => &mut c.state,
2257            PageObject::Image(c) => &mut c.state,
2258            PageObject::Form(c) => &mut c.state,
2259            // Excluded above, and unreachable here.
2260            PageObject::Shading(_) => continue,
2261        };
2262        if state.clip.len() != 1 {
2263            continue;
2264        }
2265        let Some(crate::state::ClipEntry::Path { path, .. }) = state.clip.entries().first() else {
2266            continue;
2267        };
2268        let clip_rect = kurbo::Shape::bounding_box(path);
2269        if clip_rect.x0 <= rect.x0
2270            && clip_rect.y0 <= rect.y0
2271            && clip_rect.x1 >= rect.x1
2272            && clip_rect.y1 >= rect.y1
2273        {
2274            state.clip = crate::state::ClipStack::new();
2275        }
2276    }
2277}
2278
2279#[cfg(test)]
2280mod tests {
2281    // Test fixtures quote the oracle's own vectors, compare floats exactly
2282    // where the behaviour being pinned is exact, and index arrays whose
2283    // length the fixture itself fixes.
2284    #![allow(
2285        clippy::unreadable_literal,
2286        clippy::float_cmp,
2287        clippy::indexing_slicing,
2288        clippy::cast_precision_loss,
2289        clippy::cast_possible_truncation,
2290        reason = "test fixtures quote oracle vectors verbatim and compare exactly"
2291    )]
2292
2293    use super::{BuildContext, MAX_FORM_LEVEL, build_page};
2294    use crate::color::ColorSpace;
2295    use crate::ops::{FillRule, LineCap};
2296    use crate::page::PageObject;
2297    use crate::resources::Resources;
2298    use crate::state::GraphicsState;
2299    use kurbo::{Affine, Point};
2300    use pdfrum_common::{DiagKind, Diagnostics, Limits};
2301    use pdfrum_object::NoResolve;
2302
2303    fn build(src: &[u8]) -> (crate::page::Page, Diagnostics) {
2304        build_with(src, &Resources::default())
2305    }
2306
2307    /// The clip stack a page's last object carries, by entry kind.
2308    fn clip_kinds(page: &crate::page::Page) -> Vec<&'static str> {
2309        page.objects
2310            .last()
2311            .expect("at least one object")
2312            .state()
2313            .clip
2314            .entries()
2315            .iter()
2316            .map(|e| match e {
2317                crate::state::ClipEntry::Path { .. } => "path",
2318                crate::state::ClipEntry::Text { .. } => "text",
2319            })
2320            .collect()
2321    }
2322
2323    #[test]
2324    fn a_clipping_text_mode_reaches_the_clip_stack_at_et() {
2325        // `Tr 7` shows no ink and contributes its glyphs to the clip, so the
2326        // rectangle drawn after `ET` is clipped by the text. Before this was
2327        // wired the rectangle painted whole — `clipping_text.pdf` and
2328        // `path_9.pdf` both paint their swatches over the glyphs that should
2329        // have cut them out.
2330        let (page, _) = build(b"BT /F1 24 Tf 7 Tr 10 10 Td (Hi) Tj ET 0 0 50 50 re f");
2331        assert_eq!(clip_kinds(&page), ["text"]);
2332    }
2333
2334    #[test]
2335    fn a_non_clipping_mode_contributes_nothing() {
2336        let (page, _) = build(b"BT /F1 24 Tf 10 10 Td (Hi) Tj ET 0 0 50 50 re f");
2337        assert!(clip_kinds(&page).is_empty());
2338    }
2339
2340    /// The render mode is re-read **at `ET`**, not the one each run was
2341    /// shown under, so a run collected under `Tr 7` is discarded when the
2342    /// mode has gone back to filling before the text object closes.
2343    #[test]
2344    fn the_mode_at_et_decides_whether_the_batch_is_kept() {
2345        let (page, _) = build(b"BT /F1 24 Tf 7 Tr 10 10 Td (Hi) Tj 0 Tr ET 0 0 50 50 re f");
2346        assert!(clip_kinds(&page).is_empty(), "the batch is dropped at ET");
2347        // And it does not survive into the next text object either.
2348        let (page, _) = build(
2349            b"BT /F1 24 Tf 7 Tr 10 10 Td (Hi) Tj 0 Tr ET \
2350              BT /F1 24 Tf 7 Tr 10 10 Td (o) Tj ET 0 0 50 50 re f",
2351        );
2352        assert_eq!(
2353            clip_kinds(&page),
2354            ["text"],
2355            "only the second object's own run clips"
2356        );
2357    }
2358
2359    #[test]
2360    fn a_standalone_form_clips_to_its_own_bbox() {
2361        // An annotation appearance has no enclosing `q`/`Q` to inherit a clip
2362        // from, so `build_form_object` has to push the `/BBox` itself. Without
2363        // it an ink annotation whose `/InkList` runs outside its `/Rect` paints
2364        // strokes the oracle clips away entirely.
2365        use pdfrum_object::{ByteSpan, Dict, Name, Object, Stream};
2366        let dict = Dict::from_pairs([(
2367            Name::from("BBox"),
2368            Object::Array(pdfrum_object::Array::of([
2369                Object::Int(0),
2370                Object::Int(0),
2371                Object::Int(10),
2372                Object::Int(20),
2373            ])),
2374        )]);
2375        let stream = Stream::new(dict, ByteSpan::from(b"0 0 100 100 re f".to_vec()));
2376        let mut ctx = BuildContext::default();
2377        let mut diags = Diagnostics::default();
2378        let object = super::build_form_object(
2379            &stream,
2380            Affine::IDENTITY,
2381            &Resources::default(),
2382            &NoResolve,
2383            &mut ctx,
2384            &Limits::default(),
2385            &mut diags,
2386        )
2387        .expect("a form");
2388        let PageObject::Form(form) = &object else {
2389            panic!("expected a form");
2390        };
2391        assert_eq!(
2392            form.object.bbox,
2393            Some(kurbo::Rect::new(0.0, 0.0, 10.0, 20.0))
2394        );
2395        // The clip reached the child object, which is the half that matters:
2396        // the `bbox` field alone is only used for culling.
2397        let child = form.object.objects.first().expect("one child");
2398        let PageObject::Path(path) = child else {
2399            panic!("expected a path");
2400        };
2401        assert_eq!(path.state.clip.len(), 1, "the bbox is on the child's clip");
2402        assert_eq!(
2403            path.state.clip.bounds(),
2404            Some(kurbo::Rect::new(0.0, 0.0, 10.0, 20.0))
2405        );
2406    }
2407
2408    #[test]
2409    fn a_form_with_no_bbox_is_unclipped() {
2410        use pdfrum_object::{ByteSpan, Dict, Stream};
2411        let stream = Stream::new(Dict::new(), ByteSpan::from(b"0 0 100 100 re f".to_vec()));
2412        let mut ctx = BuildContext::default();
2413        let mut diags = Diagnostics::default();
2414        let object = super::build_form_object(
2415            &stream,
2416            Affine::IDENTITY,
2417            &Resources::default(),
2418            &NoResolve,
2419            &mut ctx,
2420            &Limits::default(),
2421            &mut diags,
2422        )
2423        .expect("a form");
2424        let PageObject::Form(form) = &object else {
2425            panic!("expected a form");
2426        };
2427        assert_eq!(form.object.bbox, None, "a missing /BBox is no clip at all");
2428        let PageObject::Path(path) = form.object.objects.first().expect("one child") else {
2429            panic!("expected a path");
2430        };
2431        assert!(path.state.clip.is_empty());
2432    }
2433
2434    fn build_with(src: &[u8], resources: &Resources) -> (crate::page::Page, Diagnostics) {
2435        build_under(src, resources, &Limits::default())
2436    }
2437
2438    fn build_under(
2439        src: &[u8],
2440        resources: &Resources,
2441        limits: &Limits,
2442    ) -> (crate::page::Page, Diagnostics) {
2443        let mut diags = Diagnostics::default();
2444        let ops = crate::parse_content(src, limits, &mut diags);
2445        let mut ctx = BuildContext::new();
2446        let page = build_page(&ops, resources, &NoResolve, &mut ctx, limits, &mut diags);
2447        (page, diags)
2448    }
2449
2450    /// The interpreter reads the deadline every 256 operators, the first
2451    /// time before the first one: a spent budget builds nothing and says so.
2452    #[test]
2453    fn a_spent_deadline_stops_the_interpreter_with_a_diagnostic() {
2454        let spent = Limits {
2455            deadline: Some(pdfrum_common::Deadline::after(std::time::Duration::ZERO)),
2456            ..Limits::default()
2457        };
2458        let (page, diags) = build_under(
2459            b"0 0 10 10 re f 0 0 20 20 re f",
2460            &Resources::default(),
2461            &spent,
2462        );
2463        assert!(page.objects.is_empty());
2464        assert!(diags.contains(&DiagKind::TimeLimitReached));
2465
2466        let generous = Limits {
2467            deadline: Some(pdfrum_common::Deadline::after(
2468                std::time::Duration::from_hours(1),
2469            )),
2470            ..Limits::default()
2471        };
2472        let (page, diags) = build_under(
2473            b"0 0 10 10 re f 0 0 20 20 re f",
2474            &Resources::default(),
2475            &generous,
2476        );
2477        assert_eq!(page.objects.len(), 2);
2478        assert!(!diags.contains(&DiagKind::TimeLimitReached));
2479    }
2480
2481    // -----------------------------------------------------------------
2482    // `/ExtGState /Font`. Table 58 makes the array's first element an
2483    // *indirect reference to a font dictionary*;
2484    // `cpdf_allstates.cpp:87-89` reads it as a byte string and looks that up
2485    // in the page's `/Font` resources, so the spec's form yields `""`, misses,
2486    // and `cpdf_streamcontentparser.cpp:1239` substitutes stock Helvetica.
2487    // pdf.js resolves the reference (`evaluator.js:1142-1154`, `:1256-1261`).
2488    // No corpus file uses either form, so these fixtures are constructed.
2489    // -----------------------------------------------------------------
2490
2491    /// A map-backed resolver, so a `Ref` in a fixture can actually be
2492    /// followed.
2493    #[derive(Debug, Default)]
2494    struct Store(std::collections::HashMap<u32, std::sync::Arc<pdfrum_object::Object>>);
2495
2496    impl pdfrum_object::Resolve for Store {
2497        fn fetch(
2498            &self,
2499            r: pdfrum_object::ObjRef,
2500        ) -> Result<std::sync::Arc<pdfrum_object::Object>, pdfrum_object::Error> {
2501            self.0
2502                .get(&r.num)
2503                .map(std::sync::Arc::clone)
2504                .ok_or(pdfrum_object::Error::UnresolvedRef(r))
2505        }
2506    }
2507
2508    /// A `/Type1 /Helvetica` font dictionary, which loads without any
2509    /// embedded program.
2510    fn helvetica() -> pdfrum_object::Dict {
2511        use pdfrum_object::{Dict, Name, Object};
2512        Dict::from_pairs([
2513            (Name::from("Type"), Object::Name(Name::from("Font"))),
2514            (Name::from("Subtype"), Object::Name(Name::from("Type1"))),
2515            (
2516                Name::from("BaseFont"),
2517                Object::Name(Name::from("Helvetica")),
2518            ),
2519        ])
2520    }
2521
2522    /// Build `/GS gs` where `/GS` holds `/Font [<first> 12]`, against a store
2523    /// that has a font dictionary at object 7 and resources naming it `F1`.
2524    fn font_from_ext_gstate(first: pdfrum_object::Object) -> Option<f32> {
2525        use pdfrum_object::{Array, Dict, Name, Object};
2526        let store = Store(
2527            [(7u32, std::sync::Arc::new(Object::Dict(helvetica())))]
2528                .into_iter()
2529                .collect(),
2530        );
2531        let gs = Dict::from_pairs([(
2532            Name::from("Font"),
2533            Object::Array(Array::of([first, Object::Int(12)])),
2534        )]);
2535        let resources = Resources {
2536            chosen: Some(Dict::from_pairs([
2537                (
2538                    Name::from("ExtGState"),
2539                    Object::Dict(Dict::from_pairs([(Name::from("GS"), Object::Dict(gs))])),
2540                ),
2541                (
2542                    Name::from("Font"),
2543                    Object::Dict(Dict::from_pairs([(
2544                        Name::from("F1"),
2545                        Object::Dict(helvetica()),
2546                    )])),
2547                ),
2548            ])),
2549            page: None,
2550        };
2551        let limits = Limits::default();
2552        let mut diags = Diagnostics::default();
2553        let ops = crate::parse_content(b"/GS gs BT (x) Tj ET", &limits, &mut diags);
2554        let mut ctx = BuildContext::new();
2555        let mut state = GraphicsState::default();
2556        // Drive the interpreter far enough to apply the `gs`, then read the
2557        // font size the arm installed — non-`None` exactly when a font was
2558        // found, and 12 when it came from this array.
2559        let page = build_page(&ops, &resources, &store, &mut ctx, &limits, &mut diags);
2560        let _ = &mut state;
2561        page.objects
2562            .first()
2563            .and_then(|o| o.state().text.font.as_ref())
2564            .map(|(_, size)| *size)
2565    }
2566
2567    /// Table 58's own form resolves. This fails against the oracle's
2568    /// reading, where `GetByteStringAt(0)` on a reference is `""`.
2569    #[test]
2570    fn an_ext_gstate_font_resolves_the_specs_indirect_reference() {
2571        let size =
2572            font_from_ext_gstate(pdfrum_object::Object::Ref(pdfrum_object::ObjRef::new(7, 0)));
2573        assert_eq!(size, Some(12.0));
2574    }
2575
2576    /// The oracle's form keeps working — tolerance, not the specification.
2577    #[test]
2578    fn an_ext_gstate_font_still_takes_the_oracles_resource_name() {
2579        let size =
2580            font_from_ext_gstate(pdfrum_object::Object::Name(pdfrum_object::Name::from("F1")));
2581        assert_eq!(size, Some(12.0));
2582    }
2583
2584    /// A reference to nothing installs nothing, rather than falling back to
2585    /// a stock face as the oracle does — the fallback is the interpreter's
2586    /// job at `Tf`, not this arm's.
2587    #[test]
2588    fn an_ext_gstate_font_reference_to_nothing_installs_nothing() {
2589        let size = font_from_ext_gstate(pdfrum_object::Object::Ref(pdfrum_object::ObjRef::new(
2590            99, 0,
2591        )));
2592        assert_eq!(size, None);
2593    }
2594
2595    #[test]
2596    fn two_form_objects_with_identical_bytes_are_two_forms() {
2597        // The recursion guard's identity is the *stream object*, not its
2598        // content. Upstream keys on the decoded buffer's address, which is
2599        // distinct per stream object even when two objects hold the same
2600        // bytes; hashing the content alone makes a chain of forms that each
2601        // say `/X1 Do` — thirty-five of them on `bug_972999.in` — look like
2602        // one form calling itself, and every level below the first is
2603        // refused.
2604        use super::BufferId;
2605        use pdfrum_object::ObjRef;
2606        let body = b"/X1 Do";
2607        let five = BufferId::new(
2608            Some(ObjRef {
2609                num: 5,
2610                generation: 0,
2611            }),
2612            body,
2613        );
2614        let six = BufferId::new(
2615            Some(ObjRef {
2616                num: 6,
2617                generation: 0,
2618            }),
2619            body,
2620        );
2621        assert_ne!(five, six, "same bytes, different objects, different ids");
2622        assert_eq!(
2623            five,
2624            BufferId::new(
2625                Some(ObjRef {
2626                    num: 5,
2627                    generation: 0
2628                }),
2629                body
2630            ),
2631            "the same object really is the same id, which is what catches a \
2632             form that draws itself"
2633        );
2634        // And two anonymous buffers still separate by content, which is what
2635        // the id does for a caller with no reference to offer.
2636        assert_ne!(
2637            BufferId::new(None, b"a"),
2638            BufferId::new(None, b"b"),
2639            "content still distinguishes two unreferenced buffers"
2640        );
2641    }
2642
2643    #[test]
2644    fn a_rectangle_fill_produces_one_path_object() {
2645        let (page, _) = build(b"0 0 100 50 re f");
2646        assert_eq!(page.objects.len(), 1);
2647        let PageObject::Path(path) = &page.objects[0] else {
2648            panic!("expected a path, got {:?}", page.objects[0]);
2649        };
2650        assert_eq!(path.object.fill_rule, FillRule::Winding);
2651        assert!(!path.object.stroke);
2652    }
2653
2654    #[test]
2655    fn n_with_no_clip_produces_nothing() {
2656        let (page, _) = build(b"0 0 100 50 re n");
2657        assert!(page.objects.is_empty());
2658    }
2659
2660    #[test]
2661    fn n_with_a_pending_clip_clips_but_paints_nothing() {
2662        let (page, _) = build(b"0 0 100 50 re W n 0 0 10 10 re f");
2663        // Only the second rectangle paints.
2664        assert_eq!(page.objects.len(), 1);
2665        let PageObject::Path(path) = &page.objects[0] else {
2666            panic!("expected a path");
2667        };
2668        assert_eq!(path.state.clip.len(), 1);
2669    }
2670
2671    /// `h` on a subpath that a curve already brought back to its start must
2672    /// keep that curve a curve, and must not let its control points reach
2673    /// the next subpath.
2674    ///
2675    /// This is `vector_en_system.pdf`'s whole loss, reduced to two glyph
2676    /// outlines: every subpath there is a run of `c`s ending exactly on the
2677    /// `m`, then `h`. Marking the closing point by rewriting its *kind*
2678    /// turned that last curve into a straight line and stranded its first
2679    /// two control points, which the following subpath's first curve point
2680    /// then completed — painting a stroked diagonal between the two glyphs.
2681    /// Upstream keeps the two apart (`cpdf_streamcontentparser.cpp:979`
2682    /// sets `close_figure_`, not the point's `Type`).
2683    #[test]
2684    fn a_curve_that_closes_its_subpath_stays_a_curve_and_leaks_nothing() {
2685        let (page, _) = build(
2686            b"10 10 m 12 14 16 14 18 10 c 14 6 12 6 10 10 c h \
2687              50 10 m 52 14 56 14 58 10 c 54 6 52 6 50 10 c h S",
2688        );
2689        let PageObject::Path(path) = &page.objects[0] else {
2690            panic!("expected a path");
2691        };
2692        let elements: Vec<_> = path.object.path.elements().to_vec();
2693        // Two subpaths, each: MoveTo, CurveTo, CurveTo, ClosePath.
2694        let kinds: Vec<&str> = elements
2695            .iter()
2696            .map(|e| match e {
2697                kurbo::PathEl::MoveTo(_) => "M",
2698                kurbo::PathEl::LineTo(_) => "L",
2699                kurbo::PathEl::CurveTo(..) => "C",
2700                kurbo::PathEl::QuadTo(..) => "Q",
2701                kurbo::PathEl::ClosePath => "Z",
2702            })
2703            .collect();
2704        assert_eq!(kinds, ["M", "C", "C", "Z", "M", "C", "C", "Z"], "{kinds:?}");
2705        // The second subpath's first curve must start from its own `m` and
2706        // stay in its own x range — not reach back to the first glyph.
2707        let kurbo::PathEl::CurveTo(a, b, c) = elements[5] else {
2708            panic!("expected the second subpath's first curve");
2709        };
2710        for p in [a, b, c] {
2711            assert!(
2712                p.x >= 49.0,
2713                "control point {p:?} leaked from the first glyph"
2714            );
2715        }
2716    }
2717
2718    #[test]
2719    fn a_line_before_any_move_is_discarded() {
2720        let (page, _) = build(b"5 5 l 10 10 l S");
2721        // Nothing was ever started, so nothing paints.
2722        assert!(page.objects.is_empty());
2723    }
2724
2725    #[test]
2726    fn consecutive_moves_collapse_to_the_last() {
2727        let (page, _) = build(b"1 1 m 2 2 m 3 3 m 9 9 l S");
2728        let PageObject::Path(path) = &page.objects[0] else {
2729            panic!("expected a path");
2730        };
2731        // The path starts at the last move, not the first.
2732        let start = path.object.path.elements().first().copied();
2733        assert!(
2734            matches!(start, Some(kurbo::PathEl::MoveTo(p)) if (p.x - 3.0).abs() < 1e-6),
2735            "got {start:?}"
2736        );
2737    }
2738
2739    #[test]
2740    fn a_single_point_paints_nothing_unless_the_cap_is_round() {
2741        // Butt cap: nothing.
2742        let (page, _) = build(b"5 5 m h S");
2743        assert!(page.objects.is_empty());
2744        // Round cap: a dot.
2745        let (page, _) = build(b"1 J 5 5 m h S");
2746        assert_eq!(page.objects.len(), 1);
2747    }
2748
2749    #[test]
2750    fn a_single_point_with_a_pending_clip_blanks_everything() {
2751        let (page, _) = build(b"5 5 m W n 0 0 10 10 re f");
2752        let PageObject::Path(path) = &page.objects[0] else {
2753            panic!("expected a path");
2754        };
2755        let bounds = path.state.clip.bounds().expect("an empty clip");
2756        assert!(bounds.area() < 1e-6, "got {bounds:?}");
2757    }
2758
2759    #[test]
2760    fn q_and_restore_round_trip_the_state() {
2761        let (page, _) = build(b"q 5 w 1 0 0 rg Q 0 0 10 10 re f");
2762        let PageObject::Path(path) = &page.objects[0] else {
2763            panic!("expected a path");
2764        };
2765        // The `Q` undid both changes.
2766        assert!((path.state.stroke_params.width - 1.0).abs() < 1e-6);
2767        assert_eq!(&path.state.fill.components[..], &[0.0]);
2768    }
2769
2770    #[test]
2771    fn an_unbalanced_restore_is_harmless() {
2772        let (page, diags) = build(b"Q Q 0 0 10 10 re f");
2773        assert_eq!(page.objects.len(), 1);
2774        assert!(diags.contains(&DiagKind::UnbalancedRestore));
2775    }
2776
2777    #[test]
2778    fn cm_pre_concatenates() {
2779        let (page, _) = build(b"2 0 0 2 0 0 cm 1 0 0 1 10 0 cm 0 0 1 1 re f");
2780        let PageObject::Path(path) = &page.objects[0] else {
2781            panic!("expected a path");
2782        };
2783        // The translation is scaled by the earlier `cm`, so it lands at 20.
2784        let origin = path.object.matrix * Point::ZERO;
2785        assert!((origin.x - 20.0).abs() < 1e-6, "got {origin:?}");
2786    }
2787
2788    #[test]
2789    fn tz_is_stored_as_a_fraction() {
2790        let (page, _) = build(b"150 Tz 0 0 10 10 re f");
2791        let PageObject::Path(path) = &page.objects[0] else {
2792            panic!("expected a path");
2793        };
2794        assert!((path.state.text.horz_scale - 1.5).abs() < 1e-6);
2795    }
2796
2797    #[test]
2798    fn td_sets_the_leading_to_the_negated_offset() {
2799        let (page, _) = build(b"BT 0 -14 TD ET 0 0 1 1 re f");
2800        let PageObject::Path(path) = &page.objects[0] else {
2801            panic!("expected a path");
2802        };
2803        assert!((path.state.text.leading - 14.0).abs() < 1e-6);
2804    }
2805
2806    fn first_text_state(page: &crate::page::Page) -> &crate::state::TextState {
2807        let PageObject::Text(text) = &page.objects[0] else {
2808            panic!("expected text, got {:?}", page.objects[0]);
2809        };
2810        &text.state.text
2811    }
2812
2813    #[test]
2814    fn a_stroked_tj_under_a_scaling_ctm_records_the_transposed_linear_part() {
2815        // `2 0 0 3 0 0 cm` is PDF [a b c d] = [2, 0, 0, 3]. The stored slot is
2816        // `[a, c, b, d]`, which for a diagonal is the same four numbers.
2817        let (page, _) = build(b"2 0 0 3 0 0 cm BT /F1 24 Tf 1 Tr (x) Tj ET");
2818        assert_eq!(first_text_state(&page).stroke_ctm, [2.0, 0.0, 0.0, 3.0]);
2819        assert_eq!(
2820            first_text_state(&page).render_mode,
2821            crate::ops::TextRenderMode::Stroke
2822        );
2823
2824        // Off-diagonal: `1 2 3 4 cm` stores `[a, c, b, d] = [1, 3, 2, 4]`.
2825        let (page, _) = build(b"1 2 3 4 0 0 cm BT /F1 24 Tf 1 Tr (x) Tj ET");
2826        assert_eq!(first_text_state(&page).stroke_ctm, [1.0, 3.0, 2.0, 4.0]);
2827    }
2828
2829    #[test]
2830    fn a_filled_tj_under_a_scaling_ctm_keeps_the_identity_stroke_ctm() {
2831        let (page, _) = build(b"2 0 0 3 0 0 cm BT /F1 24 Tf 0 Tr (x) Tj ET");
2832        assert_eq!(first_text_state(&page).stroke_ctm, [1.0, 0.0, 0.0, 1.0]);
2833        assert_eq!(
2834            first_text_state(&page).render_mode,
2835            crate::ops::TextRenderMode::Fill
2836        );
2837    }
2838
2839    #[test]
2840    fn an_out_of_range_text_render_mode_leaves_the_mode_alone() {
2841        let (page, diags) = build(b"2 Tr 9 Tr 0 0 1 1 re f");
2842        let PageObject::Path(path) = &page.objects[0] else {
2843            panic!("expected a path");
2844        };
2845        assert_eq!(
2846            path.state.text.render_mode,
2847            crate::ops::TextRenderMode::FillStroke,
2848            "the 9 should have been refused"
2849        );
2850        assert!(diags.contains(&DiagKind::BadTextRenderMode));
2851    }
2852
2853    #[test]
2854    fn a_colorspace_operator_resets_the_colour_to_the_default() {
2855        let (page, _) = build(b"1 0 0 rg /DeviceGray cs 0 0 1 1 re f");
2856        let PageObject::Path(path) = &page.objects[0] else {
2857            panic!("expected a path");
2858        };
2859        assert_eq!(&path.state.fill.components[..], &[0.0]);
2860        assert_eq!(
2861            path.state.fill.space.as_deref(),
2862            Some(&ColorSpace::DeviceGray)
2863        );
2864    }
2865
2866    #[test]
2867    fn too_few_colour_operands_leave_the_colour_standing() {
2868        let (page, _) = build(b"0 0 1 rg /DeviceCMYK cs 0.5 0.5 sc 0 0 1 1 re f");
2869        let PageObject::Path(path) = &page.objects[0] else {
2870            panic!("expected a path");
2871        };
2872        // `cs` reset to CMYK's default; the short `sc` changed nothing.
2873        assert_eq!(&path.state.fill.components[..], &[0.0, 0.0, 0.0, 0.0]);
2874    }
2875
2876    #[test]
2877    fn marked_content_is_snapshotted_onto_each_object() {
2878        let (page, _) = build(b"/Span BMC 0 0 1 1 re f EMC 0 0 1 1 re f");
2879        assert_eq!(page.objects.len(), 2);
2880        assert_eq!(page.objects[0].marks().len(), 1);
2881        assert_eq!(page.objects[1].marks().len(), 0);
2882    }
2883
2884    #[test]
2885    fn an_unbalanced_emc_is_harmless() {
2886        let (page, diags) = build(b"EMC EMC 0 0 1 1 re f");
2887        assert_eq!(page.objects.len(), 1);
2888        assert!(diags.contains(&DiagKind::UnbalancedMarkedContent));
2889    }
2890
2891    #[test]
2892    fn b_star_appends_its_closing_segment_unconditionally() {
2893        // Both close back onto the start; `b*` still appends a segment.
2894        let (with_b, _) = build(b"0 0 m 10 0 l 0 0 l b");
2895        let (with_b_star, _) = build(b"0 0 m 10 0 l 0 0 l b*");
2896        let PageObject::Path(a) = &with_b.objects[0] else {
2897            panic!("expected a path");
2898        };
2899        let PageObject::Path(b) = &with_b_star.objects[0] else {
2900            panic!("expected a path");
2901        };
2902        assert!(
2903            b.object.path.elements().len() >= a.object.path.elements().len(),
2904            "b* should not produce fewer elements than b"
2905        );
2906    }
2907
2908    #[test]
2909    fn the_form_guard_allows_forty_one_and_refuses_the_forty_second() {
2910        // The cap is compared with `>`, so `MAX_FORM_LEVEL + 1` fit.
2911        assert_eq!(MAX_FORM_LEVEL, 40);
2912        let ctx = BuildContext::new();
2913        assert_eq!(ctx.forms_in_flight(), 0);
2914    }
2915
2916    #[test]
2917    fn a_dash_operand_that_is_not_an_array_is_a_no_op() {
2918        let (page, _) = build(b"[3 3] 0 d 5 0 d 0 0 1 1 re f");
2919        let PageObject::Path(path) = &page.objects[0] else {
2920            panic!("expected a path");
2921        };
2922        // The second `d` did not clear the pattern.
2923        assert_eq!(&path.state.stroke_params.dash[..], &[3.0, 3.0]);
2924    }
2925
2926    #[test]
2927    fn the_default_state_is_what_a_page_starts_with() {
2928        let state = GraphicsState::default();
2929        assert_eq!(state.ctm, Affine::IDENTITY);
2930        assert_eq!(state.stroke_params.cap, LineCap::Butt);
2931    }
2932
2933    #[test]
2934    fn an_appearance_is_not_a_live_edit_unless_it_is_built_as_one() {
2935        // The flag is off for every existing producer, which is what makes it
2936        // additive: the file's own appearance streams and a session's
2937        // *regenerated* ones are both ordinary, and only the appearance a
2938        // session produces for the field it is editing is marked.
2939        let stream = pdfrum_object::Stream::new(
2940            pdfrum_object::Dict::new(),
2941            pdfrum_object::ByteSpan::from(b"0 0 10 10 re f".to_vec()),
2942        );
2943        let build = |live_edit| {
2944            let mut ctx = BuildContext::default();
2945            let mut diags = Diagnostics::default();
2946            let object = super::build_form_object_with(
2947                &stream,
2948                Affine::IDENTITY,
2949                &Resources::default(),
2950                &NoResolve,
2951                &mut ctx,
2952                &Limits::default(),
2953                &mut diags,
2954                live_edit,
2955            )
2956            .expect("a form");
2957            let PageObject::Form(form) = object else {
2958                panic!("expected a form");
2959            };
2960            form.object.live_edit
2961        };
2962        assert!(!build(false));
2963        assert!(build(true));
2964    }
2965
2966    #[test]
2967    fn the_plain_entry_point_never_marks_a_live_edit() {
2968        // `build_form_object` is `build_form_object_with(.., false)`, and every
2969        // caller that predates the flag goes through it.
2970        let stream = pdfrum_object::Stream::new(
2971            pdfrum_object::Dict::new(),
2972            pdfrum_object::ByteSpan::from(b"0 0 10 10 re f".to_vec()),
2973        );
2974        let mut ctx = BuildContext::default();
2975        let mut diags = Diagnostics::default();
2976        let object = super::build_form_object(
2977            &stream,
2978            Affine::IDENTITY,
2979            &Resources::default(),
2980            &NoResolve,
2981            &mut ctx,
2982            &Limits::default(),
2983            &mut diags,
2984        )
2985        .expect("a form");
2986        let PageObject::Form(form) = object else {
2987            panic!("expected a form");
2988        };
2989        assert!(!form.object.live_edit);
2990    }
2991}