Skip to main content

oxideav_pdf/reader/
content.rs

1//! PDF content-stream operator parser — inverse of [`crate::operators`].
2//!
3//! Walks the operator stream emitted by a per-page Contents object and
4//! reconstructs the [`oxideav_core::vector::Group`] tree that the
5//! writer originally walked. The mapping is the same one the writer
6//! uses, run in reverse:
7//!
8//! | PDF operator         | Vector IR                                |
9//! |----------------------|------------------------------------------|
10//! | `q` / `Q`            | enter / leave a child [`Group`]          |
11//! | `cm`                 | concat into the current group's transform |
12//! | `m` / `l` / `c` / `h`| [`PathCommand::MoveTo`] / `LineTo` / `CubicCurveTo` / `Close` |
13//! | `v` / `y`            | shorthand cubic — lifted to a full `c`   |
14//! | `re`                 | rectangle subpath (m + 3*l + h)          |
15//! | `f` / `f*`           | fill (NonZero / EvenOdd)                 |
16//! | `S`                  | stroke                                   |
17//! | `B` / `B*`           | fill + stroke                            |
18//! | `b` / `b*`           | close + fill + stroke                    |
19//! | `n`                  | no-op paint (consume current path)       |
20//! | `W` / `W*`           | clip — assigns to the current group's `clip` |
21//! | `rg` / `RG`          | fill / stroke colour (DeviceRGB)         |
22//! | `g` / `G`            | grayscale fill / stroke (round-3 maps to RGB triplet) |
23//! | `k` / `K`            | DeviceCMYK fill / stroke — converted to RGB per §10.3.5 |
24//! | `w` / `J` / `j` / `M`| stroke width / cap / join / miter limit  |
25//! | `d`                  | dash array + offset                      |
26//! | `cs` / `CS`          | select nonstroking / stroking colour space (device families resolved directly; round 275 resolves `/Resources /ColorSpace` keys that reduce to an ICCBased or Indexed device fallback) |
27//! | `sc` / `scn` / `SC` / `SCN` | colour value in the current space — DeviceGray / DeviceRGB / DeviceCMYK components honoured (§8.6.8); round 275 adds Indexed-table index lookup (§8.6.6.3) |
28//! | `gs`                 | ExtGState resource lookup — round 125 resolves `LW` / `LC` / `LJ` / `ML` / `D` / `CA` / `ca` from the page's `/Resources /ExtGState` dict |
29//!
30//! ExtGState lookup (round 125): when a page's `/Resources /ExtGState`
31//! dictionary is plumbed in via [`parse_content_stream_with_resources`],
32//! a `/GSx gs` operator looks the named subdict up against Table 58
33//! (ISO 32000-1 §8.4.5) and applies the cumulative-merge subset that
34//! the IR can carry: line width (`LW`), line cap (`LC`), line join
35//! (`LJ`), miter limit (`ML`), dash pattern (`D`), and the stroking /
36//! nonstroking alpha constants (`CA` / `ca`) per §11.6.4.4. Soft mask
37//! (`SMask`), blend mode (`BM`), overprint (`OP` / `op` / `OPM`),
38//! transfer / halftone / black-generation, font (`Font`), and rendering
39//! intent (`RI`) are silently ignored — they require IR plumbing that
40//! the round-3 vector model doesn't carry yet. Unknown keys are tolerated
41//! per §8.4.5 ("any combination of parameter entries"). Without the
42//! resource-aware entry point, `gs` is still treated as a tolerated
43//! no-op so legacy `parse_content_stream` callers don't regress.
44//!
45//! Text-show resolution (round 128): when the page's `/Resources /Font`
46//! subdictionary is also plumbed in via [`parse_content_stream_full`], the
47//! `BT … ET` text-object operators (ISO 32000-1 §9.4 + Table 105) are
48//! parsed: a `Tf` (`/Fx 12 Tf`) records the active font + size, `Tm` /
49//! `Td` / `TD` / `T*` update the text matrix per §9.4.4 Table 108, and
50//! every `Tj` / `TJ` / `'` / `"` show operator emits one [`ContentTextShow`]
51//! event carrying the raw operand bytes, font resource name (with the
52//! resolved font dictionary handed back via `font_dict`), font size, and
53//! text-matrix origin at the moment of the show. The events come back
54//! alongside the painted `root` group in a [`ParsedContent`] struct; the
55//! reader's higher-level text-extraction walker (round 22) still owns the
56//! byte→Unicode decoding, but the new entry point lets a consumer that
57//! already has the page's `/Resources /Font` resolved get a font-aware
58//! show stream straight from the vector-content parser. Without the
59//! resource-aware entry point, `Tj` / `TJ` / `Tf` / … keep their
60//! round-3 no-op behaviour so existing callers don't regress.
61//!
62//! Colour-space tracking (round 118): `cs` / `CS` record which device
63//! colour family is active so a following `sc` / `scn` (or `SC` /
64//! `SCN`) interprets its operands correctly — `/DeviceRGB cs 1 0 0 sc`
65//! now produces red, where the round-3 parser collapsed every
66//! `sc`/`scn` to black. The parser still does not reach into the
67//! page's `/Resources /ColorSpace` dict for non-device colour-space
68//! keys, nor for gradient / pattern lookups — those land later (the
69//! top-level walker that has the resolved Document). A `/Pat0 scn`
70//! pair, a CIE-based / Indexed / Separation / DeviceN space, or any
71//! unresolved resource key produces a black solid fill (matches the
72//! writer's "unknown-paint fallback", so the roundtrip stays
73//! semantically conservative).
74//!
75//! Text-showing operators (`BT` / `ET` / `Tj` / `TJ` / `'` / `"`) are
76//! parsed when the page's `/Resources /Font` dictionary is plumbed in
77//! via [`parse_content_stream_full`] and surface as
78//! [`ContentTextShow`] events on the returned [`ParsedContent`]. The
79//! legacy [`parse_content_stream`] and
80//! [`parse_content_stream_with_resources`] entry points drop them
81//! silently to preserve round-3 / round-125 callers' behaviour.
82
83use std::collections::{BTreeMap, BTreeSet};
84use std::str;
85
86use oxideav_core::vector::{
87    DashPattern, FillRule, GradientStop, Group, LineCap, LineJoin, LinearGradient, Node, Paint,
88    Path, PathCommand, PathNode, Point, RadialGradient, Rgba, SpreadMethod, Stroke, Transform2D,
89};
90
91use crate::error::PdfError;
92use crate::objects::{Dict, Object};
93use crate::reader::inline_images::{find_inline_image_ei, parse_one_inline_image, PdfInlineImage};
94
95/// Parse a content-stream byte sequence into a single [`Group`]
96/// containing every shape painted by the stream. Nested `q`/`Q`
97/// brackets become nested `Node::Group` children. The returned root
98/// group has identity transform; per-`q` transforms live on the
99/// child groups.
100///
101/// `gs` operators are tolerated (operands dropped) since this entry
102/// point has no view of the page's `/Resources` dictionary. Callers
103/// that have already resolved the page resources should use
104/// [`parse_content_stream_with_resources`] so a `/GSx gs` can apply
105/// the named graphics-state parameter dictionary's entries to the
106/// current state per ISO 32000-1 §8.4.5.
107///
108/// Text-show operators (`BT … Tj/TJ/'/'" … ET`) are skipped silently;
109/// callers that need them should route through
110/// [`parse_content_stream_full`] with a resolved `/Resources /Font`
111/// dictionary attached.
112pub fn parse_content_stream(input: &[u8]) -> Result<Group, PdfError> {
113    let mut state = State::new(None, None, None, None, None);
114    state.parse(input)?;
115    Ok(state.finish().root)
116}
117
118/// Parse a content-stream with the page's resolved `/Resources
119/// /ExtGState` subdictionary attached. A `/Name gs` operator looks
120/// `Name` up in `ext_gstate` and applies the entries Table 58 defines
121/// that map cleanly onto the round-3 vector IR (`LW`, `LC`, `LJ`,
122/// `ML`, `D`, `CA`, `ca`).
123///
124/// The dictionary is read-only — keys are resolved by name lookup, no
125/// indirect-reference following is attempted (the caller is expected
126/// to have already resolved every child dict). When `ext_gstate` is
127/// `None` or doesn't contain the named entry, the `gs` operator
128/// silently no-ops, matching the round-3 fallback behaviour.
129///
130/// Text-show events are still skipped — see [`parse_content_stream_full`]
131/// for the entry point that also plumbs `/Resources /Font`.
132pub fn parse_content_stream_with_resources(
133    input: &[u8],
134    ext_gstate: Option<&Dict>,
135) -> Result<Group, PdfError> {
136    let mut state = State::new(ext_gstate, None, None, None, None);
137    state.parse(input)?;
138    Ok(state.finish().root)
139}
140
141/// Parse a content-stream with both the page's resolved `/Resources
142/// /ExtGState` and `/Resources /Font` subdictionaries attached. In
143/// addition to the round-125 `gs` resolution path, every text-object
144/// operator inside a `BT … ET` block (ISO 32000-1 §9.4 + Table 105)
145/// is honoured: `Tf` records the active font name + size,
146/// `Tm`/`Td`/`TD`/`T*` update the text matrix per §9.4.4 Table 108,
147/// and each `Tj`/`TJ`/`'`/`"` show operator emits one
148/// [`ContentTextShow`] event into the returned [`ParsedContent`].
149///
150/// Both resource dictionaries are read-only — keys are resolved by
151/// name lookup, no indirect-reference following is attempted (the
152/// caller is expected to have already resolved every child dict via
153/// the helpers in [`crate::reader::document`]). When `font_resources`
154/// is `None` or doesn't contain the `Tf`-named font, the show event
155/// still fires but its `font_dict` is `None` so the consumer knows
156/// the font wasn't resolved.
157pub fn parse_content_stream_full(
158    input: &[u8],
159    ext_gstate: Option<&Dict>,
160    font_resources: Option<&Dict>,
161) -> Result<ParsedContent, PdfError> {
162    parse_content_stream_full_with_shading(input, ext_gstate, font_resources, None)
163}
164
165/// Parse a content-stream with the page's resolved `/Resources
166/// /ExtGState`, `/Resources /Font`, and `/Resources /Shading`
167/// subdictionaries attached. Same as [`parse_content_stream_full`]
168/// plus dispatch for the §8.7.4.5 `name sh` operator: each `sh`
169/// records one [`ContentShading`] event into
170/// [`ParsedContent::shadings`] capturing the shading resource name,
171/// the resolved shading dictionary from `/Resources /Shading`, the
172/// effective CTM at the moment of the paint, and the current clip
173/// path (the `W`/`W*`-committed region for the active `q` frame).
174///
175/// The shading dictionary is not interpreted — its `ShadingType`,
176/// `ColorSpace`, `Coords`, `Function`, etc. (§8.7.4.5 Tables 78..86)
177/// stay verbatim so the caller can either route them through a
178/// dedicated shading-resolver or attach them to a downstream IR.
179///
180/// `shading_resources` follows the same one-hop-indirect contract as
181/// `ext_gstate` and `font_resources`: callers go through
182/// [`crate::reader::document::resolve_shading_resources`] to get a
183/// resolved dict whose per-name entries are direct `Object::Dict`
184/// values. When `shading_resources` is `None` or doesn't contain the
185/// `sh`-named key, the event still fires but its `shading_dict` is
186/// `None` so the consumer knows the resource wasn't resolved.
187pub fn parse_content_stream_full_with_shading(
188    input: &[u8],
189    ext_gstate: Option<&Dict>,
190    font_resources: Option<&Dict>,
191    shading_resources: Option<&Dict>,
192) -> Result<ParsedContent, PdfError> {
193    parse_content_stream_full_with_color_space(
194        input,
195        ext_gstate,
196        font_resources,
197        shading_resources,
198        None,
199    )
200}
201
202/// Parse a content-stream with the page's resolved `/Resources`
203/// `/ExtGState`, `/Font`, `/Shading`, **and `/ColorSpace`**
204/// subdictionaries attached. Same as
205/// [`parse_content_stream_full_with_shading`] plus round-275
206/// colour-space resolution: a `cs` / `CS` operator naming a key in the
207/// `/Resources /ColorSpace` dict (rather than a bare device family
208/// `/DeviceGray` / `/DeviceRGB` / `/DeviceCMYK`) is resolved against it
209/// per ISO 32000-1 §8.6.8 Table 74 + §8.6.5 + §8.6.6.
210///
211/// Two non-CIE resource colour-space families reduce to a device
212/// fallback the round-118 parser previously collapsed to black:
213///
214/// * **`ICCBased`** (§8.6.5.5) — the `/Alternate` device space is used
215///   when present, otherwise the profile's `/N` component count selects
216///   DeviceGray (1) / DeviceRGB (3) / DeviceCMYK (4). The ICC profile
217///   bytes themselves are not interpreted (the spec authorises exactly
218///   this fallback for a reader that does not process the profile).
219/// * **`Indexed`** (§8.6.6.3) — when the base reduces to a device
220///   family, a subsequent `sc`/`scn` index selects the corresponding
221///   `m`-byte entry from the resolved colour table (index rounded to
222///   nearest, clamped into `0..=hival`, each byte scaled `0..255 →`
223///   the component range).
224///
225/// CalRGB / CalGray / Lab (CIE-based, need a gamut-mapping pass),
226/// Separation / DeviceN (need tint-transform function evaluation), and
227/// `/Pattern` keep the conservative black fallback.
228///
229/// `color_space_resources` follows the same one-hop-resolved contract
230/// as the other resource dicts: callers go through
231/// [`crate::reader::document::resolve_color_space_resources`] to get a
232/// dict whose per-name entries are resolved colour-space `Object`s
233/// (ICC profile streams replaced by their dictionaries, Indexed lookup
234/// streams replaced by their decoded bytes). When
235/// `color_space_resources` is `None` or doesn't contain the named key,
236/// a non-device `cs`/`CS` stays `Unknown` and `sc`/`scn` keeps the
237/// black fallback, matching round-118 behaviour.
238pub fn parse_content_stream_full_with_color_space(
239    input: &[u8],
240    ext_gstate: Option<&Dict>,
241    font_resources: Option<&Dict>,
242    shading_resources: Option<&Dict>,
243    color_space_resources: Option<&Dict>,
244) -> Result<ParsedContent, PdfError> {
245    parse_content_stream_full_with_properties(
246        input,
247        ext_gstate,
248        font_resources,
249        shading_resources,
250        color_space_resources,
251        None,
252    )
253}
254
255/// Parse a content-stream with the page's resolved `/Resources`
256/// `/ExtGState`, `/Font`, `/Shading`, `/ColorSpace`, **and
257/// `/Properties`** subdictionaries attached. Same as
258/// [`parse_content_stream_full_with_color_space`] plus dispatch for the
259/// §14.6 marked-content operators (Table 320):
260///
261/// * `tag MP` / `tag properties DP` — a marked-content **point**.
262/// * `tag BMC` / `tag properties BDC` — **begin** a marked-content
263///   sequence, terminated by a balancing `EMC`.
264/// * `EMC` — **end** the most recent `BMC`/`BDC` sequence.
265///
266/// Each operator records one [`ContentMarkedContent`] into
267/// [`ParsedContent::marked_content`] in stream order, carrying the
268/// operator discriminator, the `tag` name, the resolved property list
269/// (`DP`/`BDC` only), and the sequence-nesting depth. The walker does
270/// not interpret the property list — its entries (`/OC`, `/MCID`,
271/// `/ActualText`, `/Alt`, …) stay verbatim for a downstream consumer.
272///
273/// The `properties` operand of `DP`/`BDC` is resolved per §14.6.2:
274/// an inline `<< … >>` dictionary is captured directly; a `/Name`
275/// operand is looked up in `properties_resources` (the page's resolved
276/// `/Resources /Properties` subdictionary). `properties_resources`
277/// follows the same one-hop-indirect contract as the other resource
278/// dicts: callers go through
279/// [`crate::reader::document::resolve_properties_resources`] to get a
280/// dict whose per-name entries are direct `Object::Dict` values. When
281/// `properties_resources` is `None` or doesn't contain the named key,
282/// the event still fires but its `properties` stays `None`.
283pub fn parse_content_stream_full_with_properties(
284    input: &[u8],
285    ext_gstate: Option<&Dict>,
286    font_resources: Option<&Dict>,
287    shading_resources: Option<&Dict>,
288    color_space_resources: Option<&Dict>,
289    properties_resources: Option<&Dict>,
290) -> Result<ParsedContent, PdfError> {
291    let mut state = State::new(
292        ext_gstate,
293        font_resources,
294        shading_resources,
295        color_space_resources,
296        properties_resources,
297    );
298    state.parse(input)?;
299    Ok(state.finish())
300}
301
302/// Like [`parse_content_stream_full_with_properties`] but also accepts
303/// the page's pre-parsed Form XObjects (§8.10), keyed by `/Resources
304/// /XObject` resource name (leading `/` stripped). When a `name Do`
305/// operator references one of these, the form's content — already
306/// parsed into a [`Group`] whose `transform` is the form's `/Matrix`
307/// and whose `clip` is the `/BBox` rectangle — is spliced into the
308/// scene tree under the current CTM (§8.10.1's q / concat-Matrix /
309/// clip-BBox / paint / Q algorithm). A `Do` naming an unknown form
310/// (or any Image XObject, which is surfaced separately by
311/// [`crate::reader::images`]) stays a tolerated no-op.
312///
313/// The forms are pre-parsed by the caller
314/// ([`crate::reader::document::resolve_xobject_forms`]) so this parser
315/// never touches the reader: each form's own `/Resources` are resolved
316/// and its content recursively parsed before the map is built, with a
317/// depth guard against nested-form cycles.
318#[allow(clippy::too_many_arguments)]
319pub fn parse_content_stream_full_with_xobjects(
320    input: &[u8],
321    ext_gstate: Option<&Dict>,
322    font_resources: Option<&Dict>,
323    shading_resources: Option<&Dict>,
324    color_space_resources: Option<&Dict>,
325    properties_resources: Option<&Dict>,
326    xobject_forms: Option<&BTreeMap<String, Group>>,
327) -> Result<ParsedContent, PdfError> {
328    let mut state = State::new(
329        ext_gstate,
330        font_resources,
331        shading_resources,
332        color_space_resources,
333        properties_resources,
334    )
335    .with_xobject_forms(xobject_forms);
336    state.parse(input)?;
337    Ok(state.finish())
338}
339
340/// [`parse_content_stream_full_with_xobjects`] plus the page's
341/// `/Resources /Pattern` subdictionary, so a `scn`/`SCN` shading-pattern
342/// fill (`/PatternType 2`, §8.7.3.3 + §8.7.4.5) paints the equivalent
343/// scene gradient instead of falling back to black. Each pattern entry's
344/// `/Shading` is evaluated through the same axial / radial machinery the
345/// `sh` operator uses; the shading `Coords` are mapped to device space
346/// through the pattern `/Matrix` composed with the CTM in effect.
347#[allow(clippy::too_many_arguments)]
348pub fn parse_content_stream_full_with_patterns(
349    input: &[u8],
350    ext_gstate: Option<&Dict>,
351    font_resources: Option<&Dict>,
352    shading_resources: Option<&Dict>,
353    color_space_resources: Option<&Dict>,
354    properties_resources: Option<&Dict>,
355    xobject_forms: Option<&BTreeMap<String, Group>>,
356    pattern_resources: Option<&Dict>,
357) -> Result<ParsedContent, PdfError> {
358    let mut state = State::new(
359        ext_gstate,
360        font_resources,
361        shading_resources,
362        color_space_resources,
363        properties_resources,
364    )
365    .with_xobject_forms(xobject_forms)
366    .with_pattern_resources(pattern_resources);
367    state.parse(input)?;
368    Ok(state.finish())
369}
370
371/// Like [`parse_content_stream_full_with_patterns`] but also accepts the
372/// page's pre-parsed `/PatternType 1` tiling patterns (§8.7.3), so a
373/// `scn`/`SCN` fill naming a tiling pattern replicates its pattern cell
374/// across the filled region instead of falling back to black. Each entry
375/// is a [`TilingPattern`] carrying the cell content parsed into a
376/// [`Group`] (against the pattern's own `/Resources`), the `/BBox` clip,
377/// the `/XStep` / `/YStep` spacing, and the pattern `/Matrix`.
378#[allow(clippy::too_many_arguments)]
379pub fn parse_content_stream_full_with_tiling(
380    input: &[u8],
381    ext_gstate: Option<&Dict>,
382    font_resources: Option<&Dict>,
383    shading_resources: Option<&Dict>,
384    color_space_resources: Option<&Dict>,
385    properties_resources: Option<&Dict>,
386    xobject_forms: Option<&BTreeMap<String, Group>>,
387    pattern_resources: Option<&Dict>,
388    tiling_patterns: Option<&BTreeMap<String, TilingPattern>>,
389) -> Result<ParsedContent, PdfError> {
390    let mut state = State::new(
391        ext_gstate,
392        font_resources,
393        shading_resources,
394        color_space_resources,
395        properties_resources,
396    )
397    .with_xobject_forms(xobject_forms)
398    .with_pattern_resources(pattern_resources)
399    .with_tiling_patterns(tiling_patterns);
400    state.parse(input)?;
401    Ok(state.finish())
402}
403
404/// Like [`parse_content_stream_full_with_tiling`] but also accepts the
405/// page's pre-parsed Type 3 fonts (§9.6.5), so a `Tj`/`TJ`/`'`/`"` show
406/// selecting a Type 3 font paints each glyph's `/CharProcs` description
407/// into the scene tree as vector geometry — the one simple-font family
408/// whose glyphs are themselves content streams and therefore need no
409/// external glyph rasteriser. Each entry is a [`Type3Font`] carrying the
410/// `/FontMatrix`, the `/Encoding` code→glyph-name map, and every glyph
411/// description pre-parsed into a [`Group`] against the font's own
412/// `/Resources`.
413#[allow(clippy::too_many_arguments)]
414pub fn parse_content_stream_full_with_type3(
415    input: &[u8],
416    ext_gstate: Option<&Dict>,
417    font_resources: Option<&Dict>,
418    shading_resources: Option<&Dict>,
419    color_space_resources: Option<&Dict>,
420    properties_resources: Option<&Dict>,
421    xobject_forms: Option<&BTreeMap<String, Group>>,
422    pattern_resources: Option<&Dict>,
423    tiling_patterns: Option<&BTreeMap<String, TilingPattern>>,
424    type3_fonts: Option<&BTreeMap<String, Type3Font>>,
425) -> Result<ParsedContent, PdfError> {
426    let mut state = State::new(
427        ext_gstate,
428        font_resources,
429        shading_resources,
430        color_space_resources,
431        properties_resources,
432    )
433    .with_xobject_forms(xobject_forms)
434    .with_pattern_resources(pattern_resources)
435    .with_tiling_patterns(tiling_patterns)
436    .with_type3_fonts(type3_fonts);
437    state.parse(input)?;
438    Ok(state.finish())
439}
440
441/// Output of [`parse_content_stream_full`] — the painted-shapes group
442/// (same as the round-3 / round-125 entry points return) plus the
443/// stream-order list of text-show events the round-128 walker
444/// surfaces when `/Resources /Font` is plumbed in.
445#[derive(Clone, Debug, Default)]
446pub struct ParsedContent {
447    /// Painted-shapes group — identical to the `Group` returned by
448    /// [`parse_content_stream`] / [`parse_content_stream_with_resources`].
449    pub root: Group,
450    /// Every `Tj`/`TJ`/`'`/`"` show, in stream order. Decoding the
451    /// raw bytes to Unicode is the caller's responsibility (the
452    /// round-22 [`crate::reader::text::extract_text`] walker owns that
453    /// path); this surface gives a resource-resolved view of the show
454    /// operators for tooling that wants something narrower than the
455    /// full text-extraction pipeline.
456    pub text_shows: Vec<ContentTextShow>,
457    /// Every `name sh` shading-paint event surfaced by
458    /// [`parse_content_stream_full_with_shading`] when
459    /// `/Resources /Shading` is plumbed in. One entry per `sh`
460    /// operator in stream order. Empty when no `sh` operator fired
461    /// or when the legacy entry points (`parse_content_stream`,
462    /// `parse_content_stream_with_resources`, the no-shading
463    /// `parse_content_stream_full`) are used.
464    pub shadings: Vec<ContentShading>,
465    /// Every marked-content operator (`MP`/`DP`/`BMC`/`BDC`/`EMC`,
466    /// §14.6 Table 320) seen by the walker, in stream order. One entry
467    /// per operator. Like [`text_shows`](Self::text_shows) and
468    /// [`shadings`](Self::shadings), these events surface from every
469    /// `ParsedContent`-returning entry point — `MP`/`BMC`/`EMC` carry
470    /// no property list, and a `DP`/`BDC` whose `properties` operand is
471    /// a `/Name` simply lands with `properties = None` unless
472    /// `/Resources /Properties` was plumbed in via
473    /// [`parse_content_stream_full_with_properties`]. Inline `<< … >>`
474    /// property lists are captured regardless of the entry point. The
475    /// `Group`-returning legacy entries (`parse_content_stream`,
476    /// `parse_content_stream_with_resources`) discard the whole
477    /// `ParsedContent`, so the events are unobservable there.
478    pub marked_content: Vec<ContentMarkedContent>,
479    /// Every `BI … ID … EI` inline image (§8.9.7) the walker saw, in
480    /// stream order — one entry per inline image, carrying its
481    /// resolved dictionary + payload and the CTM / clip in force at
482    /// the `BI`. Surfaced from every `ParsedContent`-returning entry
483    /// point (the resolution needs no `/Resources` plumbing — an
484    /// inline image is self-contained). The `Group`-returning legacy
485    /// entries discard the whole `ParsedContent`, but they still
486    /// consume the `BI … EI` correctly so the surrounding shapes
487    /// survive.
488    pub inline_images: Vec<ContentInlineImage>,
489}
490
491/// One `Tj`/`TJ`/`'`/`"` text-show event surfaced by
492/// [`parse_content_stream_full`]. The raw operand bytes are preserved
493/// verbatim — escape-decoded for literal strings and hex-pair-decoded
494/// for hex strings — so a consumer that wants byte→Unicode mapping can
495/// route them through whatever decoder its `font_dict` calls for.
496#[derive(Clone, Debug)]
497pub struct ContentTextShow {
498    /// Font resource name as named by the most recent `Tf`, with the
499    /// leading `/` stripped (e.g. `"F1"`). Empty when the content
500    /// stream issued a show without a preceding `Tf` (malformed but
501    /// tolerated).
502    pub font_name: String,
503    /// Font size from the most recent `Tf`. `0.0` if no `Tf` was seen.
504    pub font_size: f32,
505    /// Resolved font dictionary from `/Resources /Font /<font_name>`,
506    /// or `None` when the font wasn't found in the supplied
507    /// `font_resources` (or no `font_resources` was supplied).
508    pub font_dict: Option<Dict>,
509    /// Concatenated payload bytes — for `Tj` and `'` the single
510    /// operand; for `"` the trailing string operand; for `TJ` the
511    /// strings inside the array, concatenated in array order (the
512    /// per-element numeric displacements aren't applied because they
513    /// only affect glyph kerning, not the decoded text).
514    pub bytes: Vec<u8>,
515    /// Text-matrix origin `(e, f)` in user space at the moment the
516    /// show fired — the position the first glyph would have been
517    /// painted at. Reflects every `Tm`/`Td`/`TD`/`T*` update issued
518    /// inside the enclosing `BT … ET` (the matrix resets to identity
519    /// at every `BT`).
520    pub position: (f32, f32),
521    /// Which show operator produced this event (`Tj` / `TJ` / `'` /
522    /// `"`). Lets a downstream consumer reconstruct the original
523    /// operator stream verbatim.
524    pub operator: TextShowOp,
525}
526
527/// Discriminator for [`ContentTextShow::operator`].
528#[derive(Clone, Copy, Debug, PartialEq, Eq)]
529pub enum TextShowOp {
530    /// `string Tj` — show one string.
531    Tj,
532    /// `[(s1) num1 (s2) num2 …] TJ` — show with per-element kerning.
533    TJ,
534    /// `' string` — move to the next line, then show (`T* Tj`).
535    SingleQuote,
536    /// `"`a_w a_c string"`` — set word + char spacing, move to next
537    /// line, then show.
538    DoubleQuote,
539}
540
541/// One `BI … ID … EI` inline-image event surfaced by the
542/// content-stream walker (ISO 32000-1 §8.9.7). Unlike an Image
543/// XObject (which is named and resolved through `/Resources
544/// /XObject`), an inline image carries its dictionary + payload
545/// directly in the content stream, so the walker is the only place
546/// it can be observed with the correct graphics-state context.
547///
548/// The walker does NOT decode the payload into pixels — that belongs
549/// to the image pipeline. It captures the resolved
550/// [`PdfInlineImage`] (dictionary + filter-peeled payload, per
551/// [`crate::reader::inline_images`]) together with the placement
552/// context that §8.9 / §8.7.3.4 require to position it:
553///
554/// * `ctm` — the composed current transformation matrix at the `BI`.
555///   An inline image is painted into the unit square `0 ≤ x,y ≤ 1`
556///   in image space, mapped to user space by the CTM (§8.9.5.1), so
557///   `ctm` is exactly the placement matrix.
558/// * `clip` — the most recent `W`/`W*`-committed clip path in force,
559///   or `None`. The image is subject to it (§8.5.4).
560#[derive(Clone, Debug)]
561pub struct ContentInlineImage {
562    /// The resolved inline image — dictionary fields (width, height,
563    /// colour space, bits-per-component, terminal codec filter,
564    /// image-mask flag) plus the wrapping-filter-peeled payload.
565    pub image: PdfInlineImage,
566    /// Composed current transformation matrix at the moment of the
567    /// `BI` operator. Maps the unit-square image space to user space
568    /// (§8.9.5.1).
569    pub ctm: Transform2D,
570    /// Active clip path (most recent `W`/`W*` commit in the live `q`
571    /// frame), or `None` when no clip is in force (§8.5.4).
572    pub clip: Option<Path>,
573}
574
575/// One `name sh` shading-paint event surfaced by
576/// [`parse_content_stream_full_with_shading`]. ISO 32000-1 §8.7.4.5
577/// defines `sh` as "paint the shape and colour shading described by
578/// a shading dictionary, subject to the current clipping path". This
579/// surface captures every input that determines the painted region
580/// and colour:
581///
582/// * `name` — the shading-resource key the operator named (leading
583///   `/` stripped).
584/// * `shading_dict` — the resolved shading dictionary from
585///   `/Resources /Shading /<name>`, or `None` when the caller didn't
586///   plumb in `shading_resources` (or when the name wasn't a key in
587///   the supplied resources).
588/// * `ctm` — the composed current transformation matrix at the
589///   moment of the paint (every `cm` in every enclosing `q` frame,
590///   composed root-to-leaf). All coordinates inside `shading_dict`
591///   are interpreted relative to this transform per §8.7.4.5
592///   ("interpreted relative to the current user space").
593/// * `clip` — the most recent `W`/`W*`-committed clip path in the
594///   active `q` frame, or `None` when no clip is in force. The
595///   shading is subject to this region (§8.7.4.5 "subject to the
596///   current clipping path").
597#[derive(Clone, Debug)]
598pub struct ContentShading {
599    /// Shading-resource key the `sh` operator named, with the
600    /// leading `/` stripped (e.g. `"Sh1"`). Empty when the operator
601    /// was issued without a `/Name` operand (malformed but
602    /// tolerated, mirroring the round-128 `Tj`-without-`Tf` stance).
603    pub name: String,
604    /// Resolved shading dictionary from `/Resources /Shading
605    /// /<name>`, or `None` when the caller didn't plumb in
606    /// `shading_resources` (the legacy entry points) or when the
607    /// name wasn't a key in the supplied resources.
608    pub shading_dict: Option<Dict>,
609    /// Effective CTM at the moment of the paint — composed of every
610    /// `cm` operator in every enclosing `q` frame, root-to-leaf.
611    pub ctm: Transform2D,
612    /// Active clip path from the current `q` frame's most recent
613    /// `W`/`W*`. `None` when no clip is in force.
614    pub clip: Option<Path>,
615    /// Evaluated mesh geometry for a Type 4–7 shading (free-form /
616    /// lattice-form Gouraud triangle mesh, Coons patch mesh, or
617    /// tensor-product patch mesh, §8.7.4.5.5–8.7.4.5.8). `None` for an
618    /// axial / radial / function-based shading (Types 1–3, which carry
619    /// their colour in the `Function` entry rather than a mesh stream),
620    /// for a shading the caller didn't plumb resources for, or for a
621    /// mesh whose stream / colour space / function couldn't be reduced
622    /// to evaluated RGB vertices. Coordinates are in the shading's
623    /// target coordinate space (pre-`ctm`); apply `ctm` to map them to
624    /// device space.
625    pub mesh: Option<MeshShading>,
626    /// Evaluated gradient geometry + sampled colour stops for a Type 1–3
627    /// shading (function-based / axial / radial, §8.7.4.5.2–4). `None`
628    /// for a Type 4–7 mesh shading (use [`mesh`](Self::mesh) instead),
629    /// for a shading the caller didn't plumb resources for, or for a
630    /// shading whose colour space / function couldn't be evaluated.
631    /// Coordinates are in the shading's target coordinate space
632    /// (pre-`ctm`).
633    pub gradient: Option<ShadingGradient>,
634}
635
636/// Evaluated geometry + sampled colour for a Type 1–3 shading
637/// (§8.7.4.5.2–4). The colour function is sampled at a fixed resolution
638/// so a downstream rasteriser sees concrete RGB stops rather than an
639/// abstract function object; the geometry (axis / circles / domain
640/// rectangle) and `Extend` flags are carried so the consumer can map a
641/// device-space point to its parametric value.
642#[derive(Clone, Debug, PartialEq)]
643pub enum ShadingGradient {
644    /// Type 2 (axial) shading (§8.7.4.5.3): a colour blend along the
645    /// linear axis from `(x0, y0)` to `(x1, y1)`.
646    Axial {
647        /// Axis endpoints `[x0, y0, x1, y1]` (`Coords`) in target space.
648        coords: [f32; 4],
649        /// `Extend` flags `[before, after]` (§8.7.4.5.3).
650        extend: [bool; 2],
651        /// Colour stops sampled uniformly across the `Domain` `[t0, t1]`,
652        /// from `t0` (first) to `t1` (last).
653        stops: Vec<Rgba>,
654    },
655    /// Type 3 (radial) shading (§8.7.4.5.4): a colour blend between the
656    /// circle `(x0, y0, r0)` and `(x1, y1, r1)`.
657    Radial {
658        /// Circle parameters `[x0, y0, r0, x1, y1, r1]` (`Coords`).
659        coords: [f32; 6],
660        /// `Extend` flags `[before, after]` (§8.7.4.5.4).
661        extend: [bool; 2],
662        /// Colour stops sampled uniformly across the `Domain` `[t0, t1]`,
663        /// from the starting circle (`s = 0`) to the ending circle
664        /// (`s = 1`).
665        stops: Vec<Rgba>,
666    },
667    /// Type 1 (function-based) shading (§8.7.4.5.2): the colour at every
668    /// point of the `Domain` rectangle is the value of a 2-in / n-out
669    /// function. Sampled onto a uniform grid over the domain.
670    FunctionBased {
671        /// Domain rectangle `[xmin, xmax, ymin, ymax]` (`Domain`).
672        domain: [f32; 4],
673        /// `Matrix` mapping the domain rectangle into target space.
674        matrix: Transform2D,
675        /// Grid width / height (samples per axis).
676        grid: (usize, usize),
677        /// `grid.0 × grid.1` sampled colours, row-major with the first
678        /// (x) axis varying fastest; sample `(i, j)` at index
679        /// `j * grid.0 + i` corresponds to domain point
680        /// `(xmin + i·dx, ymin + j·dy)`.
681        samples: Vec<Rgba>,
682    },
683}
684
685/// Evaluated geometry + colour for a Type 4–7 shading (§8.7.4.5.5–8).
686/// All four mesh types reduce to either a list of Gouraud-shaded
687/// triangles (Types 4 and 5) or a list of colour patches each bounded
688/// by four cubic Bézier curves (Types 6 and 7), so this enum carries
689/// the two shapes. Every coordinate is in the shading's target
690/// coordinate space (the pre-`ctm` space the stream's `Decode` array
691/// maps into); every colour is already reduced to device RGB through
692/// the shading's colour space (and its optional parametric `Function`).
693#[derive(Clone, Debug, PartialEq)]
694pub enum MeshShading {
695    /// Types 4 (free-form) and 5 (lattice-form) Gouraud-shaded triangle
696    /// meshes. Each triangle carries three [`MeshVertex`]es; the
697    /// interior colour is the Gouraud (barycentric-linear) interpolation
698    /// of the three vertex colours.
699    Triangles(Vec<MeshTriangle>),
700    /// Types 6 (Coons) and 7 (tensor-product) patch meshes. Each patch
701    /// is a bicubic surface bounded by four cubic Bézier curves with a
702    /// colour at each of its four corners (bilinearly interpolated over
703    /// the patch interior).
704    Patches(Vec<MeshPatch>),
705}
706
707/// One Gouraud-shaded triangle of a Type 4 / Type 5 mesh (§8.7.4.5.5).
708#[derive(Clone, Copy, Debug, PartialEq)]
709pub struct MeshTriangle {
710    /// The three vertices, in stream order. The shaded colour at an
711    /// interior point is the barycentric-linear blend of the three
712    /// vertex colours (Gouraud interpolation).
713    pub vertices: [MeshVertex; 3],
714}
715
716/// One vertex of a Gouraud triangle mesh: a target-space coordinate
717/// plus its evaluated device-RGB colour (§8.7.4.5.5).
718#[derive(Clone, Copy, Debug, PartialEq)]
719pub struct MeshVertex {
720    /// Vertex coordinate in the shading's target coordinate space
721    /// (pre-`ctm`).
722    pub point: Point,
723    /// Evaluated device-RGB colour at the vertex.
724    pub color: Rgba,
725}
726
727/// One colour patch of a Type 6 (Coons) / Type 7 (tensor-product) patch
728/// mesh (§8.7.4.5.7–8). The patch geometry is a bicubic surface; Type 6
729/// patches are stored as the equivalent tensor-product patch (the four
730/// internal control points derived from the boundary curves per the
731/// §8.7.4.5.8 conversion equations), so both types share this 4×4
732/// control-point representation.
733#[derive(Clone, Copy, Debug, PartialEq)]
734pub struct MeshPatch {
735    /// The 16 tensor-product control points `p[col][row]` (§8.7.4.5.8
736    /// Figure 32) in the shading's target coordinate space. For a Type 6
737    /// Coons patch the four internal points (`p[1][1]`, `p[1][2]`,
738    /// `p[2][1]`, `p[2][2]`) are computed from the boundary curves.
739    pub control_points: [[Point; 4]; 4],
740    /// The four corner colours, in the §8.7.4.5.7 corner order
741    /// (`c1`=`p00`, `c2`=`p03`, `c3`=`p33`, `c4`=`p30`). The patch
742    /// interior colour is the bilinear interpolation of these four.
743    pub corner_colors: [Rgba; 4],
744}
745
746/// One marked-content operator surfaced by
747/// [`parse_content_stream_full_with_properties`]. ISO 32000-1 §14.6
748/// defines five marked-content operators (Table 320) that fall into
749/// two shapes:
750///
751/// * **Points** — `MP` (`tag`) and `DP` (`tag properties`) designate a
752///   single marked-content point in the stream.
753/// * **Sequences** — `BMC` (`tag`) and `BDC` (`tag properties`) begin a
754///   sequence terminated by a balancing `EMC`.
755///
756/// One [`ContentMarkedContent`] is recorded per operator in stream
757/// order, so a downstream consumer can reconstruct the marked-content
758/// tree (e.g. to find an `/OC` membership tag for optional content,
759/// §8.11.3.2, or an `/ActualText` / `/Alt` accessibility entry,
760/// §14.9.4). The walker does **not** interpret the property list — its
761/// entries stay verbatim in `properties` so a downstream consumer can
762/// resolve `/OC`, `/MCID`, `/ActualText`, etc. as it sees fit.
763#[derive(Clone, Debug)]
764pub struct ContentMarkedContent {
765    /// Which marked-content operator produced this event.
766    pub operator: MarkedContentOp,
767    /// The `tag` operand — a `Name` indicating the role or significance
768    /// of the marked content (e.g. `"OC"`, `"Span"`, `"P"`), leading
769    /// `/` stripped. `EMC` carries no tag, so its `tag` is empty.
770    pub tag: String,
771    /// The resolved property list for `DP` / `BDC` (§14.6.2): either the
772    /// inline `<< … >>` dictionary written directly after the tag, or
773    /// the dictionary named in `/Resources /Properties` when the
774    /// operand was a `/Name`. `None` for `MP` / `BMC` / `EMC` (which
775    /// carry no property list) and for a `DP` / `BDC` whose `/Name`
776    /// operand wasn't resolvable against the supplied
777    /// `properties_resources`.
778    pub properties: Option<Dict>,
779    /// Nesting depth at the moment the operator fired, counting
780    /// `BMC`/`BDC`-opened sequences only (`MP`/`DP` points do not nest).
781    /// `BMC`/`BDC` report the depth of the sequence they open (0 for a
782    /// top-level sequence); the matching `EMC` reports the same depth;
783    /// `MP`/`DP` report the depth of the sequence that encloses them
784    /// (0 when not inside any). An unbalanced `EMC` (no open sequence)
785    /// reports depth 0 and is tolerated.
786    pub depth: u32,
787}
788
789/// Discriminator for [`ContentMarkedContent::operator`] (ISO 32000-1
790/// §14.6 Table 320).
791#[derive(Clone, Copy, Debug, PartialEq, Eq)]
792pub enum MarkedContentOp {
793    /// `tag MP` — designate a marked-content point.
794    Mp,
795    /// `tag properties DP` — marked-content point with a property list.
796    Dp,
797    /// `tag BMC` — begin a marked-content sequence.
798    Bmc,
799    /// `tag properties BDC` — begin a sequence with a property list.
800    Bdc,
801    /// `EMC` — end the most recent `BMC`/`BDC` sequence.
802    Emc,
803}
804
805// ───────────────────────── parser state ─────────────────────────
806
807/// Per-graphics-state tracker. Pushed on `q`, popped on `Q`. The
808/// active state is `stack.last_mut().unwrap()`; the always-present
809/// root frame collects whatever the input emits before any explicit
810/// `q`/`Q`.
811struct State<'a> {
812    /// Argument stack — operands are pushed as the parser scans
813    /// numbers, names, arrays; an operator keyword consumes them.
814    operands: Vec<Operand>,
815    /// Group stack mirroring PDF's graphics-state stack.
816    stack: Vec<Frame>,
817    /// Current path being built (the most recent `m`/`l`/`c`/`re`
818    /// sequence). `None` after a paint operator commits it.
819    current_path: Option<Path>,
820    /// Tracking for the current path's last endpoint — needed to
821    /// handle the shorthand cubics `v` (use current pt as c1) and
822    /// `y` (use end pt as c2).
823    current_point: Point,
824    /// Last set fill / stroke paint state. Reset on each `q`/`Q`
825    /// (PDF graphics state) since `q` saves the entire colour /
826    /// stroke state and `Q` restores it.
827    fill_paint: Option<Paint>,
828    stroke_paint: Option<Paint>,
829    /// Current nonstroking colour space, selected by `cs` (§8.6.8
830    /// Table 74). `sc`/`scn` interpret their numeric operands against
831    /// it. Defaults to `DeviceGray` per §8.6.3 Table 73 (the initial
832    /// colour space for nonstroking operations).
833    fill_cs: ColorSpaceKind,
834    /// Current stroking colour space, selected by `CS`.
835    stroke_cs: ColorSpaceKind,
836    stroke_width: f32,
837    line_cap: LineCap,
838    line_join: LineJoin,
839    miter_limit: f32,
840    dash: Option<DashPattern>,
841    /// Current nonstroking alpha constant (`ca`, §11.6.4.4 + Table
842    /// 58). Multiplied into the fill paint's per-channel alpha at
843    /// `commit_path`. Initial value 1.0 per the table.
844    fill_alpha: f32,
845    /// Current stroking alpha constant (`CA`, §11.6.4.4 + Table 58).
846    /// Mirror of `fill_alpha` for the stroke side.
847    stroke_alpha: f32,
848    /// Page's `/Resources /ExtGState` subdictionary, if the caller
849    /// went through [`parse_content_stream_with_resources`] — `None`
850    /// for the legacy entry point. Used by the `gs` dispatcher to
851    /// look up the named parameter dict per §8.4.5.
852    ext_gstate: Option<&'a Dict>,
853    /// Page's `/Resources /Font` subdictionary, if the caller went
854    /// through [`parse_content_stream_full`] — `None` otherwise. Each
855    /// per-name entry should already be a direct `Object::Dict`
856    /// (single-hop indirect references dereferenced by
857    /// `reader::document::resolve_font_resources`). When this is
858    /// `Some`, `Tj`/`TJ`/`'`/`"` operators emit
859    /// [`ContentTextShow`] events; when it's `None`, text-show
860    /// operators stay round-3-no-op.
861    font_resources: Option<&'a Dict>,
862    /// Page's `/Resources /Shading` subdictionary, if the caller
863    /// went through [`parse_content_stream_full_with_shading`] —
864    /// `None` otherwise. Each per-name entry should already be a
865    /// direct `Object::Dict` (single-hop indirect references
866    /// dereferenced by `reader::document::resolve_shading_resources`).
867    /// When this is `Some`, a `sh` operator emits a
868    /// [`ContentShading`] with `shading_dict` populated; when it's
869    /// `None`, the event still fires (so the consumer sees the
870    /// operator + name + CTM + clip) but `shading_dict` stays
871    /// `None`.
872    shading_resources: Option<&'a Dict>,
873    /// Page's `/Resources /ColorSpace` subdictionary, if the caller
874    /// went through [`parse_content_stream_full_with_color_space`] —
875    /// `None` otherwise. Each per-name entry is the resolved
876    /// colour-space `Object` (a bare device `/Name`, an `[/ICCBased
877    /// <dict>]` array with the ICC profile stream replaced by its
878    /// dictionary, or an `[/Indexed base hival lookup]` array with the
879    /// lookup stream replaced by its decoded bytes), produced by
880    /// [`crate::reader::document::resolve_color_space_resources`]. When
881    /// this is `Some`, a `cs`/`CS` naming a key in it resolves against
882    /// it (§8.6.5.5 ICCBased + §8.6.6.3 Indexed device fallbacks); when
883    /// it's `None`, a non-device `cs`/`CS` name stays `Unknown`,
884    /// matching the round-118 conservative black fallback.
885    color_space_resources: Option<&'a Dict>,
886    /// Page's `/Resources /Properties` subdictionary, if the caller
887    /// went through [`parse_content_stream_full_with_properties`] —
888    /// `None` otherwise. A `DP` / `BDC` whose `properties` operand is a
889    /// `/Name` (rather than an inline `<< … >>` dictionary, §14.6.2)
890    /// looks the name up here. Each per-name entry should already be a
891    /// direct `Object::Dict` (single-hop indirect references
892    /// dereferenced by
893    /// `reader::document::resolve_properties_resources`). When this is
894    /// `None` or doesn't contain the named key, the marked-content
895    /// event still fires but its `properties` stays `None`.
896    properties_resources: Option<&'a Dict>,
897    /// Open `BMC`/`BDC` sequence count (§14.6). Incremented after a
898    /// `BMC`/`BDC`, decremented before a matching `EMC` (saturating at
899    /// 0 so an unbalanced `EMC` is tolerated). Reported as the
900    /// `ContentMarkedContent::depth` of each event.
901    mc_depth: u32,
902    /// Stream-order marked-content events accumulated for the
903    /// [`ParsedContent::marked_content`] return slot.
904    marked_content: Vec<ContentMarkedContent>,
905    /// Currently-selected font: name (Tf operand, leading `/`
906    /// stripped) + size. Reset on each `Tf`. Cleared when no `Tf`
907    /// has been seen yet — `Tj` then emits with an empty `font_name`
908    /// and `font_size = 0.0`.
909    current_font: Option<(String, f32)>,
910    /// Text matrix `Tm` (§9.4.4 — six-element matrix
911    /// `[ a b c d e f ]`). Reset to identity by every `BT`; updated
912    /// by `Tm`, `Td`, `TD`, `T*`, and the implicit `T*` inside `'`
913    /// and `"`.
914    text_matrix: Transform2D,
915    /// Text line matrix `Tlm` — duplicated from `Tm` by every
916    /// `BT`/`Td`/`TD`/`Tm`, advanced (combined with leading) by
917    /// `T*`/`'`/`"` to give the next line's origin (§9.4.4 "the text
918    /// line matrix … records the start of the next line").
919    text_line_matrix: Transform2D,
920    /// Text leading `TL` (§9.3.5) — the y-step `T*` uses. Defaults
921    /// to 0.0 per Table 105. Set by `TL` and by the implicit `TL` a
922    /// `"` operator emits.
923    text_leading: f32,
924    /// Character spacing `Tc` (§9.3.2) in unscaled text-space units.
925    /// Added to the horizontal component of every glyph's
926    /// displacement (§9.4.4). Default 0.0 (Table 105). Set by `Tc`,
927    /// and by the implicit `Tc` a `"` operator emits.
928    char_spacing: f32,
929    /// Word spacing `Tw` (§9.3.3) in unscaled text-space units.
930    /// Added to the displacement of every single-byte code 32
931    /// (ASCII space) glyph (§9.4.4). Default 0.0 (Table 105). Set by
932    /// `Tw`, and by the implicit `Tw` a `"` operator emits.
933    word_spacing: f32,
934    /// Horizontal scaling `Th` (§9.3.4), stored as a fraction
935    /// (`scale ÷ 100`). Scales the horizontal text-space displacement
936    /// (§9.4.4). Default 1.0 (Table 105 default `100`). Set by `Tz`.
937    horiz_scale: f32,
938    /// Whether the parser is currently inside a `BT … ET` text
939    /// object (§9.4 — operators outside a `BT` are silently ignored
940    /// per Table 105). Toggled by `BT` (`true`) and `ET` (`false`).
941    in_text_object: bool,
942    /// Stream-order text-show events accumulated for the round-128
943    /// [`ParsedContent::text_shows`] return slot.
944    text_shows: Vec<ContentTextShow>,
945    /// Stream-order `sh`-paint events accumulated for the round-259
946    /// [`ParsedContent::shadings`] return slot.
947    shadings: Vec<ContentShading>,
948    /// Stream-order `BI … ID … EI` inline-image events accumulated for
949    /// the [`ParsedContent::inline_images`] return slot.
950    inline_images: Vec<ContentInlineImage>,
951    /// Pre-parsed Form XObjects from the page's `/Resources /XObject`
952    /// subdictionary, keyed by resource name (leading `/` stripped),
953    /// supplied by [`parse_content_stream_full_with_xobjects`] — `None`
954    /// for the legacy entry points. Each value is the form's content
955    /// stream already parsed into a [`Group`] (§8.10.1) whose
956    /// `transform` is the form's `/Matrix` and whose `clip` is the
957    /// `/BBox` rectangle, so a `name Do` against it splices the group
958    /// under the current CTM with a single clone. When this is `None`
959    /// or doesn't contain the named key, `Do` stays a tolerated no-op
960    /// (matching the round-3 drop). Image XObjects are not stored here
961    /// — they are surfaced through the dedicated
962    /// [`crate::reader::images`] walker.
963    xobject_forms: Option<&'a BTreeMap<String, Group>>,
964    /// Page's `/Resources /Pattern` subdictionary, if plumbed in. Each
965    /// per-name entry is the pattern's resolved dictionary (for a
966    /// `/PatternType 2` shading pattern, its `/Shading` subdictionary is
967    /// folded in place exactly like `/Resources /Shading` entries, so a
968    /// `scn /Pname` fill can evaluate the shading's gradient). When this
969    /// is `None` or the named pattern isn't a renderable shading
970    /// pattern, a `scn` pattern operand keeps the conservative black
971    /// fallback (the round-3 behaviour).
972    pattern_resources: Option<&'a Dict>,
973    /// Pre-parsed `/PatternType 1` tiling patterns from the page's
974    /// `/Resources /Pattern` subdictionary, keyed by resource name,
975    /// supplied by [`parse_content_stream_full_with_tiling`] (§8.7.3).
976    /// Each value carries the pattern cell already parsed into a
977    /// [`Group`] plus its `/BBox` clip, `/XStep` / `/YStep` spacing,
978    /// `/Matrix`, and `/PaintType`, so a `scn /Pname` fill naming a
979    /// tiling pattern replicates the cell across the filled region
980    /// (§8.7.3.1) instead of the conservative black fallback. When this
981    /// is `None` or the named pattern isn't a tiling pattern, a `scn`
982    /// pattern operand keeps the black fallback.
983    tiling_patterns: Option<&'a BTreeMap<String, TilingPattern>>,
984    /// Name of the active `/PatternType 1` tiling pattern for the
985    /// nonstroking colour, set by a `scn /Pname` whose `/Pname` resolves
986    /// to a [`TilingPattern`]. Consumed (and cleared by a subsequent
987    /// non-pattern colour operator) at `commit_path` fill time to tile
988    /// the painted region. `None` when no tiling fill is in force.
989    fill_tiling: Option<String>,
990    /// Underlying colour for an *uncoloured* (`/PaintType 2`) tiling
991    /// pattern fill (§8.7.3.3): the numeric components a `scn` supplies
992    /// before the pattern name, in the Pattern colour space's underlying
993    /// space (read by component count — 1 gray / 3 RGB / 4 CMYK). The
994    /// cell is a stencil poured with this colour. `None` for a coloured
995    /// (`/PaintType 1`) pattern or when no components were supplied.
996    fill_tiling_color: Option<Rgba>,
997    /// Name of the active tiling pattern for the stroking colour
998    /// (`SCN /Pname`). Strokes are not tiled — a tiling-stroke pattern
999    /// keeps the black fallback — but the name is tracked symmetrically
1000    /// so a stroking `SCN /Pname` clears the previous solid stroke
1001    /// rather than leaving a stale colour.
1002    stroke_tiling: Option<String>,
1003    /// Pre-parsed Type 3 fonts (§9.6.5) from the page's
1004    /// `/Resources /Font` subdictionary, keyed by font resource name
1005    /// (leading `/` stripped), supplied by
1006    /// [`parse_content_stream_full_with_type3`]. When a `Tj`/`TJ`/`'`/`"`
1007    /// show runs under a font name present here, each character code's
1008    /// glyph description [`Group`] is spliced into the scene tree at the
1009    /// glyph's text-rendering matrix (§9.4.4) composed with the font's
1010    /// `/FontMatrix`. When this is `None` or the active font isn't a
1011    /// Type 3 font, text-show operators stay event-only on the vector
1012    /// side (Type 1 / TrueType / Type 0 outlines need a glyph
1013    /// rasteriser this walker doesn't carry).
1014    type3_fonts: Option<&'a BTreeMap<String, Type3Font>>,
1015    /// Text rendering mode `Tr` (§9.3.6 Table 106). Only mode `3`
1016    /// (invisible — the OCR layer) suppresses Type 3 glyph painting;
1017    /// every other mode paints. Default `0` (fill). Tracked here only
1018    /// for the Type 3 paint path; the dedicated text-extraction walker
1019    /// surfaces the mode independently.
1020    text_render_mode: i64,
1021    /// Text rise `Ts` (§9.4.4) in unscaled text-space units — the
1022    /// vertical offset baked into the text-rendering matrix's `f`
1023    /// component. Raises (positive) / lowers (negative) the glyph
1024    /// baseline. Default `0.0`.
1025    text_rise: f32,
1026    /// Re-entrancy guard for the Type 3 glyph paint path. A glyph
1027    /// description is itself a content stream that may show text in
1028    /// another (or the same) Type 3 font; this caps the nesting so a
1029    /// self-referential `/CharProcs` entry can't recurse without bound.
1030    type3_depth: u32,
1031}
1032
1033/// A `/PatternType 1` tiling pattern (§8.7.3) reduced to what the
1034/// content walker needs to replicate its cell across a filled region:
1035/// the cell's content pre-parsed into a [`Group`] (against the pattern's
1036/// own `/Resources`), the `/BBox` clip rectangle, the `/XStep` / `/YStep`
1037/// replication spacing, the `/Matrix` mapping pattern space to the page's
1038/// default coordinate system (§8.7.2 NOTE 1), and the `/PaintType`
1039/// (1 = coloured, 2 = uncoloured stencil).
1040#[derive(Clone, Debug)]
1041pub struct TilingPattern {
1042    /// Cell content stream parsed into a group (its `/Resources` already
1043    /// resolved). Each tile clones this group under a per-tile transform.
1044    pub cell: Group,
1045    /// `/BBox` — `[llx, lly, urx, ury]` in pattern space; clips each
1046    /// tile (§8.7.3.1 Table 75).
1047    pub bbox: [f32; 4],
1048    /// `/XStep` — horizontal replication interval in pattern space.
1049    /// Non-zero per Table 75.
1050    pub xstep: f32,
1051    /// `/YStep` — vertical replication interval in pattern space.
1052    pub ystep: f32,
1053    /// `/Matrix` — maps pattern space to the parent content stream's
1054    /// default coordinate space (§8.7.2). Identity when absent.
1055    pub matrix: Transform2D,
1056    /// `/PaintType` — 1 (coloured: cell carries its own colours) or 2
1057    /// (uncoloured: cell is a stencil poured with the current colour).
1058    pub paint_type: i64,
1059}
1060
1061/// A Type 3 font (§9.6.5) reduced to what the content walker needs to
1062/// paint its glyphs as vector geometry. Unlike Type 1 / TrueType fonts
1063/// — whose glyph outlines live in an external font program that a
1064/// software renderer would have to rasterise — a Type 3 font defines
1065/// each glyph as a *content stream* of PDF graphics operators
1066/// (`/CharProcs`). Those operators are exactly the marking operators
1067/// this walker already understands, so each glyph description can be
1068/// pre-parsed into a [`Group`] and spliced into the scene tree at the
1069/// glyph's text-rendering matrix.
1070///
1071/// Per §9.6.5 the conforming reader, for each shown character code:
1072///   a) looks the code up in `/Encoding` (`/Differences`) to get a
1073///      glyph name;
1074///   b) looks the glyph name up in `/CharProcs` to get a glyph
1075///      description stream (no key → no glyph painted);
1076///   c) invokes the description with the CTM set to the concatenation
1077///      of `/FontMatrix` and the text space in effect at show time.
1078#[derive(Clone, Debug)]
1079pub struct Type3Font {
1080    /// `/FontMatrix` — maps glyph space to text space (§9.2.4). Each
1081    /// glyph `Group` is painted under `text_render_matrix ∘ FontMatrix`.
1082    pub font_matrix: Transform2D,
1083    /// Code → glyph name, built from `/Encoding /Differences` (§9.6.6.1).
1084    /// A code absent here paints nothing.
1085    pub encoding: BTreeMap<u8, String>,
1086    /// Glyph name → pre-parsed glyph description. Each value is the
1087    /// `/CharProcs` content stream (with its leading `d0` / `d1`
1088    /// stripped, §9.6.5 Table 113) parsed against the font's own
1089    /// `/Resources` into a [`Group`]. A glyph name in `encoding` but
1090    /// not here paints nothing.
1091    pub glyphs: BTreeMap<String, Group>,
1092    /// Glyph names whose description began with `d1` (§9.6.5 Table 113):
1093    /// the glyph specifies *shape only*, and its colour comes from the
1094    /// graphics state in force when the text-showing operator runs. A
1095    /// `d0` glyph (or one with neither) specifies its own colour and is
1096    /// painted with the colours baked into its `Group`.
1097    pub shape_only: BTreeSet<String>,
1098}
1099
1100struct Frame {
1101    /// Transform applied to this group via `cm` operators since `q`.
1102    transform: Transform2D,
1103    /// Children accumulated while this `q` is active.
1104    children: Vec<Node>,
1105    /// Clip path, if a `W`/`W*` was issued.
1106    clip: Option<Path>,
1107}
1108
1109#[derive(Clone, Debug)]
1110enum Operand {
1111    Number(f32),
1112    /// Heterogeneous PDF array `[ ... ]`. The `d` operator filters
1113    /// out non-`Number` elements; `TJ` walks the mix of strings +
1114    /// numbers in array order.
1115    Array(Vec<ArrayElem>),
1116    /// Name operand. Read by `cs` / `CS` (to pick the colour space)
1117    /// and by `sc` / `scn` (a trailing `/Name` marks a Pattern fill,
1118    /// §8.7.3.3) and by `Tf` (font resource name). Resource lookups
1119    /// against `/Resources` for non-device colour spaces / gradients
1120    /// / patterns still land later, when the page's resolved
1121    /// Document is available.
1122    Name(String),
1123    /// Literal-or-hex PDF string `(...)` / `<...>`. Held as raw
1124    /// bytes (escape-decoded for literal strings, hex-pair-decoded
1125    /// for hex strings); consumed by `Tj` / `'` / `"`.
1126    String(Vec<u8>),
1127    /// Inline dictionary `<< … >>`. The only content-stream operators
1128    /// that take one are the §14.6.2 property-list carriers `DP` /
1129    /// `BDC` (the `properties` operand may be written inline when every
1130    /// value is a direct object). Held verbatim so the marked-content
1131    /// dispatcher can surface it as `ContentMarkedContent::properties`.
1132    Dict(Dict),
1133}
1134
1135/// One element of a PDF content-stream array operand `[ ... ]`. PDF
1136/// arrays inside content streams carry either numbers (the `d`
1137/// operator's dash-array) or a mix of numbers + strings (the `TJ`
1138/// operator's per-element kerning displacements). We keep both shapes
1139/// in a single enum so the parser can stay agnostic until the
1140/// consuming operator dispatches.
1141#[derive(Clone, Debug)]
1142enum ArrayElem {
1143    Number(f32),
1144    String(Vec<u8>),
1145}
1146
1147/// One element of a `TJ` array, used while advancing the text matrix
1148/// (§9.4.3 / §9.4.4): a shown string or a numeric kern adjustment
1149/// (thousandths of a text-space unit).
1150enum TjElem {
1151    Str(Vec<u8>),
1152    Kern(f32),
1153}
1154
1155/// A PDF function (ISO 32000-1 §7.10) reduced to the two
1156/// function types this content parser can evaluate at a 1-input call
1157/// site (Separation tint transforms, §8.6.6.4):
1158///
1159/// * **Type 0** (sampled, §7.10.2) — a sample table read from the
1160///   stream body, with the §7.10.2 Encode/Decode linear mappings and
1161///   Order-1 linear interpolation between adjacent samples.
1162/// * **Type 2** (exponential interpolation, §7.10.3) —
1163///   `f(x) = C0 + x^N · (C1 − C0)`, one input, `n` outputs.
1164/// * **Type 3** (stitching, §7.10.4) — a 1-input function partitioned
1165///   across `k` subdomains, each evaluated by a child [`PdfFunction`].
1166/// * **Type 4** (PostScript calculator, §7.10.5) — a small stack
1167///   machine over the §7.10.5 / Annex B operator subset. The program
1168///   text is folded into the dictionary under `__Program` by
1169///   `prepare_function_object`; [`PdfFunction::parse`] tokenises it into
1170///   a nested expression tree and [`PdfFunction::eval`] runs it with the
1171///   1-input call site's argument seeded on the operand stack.
1172///
1173/// All carry the §7.10.1 Table 38 `Domain` (always 1-input here) and the
1174/// `Range` — required for Type 0 and Type 4, optional output-clip for
1175/// Type 2/3. A malformed program leaves the owning Separation space
1176/// `Unknown` (conservative black fallback).
1177#[derive(Clone, Debug, PartialEq)]
1178enum PdfFunction {
1179    /// §7.10.3 Type 2: exponential interpolation between `c0` and `c1`
1180    /// with exponent `n`. `domain` is `[d0, d1]` (input clip);
1181    /// `range`, when present, is `2·outputs` output-clip bounds.
1182    Exponential {
1183        domain: [f32; 2],
1184        range: Option<Vec<f32>>,
1185        c0: Vec<f32>,
1186        c1: Vec<f32>,
1187        n: f32,
1188    },
1189    /// §7.10.4 Type 3: stitching. `domain` is `[d0, d1]`; `functions`
1190    /// are the `k` child functions; `bounds` are the `k−1` interior
1191    /// partition points; `encode` is the `2·k` per-subdomain input
1192    /// remapping. `range`, when present, clips the final outputs.
1193    Stitching {
1194        domain: [f32; 2],
1195        range: Option<Vec<f32>>,
1196        functions: Vec<PdfFunction>,
1197        bounds: Vec<f32>,
1198        encode: Vec<f32>,
1199    },
1200    /// §7.10.2 Type 0: a sampled function with `m` input dimensions.
1201    /// `domain` is the `2·m` input clip (`[d0_0, d1_0, d0_1, d1_1, …]`);
1202    /// `range` is the required `2·n` output clip; `size` is the per-axis
1203    /// sample count (length `m`); `n` is the output dimensionality;
1204    /// `encode` is the `2·m` input→table mapping (default
1205    /// `[0, size_0−1, 0, size_1−1, …]`); `decode` is the `2·n`
1206    /// sample→output mapping (default `= range`); `samples` holds each
1207    /// sample value already widened to `f32` in storage order — the
1208    /// first input dimension varies fastest, output `j` of flat sample
1209    /// index `s` at `s·n + j` (§7.10.2 "the sample values in the first
1210    /// dimension vary fastest … values shall be stored in the same order
1211    /// as Range") — normalised out of the `[0, 2^BitsPerSample − 1]`
1212    /// integer interval before Decode. `order` is the §7.10.2 `/Order`
1213    /// interpolation degree: `1` for multilinear, `3` for the cubic-spline
1214    /// tensor blend (a per-axis cubic that passes through the four nearest
1215    /// samples). Per §7.10.2, a `/Size` below 4 on an axis falls back to
1216    /// linear interpolation on that axis even when `order == 3`.
1217    Sampled {
1218        domain: Vec<f32>,
1219        range: Vec<f32>,
1220        size: Vec<usize>,
1221        n: usize,
1222        encode: Vec<f32>,
1223        decode: Vec<f32>,
1224        samples: Vec<f32>,
1225        order: u8,
1226    },
1227    /// §7.10.5 Type 4: a PostScript-calculator program. `domain` is the
1228    /// `2·m` input clip (one `[d0 d1]` pair per input variable — a
1229    /// 1-input call site supplies `m = 1`, a DeviceN tint transform
1230    /// supplies one pair per colorant); `range` is the required `2·n`
1231    /// output clip (its length also fixes the number of output
1232    /// components taken off the final operand stack); `program` is the
1233    /// parsed top-level expression (the body inside the outermost
1234    /// `{ }`).
1235    Calculator {
1236        domain: Vec<f32>,
1237        range: Vec<f32>,
1238        program: Vec<PsToken>,
1239    },
1240}
1241
1242/// One token in a parsed Type 4 (PostScript-calculator) program
1243/// (§7.10.5). The language has no strings, arrays, names, procedures, or
1244/// variables — only numbers, booleans, the Table 42 operators, and brace
1245/// blocks used as the operands of `if` / `ifelse`.
1246#[derive(Clone, Debug, PartialEq)]
1247enum PsToken {
1248    /// A numeric literal (integer or real; both stored as `f32`).
1249    Number(f32),
1250    /// The `true` / `false` boolean literals.
1251    Bool(bool),
1252    /// One of the Table 42 operators, stored as its lower-case keyword.
1253    Op(PsOp),
1254    /// A `{ … }` brace block — a procedure operand for `if` / `ifelse`.
1255    /// Never executed directly; only consumed by the conditional
1256    /// operators that follow it.
1257    Block(Vec<PsToken>),
1258}
1259
1260/// The §7.10.5 Table 42 / Annex B operator set permitted in a Type 4
1261/// function. `if` / `ifelse` are handled structurally against preceding
1262/// [`PsToken::Block`]s, so they are part of this set too.
1263#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1264enum PsOp {
1265    // B.2 Arithmetic.
1266    Abs,
1267    Add,
1268    Atan,
1269    Ceiling,
1270    Cos,
1271    Cvi,
1272    Cvr,
1273    Div,
1274    Exp,
1275    Floor,
1276    Idiv,
1277    Ln,
1278    Log,
1279    Mod,
1280    Mul,
1281    Neg,
1282    Round,
1283    Sin,
1284    Sqrt,
1285    Sub,
1286    Truncate,
1287    // B.3 Relational / boolean / bitwise.
1288    And,
1289    Bitshift,
1290    Eq,
1291    Ge,
1292    Gt,
1293    Le,
1294    Lt,
1295    Ne,
1296    Not,
1297    Or,
1298    Xor,
1299    // B.4 Conditional.
1300    If,
1301    Ifelse,
1302    // B.5 Stack.
1303    Copy,
1304    Dup,
1305    Exch,
1306    Index,
1307    Pop,
1308    Roll,
1309}
1310
1311impl PsOp {
1312    /// Map a lower-case operator keyword to its [`PsOp`], or `None` for
1313    /// an unknown token (a syntax error, §7.10.5.2).
1314    fn from_keyword(kw: &str) -> Option<PsOp> {
1315        Some(match kw {
1316            "abs" => PsOp::Abs,
1317            "add" => PsOp::Add,
1318            "atan" => PsOp::Atan,
1319            "ceiling" => PsOp::Ceiling,
1320            "cos" => PsOp::Cos,
1321            "cvi" => PsOp::Cvi,
1322            "cvr" => PsOp::Cvr,
1323            "div" => PsOp::Div,
1324            "exp" => PsOp::Exp,
1325            "floor" => PsOp::Floor,
1326            "idiv" => PsOp::Idiv,
1327            "ln" => PsOp::Ln,
1328            "log" => PsOp::Log,
1329            "mod" => PsOp::Mod,
1330            "mul" => PsOp::Mul,
1331            "neg" => PsOp::Neg,
1332            "round" => PsOp::Round,
1333            "sin" => PsOp::Sin,
1334            "sqrt" => PsOp::Sqrt,
1335            "sub" => PsOp::Sub,
1336            "truncate" => PsOp::Truncate,
1337            "and" => PsOp::And,
1338            "bitshift" => PsOp::Bitshift,
1339            "eq" => PsOp::Eq,
1340            "ge" => PsOp::Ge,
1341            "gt" => PsOp::Gt,
1342            "le" => PsOp::Le,
1343            "lt" => PsOp::Lt,
1344            "ne" => PsOp::Ne,
1345            "not" => PsOp::Not,
1346            "or" => PsOp::Or,
1347            "xor" => PsOp::Xor,
1348            "if" => PsOp::If,
1349            "ifelse" => PsOp::Ifelse,
1350            "copy" => PsOp::Copy,
1351            "dup" => PsOp::Dup,
1352            "exch" => PsOp::Exch,
1353            "index" => PsOp::Index,
1354            "pop" => PsOp::Pop,
1355            "roll" => PsOp::Roll,
1356            _ => return None,
1357        })
1358    }
1359}
1360
1361/// A value on the Type 4 operand stack: a number or a boolean (§7.10.5
1362/// permits integers, reals, and booleans only). Integers and reals are
1363/// both held as `f32`; the few integer-only operators (`idiv`, `mod`,
1364/// bitwise ops) convert on demand.
1365#[derive(Clone, Copy, Debug, PartialEq)]
1366enum PsValue {
1367    Num(f32),
1368    Bool(bool),
1369}
1370
1371impl PdfFunction {
1372    /// Parse a resolved function dictionary (already normalised by
1373    /// `prepare_function_object`) into an evaluable [`PdfFunction`].
1374    /// Type 0 reads its decoded sample body from the `__Samples` entry
1375    /// `prepare_function_object` folds in; Type 2/3 are pure dictionary
1376    /// forms. Returns `None` for a Type 4 function (only its dictionary
1377    /// is reachable here), a Type 0 with more than one input dimension,
1378    /// a missing/invalid `/FunctionType`, or a malformed dictionary —
1379    /// every such case leaves the owning Separation space unevaluable.
1380    fn parse(obj: &Object) -> Option<PdfFunction> {
1381        let Object::Dict(dict) = obj else {
1382            return None;
1383        };
1384        let get = |key: &str| {
1385            dict.entries()
1386                .iter()
1387                .find(|(k, _)| k == key)
1388                .map(|(_, v)| v)
1389        };
1390        let range = get("Range").and_then(read_num_array);
1391        match get("FunctionType").and_then(number_as_i64) {
1392            Some(0) => {
1393                // §7.10.2 Table 39. `m` input dimensions (any number);
1394                // /Range is required and gives the output dimensionality
1395                // n. /Domain has 2·m entries (one [d0 d1] pair per axis).
1396                let domain = get("Domain").and_then(read_num_array)?;
1397                if domain.is_empty() || domain.len() % 2 != 0 {
1398                    return None;
1399                }
1400                let range = range?;
1401                if range.is_empty() || range.len() % 2 != 0 {
1402                    return None;
1403                }
1404                let n = range.len() / 2;
1405                // /Order ∈ {1, 3} (§7.10.2): 1 = linear (multilinear over
1406                // m axes), 3 = cubic spline. Any other value is malformed
1407                // and leaves the owning space unevaluable. Default is 1.
1408                let order = match get("Order").and_then(number_as_i64) {
1409                    Some(1) | None => 1u8,
1410                    Some(3) => 3u8,
1411                    Some(_) => return None,
1412                };
1413                // /Size is an array of m positive integers; m must match
1414                // /Domain's pair count.
1415                let size_arr = get("Size").and_then(read_num_array)?;
1416                let m = domain.len() / 2;
1417                if size_arr.len() != m {
1418                    return None;
1419                }
1420                let mut size = Vec::with_capacity(m);
1421                for s in &size_arr {
1422                    if !s.is_finite() || *s < 1.0 {
1423                        return None;
1424                    }
1425                    size.push(*s as usize);
1426                }
1427                // /BitsPerSample ∈ {1,2,4,8,12,16,24,32}.
1428                let bps = get("BitsPerSample").and_then(number_as_i64)?;
1429                if !matches!(bps, 1 | 2 | 4 | 8 | 12 | 16 | 24 | 32) {
1430                    return None;
1431                }
1432                let bps = bps as u32;
1433                // /Encode default [0 (Size_i−1)] per axis; /Decode
1434                // default = Range.
1435                let encode = match get("Encode").and_then(read_num_array) {
1436                    Some(e) if e.len() == 2 * m => e,
1437                    Some(_) => return None,
1438                    None => {
1439                        let mut e = Vec::with_capacity(2 * m);
1440                        for &sz in &size {
1441                            e.push(0.0);
1442                            e.push((sz as f32) - 1.0);
1443                        }
1444                        e
1445                    }
1446                };
1447                let decode = get("Decode")
1448                    .and_then(read_num_array)
1449                    .unwrap_or_else(|| range.clone());
1450                if decode.len() != 2 * n {
1451                    return None;
1452                }
1453                // Total sample count = (∏ Size_i) · n. Guard the product
1454                // against overflow for an adversarial /Size.
1455                let mut total: usize = 1;
1456                for &sz in &size {
1457                    total = total.checked_mul(sz)?;
1458                }
1459                let count = total.checked_mul(n)?;
1460                // Sample body folded in by `prepare_function_object`.
1461                let raw = match get("__Samples") {
1462                    Some(Object::HexString(bytes)) => bytes.as_slice(),
1463                    _ => return None,
1464                };
1465                let samples = unpack_samples(raw, bps, count)?;
1466                Some(PdfFunction::Sampled {
1467                    domain,
1468                    range,
1469                    size,
1470                    n,
1471                    encode,
1472                    decode,
1473                    samples,
1474                    order,
1475                })
1476            }
1477            Some(2) => {
1478                // §7.10.3 Table 40. A Type 2 function has exactly one
1479                // input (Domain is a single [d0 d1] pair). C0 defaults to
1480                // [0.0], C1 to [1.0].
1481                let domain = get("Domain").and_then(read_num_pair)?;
1482                let c0 = get("C0")
1483                    .and_then(read_num_array)
1484                    .unwrap_or_else(|| vec![0.0]);
1485                let c1 = get("C1")
1486                    .and_then(read_num_array)
1487                    .unwrap_or_else(|| vec![1.0]);
1488                let n = get("N").and_then(number_as_f32)?;
1489                if c0.len() != c1.len() || c0.is_empty() {
1490                    return None;
1491                }
1492                Some(PdfFunction::Exponential {
1493                    domain,
1494                    range,
1495                    c0,
1496                    c1,
1497                    n,
1498                })
1499            }
1500            Some(3) => {
1501                // §7.10.4 Table 41. A Type 3 stitching function has
1502                // exactly one input (Domain is a single [d0 d1] pair).
1503                let domain = get("Domain").and_then(read_num_pair)?;
1504                let Some(Object::Array(fs)) = get("Functions") else {
1505                    return None;
1506                };
1507                let functions: Vec<PdfFunction> =
1508                    fs.iter().map(PdfFunction::parse).collect::<Option<_>>()?;
1509                if functions.is_empty() {
1510                    return None;
1511                }
1512                let bounds = get("Bounds").and_then(read_num_array).unwrap_or_default();
1513                let encode = get("Encode").and_then(read_num_array)?;
1514                // k functions ⇒ k−1 bounds and 2·k encode pairs.
1515                if bounds.len() + 1 != functions.len() || encode.len() != 2 * functions.len() {
1516                    return None;
1517                }
1518                Some(PdfFunction::Stitching {
1519                    domain,
1520                    range,
1521                    functions,
1522                    bounds,
1523                    encode,
1524                })
1525            }
1526            Some(4) => {
1527                // §7.10.5 Table 38: Domain and Range are both required.
1528                // Domain carries 2·m entries (one [d0 d1] pair per input
1529                // variable — `m` inputs for a DeviceN tint transform);
1530                // Range fixes the output dimensionality n.
1531                let domain = get("Domain").and_then(read_num_array)?;
1532                if domain.is_empty() || domain.len() % 2 != 0 {
1533                    return None;
1534                }
1535                let range = range?;
1536                if range.is_empty() || range.len() % 2 != 0 {
1537                    return None;
1538                }
1539                // The decoded program source, folded in by
1540                // `prepare_function_object` under `__Program`.
1541                let src = match get("__Program") {
1542                    Some(Object::HexString(bytes)) => bytes.as_slice(),
1543                    _ => return None,
1544                };
1545                let program = parse_ps_program(src)?;
1546                Some(PdfFunction::Calculator {
1547                    domain,
1548                    range,
1549                    program,
1550                })
1551            }
1552            _ => None,
1553        }
1554    }
1555
1556    /// Evaluate this 1-input function at `x`, returning the output
1557    /// component vector. A thin wrapper over [`eval_n`](Self::eval_n) for
1558    /// the single-input call sites (Separation tint transforms, Type 3
1559    /// child functions).
1560    fn eval(&self, x: f32) -> Vec<f32> {
1561        self.eval_n(&[x])
1562    }
1563
1564    /// Evaluate this function at the `m`-component input vector `inputs`,
1565    /// returning the output component vector. Inputs are clipped to
1566    /// `Domain` and outputs to `Range` (§7.10.1). Type 2 (exponential)
1567    /// and Type 3 (stitching) are intrinsically 1-input and read
1568    /// `inputs[0]` (a missing first input is treated as `0.0`); Type 0
1569    /// (sampled) and Type 4 (calculator) consume all `m` inputs.
1570    fn eval_n(&self, inputs: &[f32]) -> Vec<f32> {
1571        let first = inputs.first().copied().unwrap_or(0.0);
1572        match self {
1573            PdfFunction::Exponential {
1574                domain,
1575                range,
1576                c0,
1577                c1,
1578                n,
1579            } => {
1580                let xc = first.clamp(domain[0], domain[1]);
1581                // y_j = C0_j + x^N · (C1_j − C0_j), §7.10.3 Table 40.
1582                let xn = xc.powf(*n);
1583                let mut out: Vec<f32> = c0
1584                    .iter()
1585                    .zip(c1.iter())
1586                    .map(|(&a, &b)| a + xn * (b - a))
1587                    .collect();
1588                clip_to_range(&mut out, range.as_deref());
1589                out
1590            }
1591            PdfFunction::Stitching {
1592                domain,
1593                range,
1594                functions,
1595                bounds,
1596                encode,
1597            } => {
1598                let xc = first.clamp(domain[0], domain[1]);
1599                // Find subdomain i: the half-open interval [b_{i-1}, b_i)
1600                // (the last is closed on the right), §7.10.4. b_{-1} =
1601                // Domain0, b_{k-1} = Domain1.
1602                let k = functions.len();
1603                let mut i = 0;
1604                while i < bounds.len() && xc >= bounds[i] {
1605                    i += 1;
1606                }
1607                let lo = if i == 0 { domain[0] } else { bounds[i - 1] };
1608                let hi = if i == k - 1 { domain[1] } else { bounds[i] };
1609                // Encode x into the child function's domain. If the
1610                // subdomain is degenerate (lo == hi, the §7.10.4
1611                // last-bound-equals-Domain1 case), use Encode_{2i}.
1612                let xprime = if (hi - lo).abs() < f32::EPSILON {
1613                    encode[2 * i]
1614                } else {
1615                    interpolate(xc, lo, hi, encode[2 * i], encode[2 * i + 1])
1616                };
1617                let mut out = functions[i].eval(xprime);
1618                clip_to_range(&mut out, range.as_deref());
1619                out
1620            }
1621            PdfFunction::Sampled {
1622                domain,
1623                range,
1624                size,
1625                n,
1626                encode,
1627                decode,
1628                samples,
1629                order,
1630            } => eval_sampled(
1631                inputs, domain, range, size, *n, encode, decode, samples, *order,
1632            ),
1633            PdfFunction::Calculator {
1634                domain,
1635                range,
1636                program,
1637            } => {
1638                // §7.10.5: the input variables form the initial operand
1639                // stack, in order, each clipped to its Domain pair.
1640                let m = domain.len() / 2;
1641                let n = range.len() / 2;
1642                let mut stack: Vec<PsValue> = Vec::with_capacity(m);
1643                for i in 0..m {
1644                    let xi = inputs.get(i).copied().unwrap_or(0.0);
1645                    stack.push(PsValue::Num(xi.clamp(domain[2 * i], domain[2 * i + 1])));
1646                }
1647                if exec_ps(program, &mut stack).is_err() {
1648                    // §7.10.5.2 execution error (stack under/overflow,
1649                    // type error, undefined result). Fall back to black.
1650                    return vec![0.0; n];
1651                }
1652                // The items remaining after execution are the outputs.
1653                // It is an error for the count to differ from Range's n
1654                // (§7.10.5) or for any to be non-numeric; treat both as
1655                // the conservative black fallback.
1656                if stack.len() != n {
1657                    return vec![0.0; n];
1658                }
1659                let mut out = Vec::with_capacity(n);
1660                for v in &stack {
1661                    match v {
1662                        PsValue::Num(f) => out.push(*f),
1663                        PsValue::Bool(_) => return vec![0.0; n],
1664                    }
1665                }
1666                clip_to_range(&mut out, Some(range));
1667                out
1668            }
1669        }
1670    }
1671
1672    /// The number of input variables `m` this function consumes. Type 2
1673    /// (exponential) and Type 3 (stitching) are 1-input by definition
1674    /// (§7.10.3 / §7.10.4); Type 0 (sampled) and Type 4 (calculator)
1675    /// derive `m` from their `Domain` pair count. Used to validate a
1676    /// DeviceN tint transform's arity against the colorant count
1677    /// (§8.6.6.5).
1678    fn input_arity(&self) -> usize {
1679        match self {
1680            PdfFunction::Exponential { .. } | PdfFunction::Stitching { .. } => 1,
1681            PdfFunction::Sampled { size, .. } => size.len(),
1682            PdfFunction::Calculator { domain, .. } => domain.len() / 2,
1683        }
1684    }
1685
1686    /// The number of output components `n` this function produces, when
1687    /// statically known. Type 0 carries `n` directly; Type 2's arity is
1688    /// `C0`'s length; Type 4's is `Range`'s pair count. Type 3
1689    /// (stitching) returns `None` — its output arity follows its child
1690    /// functions, which a DeviceN tint transform never is (its tint
1691    /// transform is the top-level n-in/m-out function). Used to validate
1692    /// a DeviceN tint transform's output against the alternate space's
1693    /// component count (§8.6.6.5).
1694    fn output_arity(&self) -> Option<usize> {
1695        match self {
1696            PdfFunction::Sampled { n, .. } => Some(*n),
1697            PdfFunction::Exponential { c0, .. } => Some(c0.len()),
1698            PdfFunction::Calculator { range, .. } => Some(range.len() / 2),
1699            PdfFunction::Stitching { .. } => None,
1700        }
1701    }
1702}
1703
1704/// Maximum operand-stack depth for a Type 4 program. §7.10.5 requires at
1705/// least 100 entries and explicitly makes overflowing the stack an
1706/// error; this is also the guard that keeps an adversarial `dup`-loop
1707/// program bounded.
1708const PS_STACK_LIMIT: usize = 100;
1709
1710/// Tokenise + parse a Type 4 (PostScript-calculator) program body
1711/// (§7.10.5). The whole program is wrapped in an outermost `{ }`; this
1712/// returns the token sequence *inside* that brace. Brace blocks nested
1713/// for `if` / `ifelse` become [`PsToken::Block`]s. Returns `None` for a
1714/// syntax error (§7.10.5.2): unmatched braces, a non-numeric / unknown
1715/// token, or missing outer braces.
1716fn parse_ps_program(src: &[u8]) -> Option<Vec<PsToken>> {
1717    // The grammar is whitespace-separated; the only special characters
1718    // are the curly braces. A real number is `[+-]?digits[.digits]` and
1719    // the spec language has no other lexical forms.
1720    let mut words: Vec<&[u8]> = Vec::new();
1721    let mut start: Option<usize> = None;
1722    for (i, &b) in src.iter().enumerate() {
1723        if b == b'{' || b == b'}' {
1724            if let Some(s) = start.take() {
1725                words.push(&src[s..i]);
1726            }
1727            words.push(&src[i..i + 1]);
1728        } else if b.is_ascii_whitespace() {
1729            if let Some(s) = start.take() {
1730                words.push(&src[s..i]);
1731            }
1732        } else if start.is_none() {
1733            start = Some(i);
1734        }
1735    }
1736    if let Some(s) = start {
1737        words.push(&src[s..]);
1738    }
1739    // The first non-empty token must be the opening brace; the matching
1740    // close brace must be the last. Parse the body between them.
1741    let mut iter = words.into_iter().peekable();
1742    if iter.next() != Some(b"{") {
1743        return None;
1744    }
1745    let (body, closed) = parse_ps_block(&mut iter)?;
1746    // After the top-level block closes, nothing else may follow.
1747    if !closed || iter.next().is_some() {
1748        return None;
1749    }
1750    Some(body)
1751}
1752
1753/// Parse the contents of one brace block up to (and consuming) its
1754/// closing `}`. Returns the token list plus whether a matching `}` was
1755/// actually seen (`false` ⇒ unterminated block ⇒ caller reports a syntax
1756/// error).
1757fn parse_ps_block<'a, I>(iter: &mut std::iter::Peekable<I>) -> Option<(Vec<PsToken>, bool)>
1758where
1759    I: Iterator<Item = &'a [u8]>,
1760{
1761    let mut tokens = Vec::new();
1762    while let Some(w) = iter.next() {
1763        match w {
1764            b"}" => return Some((tokens, true)),
1765            b"{" => {
1766                let (inner, closed) = parse_ps_block(iter)?;
1767                if !closed {
1768                    return None;
1769                }
1770                tokens.push(PsToken::Block(inner));
1771            }
1772            b"true" => tokens.push(PsToken::Bool(true)),
1773            b"false" => tokens.push(PsToken::Bool(false)),
1774            other => {
1775                let text = str::from_utf8(other).ok()?;
1776                if let Ok(num) = text.parse::<f32>() {
1777                    tokens.push(PsToken::Number(num));
1778                } else if let Some(op) = PsOp::from_keyword(text) {
1779                    tokens.push(PsToken::Op(op));
1780                } else {
1781                    // Unknown token — a syntax error.
1782                    return None;
1783                }
1784            }
1785        }
1786    }
1787    // Ran out of tokens without a closing brace.
1788    Some((tokens, false))
1789}
1790
1791/// Execute a parsed Type 4 token sequence against the operand `stack`
1792/// (§7.10.5). Returns `Err(())` on any execution error — stack
1793/// under/overflow, a type error, or an undefined result (§7.10.5.2) —
1794/// which the caller maps to the conservative black fallback.
1795fn exec_ps(tokens: &[PsToken], stack: &mut Vec<PsValue>) -> Result<(), ()> {
1796    let mut i = 0;
1797    while i < tokens.len() {
1798        match &tokens[i] {
1799            PsToken::Number(f) => push(stack, PsValue::Num(*f))?,
1800            PsToken::Bool(b) => push(stack, PsValue::Bool(*b))?,
1801            // A bare block on the operand path is only meaningful as the
1802            // operand of the `if` / `ifelse` that follows it; those are
1803            // handled when the operator token is reached, so a block by
1804            // itself (without a following conditional) is a syntax-shaped
1805            // error we treat as an execution error.
1806            PsToken::Block(_) => {
1807                // Look ahead: `bool { proc } if` or
1808                // `bool { p1 } { p2 } ifelse`.
1809                let proc1 = match &tokens[i] {
1810                    PsToken::Block(b) => b,
1811                    _ => unreachable!(),
1812                };
1813                match tokens.get(i + 1) {
1814                    Some(PsToken::Op(PsOp::If)) => {
1815                        let cond = pop_bool(stack)?;
1816                        if cond {
1817                            exec_ps(proc1, stack)?;
1818                        }
1819                        i += 2;
1820                        continue;
1821                    }
1822                    Some(PsToken::Block(proc2)) => {
1823                        // Expect `ifelse` after the second block.
1824                        if tokens.get(i + 2) != Some(&PsToken::Op(PsOp::Ifelse)) {
1825                            return Err(());
1826                        }
1827                        let cond = pop_bool(stack)?;
1828                        if cond {
1829                            exec_ps(proc1, stack)?;
1830                        } else {
1831                            exec_ps(proc2, stack)?;
1832                        }
1833                        i += 3;
1834                        continue;
1835                    }
1836                    _ => return Err(()),
1837                }
1838            }
1839            PsToken::Op(op) => exec_ps_op(*op, stack)?,
1840        }
1841        i += 1;
1842    }
1843    Ok(())
1844}
1845
1846/// Push a value, enforcing the §7.10.5 100-entry stack ceiling
1847/// (overflow is an error).
1848fn push(stack: &mut Vec<PsValue>, v: PsValue) -> Result<(), ()> {
1849    if stack.len() >= PS_STACK_LIMIT {
1850        return Err(());
1851    }
1852    stack.push(v);
1853    Ok(())
1854}
1855
1856/// Pop a numeric operand; a non-number or empty stack is an error.
1857fn pop_num(stack: &mut Vec<PsValue>) -> Result<f32, ()> {
1858    match stack.pop() {
1859        Some(PsValue::Num(f)) => Ok(f),
1860        _ => Err(()),
1861    }
1862}
1863
1864/// Pop a boolean operand; a non-boolean or empty stack is an error.
1865fn pop_bool(stack: &mut Vec<PsValue>) -> Result<bool, ()> {
1866    match stack.pop() {
1867        Some(PsValue::Bool(b)) => Ok(b),
1868        _ => Err(()),
1869    }
1870}
1871
1872/// Pop a value usable as an integer (§B.3 bitwise / B.2 idiv·mod). The
1873/// number must be integral within `f32` range; a non-number or a value
1874/// outside the i32 range is an error.
1875fn pop_int(stack: &mut Vec<PsValue>) -> Result<i32, ()> {
1876    let f = pop_num(stack)?;
1877    if !f.is_finite() || f.fract() != 0.0 || f < i32::MIN as f32 || f > i32::MAX as f32 {
1878        return Err(());
1879    }
1880    Ok(f as i32)
1881}
1882
1883/// Execute a single non-conditional operator against the stack
1884/// (§7.10.5 / Annex B). `if` / `ifelse` never reach here — they are
1885/// handled structurally in [`exec_ps`].
1886fn exec_ps_op(op: PsOp, stack: &mut Vec<PsValue>) -> Result<(), ()> {
1887    match op {
1888        // ---- B.2 Arithmetic ------------------------------------------
1889        PsOp::Add => {
1890            let (a, b) = (pop_num(stack)?, pop_num(stack)?);
1891            push(stack, PsValue::Num(b + a))
1892        }
1893        PsOp::Sub => {
1894            let (a, b) = (pop_num(stack)?, pop_num(stack)?);
1895            push(stack, PsValue::Num(b - a))
1896        }
1897        PsOp::Mul => {
1898            let (a, b) = (pop_num(stack)?, pop_num(stack)?);
1899            push(stack, PsValue::Num(b * a))
1900        }
1901        PsOp::Div => {
1902            let a = pop_num(stack)?;
1903            let b = pop_num(stack)?;
1904            if a == 0.0 {
1905                return Err(()); // division by zero ⇒ undefined result
1906            }
1907            push(stack, PsValue::Num(b / a))
1908        }
1909        PsOp::Idiv => {
1910            let a = pop_int(stack)?;
1911            let b = pop_int(stack)?;
1912            if a == 0 {
1913                return Err(());
1914            }
1915            push(stack, PsValue::Num((b / a) as f32))
1916        }
1917        PsOp::Mod => {
1918            let a = pop_int(stack)?;
1919            let b = pop_int(stack)?;
1920            if a == 0 {
1921                return Err(());
1922            }
1923            // PostScript `mod` takes the sign of the dividend (Rust `%`
1924            // already does this for integers).
1925            push(stack, PsValue::Num((b % a) as f32))
1926        }
1927        PsOp::Neg => {
1928            let a = pop_num(stack)?;
1929            push(stack, PsValue::Num(-a))
1930        }
1931        PsOp::Abs => {
1932            let a = pop_num(stack)?;
1933            push(stack, PsValue::Num(a.abs()))
1934        }
1935        PsOp::Ceiling => {
1936            let a = pop_num(stack)?;
1937            push(stack, PsValue::Num(a.ceil()))
1938        }
1939        PsOp::Floor => {
1940            let a = pop_num(stack)?;
1941            push(stack, PsValue::Num(a.floor()))
1942        }
1943        PsOp::Round => {
1944            let a = pop_num(stack)?;
1945            // PostScript rounds half away from zero, matching `f32::round`.
1946            push(stack, PsValue::Num(a.round()))
1947        }
1948        PsOp::Truncate => {
1949            let a = pop_num(stack)?;
1950            push(stack, PsValue::Num(a.trunc()))
1951        }
1952        PsOp::Sqrt => {
1953            let a = pop_num(stack)?;
1954            if a < 0.0 {
1955                return Err(()); // range error
1956            }
1957            push(stack, PsValue::Num(a.sqrt()))
1958        }
1959        PsOp::Sin => {
1960            let a = pop_num(stack)?;
1961            push(stack, PsValue::Num(a.to_radians().sin()))
1962        }
1963        PsOp::Cos => {
1964            let a = pop_num(stack)?;
1965            push(stack, PsValue::Num(a.to_radians().cos()))
1966        }
1967        PsOp::Atan => {
1968            // num den atan angle — result in degrees, normalised to
1969            // [0, 360) (PostScript semantics).
1970            let den = pop_num(stack)?;
1971            let num = pop_num(stack)?;
1972            if num == 0.0 && den == 0.0 {
1973                return Err(()); // undefined
1974            }
1975            let mut deg = num.atan2(den).to_degrees();
1976            if deg < 0.0 {
1977                deg += 360.0;
1978            }
1979            push(stack, PsValue::Num(deg))
1980        }
1981        PsOp::Exp => {
1982            // base exponent exp real.
1983            let exponent = pop_num(stack)?;
1984            let base = pop_num(stack)?;
1985            let r = base.powf(exponent);
1986            if !r.is_finite() {
1987                return Err(());
1988            }
1989            push(stack, PsValue::Num(r))
1990        }
1991        PsOp::Ln => {
1992            let a = pop_num(stack)?;
1993            if a <= 0.0 {
1994                return Err(());
1995            }
1996            push(stack, PsValue::Num(a.ln()))
1997        }
1998        PsOp::Log => {
1999            let a = pop_num(stack)?;
2000            if a <= 0.0 {
2001                return Err(());
2002            }
2003            push(stack, PsValue::Num(a.log10()))
2004        }
2005        PsOp::Cvi => {
2006            // Convert to integer by truncation toward zero.
2007            let a = pop_num(stack)?;
2008            push(stack, PsValue::Num(a.trunc()))
2009        }
2010        PsOp::Cvr => {
2011            // Convert to real — already an f32, a no-op type assertion.
2012            let a = pop_num(stack)?;
2013            push(stack, PsValue::Num(a))
2014        }
2015        // ---- B.3 Relational / boolean / bitwise ----------------------
2016        PsOp::Eq => {
2017            let (a, b) = (stack.pop().ok_or(())?, stack.pop().ok_or(())?);
2018            push(stack, PsValue::Bool(b == a))
2019        }
2020        PsOp::Ne => {
2021            let (a, b) = (stack.pop().ok_or(())?, stack.pop().ok_or(())?);
2022            push(stack, PsValue::Bool(b != a))
2023        }
2024        PsOp::Gt => {
2025            let (a, b) = (pop_num(stack)?, pop_num(stack)?);
2026            push(stack, PsValue::Bool(b > a))
2027        }
2028        PsOp::Ge => {
2029            let (a, b) = (pop_num(stack)?, pop_num(stack)?);
2030            push(stack, PsValue::Bool(b >= a))
2031        }
2032        PsOp::Lt => {
2033            let (a, b) = (pop_num(stack)?, pop_num(stack)?);
2034            push(stack, PsValue::Bool(b < a))
2035        }
2036        PsOp::Le => {
2037            let (a, b) = (pop_num(stack)?, pop_num(stack)?);
2038            push(stack, PsValue::Bool(b <= a))
2039        }
2040        PsOp::And => bool_or_bitwise(stack, |x, y| x & y, |x, y| x && y),
2041        PsOp::Or => bool_or_bitwise(stack, |x, y| x | y, |x, y| x || y),
2042        PsOp::Xor => bool_or_bitwise(stack, |x, y| x ^ y, |x, y| x != y),
2043        PsOp::Not => {
2044            // Logical not on a bool, bitwise not on an int (§B.3).
2045            match stack.pop() {
2046                Some(PsValue::Bool(b)) => push(stack, PsValue::Bool(!b)),
2047                Some(PsValue::Num(f)) => push(stack, PsValue::Num(!integer_value(f)? as f32)),
2048                _ => Err(()),
2049            }
2050        }
2051        PsOp::Bitshift => {
2052            // int1 shift bitshift int2 (positive shift is left, §B.3).
2053            let shift = pop_int(stack)?;
2054            let v = pop_int(stack)?;
2055            let r = if shift >= 0 {
2056                if shift >= 32 {
2057                    0
2058                } else {
2059                    v.wrapping_shl(shift as u32)
2060                }
2061            } else {
2062                let s = (-shift) as u32;
2063                if s >= 32 {
2064                    0
2065                } else {
2066                    v >> s
2067                }
2068            };
2069            push(stack, PsValue::Num(r as f32))
2070        }
2071        // ---- B.5 Stack -----------------------------------------------
2072        PsOp::Pop => {
2073            stack.pop().ok_or(())?;
2074            Ok(())
2075        }
2076        PsOp::Exch => {
2077            let len = stack.len();
2078            if len < 2 {
2079                return Err(());
2080            }
2081            stack.swap(len - 1, len - 2);
2082            Ok(())
2083        }
2084        PsOp::Dup => {
2085            let top = *stack.last().ok_or(())?;
2086            push(stack, top)
2087        }
2088        PsOp::Copy => {
2089            // any1 … anyn n copy any1 … anyn any1 … anyn (§B.5).
2090            let n = pop_int(stack)?;
2091            if n < 0 {
2092                return Err(());
2093            }
2094            let n = n as usize;
2095            let len = stack.len();
2096            if n > len {
2097                return Err(());
2098            }
2099            if stack.len() + n > PS_STACK_LIMIT {
2100                return Err(());
2101            }
2102            for k in 0..n {
2103                stack.push(stack[len - n + k]);
2104            }
2105            Ok(())
2106        }
2107        PsOp::Index => {
2108            // anyn … any0 n index anyn … any0 anyn (§B.5): duplicate the
2109            // element n positions down from the top (0 = top).
2110            let n = pop_int(stack)?;
2111            if n < 0 {
2112                return Err(());
2113            }
2114            let n = n as usize;
2115            let len = stack.len();
2116            if n >= len {
2117                return Err(());
2118            }
2119            push(stack, stack[len - 1 - n])
2120        }
2121        PsOp::Roll => {
2122            // anyn-1 … any0 n j roll — circularly roll the top n elements
2123            // up by j (§B.5).
2124            let j = pop_int(stack)?;
2125            let n = pop_int(stack)?;
2126            if n < 0 {
2127                return Err(());
2128            }
2129            let n = n as usize;
2130            let len = stack.len();
2131            if n > len {
2132                return Err(());
2133            }
2134            if n > 0 {
2135                let base = len - n;
2136                let slice = &mut stack[base..];
2137                // Positive j rotates "up" (toward the top): the top
2138                // element moves down. `rotate_right(k)` moves the last k
2139                // elements to the front, matching a roll-up by k.
2140                let k = j.rem_euclid(n as i32) as usize;
2141                slice.rotate_right(k);
2142            }
2143            Ok(())
2144        }
2145        // if / ifelse are handled structurally in exec_ps.
2146        PsOp::If | PsOp::Ifelse => Err(()),
2147    }
2148}
2149
2150/// Coerce an `f32` operand to an `i32` for a bitwise operator, erroring
2151/// on a non-integral or out-of-range value (§B.3 bitwise ops are
2152/// integer-only).
2153fn integer_value(f: f32) -> Result<i32, ()> {
2154    if !f.is_finite() || f.fract() != 0.0 || f < i32::MIN as f32 || f > i32::MAX as f32 {
2155        return Err(());
2156    }
2157    Ok(f as i32)
2158}
2159
2160/// Implement `and` / `or` / `xor`, which are logical on two booleans and
2161/// bitwise on two integers (§B.3). A mixed pair is a type error.
2162fn bool_or_bitwise(
2163    stack: &mut Vec<PsValue>,
2164    bitwise: fn(i32, i32) -> i32,
2165    logical: fn(bool, bool) -> bool,
2166) -> Result<(), ()> {
2167    let a = stack.pop().ok_or(())?;
2168    let b = stack.pop().ok_or(())?;
2169    match (b, a) {
2170        (PsValue::Bool(x), PsValue::Bool(y)) => push(stack, PsValue::Bool(logical(x, y))),
2171        (PsValue::Num(x), PsValue::Num(y)) => {
2172            let xi = integer_value(x)?;
2173            let yi = integer_value(y)?;
2174            push(stack, PsValue::Num(bitwise(xi, yi) as f32))
2175        }
2176        _ => Err(()),
2177    }
2178}
2179
2180/// §7.10.2 / §7.10.4 linear `Interpolate`: the `y` value on the line
2181/// through `(xmin, ymin)` and `(xmax, ymax)`. A zero-width input
2182/// interval maps to `ymin` (avoids a divide-by-zero; callers handle the
2183/// degenerate stitching case before calling).
2184fn interpolate(x: f32, xmin: f32, xmax: f32, ymin: f32, ymax: f32) -> f32 {
2185    if (xmax - xmin).abs() < f32::EPSILON {
2186        return ymin;
2187    }
2188    ymin + (x - xmin) * (ymax - ymin) / (xmax - xmin)
2189}
2190
2191/// Clip each output value into its `Range` pair (§7.10.1): output `j` is
2192/// clamped into `[Range_{2j}, Range_{2j+1}]`. A `None` range leaves the
2193/// outputs unclipped.
2194fn clip_to_range(out: &mut [f32], range: Option<&[f32]>) {
2195    if let Some(r) = range {
2196        for (j, v) in out.iter_mut().enumerate() {
2197            if let (Some(&lo), Some(&hi)) = (r.get(2 * j), r.get(2 * j + 1)) {
2198                *v = v.clamp(lo, hi);
2199            }
2200        }
2201    }
2202}
2203
2204/// Read a `[a b]` two-number array (a function `/Domain` for a 1-input
2205/// function, §7.10.1 Table 38).
2206fn read_num_pair(obj: &Object) -> Option<[f32; 2]> {
2207    let Object::Array(items) = obj else {
2208        return None;
2209    };
2210    if items.len() != 2 {
2211        return None;
2212    }
2213    Some([number_as_f32(&items[0])?, number_as_f32(&items[1])?])
2214}
2215
2216/// Unpack `count` sample values of `bps` bits each from a §7.10.2
2217/// sample stream, normalising each raw integer code into `[0.0, 1.0]`
2218/// by dividing by `2^bps − 1`. The bytes form a continuous bit stream
2219/// with the high-order bit of each byte first and no padding at value
2220/// boundaries; values run output-fastest then input-axis (storage
2221/// order). Returns `None` if the stream is too short to hold `count`
2222/// values. `bps` is one of {1,2,4,8,12,16,24,32}, so a value never
2223/// spans more than 32 bits.
2224fn unpack_samples(raw: &[u8], bps: u32, count: usize) -> Option<Vec<f32>> {
2225    let total_bits = (count as u64).checked_mul(bps as u64)?;
2226    if (raw.len() as u64) * 8 < total_bits {
2227        return None;
2228    }
2229    let max_code = ((1u64 << bps) - 1) as f32;
2230    let mut out = Vec::with_capacity(count);
2231    let mut bit_pos: u64 = 0;
2232    for _ in 0..count {
2233        let mut code: u64 = 0;
2234        for _ in 0..bps {
2235            let byte = raw[(bit_pos / 8) as usize];
2236            let bit = (byte >> (7 - (bit_pos % 8) as u32)) & 1;
2237            code = (code << 1) | (bit as u64);
2238            bit_pos += 1;
2239        }
2240        out.push((code as f32) / max_code);
2241    }
2242    Some(out)
2243}
2244
2245/// The §7.10.2 cubic-spline basis weights for a fractional position `t`
2246/// in `[0, 1]` between the two central samples of a four-sample window
2247/// `[p_{-1}, p_0, p_1, p_2]`. These are the Catmull-Rom weights — the
2248/// cubic that passes through all four samples and reproduces `p_0` at
2249/// `t = 0` and `p_1` at `t = 1` (so the curve interpolates, not merely
2250/// approximates, the sample points the spec requires it to pass
2251/// through). Returned in window order `[w_{-1}, w_0, w_1, w_2]`; the
2252/// weights sum to 1 for every `t`, so a constant sample table is
2253/// reproduced exactly. At `t = 0` this collapses to `[0, 1, 0, 0]` and
2254/// at `t = 1` to `[0, 0, 1, 0]`, matching the linear blend at the knots.
2255fn cubic_weights(t: f32) -> [f32; 4] {
2256    let t2 = t * t;
2257    let t3 = t2 * t;
2258    // Catmull-Rom (tension 0.5) cardinal basis.
2259    [
2260        -0.5 * t3 + t2 - 0.5 * t,
2261        1.5 * t3 - 2.5 * t2 + 1.0,
2262        -1.5 * t3 + 2.0 * t2 + 0.5 * t,
2263        0.5 * t3 - 0.5 * t2,
2264    ]
2265}
2266
2267/// One axis's interpolation contributions: a short list of
2268/// `(sample index on this axis, weight)` pairs whose weights sum to 1.
2269/// Order-1 yields up to two entries (the two bracketing samples);
2270/// Order-3 yields up to four (the cubic window). The full sample blend
2271/// is the tensor product of these per-axis contribution lists.
2272type AxisTaps = smallvec_like::Taps;
2273
2274/// A tiny fixed-capacity tap list (max 4 entries — the widest axis
2275/// window is the Order-3 cubic). Avoids a heap allocation per axis per
2276/// evaluation, which matters because tint transforms are called once
2277/// per painted sample.
2278mod smallvec_like {
2279    /// Up to four `(index, weight)` taps for one input axis.
2280    #[derive(Clone, Copy)]
2281    pub(super) struct Taps {
2282        pub(super) items: [(usize, f32); 4],
2283        pub(super) len: usize,
2284    }
2285
2286    impl Taps {
2287        pub(super) fn new() -> Self {
2288            Taps {
2289                items: [(0, 0.0); 4],
2290                len: 0,
2291            }
2292        }
2293        pub(super) fn push(&mut self, idx: usize, w: f32) {
2294            self.items[self.len] = (idx, w);
2295            self.len += 1;
2296        }
2297        pub(super) fn as_slice(&self) -> &[(usize, f32)] {
2298            &self.items[..self.len]
2299        }
2300    }
2301}
2302
2303/// Evaluate an `m`-input Type 0 (sampled) function (§7.10.2) at the
2304/// `inputs` vector. Each input `x_i` is clipped to its `Domain` pair,
2305/// encoded into the sample-table axis `[0, Size_i − 1]`, and split into
2306/// a base index plus fraction. With `order == 1` the output is the
2307/// multilinear blend of the `2^m` surrounding grid corners; with
2308/// `order == 3` it is the tensor-product cubic-spline blend over the
2309/// four nearest samples per axis (§7.10.2 "cubic spline
2310/// interpolation"). Per §7.10.2, an axis whose `Size < 4` cannot carry a
2311/// cubic window and falls back to linear on that axis. The blend is then
2312/// decoded into the output range and clipped to `Range`. The sample
2313/// table stores the first input dimension fastest (`flat =
2314/// i_0 + Size_0·(i_1 + Size_1·(i_2 + …))`) with `n` interleaved outputs
2315/// per grid point.
2316#[allow(clippy::too_many_arguments)]
2317fn eval_sampled(
2318    inputs: &[f32],
2319    domain: &[f32],
2320    range: &[f32],
2321    size: &[usize],
2322    n: usize,
2323    encode: &[f32],
2324    decode: &[f32],
2325    samples: &[f32],
2326    order: u8,
2327) -> Vec<f32> {
2328    let m = size.len();
2329    // Per-axis tap lists (index + weight contributions) and strides.
2330    let mut taps: Vec<AxisTaps> = Vec::with_capacity(m);
2331    for i in 0..m {
2332        let xi = inputs.get(i).copied().unwrap_or(0.0);
2333        let xc = xi.clamp(domain[2 * i], domain[2 * i + 1]);
2334        let e = interpolate(
2335            xc,
2336            domain[2 * i],
2337            domain[2 * i + 1],
2338            encode[2 * i],
2339            encode[2 * i + 1],
2340        );
2341        let last = size[i] - 1;
2342        let e = e.clamp(0.0, last as f32);
2343        let i0 = e.floor() as usize;
2344        let frac = e - (i0 as f32);
2345        let mut t = AxisTaps::new();
2346        // Order-3 requires Size ≥ 4 to form the four-sample cubic window
2347        // (§7.10.2: "If Size is less than 4, … Order 3 shall be
2348        // ignored"). Otherwise interpolate linearly between the two
2349        // bracketing samples.
2350        if order == 3 && size[i] >= 4 {
2351            // Window indices i0−1, i0, i0+1, i0+2, each clamped to the
2352            // axis so an edge window reuses the boundary sample (the
2353            // weights still sum to 1, giving an extrapolation-free clamp
2354            // at the table edges).
2355            let w = cubic_weights(frac);
2356            let lo = i0 as isize - 1;
2357            for (k, &wk) in w.iter().enumerate() {
2358                let idx = (lo + k as isize).clamp(0, last as isize) as usize;
2359                if wk != 0.0 {
2360                    t.push(idx, wk);
2361                }
2362            }
2363        } else {
2364            let up = (i0 + 1).min(last);
2365            t.push(i0, 1.0 - frac);
2366            if frac != 0.0 && up != i0 {
2367                t.push(up, frac);
2368            }
2369        }
2370        taps.push(t);
2371    }
2372    // Tensor-product accumulation over the cartesian product of the
2373    // per-axis taps. `combo` indexes one tap per axis; the contribution
2374    // weight is the product of the chosen per-axis weights and the flat
2375    // sample offset is Σ idx_i · stride_i (axis 0 varies fastest).
2376    let mut out = vec![0.0f32; n];
2377    let mut idx_in_axis = vec![0usize; m];
2378    loop {
2379        let mut weight = 1.0f32;
2380        let mut flat = 0usize;
2381        let mut stride = 1usize;
2382        for i in 0..m {
2383            let (idx, w) = taps[i].as_slice()[idx_in_axis[i]];
2384            weight *= w;
2385            flat += idx * stride;
2386            stride *= size[i];
2387        }
2388        if weight != 0.0 {
2389            let off = flat * n;
2390            for (j, acc) in out.iter_mut().enumerate() {
2391                *acc += weight * samples[off + j];
2392            }
2393        }
2394        // Odometer increment across the per-axis tap lists.
2395        let mut axis = 0;
2396        loop {
2397            if axis == m {
2398                // All combinations exhausted.
2399                idx_in_axis.clear();
2400                break;
2401            }
2402            idx_in_axis[axis] += 1;
2403            if idx_in_axis[axis] < taps[axis].as_slice().len() {
2404                break;
2405            }
2406            idx_in_axis[axis] = 0;
2407            axis += 1;
2408        }
2409        if idx_in_axis.is_empty() {
2410            break;
2411        }
2412    }
2413    // Decode each blended sample [0,1] → output range, clip to Range.
2414    for (j, v) in out.iter_mut().enumerate() {
2415        *v = interpolate(*v, 0.0, 1.0, decode[2 * j], decode[2 * j + 1]);
2416    }
2417    clip_to_range(&mut out, Some(range));
2418    out
2419}
2420
2421/// Read an all-numeric array into a `Vec<f32>` (function `/C0`, `/C1`,
2422/// `/Range`, `/Bounds`, `/Encode`). Returns `None` if the object isn't
2423/// an array or any element isn't a number.
2424fn read_num_array(obj: &Object) -> Option<Vec<f32>> {
2425    let Object::Array(items) = obj else {
2426        return None;
2427    };
2428    items.iter().map(number_as_f32).collect()
2429}
2430
2431/// Which colour space the current `sc`/`scn` (or `SC`/`SCN`) operands
2432/// are interpreted in, as established by the most recent `cs` / `CS`
2433/// operator (ISO 32000-1 §8.6.8 Table 74). The three device families
2434/// — whose component counts are fixed and whose component → RGB
2435/// mapping needs no `/Resources` lookup — are tracked directly. When
2436/// the page's `/Resources /ColorSpace` subdictionary is plumbed in
2437/// (round 275, [`parse_content_stream_full_with_color_space`]), a
2438/// named key resolving to an `ICCBased` stream maps to its device
2439/// alternate (§8.6.5.5) and a named key resolving to an `Indexed`
2440/// array carries its base + colour table for `sc`/`scn` index lookups
2441/// (§8.6.6.3). Every other space (Pattern, CIE-based CalRGB/CalGray/
2442/// Lab, Separation, DeviceN, or any key the parser can't resolve)
2443/// collapses to `Unknown`, for which `sc`/`scn` keep the conservative
2444/// black fallback.
2445#[derive(Clone, Debug, PartialEq)]
2446enum ColorSpaceKind {
2447    /// `/DeviceGray` — one component (§8.6.4.2).
2448    DeviceGray,
2449    /// `/DeviceRGB` — three components (§8.6.4.3).
2450    DeviceRgb,
2451    /// `/DeviceCMYK` — four components (§8.6.4.4).
2452    DeviceCmyk,
2453    /// An `/Indexed` space (§8.6.6.3): a single index component selects
2454    /// a `base`-space colour from `table`. `base` is the device family
2455    /// the table entries are interpreted in; `hival` is the maximum
2456    /// valid index; `table` is the `(hival+1) * base.components()`
2457    /// resolved lookup bytes (each scaled 0..255 → component range).
2458    Indexed {
2459        base: Box<ColorSpaceKind>,
2460        hival: u32,
2461        table: Vec<u8>,
2462    },
2463    /// A `/Separation` space (§8.6.6.4): a single tint component in
2464    /// `0.0..=1.0` is mapped through `tint` (the tint-transform
2465    /// function, §7.10) into `alt`-space component values, which `alt`
2466    /// then renders to RGB. `alt` is the alternate device family (the
2467    /// only families this round renders); `tint` is the evaluable
2468    /// function (Type 0 sampled / Type 2 / Type 3). The special colorant names `All` and
2469    /// `None` are folded in at resolve time (`None` → no paint, `All`
2470    /// applied through the alternate as a single tint).
2471    Separation {
2472        alt: Box<ColorSpaceKind>,
2473        tint: PdfFunction,
2474        /// `true` for the special `/None` colorant — painting produces
2475        /// no visible output (§8.6.6.4), so `sc`/`scn` yields no paint.
2476        none_colorant: bool,
2477    },
2478    /// A `/DeviceN` space (§8.6.6.5): `n_in` tint components (one per
2479    /// entry in the colour space's `names` array, in stream order) are
2480    /// mapped through `tint` (an `n_in`-in / m-out tint-transform
2481    /// function, §7.10) into `alt`-space component values, which `alt`
2482    /// then renders to RGB. `alt` is the alternate device family (the
2483    /// only families this round renders); `tint` is an evaluable Type 0
2484    /// (sampled) / Type 4 (PostScript-calculator) function — the
2485    /// multi-input families a DeviceN tint transform uses. `all_none` is
2486    /// set when every colorant name is `/None`: such a space always
2487    /// discards its output and never reverts to the alternate
2488    /// (§8.6.6.5).
2489    DeviceN {
2490        n_in: usize,
2491        alt: Box<ColorSpaceKind>,
2492        tint: PdfFunction,
2493        all_none: bool,
2494    },
2495    /// A `/CalGray` space (§8.6.5.2): one component decoded by `gamma`
2496    /// and scaled by the `white` point `[XW YW ZW]` to a CIE XYZ value,
2497    /// then mapped to device RGB.
2498    CalGray { white: [f32; 3], gamma: f32 },
2499    /// A `/CalRGB` space (§8.6.5.3): three components decoded by the
2500    /// per-channel `gamma` `[GR GG GB]`, multiplied by the 3×3 `matrix`
2501    /// `[XA YA ZA XB YB ZB XC YC ZC]` to a CIE XYZ value, then mapped to
2502    /// device RGB.
2503    CalRgb { gamma: [f32; 3], matrix: [f32; 9] },
2504    /// A `/Lab` space (§8.6.5.4): the L*a*b* triple (L* in 0..=100,
2505    /// a*/b* clamped into `range` `[amin amax bmin bmax]`) mapped to a
2506    /// CIE XYZ value through the implicit two-stage transform scaled by
2507    /// the `white` point, then to device RGB.
2508    Lab { white: [f32; 3], range: [f32; 4] },
2509    /// Any space the parser doesn't resolve to a device family, a
2510    /// device-based Indexed space, a device-alternate Separation, or a
2511    /// device-alternate DeviceN — `/Pattern`, a CIE-based CalRGB /
2512    /// CalGray / Lab space, a Separation/DeviceN whose tint transform
2513    /// isn't an evaluable function or whose alternate isn't a device
2514    /// family, or a `/Resources /ColorSpace` key whose definition the
2515    /// parser can't reduce to a device fallback.
2516    Unknown,
2517}
2518
2519impl ColorSpaceKind {
2520    /// Map a `cs` / `CS` name operand to a tracked colour space without
2521    /// consulting `/Resources`. The three device-family names are
2522    /// recognised directly (§8.6.4.1); everything else — including
2523    /// `/Pattern` and any resource key — is `Unknown` until
2524    /// [`resolve_with_resources`](Self::resolve_with_resources) gets a
2525    /// chance to look the key up.
2526    fn from_name(name: &str) -> Self {
2527        match name {
2528            "DeviceGray" | "G" => ColorSpaceKind::DeviceGray,
2529            "DeviceRGB" | "RGB" => ColorSpaceKind::DeviceRgb,
2530            "DeviceCMYK" | "CMYK" => ColorSpaceKind::DeviceCmyk,
2531            _ => ColorSpaceKind::Unknown,
2532        }
2533    }
2534
2535    /// Number of numeric components an `sc`/`scn` carries in this
2536    /// space. `Indexed` always carries a single index component
2537    /// (§8.6.6.3). `None` for `Unknown` (where the count is unknowable
2538    /// without resolving the resource definition).
2539    fn components(&self) -> Option<usize> {
2540        match self {
2541            ColorSpaceKind::DeviceGray => Some(1),
2542            ColorSpaceKind::DeviceRgb => Some(3),
2543            ColorSpaceKind::DeviceCmyk => Some(4),
2544            ColorSpaceKind::Indexed { .. } => Some(1),
2545            // §8.6.6.4: a Separation colour value is a single tint
2546            // component, regardless of the alternate space's arity.
2547            ColorSpaceKind::Separation { .. } => Some(1),
2548            // §8.6.6.5: a DeviceN colour value carries one tint per
2549            // colorant name, in the names-array order.
2550            ColorSpaceKind::DeviceN { n_in, .. } => Some(*n_in),
2551            // §8.6.5.2: a CIE-based A space carries one component.
2552            ColorSpaceKind::CalGray { .. } => Some(1),
2553            // §8.6.5.3 / §8.6.5.4: a CIE-based ABC space carries three.
2554            ColorSpaceKind::CalRgb { .. } | ColorSpaceKind::Lab { .. } => Some(3),
2555            ColorSpaceKind::Unknown => None,
2556        }
2557    }
2558
2559    /// Resolve a `cs` / `CS` name against the page's `/Resources
2560    /// /ColorSpace` subdictionary (when one is plumbed in). The three
2561    /// device-family names short-circuit to themselves (a resource key
2562    /// can't shadow them per §8.6.8 Table 74). Otherwise the named
2563    /// entry's resolved `Object` is interpreted per
2564    /// [`color_space_from_object`]; an absent key (or one this round
2565    /// can't reduce to a device fallback) stays `Unknown`, preserving
2566    /// the conservative black fallback.
2567    fn resolve_with_resources(name: &str, resources: Option<&Dict>) -> Self {
2568        let direct = ColorSpaceKind::from_name(name);
2569        if direct != ColorSpaceKind::Unknown {
2570            return direct;
2571        }
2572        let Some(res) = resources else {
2573            return ColorSpaceKind::Unknown;
2574        };
2575        match res.entries().iter().find(|(k, _)| k == name) {
2576            Some((_, obj)) => color_space_from_object(obj),
2577            None => ColorSpaceKind::Unknown,
2578        }
2579    }
2580}
2581
2582/// Interpret a resolved `/Resources /ColorSpace` entry (already
2583/// fully-dereferenced by
2584/// [`crate::reader::document::resolve_color_space_resources`]) as a
2585/// tracked [`ColorSpaceKind`].
2586///
2587/// Recognised, all reducible to a device family without CIE colour
2588/// science:
2589///
2590/// * A bare device name (`/DeviceGray` / `/DeviceRGB` / `/DeviceCMYK`).
2591/// * `[ /ICCBased << /N n /Alternate alt … >> ]` — §8.6.5.5: the
2592///   `/Alternate` space is used when present, otherwise `/N` (1/3/4)
2593///   selects DeviceGray / DeviceRGB / DeviceCMYK exactly as the spec's
2594///   "if this entry is omitted … the colour space that shall be used
2595///   is DeviceGray, DeviceRGB, or DeviceCMYK, depending on whether the
2596///   value of N is 1, 3, or 4" fallback prescribes. The ICC profile
2597///   bytes themselves are not interpreted.
2598/// * `[ /Indexed base hival lookup ]` — §8.6.6.3: when `base` reduces
2599///   to a device family, the resolved lookup bytes are carried so
2600///   `sc`/`scn` can index the table.
2601///
2602/// CalRGB / CalGray / Lab (CIE-based, need a gamut-mapping pass),
2603/// Separation / DeviceN (need tint-transform function evaluation), and
2604/// `/Pattern` all stay `Unknown`.
2605fn color_space_from_object(obj: &Object) -> ColorSpaceKind {
2606    match obj {
2607        Object::Name(n) => ColorSpaceKind::from_name(n),
2608        Object::Array(items) => match items.first() {
2609            Some(Object::Name(family)) if family == "ICCBased" => icc_based_from_array(items),
2610            Some(Object::Name(family)) if family == "Indexed" => indexed_from_array(items),
2611            Some(Object::Name(family)) if family == "Separation" => separation_from_array(items),
2612            Some(Object::Name(family)) if family == "DeviceN" => device_n_from_array(items),
2613            Some(Object::Name(family)) if family == "CalGray" => cal_gray_from_array(items),
2614            Some(Object::Name(family)) if family == "CalRGB" => cal_rgb_from_array(items),
2615            Some(Object::Name(family)) if family == "Lab" => lab_from_array(items),
2616            _ => ColorSpaceKind::Unknown,
2617        },
2618        _ => ColorSpaceKind::Unknown,
2619    }
2620}
2621
2622/// Reduce `[ /ICCBased << /N n /Alternate alt … >> ]` to its device
2623/// fallback per §8.6.5.5. The stream's dictionary is surfaced as the
2624/// second array element (the document-level resolver replaces the ICC
2625/// profile stream with its dictionary). `/Alternate` wins when it
2626/// itself reduces to a device family; otherwise `/N` selects the
2627/// device space.
2628fn icc_based_from_array(items: &[Object]) -> ColorSpaceKind {
2629    let Some(Object::Dict(dict)) = items.get(1) else {
2630        return ColorSpaceKind::Unknown;
2631    };
2632    if let Some((_, alt)) = dict.entries().iter().find(|(k, _)| k == "Alternate") {
2633        let resolved = color_space_from_object(alt);
2634        if resolved != ColorSpaceKind::Unknown {
2635            return resolved;
2636        }
2637    }
2638    match dict.entries().iter().find(|(k, _)| k == "N") {
2639        Some((_, Object::Integer(1))) => ColorSpaceKind::DeviceGray,
2640        Some((_, Object::Integer(3))) => ColorSpaceKind::DeviceRgb,
2641        Some((_, Object::Integer(4))) => ColorSpaceKind::DeviceCmyk,
2642        _ => ColorSpaceKind::Unknown,
2643    }
2644}
2645
2646/// Reduce `[ /Indexed base hival lookup ]` to a tracked `Indexed`
2647/// space per §8.6.6.3. The base must itself reduce to a device family
2648/// (the only families whose table entries this round can interpret);
2649/// `hival` must be a non-negative integer ≤ 255; the lookup parameter
2650/// must be a resolved byte string (the document-level resolver
2651/// replaces a lookup *stream* with its decoded bytes as a
2652/// `HexString`). Any deviation collapses to `Unknown`.
2653fn indexed_from_array(items: &[Object]) -> ColorSpaceKind {
2654    if items.len() < 4 {
2655        return ColorSpaceKind::Unknown;
2656    }
2657    let base = color_space_from_object(&items[1]);
2658    // §8.6.6.3 forbids a Pattern, Indexed, Separation, or DeviceN base.
2659    // Device families and the CIE-based families (CalGray / CalRGB /
2660    // Lab) are permitted; their table entries are decoded per
2661    // `indexed_color`. `components()` is `None` only for `Unknown`,
2662    // which is also rejected. The table's per-entry byte count `m`
2663    // follows from the base at lookup time; a short table is tolerated
2664    // by returning no colour for an out-of-range slot rather than
2665    // rejecting the whole space here.
2666    if base.components().is_none()
2667        || matches!(
2668            base,
2669            ColorSpaceKind::Indexed { .. }
2670                | ColorSpaceKind::Separation { .. }
2671                | ColorSpaceKind::DeviceN { .. }
2672        )
2673    {
2674        return ColorSpaceKind::Unknown;
2675    }
2676    let hival = match &items[2] {
2677        Object::Integer(n) if *n >= 0 && *n <= 255 => *n as u32,
2678        _ => return ColorSpaceKind::Unknown,
2679    };
2680    let table = match &items[3] {
2681        Object::LiteralString(b) | Object::HexString(b) => b.clone(),
2682        _ => return ColorSpaceKind::Unknown,
2683    };
2684    ColorSpaceKind::Indexed {
2685        base: Box::new(base),
2686        hival,
2687        table,
2688    }
2689}
2690
2691/// Reduce `[ /Separation name alternateSpace tintTransform ]` to a
2692/// tracked `Separation` space per ISO 32000-1 §8.6.6.4.
2693///
2694/// The space resolves only when the alternate reduces to a device
2695/// family (DeviceGray / DeviceRGB / DeviceCMYK) or a CIE-based family
2696/// (CalGray / CalRGB / Lab) — the families this round renders — and the
2697/// tint transform parses as an evaluable Type 0 (sampled) / Type 2 /
2698/// Type 3 function ([`PdfFunction::parse`]). An `Indexed`/`Separation`/
2699/// `DeviceN` alternate (forbidden by §8.6.6.4 anyway), an unresolvable
2700/// alternate, or a Type 4 tint transform collapses to `Unknown`,
2701/// preserving the conservative black fallback.
2702///
2703/// The special colorant names `All` and `None` (§8.6.6.4): for these a
2704/// conforming reader ignores the alternate and tint transform. `None`
2705/// produces no visible output, so it is tracked as a Separation whose
2706/// `none_colorant` flag suppresses any paint. `All` applies a single
2707/// tint to all colorants; with no per-colorant device model here, it is
2708/// approximated through the alternate exactly like a named colorant
2709/// when one is supplied, otherwise it stays `Unknown`.
2710fn separation_from_array(items: &[Object]) -> ColorSpaceKind {
2711    if items.len() < 4 {
2712        return ColorSpaceKind::Unknown;
2713    }
2714    let none_colorant = matches!(&items[1], Object::Name(n) if n == "None");
2715    let alt = color_space_from_object(&items[2]);
2716    // §8.6.6.4: the alternate "may not be another special colour space
2717    // (Pattern, Indexed, Separation, or DeviceN)" — `components()` is
2718    // `None` for `Unknown`, and an Indexed/Separation/DeviceN alternate
2719    // is rejected by matching their variants.
2720    if alt.components().is_none()
2721        || matches!(
2722            alt,
2723            ColorSpaceKind::Indexed { .. }
2724                | ColorSpaceKind::Separation { .. }
2725                | ColorSpaceKind::DeviceN { .. }
2726        )
2727    {
2728        // A `/None` colorant ignores the alternate entirely (no visible
2729        // output), so it still resolves even with a degenerate
2730        // alternate; everything else needs a renderable alternate.
2731        if none_colorant {
2732            return ColorSpaceKind::Separation {
2733                alt: Box::new(ColorSpaceKind::DeviceGray),
2734                tint: PdfFunction::Exponential {
2735                    domain: [0.0, 1.0],
2736                    range: None,
2737                    c0: vec![0.0],
2738                    c1: vec![0.0],
2739                    n: 1.0,
2740                },
2741                none_colorant: true,
2742            };
2743        }
2744        return ColorSpaceKind::Unknown;
2745    }
2746    let Some(tint) = PdfFunction::parse(&items[3]) else {
2747        return ColorSpaceKind::Unknown;
2748    };
2749    ColorSpaceKind::Separation {
2750        alt: Box::new(alt),
2751        tint,
2752        none_colorant,
2753    }
2754}
2755
2756/// Reduce `[ /DeviceN names alternateSpace tintTransform (attributes) ]`
2757/// to a tracked `DeviceN` space per ISO 32000-1 §8.6.6.5.
2758///
2759/// `names` is the array of `n_in` colorant names; its length fixes the
2760/// number of tint components an `sc`/`scn` carries. The space resolves
2761/// only when the alternate reduces to a device family (DeviceGray /
2762/// DeviceRGB / DeviceCMYK) or a CIE-based family (CalGray / CalRGB /
2763/// Lab) — the families this round renders — and the
2764/// `n_in`-input tint transform parses as an evaluable function. A
2765/// special-space alternate (forbidden by §8.6.6.5 anyway),
2766/// or a tint transform whose input arity doesn't match `n_in` or whose
2767/// output arity doesn't match the alternate's component count, collapses
2768/// to `Unknown`, preserving the conservative black fallback. The
2769/// optional `attributes` dictionary (NChannel `/Subtype`, `/Colorants`,
2770/// `/Process`, `/MixingHints`) is not consulted — a conforming reader
2771/// that does not use those custom-blending hints renders through the
2772/// supplied alternate + tint transform (§8.6.6.5), which is what this
2773/// does.
2774///
2775/// §8.6.6.5: when every colorant name is `/None` the space always
2776/// discards its output (`all_none`); the parser still resolves it so the
2777/// `sc`/`scn` operand count is known and no paint is produced.
2778fn device_n_from_array(items: &[Object]) -> ColorSpaceKind {
2779    if items.len() < 4 {
2780        return ColorSpaceKind::Unknown;
2781    }
2782    let Object::Array(name_objs) = &items[1] else {
2783        return ColorSpaceKind::Unknown;
2784    };
2785    if name_objs.is_empty() {
2786        return ColorSpaceKind::Unknown;
2787    }
2788    let mut all_none = true;
2789    for nm in name_objs {
2790        match nm {
2791            Object::Name(n) => {
2792                if n != "None" {
2793                    all_none = false;
2794                }
2795            }
2796            // A non-name entry in the names array is malformed.
2797            _ => return ColorSpaceKind::Unknown,
2798        }
2799    }
2800    let n_in = name_objs.len();
2801    let alt = color_space_from_object(&items[2]);
2802    // §8.6.6.5: the alternate "shall not be another special colour space
2803    // (Pattern, Indexed, Separation, or DeviceN)". A device family or a
2804    // CIE-based family (CalGray = 1, CalRGB / Lab = 3) is renderable;
2805    // its component count fixes the required tint-transform output arity.
2806    let alt_comps = match &alt {
2807        ColorSpaceKind::DeviceGray | ColorSpaceKind::CalGray { .. } => 1,
2808        ColorSpaceKind::DeviceRgb | ColorSpaceKind::CalRgb { .. } | ColorSpaceKind::Lab { .. } => 3,
2809        ColorSpaceKind::DeviceCmyk => 4,
2810        _ => return ColorSpaceKind::Unknown,
2811    };
2812    // An all-None space never reverts to the alternate, so the tint
2813    // transform is irrelevant; track it with a no-op tint so the operand
2814    // count is known and `scn` yields no paint.
2815    if all_none {
2816        return ColorSpaceKind::DeviceN {
2817            n_in,
2818            alt: Box::new(alt),
2819            tint: PdfFunction::Exponential {
2820                domain: [0.0, 1.0],
2821                range: None,
2822                c0: vec![0.0],
2823                c1: vec![0.0],
2824                n: 1.0,
2825            },
2826            all_none: true,
2827        };
2828    }
2829    let Some(tint) = PdfFunction::parse(&items[3]) else {
2830        return ColorSpaceKind::Unknown;
2831    };
2832    // The tint transform must be n_in-in / alt_comps-out (§8.6.6.5). A
2833    // mismatch is a malformed space — fall back to the black behaviour.
2834    if tint.input_arity() != n_in || tint.output_arity() != Some(alt_comps) {
2835        return ColorSpaceKind::Unknown;
2836    }
2837    ColorSpaceKind::DeviceN {
2838        n_in,
2839        alt: Box::new(alt),
2840        tint,
2841        all_none: false,
2842    }
2843}
2844
2845/// Read a fixed-length array of numbers from a colour-space dictionary
2846/// entry — used for `WhitePoint`/`BlackPoint` (3), `Gamma` (3),
2847/// `Matrix` (9), and `Range` (4). Returns `None` when the key is
2848/// absent, not an array, the wrong length, or carries a non-number.
2849fn read_fixed_num_array<const N: usize>(dict: &Dict, key: &str) -> Option<[f32; N]> {
2850    let (_, obj) = dict.entries().iter().find(|(k, _)| k == key)?;
2851    let nums = read_num_array(obj)?;
2852    if nums.len() != N {
2853        return None;
2854    }
2855    let mut out = [0.0f32; N];
2856    out.copy_from_slice(&nums);
2857    Some(out)
2858}
2859
2860/// Validate a `WhitePoint` per §8.6.5.2–4: `XW` and `ZW` shall be
2861/// positive and `YW` shall be 1.0. A non-conforming white point makes
2862/// the whole CIE space unrenderable, so the caller falls back to
2863/// `Unknown` (conservative black).
2864fn valid_white_point(w: [f32; 3]) -> bool {
2865    w[0] > 0.0 && w[2] > 0.0 && (w[1] - 1.0).abs() < 1e-4 && w.iter().all(|c| c.is_finite())
2866}
2867
2868/// Reduce `[ /CalGray << /WhitePoint … /Gamma g >> ]` to a tracked
2869/// `CalGray` space per §8.6.5.2. `WhitePoint` is required and validated;
2870/// `Gamma` is an optional positive number (default 1.0).
2871fn cal_gray_from_array(items: &[Object]) -> ColorSpaceKind {
2872    let Some(Object::Dict(dict)) = items.get(1) else {
2873        return ColorSpaceKind::Unknown;
2874    };
2875    let Some(white) = read_fixed_num_array::<3>(dict, "WhitePoint") else {
2876        return ColorSpaceKind::Unknown;
2877    };
2878    if !valid_white_point(white) {
2879        return ColorSpaceKind::Unknown;
2880    }
2881    let gamma = match dict.entries().iter().find(|(k, _)| k == "Gamma") {
2882        Some((_, obj)) => match number_as_f32(obj) {
2883            Some(g) if g > 0.0 && g.is_finite() => g,
2884            // A present-but-malformed Gamma collapses the space.
2885            _ => return ColorSpaceKind::Unknown,
2886        },
2887        None => 1.0,
2888    };
2889    ColorSpaceKind::CalGray { white, gamma }
2890}
2891
2892/// Reduce `[ /CalRGB << /WhitePoint … /Gamma [..] /Matrix [..] >> ]` to
2893/// a tracked `CalRgb` space per §8.6.5.3. `WhitePoint` is required and
2894/// validated; `Gamma` (default `[1 1 1]`) and `Matrix` (default
2895/// identity) are optional. The white point is not separately stored —
2896/// it is already folded into the `Matrix` columns by the producer, and
2897/// §8.6.5.3's transform reads only Gamma + Matrix.
2898fn cal_rgb_from_array(items: &[Object]) -> ColorSpaceKind {
2899    let Some(Object::Dict(dict)) = items.get(1) else {
2900        return ColorSpaceKind::Unknown;
2901    };
2902    let Some(white) = read_fixed_num_array::<3>(dict, "WhitePoint") else {
2903        return ColorSpaceKind::Unknown;
2904    };
2905    if !valid_white_point(white) {
2906        return ColorSpaceKind::Unknown;
2907    }
2908    let gamma = match dict.entries().iter().find(|(k, _)| k == "Gamma") {
2909        Some(_) => match read_fixed_num_array::<3>(dict, "Gamma") {
2910            Some(g) if g.iter().all(|x| *x > 0.0 && x.is_finite()) => g,
2911            _ => return ColorSpaceKind::Unknown,
2912        },
2913        None => [1.0, 1.0, 1.0],
2914    };
2915    let matrix = match dict.entries().iter().find(|(k, _)| k == "Matrix") {
2916        Some(_) => match read_fixed_num_array::<9>(dict, "Matrix") {
2917            Some(m) if m.iter().all(|x| x.is_finite()) => m,
2918            _ => return ColorSpaceKind::Unknown,
2919        },
2920        // Identity matrix default per Table 64.
2921        None => [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0],
2922    };
2923    ColorSpaceKind::CalRgb { gamma, matrix }
2924}
2925
2926/// Reduce `[ /Lab << /WhitePoint … /Range [..] >> ]` to a tracked `Lab`
2927/// space per §8.6.5.4. `WhitePoint` is required and validated; `Range`
2928/// (default `[-100 100 -100 100]`) bounds the a*/b* components. A
2929/// malformed `Range` (wrong length, non-number, or min > max) collapses
2930/// the space.
2931fn lab_from_array(items: &[Object]) -> ColorSpaceKind {
2932    let Some(Object::Dict(dict)) = items.get(1) else {
2933        return ColorSpaceKind::Unknown;
2934    };
2935    let Some(white) = read_fixed_num_array::<3>(dict, "WhitePoint") else {
2936        return ColorSpaceKind::Unknown;
2937    };
2938    if !valid_white_point(white) {
2939        return ColorSpaceKind::Unknown;
2940    }
2941    let range = match dict.entries().iter().find(|(k, _)| k == "Range") {
2942        Some(_) => match read_fixed_num_array::<4>(dict, "Range") {
2943            Some(r) if r.iter().all(|x| x.is_finite()) && r[0] <= r[1] && r[2] <= r[3] => r,
2944            _ => return ColorSpaceKind::Unknown,
2945        },
2946        None => [-100.0, 100.0, -100.0, 100.0],
2947    };
2948    ColorSpaceKind::Lab { white, range }
2949}
2950
2951impl<'a> State<'a> {
2952    fn new(
2953        ext_gstate: Option<&'a Dict>,
2954        font_resources: Option<&'a Dict>,
2955        shading_resources: Option<&'a Dict>,
2956        color_space_resources: Option<&'a Dict>,
2957        properties_resources: Option<&'a Dict>,
2958    ) -> Self {
2959        Self {
2960            operands: Vec::new(),
2961            stack: vec![Frame::new()],
2962            current_path: None,
2963            current_point: Point::default(),
2964            fill_paint: None,
2965            stroke_paint: None,
2966            fill_cs: ColorSpaceKind::DeviceGray,
2967            stroke_cs: ColorSpaceKind::DeviceGray,
2968            stroke_width: 1.0,
2969            line_cap: LineCap::Butt,
2970            line_join: LineJoin::Miter,
2971            miter_limit: 10.0,
2972            dash: None,
2973            fill_alpha: 1.0,
2974            stroke_alpha: 1.0,
2975            ext_gstate,
2976            font_resources,
2977            shading_resources,
2978            color_space_resources,
2979            properties_resources,
2980            mc_depth: 0,
2981            marked_content: Vec::new(),
2982            current_font: None,
2983            text_matrix: Transform2D::identity(),
2984            text_line_matrix: Transform2D::identity(),
2985            text_leading: 0.0,
2986            char_spacing: 0.0,
2987            word_spacing: 0.0,
2988            horiz_scale: 1.0,
2989            in_text_object: false,
2990            text_shows: Vec::new(),
2991            shadings: Vec::new(),
2992            inline_images: Vec::new(),
2993            xobject_forms: None,
2994            pattern_resources: None,
2995            tiling_patterns: None,
2996            fill_tiling: None,
2997            fill_tiling_color: None,
2998            stroke_tiling: None,
2999            type3_fonts: None,
3000            text_render_mode: 0,
3001            text_rise: 0.0,
3002            type3_depth: 0,
3003        }
3004    }
3005
3006    /// Attach the page's pre-parsed Form XObjects (§8.10) so the `Do`
3007    /// operator can splice a named form's content into the scene tree.
3008    /// Used by [`parse_content_stream_full_with_xobjects`]; the legacy
3009    /// entry points leave this `None` and `Do` stays a no-op.
3010    fn with_xobject_forms(mut self, forms: Option<&'a BTreeMap<String, Group>>) -> Self {
3011        self.xobject_forms = forms;
3012        self
3013    }
3014
3015    /// Attach the page's `/Resources /Pattern` subdictionary (§8.7.3) so
3016    /// a `scn /Pname` shading-pattern fill (`/PatternType 2`) can paint
3017    /// a gradient. The legacy entry points leave this `None` and a
3018    /// pattern fill stays the conservative black fallback.
3019    fn with_pattern_resources(mut self, patterns: Option<&'a Dict>) -> Self {
3020        self.pattern_resources = patterns;
3021        self
3022    }
3023
3024    /// Attach the page's pre-parsed `/PatternType 1` tiling patterns
3025    /// (§8.7.3) so a `scn /Pname` tiling-pattern fill replicates its cell
3026    /// across the painted region. The legacy entry points leave this
3027    /// `None` and a tiling-pattern fill stays the conservative black
3028    /// fallback.
3029    fn with_tiling_patterns(
3030        mut self,
3031        patterns: Option<&'a BTreeMap<String, TilingPattern>>,
3032    ) -> Self {
3033        self.tiling_patterns = patterns;
3034        self
3035    }
3036
3037    /// Attach the page's pre-parsed Type 3 fonts (§9.6.5) so a
3038    /// `Tj`/`TJ`/`'`/`"` show under a Type 3 font paints each glyph's
3039    /// `/CharProcs` description into the scene tree. The legacy entry
3040    /// points leave this `None` and text shows stay event-only on the
3041    /// vector side.
3042    fn with_type3_fonts(mut self, fonts: Option<&'a BTreeMap<String, Type3Font>>) -> Self {
3043        self.type3_fonts = fonts;
3044        self
3045    }
3046
3047    fn finish(mut self) -> ParsedContent {
3048        // Unwind any unmatched `q` frames by promoting them in order
3049        // — the input was malformed but we'd rather salvage what we
3050        // can than refuse the whole document.
3051        while self.stack.len() > 1 {
3052            self.pop_q();
3053        }
3054        let root = self.stack.pop().expect("root frame present");
3055        ParsedContent {
3056            root: Group {
3057                transform: root.transform,
3058                opacity: 1.0,
3059                clip: root.clip,
3060                children: root.children,
3061                ..Group::default()
3062            },
3063            text_shows: self.text_shows,
3064            shadings: self.shadings,
3065            marked_content: self.marked_content,
3066            inline_images: self.inline_images,
3067        }
3068    }
3069
3070    fn current(&mut self) -> &mut Frame {
3071        self.stack.last_mut().expect("at least the root frame")
3072    }
3073
3074    fn push_q(&mut self) {
3075        self.stack.push(Frame::new());
3076    }
3077
3078    fn pop_q(&mut self) {
3079        // Only pop if we have more than the root frame — otherwise
3080        // ignore the unbalanced `Q` per the writer's "permissive
3081        // recovery" stance.
3082        if self.stack.len() <= 1 {
3083            return;
3084        }
3085        let frame = self.stack.pop().unwrap();
3086        // Translate the frame into a Node::Group child of its parent
3087        // — but skip empty groups (just `q Q` with nothing in
3088        // between is a no-op for the IR).
3089        if frame.is_effectively_empty() {
3090            return;
3091        }
3092        let g = Group {
3093            transform: frame.transform,
3094            opacity: 1.0,
3095            clip: frame.clip,
3096            children: frame.children,
3097            ..Group::default()
3098        };
3099        self.current().children.push(Node::Group(g));
3100    }
3101
3102    /// Handle one keyword (operator). Operands have already been
3103    /// pushed to `self.operands`.
3104    fn dispatch(&mut self, op: &[u8]) -> Result<(), PdfError> {
3105        match op {
3106            // Graphics state -------------------------------------
3107            b"q" => {
3108                self.push_q();
3109            }
3110            b"Q" => {
3111                self.pop_q();
3112            }
3113            b"cm" => {
3114                let nums = self.take_numbers(6)?;
3115                let t = Transform2D {
3116                    a: nums[0],
3117                    b: nums[1],
3118                    c: nums[2],
3119                    d: nums[3],
3120                    e: nums[4],
3121                    f: nums[5],
3122                };
3123                let frame = self.current();
3124                frame.transform = compose(frame.transform, t);
3125            }
3126            b"gs" => {
3127                // `/Name gs` — Table 57 sets graphics-state parameters
3128                // from the named dict in `/Resources /ExtGState`
3129                // (§8.4.5). When the resource map is available we apply
3130                // the Table 58 entries that map cleanly onto the
3131                // round-3 vector IR (LW / LC / LJ / ML / D / CA / ca);
3132                // every other key (SMask, BM, OP / op / OPM, BG / UCR /
3133                // TR / HT, Font, RI, SA, AIS, TK, FL, SM) is silently
3134                // tolerated per "any combination of parameter entries".
3135                let name = match self.operands.last() {
3136                    Some(Operand::Name(n)) => Some(n.clone()),
3137                    _ => None,
3138                };
3139                self.operands.clear();
3140                if let (Some(name), Some(ext_gstate)) = (name, self.ext_gstate) {
3141                    if let Some(dict) = lookup_dict(ext_gstate, &name) {
3142                        self.apply_ext_gstate(dict);
3143                    }
3144                }
3145            }
3146
3147            // Path construction ----------------------------------
3148            b"m" => {
3149                let p = self.take_point()?;
3150                let path = self.path_mut();
3151                path.commands.push(PathCommand::MoveTo(p));
3152                self.current_point = p;
3153            }
3154            b"l" => {
3155                let p = self.take_point()?;
3156                let path = self.path_mut();
3157                path.commands.push(PathCommand::LineTo(p));
3158                self.current_point = p;
3159            }
3160            b"c" => {
3161                let nums = self.take_numbers(6)?;
3162                let c1 = Point::new(nums[0], nums[1]);
3163                let c2 = Point::new(nums[2], nums[3]);
3164                let end = Point::new(nums[4], nums[5]);
3165                let path = self.path_mut();
3166                path.commands
3167                    .push(PathCommand::CubicCurveTo { c1, c2, end });
3168                self.current_point = end;
3169            }
3170            b"v" => {
3171                // Shorthand cubic: c1 = current point.
3172                let nums = self.take_numbers(4)?;
3173                let c1 = self.current_point;
3174                let c2 = Point::new(nums[0], nums[1]);
3175                let end = Point::new(nums[2], nums[3]);
3176                let path = self.path_mut();
3177                path.commands
3178                    .push(PathCommand::CubicCurveTo { c1, c2, end });
3179                self.current_point = end;
3180            }
3181            b"y" => {
3182                // Shorthand cubic: c2 = end.
3183                let nums = self.take_numbers(4)?;
3184                let c1 = Point::new(nums[0], nums[1]);
3185                let end = Point::new(nums[2], nums[3]);
3186                let c2 = end;
3187                let path = self.path_mut();
3188                path.commands
3189                    .push(PathCommand::CubicCurveTo { c1, c2, end });
3190                self.current_point = end;
3191            }
3192            b"re" => {
3193                // x y w h re — a rectangle as a closed subpath.
3194                let nums = self.take_numbers(4)?;
3195                let (x, y, w, h) = (nums[0], nums[1], nums[2], nums[3]);
3196                let path = self.path_mut();
3197                path.commands.push(PathCommand::MoveTo(Point::new(x, y)));
3198                path.commands
3199                    .push(PathCommand::LineTo(Point::new(x + w, y)));
3200                path.commands
3201                    .push(PathCommand::LineTo(Point::new(x + w, y + h)));
3202                path.commands
3203                    .push(PathCommand::LineTo(Point::new(x, y + h)));
3204                path.commands.push(PathCommand::Close);
3205                self.current_point = Point::new(x, y);
3206            }
3207            b"h" => {
3208                let path = self.path_mut();
3209                path.commands.push(PathCommand::Close);
3210            }
3211
3212            // Painting -------------------------------------------
3213            b"f" | b"F" => self.commit_path(true, false, FillRule::NonZero),
3214            b"f*" => self.commit_path(true, false, FillRule::EvenOdd),
3215            b"S" => self.commit_path(false, true, FillRule::NonZero),
3216            b"s" => {
3217                // s = h + S — implicit close before stroke.
3218                if let Some(p) = &mut self.current_path {
3219                    p.commands.push(PathCommand::Close);
3220                }
3221                self.commit_path(false, true, FillRule::NonZero);
3222            }
3223            b"B" => self.commit_path(true, true, FillRule::NonZero),
3224            b"B*" => self.commit_path(true, true, FillRule::EvenOdd),
3225            b"b" => {
3226                if let Some(p) = &mut self.current_path {
3227                    p.commands.push(PathCommand::Close);
3228                }
3229                self.commit_path(true, true, FillRule::NonZero);
3230            }
3231            b"b*" => {
3232                if let Some(p) = &mut self.current_path {
3233                    p.commands.push(PathCommand::Close);
3234                }
3235                self.commit_path(true, true, FillRule::EvenOdd);
3236            }
3237            b"n" => {
3238                // No-op paint — drop the current path.
3239                self.current_path = None;
3240                self.operands.clear();
3241            }
3242
3243            // Clip --------------------------------------------------
3244            b"W" | b"W*" => {
3245                // The clip operator consumes the current path as the
3246                // clip region — but in PDF the clip is committed by
3247                // the next paint operator, conventionally `n`. We
3248                // record it onto the current frame here; if the
3249                // upcoming paint is `n` it'll just discard the path
3250                // (which we've already moved into `clip`).
3251                if let Some(p) = self.current_path.take() {
3252                    self.current().clip = Some(p);
3253                }
3254                self.operands.clear();
3255            }
3256
3257            // Colour ----------------------------------------------
3258            b"rg" => {
3259                // `rg` implicitly sets DeviceRGB nonstroking space
3260                // (§8.6.8 Table 74) — track it so a later bare `sc`
3261                // resolves in RGB.
3262                let nums = self.take_numbers(3)?;
3263                self.fill_cs = ColorSpaceKind::DeviceRgb;
3264                self.fill_tiling = None;
3265                self.fill_tiling_color = None;
3266                self.fill_paint = Some(Paint::Solid(rgb_from_unit(nums[0], nums[1], nums[2])));
3267            }
3268            b"RG" => {
3269                let nums = self.take_numbers(3)?;
3270                self.stroke_cs = ColorSpaceKind::DeviceRgb;
3271                self.stroke_tiling = None;
3272                self.stroke_paint = Some(Paint::Solid(rgb_from_unit(nums[0], nums[1], nums[2])));
3273            }
3274            b"g" => {
3275                let nums = self.take_numbers(1)?;
3276                self.fill_cs = ColorSpaceKind::DeviceGray;
3277                self.fill_tiling = None;
3278                self.fill_tiling_color = None;
3279                self.fill_paint = Some(Paint::Solid(rgb_from_unit(nums[0], nums[0], nums[0])));
3280            }
3281            b"G" => {
3282                let nums = self.take_numbers(1)?;
3283                self.stroke_cs = ColorSpaceKind::DeviceGray;
3284                self.stroke_tiling = None;
3285                self.stroke_paint = Some(Paint::Solid(rgb_from_unit(nums[0], nums[0], nums[0])));
3286            }
3287            b"k" | b"K" => {
3288                // DeviceCMYK fill (`k`) / stroke (`K`). The IR carries
3289                // only RGB, so convert per ISO 32000-1 §10.3.5
3290                // (DeviceCMYK → DeviceRGB): a simple operation that does
3291                // not involve black generation or undercolour removal.
3292                // The operator also sets the implicit colour space.
3293                let nums = self.take_numbers(4)?;
3294                let p = Some(Paint::Solid(rgb_from_cmyk(
3295                    nums[0], nums[1], nums[2], nums[3],
3296                )));
3297                if op == b"K" {
3298                    self.stroke_cs = ColorSpaceKind::DeviceCmyk;
3299                    self.stroke_tiling = None;
3300                    self.stroke_paint = p;
3301                } else {
3302                    self.fill_cs = ColorSpaceKind::DeviceCmyk;
3303                    self.fill_tiling = None;
3304                    self.fill_tiling_color = None;
3305                    self.fill_paint = p;
3306                }
3307            }
3308            b"sc" | b"scn" => {
3309                // `sc`/`scn` set the nonstroking colour in whatever
3310                // space the most-recent `cs` selected (§8.6.8). When
3311                // that's a device family with a fixed component count,
3312                // interpret the numeric operands directly; otherwise
3313                // (Pattern, an unresolved resource colour space, or a
3314                // trailing `/Name` pattern operand) keep the round-3
3315                // conservative black fallback.
3316                // A `/PatternType 1` tiling-pattern operand defers to
3317                // `commit_path`, which replicates the cell across the
3318                // filled region; record it (and clear any stale shading
3319                // paint). A non-tiling operand clears the tiling state.
3320                self.fill_tiling = self.tiling_pattern_name_from_operand();
3321                // For an uncoloured (`/PaintType 2`) pattern, capture the
3322                // underlying colour the cell stencil is poured with from
3323                // the numeric operands preceding the name (§8.7.3.3).
3324                self.fill_tiling_color = self
3325                    .fill_tiling
3326                    .as_ref()
3327                    .and_then(|n| self.uncoloured_tiling_color(n));
3328                let paint = self
3329                    .color_from_components(&self.fill_cs.clone())
3330                    .or_else(|| self.pattern_paint_from_operand());
3331                self.fill_paint = paint.or_else(|| {
3332                    self.fill_paint
3333                        .clone()
3334                        .or(Some(Paint::Solid(Rgba::opaque(0, 0, 0))))
3335                });
3336                self.operands.clear();
3337            }
3338            b"SC" | b"SCN" => {
3339                self.stroke_tiling = self.tiling_pattern_name_from_operand();
3340                let paint = self
3341                    .color_from_components(&self.stroke_cs.clone())
3342                    .or_else(|| self.pattern_paint_from_operand());
3343                self.stroke_paint = paint.or_else(|| {
3344                    self.stroke_paint
3345                        .clone()
3346                        .or(Some(Paint::Solid(Rgba::opaque(0, 0, 0))))
3347                });
3348                self.operands.clear();
3349            }
3350            b"cs" => {
3351                // Nonstroking colour-space switch — last operand is a
3352                // /Name. Record the space so a following `sc`/`scn`
3353                // knows how to read its components. Setting a device
3354                // colour space initialises the current colour to its
3355                // black/zero value per §8.6.4.2..4 ("Setting … shall
3356                // initialize the corresponding current colour to 0.0").
3357                self.fill_cs = self.take_color_space_name();
3358                self.fill_tiling = None;
3359                self.fill_tiling_color = None;
3360                self.fill_paint = initial_color_for(&self.fill_cs);
3361                self.operands.clear();
3362            }
3363            b"CS" => {
3364                self.stroke_cs = self.take_color_space_name();
3365                self.stroke_tiling = None;
3366                self.stroke_paint = initial_color_for(&self.stroke_cs);
3367                self.operands.clear();
3368            }
3369
3370            // Stroke style -----------------------------------------
3371            b"w" => {
3372                let nums = self.take_numbers(1)?;
3373                self.stroke_width = nums[0];
3374            }
3375            b"J" => {
3376                let nums = self.take_numbers(1)?;
3377                self.line_cap = match nums[0] as i32 {
3378                    0 => LineCap::Butt,
3379                    1 => LineCap::Round,
3380                    2 => LineCap::Square,
3381                    _ => LineCap::Butt,
3382                };
3383            }
3384            b"j" => {
3385                let nums = self.take_numbers(1)?;
3386                self.line_join = match nums[0] as i32 {
3387                    0 => LineJoin::Miter,
3388                    1 => LineJoin::Round,
3389                    2 => LineJoin::Bevel,
3390                    _ => LineJoin::Miter,
3391                };
3392            }
3393            b"M" => {
3394                let nums = self.take_numbers(1)?;
3395                self.miter_limit = nums[0];
3396            }
3397            b"d" => {
3398                // [array] offset d. The array carries numbers only
3399                // — strings inside a `d`-array are malformed PDF and
3400                // are dropped (rather than refused) for tolerance.
3401                if self.operands.len() < 2 {
3402                    self.operands.clear();
3403                    return Ok(());
3404                }
3405                let offset = match self.operands.pop().unwrap() {
3406                    Operand::Number(n) => n,
3407                    _ => 0.0,
3408                };
3409                let array = match self.operands.pop().unwrap() {
3410                    Operand::Array(v) => v
3411                        .into_iter()
3412                        .filter_map(|el| match el {
3413                            ArrayElem::Number(n) => Some(n),
3414                            ArrayElem::String(_) => None,
3415                        })
3416                        .collect::<Vec<f32>>(),
3417                    _ => Vec::new(),
3418                };
3419                self.dash = if array.is_empty() {
3420                    None
3421                } else {
3422                    Some(DashPattern { array, offset })
3423                };
3424                self.operands.clear();
3425            }
3426
3427            // Text-object brackets (§9.4 + Table 105) -------------
3428            b"BT" => {
3429                // §9.4 — every BT resets the text matrix + text line
3430                // matrix to identity. Leading + font carry across BT
3431                // boundaries per §9.3 Table 105 NOTE 1.
3432                self.text_matrix = Transform2D::identity();
3433                self.text_line_matrix = Transform2D::identity();
3434                self.in_text_object = true;
3435                self.operands.clear();
3436            }
3437            b"ET" => {
3438                self.in_text_object = false;
3439                self.operands.clear();
3440            }
3441
3442            // Text state — §9.3 + Table 105 ------------------------
3443            b"Tf" => {
3444                // /Fx size Tf — last two operands are the font
3445                // resource name + the size.
3446                let size = match self.operands.last() {
3447                    Some(Operand::Number(n)) => *n,
3448                    _ => 0.0,
3449                };
3450                let name = match self.operands.iter().rev().nth(1) {
3451                    Some(Operand::Name(s)) => s.clone(),
3452                    _ => String::new(),
3453                };
3454                self.current_font = Some((name, size));
3455                self.operands.clear();
3456            }
3457            b"TL" => {
3458                // single-number text leading.
3459                if let Some(Operand::Number(n)) = self.operands.last() {
3460                    self.text_leading = *n;
3461                }
3462                self.operands.clear();
3463            }
3464            b"Tc" => {
3465                // charSpace Tc — set character spacing (§9.3.2). Feeds
3466                // the §9.4.4 displacement so consecutive shows on a
3467                // line advance correctly.
3468                if let Some(Operand::Number(n)) = self.operands.last() {
3469                    self.char_spacing = *n;
3470                }
3471                self.operands.clear();
3472            }
3473            b"Tw" => {
3474                // wordSpace Tw — set word spacing (§9.3.3). Applied to
3475                // single-byte code-32 glyphs in the §9.4.4
3476                // displacement.
3477                if let Some(Operand::Number(n)) = self.operands.last() {
3478                    self.word_spacing = *n;
3479                }
3480                self.operands.clear();
3481            }
3482            b"Tz" => {
3483                // scale Tz — set horizontal scaling (§9.3.4). The
3484                // operand is a percentage; Th is `scale ÷ 100`.
3485                if let Some(Operand::Number(n)) = self.operands.last() {
3486                    self.horiz_scale = *n / 100.0;
3487                }
3488                self.operands.clear();
3489            }
3490            b"Tr" => {
3491                // `render Tr` — text rendering mode (§9.3.6 Table 106).
3492                // Mode 3 (invisible) suppresses Type 3 glyph painting;
3493                // every other mode paints. The mode also affects the
3494                // fill/stroke split for outline fonts, which is moot on
3495                // the vector side here.
3496                if let Some(Operand::Number(n)) = self.operands.last() {
3497                    self.text_render_mode = *n as i64;
3498                }
3499                self.operands.clear();
3500            }
3501            b"Ts" => {
3502                // `rise Ts` — text rise (§9.4.4), the vertical offset
3503                // baked into the text-rendering matrix. Tracked for the
3504                // Type 3 glyph paint path.
3505                if let Some(Operand::Number(n)) = self.operands.last() {
3506                    self.text_rise = *n;
3507                }
3508                self.operands.clear();
3509            }
3510
3511            // Text positioning — §9.4.2 + Table 108 ---------------
3512            b"Td" => {
3513                // tx ty Td — text-line-matrix moves by (tx, ty);
3514                // text matrix copies from it.
3515                if let Ok(nums) = self.take_numbers(2) {
3516                    let (tx, ty) = (nums[0], nums[1]);
3517                    let m = Transform2D {
3518                        a: 1.0,
3519                        b: 0.0,
3520                        c: 0.0,
3521                        d: 1.0,
3522                        e: tx,
3523                        f: ty,
3524                    };
3525                    self.text_line_matrix = compose(self.text_line_matrix, m);
3526                    self.text_matrix = self.text_line_matrix;
3527                }
3528                self.operands.clear();
3529            }
3530            b"TD" => {
3531                // tx ty TD — equivalent to "-ty TL tx ty Td".
3532                if let Ok(nums) = self.take_numbers(2) {
3533                    let (tx, ty) = (nums[0], nums[1]);
3534                    self.text_leading = -ty;
3535                    let m = Transform2D {
3536                        a: 1.0,
3537                        b: 0.0,
3538                        c: 0.0,
3539                        d: 1.0,
3540                        e: tx,
3541                        f: ty,
3542                    };
3543                    self.text_line_matrix = compose(self.text_line_matrix, m);
3544                    self.text_matrix = self.text_line_matrix;
3545                }
3546                self.operands.clear();
3547            }
3548            b"Tm" => {
3549                // a b c d e f Tm — set text matrix + text line matrix
3550                // to the six-element matrix verbatim.
3551                if let Ok(nums) = self.take_numbers(6) {
3552                    let m = Transform2D {
3553                        a: nums[0],
3554                        b: nums[1],
3555                        c: nums[2],
3556                        d: nums[3],
3557                        e: nums[4],
3558                        f: nums[5],
3559                    };
3560                    self.text_matrix = m;
3561                    self.text_line_matrix = m;
3562                }
3563                self.operands.clear();
3564            }
3565            b"T*" => {
3566                // Move to the next line — `0 -Tl Td`. (Note the sign:
3567                // §9.4.2 Table 108 says `0 -Tl Td`; `text_leading` is
3568                // already the positive y-step, so the displacement is
3569                // `(0, -Tl)`.)
3570                let leading = self.text_leading;
3571                let m = Transform2D {
3572                    a: 1.0,
3573                    b: 0.0,
3574                    c: 0.0,
3575                    d: 1.0,
3576                    e: 0.0,
3577                    f: -leading,
3578                };
3579                self.text_line_matrix = compose(self.text_line_matrix, m);
3580                self.text_matrix = self.text_line_matrix;
3581                self.operands.clear();
3582            }
3583
3584            // Text showing — §9.4.3 + Table 109 -------------------
3585            b"Tj" => {
3586                // string Tj
3587                let bytes = match self.operands.last() {
3588                    Some(Operand::String(s)) => s.clone(),
3589                    _ => Vec::new(),
3590                };
3591                let metrics = self.current_font_metrics();
3592                self.emit_text_show(bytes.clone(), TextShowOp::Tj);
3593                // §9.6.5 — a Type 3 font paints each glyph's /CharProcs
3594                // description into the scene at the current text origin,
3595                // before Tm advances.
3596                self.paint_type3_show(&bytes);
3597                // §9.4.4 — advance Tm by the shown glyphs so a
3598                // following show on the same line starts at the right
3599                // origin.
3600                self.advance_text(&bytes, &metrics);
3601                self.operands.clear();
3602            }
3603            b"TJ" => {
3604                // [(s1) num1 (s2) num2 …] TJ — show the strings in
3605                // array order, advancing the text matrix between each
3606                // glyph (§9.4.4) and applying the per-element numeric
3607                // kern adjustments (§9.4.3: a number `Tj` translates
3608                // Tm by `−Tj/1000 × Tfs × Th`). The decoded payload
3609                // surfaced on the event is the concatenation of every
3610                // string element.
3611                let metrics = self.current_font_metrics();
3612                let mut bytes = Vec::new();
3613                let mut elements: Vec<TjElem> = Vec::new();
3614                if let Some(Operand::Array(items)) = self.operands.last() {
3615                    for el in items {
3616                        match el {
3617                            ArrayElem::String(s) => {
3618                                bytes.extend_from_slice(s);
3619                                elements.push(TjElem::Str(s.clone()));
3620                            }
3621                            ArrayElem::Number(n) => elements.push(TjElem::Kern(*n)),
3622                        }
3623                    }
3624                }
3625                // Record the show at the array's start origin first.
3626                self.emit_text_show(bytes, TextShowOp::TJ);
3627                let tfs = self.current_font.as_ref().map(|(_, s)| *s).unwrap_or(0.0);
3628                let th = self.horiz_scale;
3629                for el in elements {
3630                    match el {
3631                        TjElem::Str(s) => {
3632                            // §9.6.5 — paint Type 3 glyphs at this
3633                            // element's origin, then advance Tm past
3634                            // them (so the next element / kern starts
3635                            // at the right place).
3636                            self.paint_type3_show(&s);
3637                            self.advance_text(&s, &metrics);
3638                        }
3639                        TjElem::Kern(adj) => {
3640                            // A positive kern moves the next glyph
3641                            // *left* (§9.4.3): tx = −adj/1000 × Tfs × Th.
3642                            let tx = -adj / 1000.0 * tfs * th;
3643                            self.translate_text(tx);
3644                        }
3645                    }
3646                }
3647                self.operands.clear();
3648            }
3649            b"'" => {
3650                // ' string — T* then Tj. Implicit line-advance per
3651                // Table 109.
3652                let leading = self.text_leading;
3653                let m = Transform2D {
3654                    a: 1.0,
3655                    b: 0.0,
3656                    c: 0.0,
3657                    d: 1.0,
3658                    e: 0.0,
3659                    f: -leading,
3660                };
3661                self.text_line_matrix = compose(self.text_line_matrix, m);
3662                self.text_matrix = self.text_line_matrix;
3663                let bytes = match self.operands.last() {
3664                    Some(Operand::String(s)) => s.clone(),
3665                    _ => Vec::new(),
3666                };
3667                let metrics = self.current_font_metrics();
3668                self.emit_text_show(bytes.clone(), TextShowOp::SingleQuote);
3669                self.paint_type3_show(&bytes);
3670                self.advance_text(&bytes, &metrics);
3671                self.operands.clear();
3672            }
3673            b"\"" => {
3674                // aw ac string " — set word spacing (aw) + char spacing
3675                // (ac), do an implicit T*, then show like Tj
3676                // (§9.4.3 / Table 109). The two leading numeric
3677                // operands set Tw and Tc for this and subsequent shows.
3678                let (aw, ac) = match (
3679                    self.operands.iter().rev().nth(2),
3680                    self.operands.iter().rev().nth(1),
3681                ) {
3682                    (Some(Operand::Number(aw)), Some(Operand::Number(ac))) => (*aw, *ac),
3683                    _ => (self.word_spacing, self.char_spacing),
3684                };
3685                self.word_spacing = aw;
3686                self.char_spacing = ac;
3687                let leading = self.text_leading;
3688                let m = Transform2D {
3689                    a: 1.0,
3690                    b: 0.0,
3691                    c: 0.0,
3692                    d: 1.0,
3693                    e: 0.0,
3694                    f: -leading,
3695                };
3696                self.text_line_matrix = compose(self.text_line_matrix, m);
3697                self.text_matrix = self.text_line_matrix;
3698                let bytes = match self.operands.last() {
3699                    Some(Operand::String(s)) => s.clone(),
3700                    _ => Vec::new(),
3701                };
3702                let metrics = self.current_font_metrics();
3703                self.emit_text_show(bytes.clone(), TextShowOp::DoubleQuote);
3704                self.paint_type3_show(&bytes);
3705                self.advance_text(&bytes, &metrics);
3706                self.operands.clear();
3707            }
3708
3709            // Type 3 glyph metrics (§9.6.5 Table 113) ---------------
3710            b"d0" | b"d1" => {
3711                // `wx wy d0` / `wx wy llx lly urx ury d1` — declare the
3712                // glyph's width (and, for d1, bounding box). These only
3713                // appear as the first operator of a /CharProcs glyph
3714                // description; the width is already taken from the
3715                // font's /Widths array (§9.6.5) and the bbox is purely
3716                // advisory, so the marks are produced by the path /
3717                // image operators that follow. Drop the operands so the
3718                // numbers don't leak into the next operator.
3719                self.operands.clear();
3720            }
3721
3722            // XObject paint ----------------------------------------
3723            b"Do" => {
3724                // `/Name Do` — paint an external object (§8.8). A Form
3725                // XObject (§8.10) is spliced into the scene tree here;
3726                // an Image XObject is left to the dedicated
3727                // [`crate::reader::images`] walker (round-3 no-op on the
3728                // scene side).
3729                //
3730                // §8.10.1 specifies the Do-on-form algorithm as:
3731                //   a) q (save graphics state)
3732                //   b) concat the form's /Matrix with the CTM
3733                //   c) clip to the form's /BBox
3734                //   d) paint the form's content stream
3735                //   e) Q (restore)
3736                //
3737                // The pre-parsed form `Group` already carries /Matrix in
3738                // its `transform` and the /BBox rectangle in its `clip`,
3739                // so pushing it as a child of the current frame applies
3740                // (b)+(c) under the frame's accumulated `cm` CTM — the
3741                // q/Q bracket is implicit in the nested-group boundary.
3742                let name = match self.operands.last() {
3743                    Some(Operand::Name(n)) => n.clone(),
3744                    _ => String::new(),
3745                };
3746                if !name.is_empty() {
3747                    if let Some(form) = self.xobject_forms.and_then(|m| m.get(&name)) {
3748                        if !form.children.is_empty() {
3749                            self.current().children.push(Node::Group(form.clone()));
3750                        }
3751                    }
3752                }
3753                self.operands.clear();
3754            }
3755
3756            // Shading paint (§8.7.4.5) -----------------------------
3757            b"sh" => {
3758                // `name sh` — paint the shape and colour shading
3759                // described by a shading dictionary, subject to the
3760                // current clipping path. The current colour in the
3761                // graphics state is neither used nor altered.
3762                //
3763                // We record one [`ContentShading`] event per `sh` so
3764                // a downstream consumer can resolve the shading
3765                // dictionary's `ShadingType` / `ColorSpace` /
3766                // `Coords` / `Function` (Tables 78..86) into a
3767                // concrete paint. The walker does NOT interpret the
3768                // shading dictionary itself — that would require
3769                // colour-space resolution + function evaluation
3770                // (§7.10) + the per-type geometry rules (axial /
3771                // radial / Gouraud / Coons / tensor), all of which
3772                // belong in a dedicated shading-resolver crate or
3773                // module.
3774                let name = match self.operands.last() {
3775                    Some(Operand::Name(n)) => n.clone(),
3776                    _ => String::new(),
3777                };
3778                let shading_dict = match (self.shading_resources, name.as_str()) {
3779                    (Some(res), n) if !n.is_empty() => lookup_dict(res, n).cloned(),
3780                    _ => None,
3781                };
3782                // A Type 4–7 (mesh) shading is evaluated into its
3783                // device-space triangle / patch geometry; a Type 1–3
3784                // (function-based / axial / radial) shading is evaluated
3785                // into sampled-colour gradient stops. Exactly one of
3786                // `mesh` / `gradient` is populated (or neither, when the
3787                // shading can't be reduced).
3788                let mesh = shading_dict
3789                    .as_ref()
3790                    .and_then(|d| evaluate_mesh_shading(d, self.color_space_resources));
3791                let gradient = shading_dict
3792                    .as_ref()
3793                    .and_then(|d| evaluate_gradient_shading(d, self.color_space_resources));
3794                let ctm = self.effective_ctm();
3795                let clip = self.current_clip();
3796                // Paint a clipped axial / radial `sh` into the scene: the
3797                // shading fills the current clipping region (§8.7.4.5). The
3798                // clip path is in the current frame's local coordinate
3799                // basis — the same basis the shading `Coords` are written
3800                // in — so the gradient maps into it by identity (the
3801                // frame's accumulated `cm` is applied once when the node is
3802                // rendered). We only paint when a clip is in force; an
3803                // unclipped `sh` would fill the whole page, which we leave
3804                // to the `ContentShading` event rather than synthesising a
3805                // page-sized fill. Type 1 (function-based) and mesh
3806                // shadings have no `Paint` analogue and stay event-only.
3807                if let Some(clip_path) = &clip {
3808                    if let Some(paint) = gradient
3809                        .as_ref()
3810                        .and_then(|g| gradient_to_paint(g, Transform2D::identity()))
3811                    {
3812                        let node = Node::Path(PathNode {
3813                            path: clip_path.clone(),
3814                            fill: Some(apply_alpha(paint, self.fill_alpha)),
3815                            stroke: None,
3816                            fill_rule: FillRule::NonZero,
3817                        });
3818                        self.current().children.push(node);
3819                    }
3820                }
3821                self.shadings.push(ContentShading {
3822                    name,
3823                    shading_dict,
3824                    ctm,
3825                    clip,
3826                    mesh,
3827                    gradient,
3828                });
3829                self.operands.clear();
3830            }
3831
3832            // Marked content (§14.6 Table 320) ---------------------
3833            b"MP" => {
3834                // `tag MP` — marked-content point. The point sits
3835                // inside whatever sequence is currently open, so its
3836                // reported depth is the current open-sequence count.
3837                let tag = self.last_name_operand();
3838                let depth = self.mc_depth;
3839                self.marked_content.push(ContentMarkedContent {
3840                    operator: MarkedContentOp::Mp,
3841                    tag,
3842                    properties: None,
3843                    depth,
3844                });
3845                self.operands.clear();
3846            }
3847            b"DP" => {
3848                // `tag properties DP` — marked-content point with a
3849                // property list. `properties` is the operand after the
3850                // tag (an inline dict or a /Name into /Properties).
3851                let (tag, properties) = self.marked_content_tag_props();
3852                let depth = self.mc_depth;
3853                self.marked_content.push(ContentMarkedContent {
3854                    operator: MarkedContentOp::Dp,
3855                    tag,
3856                    properties,
3857                    depth,
3858                });
3859                self.operands.clear();
3860            }
3861            b"BMC" => {
3862                // `tag BMC` — begin a marked-content sequence. The
3863                // sequence's own depth is the current count *before*
3864                // we open it; the count then increments.
3865                let tag = self.last_name_operand();
3866                let depth = self.mc_depth;
3867                self.marked_content.push(ContentMarkedContent {
3868                    operator: MarkedContentOp::Bmc,
3869                    tag,
3870                    properties: None,
3871                    depth,
3872                });
3873                self.mc_depth = self.mc_depth.saturating_add(1);
3874                self.operands.clear();
3875            }
3876            b"BDC" => {
3877                // `tag properties BDC` — begin a sequence with a
3878                // property list.
3879                let (tag, properties) = self.marked_content_tag_props();
3880                let depth = self.mc_depth;
3881                self.marked_content.push(ContentMarkedContent {
3882                    operator: MarkedContentOp::Bdc,
3883                    tag,
3884                    properties,
3885                    depth,
3886                });
3887                self.mc_depth = self.mc_depth.saturating_add(1);
3888                self.operands.clear();
3889            }
3890            b"EMC" => {
3891                // End the most recent `BMC`/`BDC` sequence. Decrement
3892                // first (saturating, so an unbalanced `EMC` reports
3893                // depth 0 and is tolerated) and report the depth of the
3894                // sequence it closes.
3895                self.mc_depth = self.mc_depth.saturating_sub(1);
3896                let depth = self.mc_depth;
3897                self.marked_content.push(ContentMarkedContent {
3898                    operator: MarkedContentOp::Emc,
3899                    tag: String::new(),
3900                    properties: None,
3901                    depth,
3902                });
3903                self.operands.clear();
3904            }
3905
3906            // Everything else --------------------------------------
3907            _ => {
3908                self.operands.clear();
3909            }
3910        }
3911        Ok(())
3912    }
3913
3914    /// The most-recent `Name` operand (leading `/` already stripped at
3915    /// scan time), or empty when none was pushed. Used by the no-
3916    /// property marked-content operators `MP` / `BMC` whose only
3917    /// operand is the tag name.
3918    fn last_name_operand(&self) -> String {
3919        self.operands
3920            .iter()
3921            .rev()
3922            .find_map(|o| match o {
3923                Operand::Name(n) => Some(n.clone()),
3924                _ => None,
3925            })
3926            .unwrap_or_default()
3927    }
3928
3929    /// Resolve the `tag properties` operand pair for `DP` / `BDC`
3930    /// (§14.6 Table 320). The operands are `tag` then `properties` in
3931    /// stream order, so on the stack `properties` is last and `tag` is
3932    /// the `Name` before it.
3933    ///
3934    /// * `tag` — the first `Name` operand.
3935    /// * `properties` — resolved per §14.6.2: an inline `Operand::Dict`
3936    ///   is captured directly; an `Operand::Name` is looked up in the
3937    ///   `/Resources /Properties` subdictionary (`properties_resources`)
3938    ///   and dereferenced one hop into a `Dict`. `None` when the operand
3939    ///   is absent, isn't a dict/name, or the name doesn't resolve.
3940    fn marked_content_tag_props(&self) -> (String, Option<Dict>) {
3941        // tag is the first Name from the bottom; properties is the last
3942        // operand. A well-formed `tag properties` pair has the tag as
3943        // the earliest Name operand.
3944        let tag = self
3945            .operands
3946            .iter()
3947            .find_map(|o| match o {
3948                Operand::Name(n) => Some(n.clone()),
3949                _ => None,
3950            })
3951            .unwrap_or_default();
3952        let properties = match self.operands.last() {
3953            Some(Operand::Dict(d)) => Some(d.clone()),
3954            // A `/Name` properties operand — but only when it isn't the
3955            // tag itself (a bare `tag BDC`-shaped misuse with no real
3956            // property operand should resolve to `None`, not loop the
3957            // tag name back through /Properties).
3958            Some(Operand::Name(n)) if self.operands.len() >= 2 => self
3959                .properties_resources
3960                .and_then(|res| lookup_dict(res, n).cloned()),
3961            _ => None,
3962        };
3963        (tag, properties)
3964    }
3965
3966    /// Compose every frame's `transform` from the root down to the
3967    /// current top frame. PDF's CTM is the accumulated product of
3968    /// every `cm` since the start of the content stream (across `q`
3969    /// frames — a `Q` pops the frame and discards its transform, but
3970    /// while the frame is live its transform composes with the
3971    /// ancestor frames').
3972    ///
3973    /// Mirrors the convention `commit_path` uses when it emits a
3974    /// `Node::Path` into the current frame: the path's coordinates
3975    /// are in the local frame's space; the frame's transform is the
3976    /// `cm` accumulation since the most recent `q`. To get user-space
3977    /// coordinates we have to compose root-to-leaf, which is what
3978    /// this helper returns.
3979    fn effective_ctm(&self) -> Transform2D {
3980        let mut acc = Transform2D::identity();
3981        for frame in &self.stack {
3982            acc = compose(acc, frame.transform);
3983        }
3984        acc
3985    }
3986
3987    /// Return a clone of the most recent `W`/`W*`-committed clip
3988    /// path in the active frame. `None` when the current frame has
3989    /// no clip in force (in PDF the clip is per-`q` — a `Q` restores
3990    /// the parent frame's clip; we expose only the current frame's
3991    /// clip because the parent's was already in force before we
3992    /// entered the child `q`).
3993    fn current_clip(&self) -> Option<Path> {
3994        self.stack.last().and_then(|f| f.clip.clone())
3995    }
3996
3997    fn commit_path(&mut self, fill: bool, stroke: bool, rule: FillRule) {
3998        let Some(path) = self.current_path.take() else {
3999            self.operands.clear();
4000            return;
4001        };
4002        // A `/PatternType 1` tiling-pattern fill (§8.7.3) replicates the
4003        // pattern cell across the filled region instead of painting a
4004        // solid colour. When one is active, emit the tiled cells (clipped
4005        // to `path`) and drop the solid fill on the PathNode — the stroke,
4006        // if any, still paints below.
4007        let tiled = if fill {
4008            self.emit_tiling_fill(&path, rule)
4009        } else {
4010            false
4011        };
4012        let fill_paint = if fill && !tiled {
4013            let base = self
4014                .fill_paint
4015                .clone()
4016                .unwrap_or(Paint::Solid(Rgba::opaque(0, 0, 0)));
4017            Some(apply_alpha(base, self.fill_alpha))
4018        } else {
4019            None
4020        };
4021        let stroke_obj = if stroke {
4022            let stroke_paint = self
4023                .stroke_paint
4024                .clone()
4025                .unwrap_or(Paint::Solid(Rgba::opaque(0, 0, 0)));
4026            Some(Stroke {
4027                width: self.stroke_width,
4028                paint: apply_alpha(stroke_paint, self.stroke_alpha),
4029                cap: self.line_cap,
4030                join: self.line_join,
4031                miter_limit: self.miter_limit,
4032                dash: self.dash.clone(),
4033            })
4034        } else {
4035            None
4036        };
4037        let node = Node::Path(PathNode {
4038            path,
4039            fill: fill_paint,
4040            stroke: stroke_obj,
4041            fill_rule: rule,
4042        });
4043        self.current().children.push(node);
4044        self.operands.clear();
4045    }
4046
4047    /// Replicate the active `/PatternType 1` tiling pattern's cell
4048    /// across the region bounded by `fill_path` (§8.7.3.1). Returns
4049    /// `true` when a tiling fill was emitted (so [`commit_path`] drops
4050    /// the solid fill), `false` otherwise (no tiling pattern active or
4051    /// resolvable — the caller keeps its existing fill).
4052    ///
4053    /// Geometry (§8.7.2 NOTE 1 + §8.7.3.1):
4054    /// * The pattern `/Matrix` maps pattern space to the page's default
4055    ///   (initial) coordinate space — the root frame's local space —
4056    ///   independent of any `cm` in force at paint time. The tiled group
4057    ///   is therefore emitted as a child of the **root** frame, with each
4058    ///   tile placed at `Matrix · translate(i·XStep, j·YStep)`.
4059    /// * Each tile clones the cell `Group` and clips it to the `/BBox`.
4060    /// * The fill region clips the whole tiling: `fill_path` is mapped
4061    ///   from the current frame's local space into root-local space
4062    ///   (composing the frames above the root) and used as the group clip.
4063    /// * The `i`/`j` index range is the cell-origin lattice covering the
4064    ///   fill region's bounding box, mapped back through the inverse of
4065    ///   the pattern matrix; the tile count is hard-capped so a huge fill
4066    ///   over a tiny step can't explode.
4067    fn emit_tiling_fill(&mut self, fill_path: &Path, rule: FillRule) -> bool {
4068        let Some(name) = self.fill_tiling.clone() else {
4069            return false;
4070        };
4071        let Some(pat) = self.tiling_patterns.and_then(|m| m.get(&name)) else {
4072            return false;
4073        };
4074        if pat.cell.children.is_empty() {
4075            return false;
4076        }
4077        let xstep = pat.xstep;
4078        let ystep = pat.ystep;
4079        if !xstep.is_finite() || !ystep.is_finite() || xstep == 0.0 || ystep == 0.0 {
4080            return false;
4081        }
4082        // Map `fill_path` from the current frame's local space into the
4083        // root frame's local space (= compose of every frame above the
4084        // root). At the root frame this is identity.
4085        let mut above_root = Transform2D::identity();
4086        for frame in self.stack.iter().skip(1) {
4087            above_root = compose(above_root, frame.transform);
4088        }
4089        let region_path = transform_path(fill_path, above_root);
4090        let Some((rx0, ry0, rx1, ry1)) = path_bounds(&region_path) else {
4091            return false;
4092        };
4093        // Invert the pattern matrix to map the region bbox corners into
4094        // pattern space and bound the tile lattice.
4095        let Some(inv) = invert_transform(pat.matrix) else {
4096            return false;
4097        };
4098        let corners = [
4099            inv.apply(Point::new(rx0, ry0)),
4100            inv.apply(Point::new(rx1, ry0)),
4101            inv.apply(Point::new(rx0, ry1)),
4102            inv.apply(Point::new(rx1, ry1)),
4103        ];
4104        let (mut px0, mut py0, mut px1, mut py1) = (
4105            f32::INFINITY,
4106            f32::INFINITY,
4107            f32::NEG_INFINITY,
4108            f32::NEG_INFINITY,
4109        );
4110        for c in corners {
4111            if !c.x.is_finite() || !c.y.is_finite() {
4112                return false;
4113            }
4114            px0 = px0.min(c.x);
4115            py0 = py0.min(c.y);
4116            px1 = px1.max(c.x);
4117            py1 = py1.max(c.y);
4118        }
4119        // Tile-index range over the pattern-space bbox, padded by one
4120        // cell each side so a cell whose /BBox overhangs its step still
4121        // covers the region edges.
4122        let i_lo = (px0 / xstep).floor() as i64 - 1;
4123        let i_hi = (px1 / xstep).ceil() as i64 + 1;
4124        let j_lo = (py0 / ystep).floor() as i64 - 1;
4125        let j_hi = (py1 / ystep).ceil() as i64 + 1;
4126        // Normalise so the range is well-ordered regardless of a negative
4127        // XStep / YStep (Table 75 allows either sign).
4128        let (i_lo, i_hi) = (i_lo.min(i_hi), i_lo.max(i_hi));
4129        let (j_lo, j_hi) = (j_lo.min(j_hi), j_lo.max(j_hi));
4130        let tile_count = (i_hi - i_lo + 1).saturating_mul(j_hi - j_lo + 1);
4131        if tile_count <= 0 || tile_count > MAX_TILING_CELLS {
4132            return false;
4133        }
4134        // The /BBox clip (pattern space) applied per tile.
4135        let bbox_clip = rect_path(pat.bbox[0], pat.bbox[1], pat.bbox[2], pat.bbox[3]);
4136        // An uncoloured (`/PaintType 2`) cell is a stencil poured with
4137        // the underlying colour the `scn` supplied (§8.7.3.3); default to
4138        // black when none was given. A coloured cell keeps its own paint.
4139        let stencil_color = if pat.paint_type == 2 {
4140            Some(self.fill_tiling_color.unwrap_or(Rgba::opaque(0, 0, 0)))
4141        } else {
4142            None
4143        };
4144        let mut tiles: Vec<Node> = Vec::new();
4145        for j in j_lo..=j_hi {
4146            for i in i_lo..=i_hi {
4147                let placement = compose(
4148                    pat.matrix,
4149                    Transform2D::translate(i as f32 * xstep, j as f32 * ystep),
4150                );
4151                let mut cell = pat.cell.clone();
4152                cell.transform = placement;
4153                // Clip the cell to its /BBox (pattern space).
4154                cell.clip = Some(bbox_clip.clone());
4155                if let Some(color) = stencil_color {
4156                    for child in &mut cell.children {
4157                        recolor_node(child, color);
4158                    }
4159                }
4160                tiles.push(Node::Group(cell));
4161            }
4162        }
4163        if tiles.is_empty() {
4164            return false;
4165        }
4166        // One group, clipped to the fill region (root-local space),
4167        // holding every tile. Pushed onto the root frame so the pattern
4168        // stays anchored to page space (§8.7.2 NOTE 1).
4169        let mut region_clip = region_path;
4170        // Preserve the fill rule on the clip path (NonZero vs EvenOdd
4171        // from the `f` / `f*` operator) per §8.5.3.3.
4172        let _ = rule;
4173        if region_clip.commands.is_empty() {
4174            return false;
4175        }
4176        // Intersect with any clip already in force on the current frame,
4177        // mapped to root space, by nesting: outer group carries the
4178        // active clip, inner the fill region. We keep it simple — the
4179        // fill region is the dominant clip for the tiling.
4180        let group = Group {
4181            transform: Transform2D::identity(),
4182            opacity: 1.0,
4183            clip: Some(std::mem::take(&mut region_clip)),
4184            children: tiles,
4185            ..Group::default()
4186        };
4187        self.stack[0].children.push(Node::Group(group));
4188        true
4189    }
4190
4191    /// Emit one [`ContentTextShow`] event for the current state. Only
4192    /// fired when `in_text_object` is `true` and the caller plumbed
4193    /// in `/Resources /Font` (so we have a meaningful font_dict to
4194    /// hand back); outside a `BT` or without font resources the show
4195    /// silently drops so the legacy `parse_content_stream` /
4196    /// `parse_content_stream_with_resources` callers don't see new
4197    /// behaviour.
4198    ///
4199    /// The position is the text-matrix origin `(e, f)` at the moment
4200    /// of the show — §9.4.4 Table 108's `Tm = [a b c d e f]`.
4201    fn emit_text_show(&mut self, bytes: Vec<u8>, operator: TextShowOp) {
4202        if !self.in_text_object || self.font_resources.is_none() {
4203            return;
4204        }
4205        let (font_name, font_size) = match &self.current_font {
4206            Some((n, s)) => (n.clone(), *s),
4207            None => (String::new(), 0.0),
4208        };
4209        let font_dict = match self.font_resources {
4210            Some(fr) if !font_name.is_empty() => lookup_dict(fr, &font_name).cloned(),
4211            _ => None,
4212        };
4213        self.text_shows.push(ContentTextShow {
4214            font_name,
4215            font_size,
4216            font_dict,
4217            bytes,
4218            position: (self.text_matrix.e, self.text_matrix.f),
4219            operator,
4220        });
4221    }
4222
4223    /// Resolve the currently-selected font (`Tf` name) into
4224    /// [`FontMetrics`]. Returns [`FontMetrics::None`] when no font is
4225    /// set, the name isn't in `/Resources /Font`, or the dict carries
4226    /// no resolvable width data.
4227    fn current_font_metrics(&self) -> FontMetrics {
4228        let name = match &self.current_font {
4229            Some((n, _)) if !n.is_empty() => n.as_str(),
4230            _ => return FontMetrics::None,
4231        };
4232        match self.font_resources.and_then(|fr| lookup_dict(fr, name)) {
4233            Some(d) => build_font_metrics(d),
4234            None => FontMetrics::None,
4235        }
4236    }
4237
4238    /// Advance the text matrix `Tm` by the displacement of every glyph
4239    /// in `bytes`, per §9.4.4. `metrics` supplies the per-glyph widths;
4240    /// the current `char_spacing` / `word_spacing` / `horiz_scale` /
4241    /// font size feed the displacement equation
4242    ///
4243    /// ```text
4244    /// tx = ((w0 − Tj/1000)·Tfs + Tc + Tw)·Th
4245    /// ```
4246    ///
4247    /// with `Tj = 0` (the per-element `TJ` kern is applied separately).
4248    /// `Tw` is added only for the single-byte code 32 (ASCII space) per
4249    /// §9.3.3. Word/char spacing and `Th` scaling are applied even when
4250    /// `metrics` is [`FontMetrics::None`] (`w0 = 0`), so spacing-only
4251    /// adjustments still move the origin.
4252    fn advance_text(&mut self, bytes: &[u8], metrics: &FontMetrics) {
4253        let tfs = self.current_font.as_ref().map(|(_, s)| *s).unwrap_or(0.0);
4254        let th = self.horiz_scale;
4255        let tc = self.char_spacing;
4256        // Convert a stored width to text-space units: 1/1000 for
4257        // Type1 / TrueType / composite, the Type 3 /FontMatrix scale for
4258        // a Type 3 font (§9.2.4 / §9.6.5).
4259        let scale = metrics.text_scale();
4260        if metrics.two_byte() {
4261            // Composite Identity font: each code is two bytes, CID =
4262            // code; Tw never applies to multi-byte codes (§9.3.3).
4263            let mut i = 0;
4264            while i + 1 < bytes.len() {
4265                let cid = ((bytes[i] as i64) << 8) | bytes[i + 1] as i64;
4266                let w0 = metrics.width(cid) * scale;
4267                let tx = (w0 * tfs + tc) * th;
4268                self.translate_text(tx);
4269                i += 2;
4270            }
4271        } else {
4272            for &b in bytes {
4273                let w0 = metrics.width(b as i64) * scale;
4274                let tw = if b == 32 { self.word_spacing } else { 0.0 };
4275                let tx = (w0 * tfs + tc + tw) * th;
4276                self.translate_text(tx);
4277            }
4278        }
4279    }
4280
4281    /// Paint a Type 3 font's glyphs for one shown byte string into the
4282    /// scene tree (§9.6.5). For each byte the walker resolves the glyph
4283    /// name via the font's `/Encoding`, looks the description `Group` up
4284    /// in `/CharProcs`, and splices it under the glyph's text-rendering
4285    /// matrix (§9.4.4):
4286    ///
4287    /// ```text
4288    /// T_rm = [ Tfs·Th  0      0 ]   [ a b 0 ]
4289    ///        [ 0       Tfs    0 ] × [ c d 0 ] × Tm    (then CTM on pop)
4290    ///        [ 0       Trise  1 ]   [ e f 1 ]
4291    /// ```
4292    ///
4293    /// where `[a b c d e f]` is the font's `/FontMatrix`. The frame's
4294    /// accumulated `cm` CTM is applied when the frame is popped, so the
4295    /// spliced group's transform is `Tm ∘ textState ∘ FontMatrix`. The
4296    /// text matrix is advanced separately by [`Self::advance_text`].
4297    ///
4298    /// Mode `3` (invisible, §9.3.6) paints nothing. A glyph name absent
4299    /// from `/Encoding` or `/CharProcs` paints nothing (§9.6.5 step b).
4300    /// Re-entrancy (a glyph that itself shows Type 3 text) is depth-
4301    /// bounded by `type3_depth`.
4302    fn paint_type3_show(&mut self, bytes: &[u8]) {
4303        // Invisible text-render mode shows no marks (§9.3.6 mode 3).
4304        if self.text_render_mode == 3 {
4305            return;
4306        }
4307        if self.type3_depth >= MAX_TYPE3_DEPTH {
4308            return;
4309        }
4310        let font_name = match &self.current_font {
4311            Some((n, _)) if !n.is_empty() => n.clone(),
4312            _ => return,
4313        };
4314        let font = match self.type3_fonts.and_then(|m| m.get(&font_name)) {
4315            Some(f) => f,
4316            None => return,
4317        };
4318        let tfs = self.current_font.as_ref().map(|(_, s)| *s).unwrap_or(0.0);
4319        let th = self.horiz_scale;
4320        let tc = self.char_spacing;
4321        let word_spacing = self.word_spacing;
4322        let rise = self.text_rise;
4323        // Per-show graphics-state scale (the text-rendering-matrix's
4324        // leftmost factor, §9.4.4). FontMatrix sits inside this; the
4325        // per-glyph text matrix outside; CTM (frame.transform) is applied
4326        // on frame pop.
4327        let text_state = Transform2D {
4328            a: tfs * th,
4329            b: 0.0,
4330            c: 0.0,
4331            d: tfs,
4332            e: 0.0,
4333            f: rise,
4334        };
4335        // Width metrics so the glyph origin advances *between* the bytes
4336        // of a single show (the caller's `advance_text` only moves
4337        // `self.text_matrix` once, after the whole string). We walk a
4338        // local running matrix so painting and the caller's advance stay
4339        // independent.
4340        let metrics = self.current_font_metrics();
4341        let scale = metrics.text_scale();
4342        // The current fill colour the graphics state supplies — a
4343        // shape-only (`d1`) glyph (§9.6.5 Table 113) is painted with this
4344        // colour rather than any colour baked into its description
4345        // (NOTE 2: the text-showing operators paint glyphs in the current
4346        // colour). Self-coloured (`d0`) glyphs keep their own colours.
4347        let fill_color = match &self.fill_paint {
4348            Some(Paint::Solid(c)) => *c,
4349            _ => Rgba::opaque(0, 0, 0),
4350        };
4351        let mut tm = self.text_matrix;
4352        // Build the spliceable glyph nodes while only `font` (an
4353        // immutable borrow of `self.type3_fonts`) is held, then splice
4354        // them in one pass — `self.current()` needs `&mut self`, which
4355        // can't overlap the `font` borrow. Only matched glyph groups are
4356        // cloned (not the whole font).
4357        let mut nodes: Vec<Node> = Vec::new();
4358        for &b in bytes {
4359            if let Some((glyph_name, glyph)) = font
4360                .encoding
4361                .get(&b)
4362                .and_then(|n| font.glyphs.get(n).map(|g| (n, g)))
4363            {
4364                if !glyph.children.is_empty() {
4365                    // group transform = Tm ∘ text_state ∘ FontMatrix
4366                    let outer = compose(tm, text_state);
4367                    let g_xform = compose(outer, font.font_matrix);
4368                    let mut children = glyph.children.clone();
4369                    // §9.6.5 — a `d1` shape-only glyph takes the current
4370                    // fill colour, not its own; recolour its paints.
4371                    if font.shape_only.contains(glyph_name) {
4372                        for child in &mut children {
4373                            recolor_node(child, fill_color);
4374                        }
4375                    }
4376                    nodes.push(Node::Group(Group {
4377                        transform: g_xform,
4378                        children,
4379                        ..Group::default()
4380                    }));
4381                }
4382            }
4383            // Advance the local running matrix by this glyph's
4384            // displacement (§9.4.4) so the next glyph in the string paints
4385            // at the right origin. `Tw` applies only to the single-byte
4386            // space (code 32, §9.3.3).
4387            let w0 = metrics.width(b as i64) * scale;
4388            let tw = if b == 32 { word_spacing } else { 0.0 };
4389            let tx = (w0 * tfs + tc + tw) * th;
4390            tm = compose(
4391                tm,
4392                Transform2D {
4393                    a: 1.0,
4394                    b: 0.0,
4395                    c: 0.0,
4396                    d: 1.0,
4397                    e: tx,
4398                    f: 0.0,
4399                },
4400            );
4401        }
4402        if nodes.is_empty() {
4403            return;
4404        }
4405        // The depth guard brackets the splice: a glyph description that
4406        // itself painted Type 3 text did so while building `glyph` (at
4407        // resolve time), so the runtime guard simply caps how deep a
4408        // single show's nodes nest before they're attached here.
4409        self.type3_depth += 1;
4410        self.current().children.extend(nodes);
4411        self.type3_depth -= 1;
4412    }
4413
4414    /// Translate the text matrix by `(tx, 0)` in text space — the
4415    /// horizontal-writing displacement of §9.4.4
4416    /// (`Tm_new = [1 0 0 1 tx 0] × Tm`).
4417    fn translate_text(&mut self, tx: f32) {
4418        let m = Transform2D {
4419            a: 1.0,
4420            b: 0.0,
4421            c: 0.0,
4422            d: 1.0,
4423            e: tx,
4424            f: 0.0,
4425        };
4426        self.text_matrix = compose(self.text_matrix, m);
4427    }
4428
4429    /// Apply the entries of a `/Type /ExtGState` parameter dictionary
4430    /// to the current state (Table 58). Only the keys whose effect
4431    /// fits the round-3 vector IR are honoured; the rest are silently
4432    /// ignored — the spec explicitly allows partial dicts ("any
4433    /// combination of parameter entries"). Values are cumulative —
4434    /// previous settings persist until explicitly overridden, matching
4435    /// the §8.4.5 "results of gs shall be cumulative" rule.
4436    fn apply_ext_gstate(&mut self, dict: &Dict) {
4437        for (k, v) in dict.entries() {
4438            match k.as_str() {
4439                "LW" => {
4440                    if let Some(n) = number_as_f32(v) {
4441                        self.stroke_width = n;
4442                    }
4443                }
4444                "LC" => {
4445                    if let Some(i) = number_as_i64(v) {
4446                        self.line_cap = match i {
4447                            0 => LineCap::Butt,
4448                            1 => LineCap::Round,
4449                            2 => LineCap::Square,
4450                            _ => self.line_cap,
4451                        };
4452                    }
4453                }
4454                "LJ" => {
4455                    if let Some(i) = number_as_i64(v) {
4456                        self.line_join = match i {
4457                            0 => LineJoin::Miter,
4458                            1 => LineJoin::Round,
4459                            2 => LineJoin::Bevel,
4460                            _ => self.line_join,
4461                        };
4462                    }
4463                }
4464                "ML" => {
4465                    if let Some(n) = number_as_f32(v) {
4466                        self.miter_limit = n;
4467                    }
4468                }
4469                "D" => {
4470                    // `[dashArray dashPhase]` two-element array — Table
4471                    // 58. Matches the `d` operator's pair shape.
4472                    if let Some((array, offset)) = parse_dash_pair(v) {
4473                        self.dash = if array.is_empty() {
4474                            None
4475                        } else {
4476                            Some(DashPattern { array, offset })
4477                        };
4478                    }
4479                }
4480                "CA" => {
4481                    if let Some(n) = number_as_f32(v) {
4482                        self.stroke_alpha = n.clamp(0.0, 1.0);
4483                    }
4484                }
4485                "ca" => {
4486                    if let Some(n) = number_as_f32(v) {
4487                        self.fill_alpha = n.clamp(0.0, 1.0);
4488                    }
4489                }
4490                // Tolerated-but-unhandled keys (Table 58):
4491                //   Type, RI, OP, op, OPM, Font, BG, BG2, UCR, UCR2,
4492                //   TR, TR2, HT, FL, SM, SA, BM, SMask, AIS, TK.
4493                _ => {}
4494            }
4495        }
4496    }
4497
4498    fn path_mut(&mut self) -> &mut Path {
4499        if self.current_path.is_none() {
4500            self.current_path = Some(Path::new());
4501        }
4502        self.current_path.as_mut().unwrap()
4503    }
4504
4505    fn take_numbers(&mut self, n: usize) -> Result<Vec<f32>, PdfError> {
4506        if self.operands.len() < n {
4507            return Err(PdfError::other(format!(
4508                "PDF content parser: operator needed {n} numeric operands, got {}",
4509                self.operands.len()
4510            )));
4511        }
4512        let split = self.operands.len() - n;
4513        let tail: Vec<Operand> = self.operands.drain(split..).collect();
4514        let mut out = Vec::with_capacity(n);
4515        for op in tail {
4516            match op {
4517                Operand::Number(f) => out.push(f),
4518                other => {
4519                    return Err(PdfError::other(format!(
4520                        "PDF content parser: expected numeric operand, got {other:?}"
4521                    )));
4522                }
4523            }
4524        }
4525        Ok(out)
4526    }
4527
4528    fn take_point(&mut self) -> Result<Point, PdfError> {
4529        let nums = self.take_numbers(2)?;
4530        Ok(Point::new(nums[0], nums[1]))
4531    }
4532
4533    /// Resolve an `sc`/`scn` (or `SC`/`SCN`) operand list into a
4534    /// [`Paint`] for the given colour space. Returns `None` when the
4535    /// space is `Unknown`, when a trailing `/Name` pattern operand is
4536    /// present (Pattern colour space, §8.7.3.3 — `c1 … cn /name scn`),
4537    /// or when the numeric-operand count doesn't match the device
4538    /// family's component count. In those cases the caller falls back
4539    /// to the conservative black behaviour.
4540    fn color_from_components(&self, cs: &ColorSpaceKind) -> Option<Paint> {
4541        let want = cs.components()?;
4542        // A trailing `/Name` operand marks a Pattern fill — no device
4543        // colour to read.
4544        if matches!(self.operands.last(), Some(Operand::Name(_))) {
4545            return None;
4546        }
4547        // Count the trailing numeric operands.
4548        let nums: Vec<f32> = self
4549            .operands
4550            .iter()
4551            .rev()
4552            .take_while(|o| matches!(o, Operand::Number(_)))
4553            .filter_map(|o| match o {
4554                Operand::Number(n) => Some(*n),
4555                _ => None,
4556            })
4557            .collect();
4558        if nums.len() < want {
4559            return None;
4560        }
4561        // `nums` was collected reversed; take the last `want` of them
4562        // in stream order.
4563        let comps: Vec<f32> = nums.iter().take(want).rev().copied().collect();
4564        Some(match cs {
4565            ColorSpaceKind::DeviceGray => Paint::Solid(rgb_from_unit(comps[0], comps[0], comps[0])),
4566            ColorSpaceKind::DeviceRgb => Paint::Solid(rgb_from_unit(comps[0], comps[1], comps[2])),
4567            ColorSpaceKind::DeviceCmyk => {
4568                Paint::Solid(rgb_from_cmyk(comps[0], comps[1], comps[2], comps[3]))
4569            }
4570            ColorSpaceKind::Indexed { base, hival, table } => {
4571                return indexed_color(base, *hival, table, comps[0])
4572            }
4573            ColorSpaceKind::Separation {
4574                alt,
4575                tint,
4576                none_colorant,
4577            } => return separation_color(alt, tint, *none_colorant, comps[0]),
4578            ColorSpaceKind::DeviceN {
4579                alt,
4580                tint,
4581                all_none,
4582                ..
4583            } => return device_n_color(alt, tint, *all_none, &comps),
4584            ColorSpaceKind::CalGray { .. }
4585            | ColorSpaceKind::CalRgb { .. }
4586            | ColorSpaceKind::Lab { .. } => return cie_color(cs, &comps),
4587            ColorSpaceKind::Unknown => unreachable!("components() returned Some"),
4588        })
4589    }
4590
4591    /// Pop the trailing `/Name` operand of a `cs` / `CS` operator and
4592    /// map it to a tracked colour space, consulting the page's
4593    /// `/Resources /ColorSpace` subdictionary (round 275) for
4594    /// non-device names. A `cs` with no name operand (malformed) leaves
4595    /// the space `Unknown`.
4596    fn take_color_space_name(&mut self) -> ColorSpaceKind {
4597        match self.operands.last() {
4598            Some(Operand::Name(n)) => {
4599                ColorSpaceKind::resolve_with_resources(n, self.color_space_resources)
4600            }
4601            _ => ColorSpaceKind::Unknown,
4602        }
4603    }
4604
4605    /// Resolve a `scn`/`SCN` trailing `/Name` operand as a shading
4606    /// pattern (§8.7.3.3 + §8.7.4.5) and, when it is a `/PatternType 2`
4607    /// pattern carrying an axial / radial `/Shading`, return the
4608    /// equivalent scene gradient [`Paint`]. The shading's `Coords` are
4609    /// mapped from pattern space into device space through the pattern's
4610    /// `/Matrix` composed with the current CTM (§8.7.3.1: a pattern's
4611    /// matrix maps pattern space to the default coordinate space of the
4612    /// page, then the CTM in effect applies). Returns `None` when no
4613    /// pattern resources are plumbed in, the name isn't a renderable
4614    /// shading pattern, or the shading is function-based / mesh (no
4615    /// linear/radial scene analogue).
4616    fn pattern_paint_from_operand(&self) -> Option<Paint> {
4617        let name = match self.operands.last() {
4618            Some(Operand::Name(n)) => n.as_str(),
4619            _ => return None,
4620        };
4621        let pat = lookup_dict(self.pattern_resources?, name)?;
4622        let get = |k: &str| pat.entries().iter().find(|(kk, _)| kk == k).map(|(_, v)| v);
4623        // Only shading patterns (PatternType 2) map to a scene gradient.
4624        if get("PatternType").and_then(number_as_i64) != Some(2) {
4625            return None;
4626        }
4627        let Some(Object::Dict(shading)) = get("Shading") else {
4628            return None;
4629        };
4630        let gradient = evaluate_gradient_shading(shading, self.color_space_resources)?;
4631        let pattern_matrix = match get("Matrix").and_then(read_num_array) {
4632            Some(m) if m.len() == 6 => Transform2D {
4633                a: m[0],
4634                b: m[1],
4635                c: m[2],
4636                d: m[3],
4637                e: m[4],
4638                f: m[5],
4639            },
4640            // A malformed Matrix is rejected; an absent one defaults to
4641            // identity (§8.7.3.1).
4642            Some(_) => return None,
4643            None => Transform2D::identity(),
4644        };
4645        let to_target = compose(self.effective_ctm(), pattern_matrix);
4646        gradient_to_paint(&gradient, to_target)
4647    }
4648
4649    /// If the trailing `scn`/`SCN` operand names a pre-parsed
4650    /// `/PatternType 1` tiling pattern (§8.7.3), return its name so
4651    /// `commit_path` can replicate the cell across the painted region.
4652    /// Returns `None` when no tiling patterns are plumbed in or the
4653    /// operand isn't a tiling-pattern name (e.g. a numeric colour, a
4654    /// shading-pattern name, or an unknown name).
4655    fn tiling_pattern_name_from_operand(&self) -> Option<String> {
4656        let name = match self.operands.last() {
4657            Some(Operand::Name(n)) => n.as_str(),
4658            _ => return None,
4659        };
4660        if self.tiling_patterns?.contains_key(name) {
4661            Some(name.to_string())
4662        } else {
4663            None
4664        }
4665    }
4666
4667    /// For an *uncoloured* (`/PaintType 2`) tiling pattern named `name`,
4668    /// read the underlying colour the cell stencil is poured with from
4669    /// the numeric operands a `scn`/`SCN` supplies before the pattern
4670    /// name (§8.7.3.3). The underlying space is the second element of the
4671    /// `[/Pattern base]` colour space; this round reads it by component
4672    /// count (1 → DeviceGray, 3 → DeviceRGB, 4 → DeviceCMYK), which
4673    /// covers the device underlying spaces. Returns `None` for a coloured
4674    /// (`/PaintType 1`) pattern, when no numeric operands precede the
4675    /// name, or when the count isn't a device arity.
4676    fn uncoloured_tiling_color(&self, name: &str) -> Option<Rgba> {
4677        let pat = self.tiling_patterns?.get(name)?;
4678        if pat.paint_type != 2 {
4679            return None;
4680        }
4681        // The operands are `[c0 .. cn] /Pname`; collect the numeric
4682        // components that precede the trailing name.
4683        let comps: Vec<f32> = self
4684            .operands
4685            .iter()
4686            .filter_map(|o| match o {
4687                Operand::Number(n) => Some(*n),
4688                _ => None,
4689            })
4690            .collect();
4691        let paint = match comps.len() {
4692            1 => Paint::Solid(rgb_from_unit(comps[0], comps[0], comps[0])),
4693            3 => Paint::Solid(rgb_from_unit(comps[0], comps[1], comps[2])),
4694            4 => Paint::Solid(rgb_from_cmyk(comps[0], comps[1], comps[2], comps[3])),
4695            _ => return None,
4696        };
4697        match paint {
4698            Paint::Solid(c) => Some(c),
4699            _ => None,
4700        }
4701    }
4702
4703    fn parse(&mut self, input: &[u8]) -> Result<(), PdfError> {
4704        let mut i = 0;
4705        while i < input.len() {
4706            let b = input[i];
4707            if is_whitespace(b) {
4708                i += 1;
4709                continue;
4710            }
4711            if b == b'%' {
4712                // Comment to end of line.
4713                while i < input.len() && input[i] != b'\n' && input[i] != b'\r' {
4714                    i += 1;
4715                }
4716                continue;
4717            }
4718            if b == b'(' {
4719                // Literal-string operand — keep the escape-decoded
4720                // bytes (`Tj` / `'` / `"` consume them).
4721                let (end, bytes) = read_literal_string(input, i)?;
4722                self.operands.push(Operand::String(bytes));
4723                i = end;
4724                continue;
4725            }
4726            if b == b'<' && input.get(i + 1) == Some(&b'<') {
4727                // Inline dictionary `<< … >>` operand — the §14.6.2
4728                // property list a `DP`/`BDC` may carry directly. Reuse
4729                // the object parser over the tail so nested arrays /
4730                // dicts / strings inside the property list are handled
4731                // by the same battle-tested code the body parser uses;
4732                // `position()` tells us how many bytes it consumed.
4733                let mut p = crate::reader::parse::Parser::new(&input[i..]);
4734                match p.parse_object() {
4735                    Ok(Some(Object::Dict(d))) => {
4736                        self.operands.push(Operand::Dict(d));
4737                        i += p.position();
4738                        continue;
4739                    }
4740                    // A malformed or non-dict `<<` — skip the opening
4741                    // delimiter and resync rather than abort the whole
4742                    // stream (mirrors the round-3 salvage stance).
4743                    _ => {
4744                        i += 2;
4745                        continue;
4746                    }
4747                }
4748            }
4749            if b == b'<' && input.get(i + 1) != Some(&b'<') {
4750                // Hex-string operand — decode pairs into bytes.
4751                let (end, bytes) = read_hex_string(input, i)?;
4752                self.operands.push(Operand::String(bytes));
4753                i = end;
4754                continue;
4755            }
4756            if b == b'[' {
4757                // Array operand — for the dash array `[5 3] 0 d`
4758                // (numbers only), the `TJ` operator `[(s1) num1 …]`
4759                // (strings + numbers), and any other inline array.
4760                let (end, items) = read_array(input, i)?;
4761                self.operands.push(Operand::Array(items));
4762                i = end;
4763                continue;
4764            }
4765            if b == b'/' {
4766                // Name operand.
4767                let mut end = i + 1;
4768                while end < input.len() && !is_whitespace(input[end]) && !is_delimiter(input[end]) {
4769                    end += 1;
4770                }
4771                // We don't bother decoding #xx in content-stream
4772                // names — round-3 callers never produce such names.
4773                let name = String::from_utf8_lossy(&input[i + 1..end]).into_owned();
4774                self.operands.push(Operand::Name(name));
4775                i = end;
4776                continue;
4777            }
4778            if matches!(b, b'+' | b'-' | b'.' | b'0'..=b'9') {
4779                // Number operand — exact-arithmetic fast conversion
4780                // with a `str::parse` fallback for out-of-range
4781                // significands (see `scan_number`).
4782                match scan_number(input, i) {
4783                    NumScan::Fast(end, f) => {
4784                        self.operands.push(Operand::Number(f));
4785                        i = end;
4786                        continue;
4787                    }
4788                    NumScan::Slow(end) => {
4789                        let s = str::from_utf8(&input[i..end]).map_err(|_| {
4790                            PdfError::other(format!(
4791                                "PDF content parser: non-UTF-8 number at byte {i}"
4792                            ))
4793                        })?;
4794                        let f: f32 = s.parse().map_err(|_| {
4795                            PdfError::other(format!(
4796                                "PDF content parser: invalid number `{s}` at byte {i}"
4797                            ))
4798                        })?;
4799                        self.operands.push(Operand::Number(f));
4800                        i = end;
4801                        continue;
4802                    }
4803                    NumScan::NotANumber => {
4804                        // Bare sign / dot — fall through to keyword
4805                        // handling.
4806                        let kw_end = scan_keyword_end(input, i);
4807                        let kw = &input[i..kw_end];
4808                        self.dispatch(kw)?;
4809                        i = kw_end;
4810                        continue;
4811                    }
4812                }
4813            }
4814            // Anything else is a keyword (operator).
4815            let kw_end = scan_keyword_end(input, i);
4816            if kw_end == i {
4817                // Unrecognised single byte — skip to avoid infinite
4818                // loop.
4819                i += 1;
4820                continue;
4821            }
4822            let kw = &input[i..kw_end];
4823            // `BI` opens an inline image (§8.9.7). Its raw payload
4824            // between `ID` and `EI` can contain *any* bytes — including
4825            // sequences that look like operators / numbers — so it must
4826            // be consumed by the inline-image framer rather than tokenized
4827            // by this loop. We hand the framer the bytes from just past
4828            // `BI` and resume past the framer's reported `EI`.
4829            if kw == b"BI" {
4830                i = self.consume_inline_image(input, kw_end);
4831                continue;
4832            }
4833            self.dispatch(kw)?;
4834            i = kw_end;
4835        }
4836        Ok(())
4837    }
4838
4839    /// Consume a `BI … ID … EI` inline image (§8.9.7) starting just past
4840    /// the `BI` keyword (`after_bi`). Records a [`ContentInlineImage`]
4841    /// event with the CTM + clip in force, and returns the byte offset
4842    /// to resume parsing at (just past `EI`).
4843    ///
4844    /// The pre-`BI` operands are cleared (an inline image takes no
4845    /// operands; any stragglers are tolerated and dropped). On a
4846    /// malformed dictionary the framer returns an error — we salvage by
4847    /// scanning forward to the next `EI` so the rest of the content
4848    /// stream still parses, rather than aborting the whole document.
4849    fn consume_inline_image(&mut self, input: &[u8], after_bi: usize) -> usize {
4850        self.operands.clear();
4851        match parse_one_inline_image(input, after_bi) {
4852            Ok((image, resume)) => {
4853                let ctm = self.effective_ctm();
4854                let clip = self.current_clip();
4855                self.inline_images
4856                    .push(ContentInlineImage { image, ctm, clip });
4857                resume
4858            }
4859            // Salvage: skip to the next `EI` (past it) so the trailing
4860            // content stream survives a malformed inline-image dict.
4861            Err(_) => match find_inline_image_ei(input, after_bi) {
4862                Some(ei) => ei + 2,
4863                // No `EI` at all — consume the rest of the stream.
4864                None => input.len(),
4865            },
4866        }
4867    }
4868}
4869
4870impl Frame {
4871    fn new() -> Self {
4872        Self {
4873            transform: Transform2D::identity(),
4874            children: Vec::new(),
4875            clip: None,
4876        }
4877    }
4878
4879    fn is_effectively_empty(&self) -> bool {
4880        self.children.is_empty() && self.clip.is_none() && self.transform.is_identity()
4881    }
4882}
4883
4884// ───────────────────────── helpers ─────────────────────────
4885
4886fn is_whitespace(b: u8) -> bool {
4887    matches!(b, 0x00 | b'\t' | b'\n' | 0x0C | b'\r' | b' ')
4888}
4889
4890fn is_delimiter(b: u8) -> bool {
4891    matches!(
4892        b,
4893        b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%'
4894    )
4895}
4896
4897fn rgb_from_unit(r: f32, g: f32, b: f32) -> Rgba {
4898    Rgba::opaque(unit_to_byte(r), unit_to_byte(g), unit_to_byte(b))
4899}
4900
4901/// The current colour established by a bare `cs` / `CS` before any
4902/// `sc`/`scn`. Per §8.6.4.2..4 setting a device colour space
4903/// initialises the colour to its 0.0 value (black for Gray/RGB,
4904/// `0 0 0 1`-equivalent — also black — for CMYK). For an unresolved
4905/// space we leave the paint cleared so the existing black fallback in
4906/// `commit_path` applies if nothing further is set.
4907fn initial_color_for(cs: &ColorSpaceKind) -> Option<Paint> {
4908    match cs {
4909        ColorSpaceKind::DeviceGray | ColorSpaceKind::DeviceRgb | ColorSpaceKind::DeviceCmyk => {
4910            Some(Paint::Solid(Rgba::opaque(0, 0, 0)))
4911        }
4912        // §8.6.6.3: "Setting the current … colour space to an Indexed
4913        // colour space shall initialize the corresponding current
4914        // colour to 0" — i.e. table entry 0.
4915        ColorSpaceKind::Indexed { base, hival, table } => indexed_color(base, *hival, table, 0.0),
4916        // §8.6.6.4: "The initial value for both the stroking and
4917        // nonstroking colour in the graphics state shall be 1.0" — i.e.
4918        // the maximum tint, evaluated through the tint transform.
4919        ColorSpaceKind::Separation {
4920            alt,
4921            tint,
4922            none_colorant,
4923        } => separation_color(alt, tint, *none_colorant, 1.0),
4924        // §8.6.6.5: "each component shall be given an initial value of
4925        // 1.0" — the maximum tint on every colorant, run through the
4926        // tint transform.
4927        ColorSpaceKind::DeviceN {
4928            n_in,
4929            alt,
4930            tint,
4931            all_none,
4932        } => device_n_color(alt, tint, *all_none, &vec![1.0; *n_in]),
4933        // §8.6.5: "Setting the current stroking or nonstroking colour
4934        // space to any CIE-based colour space shall initialize all
4935        // components of the corresponding current colour to 0.0." For
4936        // Lab the a*/b* zero is clamped into Range by `cie_color`.
4937        ColorSpaceKind::CalGray { .. } => cie_color(cs, &[0.0]),
4938        ColorSpaceKind::CalRgb { .. } | ColorSpaceKind::Lab { .. } => {
4939            cie_color(cs, &[0.0, 0.0, 0.0])
4940        }
4941        ColorSpaceKind::Unknown => None,
4942    }
4943}
4944
4945/// Resolve a single `sc`/`scn` index against an `/Indexed` colour
4946/// table per ISO 32000-1 §8.6.6.3. The `index` operand is rounded to
4947/// the nearest integer and clamped into `0..=hival` ("If the value is
4948/// a real number, it shall be rounded to the nearest integer; if it is
4949/// outside the range 0 to hival, it shall be adjusted to the nearest
4950/// value within that range"). Each of the base space's `m` components
4951/// is one table byte scaled `0..255 → 0.0..1.0`, then mapped to RGB
4952/// through the base device family. Returns `None` when the table is
4953/// too short to hold the selected entry (a malformed/truncated lookup)
4954/// so the conservative black fallback applies.
4955fn indexed_color(base: &ColorSpaceKind, hival: u32, table: &[u8], index: f32) -> Option<Paint> {
4956    let m = base.components()?;
4957    // Round to nearest, clamp into [0, hival].
4958    let idx = if index.is_finite() {
4959        let r = index.round();
4960        r.clamp(0.0, hival as f32) as u32
4961    } else {
4962        0
4963    };
4964    let start = (idx as usize).checked_mul(m)?;
4965    let entry = table.get(start..start + m)?;
4966    let unit = |i: usize| entry[i] as f32 / 255.0;
4967    Some(match base {
4968        ColorSpaceKind::DeviceGray => Paint::Solid(rgb_from_unit(unit(0), unit(0), unit(0))),
4969        ColorSpaceKind::DeviceRgb => Paint::Solid(rgb_from_unit(unit(0), unit(1), unit(2))),
4970        ColorSpaceKind::DeviceCmyk => {
4971            Paint::Solid(rgb_from_cmyk(unit(0), unit(1), unit(2), unit(3)))
4972        }
4973        // §8.6.6.3: a CIE-based base is permitted. Each table byte is
4974        // decoded `0..255 → 0.0..1.0` then mapped into the base
4975        // component's own range before the CIE → RGB transform. CalGray
4976        // / CalRGB components already lie in 0.0..1.0; for Lab the L*
4977        // component spans 0..100 and a*/b* span the space's `range`.
4978        ColorSpaceKind::CalGray { .. } | ColorSpaceKind::CalRgb { .. } => {
4979            cie_color(base, &(0..m).map(unit).collect::<Vec<_>>())?
4980        }
4981        ColorSpaceKind::Lab { range, .. } => {
4982            let l = unit(0) * 100.0;
4983            let a = range[0] + unit(1) * (range[1] - range[0]);
4984            let b = range[2] + unit(2) * (range[3] - range[2]);
4985            cie_color(base, &[l, a, b])?
4986        }
4987        // An `Indexed`, `Separation`, or `DeviceN` base is forbidden by
4988        // §8.6.6.3 (and rejected by `indexed_from_array`), so these are
4989        // unreachable in practice; fall back to black for total safety.
4990        ColorSpaceKind::Indexed { .. }
4991        | ColorSpaceKind::Separation { .. }
4992        | ColorSpaceKind::DeviceN { .. }
4993        | ColorSpaceKind::Unknown => Paint::Solid(Rgba::opaque(0, 0, 0)),
4994    })
4995}
4996
4997/// Resolve a single `sc`/`scn` tint operand against a `/Separation`
4998/// colour space per ISO 32000-1 §8.6.6.4. The tint is clamped into the
4999/// `0.0..=1.0` colour range, run through the tint-transform function to
5000/// produce the alternate space's component values, then those
5001/// components are rendered to RGB through the alternate device family.
5002///
5003/// A `/None` colorant produces no visible output (`None` paint, so the
5004/// caller leaves the path unpainted). A component-count mismatch between
5005/// the tint transform's output and the alternate family — a malformed
5006/// space — yields `None` (conservative black fallback).
5007fn separation_color(
5008    alt: &ColorSpaceKind,
5009    tint: &PdfFunction,
5010    none_colorant: bool,
5011    tint_value: f32,
5012) -> Option<Paint> {
5013    if none_colorant {
5014        return None;
5015    }
5016    let t = tint_value.clamp(0.0, 1.0);
5017    let comps = tint.eval(t);
5018    paint_from_alt_components(alt, &comps)
5019}
5020
5021/// Render a tint-transform output vector through a Separation / DeviceN
5022/// *alternate* colour space (§8.6.6.4–5). The alternate may be a device
5023/// family or a CIE-based family (CalGray / CalRGB / Lab) — both are
5024/// rendered to RGB; the Lab alternate interprets its three components as
5025/// an L*a*b* triple (the tint transform is responsible for emitting them
5026/// in the alternate's own range). Returns `None` for an arity mismatch
5027/// or a non-renderable alternate, preserving the conservative black
5028/// fallback.
5029fn paint_from_alt_components(alt: &ColorSpaceKind, comps: &[f32]) -> Option<Paint> {
5030    match alt {
5031        ColorSpaceKind::DeviceGray | ColorSpaceKind::DeviceRgb | ColorSpaceKind::DeviceCmyk => {
5032            paint_from_device_components(alt, comps)
5033        }
5034        ColorSpaceKind::CalGray { .. }
5035        | ColorSpaceKind::CalRgb { .. }
5036        | ColorSpaceKind::Lab { .. } => cie_color(alt, comps),
5037        _ => None,
5038    }
5039}
5040
5041/// Resolve an `sc`/`scn` tint-component vector against a `/DeviceN`
5042/// colour space per ISO 32000-1 §8.6.6.5. Each of the `n_in` tints is
5043/// clamped into `0.0..=1.0`, the whole vector is run through the n-in /
5044/// m-out tint-transform function to produce the alternate space's `m`
5045/// component values, and those are rendered to RGB through the alternate
5046/// device family.
5047///
5048/// An all-`/None` space (`all_none`) discards its output (no paint, like
5049/// a Separation `/None` colorant). A component-count mismatch between the
5050/// tint transform's output and the alternate family yields `None`
5051/// (conservative black fallback) — though `device_n_from_array` already
5052/// validates the arity at resolve time, so this is defence in depth.
5053fn device_n_color(
5054    alt: &ColorSpaceKind,
5055    tint: &PdfFunction,
5056    all_none: bool,
5057    tints: &[f32],
5058) -> Option<Paint> {
5059    if all_none {
5060        return None;
5061    }
5062    let clamped: Vec<f32> = tints.iter().map(|t| t.clamp(0.0, 1.0)).collect();
5063    let comps = tint.eval_n(&clamped);
5064    paint_from_alt_components(alt, &comps)
5065}
5066
5067/// Resolve an `sc`/`scn` component vector against a CIE-based colour
5068/// space (CalGray §8.6.5.2, CalRGB §8.6.5.3, Lab §8.6.5.4) to a
5069/// [`Paint`]. `comps` carries one component for CalGray, three for
5070/// CalRGB / Lab. Lab's a*/b* operands are clamped into the space's
5071/// `range` "without error indication" (§8.6.5.4 Range). Returns `None`
5072/// for a component-count mismatch or a non-CIE space.
5073fn cie_color(cs: &ColorSpaceKind, comps: &[f32]) -> Option<Paint> {
5074    match cs {
5075        ColorSpaceKind::CalGray { white, gamma } if comps.len() == 1 => {
5076            Some(Paint::Solid(cal_gray_color(*white, *gamma, comps[0])))
5077        }
5078        ColorSpaceKind::CalRgb { gamma, matrix } if comps.len() == 3 => Some(Paint::Solid(
5079            cal_rgb_color(*gamma, *matrix, [comps[0], comps[1], comps[2]]),
5080        )),
5081        ColorSpaceKind::Lab { white, range } if comps.len() == 3 => {
5082            let a = comps[1].clamp(range[0], range[1]);
5083            let b = comps[2].clamp(range[2], range[3]);
5084            Some(Paint::Solid(lab_color(*white, [comps[0], a, b])))
5085        }
5086        _ => None,
5087    }
5088}
5089
5090/// Render a device colour space's component values to a [`Paint`].
5091/// Returns `None` when the count doesn't match the family's arity (a
5092/// non-device family is also rejected — only the three device families
5093/// have a direct component → RGB mapping).
5094fn paint_from_device_components(cs: &ColorSpaceKind, comps: &[f32]) -> Option<Paint> {
5095    match cs {
5096        ColorSpaceKind::DeviceGray if comps.len() == 1 => {
5097            Some(Paint::Solid(rgb_from_unit(comps[0], comps[0], comps[0])))
5098        }
5099        ColorSpaceKind::DeviceRgb if comps.len() == 3 => {
5100            Some(Paint::Solid(rgb_from_unit(comps[0], comps[1], comps[2])))
5101        }
5102        ColorSpaceKind::DeviceCmyk if comps.len() == 4 => Some(Paint::Solid(rgb_from_cmyk(
5103            comps[0], comps[1], comps[2], comps[3],
5104        ))),
5105        _ => None,
5106    }
5107}
5108
5109// ───────────────── mesh shadings (§8.7.4.5.5–8) ──────────────────
5110
5111/// Convert a `count`-component colour value in colour space `cs` to a
5112/// device-RGB [`Rgba`]. Reuses the same colour-space machinery the
5113/// `sc`/`scn` path uses (device families, `Indexed` table lookup,
5114/// `Separation` / `DeviceN` tint transforms), so a mesh vertex / patch
5115/// corner colour is reduced exactly as a fill colour would be. Returns
5116/// `None` for an `Unknown` space or a component-count mismatch.
5117fn rgba_from_components(cs: &ColorSpaceKind, comps: &[f32]) -> Option<Rgba> {
5118    let paint = match cs {
5119        ColorSpaceKind::DeviceGray | ColorSpaceKind::DeviceRgb | ColorSpaceKind::DeviceCmyk => {
5120            paint_from_device_components(cs, comps)?
5121        }
5122        ColorSpaceKind::Indexed { base, hival, table } => {
5123            indexed_color(base, *hival, table, *comps.first()?)?
5124        }
5125        ColorSpaceKind::Separation {
5126            alt,
5127            tint,
5128            none_colorant,
5129        } => separation_color(alt, tint, *none_colorant, *comps.first()?)?,
5130        ColorSpaceKind::DeviceN {
5131            alt,
5132            tint,
5133            all_none,
5134            ..
5135        } => device_n_color(alt, tint, *all_none, comps)?,
5136        ColorSpaceKind::CalGray { .. }
5137        | ColorSpaceKind::CalRgb { .. }
5138        | ColorSpaceKind::Lab { .. } => cie_color(cs, comps)?,
5139        ColorSpaceKind::Unknown => return None,
5140    };
5141    match paint {
5142        Paint::Solid(rgba) => Some(rgba),
5143        _ => None,
5144    }
5145}
5146
5147/// A little-endian-free MSB-first bit reader over a mesh stream body.
5148/// `read(bits)` pulls the next `bits` bits, most-significant first
5149/// (§8.7.4.5.5: "reading in sequence from higher-order to lower-order
5150/// bit positions"); `align_byte` discards the remaining bits of the
5151/// current byte (each vertex / patch element occupies a whole number of
5152/// bytes — the trailing pad bits "shall be ignored").
5153struct BitReader<'a> {
5154    data: &'a [u8],
5155    bit_pos: usize,
5156}
5157
5158impl<'a> BitReader<'a> {
5159    fn new(data: &'a [u8]) -> Self {
5160        Self { data, bit_pos: 0 }
5161    }
5162
5163    /// Read `bits` (1..=32) bits MSB-first as an unsigned integer.
5164    /// Returns `None` when the stream is exhausted.
5165    fn read(&mut self, bits: u32) -> Option<u64> {
5166        let mut code: u64 = 0;
5167        for _ in 0..bits {
5168            let byte = *self.data.get(self.bit_pos / 8)?;
5169            let bit = (byte >> (7 - (self.bit_pos % 8) as u32)) & 1;
5170            code = (code << 1) | (bit as u64);
5171            self.bit_pos += 1;
5172        }
5173        Some(code)
5174    }
5175
5176    /// Advance to the next byte boundary (each mesh element — vertex or
5177    /// patch — is byte-aligned, §8.7.4.5.5).
5178    fn align_byte(&mut self) {
5179        if self.bit_pos % 8 != 0 {
5180            self.bit_pos = self.bit_pos.div_ceil(8) * 8;
5181        }
5182    }
5183
5184    /// `true` once the reader has consumed at least one whole byte and
5185    /// no further byte-aligned element can be read (used to terminate a
5186    /// patch / triangle stream that provides a whole number of elements).
5187    fn at_end(&self) -> bool {
5188        self.bit_pos / 8 >= self.data.len()
5189    }
5190}
5191
5192/// Decode an integer coordinate / colour code in `[0, 2^bits − 1]` to
5193/// its target value via the §8.9.5.2 `Decode` linear map (the same
5194/// `Interpolate` the image Decode array uses).
5195fn decode_value(code: u64, bits: u32, dmin: f32, dmax: f32) -> f32 {
5196    let max_code = if bits >= 32 {
5197        u32::MAX as f32
5198    } else {
5199        ((1u64 << bits) - 1) as f32
5200    };
5201    if max_code == 0.0 {
5202        return dmin;
5203    }
5204    dmin + (code as f32) * (dmax - dmin) / max_code
5205}
5206
5207/// Parse + evaluate a Type 4–7 (mesh) shading dictionary (§8.7.4.5.5–8)
5208/// into its device-space [`MeshShading`] geometry. Returns `None` for a
5209/// Type 1–3 shading, a missing / malformed stream body (the
5210/// `resolve_shading_resources` `__MeshData` fold), an unresolved colour
5211/// space, or any structural error in the bit-packed stream. The colour
5212/// at each vertex / corner is reduced to device RGB through the
5213/// shading's `ColorSpace` and (when present) its parametric `Function`.
5214/// Crate-internal test accessor for [`evaluate_mesh_shading`] so the
5215/// `document` module's integration test can drive the evaluator over a
5216/// dict its `resolve_shading_resources` produced.
5217#[cfg(test)]
5218pub(crate) fn evaluate_mesh_shading_for_test(dict: &Dict) -> Option<MeshShading> {
5219    evaluate_mesh_shading(dict, None)
5220}
5221
5222/// Resolve a shading dictionary's `/ColorSpace` entry to a tracked
5223/// [`ColorSpaceKind`] (§8.7.4.5.2). The entry is usually an inline array
5224/// (`[/CalRGB …]`, `[/ICCBased …]`, a device name, …) which
5225/// [`color_space_from_object`] interprets directly. PDF also permits a
5226/// shading's `/ColorSpace` to be a *name* referring to the page's
5227/// `/Resources /ColorSpace` subdictionary; when the object is a bare
5228/// non-device name and `color_space_resources` is plumbed in, the name
5229/// is resolved through it (the same path `cs`/`CS` uses).
5230fn shading_color_space(obj: &Object, color_space_resources: Option<&Dict>) -> ColorSpaceKind {
5231    if let Object::Name(n) = obj {
5232        return ColorSpaceKind::resolve_with_resources(n, color_space_resources);
5233    }
5234    color_space_from_object(obj)
5235}
5236
5237fn evaluate_mesh_shading(dict: &Dict, color_space_resources: Option<&Dict>) -> Option<MeshShading> {
5238    let get = |key: &str| {
5239        dict.entries()
5240            .iter()
5241            .find(|(k, _)| k == key)
5242            .map(|(_, v)| v)
5243    };
5244    let shading_type = get("ShadingType").and_then(number_as_i64)?;
5245    if !(4..=7).contains(&shading_type) {
5246        return None;
5247    }
5248    let cs = shading_color_space(get("ColorSpace")?, color_space_resources);
5249    if cs == ColorSpaceKind::Unknown {
5250        return None;
5251    }
5252    // §8.7.4.5.5: the colour-component count comes from the colour
5253    // space — unless a `/Function` entry is present, in which case the
5254    // stream carries a single parametric value `t` per vertex / corner
5255    // and the function maps it to the space's components.
5256    let func = parse_shading_function(get("Function"));
5257    let n_color = if func.is_some() { 1 } else { cs.components()? };
5258    let bits_coord = get("BitsPerCoordinate").and_then(number_as_i64)? as u32;
5259    if !matches!(bits_coord, 1 | 2 | 4 | 8 | 12 | 16 | 24 | 32) {
5260        return None;
5261    }
5262    let bits_comp = get("BitsPerComponent").and_then(number_as_i64)? as u32;
5263    if !matches!(bits_comp, 1 | 2 | 4 | 8 | 12 | 16) {
5264        return None;
5265    }
5266    let decode = get("Decode").and_then(read_num_array)?;
5267    // Decode: [ xmin xmax ymin ymax c1min c1max … ]. Two coordinate
5268    // pairs + one pair per colour component.
5269    if decode.len() != 4 + 2 * n_color {
5270        return None;
5271    }
5272    let raw = match get("__MeshData") {
5273        Some(Object::HexString(bytes)) => bytes.as_slice(),
5274        _ => return None,
5275    };
5276    // §8.7.4.5.5 / §8.7.4.5.7: `BitsPerFlag` is required for Type 4
5277    // (free-form triangle mesh) and Type 6/7 (patch meshes); Type 5
5278    // (lattice) carries no edge flags, so its flag width is irrelevant.
5279    let bits_flag = if shading_type == 5 {
5280        0
5281    } else {
5282        let bf = get("BitsPerFlag").and_then(number_as_i64)? as u32;
5283        if !matches!(bf, 2 | 4 | 8) {
5284            return None;
5285        }
5286        bf
5287    };
5288    let evaluator = MeshEvaluator {
5289        cs: &cs,
5290        func: func.as_ref(),
5291        n_color,
5292        bits_coord,
5293        bits_comp,
5294        bits_flag,
5295        decode: &decode,
5296    };
5297    match shading_type {
5298        4 => evaluator.eval_type4(raw),
5299        5 => {
5300            let vpr = get("VerticesPerRow").and_then(number_as_i64)?;
5301            if vpr < 2 {
5302                return None;
5303            }
5304            evaluator.eval_type5(raw, vpr as usize)
5305        }
5306        6 => evaluator.eval_patch(raw, bits_flag, false),
5307        7 => evaluator.eval_patch(raw, bits_flag, true),
5308        _ => None,
5309    }
5310}
5311
5312/// Number of colour stops sampled across an axial / radial shading's
5313/// parametric domain (§8.7.4.5.3–4). 64 evenly-spaced samples capture a
5314/// smooth gradient at typical output resolutions; a downstream consumer
5315/// interpolates between adjacent stops.
5316const GRADIENT_STOPS: usize = 64;
5317/// Per-axis sample count for a Type 1 (function-based) shading's domain
5318/// grid (§8.7.4.5.2). 16×16 = 256 samples balances fidelity vs. size for
5319/// the general 2-in / n-out colour function.
5320const FUNCTION_GRID: usize = 16;
5321
5322/// Parse + evaluate a Type 1–3 (function-based / axial / radial) shading
5323/// dictionary (§8.7.4.5.2–4) into its geometry + sampled colour stops.
5324/// Returns `None` for a Type 4–7 mesh shading (use
5325/// [`evaluate_mesh_shading`]), an unresolved colour space, a missing /
5326/// malformed `Function`, or malformed geometry. The colour function is
5327/// evaluated across the parametric `Domain` and each result reduced to
5328/// device RGB through the shading's `ColorSpace`.
5329/// Convert evenly-spaced shading colour samples into `[GradientStop]`
5330/// with offsets `i / (n − 1)` across `0.0..=1.0`. A single sample is
5331/// pinned at offset 0.0.
5332fn stops_to_gradient_stops(stops: &[Rgba]) -> Vec<GradientStop> {
5333    let n = stops.len();
5334    stops
5335        .iter()
5336        .enumerate()
5337        .map(|(i, c)| GradientStop {
5338            offset: if n <= 1 {
5339                0.0
5340            } else {
5341                i as f32 / (n - 1) as f32
5342            },
5343            color: *c,
5344        })
5345        .collect()
5346}
5347
5348/// Map the `Extend` flags of an axial / radial shading to a scene
5349/// [`SpreadMethod`]. PDF only has "extend" (pad) or "don't extend"; the
5350/// scene's `Pad` covers the extend-true case, and a non-extending
5351/// shading is also approximated as `Pad` (the colour outside the axis is
5352/// undefined in PDF — clamping is the conservative choice).
5353fn extend_to_spread(_extend: [bool; 2]) -> SpreadMethod {
5354    SpreadMethod::Pad
5355}
5356
5357/// Convert an evaluated [`ShadingGradient`] (axial or radial) into a
5358/// scene [`Paint`] gradient, mapping the shading `Coords` from shading
5359/// space into target space through `to_target` (the pattern `/Matrix`
5360/// composed with the current CTM). A function-based (Type 1) shading has
5361/// no single scene-gradient analogue and yields `None`. The radial
5362/// radii are scaled by the geometric-mean scale factor of `to_target`
5363/// (PDF shading-pattern matrices are typically uniform-scale, so this is
5364/// exact in the common case and a reasonable approximation otherwise).
5365fn gradient_to_paint(g: &ShadingGradient, to_target: Transform2D) -> Option<Paint> {
5366    let scale = {
5367        // |det|^(1/2) — the uniform-equivalent linear scale of the 2×2
5368        // part of the affine map.
5369        let det = (to_target.a * to_target.d - to_target.b * to_target.c).abs();
5370        det.sqrt()
5371    };
5372    match g {
5373        ShadingGradient::Axial {
5374            coords,
5375            extend,
5376            stops,
5377        } => {
5378            let start = to_target.apply(Point::new(coords[0], coords[1]));
5379            let end = to_target.apply(Point::new(coords[2], coords[3]));
5380            Some(Paint::LinearGradient(LinearGradient {
5381                start,
5382                end,
5383                stops: stops_to_gradient_stops(stops),
5384                spread: extend_to_spread(*extend),
5385            }))
5386        }
5387        ShadingGradient::Radial {
5388            coords,
5389            extend,
5390            stops,
5391        } => {
5392            // Map the ending circle (the one the stops sweep toward) to
5393            // the scene's outer circle; the starting circle becomes the
5394            // focal point. r1 is the outer radius.
5395            let focal = to_target.apply(Point::new(coords[0], coords[1]));
5396            let center = to_target.apply(Point::new(coords[3], coords[4]));
5397            let radius = coords[5] * scale;
5398            Some(Paint::RadialGradient(RadialGradient {
5399                center,
5400                radius,
5401                focal: Some(focal),
5402                stops: stops_to_gradient_stops(stops),
5403                spread: extend_to_spread(*extend),
5404            }))
5405        }
5406        // A function-based shading paints a 2-D colour field with no
5407        // linear/radial scene analogue.
5408        ShadingGradient::FunctionBased { .. } => None,
5409    }
5410}
5411
5412fn evaluate_gradient_shading(
5413    dict: &Dict,
5414    color_space_resources: Option<&Dict>,
5415) -> Option<ShadingGradient> {
5416    let get = |key: &str| {
5417        dict.entries()
5418            .iter()
5419            .find(|(k, _)| k == key)
5420            .map(|(_, v)| v)
5421    };
5422    let shading_type = get("ShadingType").and_then(number_as_i64)?;
5423    if !(1..=3).contains(&shading_type) {
5424        return None;
5425    }
5426    let cs = shading_color_space(get("ColorSpace")?, color_space_resources);
5427    if cs == ColorSpaceKind::Unknown {
5428        return None;
5429    }
5430    let func = parse_shading_function(get("Function"))?;
5431    let extend = match get("Extend") {
5432        Some(Object::Array(items)) if items.len() == 2 => [
5433            matches!(items[0], Object::Bool(true)),
5434            matches!(items[1], Object::Bool(true)),
5435        ],
5436        _ => [false, false],
5437    };
5438    match shading_type {
5439        2 => {
5440            // §8.7.4.5.3: Coords = [x0 y0 x1 y1]; Domain = [t0 t1]
5441            // (default [0 1]). Sample the function across [t0, t1].
5442            let coords_v = get("Coords").and_then(read_num_array)?;
5443            if coords_v.len() != 4 {
5444                return None;
5445            }
5446            let coords = [coords_v[0], coords_v[1], coords_v[2], coords_v[3]];
5447            let (t0, t1) = shading_domain(get("Domain"));
5448            let stops = sample_stops(&cs, &func, t0, t1)?;
5449            Some(ShadingGradient::Axial {
5450                coords,
5451                extend,
5452                stops,
5453            })
5454        }
5455        3 => {
5456            // §8.7.4.5.4: Coords = [x0 y0 r0 x1 y1 r1].
5457            let coords_v = get("Coords").and_then(read_num_array)?;
5458            if coords_v.len() != 6 {
5459                return None;
5460            }
5461            let coords = [
5462                coords_v[0],
5463                coords_v[1],
5464                coords_v[2],
5465                coords_v[3],
5466                coords_v[4],
5467                coords_v[5],
5468            ];
5469            let (t0, t1) = shading_domain(get("Domain"));
5470            let stops = sample_stops(&cs, &func, t0, t1)?;
5471            Some(ShadingGradient::Radial {
5472                coords,
5473                extend,
5474                stops,
5475            })
5476        }
5477        1 => {
5478            // §8.7.4.5.2: Domain = [xmin xmax ymin ymax] (default
5479            // [0 1 0 1]); Matrix maps the domain into target space; the
5480            // 2-in / n-out Function gives the colour at each domain
5481            // point. Sample onto a FUNCTION_GRID × FUNCTION_GRID grid.
5482            let domain = match get("Domain").and_then(read_num_array) {
5483                Some(d) if d.len() == 4 => [d[0], d[1], d[2], d[3]],
5484                Some(_) => return None,
5485                None => [0.0, 1.0, 0.0, 1.0],
5486            };
5487            let matrix = match get("Matrix").and_then(read_num_array) {
5488                Some(m) if m.len() == 6 => Transform2D {
5489                    a: m[0],
5490                    b: m[1],
5491                    c: m[2],
5492                    d: m[3],
5493                    e: m[4],
5494                    f: m[5],
5495                },
5496                Some(_) => return None,
5497                None => Transform2D::identity(),
5498            };
5499            let nx = FUNCTION_GRID;
5500            let ny = FUNCTION_GRID;
5501            let mut samples = Vec::with_capacity(nx * ny);
5502            for j in 0..ny {
5503                let y = lerp_domain(domain[2], domain[3], j, ny);
5504                for i in 0..nx {
5505                    let x = lerp_domain(domain[0], domain[1], i, nx);
5506                    let comps = func.eval_n(&[x, y]);
5507                    samples.push(rgba_from_components(&cs, &comps)?);
5508                }
5509            }
5510            Some(ShadingGradient::FunctionBased {
5511                domain,
5512                matrix,
5513                grid: (nx, ny),
5514                samples,
5515            })
5516        }
5517        _ => None,
5518    }
5519}
5520
5521/// Read a shading's optional `/Domain` `[t0 t1]` entry (§8.7.4.5.3–4),
5522/// defaulting to `[0.0, 1.0]`.
5523fn shading_domain(obj: Option<&Object>) -> (f32, f32) {
5524    match obj.and_then(read_num_array) {
5525        Some(d) if d.len() == 2 => (d[0], d[1]),
5526        _ => (0.0, 1.0),
5527    }
5528}
5529
5530/// The `k`-th of `n` uniform samples across `[lo, hi]` (inclusive of both
5531/// endpoints when `n > 1`).
5532fn lerp_domain(lo: f32, hi: f32, k: usize, n: usize) -> f32 {
5533    if n <= 1 {
5534        return lo;
5535    }
5536    lo + (hi - lo) * (k as f32) / ((n - 1) as f32)
5537}
5538
5539/// Sample an axial / radial shading's colour function at
5540/// [`GRADIENT_STOPS`] uniform parametric values across `[t0, t1]`,
5541/// reducing each to device RGB. Returns `None` if any sample's colour
5542/// can't be reduced (unresolved alternate, arity mismatch).
5543fn sample_stops(
5544    cs: &ColorSpaceKind,
5545    func: &ShadingFunction,
5546    t0: f32,
5547    t1: f32,
5548) -> Option<Vec<Rgba>> {
5549    let mut stops = Vec::with_capacity(GRADIENT_STOPS);
5550    for k in 0..GRADIENT_STOPS {
5551        let t = lerp_domain(t0, t1, k, GRADIENT_STOPS);
5552        let comps = func.eval(t);
5553        stops.push(rgba_from_components(cs, &comps)?);
5554    }
5555    Some(stops)
5556}
5557
5558/// Parse a shading's optional `/Function` entry (§8.7.4.5.5) — either a
5559/// single 1-in / n-out function or an array of n 1-in / 1-out functions
5560/// (`resolve_shading_resources` has already made each self-contained).
5561/// `None` for an absent / unparseable entry, in which case the stream
5562/// carries explicit colour components rather than a parametric value.
5563fn parse_shading_function(obj: Option<&Object>) -> Option<ShadingFunction> {
5564    match obj? {
5565        Object::Array(items) => {
5566            let parts: Vec<PdfFunction> = items
5567                .iter()
5568                .map(PdfFunction::parse)
5569                .collect::<Option<_>>()?;
5570            if parts.is_empty() {
5571                return None;
5572            }
5573            Some(ShadingFunction::Array(parts))
5574        }
5575        single => Some(ShadingFunction::Single(PdfFunction::parse(single)?)),
5576    }
5577}
5578
5579/// A shading's parametric colour function (§8.7.4.5.5): the stream gives
5580/// one value `t` per vertex / corner, mapped to the colour space's
5581/// components by either one `n`-out function or `n` 1-out functions.
5582enum ShadingFunction {
5583    Single(PdfFunction),
5584    Array(Vec<PdfFunction>),
5585}
5586
5587impl ShadingFunction {
5588    /// Evaluate at parametric value `t`, returning the colour-space
5589    /// component vector.
5590    fn eval(&self, t: f32) -> Vec<f32> {
5591        self.eval_n(&[t])
5592    }
5593
5594    /// Evaluate at the `m`-input vector `inputs` (one input for axial /
5595    /// radial / mesh §8.7.4.5.5 functions, two for a Type 1
5596    /// function-based shading, §8.7.4.5.2), returning the colour-space
5597    /// component vector. A `Single` n-out function consumes all inputs;
5598    /// an `Array` of 1-out functions feeds the same inputs to each and
5599    /// concatenates their first outputs.
5600    fn eval_n(&self, inputs: &[f32]) -> Vec<f32> {
5601        match self {
5602            ShadingFunction::Single(f) => f.eval_n(inputs),
5603            ShadingFunction::Array(fs) => fs
5604                .iter()
5605                .filter_map(|f| f.eval_n(inputs).first().copied())
5606                .collect(),
5607        }
5608    }
5609}
5610
5611/// Bundled mesh-stream parameters threaded through the per-type
5612/// evaluators (Type 4 free-form, Type 5 lattice, Type 6/7 patch).
5613struct MeshEvaluator<'a> {
5614    cs: &'a ColorSpaceKind,
5615    func: Option<&'a ShadingFunction>,
5616    n_color: usize,
5617    bits_coord: u32,
5618    bits_comp: u32,
5619    /// `BitsPerFlag` (§8.7.4.5.5) — the edge-flag width for Type 4
5620    /// (free-form) triangle meshes and Type 6/7 patch meshes. `0` for
5621    /// Type 5 (lattice-form), which carries no edge flags.
5622    bits_flag: u32,
5623    decode: &'a [f32],
5624}
5625
5626impl MeshEvaluator<'_> {
5627    /// Read + decode one vertex coordinate pair from `r` using the
5628    /// `Decode` array's first two pairs.
5629    fn read_point(&self, r: &mut BitReader) -> Option<Point> {
5630        let xc = r.read(self.bits_coord)?;
5631        let yc = r.read(self.bits_coord)?;
5632        let x = decode_value(xc, self.bits_coord, self.decode[0], self.decode[1]);
5633        let y = decode_value(yc, self.bits_coord, self.decode[2], self.decode[3]);
5634        Some(Point::new(x, y))
5635    }
5636
5637    /// Read + decode one vertex / corner colour from `r`. When the
5638    /// shading has a `/Function`, the stream carries a single parametric
5639    /// value `t` (decoded against the first colour-Decode pair) that the
5640    /// function maps to the colour-space components; otherwise it carries
5641    /// `n_color` raw components.
5642    fn read_color(&self, r: &mut BitReader) -> Option<Rgba> {
5643        if let Some(func) = self.func {
5644            let code = r.read(self.bits_comp)?;
5645            let t = decode_value(code, self.bits_comp, self.decode[4], self.decode[5]);
5646            let comps = func.eval(t);
5647            rgba_from_components(self.cs, &comps)
5648        } else {
5649            let mut comps = Vec::with_capacity(self.n_color);
5650            for i in 0..self.n_color {
5651                let code = r.read(self.bits_comp)?;
5652                let dmin = self.decode[4 + 2 * i];
5653                let dmax = self.decode[5 + 2 * i];
5654                comps.push(decode_value(code, self.bits_comp, dmin, dmax));
5655            }
5656            rgba_from_components(self.cs, &comps)
5657        }
5658    }
5659
5660    /// Read one full vertex (point + colour), byte-aligned afterwards.
5661    fn read_vertex(&self, r: &mut BitReader) -> Option<MeshVertex> {
5662        let point = self.read_point(r)?;
5663        let color = self.read_color(r)?;
5664        r.align_byte();
5665        Some(MeshVertex { point, color })
5666    }
5667
5668    /// §8.7.4.5.5 Type 4: free-form Gouraud triangle mesh. Each vertex
5669    /// carries an edge flag (`f = 0` starts a new triangle; `f = 1`/`2`
5670    /// continues from the previous one).
5671    fn eval_type4(&self, raw: &[u8]) -> Option<MeshShading> {
5672        let bits_flag = self.bits_flag;
5673        let mut r = BitReader::new(raw);
5674        let mut triangles: Vec<MeshTriangle> = Vec::new();
5675        // The three vertices of the most recent triangle, in stream
5676        // order (va, vb, vc) per Figure 25.
5677        let mut prev: Option<[MeshVertex; 3]> = None;
5678        loop {
5679            if r.at_end() {
5680                break;
5681            }
5682            let f = match r.read(bits_flag) {
5683                Some(v) => v & 0b11,
5684                None => break,
5685            };
5686            let v = self.read_vertex(&mut r)?;
5687            match f {
5688                0 => {
5689                    // Start a new triangle: this vertex plus the next
5690                    // two (whose own flags are ignored, §8.7.4.5.5).
5691                    r.read(bits_flag)?;
5692                    let vb = self.read_vertex(&mut r)?;
5693                    r.read(bits_flag)?;
5694                    let vc = self.read_vertex(&mut r)?;
5695                    let tri = [v, vb, vc];
5696                    triangles.push(MeshTriangle { vertices: tri });
5697                    prev = Some(tri);
5698                }
5699                1 => {
5700                    // Continue on side vbc: new triangle (vb, vc, vd).
5701                    let [_va, vb, vc] = prev?;
5702                    let tri = [vb, vc, v];
5703                    triangles.push(MeshTriangle { vertices: tri });
5704                    prev = Some(tri);
5705                }
5706                2 => {
5707                    // Continue on side vac: new triangle (va, vc, vd).
5708                    let [va, _vb, vc] = prev?;
5709                    let tri = [va, vc, v];
5710                    triangles.push(MeshTriangle { vertices: tri });
5711                    prev = Some(tri);
5712                }
5713                _ => return None,
5714            }
5715        }
5716        if triangles.is_empty() {
5717            return None;
5718        }
5719        Some(MeshShading::Triangles(triangles))
5720    }
5721
5722    /// §8.7.4.5.6 Type 5: lattice-form Gouraud triangle mesh. Vertices
5723    /// are laid out row-major (`VerticesPerRow` per row, no edge flags);
5724    /// adjacent rows form two triangles per cell (§8.7.4.5.6).
5725    fn eval_type5(&self, raw: &[u8], vpr: usize) -> Option<MeshShading> {
5726        let mut r = BitReader::new(raw);
5727        let mut rows: Vec<Vec<MeshVertex>> = Vec::new();
5728        loop {
5729            if r.at_end() {
5730                break;
5731            }
5732            let mut row = Vec::with_capacity(vpr);
5733            for _ in 0..vpr {
5734                match self.read_vertex(&mut r) {
5735                    Some(v) => row.push(v),
5736                    None => break,
5737                }
5738            }
5739            if row.len() != vpr {
5740                break;
5741            }
5742            rows.push(row);
5743        }
5744        if rows.len() < 2 {
5745            return None;
5746        }
5747        let mut triangles = Vec::new();
5748        for i in 0..rows.len() - 1 {
5749            for j in 0..vpr - 1 {
5750                // (V_i,j, V_i,j+1, V_i+1,j) and
5751                // (V_i,j+1, V_i+1,j, V_i+1,j+1), §8.7.4.5.6.
5752                let a = rows[i][j];
5753                let b = rows[i][j + 1];
5754                let c = rows[i + 1][j];
5755                let d = rows[i + 1][j + 1];
5756                triangles.push(MeshTriangle {
5757                    vertices: [a, b, c],
5758                });
5759                triangles.push(MeshTriangle {
5760                    vertices: [b, c, d],
5761                });
5762            }
5763        }
5764        Some(MeshShading::Triangles(triangles))
5765    }
5766
5767    /// §8.7.4.5.7–8 Type 6 / 7: Coons / tensor-product patch mesh.
5768    /// `tensor` selects the 16-control-point tensor layout (Type 7) vs
5769    /// the 12-control-point Coons layout (Type 6); a Coons patch is
5770    /// expanded to the equivalent tensor patch via the §8.7.4.5.8
5771    /// internal-control-point equations.
5772    fn eval_patch(&self, raw: &[u8], bits_flag: u32, tensor: bool) -> Option<MeshShading> {
5773        let mut r = BitReader::new(raw);
5774        let mut patches: Vec<MeshPatch> = Vec::new();
5775        // Coordinate count per patch element: 12 boundary control points
5776        // (Coons) or 16 (tensor); a continuation patch (`f != 0`)
5777        // supplies only the 8 / 12 *new* points.
5778        loop {
5779            if r.at_end() {
5780                break;
5781            }
5782            let f = match r.read(bits_flag) {
5783                Some(v) => v & 0b11,
5784                None => break,
5785            };
5786            let new_pts = if f == 0 {
5787                if tensor {
5788                    16
5789                } else {
5790                    12
5791                }
5792            } else if tensor {
5793                12
5794            } else {
5795                8
5796            };
5797            let mut pts = Vec::with_capacity(new_pts);
5798            for _ in 0..new_pts {
5799                pts.push(self.read_point(&mut r)?);
5800            }
5801            let new_cols = if f == 0 { 4 } else { 2 };
5802            let mut cols = Vec::with_capacity(new_cols);
5803            for _ in 0..new_cols {
5804                cols.push(self.read_color(&mut r)?);
5805            }
5806            r.align_byte();
5807            let patch = build_patch(f, tensor, &pts, &cols, patches.last())?;
5808            patches.push(patch);
5809        }
5810        if patches.is_empty() {
5811            return None;
5812        }
5813        Some(MeshShading::Patches(patches))
5814    }
5815}
5816
5817/// Assemble one Coons / tensor patch from its freshly-read control
5818/// points + corner colours and the previous patch's edge data
5819/// (§8.7.4.5.7 Table 85 / §8.7.4.5.8 Table 86).
5820///
5821/// The 16 tensor control points are addressed as `p[col][row]`
5822/// (`p[i][j]` = `pij` in Figure 32). For a tensor patch (`tensor =
5823/// true`) the 16 explicit points arrive in the Table 86 stream order;
5824/// for a Coons patch (`tensor = false`) the 12 boundary points arrive in
5825/// the Table 85 order (= the 12 boundary entries of the tensor order)
5826/// and the four internal points are derived from the boundary curves via
5827/// the §8.7.4.5.8 conversion equations. A continuation patch (`f != 0`)
5828/// inherits four boundary points + two corner colours from the previous
5829/// patch's shared edge.
5830fn build_patch(
5831    f: u64,
5832    tensor: bool,
5833    new_pts: &[Point],
5834    new_cols: &[Rgba],
5835    prev: Option<&MeshPatch>,
5836) -> Option<MeshPatch> {
5837    // The tensor stream order of the 16 points (Table 86, f=0), as
5838    // (col, row) indices into p[col][row]:
5839    //   p00 p01 p02 p03 p13 p23 p33 p32 p31 p30 p20 p10 p11 p12 p22 p21
5840    const TENSOR_ORDER: [(usize, usize); 16] = [
5841        (0, 0),
5842        (0, 1),
5843        (0, 2),
5844        (0, 3),
5845        (1, 3),
5846        (2, 3),
5847        (3, 3),
5848        (3, 2),
5849        (3, 1),
5850        (3, 0),
5851        (2, 0),
5852        (1, 0),
5853        (1, 1),
5854        (1, 2),
5855        (2, 2),
5856        (2, 1),
5857    ];
5858    // For a continuation patch the stream supplies the *new* points,
5859    // which fill the tensor-order slots starting at index 4 (Coons) /
5860    // index 4 (tensor) — the first four entries are the shared edge,
5861    // inherited below. Table 85/86 list the new points starting with
5862    // p13/x5; the first four tensor-order slots (the shared edge) are
5863    // not in the stream.
5864    let mut p = [[Point::new(0.0, 0.0); 4]; 4];
5865    // For tensor patches we always work in the 16-slot order. For Coons
5866    // patches only the 12 boundary slots (the first 12 entries of
5867    // TENSOR_ORDER) are populated from the stream; the four internal
5868    // slots are the last four entries.
5869    let boundary_slots = 12usize;
5870    if f == 0 {
5871        let count = if tensor { 16 } else { boundary_slots };
5872        if new_pts.len() != count {
5873            return None;
5874        }
5875        for (k, &pt) in new_pts.iter().enumerate() {
5876            let (c, rr) = TENSOR_ORDER[k];
5877            p[c][rr] = pt;
5878        }
5879    } else {
5880        let prev = prev?;
5881        // Inherit the four shared-edge boundary points from the previous
5882        // patch per the edge flag (§8.7.4.5.8 Table 86 — the same four
5883        // tensor-order slots 0..3 are filled from the previous patch's
5884        // selected edge). The mapping is expressed via the previous
5885        // patch's `p[col][row]`.
5886        let shared: [(usize, usize); 4] = match f {
5887            1 => [(0, 3), (1, 3), (2, 3), (3, 3)], // prev top edge (p03 p13 p23 p33)
5888            2 => [(3, 3), (3, 2), (3, 1), (3, 0)], // prev right edge (p33 p32 p31 p30)
5889            3 => [(3, 0), (2, 0), (1, 0), (0, 0)], // prev bottom edge (p30 p20 p10 p00)
5890            _ => return None,
5891        };
5892        // Tensor-order slots 0..3 (p00 p01 p02 p03) take the previous
5893        // patch's shared-edge points.
5894        for (k, &(c, rr)) in shared.iter().enumerate() {
5895            let (tc, trr) = TENSOR_ORDER[k];
5896            p[tc][trr] = prev.control_points[c][rr];
5897        }
5898        // The remaining new points fill tensor-order slots 4..count.
5899        let count = if tensor { 16 } else { boundary_slots };
5900        if new_pts.len() != count - 4 {
5901            return None;
5902        }
5903        for (k, &pt) in new_pts.iter().enumerate() {
5904            let (c, rr) = TENSOR_ORDER[k + 4];
5905            p[c][rr] = pt;
5906        }
5907    }
5908    // For a Coons patch, derive the four internal control points from
5909    // the boundary curves (§8.7.4.5.8 conversion equations).
5910    if !tensor {
5911        p[1][1] = coons_internal(
5912            p[0][0], p[0][1], p[1][0], p[0][3], p[3][0], p[3][1], p[1][3], p[3][3],
5913        );
5914        p[1][2] = coons_internal(
5915            p[0][3], p[0][2], p[1][3], p[0][0], p[3][3], p[3][2], p[1][0], p[3][0],
5916        );
5917        p[2][1] = coons_internal(
5918            p[3][0], p[3][1], p[2][0], p[3][3], p[0][0], p[0][1], p[2][3], p[0][3],
5919        );
5920        p[2][2] = coons_internal(
5921            p[3][3], p[3][2], p[2][3], p[3][0], p[0][3], p[0][2], p[2][0], p[0][0],
5922        );
5923    }
5924    // Corner colours: c1=p00, c2=p03, c3=p33, c4=p30 (§8.7.4.5.7).
5925    let corner_colors: [Rgba; 4] = if f == 0 {
5926        if new_cols.len() != 4 {
5927            return None;
5928        }
5929        [new_cols[0], new_cols[1], new_cols[2], new_cols[3]]
5930    } else {
5931        let prev = prev?;
5932        if new_cols.len() != 2 {
5933            return None;
5934        }
5935        // Two corner colours inherited from the previous patch's shared
5936        // edge, two read from the stream (§8.7.4.5.7 Table 85 /
5937        // §8.7.4.5.8 Table 86: c1 c2 inherited, c3 c4 = the new pair).
5938        let (c1, c2) = match f {
5939            1 => (prev.corner_colors[1], prev.corner_colors[2]), // c1=c2prev c2=c3prev
5940            2 => (prev.corner_colors[2], prev.corner_colors[3]), // c1=c3prev c2=c4prev
5941            3 => (prev.corner_colors[3], prev.corner_colors[0]), // c1=c4prev c2=c1prev
5942            _ => return None,
5943        };
5944        [c1, c2, new_cols[0], new_cols[1]]
5945    };
5946    Some(MeshPatch {
5947        control_points: p,
5948        corner_colors,
5949    })
5950}
5951
5952/// One internal-control-point of a Coons patch, derived from the
5953/// boundary control points per the §8.7.4.5.8 conversion equation
5954///
5955/// ```text
5956/// p = 1/9 × [ −4·a + 6·(b + c) − 2·(d + e) + 3·(f + g) − 1·h ]
5957/// ```
5958///
5959/// The four `p11`/`p12`/`p21`/`p22` equations share this shape with
5960/// different point assignments; the caller supplies them in
5961/// `(a, b, c, d, e, f, g, h)` order.
5962#[allow(clippy::too_many_arguments)]
5963fn coons_internal(
5964    a: Point,
5965    b: Point,
5966    c: Point,
5967    d: Point,
5968    e: Point,
5969    f: Point,
5970    g: Point,
5971    h: Point,
5972) -> Point {
5973    let comp = |a: f32, b: f32, c: f32, d: f32, e: f32, f: f32, g: f32, h: f32| -> f32 {
5974        (-4.0 * a + 6.0 * (b + c) - 2.0 * (d + e) + 3.0 * (f + g) - h) / 9.0
5975    };
5976    Point::new(
5977        comp(a.x, b.x, c.x, d.x, e.x, f.x, g.x, h.x),
5978        comp(a.y, b.y, c.y, d.y, e.y, f.y, g.y, h.y),
5979    )
5980}
5981
5982/// Convert a DeviceCMYK colour value to DeviceRGB per ISO 32000-1
5983/// §10.3.5 ("Conversion from DeviceCMYK to DeviceRGB"):
5984///
5985/// ```text
5986/// red   = 1.0 − min(1.0, cyan    + black)
5987/// green = 1.0 − min(1.0, magenta + black)
5988/// blue  = 1.0 − min(1.0, yellow  + black)
5989/// ```
5990///
5991/// The black component is added to each of the other components, which
5992/// are then converted to their complementary colours by subtracting
5993/// each from 1.0. No black generation or undercolour removal is
5994/// involved. Components are clamped into 0.0..=1.0 first so an
5995/// out-of-range operand cannot escape the 1.0 ceiling (§10.3.4 NOTE 4
5996/// applies the same nearest-valid-value substitution without error).
5997fn rgb_from_cmyk(cyan: f32, magenta: f32, yellow: f32, black: f32) -> Rgba {
5998    let c = cyan.clamp(0.0, 1.0);
5999    let m = magenta.clamp(0.0, 1.0);
6000    let y = yellow.clamp(0.0, 1.0);
6001    let k = black.clamp(0.0, 1.0);
6002    let red = 1.0 - (c + k).min(1.0);
6003    let green = 1.0 - (m + k).min(1.0);
6004    let blue = 1.0 - (y + k).min(1.0);
6005    rgb_from_unit(red, green, blue)
6006}
6007
6008fn unit_to_byte(f: f32) -> u8 {
6009    (f.clamp(0.0, 1.0) * 255.0).round() as u8
6010}
6011
6012// ───────────── CIE-based colour science (§8.6.5.2–4) ──────────────
6013//
6014// The CalGray (§8.6.5.2), CalRGB (§8.6.5.3) and Lab (§8.6.5.4) spaces
6015// produce a CIE 1931 XYZ tristimulus value through the per-space
6016// transformations defined in their respective sub-clauses (gamma decode
6017// + WhitePoint scale for CalGray, gamma decode + 3×3 Matrix for CalRGB,
6018// the implicit L*a*b* → XYZ stages for Lab). §10.2 ("CIE-Based Colour
6019// to Device Colour") then gamut-maps XYZ onto the output device. With
6020// no physical device model in a software renderer the conventional
6021// reduction is to the sRGB display space: a fixed XYZ → linear-RGB
6022// matrix followed by the sRGB opto-electronic transfer encoding. This
6023// is the standard sRGB colorimetry (IEC 61966-2-1), reproduced here
6024// from first principles — not derived from any third-party renderer.
6025
6026/// Encode one linear-light RGB component (0.0..=1.0) with the sRGB
6027/// transfer function. Values are clamped into range first; the
6028/// piecewise curve has a small linear segment near black and a
6029/// 1/2.4-power segment above the `0.0031308` breakpoint.
6030fn srgb_encode(c: f32) -> f32 {
6031    let c = c.clamp(0.0, 1.0);
6032    if c <= 0.003_130_8 {
6033        12.92 * c
6034    } else {
6035        1.055 * c.powf(1.0 / 2.4) - 0.055
6036    }
6037}
6038
6039/// Map a CIE 1931 XYZ tristimulus value to a device-RGB [`Rgba`] via
6040/// the sRGB display space. The XYZ → linear-sRGB matrix is the standard
6041/// D65 sRGB primaries inverse; each linear component is then sRGB-
6042/// encoded and quantised. Out-of-gamut components are clamped to
6043/// `0.0..=1.0` "without error indication" in the spirit of §8.6.5's
6044/// component-clamping rule.
6045fn rgb_from_xyz(x: f32, y: f32, z: f32) -> Rgba {
6046    let r = 3.240_625_5 * x - 1.537_208 * y - 0.498_628_6 * z;
6047    let g = -0.968_930_7 * x + 1.875_756_1 * y + 0.041_517_5 * z;
6048    let b = 0.055_710_1 * x - 0.204_021_1 * y + 1.056_995_9 * z;
6049    rgb_from_unit(srgb_encode(r), srgb_encode(g), srgb_encode(b))
6050}
6051
6052/// CalGray (§8.6.5.2): decode the single gray component `a` by the
6053/// `gamma` exponent and scale by the white point `[xw, yw, zw]` to get
6054/// XYZ, then map to RGB. `a` is clamped into `0.0..=1.0` per the
6055/// CIE-based-A component range.
6056fn cal_gray_color(white: [f32; 3], gamma: f32, a: f32) -> Rgba {
6057    let a = a.clamp(0.0, 1.0);
6058    let decoded = a.powf(gamma);
6059    rgb_from_xyz(white[0] * decoded, white[1] * decoded, white[2] * decoded)
6060}
6061
6062/// CalRGB (§8.6.5.3): decode the A/B/C components by their per-channel
6063/// `gamma` exponents, multiply the decoded vector by the 3×3 `matrix`
6064/// (`[xa ya za xb yb zb xc yc zc]`, column-major per component) to get
6065/// XYZ, then map to RGB. Components are clamped into `0.0..=1.0`.
6066fn cal_rgb_color(gamma: [f32; 3], matrix: [f32; 9], abc: [f32; 3]) -> Rgba {
6067    let da = abc[0].clamp(0.0, 1.0).powf(gamma[0]);
6068    let db = abc[1].clamp(0.0, 1.0).powf(gamma[1]);
6069    let dc = abc[2].clamp(0.0, 1.0).powf(gamma[2]);
6070    // X = XA·A^GR + XB·B^GG + XC·C^GB, and likewise for Y, Z.
6071    let x = matrix[0] * da + matrix[3] * db + matrix[6] * dc;
6072    let y = matrix[1] * da + matrix[4] * db + matrix[7] * dc;
6073    let z = matrix[2] * da + matrix[5] * db + matrix[8] * dc;
6074    rgb_from_xyz(x, y, z)
6075}
6076
6077/// The §8.6.5.4 `g(x)` reverse-companding function used by both the Lab
6078/// → XYZ stage.
6079fn lab_g(x: f32) -> f32 {
6080    // 6/29 = 0.206896…; below the breakpoint the linear segment with
6081    // slope 108/841 and offset 4/29 applies.
6082    if x >= 6.0 / 29.0 {
6083        x * x * x
6084    } else {
6085        (108.0 / 841.0) * (x - 4.0 / 29.0)
6086    }
6087}
6088
6089/// Lab (§8.6.5.4): map the `[l, a, b]` triple (L* in 0..=100, a*/b*
6090/// already clamped into the space's `Range`) to XYZ through the implicit
6091/// two-stage transform, scaling by the white point `[xw, yw, zw]`, then
6092/// to RGB.
6093fn lab_color(white: [f32; 3], lab: [f32; 3]) -> Rgba {
6094    let l = lab[0].clamp(0.0, 100.0);
6095    let m_base = (l + 16.0) / 116.0;
6096    let l_in = m_base + lab[1] / 500.0;
6097    let n_in = m_base - lab[2] / 200.0;
6098    rgb_from_xyz(
6099        white[0] * lab_g(l_in),
6100        white[1] * lab_g(m_base),
6101        white[2] * lab_g(n_in),
6102    )
6103}
6104
6105/// Hard ceiling on the number of pattern cells a single tiling fill may
6106/// emit (§8.7.3). A fill region many multiples of XStep/YStep wide could
6107/// otherwise produce an unbounded node count; past this cap the fill
6108/// falls back to its solid colour rather than tiling.
6109const MAX_TILING_CELLS: i64 = 4096;
6110
6111/// Re-entrancy ceiling for the Type 3 glyph paint path (§9.6.5). A
6112/// glyph description is itself a content stream and may show text in
6113/// another Type 3 font; this caps the nesting so a `/CharProcs` entry
6114/// that (directly or transitively) shows itself terminates.
6115const MAX_TYPE3_DEPTH: u32 = 8;
6116
6117/// Recolour every painted path in a node subtree to a single solid
6118/// colour — the stencil-pour operation for an uncoloured (`/PaintType 2`)
6119/// tiling pattern cell (§8.7.3.3). A `/PaintType 2` cell carries no
6120/// colour of its own, so each fill / stroke that *is* present is repainted
6121/// with the underlying colour the `scn` supplied. Paths with no fill /
6122/// stroke (pure clip / construction paths) are left untouched; group
6123/// transforms / clips are preserved.
6124fn recolor_node(node: &mut Node, color: Rgba) {
6125    match node {
6126        Node::Path(p) => {
6127            if p.fill.is_some() {
6128                p.fill = Some(Paint::Solid(color));
6129            }
6130            if let Some(stroke) = &mut p.stroke {
6131                stroke.paint = Paint::Solid(color);
6132            }
6133        }
6134        Node::Group(g) => {
6135            for child in &mut g.children {
6136                recolor_node(child, color);
6137            }
6138        }
6139        _ => {}
6140    }
6141}
6142
6143/// A closed rectangular subpath `[llx, lly] → [urx, ury]` (the four
6144/// corners + `Close`). Used as a tiling pattern cell's `/BBox` clip and
6145/// to build the per-tile clip rectangle.
6146fn rect_path(x0: f32, y0: f32, x1: f32, y1: f32) -> Path {
6147    let mut p = Path::new();
6148    p.commands.push(PathCommand::MoveTo(Point::new(x0, y0)));
6149    p.commands.push(PathCommand::LineTo(Point::new(x1, y0)));
6150    p.commands.push(PathCommand::LineTo(Point::new(x1, y1)));
6151    p.commands.push(PathCommand::LineTo(Point::new(x0, y1)));
6152    p.commands.push(PathCommand::Close);
6153    p
6154}
6155
6156/// Apply an affine transform to every coordinate of a path's commands,
6157/// returning a new path in the transformed space. Control points of
6158/// curve commands are mapped too (affine maps preserve Béziers). `Close`
6159/// carries no coordinate. `ArcTo` is mapped by its endpoint only — the
6160/// reader never constructs arc commands in a content stream (the writer
6161/// flattens arcs to cubics), so this branch is a best-effort passthrough.
6162fn transform_path(path: &Path, m: Transform2D) -> Path {
6163    let mut out = Path::new();
6164    out.commands.reserve(path.commands.len());
6165    for cmd in &path.commands {
6166        let mapped = match *cmd {
6167            PathCommand::MoveTo(p) => PathCommand::MoveTo(m.apply(p)),
6168            PathCommand::LineTo(p) => PathCommand::LineTo(m.apply(p)),
6169            PathCommand::QuadCurveTo { control, end } => PathCommand::QuadCurveTo {
6170                control: m.apply(control),
6171                end: m.apply(end),
6172            },
6173            PathCommand::CubicCurveTo { c1, c2, end } => PathCommand::CubicCurveTo {
6174                c1: m.apply(c1),
6175                c2: m.apply(c2),
6176                end: m.apply(end),
6177            },
6178            PathCommand::ArcTo {
6179                rx,
6180                ry,
6181                x_axis_rot,
6182                large_arc,
6183                sweep,
6184                end,
6185            } => PathCommand::ArcTo {
6186                rx,
6187                ry,
6188                x_axis_rot,
6189                large_arc,
6190                sweep,
6191                end: m.apply(end),
6192            },
6193            PathCommand::Close => PathCommand::Close,
6194            _ => *cmd,
6195        };
6196        out.commands.push(mapped);
6197    }
6198    out
6199}
6200
6201/// Axis-aligned bounding box `(min_x, min_y, max_x, max_y)` over every
6202/// coordinate a path touches (anchor + control points — a conservative
6203/// superset of the true Bézier hull, which is all the tiling lattice
6204/// needs). Returns `None` for an empty path or one whose coordinates are
6205/// non-finite.
6206fn path_bounds(path: &Path) -> Option<(f32, f32, f32, f32)> {
6207    let (mut x0, mut y0, mut x1, mut y1) = (
6208        f32::INFINITY,
6209        f32::INFINITY,
6210        f32::NEG_INFINITY,
6211        f32::NEG_INFINITY,
6212    );
6213    let mut acc = |p: Point| {
6214        x0 = x0.min(p.x);
6215        y0 = y0.min(p.y);
6216        x1 = x1.max(p.x);
6217        y1 = y1.max(p.y);
6218    };
6219    for cmd in &path.commands {
6220        match *cmd {
6221            PathCommand::MoveTo(p) | PathCommand::LineTo(p) => acc(p),
6222            PathCommand::QuadCurveTo { control, end } => {
6223                acc(control);
6224                acc(end);
6225            }
6226            PathCommand::CubicCurveTo { c1, c2, end } => {
6227                acc(c1);
6228                acc(c2);
6229                acc(end);
6230            }
6231            PathCommand::ArcTo { end, .. } => acc(end),
6232            PathCommand::Close => {}
6233            _ => {}
6234        }
6235    }
6236    if x0.is_finite() && y0.is_finite() && x1.is_finite() && y1.is_finite() && x0 <= x1 && y0 <= y1
6237    {
6238        Some((x0, y0, x1, y1))
6239    } else {
6240        None
6241    }
6242}
6243
6244/// Invert an affine transform `[a b c d e f]`. Returns `None` when the
6245/// linear part is singular (zero determinant) — a degenerate pattern
6246/// matrix that maps every tile to a line/point, for which no tiling can
6247/// be computed.
6248fn invert_transform(m: Transform2D) -> Option<Transform2D> {
6249    let det = m.a * m.d - m.b * m.c;
6250    if !det.is_finite() || det.abs() < f32::EPSILON {
6251        return None;
6252    }
6253    let inv_det = 1.0 / det;
6254    let a = m.d * inv_det;
6255    let b = -m.b * inv_det;
6256    let c = -m.c * inv_det;
6257    let d = m.a * inv_det;
6258    // Translation of the inverse: −(linear⁻¹ · [e f]).
6259    let e = -(a * m.e + c * m.f);
6260    let f = -(b * m.e + d * m.f);
6261    let inv = Transform2D { a, b, c, d, e, f };
6262    if [inv.a, inv.b, inv.c, inv.d, inv.e, inv.f]
6263        .iter()
6264        .all(|v| v.is_finite())
6265    {
6266        Some(inv)
6267    } else {
6268        None
6269    }
6270}
6271
6272fn compose(a: Transform2D, b: Transform2D) -> Transform2D {
6273    // PDF `cm` post-concatenates: new CTM = b * old CTM. In the
6274    // SVG/IR convention, group.transform applies to the children
6275    // *before* any parent transform — so when we encounter a `cm`
6276    // inside a frame whose existing transform is `a`, the resulting
6277    // group transform is `a * b`.
6278    Transform2D {
6279        a: a.a * b.a + a.c * b.b,
6280        b: a.b * b.a + a.d * b.b,
6281        c: a.a * b.c + a.c * b.d,
6282        d: a.b * b.c + a.d * b.d,
6283        e: a.a * b.e + a.c * b.f + a.e,
6284        f: a.b * b.e + a.d * b.f + a.f,
6285    }
6286}
6287
6288fn scan_keyword_end(input: &[u8], start: usize) -> usize {
6289    let mut end = start;
6290    while end < input.len() && !is_whitespace(input[end]) && !is_delimiter(input[end]) {
6291        end += 1;
6292    }
6293    end
6294}
6295
6296/// Exact f32 powers of ten for the fast decimal→binary path. Every
6297/// entry is exactly representable: 10^k = 5^k·2^k and 5^10 =
6298/// 9 765 625 < 2^24, so the table stops at 10^10.
6299const POW10_F32: [f32; 11] = [1.0, 1e1, 1e2, 1e3, 1e4, 1e5, 1e6, 1e7, 1e8, 1e9, 1e10];
6300
6301/// Result of scanning one §7.3.3 numeric operand in a content stream.
6302enum NumScan {
6303    /// No digit was consumed — the bytes at `start` are a bare sign /
6304    /// dot keyword, not a number. The cursor is unchanged.
6305    NotANumber,
6306    /// Number scanned **and** converted on the exact-arithmetic fast
6307    /// path; the first field is the byte after the number.
6308    Fast(usize, f32),
6309    /// Number scanned but outside the fast-path range (significand ≥
6310    /// 2^24 or more than 10 fractional digits); the payload is the
6311    /// byte after the number — the caller re-parses the scanned bytes
6312    /// through `str::parse` with its own error policy.
6313    Slow(usize),
6314}
6315
6316/// Scan a numeric operand starting at `start` (whose byte must be
6317/// `+` / `-` / `.` / digit) and convert it without the UTF-8 +
6318/// general-purpose float-parse round trip when exact arithmetic
6319/// allows.
6320///
6321/// A content-stream number is `sign? digits? ("." digits?)?` — no
6322/// exponent (§7.3.3) — so its value is `significand / 10^frac`. When
6323/// the significand is `< 2^24` (exact in f32) and `frac ≤ 10` (the
6324/// divisor is exact in f32, see [`POW10_F32`]), IEEE-754 division of
6325/// two exact operands is correctly rounded, and the correctly rounded
6326/// result is unique — bit-identical to what `str::parse::<f32>`
6327/// returns for the same bytes. Anything wider falls back to
6328/// [`NumScan::Slow`].
6329fn scan_number(input: &[u8], start: usize) -> NumScan {
6330    let mut end = start;
6331    let neg = input[end] == b'-';
6332    if matches!(input[end], b'+' | b'-') {
6333        end += 1;
6334    }
6335    let mut mant: u64 = 0;
6336    let mut frac: usize = 0;
6337    let mut saw_digit = false;
6338    let mut saw_dot = false;
6339    // Set once the significand stops being tracked exactly (≥ ~17
6340    // digits); forces the slow path.
6341    let mut wide = false;
6342    while end < input.len() {
6343        let c = input[end];
6344        if c.is_ascii_digit() {
6345            saw_digit = true;
6346            if mant < (1 << 56) {
6347                mant = mant * 10 + (c - b'0') as u64;
6348            } else {
6349                wide = true;
6350            }
6351            if saw_dot {
6352                frac += 1;
6353            }
6354            end += 1;
6355        } else if c == b'.' && !saw_dot {
6356            saw_dot = true;
6357            end += 1;
6358        } else {
6359            break;
6360        }
6361    }
6362    if !saw_digit {
6363        return NumScan::NotANumber;
6364    }
6365    if !wide && mant < (1 << 24) && frac <= 10 {
6366        let q = mant as f32 / POW10_F32[frac];
6367        NumScan::Fast(end, if neg { -q } else { q })
6368    } else {
6369        NumScan::Slow(end)
6370    }
6371}
6372
6373/// Decode a PDF literal string `( … )` per ISO 32000-1 §7.3.4.2 —
6374/// nested parentheses balance; the escape sequences `\n \r \t \b \f
6375/// \( \) \\` produce their familiar byte, an octal escape `\ddd`
6376/// (1..3 digits) produces that byte, a line-continuation `\<EOL>` is
6377/// dropped, and any other `\c` falls through to the literal `c`. The
6378/// returned `Vec<u8>` is the raw bytes the operator should see; the
6379/// byte→Unicode mapping (per the active font's encoding) is the
6380/// caller's job.
6381fn read_literal_string(input: &[u8], start: usize) -> Result<(usize, Vec<u8>), PdfError> {
6382    let mut end = start + 1;
6383    let mut depth = 1u32;
6384    let mut decoded = Vec::new();
6385    while end < input.len() {
6386        let b = input[end];
6387        if b == b'\\' {
6388            end += 1;
6389            if end >= input.len() {
6390                break;
6391            }
6392            let esc = input[end];
6393            match esc {
6394                b'n' => {
6395                    decoded.push(b'\n');
6396                    end += 1;
6397                }
6398                b'r' => {
6399                    decoded.push(b'\r');
6400                    end += 1;
6401                }
6402                b't' => {
6403                    decoded.push(b'\t');
6404                    end += 1;
6405                }
6406                b'b' => {
6407                    decoded.push(0x08);
6408                    end += 1;
6409                }
6410                b'f' => {
6411                    decoded.push(0x0C);
6412                    end += 1;
6413                }
6414                b'(' | b')' | b'\\' => {
6415                    decoded.push(esc);
6416                    end += 1;
6417                }
6418                b'\n' => {
6419                    end += 1;
6420                }
6421                b'\r' => {
6422                    end += 1;
6423                    if end < input.len() && input[end] == b'\n' {
6424                        end += 1;
6425                    }
6426                }
6427                d if d.is_ascii_digit() => {
6428                    // Octal escape \ddd — up to three octal digits.
6429                    let mut val: u16 = 0;
6430                    let mut n = 0;
6431                    while n < 3 && end < input.len() {
6432                        let c = input[end];
6433                        if !(b'0'..=b'7').contains(&c) {
6434                            break;
6435                        }
6436                        val = val * 8 + (c - b'0') as u16;
6437                        end += 1;
6438                        n += 1;
6439                    }
6440                    decoded.push((val & 0xFF) as u8);
6441                }
6442                other => {
6443                    // Unknown escape — the spec says the backslash is
6444                    // dropped and the following byte is taken as is.
6445                    decoded.push(other);
6446                    end += 1;
6447                }
6448            }
6449            continue;
6450        }
6451        if b == b'(' {
6452            depth += 1;
6453        }
6454        if b == b')' {
6455            depth -= 1;
6456            if depth == 0 {
6457                end += 1;
6458                return Ok((end, decoded));
6459            }
6460        }
6461        decoded.push(b);
6462        end += 1;
6463    }
6464    Err(PdfError::other(
6465        "PDF content parser: unterminated literal string",
6466    ))
6467}
6468
6469/// Decode a PDF hex string `< … >` per ISO 32000-1 §7.3.4.3 —
6470/// whitespace inside the angle brackets is skipped; a trailing odd
6471/// digit is implicitly padded with `0`. The returned `Vec<u8>` holds
6472/// one byte per hex pair.
6473fn read_hex_string(input: &[u8], start: usize) -> Result<(usize, Vec<u8>), PdfError> {
6474    let mut end = start + 1;
6475    let mut nibbles: Vec<u8> = Vec::new();
6476    while end < input.len() {
6477        let c = input[end];
6478        if c == b'>' {
6479            // Pad a trailing odd nibble per §7.3.4.3.
6480            if nibbles.len() % 2 == 1 {
6481                nibbles.push(0);
6482            }
6483            let mut out = Vec::with_capacity(nibbles.len() / 2);
6484            for pair in nibbles.chunks(2) {
6485                out.push((pair[0] << 4) | pair[1]);
6486            }
6487            return Ok((end + 1, out));
6488        }
6489        if let Some(v) = hex_nibble(c) {
6490            nibbles.push(v);
6491        }
6492        // else: any other byte (including whitespace) is silently
6493        // skipped per §7.3.4.3.
6494        end += 1;
6495    }
6496    Err(PdfError::other(
6497        "PDF content parser: unterminated hex string",
6498    ))
6499}
6500
6501fn hex_nibble(c: u8) -> Option<u8> {
6502    match c {
6503        b'0'..=b'9' => Some(c - b'0'),
6504        b'a'..=b'f' => Some(10 + c - b'a'),
6505        b'A'..=b'F' => Some(10 + c - b'A'),
6506        _ => None,
6507    }
6508}
6509
6510/// Look up a name key in a dictionary and unwrap it as a nested
6511/// [`Dict`]. Returns `None` for missing keys or non-dict values. The
6512/// `gs` resolver uses this against `/Resources /ExtGState`. Indirect
6513/// references are not followed — the caller is expected to have
6514/// already resolved each subdict (the wiring in
6515/// `reader::document::page_resource_dict` does this).
6516fn lookup_dict<'a>(dict: &'a Dict, key: &str) -> Option<&'a Dict> {
6517    dict.entries()
6518        .iter()
6519        .find(|(k, _)| k == key)
6520        .and_then(|(_, v)| match v {
6521            Object::Dict(d) => Some(d),
6522            _ => None,
6523        })
6524}
6525
6526/// Per-glyph horizontal advance metrics resolved from a font
6527/// dictionary, in glyph-space units (thousandths of a text-space
6528/// unit; §9.2.4). Used by the text-showing operators to apply the
6529/// §9.4.4 displacement and so advance the text matrix between
6530/// consecutive glyphs / shows.
6531///
6532/// Built by [`build_font_metrics`] from the already-resolved
6533/// `/Resources /Font /Fx` dictionary. The walker requires the
6534/// document-level resolver to have deep-resolved the width data
6535/// (`/Widths` array entries, and for composite fonts the descendant
6536/// CIDFont's `/W` / `/DW`) so the values are direct numerics here —
6537/// matching the one-hop-resolved resource contract the rest of the
6538/// walker relies on.
6539#[derive(Clone, Debug)]
6540enum FontMetrics {
6541    /// Simple font (Type1 / TrueType / Type3, §9.6): one byte per
6542    /// code. `widths[code − first_char]` gives the advance; codes
6543    /// outside `[first_char, first_char + widths.len())` use
6544    /// `missing_width`. `text_scale` converts a stored width into
6545    /// text-space units (§9.2.4): `0.001` for Type1 / TrueType (their
6546    /// widths are in thousandths of text space), or the horizontal
6547    /// component of the Type 3 `/FontMatrix` (§9.6.5 — Type 3 widths are
6548    /// in glyph space, interpreted through `/FontMatrix`).
6549    Simple {
6550        first_char: i64,
6551        widths: Vec<f32>,
6552        missing_width: f32,
6553        text_scale: f32,
6554    },
6555    /// Composite (Type0) font (§9.7): two bytes per code under the
6556    /// Identity-H/V CMaps the writer emits, where the CID equals the
6557    /// code. `default_width` is the CIDFont `/DW` (default 1000);
6558    /// `ranges` are the parsed `/W` entries (start CID, run of
6559    /// per-CID widths).
6560    Cid {
6561        default_width: f32,
6562        ranges: Vec<(i64, Vec<f32>)>,
6563        two_byte: bool,
6564    },
6565    /// No width data could be resolved — every glyph advances by 0,
6566    /// so consecutive shows keep the prior behaviour of reporting the
6567    /// run origin without a per-glyph step.
6568    None,
6569}
6570
6571impl FontMetrics {
6572    /// Horizontal advance (glyph-space, thousandths) for one code.
6573    /// `is_cid` callers pass the CID; simple-font callers pass the
6574    /// byte. Returns 0.0 for [`FontMetrics::None`].
6575    fn width(&self, code: i64) -> f32 {
6576        match self {
6577            FontMetrics::Simple {
6578                first_char,
6579                widths,
6580                missing_width,
6581                ..
6582            } => {
6583                let idx = code - first_char;
6584                if idx >= 0 && (idx as usize) < widths.len() {
6585                    widths[idx as usize]
6586                } else {
6587                    *missing_width
6588                }
6589            }
6590            FontMetrics::Cid {
6591                default_width,
6592                ranges,
6593                ..
6594            } => {
6595                for (start, run) in ranges {
6596                    let off = code - start;
6597                    if off >= 0 && (off as usize) < run.len() {
6598                        return run[off as usize];
6599                    }
6600                }
6601                *default_width
6602            }
6603            FontMetrics::None => 0.0,
6604        }
6605    }
6606
6607    /// Whether codes are two bytes wide (composite Identity fonts).
6608    fn two_byte(&self) -> bool {
6609        matches!(self, FontMetrics::Cid { two_byte: true, .. })
6610    }
6611
6612    /// Factor converting a [`Self::width`] result into text-space units
6613    /// for the §9.4.4 displacement (§9.2.4). Type1 / TrueType widths are
6614    /// thousandths of text space (`0.001`); a Type 3 font carries the
6615    /// horizontal `/FontMatrix` scale instead, since its widths live in
6616    /// glyph space. Composite fonts are thousandths (`/W` / `/DW` are in
6617    /// glyph space with the standard 1000-unit em).
6618    fn text_scale(&self) -> f32 {
6619        match self {
6620            FontMetrics::Simple { text_scale, .. } => *text_scale,
6621            _ => 0.001,
6622        }
6623    }
6624}
6625
6626/// Resolve a font dictionary into [`FontMetrics`].
6627///
6628/// * **Simple fonts** (§9.6.2.1, Table 111): `/FirstChar`, `/Widths`
6629///   (array of numbers), and `/MissingWidth` (from `/FontDescriptor`,
6630///   default 0). When `/Widths` is absent the metrics are
6631///   [`FontMetrics::None`] — the standard-14 base fonts omit `/Widths`
6632///   and their built-in AFM metrics aren't available clean-room here.
6633/// * **Composite (Type0) fonts** (§9.7.4.3): the descendant CIDFont's
6634///   `/W` array (two-or-three-element groups, §9.7.4.3) and `/DW`
6635///   default (default 1000). The walker only resolves the Identity
6636///   CMaps, where CID = 2-byte code.
6637fn build_font_metrics(font: &Dict) -> FontMetrics {
6638    let subtype = font
6639        .entries()
6640        .iter()
6641        .find_map(|(k, v)| match (k.as_str(), v) {
6642            ("Subtype", Object::Name(s)) => Some(s.as_str()),
6643            _ => None,
6644        });
6645    if subtype == Some("Type0") {
6646        return build_cid_metrics(font);
6647    }
6648
6649    // Simple font: /FirstChar + /Widths (+ /MissingWidth in the
6650    // /FontDescriptor). When the document-level resolver has
6651    // dereferenced /Widths it is a direct Array of numbers here.
6652    let first_char = font
6653        .entries()
6654        .iter()
6655        .find(|(k, _)| k == "FirstChar")
6656        .and_then(|(_, v)| number_as_i64(v))
6657        .unwrap_or(0);
6658    let widths = match font.entries().iter().find(|(k, _)| k == "Widths") {
6659        Some((_, Object::Array(items))) => items
6660            .iter()
6661            .map(|o| number_as_f32(o).unwrap_or(0.0))
6662            .collect(),
6663        _ => Vec::new(),
6664    };
6665    if widths.is_empty() {
6666        return FontMetrics::None;
6667    }
6668    let missing_width = font
6669        .entries()
6670        .iter()
6671        .find(|(k, _)| k == "FontDescriptor")
6672        .and_then(|(_, v)| match v {
6673            Object::Dict(d) => d
6674                .entries()
6675                .iter()
6676                .find(|(k, _)| k == "MissingWidth")
6677                .and_then(|(_, v)| number_as_f32(v)),
6678            _ => None,
6679        })
6680        .unwrap_or(0.0);
6681    // §9.6.5: a Type 3 font's /Widths are in glyph space, scaled into
6682    // text space by its /FontMatrix horizontal component (matrix `a`).
6683    // Type1 / TrueType widths are already in thousandths of text space.
6684    // Other simple fonts default to the 1000-unit em.
6685    let text_scale = if subtype == Some("Type3") {
6686        font.entries()
6687            .iter()
6688            .find(|(k, _)| k == "FontMatrix")
6689            .and_then(|(_, v)| match v {
6690                Object::Array(items) if items.len() == 6 => number_as_f32(&items[0]),
6691                _ => None,
6692            })
6693            .filter(|s| s.is_finite())
6694            .unwrap_or(0.001)
6695    } else {
6696        0.001
6697    };
6698    FontMetrics::Simple {
6699        first_char,
6700        widths,
6701        missing_width,
6702        text_scale,
6703    }
6704}
6705
6706/// Resolve a Type0 font's descendant CIDFont metrics (§9.7.4.3).
6707///
6708/// The descendant CIDFont sits in `/DescendantFonts` (a one-element
6709/// array). Its `/DW` is the default width (default 1000) and `/W` is
6710/// the per-CID width array. `/W` groups are either `c [w1 w2 … wn]`
6711/// (consecutive widths starting at CID `c`) or `cfirst clast w` (the
6712/// single width `w` for every CID in `[cfirst, clast]`).
6713fn build_cid_metrics(font: &Dict) -> FontMetrics {
6714    // /Encoding decides the code width. Identity-H/V (the only CMaps
6715    // the writer emits, and the only ones the walker resolves) are
6716    // two-byte, CID = code. A non-Identity named CMap or an embedded
6717    // CMap stream can't be resolved clean-room here, so the safest
6718    // default is the two-byte Identity assumption.
6719    let two_byte = true;
6720    let descendant = font
6721        .entries()
6722        .iter()
6723        .find(|(k, _)| k == "DescendantFonts")
6724        .and_then(|(_, v)| match v {
6725            // The document-level resolver flattens the one-element
6726            // array to the CIDFont dict directly, or leaves it as an
6727            // array whose first element is the dict.
6728            Object::Dict(d) => Some(d.clone()),
6729            Object::Array(items) => items.iter().find_map(|o| match o {
6730                Object::Dict(d) => Some(d.clone()),
6731                _ => None,
6732            }),
6733            _ => None,
6734        });
6735    let Some(cid_font) = descendant else {
6736        return FontMetrics::Cid {
6737            default_width: 1000.0,
6738            ranges: Vec::new(),
6739            two_byte,
6740        };
6741    };
6742    let default_width = cid_font
6743        .entries()
6744        .iter()
6745        .find(|(k, _)| k == "DW")
6746        .and_then(|(_, v)| number_as_f32(v))
6747        .unwrap_or(1000.0);
6748    let ranges = match cid_font.entries().iter().find(|(k, _)| k == "W") {
6749        Some((_, Object::Array(items))) => parse_cid_widths(items),
6750        _ => Vec::new(),
6751    };
6752    FontMetrics::Cid {
6753        default_width,
6754        ranges,
6755        two_byte,
6756    }
6757}
6758
6759/// Parse a CIDFont `/W` array (§9.7.4.3) into `(start_cid, widths)`
6760/// runs. Tolerates malformed groups by skipping forward.
6761fn parse_cid_widths(items: &[Object]) -> Vec<(i64, Vec<f32>)> {
6762    let mut out = Vec::new();
6763    let mut i = 0;
6764    while i < items.len() {
6765        let Some(c) = number_as_i64(&items[i]) else {
6766            i += 1;
6767            continue;
6768        };
6769        match items.get(i + 1) {
6770            // `c [w1 w2 … wn]` — explicit per-CID widths.
6771            Some(Object::Array(ws)) => {
6772                let run: Vec<f32> = ws.iter().map(|o| number_as_f32(o).unwrap_or(0.0)).collect();
6773                out.push((c, run));
6774                i += 2;
6775            }
6776            // `cfirst clast w` — one width over a CID range.
6777            Some(obj) => {
6778                let clast = number_as_i64(obj);
6779                let w = items.get(i + 2).and_then(number_as_f32);
6780                match (clast, w) {
6781                    (Some(clast), Some(w)) if clast >= c => {
6782                        let count = (clast - c + 1).min(1 << 20) as usize;
6783                        out.push((c, vec![w; count]));
6784                        i += 3;
6785                    }
6786                    _ => {
6787                        i += 1;
6788                    }
6789                }
6790            }
6791            None => break,
6792        }
6793    }
6794    out
6795}
6796
6797/// Multiply a [`Paint`]'s carried alpha by a Table 58 alpha constant
6798/// (`CA` / `ca`, §11.6.4.4). For `Paint::Solid` the multiplication
6799/// lands on the `Rgba::a` channel directly; other paint variants
6800/// (gradients, the writer's pattern shading) pass through unchanged
6801/// because the round-3 IR has no per-stop alpha field — partial
6802/// gradient transparency would need a transparency-group XObject
6803/// hand-off the reader doesn't yet emit.
6804fn apply_alpha(paint: Paint, alpha: f32) -> Paint {
6805    if (alpha - 1.0).abs() < f32::EPSILON {
6806        return paint;
6807    }
6808    match paint {
6809        Paint::Solid(rgba) => {
6810            let base = rgba.a as f32 / 255.0;
6811            let combined = (base * alpha).clamp(0.0, 1.0);
6812            Paint::Solid(Rgba::new(
6813                rgba.r,
6814                rgba.g,
6815                rgba.b,
6816                (combined * 255.0).round() as u8,
6817            ))
6818        }
6819        other => other,
6820    }
6821}
6822
6823/// Read an [`Object`] as an `f32`, accepting either `Integer` or
6824/// `Real`. Returns `None` for other variants.
6825fn number_as_f32(obj: &Object) -> Option<f32> {
6826    match obj {
6827        Object::Integer(i) => Some(*i as f32),
6828        Object::Real(r) => Some(*r as f32),
6829        _ => None,
6830    }
6831}
6832
6833/// Read an [`Object`] as an `i64`, accepting `Integer` or `Real`
6834/// (truncating the fractional part — Table 58 `LC` / `LJ` are spec'd
6835/// as integers but tolerating real-typed encoders matches the
6836/// "force into valid range" tolerance §8.4 NOTE 1 calls out).
6837fn number_as_i64(obj: &Object) -> Option<i64> {
6838    match obj {
6839        Object::Integer(i) => Some(*i),
6840        Object::Real(r) => Some(*r as i64),
6841        _ => None,
6842    }
6843}
6844
6845/// Parse a Table 58 `D` value: `[dashArray dashPhase]` two-element
6846/// array, where `dashArray` is itself an array of numbers and
6847/// `dashPhase` is a single integer (treated as a number for parity
6848/// with the `d` operator).
6849fn parse_dash_pair(obj: &Object) -> Option<(Vec<f32>, f32)> {
6850    let Object::Array(items) = obj else {
6851        return None;
6852    };
6853    if items.len() != 2 {
6854        return None;
6855    }
6856    let Object::Array(arr_items) = &items[0] else {
6857        return None;
6858    };
6859    let mut array = Vec::with_capacity(arr_items.len());
6860    for it in arr_items {
6861        array.push(number_as_f32(it)?);
6862    }
6863    let offset = number_as_f32(&items[1])?;
6864    Some((array, offset))
6865}
6866
6867/// Read a heterogeneous PDF array `[ … ]` starting at the `[` byte.
6868/// Items may be numbers (the `d` operator's dash-array shape) or
6869/// strings (the `TJ` operator's mix of `(s) num (s) num`). Other
6870/// nested values (sub-arrays, dicts, names) inside a content-stream
6871/// array are not produced by the writer and we don't try to surface
6872/// them; bytes that aren't whitespace, a number lead, a `(`/`<`, or a
6873/// `]` are skipped to keep tolerant of hand-laid streams.
6874fn read_array(input: &[u8], start: usize) -> Result<(usize, Vec<ArrayElem>), PdfError> {
6875    let mut end = start + 1;
6876    let mut items: Vec<ArrayElem> = Vec::new();
6877    while end < input.len() && input[end] != b']' {
6878        let b = input[end];
6879        if is_whitespace(b) {
6880            end += 1;
6881            continue;
6882        }
6883        if b == b'(' {
6884            let (next, bytes) = read_literal_string(input, end)?;
6885            items.push(ArrayElem::String(bytes));
6886            end = next;
6887            continue;
6888        }
6889        if b == b'<' && input.get(end + 1) != Some(&b'<') {
6890            let (next, bytes) = read_hex_string(input, end)?;
6891            items.push(ArrayElem::String(bytes));
6892            end = next;
6893            continue;
6894        }
6895        if matches!(b, b'+' | b'-' | b'.' | b'0'..=b'9') {
6896            let nstart = end;
6897            match scan_number(input, nstart) {
6898                NumScan::Fast(next, f) => {
6899                    items.push(ArrayElem::Number(f));
6900                    end = next;
6901                }
6902                NumScan::Slow(next) => {
6903                    // Tolerant: a number `str::parse` rejects is
6904                    // dropped, matching the historical policy.
6905                    if let Ok(s) = str::from_utf8(&input[nstart..next]) {
6906                        if let Ok(f) = s.parse::<f32>() {
6907                            items.push(ArrayElem::Number(f));
6908                        }
6909                    }
6910                    end = next;
6911                }
6912                NumScan::NotANumber => {
6913                    // Bare sign / dot — historical behaviour consumed
6914                    // the sign byte(s) scanned so far and dropped
6915                    // them; re-create that by advancing past the
6916                    // non-number prefix one byte at a time.
6917                    end = nstart + 1;
6918                }
6919            }
6920            continue;
6921        }
6922        // Tolerant skip for anything else.
6923        end += 1;
6924    }
6925    if end < input.len() {
6926        end += 1;
6927    } // skip `]`
6928    Ok((end, items))
6929}
6930
6931#[cfg(test)]
6932mod tests {
6933    use super::*;
6934
6935    fn parse(input: &[u8]) -> Group {
6936        parse_content_stream(input).unwrap()
6937    }
6938
6939    /// `scan_number` must be **bit-identical** to `str::parse::<f32>`
6940    /// on every byte string the §7.3.3 number grammar admits — that's
6941    /// the contract that lets the content tokenizer skip the UTF-8 +
6942    /// general-float-parse round trip.
6943    #[test]
6944    fn scan_number_matches_str_parse_bitwise() {
6945        let mut cases: Vec<String> = vec![
6946            "0",
6947            "-0",
6948            "+0",
6949            "5",
6950            "-5",
6951            "+5",
6952            "5.",
6953            "-5.",
6954            ".5",
6955            "-.5",
6956            "+.5",
6957            "0.5",
6958            "595.0",
6959            "842.75",
6960            "0.0001",
6961            "-0.0001",
6962            "123456",
6963            "-123456",
6964            "16777215",
6965            "16777216",
6966            "16777217",
6967            "-16777216",
6968            "999999999",
6969            "0.1234567890",
6970            "0.12345678901",
6971            "3.14159265358979",
6972            "1000000.25",
6973            "-1000000.25",
6974            "0.000000001",
6975            "99999999999999999999",
6976            "-99999999999999999999.5",
6977            "00042",
6978            "-00042.50",
6979        ]
6980        .into_iter()
6981        .map(str::to_owned)
6982        .collect();
6983        // Deterministic generated sweep: every digit count 1..=12 on
6984        // both sides of the dot, signed and unsigned.
6985        let mut state = 0x1234_5678u32;
6986        let mut xs = || {
6987            state ^= state << 13;
6988            state ^= state >> 17;
6989            state ^= state << 5;
6990            state
6991        };
6992        for _ in 0..2000 {
6993            let int_len = (xs() % 13) as usize;
6994            let frac_len = (xs() % 13) as usize;
6995            if int_len == 0 && frac_len == 0 {
6996                continue;
6997            }
6998            let mut s = String::new();
6999            match xs() % 3 {
7000                0 => s.push('-'),
7001                1 => s.push('+'),
7002                _ => {}
7003            }
7004            for _ in 0..int_len {
7005                s.push(char::from(b'0' + (xs() % 10) as u8));
7006            }
7007            if frac_len > 0 {
7008                s.push('.');
7009                for _ in 0..frac_len {
7010                    s.push(char::from(b'0' + (xs() % 10) as u8));
7011                }
7012            }
7013            cases.push(s);
7014        }
7015        for case in &cases {
7016            let bytes = case.as_bytes();
7017            let expected: f32 = case.parse().unwrap_or_else(|_| panic!("parse {case}"));
7018            match scan_number(bytes, 0) {
7019                NumScan::Fast(end, got) => {
7020                    assert_eq!(end, bytes.len(), "consumed all of `{case}`");
7021                    assert_eq!(
7022                        got.to_bits(),
7023                        expected.to_bits(),
7024                        "`{case}`: fast {got} vs parse {expected}"
7025                    );
7026                }
7027                NumScan::Slow(end) => {
7028                    assert_eq!(end, bytes.len(), "consumed all of `{case}`");
7029                    // Slow path re-parses through str::parse — identical
7030                    // by construction.
7031                }
7032                NumScan::NotANumber => panic!("`{case}` should scan as a number"),
7033            }
7034        }
7035    }
7036
7037    #[test]
7038    fn scan_number_rejects_bare_sign_and_dot() {
7039        for case in [&b"-"[..], b"+", b".", b"-.", b"+.", b"-x", b".)"] {
7040            assert!(
7041                matches!(scan_number(case, 0), NumScan::NotANumber),
7042                "{case:?} must not scan as a number"
7043            );
7044        }
7045    }
7046
7047    #[test]
7048    fn scan_number_stops_at_delimiters_and_whitespace() {
7049        // `]` after a TJ adjustment, space between operands, an
7050        // operator straight after the digits.
7051        let input = b"-12.5]";
7052        match scan_number(input, 0) {
7053            NumScan::Fast(end, v) => {
7054                assert_eq!(end, 5);
7055                assert_eq!(v.to_bits(), (-12.5f32).to_bits());
7056            }
7057            _ => panic!("expected fast scan"),
7058        }
7059        let input = b"7 0 R";
7060        match scan_number(input, 0) {
7061            NumScan::Fast(end, v) => {
7062                assert_eq!(end, 1);
7063                assert_eq!(v, 7.0);
7064            }
7065            _ => panic!("expected fast scan"),
7066        }
7067        // Second dot terminates the number (next token starts at it).
7068        let input = b"1.2.3";
7069        match scan_number(input, 0) {
7070            NumScan::Fast(end, v) => {
7071                assert_eq!(end, 3);
7072                assert_eq!(v.to_bits(), (1.2f32).to_bits());
7073            }
7074            _ => panic!("expected fast scan"),
7075        }
7076    }
7077
7078    #[test]
7079    fn empty_content_yields_empty_group() {
7080        let g = parse(b"");
7081        assert!(g.children.is_empty());
7082        assert!(g.clip.is_none());
7083    }
7084
7085    #[test]
7086    fn rect_fill_round_trips() {
7087        // The writer would emit something like:
7088        //   q 1 0 0 rg 10 10 m 110 10 l 110 60 l 10 60 l h f Q
7089        let bytes = b"q 1 0 0 rg 10 10 m 110 10 l 110 60 l 10 60 l h f Q\n";
7090        let root = parse(bytes);
7091        // One child group containing the path.
7092        assert_eq!(root.children.len(), 1);
7093        let Node::Group(g) = &root.children[0] else {
7094            panic!("expected group")
7095        };
7096        assert_eq!(g.children.len(), 1);
7097        let Node::Path(pn) = &g.children[0] else {
7098            panic!("expected path")
7099        };
7100        // 4 verts + close = 5 commands.
7101        assert_eq!(pn.path.commands.len(), 5);
7102        assert!(matches!(pn.path.commands[0], PathCommand::MoveTo(p) if (p.x - 10.0).abs() < 1e-3));
7103        assert!(matches!(pn.path.commands[4], PathCommand::Close));
7104        assert_eq!(pn.fill_rule, FillRule::NonZero);
7105        // Fill is solid red.
7106        match &pn.fill {
7107            Some(Paint::Solid(r)) => assert_eq!((r.r, r.g, r.b), (255, 0, 0)),
7108            other => panic!("unexpected fill: {other:?}"),
7109        }
7110        assert!(pn.stroke.is_none());
7111    }
7112
7113    #[test]
7114    fn nested_q_groups_are_promoted_to_node_groups() {
7115        let bytes = b"q q 1 0 0 1 5 5 cm 0 0 m 10 10 l S Q Q\n";
7116        let root = parse(bytes);
7117        assert_eq!(root.children.len(), 1);
7118        let Node::Group(outer) = &root.children[0] else {
7119            panic!()
7120        };
7121        assert_eq!(outer.children.len(), 1);
7122        let Node::Group(inner) = &outer.children[0] else {
7123            panic!()
7124        };
7125        // Inner group has the cm transform.
7126        assert!(!inner.transform.is_identity());
7127        assert_eq!(inner.children.len(), 1);
7128    }
7129
7130    #[test]
7131    fn rectangle_operator_re_expands_to_subpath() {
7132        // 10 20 30 40 re → subpath of M(10,20), L(40,20), L(40,60), L(10,60), h
7133        let bytes = b"q 0.5 0.5 0.5 rg 10 20 30 40 re f Q\n";
7134        let root = parse(bytes);
7135        let Node::Group(g) = &root.children[0] else {
7136            panic!()
7137        };
7138        let Node::Path(p) = &g.children[0] else {
7139            panic!()
7140        };
7141        assert_eq!(p.path.commands.len(), 5);
7142        assert!(
7143            matches!(p.path.commands[0], PathCommand::MoveTo(pp) if pp.x == 10.0 && pp.y == 20.0)
7144        );
7145        assert!(
7146            matches!(p.path.commands[1], PathCommand::LineTo(pp) if pp.x == 40.0 && pp.y == 20.0)
7147        );
7148        assert!(
7149            matches!(p.path.commands[2], PathCommand::LineTo(pp) if pp.x == 40.0 && pp.y == 60.0)
7150        );
7151        assert!(
7152            matches!(p.path.commands[3], PathCommand::LineTo(pp) if pp.x == 10.0 && pp.y == 60.0)
7153        );
7154        assert!(matches!(p.path.commands[4], PathCommand::Close));
7155    }
7156
7157    #[test]
7158    fn cubic_curve_roundtrips() {
7159        let bytes = b"q 0 0 m 1 1 2 1 3 0 c S Q\n";
7160        let root = parse(bytes);
7161        let Node::Group(g) = &root.children[0] else {
7162            panic!()
7163        };
7164        let Node::Path(p) = &g.children[0] else {
7165            panic!()
7166        };
7167        assert!(matches!(
7168            p.path.commands[1],
7169            PathCommand::CubicCurveTo { c1, c2, end }
7170                if c1.x == 1.0 && c1.y == 1.0 && c2.x == 2.0 && c2.y == 1.0 && end.x == 3.0 && end.y == 0.0
7171        ));
7172    }
7173
7174    #[test]
7175    fn fill_rule_evenodd_recognised() {
7176        let bytes = b"q 0 0 m 10 0 l 10 10 l h f* Q\n";
7177        let root = parse(bytes);
7178        let Node::Group(g) = &root.children[0] else {
7179            panic!()
7180        };
7181        let Node::Path(p) = &g.children[0] else {
7182            panic!()
7183        };
7184        assert_eq!(p.fill_rule, FillRule::EvenOdd);
7185    }
7186
7187    #[test]
7188    fn cm_translate_lands_on_group_transform() {
7189        let bytes = b"q 1 0 0 1 100 200 cm 0 0 m 5 5 l S Q\n";
7190        let root = parse(bytes);
7191        let Node::Group(g) = &root.children[0] else {
7192            panic!()
7193        };
7194        assert!((g.transform.e - 100.0).abs() < 1e-3);
7195        assert!((g.transform.f - 200.0).abs() < 1e-3);
7196    }
7197
7198    #[test]
7199    fn stroke_style_w_j_m_d_recorded() {
7200        let bytes = b"q 2.5 w 1 J 2 j 8 M [5 3] 1 d 0 0 0 RG 0 0 m 10 10 l S Q\n";
7201        let root = parse(bytes);
7202        let Node::Group(g) = &root.children[0] else {
7203            panic!()
7204        };
7205        let Node::Path(p) = &g.children[0] else {
7206            panic!()
7207        };
7208        let s = p.stroke.as_ref().expect("stroke set");
7209        assert!((s.width - 2.5).abs() < 1e-3);
7210        assert!(matches!(s.cap, LineCap::Round));
7211        assert!(matches!(s.join, LineJoin::Bevel));
7212        assert!((s.miter_limit - 8.0).abs() < 1e-3);
7213        let dash = s.dash.as_ref().expect("dash set");
7214        assert_eq!(dash.array, vec![5.0, 3.0]);
7215        assert!((dash.offset - 1.0).abs() < 1e-3);
7216    }
7217
7218    #[test]
7219    fn clip_w_assigns_to_group_clip() {
7220        // Clip operator: build a path, hit `W`, then `n` to consume.
7221        let bytes =
7222            b"q 10 10 m 50 10 l 50 50 l 10 50 l h W n 0 0 0 rg 20 20 m 30 20 l 30 30 l h f Q\n";
7223        let root = parse(bytes);
7224        let Node::Group(g) = &root.children[0] else {
7225            panic!()
7226        };
7227        assert!(g.clip.is_some());
7228        // The triangle painted afterwards lives as a child node.
7229        assert_eq!(g.children.len(), 1);
7230    }
7231
7232    /// §10.3.5 fundamental cases: pure inks convert to their RGB
7233    /// complements, and pure black yields RGB black.
7234    #[test]
7235    fn cmyk_pure_inks_convert_per_10_3_5() {
7236        // cyan=1 → red=1−min(1,1+0)=0, green=blue=1 → (0,255,255).
7237        assert_eq!(rgb_from_cmyk(1.0, 0.0, 0.0, 0.0), Rgba::opaque(0, 255, 255));
7238        // magenta=1 → (255,0,255).
7239        assert_eq!(rgb_from_cmyk(0.0, 1.0, 0.0, 0.0), Rgba::opaque(255, 0, 255));
7240        // yellow=1 → (255,255,0).
7241        assert_eq!(rgb_from_cmyk(0.0, 0.0, 1.0, 0.0), Rgba::opaque(255, 255, 0));
7242        // black=1 → every channel 1−min(1,0+1)=0 → (0,0,0).
7243        assert_eq!(rgb_from_cmyk(0.0, 0.0, 0.0, 1.0), Rgba::opaque(0, 0, 0));
7244        // all zero → white.
7245        assert_eq!(
7246            rgb_from_cmyk(0.0, 0.0, 0.0, 0.0),
7247            Rgba::opaque(255, 255, 255)
7248        );
7249    }
7250
7251    /// The `min(1.0, comp + black)` ceiling caps the sum so an ink
7252    /// plus black never wraps past full saturation.
7253    #[test]
7254    fn cmyk_component_plus_black_clamps_at_one() {
7255        // cyan=0.7 black=0.7 → red=1−min(1,1.4)=0; green/blue=1−0.7=0.3.
7256        let r = rgb_from_cmyk(0.7, 0.0, 0.0, 0.7);
7257        assert_eq!(r.r, 0);
7258        assert_eq!(r.g, (0.3f32 * 255.0).round() as u8);
7259        assert_eq!(r.b, (0.3f32 * 255.0).round() as u8);
7260    }
7261
7262    /// Out-of-range operands are clamped before the formula (§10.3.4
7263    /// NOTE 4 nearest-valid-value substitution).
7264    #[test]
7265    fn cmyk_out_of_range_operands_clamp() {
7266        // Negative and >1 operands behave as 0.0 / 1.0.
7267        assert_eq!(
7268            rgb_from_cmyk(-0.5, 2.0, 0.0, 0.0),
7269            rgb_from_cmyk(0.0, 1.0, 0.0, 0.0)
7270        );
7271    }
7272
7273    /// End-to-end through the content parser: `k` sets the fill paint,
7274    /// `K` sets the stroke paint, both via the §10.3.5 conversion.
7275    #[test]
7276    fn k_and_upper_k_operators_apply_cmyk_conversion() {
7277        // Fill = pure cyan (0,255,255); stroke = pure magenta (255,0,255).
7278        let bytes = b"q 1 0 0 0 k 0 1 0 0 K 0 0 m 10 10 l 10 0 l h B Q\n";
7279        let root = parse(bytes);
7280        let Node::Group(g) = &root.children[0] else {
7281            panic!("expected group")
7282        };
7283        let Node::Path(p) = &g.children[0] else {
7284            panic!("expected path")
7285        };
7286        match &p.fill {
7287            Some(Paint::Solid(c)) => assert_eq!((c.r, c.g, c.b), (0, 255, 255)),
7288            other => panic!("unexpected fill: {other:?}"),
7289        }
7290        let s = p.stroke.as_ref().expect("stroke set");
7291        match &s.paint {
7292            Paint::Solid(c) => assert_eq!((c.r, c.g, c.b), (255, 0, 255)),
7293            other => panic!("unexpected stroke paint: {other:?}"),
7294        }
7295    }
7296
7297    // ── Colour-space selection: `cs` / `CS` + `sc` / `scn` (round 118) ──
7298
7299    /// Helper: parse a stream and return the first painted path node.
7300    fn first_path(bytes: &[u8]) -> PathNode {
7301        let root = parse(bytes);
7302        let Node::Group(g) = &root.children[0] else {
7303            panic!("expected group");
7304        };
7305        let Node::Path(p) = &g.children[0] else {
7306            panic!("expected path");
7307        };
7308        p.clone()
7309    }
7310
7311    fn fill_rgb(p: &PathNode) -> (u8, u8, u8) {
7312        match &p.fill {
7313            Some(Paint::Solid(c)) => (c.r, c.g, c.b),
7314            other => panic!("unexpected fill: {other:?}"),
7315        }
7316    }
7317
7318    /// `/DeviceRGB cs 1 0 0 sc` selects DeviceRGB then sets a red fill
7319    /// (§8.6.8). Before round 118 the parser collapsed every `sc` to
7320    /// black; the spec example `/DeviceRGB CS  red green blue SC`
7321    /// (§8.6.4.3) is the stroking analogue.
7322    #[test]
7323    fn cs_devicergb_then_sc_sets_rgb_fill() {
7324        let bytes = b"q /DeviceRGB cs 1 0 0 sc 0 0 m 10 10 l 10 0 l h f Q\n";
7325        assert_eq!(fill_rgb(&first_path(bytes)), (255, 0, 0));
7326    }
7327
7328    /// `/DeviceGray cs 0.5 sc` — one-component grey (§8.6.4.2).
7329    #[test]
7330    fn cs_devicegray_then_sc_sets_gray_fill() {
7331        let bytes = b"q /DeviceGray cs 0.5 sc 0 0 m 10 10 l 10 0 l h f Q\n";
7332        let (r, g, b) = fill_rgb(&first_path(bytes));
7333        let expect = (0.5f32 * 255.0).round() as u8;
7334        assert_eq!((r, g, b), (expect, expect, expect));
7335    }
7336
7337    /// `/DeviceCMYK cs 1 0 0 0 scn` — pure cyan via the §10.3.5
7338    /// conversion, matching the `1 0 0 0 k` operator's result.
7339    #[test]
7340    fn cs_devicecmyk_then_scn_sets_cmyk_fill() {
7341        let bytes = b"q /DeviceCMYK cs 1 0 0 0 scn 0 0 m 10 10 l 10 0 l h f Q\n";
7342        assert_eq!(fill_rgb(&first_path(bytes)), (0, 255, 255));
7343    }
7344
7345    /// Stroking side: `/DeviceRGB CS 0 1 0 SC` sets a green stroke.
7346    #[test]
7347    fn upper_cs_and_upper_sc_set_stroke_color() {
7348        let bytes = b"q /DeviceRGB CS 0 1 0 SC 0 0 m 10 10 l S Q\n";
7349        let p = first_path(bytes);
7350        let s = p.stroke.as_ref().expect("stroke set");
7351        match &s.paint {
7352            Paint::Solid(c) => assert_eq!((c.r, c.g, c.b), (0, 255, 0)),
7353            other => panic!("unexpected stroke paint: {other:?}"),
7354        }
7355    }
7356
7357    /// A `/Pattern cs … /P0 scn` pair carries a `/Name` operand and an
7358    /// unknown space — the parser keeps the conservative black fallback
7359    /// rather than misreading the pattern name as colour components.
7360    #[test]
7361    fn pattern_scn_keeps_black_fallback() {
7362        let bytes = b"q /Pattern cs /P0 scn 0 0 m 10 10 l 10 0 l h f Q\n";
7363        assert_eq!(fill_rgb(&first_path(bytes)), (0, 0, 0));
7364    }
7365
7366    /// Build a `/PatternType 2` (shading-pattern) dict wrapping an axial
7367    /// shading from `coords` with a black→white function, optionally with
7368    /// a `/Matrix`.
7369    fn shading_pattern(coords: [f32; 4], matrix: Option<[f32; 6]>) -> Dict {
7370        let shading = Dict::new()
7371            .with("ShadingType", Object::Integer(2))
7372            .with("ColorSpace", Object::Name("DeviceRGB".into()))
7373            .with(
7374                "Coords",
7375                Object::Array(coords.into_iter().map(|n| Object::Real(n as f64)).collect()),
7376            )
7377            .with("Function", exp_black_to_white());
7378        let mut d = Dict::new()
7379            .with("PatternType", Object::Integer(2))
7380            .with("Shading", Object::Dict(shading));
7381        if let Some(m) = matrix {
7382            d.set(
7383                "Matrix",
7384                Object::Array(m.into_iter().map(|n| Object::Real(n as f64)).collect()),
7385            );
7386        }
7387        d
7388    }
7389
7390    /// Parse with `/Resources /Pattern` plumbed in, return the first
7391    /// painted path's fill `Paint`.
7392    fn first_fill_with_pattern(bytes: &[u8], patterns: &Dict) -> Paint {
7393        let parsed = parse_content_stream_full_with_patterns(
7394            bytes,
7395            None,
7396            None,
7397            None,
7398            None,
7399            None,
7400            None,
7401            Some(patterns),
7402        )
7403        .unwrap();
7404        let Node::Group(g) = &parsed.root.children[0] else {
7405            panic!("expected group");
7406        };
7407        let Node::Path(p) = &g.children[0] else {
7408            panic!("expected path");
7409        };
7410        p.fill.clone().expect("path has a fill")
7411    }
7412
7413    /// A `/Pattern cs /P0 scn` fill whose `/P0` is a `/PatternType 2`
7414    /// axial shading pattern paints a `Paint::LinearGradient`, not the
7415    /// black fallback. The gradient runs along the shading axis and its
7416    /// stops sweep black → white.
7417    #[test]
7418    fn shading_pattern_axial_paints_linear_gradient() {
7419        let pat = Dict::new().with(
7420            "P0",
7421            Object::Dict(shading_pattern([0.0, 0.0, 100.0, 0.0], None)),
7422        );
7423        let bytes = b"q /Pattern cs /P0 scn 0 0 m 10 10 l 10 0 l h f Q\n";
7424        let Paint::LinearGradient(lg) = first_fill_with_pattern(bytes, &pat) else {
7425            panic!("expected a linear gradient");
7426        };
7427        // Identity CTM + no Matrix: axis endpoints pass through verbatim.
7428        assert!((lg.start.x - 0.0).abs() < 1e-3 && (lg.start.y - 0.0).abs() < 1e-3);
7429        assert!((lg.end.x - 100.0).abs() < 1e-3 && (lg.end.y - 0.0).abs() < 1e-3);
7430        assert_eq!(lg.stops.len(), 64);
7431        assert_eq!((lg.stops[0].color.r, lg.stops[0].color.g), (0, 0));
7432        let last = lg.stops.last().unwrap();
7433        assert_eq!((last.color.r, last.color.g, last.color.b), (255, 255, 255));
7434        // Offsets span 0.0..=1.0 monotonically.
7435        assert!((lg.stops[0].offset - 0.0).abs() < 1e-6);
7436        assert!((last.offset - 1.0).abs() < 1e-6);
7437    }
7438
7439    /// The pattern's `/Matrix` maps the shading axis into target space:
7440    /// a translate-by-(50, 20) matrix shifts both endpoints.
7441    #[test]
7442    fn shading_pattern_matrix_transforms_axis() {
7443        let pat = Dict::new().with(
7444            "P0",
7445            Object::Dict(shading_pattern(
7446                [0.0, 0.0, 100.0, 0.0],
7447                Some([1.0, 0.0, 0.0, 1.0, 50.0, 20.0]),
7448            )),
7449        );
7450        let bytes = b"q /Pattern cs /P0 scn 0 0 m 10 10 l 10 0 l h f Q\n";
7451        let Paint::LinearGradient(lg) = first_fill_with_pattern(bytes, &pat) else {
7452            panic!("expected a linear gradient");
7453        };
7454        assert!((lg.start.x - 50.0).abs() < 1e-3 && (lg.start.y - 20.0).abs() < 1e-3);
7455        assert!((lg.end.x - 150.0).abs() < 1e-3 && (lg.end.y - 20.0).abs() < 1e-3);
7456    }
7457
7458    /// A radial shading pattern paints a `Paint::RadialGradient` whose
7459    /// outer circle is the shading's ending circle.
7460    #[test]
7461    fn shading_pattern_radial_paints_radial_gradient() {
7462        let shading = Dict::new()
7463            .with("ShadingType", Object::Integer(3))
7464            .with("ColorSpace", Object::Name("DeviceRGB".into()))
7465            .with(
7466                "Coords",
7467                Object::Array(
7468                    [10.0, 20.0, 0.0, 10.0, 20.0, 40.0]
7469                        .into_iter()
7470                        .map(|n: f64| Object::Real(n))
7471                        .collect(),
7472                ),
7473            )
7474            .with("Function", exp_black_to_white());
7475        let pat = Dict::new().with(
7476            "P0",
7477            Object::Dict(
7478                Dict::new()
7479                    .with("PatternType", Object::Integer(2))
7480                    .with("Shading", Object::Dict(shading)),
7481            ),
7482        );
7483        let bytes = b"q /Pattern cs /P0 scn 0 0 m 10 10 l 10 0 l h f Q\n";
7484        let Paint::RadialGradient(rg) = first_fill_with_pattern(bytes, &pat) else {
7485            panic!("expected a radial gradient");
7486        };
7487        assert!((rg.center.x - 10.0).abs() < 1e-3 && (rg.center.y - 20.0).abs() < 1e-3);
7488        assert!((rg.radius - 40.0).abs() < 1e-3);
7489        assert_eq!(rg.stops.len(), 64);
7490    }
7491
7492    /// A `/PatternType 1` (tiling) pattern has no scene-gradient analogue
7493    /// this round — the fill stays the black fallback.
7494    #[test]
7495    fn tiling_pattern_keeps_black_fallback() {
7496        let pat = Dict::new().with(
7497            "P0",
7498            Object::Dict(Dict::new().with("PatternType", Object::Integer(1))),
7499        );
7500        let bytes = b"q /Pattern cs /P0 scn 0 0 m 10 10 l 10 0 l h f Q\n";
7501        match first_fill_with_pattern(bytes, &pat) {
7502            Paint::Solid(c) => assert_eq!((c.r, c.g, c.b), (0, 0, 0)),
7503            other => panic!("expected black solid fallback, got {other:?}"),
7504        }
7505    }
7506
7507    /// A `cs` naming an unresolved `/Resources /ColorSpace` key (here a
7508    /// CIE-based `/CS0`) is `Unknown`: a following `sc` can't be
7509    /// interpreted without the resource definition, so the fill stays
7510    /// black.
7511    #[test]
7512    fn unknown_resource_colorspace_sc_keeps_black_fallback() {
7513        let bytes = b"q /CS0 cs 0.2 0.4 0.6 sc 0 0 m 10 10 l 10 0 l h f Q\n";
7514        assert_eq!(fill_rgb(&first_path(bytes)), (0, 0, 0));
7515    }
7516
7517    /// Setting a device colour space with a bare `cs` (no following
7518    /// `sc`) initialises the colour to black per §8.6.4.2..4.
7519    #[test]
7520    fn bare_cs_initialises_color_to_black() {
7521        let bytes = b"q /DeviceRGB cs 0 0 m 10 10 l 10 0 l h f Q\n";
7522        assert_eq!(fill_rgb(&first_path(bytes)), (0, 0, 0));
7523    }
7524
7525    /// `sc`/`scn` interpret operands in whatever the *last* `cs`
7526    /// selected — switching spaces mid-stream re-routes the next colour.
7527    #[test]
7528    fn switching_colorspace_reroutes_following_sc() {
7529        let bytes = b"q /DeviceGray cs 1 sc /DeviceRGB cs 0 0 1 sc \
7530                      0 0 m 10 10 l 10 0 l h f Q\n";
7531        // Final colour is the DeviceRGB blue, not the grey white.
7532        assert_eq!(fill_rgb(&first_path(bytes)), (0, 0, 255));
7533    }
7534
7535    /// `from_name` maps the three device families (long + abbreviated
7536    /// inline-image spellings) and routes everything else to `Unknown`.
7537    #[test]
7538    fn color_space_from_name_table() {
7539        assert_eq!(
7540            ColorSpaceKind::from_name("DeviceGray"),
7541            ColorSpaceKind::DeviceGray
7542        );
7543        assert_eq!(ColorSpaceKind::from_name("G"), ColorSpaceKind::DeviceGray);
7544        assert_eq!(
7545            ColorSpaceKind::from_name("DeviceRGB"),
7546            ColorSpaceKind::DeviceRgb
7547        );
7548        assert_eq!(ColorSpaceKind::from_name("RGB"), ColorSpaceKind::DeviceRgb);
7549        assert_eq!(
7550            ColorSpaceKind::from_name("DeviceCMYK"),
7551            ColorSpaceKind::DeviceCmyk
7552        );
7553        assert_eq!(
7554            ColorSpaceKind::from_name("CMYK"),
7555            ColorSpaceKind::DeviceCmyk
7556        );
7557        assert_eq!(
7558            ColorSpaceKind::from_name("Pattern"),
7559            ColorSpaceKind::Unknown
7560        );
7561        assert_eq!(ColorSpaceKind::from_name("CS0"), ColorSpaceKind::Unknown);
7562    }
7563
7564    // ── Resource colour-space resolution (round 275) ──────────────
7565
7566    /// Helper: parse with a `/Resources /ColorSpace` dict plumbed in,
7567    /// return the first painted path's fill RGB.
7568    fn first_fill_with_cs(bytes: &[u8], cs: &Dict) -> (u8, u8, u8) {
7569        let parsed =
7570            parse_content_stream_full_with_color_space(bytes, None, None, None, Some(cs)).unwrap();
7571        let Node::Group(g) = &parsed.root.children[0] else {
7572            panic!("expected group");
7573        };
7574        let Node::Path(p) = &g.children[0] else {
7575            panic!("expected path");
7576        };
7577        match &p.fill {
7578            Some(Paint::Solid(c)) => (c.r, c.g, c.b),
7579            other => panic!("unexpected fill: {other:?}"),
7580        }
7581    }
7582
7583    /// `[ /ICCBased << /N 3 >> ]` with no `/Alternate` reduces to
7584    /// DeviceRGB per §8.6.5.5; a following `sc` reads three components.
7585    #[test]
7586    fn icc_based_n3_resolves_devicergb() {
7587        let arr = Object::Array(vec![
7588            Object::Name("ICCBased".into()),
7589            Object::Dict(Dict::new().with("N", Object::Integer(3))),
7590        ]);
7591        assert_eq!(color_space_from_object(&arr), ColorSpaceKind::DeviceRgb);
7592
7593        let cs = Dict::new().with("CS0", arr);
7594        let bytes = b"q /CS0 cs 1 0 0 sc 0 0 m 10 10 l 10 0 l h f Q\n";
7595        assert_eq!(first_fill_with_cs(bytes, &cs), (255, 0, 0));
7596    }
7597
7598    /// `/N 1` → DeviceGray, `/N 4` → DeviceCMYK (§8.6.5.5 fallback).
7599    #[test]
7600    fn icc_based_n1_and_n4_resolve_gray_and_cmyk() {
7601        let gray = Object::Array(vec![
7602            Object::Name("ICCBased".into()),
7603            Object::Dict(Dict::new().with("N", Object::Integer(1))),
7604        ]);
7605        assert_eq!(color_space_from_object(&gray), ColorSpaceKind::DeviceGray);
7606        let cmyk = Object::Array(vec![
7607            Object::Name("ICCBased".into()),
7608            Object::Dict(Dict::new().with("N", Object::Integer(4))),
7609        ]);
7610        assert_eq!(color_space_from_object(&cmyk), ColorSpaceKind::DeviceCmyk);
7611
7612        // End-to-end: N=4 CMYK pure cyan → (0,255,255).
7613        let cs = Dict::new().with("CS0", cmyk);
7614        let bytes = b"q /CS0 cs 1 0 0 0 scn 0 0 m 10 10 l 10 0 l h f Q\n";
7615        assert_eq!(first_fill_with_cs(bytes, &cs), (0, 255, 255));
7616    }
7617
7618    /// `/Alternate` overrides the `/N` fallback when present and
7619    /// reducible (§8.6.5.5 — "an alternate colour space that shall be
7620    /// used in case the one specified in the stream data is not
7621    /// supported").
7622    #[test]
7623    fn icc_based_alternate_wins_over_n() {
7624        // N says 3 but /Alternate names DeviceCMYK (a deliberately
7625        // mismatched fixture to prove the Alternate path is taken).
7626        let arr = Object::Array(vec![
7627            Object::Name("ICCBased".into()),
7628            Object::Dict(
7629                Dict::new()
7630                    .with("N", Object::Integer(3))
7631                    .with("Alternate", Object::Name("DeviceCMYK".into())),
7632            ),
7633        ]);
7634        assert_eq!(color_space_from_object(&arr), ColorSpaceKind::DeviceCmyk);
7635    }
7636
7637    /// An ICCBased dict with no `/N` and no reducible `/Alternate`
7638    /// stays `Unknown` (the round-118 black fallback).
7639    #[test]
7640    fn icc_based_without_n_is_unknown() {
7641        let arr = Object::Array(vec![
7642            Object::Name("ICCBased".into()),
7643            Object::Dict(Dict::new()),
7644        ]);
7645        assert_eq!(color_space_from_object(&arr), ColorSpaceKind::Unknown);
7646    }
7647
7648    /// `[ /Indexed /DeviceRGB 2 <000000 FF0000 00FF00> ]` — the
7649    /// §8.6.6.3 Example 1 shape. `1 sc` selects entry 1 (red).
7650    #[test]
7651    fn indexed_devicergb_index_selects_table_entry() {
7652        // hival 2 → 3 entries × 3 bytes = 9 bytes.
7653        let table = vec![
7654            0x00, 0x00, 0x00, // entry 0 = black
7655            0xFF, 0x00, 0x00, // entry 1 = red
7656            0x00, 0xFF, 0x00, // entry 2 = green
7657        ];
7658        let arr = Object::Array(vec![
7659            Object::Name("Indexed".into()),
7660            Object::Name("DeviceRGB".into()),
7661            Object::Integer(2),
7662            Object::HexString(table),
7663        ]);
7664        let cs = Dict::new().with("CS0", arr);
7665
7666        // index 1 → red.
7667        let bytes = b"q /CS0 cs 1 sc 0 0 m 10 10 l 10 0 l h f Q\n";
7668        assert_eq!(first_fill_with_cs(bytes, &cs), (255, 0, 0));
7669    }
7670
7671    /// Bare `cs` to an Indexed space initialises the colour to table
7672    /// entry 0 per §8.6.6.3 ("shall initialize the corresponding
7673    /// current colour to 0").
7674    #[test]
7675    fn indexed_bare_cs_uses_entry_zero() {
7676        let table = vec![0x10, 0x20, 0x30, 0xFF, 0xFF, 0xFF];
7677        let arr = Object::Array(vec![
7678            Object::Name("Indexed".into()),
7679            Object::Name("DeviceRGB".into()),
7680            Object::Integer(1),
7681            Object::HexString(table),
7682        ]);
7683        let cs = Dict::new().with("CS0", arr);
7684        // No `sc` — bare `cs` should leave the entry-0 colour in force.
7685        let bytes = b"q /CS0 cs 0 0 m 10 10 l 10 0 l h f Q\n";
7686        assert_eq!(first_fill_with_cs(bytes, &cs), (0x10, 0x20, 0x30));
7687    }
7688
7689    /// An out-of-range index is clamped to `0..=hival` and a fractional
7690    /// index rounds to nearest (§8.6.6.3).
7691    #[test]
7692    fn indexed_index_rounds_and_clamps() {
7693        let table = vec![
7694            0x00, 0x00, 0x00, // 0
7695            0x40, 0x40, 0x40, // 1
7696            0x80, 0x80, 0x80, // 2
7697        ];
7698        let mk = |arr| Dict::new().with("CS0", arr);
7699        let arr = || {
7700            Object::Array(vec![
7701                Object::Name("Indexed".into()),
7702                Object::Name("DeviceRGB".into()),
7703                Object::Integer(2),
7704                Object::HexString(table.clone()),
7705            ])
7706        };
7707        // 1.6 rounds to 2 → 0x80.
7708        let bytes = b"q /CS0 cs 1.6 sc 0 0 m 10 10 l 10 0 l h f Q\n";
7709        assert_eq!(first_fill_with_cs(bytes, &mk(arr())), (0x80, 0x80, 0x80));
7710        // 9 clamps to hival=2 → 0x80.
7711        let bytes = b"q /CS0 cs 9 sc 0 0 m 10 10 l 10 0 l h f Q\n";
7712        assert_eq!(first_fill_with_cs(bytes, &mk(arr())), (0x80, 0x80, 0x80));
7713        // -3 clamps to 0 → black.
7714        let bytes = b"q /CS0 cs -3 sc 0 0 m 10 10 l 10 0 l h f Q\n";
7715        assert_eq!(first_fill_with_cs(bytes, &mk(arr())), (0x00, 0x00, 0x00));
7716    }
7717
7718    /// An Indexed base that doesn't reduce to a device family (here a
7719    /// CIE-based `/Lab`) stays `Unknown` — no table lookup is possible.
7720    #[test]
7721    fn indexed_nondevice_base_is_unknown() {
7722        let arr = Object::Array(vec![
7723            Object::Name("Indexed".into()),
7724            Object::Name("Lab".into()),
7725            Object::Integer(1),
7726            Object::HexString(vec![0, 0, 0, 1, 1, 1]),
7727        ]);
7728        assert_eq!(color_space_from_object(&arr), ColorSpaceKind::Unknown);
7729    }
7730
7731    /// A truncated Indexed table (too short for the selected entry)
7732    /// produces no colour, so the prior colour is retained — here the
7733    /// entry-0 colour the bare `cs` initialised — rather than reading
7734    /// past the buffer. `indexed_color` returns `None` for the missing
7735    /// slot.
7736    #[test]
7737    fn indexed_truncated_table_returns_none_for_missing_slot() {
7738        // hival 2 declared but only entry 0 (black) is present.
7739        let table = vec![0x00, 0x00, 0x00];
7740        let base = ColorSpaceKind::DeviceRgb;
7741        // Entry 0 resolves.
7742        assert!(indexed_color(&base, 2, &table, 0.0).is_some());
7743        // Entry 2 is past the buffer → None (no out-of-bounds read).
7744        assert!(indexed_color(&base, 2, &table, 2.0).is_none());
7745
7746        // End-to-end: bare `cs` sets entry 0 (black); the truncated
7747        // `2 sc` returns None so the fill stays the entry-0 black.
7748        let arr = Object::Array(vec![
7749            Object::Name("Indexed".into()),
7750            Object::Name("DeviceRGB".into()),
7751            Object::Integer(2),
7752            Object::HexString(table),
7753        ]);
7754        let cs = Dict::new().with("CS0", arr);
7755        let bytes = b"q /CS0 cs 2 sc 0 0 m 10 10 l 10 0 l h f Q\n";
7756        assert_eq!(first_fill_with_cs(bytes, &cs), (0, 0, 0));
7757    }
7758
7759    /// A device family name in `cs` still resolves directly even when a
7760    /// `/Resources /ColorSpace` dict is present (a resource key cannot
7761    /// shadow a device family per §8.6.8 Table 74).
7762    #[test]
7763    fn device_name_resolves_without_consulting_resources() {
7764        let cs = Dict::new().with("DeviceRGB", Object::Name("DeviceGray".into()));
7765        let bytes = b"q /DeviceRGB cs 1 0 0 sc 0 0 m 10 10 l 10 0 l h f Q\n";
7766        assert_eq!(first_fill_with_cs(bytes, &cs), (255, 0, 0));
7767    }
7768
7769    /// Without a plumbed-in `/ColorSpace` dict, a resource key stays
7770    /// `Unknown` (round-118 behaviour preserved).
7771    #[test]
7772    fn resource_key_without_resources_stays_unknown() {
7773        assert_eq!(
7774            ColorSpaceKind::resolve_with_resources("CS0", None),
7775            ColorSpaceKind::Unknown
7776        );
7777    }
7778
7779    // ── PDF functions §7.10 + Separation colour space §8.6.6.4 ──
7780
7781    fn num_arr(vals: &[f32]) -> Object {
7782        Object::Array(vals.iter().map(|v| Object::Real(*v as f64)).collect())
7783    }
7784
7785    /// A Type 2 exponential dict (§7.10.3): `f(x)=C0+x^N·(C1−C0)`.
7786    fn type2(c0: &[f32], c1: &[f32], n: f32) -> Object {
7787        Object::Dict(
7788            Dict::new()
7789                .with("FunctionType", Object::Integer(2))
7790                .with("Domain", num_arr(&[0.0, 1.0]))
7791                .with("C0", num_arr(c0))
7792                .with("C1", num_arr(c1))
7793                .with("N", Object::Real(n as f64)),
7794        )
7795    }
7796
7797    /// `f(x)=C0+x^N·(C1−C0)` at x=0 is C0, at x=1 is C1, and at the
7798    /// midpoint for N=1 it is the average (§7.10.3 Table 40).
7799    #[test]
7800    fn type2_exponential_interpolates() {
7801        let f = PdfFunction::parse(&type2(&[0.0, 0.0, 0.0, 0.0], &[1.0, 0.0, 0.0, 0.0], 1.0))
7802            .expect("type 2 parses");
7803        assert_eq!(f.eval(0.0), vec![0.0, 0.0, 0.0, 0.0]);
7804        assert_eq!(f.eval(1.0), vec![1.0, 0.0, 0.0, 0.0]);
7805        assert_eq!(f.eval(0.5), vec![0.5, 0.0, 0.0, 0.0]);
7806    }
7807
7808    /// N=2 makes the interpolation quadratic in x: at x=0.5, x^N=0.25.
7809    #[test]
7810    fn type2_exponent_two_is_quadratic() {
7811        let f = PdfFunction::parse(&type2(&[0.0], &[1.0], 2.0)).expect("parses");
7812        assert!((f.eval(0.5)[0] - 0.25).abs() < 1e-6);
7813    }
7814
7815    /// `/Range` clips each output into `[Range_2j, Range_2j+1]`
7816    /// (§7.10.1): a C1 of 2.0 with Range `[0 1]` clips to 1.0 at x=1.
7817    #[test]
7818    fn type2_range_clips_output() {
7819        let dict = Object::Dict(
7820            Dict::new()
7821                .with("FunctionType", Object::Integer(2))
7822                .with("Domain", num_arr(&[0.0, 1.0]))
7823                .with("Range", num_arr(&[0.0, 1.0]))
7824                .with("C0", num_arr(&[0.0]))
7825                .with("C1", num_arr(&[2.0]))
7826                .with("N", Object::Integer(1)),
7827        );
7828        let f = PdfFunction::parse(&dict).expect("parses");
7829        assert_eq!(f.eval(1.0), vec![1.0]);
7830    }
7831
7832    /// A Type 3 stitching function (§7.10.4): two Type 2 children split
7833    /// at Bounds=0.5, each Encode'd onto `[0 1]`. The §7.10.4 EXAMPLE
7834    /// `g(x)=f(1−x)` shape (Encode `[1 0]`) is exercised on the first
7835    /// child to prove the per-subdomain input remap.
7836    #[test]
7837    fn type3_stitching_routes_to_subdomain() {
7838        // child 0: f0(x)=x (C0=0,C1=1,N=1); child 1: f1(x)=1−x via
7839        // C0=1,C1=0. Bounds [0.5], Encode [0 1 0 1] (identity remap).
7840        let dict = Object::Dict(
7841            Dict::new()
7842                .with("FunctionType", Object::Integer(3))
7843                .with("Domain", num_arr(&[0.0, 1.0]))
7844                .with(
7845                    "Functions",
7846                    Object::Array(vec![type2(&[0.0], &[1.0], 1.0), type2(&[1.0], &[0.0], 1.0)]),
7847                )
7848                .with("Bounds", num_arr(&[0.5]))
7849                .with("Encode", num_arr(&[0.0, 1.0, 0.0, 1.0])),
7850        );
7851        let f = PdfFunction::parse(&dict).expect("type 3 parses");
7852        // x=0.25 → subdomain 0, remapped onto [0,1]: Interpolate(0.25,
7853        // 0, 0.5, 0, 1) = 0.5 → f0(0.5)=0.5.
7854        assert!((f.eval(0.25)[0] - 0.5).abs() < 1e-6);
7855        // x=0.75 → subdomain 1, Interpolate(0.75, 0.5, 1, 0, 1)=0.5 →
7856        // f1(0.5)=0.5.
7857        assert!((f.eval(0.75)[0] - 0.5).abs() < 1e-6);
7858        // x=1.0 (last subdomain, closed on the right) → f1(1.0)=0.0.
7859        assert!(f.eval(1.0)[0].abs() < 1e-6);
7860    }
7861
7862    /// A Type 0 dictionary stripped of its sample body (`__Samples`)
7863    /// and a Type 4 dictionary stripped of its program body
7864    /// (`__Program`) are not evaluable here — `parse` returns `None`.
7865    #[test]
7866    fn type0_without_samples_and_type4_without_program_are_not_evaluable() {
7867        let t0 = Object::Dict(
7868            Dict::new()
7869                .with("FunctionType", Object::Integer(0))
7870                .with("Domain", num_arr(&[0.0, 1.0]))
7871                .with("Range", num_arr(&[0.0, 1.0]))
7872                .with("Size", num_arr(&[2.0]))
7873                .with("BitsPerSample", Object::Integer(8)),
7874        );
7875        // No __Samples entry → parse cannot reach the sample table.
7876        assert!(PdfFunction::parse(&t0).is_none());
7877        // A Type 4 with Domain + Range but no folded program body cannot
7878        // be tokenised, so it is not evaluable.
7879        let t4 = Object::Dict(
7880            Dict::new()
7881                .with("FunctionType", Object::Integer(4))
7882                .with("Domain", num_arr(&[0.0, 1.0]))
7883                .with("Range", num_arr(&[0.0, 1.0])),
7884        );
7885        assert!(PdfFunction::parse(&t4).is_none());
7886    }
7887
7888    /// Build a self-contained Type 0 sampled function dictionary with an
7889    /// 8-bit, single-input, single-output sample table whose decoded
7890    /// body lives under `__Samples` (the shape `prepare_function_object`
7891    /// produces). `codes` are the raw 0..=255 sample codes in input
7892    /// order.
7893    fn type0_8bit(domain: &[f32], range: &[f32], codes: &[u8]) -> Object {
7894        Object::Dict(
7895            Dict::new()
7896                .with("FunctionType", Object::Integer(0))
7897                .with("Domain", num_arr(domain))
7898                .with("Range", num_arr(range))
7899                .with("Size", num_arr(&[codes.len() as f32]))
7900                .with("BitsPerSample", Object::Integer(8))
7901                .with("__Samples", Object::HexString(codes.to_vec())),
7902        )
7903    }
7904
7905    /// §7.10.2: an 8-bit two-sample identity table over `[0,1] → [0,1]`
7906    /// evaluates by linear interpolation between the endpoints, and the
7907    /// endpoints decode exactly (0 → 0.0, 255 → 1.0).
7908    #[test]
7909    fn type0_8bit_linear_identity() {
7910        let f = PdfFunction::parse(&type0_8bit(&[0.0, 1.0], &[0.0, 1.0], &[0, 255]))
7911            .expect("type0 parses");
7912        assert!((f.eval(0.0)[0] - 0.0).abs() < 1e-6);
7913        assert!((f.eval(1.0)[0] - 1.0).abs() < 1e-6);
7914        // Midpoint: e = Interpolate(0.5, 0,1, 0, 1) = 0.5, blend of the
7915        // two codes (0.0 and 1.0) → 0.5.
7916        assert!((f.eval(0.5)[0] - 0.5).abs() < 1e-6);
7917    }
7918
7919    /// §7.10.2 Decode: a non-default `/Decode` remaps the [0,1] sample
7920    /// codes into the output range. With Decode [0 10] the 255 code
7921    /// decodes to 10.0, the 0 code to 0.0, midpoint 5.0.
7922    #[test]
7923    fn type0_decode_remaps_outputs() {
7924        let f = PdfFunction::parse(&Object::Dict(
7925            Dict::new()
7926                .with("FunctionType", Object::Integer(0))
7927                .with("Domain", num_arr(&[0.0, 1.0]))
7928                .with("Range", num_arr(&[0.0, 10.0]))
7929                .with("Size", num_arr(&[2.0]))
7930                .with("BitsPerSample", Object::Integer(8))
7931                .with("Decode", num_arr(&[0.0, 10.0]))
7932                .with("__Samples", Object::HexString(vec![0, 255])),
7933        ))
7934        .expect("type0 parses");
7935        assert!((f.eval(0.0)[0] - 0.0).abs() < 1e-5);
7936        assert!((f.eval(1.0)[0] - 10.0).abs() < 1e-5);
7937        assert!((f.eval(0.5)[0] - 5.0).abs() < 1e-5);
7938    }
7939
7940    /// §7.10.2 multi-output: a single-sample (Size 1) table with two
7941    /// outputs maps every input to the lone sample, decoded per output.
7942    #[test]
7943    fn type0_single_sample_multi_output() {
7944        let f = PdfFunction::parse(&Object::Dict(
7945            Dict::new()
7946                .with("FunctionType", Object::Integer(0))
7947                .with("Domain", num_arr(&[0.0, 1.0]))
7948                .with("Range", num_arr(&[0.0, 1.0, 0.0, 1.0]))
7949                .with("Size", num_arr(&[1.0]))
7950                .with("BitsPerSample", Object::Integer(8))
7951                // one input index, two outputs: codes 255, 0.
7952                .with("__Samples", Object::HexString(vec![255, 0])),
7953        ))
7954        .expect("type0 parses");
7955        let out = f.eval(0.42);
7956        assert_eq!(out.len(), 2);
7957        assert!((out[0] - 1.0).abs() < 1e-5);
7958        assert!((out[1] - 0.0).abs() < 1e-5);
7959    }
7960
7961    /// §7.10.2 1-bit packing: a 4-sample 1-bit table reads the
7962    /// high-order bit of the byte first (codes 1,0,1,0 → 0x A0 = 1010
7963    /// 0000) and interpolates between adjacent samples.
7964    #[test]
7965    fn type0_1bit_packing_msb_first() {
7966        let f = PdfFunction::parse(&Object::Dict(
7967            Dict::new()
7968                .with("FunctionType", Object::Integer(0))
7969                .with("Domain", num_arr(&[0.0, 1.0]))
7970                .with("Range", num_arr(&[0.0, 1.0]))
7971                .with("Size", num_arr(&[4.0]))
7972                .with("BitsPerSample", Object::Integer(1))
7973                .with("Encode", num_arr(&[0.0, 3.0]))
7974                .with("__Samples", Object::HexString(vec![0b1010_0000])),
7975        ))
7976        .expect("type0 parses");
7977        // index 0 → code 1 → 1.0; index 1 → code 0 → 0.0; index 2 → 1.0.
7978        assert!((f.eval(0.0)[0] - 1.0).abs() < 1e-6);
7979        assert!((f.eval(1.0 / 3.0)[0] - 0.0).abs() < 1e-5);
7980        assert!((f.eval(2.0 / 3.0)[0] - 1.0).abs() < 1e-5);
7981    }
7982
7983    /// §7.10.2 with two input dimensions: bilinear interpolation over a
7984    /// 2×2 sample grid. Samples are stored first-dimension-fastest:
7985    /// f(0,0)=0, f(1,0)=255, f(0,1)=128, f(1,1)=64 (raw 8-bit codes,
7986    /// normalised by 255 before Decode = Range = [0,1]). The corners must
7987    /// reproduce exactly; the centre is the average of all four.
7988    #[test]
7989    fn type0_bilinear_2x2_grid() {
7990        let t0 = Object::Dict(
7991            Dict::new()
7992                .with("FunctionType", Object::Integer(0))
7993                .with("Domain", num_arr(&[0.0, 1.0, 0.0, 1.0]))
7994                .with("Range", num_arr(&[0.0, 1.0]))
7995                .with("Size", num_arr(&[2.0, 2.0]))
7996                .with("BitsPerSample", Object::Integer(8))
7997                .with("__Samples", Object::HexString(vec![0, 255, 128, 64])),
7998        );
7999        let f = PdfFunction::parse(&t0).expect("2-input type0 parses");
8000        // Corners are exact.
8001        assert!((f.eval_n(&[0.0, 0.0])[0] - 0.0).abs() < 1e-6);
8002        assert!((f.eval_n(&[1.0, 0.0])[0] - 1.0).abs() < 1e-6);
8003        assert!((f.eval_n(&[0.0, 1.0])[0] - 128.0 / 255.0).abs() < 1e-6);
8004        assert!((f.eval_n(&[1.0, 1.0])[0] - 64.0 / 255.0).abs() < 1e-6);
8005        // Centre = mean of the four corners.
8006        let mean = (0.0 + 255.0 + 128.0 + 64.0) / 4.0 / 255.0;
8007        assert!((f.eval_n(&[0.5, 0.5])[0] - mean).abs() < 1e-6);
8008        // Midpoint of the bottom edge = mean of f(0,0) and f(1,0).
8009        assert!((f.eval_n(&[0.5, 0.0])[0] - 0.5).abs() < 1e-6);
8010    }
8011
8012    /// §7.10.2 Order-3 (cubic spline) is accepted and carried through to
8013    /// evaluation. The interpolant must pass through the sample knots
8014    /// (it interpolates, not approximates) — at integer encoded
8015    /// positions the result equals the corresponding sample exactly.
8016    #[test]
8017    fn type0_order_3_passes_through_knots() {
8018        // 4 samples on [0,3]; Encode maps Domain [0,3] → table [0,3].
8019        let t0 = Object::Dict(
8020            Dict::new()
8021                .with("FunctionType", Object::Integer(0))
8022                .with("Domain", num_arr(&[0.0, 3.0]))
8023                .with("Range", num_arr(&[0.0, 1.0]))
8024                .with("Size", num_arr(&[4.0]))
8025                .with("BitsPerSample", Object::Integer(8))
8026                .with("Order", Object::Integer(3))
8027                .with("__Samples", Object::HexString(vec![0, 85, 170, 255])),
8028        );
8029        let f = PdfFunction::parse(&t0).expect("order-3 sampled function parses");
8030        let expect = [0.0, 85.0 / 255.0, 170.0 / 255.0, 1.0];
8031        for (k, &e) in expect.iter().enumerate() {
8032            let got = f.eval_n(&[k as f32])[0];
8033            assert!((got - e).abs() < 1e-6, "knot {k}: got {got}, want {e}");
8034        }
8035    }
8036
8037    /// §7.10.2 cubic-spline weights sum to 1 for every fractional
8038    /// position, so a constant sample table reproduces that constant
8039    /// everywhere (no overshoot for a flat curve).
8040    #[test]
8041    fn type0_order_3_constant_table_is_flat() {
8042        let t0 = Object::Dict(
8043            Dict::new()
8044                .with("FunctionType", Object::Integer(0))
8045                .with("Domain", num_arr(&[0.0, 3.0]))
8046                .with("Range", num_arr(&[0.0, 1.0]))
8047                .with("Size", num_arr(&[4.0]))
8048                .with("BitsPerSample", Object::Integer(8))
8049                .with("Order", Object::Integer(3))
8050                .with("__Samples", Object::HexString(vec![128, 128, 128, 128])),
8051        );
8052        let f = PdfFunction::parse(&t0).expect("parses");
8053        for &x in &[0.0f32, 0.3, 1.0, 1.7, 2.5, 3.0] {
8054            let got = f.eval_n(&[x])[0];
8055            assert!((got - 128.0 / 255.0).abs() < 1e-6, "x={x}: got {got}");
8056        }
8057    }
8058
8059    /// §7.10.2: a `/Size` below 4 cannot carry a cubic window, so
8060    /// `/Order 3` is ignored on that axis and the function interpolates
8061    /// linearly. With 2 samples 0 and 255, the midpoint is exactly 0.5.
8062    #[test]
8063    fn type0_order_3_falls_back_to_linear_below_size_4() {
8064        let t0 = Object::Dict(
8065            Dict::new()
8066                .with("FunctionType", Object::Integer(0))
8067                .with("Domain", num_arr(&[0.0, 1.0]))
8068                .with("Range", num_arr(&[0.0, 1.0]))
8069                .with("Size", num_arr(&[2.0]))
8070                .with("BitsPerSample", Object::Integer(8))
8071                .with("Order", Object::Integer(3))
8072                .with("__Samples", Object::HexString(vec![0, 255])),
8073        );
8074        let f = PdfFunction::parse(&t0).expect("parses");
8075        assert!((f.eval_n(&[0.5])[0] - 0.5).abs() < 1e-6);
8076        assert!((f.eval_n(&[0.0])[0] - 0.0).abs() < 1e-6);
8077        assert!((f.eval_n(&[1.0])[0] - 1.0).abs() < 1e-6);
8078    }
8079
8080    /// A malformed `/Order` (neither 1 nor 3) leaves the function
8081    /// unevaluable, per §7.10.2 Table 39 ("Valid values shall be 1 and
8082    /// 3").
8083    #[test]
8084    fn type0_invalid_order_is_rejected() {
8085        let t0 = Object::Dict(
8086            Dict::new()
8087                .with("FunctionType", Object::Integer(0))
8088                .with("Domain", num_arr(&[0.0, 1.0]))
8089                .with("Range", num_arr(&[0.0, 1.0]))
8090                .with("Size", num_arr(&[4.0]))
8091                .with("BitsPerSample", Object::Integer(8))
8092                .with("Order", Object::Integer(2))
8093                .with("__Samples", Object::HexString(vec![0, 85, 170, 255])),
8094        );
8095        assert!(PdfFunction::parse(&t0).is_none());
8096    }
8097
8098    /// Order-1 (linear) remains the default and is unaffected by the
8099    /// cubic path: a 4-sample ramp interpolates linearly at the
8100    /// midpoints when no `/Order` is given.
8101    #[test]
8102    fn type0_default_order_is_linear() {
8103        let t0 = Object::Dict(
8104            Dict::new()
8105                .with("FunctionType", Object::Integer(0))
8106                .with("Domain", num_arr(&[0.0, 3.0]))
8107                .with("Range", num_arr(&[0.0, 1.0]))
8108                .with("Size", num_arr(&[4.0]))
8109                .with("BitsPerSample", Object::Integer(8))
8110                .with("__Samples", Object::HexString(vec![0, 85, 170, 255])),
8111        );
8112        let f = PdfFunction::parse(&t0).expect("parses");
8113        // Linear midpoint between samples 1 and 2 (85 and 170).
8114        let mid = (85.0 + 170.0) / 2.0 / 255.0;
8115        assert!((f.eval_n(&[1.5])[0] - mid).abs() < 1e-6);
8116    }
8117
8118    // ── Type 4 PostScript-calculator functions §7.10.5 ──────────────
8119
8120    /// Build a self-contained Type 4 function dictionary: the program
8121    /// `src` (including its outer braces) is folded into `__Program` the
8122    /// same way `prepare_function_object` does for a real stream.
8123    fn type4(domain: &[f32], range: &[f32], src: &str) -> Object {
8124        Object::Dict(
8125            Dict::new()
8126                .with("FunctionType", Object::Integer(4))
8127                .with("Domain", num_arr(domain))
8128                .with("Range", num_arr(range))
8129                .with("__Program", Object::HexString(src.as_bytes().to_vec())),
8130        )
8131    }
8132
8133    /// An empty program `{ }` leaves the single seeded input untouched —
8134    /// the simplest exercise of the whole pipeline (fold → parse →
8135    /// tokenise → exec → clip) for a 1-input call site (§7.10.5).
8136    #[test]
8137    fn type4_identity_program() {
8138        let f = PdfFunction::parse(&type4(&[0.0, 1.0], &[0.0, 1.0], "{ }"))
8139            .expect("type4 identity parses");
8140        // Empty program leaves the seeded input on the stack.
8141        assert!((f.eval(0.25)[0] - 0.25).abs() < 1e-6);
8142        assert!((f.eval(0.9)[0] - 0.9).abs() < 1e-6);
8143    }
8144
8145    /// Arithmetic operators (§B.2): `{ 2 mul }` doubles the input,
8146    /// clipped to Range.
8147    #[test]
8148    fn type4_arithmetic_mul_and_range_clip() {
8149        let f = PdfFunction::parse(&type4(&[0.0, 1.0], &[0.0, 1.0], "{ 2 mul }")).expect("parses");
8150        assert!((f.eval(0.25)[0] - 0.5).abs() < 1e-6);
8151        // 2·0.8 = 1.6 clips to the Range ceiling 1.0.
8152        assert!((f.eval(0.8)[0] - 1.0).abs() < 1e-6);
8153    }
8154
8155    /// `{ 1 exch sub }` computes 1 − x (the canonical invert tint
8156    /// transform), exercising `exch` (§B.5) + `sub` (§B.2).
8157    #[test]
8158    fn type4_invert_with_exch_sub() {
8159        let f =
8160            PdfFunction::parse(&type4(&[0.0, 1.0], &[0.0, 1.0], "{ 1 exch sub }")).expect("parses");
8161        assert!((f.eval(0.0)[0] - 1.0).abs() < 1e-6);
8162        assert!((f.eval(1.0)[0] - 0.0).abs() < 1e-6);
8163        assert!((f.eval(0.3)[0] - 0.7).abs() < 1e-6);
8164    }
8165
8166    /// `dup` (§B.5) duplicates the input so a 1-in program can emit two
8167    /// outputs (here `{ dup }` → a 2-component DeviceGray-pair Range).
8168    #[test]
8169    fn type4_dup_emits_two_outputs() {
8170        let f = PdfFunction::parse(&type4(&[0.0, 1.0], &[0.0, 1.0, 0.0, 1.0], "{ dup }"))
8171            .expect("parses");
8172        let out = f.eval(0.4);
8173        assert_eq!(out.len(), 2);
8174        assert!((out[0] - 0.4).abs() < 1e-6);
8175        assert!((out[1] - 0.4).abs() < 1e-6);
8176    }
8177
8178    /// Conditional `ifelse` (§B.4): a threshold function emitting 0 below
8179    /// 0.5 and 1 at/above it.
8180    #[test]
8181    fn type4_ifelse_threshold() {
8182        let f = PdfFunction::parse(&type4(
8183            &[0.0, 1.0],
8184            &[0.0, 1.0],
8185            "{ 0.5 ge { 1 } { 0 } ifelse }",
8186        ))
8187        .expect("parses");
8188        assert!((f.eval(0.2)[0] - 0.0).abs() < 1e-6);
8189        assert!((f.eval(0.5)[0] - 1.0).abs() < 1e-6);
8190        assert!((f.eval(0.9)[0] - 1.0).abs() < 1e-6);
8191    }
8192
8193    /// Single-branch `if` (§B.4): clamp negatives — `{ dup 0 lt { pop 0 }
8194    /// if }` leaves the input unless it is below 0, where it is replaced
8195    /// by 0. (Domain already clips to ≥0 here, so the branch is the
8196    /// false path and the value passes through.)
8197    #[test]
8198    fn type4_single_branch_if() {
8199        let f = PdfFunction::parse(&type4(
8200            &[0.0, 1.0],
8201            &[0.0, 1.0],
8202            "{ dup 0 lt { pop 0 } if }",
8203        ))
8204        .expect("parses");
8205        assert!((f.eval(0.6)[0] - 0.6).abs() < 1e-6);
8206    }
8207
8208    /// `roll` (§B.5): `{ 3 1 roll }` rotates the top three elements up by
8209    /// one. Seed three inputs via `dup`s, then verify the rotation order.
8210    #[test]
8211    fn type4_roll_rotates_stack() {
8212        // Program: push 10, push 20 (now stack: x 10 20), then 3 1 roll.
8213        // Per §B.5 with n=3,j=1 the input [x,10,20] becomes [20,x,10].
8214        let f = PdfFunction::parse(&type4(
8215            &[0.0, 100.0],
8216            &[0.0, 100.0, 0.0, 100.0, 0.0, 100.0],
8217            "{ 10 20 3 1 roll }",
8218        ))
8219        .expect("parses");
8220        let out = f.eval(5.0);
8221        assert_eq!(out.len(), 3);
8222        assert_eq!((out[0], out[1], out[2]), (20.0, 5.0, 10.0));
8223    }
8224
8225    /// `index` (§B.5): `{ 0 index }` duplicates the top element (n=0).
8226    #[test]
8227    fn type4_index_copies_nth() {
8228        let f = PdfFunction::parse(&type4(&[0.0, 1.0], &[0.0, 1.0, 0.0, 1.0], "{ 0 index }"))
8229            .expect("parses");
8230        let out = f.eval(0.7);
8231        assert_eq!(out.len(), 2);
8232        assert!((out[0] - 0.7).abs() < 1e-6 && (out[1] - 0.7).abs() < 1e-6);
8233    }
8234
8235    /// Boolean / relational chain: `{ 0.5 gt { 1 } { 0 } ifelse }` plus
8236    /// a `not` round-trip through the boolean path.
8237    #[test]
8238    fn type4_boolean_not_and_relational() {
8239        let f = PdfFunction::parse(&type4(
8240            &[0.0, 1.0],
8241            &[0.0, 1.0],
8242            "{ 0.5 gt not { 0 } { 1 } ifelse }",
8243        ))
8244        .expect("parses");
8245        // x=0.9 > 0.5 ⇒ true, not ⇒ false ⇒ second branch ⇒ 1.
8246        assert!((f.eval(0.9)[0] - 1.0).abs() < 1e-6);
8247        // x=0.2 > 0.5 ⇒ false, not ⇒ true ⇒ first branch ⇒ 0.
8248        assert!((f.eval(0.2)[0] - 0.0).abs() < 1e-6);
8249    }
8250
8251    /// An execution error (here division by zero, §7.10.5.2) yields the
8252    /// conservative black fallback (all-zero output of Range's arity).
8253    #[test]
8254    fn type4_division_by_zero_falls_back_to_black() {
8255        let f = PdfFunction::parse(&type4(&[0.0, 1.0], &[0.0, 1.0], "{ 0 div }")).expect("parses");
8256        assert_eq!(f.eval(0.5), vec![0.0]);
8257    }
8258
8259    /// A program whose leftover-operand count differs from Range's arity
8260    /// is an error (§7.10.5): black fallback rather than a wrong colour.
8261    #[test]
8262    fn type4_output_arity_mismatch_falls_back() {
8263        // `{ pop }` leaves zero operands but Range wants one.
8264        let f = PdfFunction::parse(&type4(&[0.0, 1.0], &[0.0, 1.0], "{ pop }")).expect("parses");
8265        assert_eq!(f.eval(0.5), vec![0.0]);
8266    }
8267
8268    /// Syntax errors (§7.10.5.2) make `parse` reject the function:
8269    /// unbalanced braces, missing outer braces, and unknown tokens.
8270    #[test]
8271    fn type4_syntax_errors_reject() {
8272        // Missing outer braces.
8273        assert!(PdfFunction::parse(&type4(&[0.0, 1.0], &[0.0, 1.0], "2 mul")).is_none());
8274        // Unbalanced (unterminated) brace.
8275        assert!(PdfFunction::parse(&type4(&[0.0, 1.0], &[0.0, 1.0], "{ 2 mul")).is_none());
8276        // Trailing tokens after the outer block close.
8277        assert!(PdfFunction::parse(&type4(&[0.0, 1.0], &[0.0, 1.0], "{ } 3")).is_none());
8278        // Unknown operator token.
8279        assert!(PdfFunction::parse(&type4(&[0.0, 1.0], &[0.0, 1.0], "{ frobnicate }")).is_none());
8280    }
8281
8282    /// A Type 4 tint transform drives a real Separation `scn` end to end:
8283    /// `{ 1 exch sub }` over a DeviceGray alternate inverts the tint, so
8284    /// `1 scn` (full ink) → gray 0.0 → black.
8285    #[test]
8286    fn type4_separation_scn_end_to_end() {
8287        let arr = separation(
8288            "Spot",
8289            Object::Name("DeviceGray".into()),
8290            type4(&[0.0, 1.0], &[0.0, 1.0], "{ 1 exch sub }"),
8291        );
8292        let cs = Dict::new().with("CS0", arr);
8293        // tint 1.0 → 1−1 = 0.0 gray → black.
8294        let bytes = b"q /CS0 cs 1 scn 0 0 m 10 10 l 10 0 l h f Q\n";
8295        assert_eq!(first_fill_with_cs(bytes, &cs), (0, 0, 0));
8296        // tint 0.0 → 1−0 = 1.0 gray → white.
8297        let bytes = b"q /CS0 cs 0 scn 0 0 m 10 10 l 10 0 l h f Q\n";
8298        assert_eq!(first_fill_with_cs(bytes, &cs), (255, 255, 255));
8299    }
8300
8301    // ── DeviceN colour spaces §8.6.6.5 ──────────────────────────────
8302
8303    /// Build a `[ /DeviceN [names…] alt tint ]` array (§8.6.6.5).
8304    fn device_n(names: &[&str], alt: Object, tint: Object) -> Object {
8305        Object::Array(vec![
8306            Object::Name("DeviceN".into()),
8307            Object::Array(names.iter().map(|n| Object::Name((*n).into())).collect()),
8308            alt,
8309            tint,
8310        ])
8311    }
8312
8313    /// A two-colorant DeviceN over DeviceRGB whose tint transform is a
8314    /// Type 4 program: `{ exch }` swaps the two tints so the (red, blue)
8315    /// inputs map to (blue, 0-stub, red)? No — keep it concrete: a 2-in
8316    /// 3-out program `{ 0 exch }` would mis-count. Use an explicit
8317    /// duotone: inputs (a, b) → RGB (a, 0, b).
8318    #[test]
8319    fn device_n_duotone_type4_maps_to_rgb() {
8320        // 2 inputs, 3 outputs. Program: stack starts [a b]; emit a, 0, b.
8321        // `{ 0 exch }` → [a 0 b]? Starting [a b]: push 0 → [a b 0];
8322        // exch → [a 0 b]. Exactly (a, 0, b).
8323        let tint = type4(
8324            &[0.0, 1.0, 0.0, 1.0],
8325            &[0.0, 1.0, 0.0, 1.0, 0.0, 1.0],
8326            "{ 0 exch }",
8327        );
8328        let arr = device_n(&["Red", "Blue"], Object::Name("DeviceRGB".into()), tint);
8329        // arity check: 2-in / 3-out over DeviceRGB.
8330        assert!(matches!(
8331            color_space_from_object(&arr),
8332            ColorSpaceKind::DeviceN { n_in: 2, .. }
8333        ));
8334        let cs = Dict::new().with("CS0", arr);
8335        // scn supplies the two tints in names order: Red=1.0, Blue=0.5.
8336        // → RGB (1.0, 0.0, 0.5) → (255, 0, 128).
8337        let bytes = b"q /CS0 cs 1 0.5 scn 0 0 m 10 10 l 10 0 l h f Q\n";
8338        assert_eq!(first_fill_with_cs(bytes, &cs), (255, 0, 128));
8339    }
8340
8341    /// A DeviceN tint transform that is a 2-input Type 0 (sampled)
8342    /// function exercises the multilinear sampled path end-to-end. The
8343    /// 2×2×(n=1) grid maps (a,b) bilinearly; the single output drives a
8344    /// DeviceGray alternate.
8345    #[test]
8346    fn device_n_type0_sampled_bilinear_to_gray() {
8347        let tint = Object::Dict(
8348            Dict::new()
8349                .with("FunctionType", Object::Integer(0))
8350                .with("Domain", num_arr(&[0.0, 1.0, 0.0, 1.0]))
8351                .with("Range", num_arr(&[0.0, 1.0]))
8352                .with("Size", num_arr(&[2.0, 2.0]))
8353                .with("BitsPerSample", Object::Integer(8))
8354                // f(0,0)=0, f(1,0)=255, f(0,1)=255, f(1,1)=255.
8355                .with("__Samples", Object::HexString(vec![0, 255, 255, 255])),
8356        );
8357        let arr = device_n(&["A", "B"], Object::Name("DeviceGray".into()), tint);
8358        let cs = Dict::new().with("CS0", arr);
8359        // (a,b) = (1,0) → f=255/255=1.0 gray → white.
8360        let bytes = b"q /CS0 cs 1 0 scn 0 0 m 10 10 l 10 0 l h f Q\n";
8361        assert_eq!(first_fill_with_cs(bytes, &cs), (255, 255, 255));
8362        // (a,b) = (0,0) → f=0 gray → black.
8363        let bytes = b"q /CS0 cs 0 0 scn 0 0 m 10 10 l 10 0 l h f Q\n";
8364        assert_eq!(first_fill_with_cs(bytes, &cs), (0, 0, 0));
8365    }
8366
8367    /// §8.6.6.5: the initial DeviceN colour is every component at 1.0 — a
8368    /// bare `cs` with no `scn` paints the full-tint colour.
8369    #[test]
8370    fn device_n_bare_cs_uses_full_tint() {
8371        // 2-in/3-out: (a,b) → RGB (a, 0, b). At the 1.0/1.0 default →
8372        // RGB (1,0,1) magenta.
8373        let tint = type4(
8374            &[0.0, 1.0, 0.0, 1.0],
8375            &[0.0, 1.0, 0.0, 1.0, 0.0, 1.0],
8376            "{ 0 exch }",
8377        );
8378        let arr = device_n(&["Red", "Blue"], Object::Name("DeviceRGB".into()), tint);
8379        let cs = Dict::new().with("CS0", arr);
8380        let bytes = b"q /CS0 cs 0 0 m 10 10 l 10 0 l h f Q\n";
8381        assert_eq!(first_fill_with_cs(bytes, &cs), (255, 0, 255));
8382    }
8383
8384    /// §8.6.6.5: an all-`/None` DeviceN space always discards its output
8385    /// — `scn` produces no paint, so the path keeps the conservative
8386    /// black fallback rather than reverting to the alternate.
8387    #[test]
8388    fn device_n_all_none_discards_output() {
8389        let tint = type4(
8390            &[0.0, 1.0, 0.0, 1.0],
8391            &[0.0, 1.0, 0.0, 1.0, 0.0, 1.0],
8392            "{ 0 exch }",
8393        );
8394        let arr = device_n(&["None", "None"], Object::Name("DeviceRGB".into()), tint);
8395        assert!(matches!(
8396            color_space_from_object(&arr),
8397            ColorSpaceKind::DeviceN { all_none: true, .. }
8398        ));
8399        let cs = Dict::new().with("CS0", arr);
8400        // scn yields no paint → caller's conservative black fallback.
8401        let bytes = b"q /CS0 cs 1 1 scn 0 0 m 10 10 l 10 0 l h f Q\n";
8402        assert_eq!(first_fill_with_cs(bytes, &cs), (0, 0, 0));
8403    }
8404
8405    /// §8.6.6.5: a tint transform whose input arity doesn't match the
8406    /// colorant count collapses the space to `Unknown` (black fallback).
8407    #[test]
8408    fn device_n_arity_mismatch_falls_back() {
8409        // 3 colorant names but a 2-input tint transform → mismatch.
8410        let tint = type4(&[0.0, 1.0, 0.0, 1.0], &[0.0, 1.0], "{ add }");
8411        let arr = device_n(&["A", "B", "C"], Object::Name("DeviceGray".into()), tint);
8412        assert_eq!(color_space_from_object(&arr), ColorSpaceKind::Unknown);
8413    }
8414
8415    /// §8.6.6.5: a non-device (e.g. another special) alternate collapses
8416    /// the DeviceN space to `Unknown`.
8417    #[test]
8418    fn device_n_nondevice_alternate_falls_back() {
8419        let tint = type4(&[0.0, 1.0, 0.0, 1.0], &[0.0, 1.0], "{ add }");
8420        // Alternate is a Pattern name → not a device family.
8421        let arr = device_n(&["A", "B"], Object::Name("Pattern".into()), tint);
8422        assert_eq!(color_space_from_object(&arr), ColorSpaceKind::Unknown);
8423    }
8424
8425    /// `parse_ps_program` tokenises numbers, booleans, nested blocks, and
8426    /// operators into the expected tree (the DoubleDot §7.10.5 example).
8427    #[test]
8428    fn type4_parses_doubledot_example() {
8429        // { 360 mul sin 2 div exch 360 mul sin 2 div add }
8430        let prog =
8431            parse_ps_program(b"{ 360 mul sin 2 div exch 360 mul sin 2 div add }").expect("parses");
8432        assert_eq!(prog.first(), Some(&PsToken::Number(360.0)));
8433        assert_eq!(prog.get(1), Some(&PsToken::Op(PsOp::Mul)));
8434        assert_eq!(prog.last(), Some(&PsToken::Op(PsOp::Add)));
8435    }
8436
8437    /// Build a `[ /Separation name alt tint ]` array (§8.6.6.4).
8438    fn separation(name: &str, alt: Object, tint: Object) -> Object {
8439        Object::Array(vec![
8440            Object::Name("Separation".into()),
8441            Object::Name(name.into()),
8442            alt,
8443            tint,
8444        ])
8445    }
8446
8447    /// §8.6.6.4 EXAMPLE 2 shape: a Separation over DeviceCMYK with a
8448    /// linear tint transform mapping tint → CMYK. At tint=1.0 the
8449    /// alternate components are the full C1; rendered through §10.3.5.
8450    #[test]
8451    fn separation_cmyk_tint_maps_through_alternate() {
8452        // tint transform: pure cyan at full tint (C1 = [1 0 0 0]).
8453        let tint = type2(&[0.0, 0.0, 0.0, 0.0], &[1.0, 0.0, 0.0, 0.0], 1.0);
8454        let arr = separation("LogoGreen", Object::Name("DeviceCMYK".into()), tint);
8455        let cs = Dict::new().with("CS0", arr);
8456        // 1.0 scn → CMYK (1,0,0,0) → §10.3.5 → (0,255,255) cyan.
8457        let bytes = b"q /CS0 cs 1 scn 0 0 m 10 10 l 10 0 l h f Q\n";
8458        assert_eq!(first_fill_with_cs(bytes, &cs), (0, 255, 255));
8459        // 0.0 scn → CMYK (0,0,0,0) → white.
8460        let bytes = b"q /CS0 cs 0 scn 0 0 m 10 10 l 10 0 l h f Q\n";
8461        assert_eq!(first_fill_with_cs(bytes, &cs), (255, 255, 255));
8462    }
8463
8464    /// §8.6.6.4: the initial Separation colour is tint 1.0 — a bare
8465    /// `cs` with no `scn` paints the full-tint colour, not black.
8466    #[test]
8467    fn separation_bare_cs_uses_full_tint() {
8468        let tint = type2(&[1.0], &[0.0], 1.0); // gray: tint 1 → 0.0 (black)
8469        let arr = separation("Spot", Object::Name("DeviceGray".into()), tint);
8470        let cs = Dict::new().with("CS0", arr);
8471        // Bare cs → tint 1.0 → gray 0.0 → black.
8472        let bytes = b"q /CS0 cs 0 0 m 10 10 l 10 0 l h f Q\n";
8473        assert_eq!(first_fill_with_cs(bytes, &cs), (0, 0, 0));
8474        // Explicit 0 scn → gray 1.0 → white, proving 1.0 was the default.
8475        let bytes = b"q /CS0 cs 0 scn 0 0 m 10 10 l 10 0 l h f Q\n";
8476        assert_eq!(first_fill_with_cs(bytes, &cs), (255, 255, 255));
8477    }
8478
8479    /// A Type 3 stitching tint transform drives a Separation end-to-end.
8480    #[test]
8481    fn separation_with_type3_tint() {
8482        let tint = Object::Dict(
8483            Dict::new()
8484                .with("FunctionType", Object::Integer(3))
8485                .with("Domain", num_arr(&[0.0, 1.0]))
8486                .with(
8487                    "Functions",
8488                    Object::Array(vec![type2(&[0.0], &[0.5], 1.0), type2(&[0.5], &[1.0], 1.0)]),
8489                )
8490                .with("Bounds", num_arr(&[0.5]))
8491                .with("Encode", num_arr(&[0.0, 1.0, 0.0, 1.0])),
8492        );
8493        let arr = separation("Spot", Object::Name("DeviceGray".into()), tint);
8494        let cs = Dict::new().with("CS0", arr);
8495        // tint 0.75 → subdomain 1, x'=0.5 → f1(0.5)=0.75 gray → 191.
8496        let bytes = b"q /CS0 cs 0.75 scn 0 0 m 10 10 l 10 0 l h f Q\n";
8497        let (r, g, b) = first_fill_with_cs(bytes, &cs);
8498        let expect = (0.75f32 * 255.0).round() as u8;
8499        assert_eq!((r, g, b), (expect, expect, expect));
8500    }
8501
8502    /// A Type 0 (sampled) tint transform drives a Separation over
8503    /// DeviceGray end-to-end through the content parser. The two-sample
8504    /// 8-bit table maps tint 1.0 → gray 0.0 (black), proving the
8505    /// `__Samples`-folded sampled function is evaluated by `scn`.
8506    #[test]
8507    fn separation_with_type0_tint() {
8508        // tint → gray, inverted: code at index 0 is 255 (gray 1.0),
8509        // index 1 is 0 (gray 0.0). Encode default [0 1].
8510        let tint = type0_8bit(&[0.0, 1.0], &[0.0, 1.0], &[255, 0]);
8511        let arr = separation("Spot", Object::Name("DeviceGray".into()), tint);
8512        let cs = Dict::new().with("CS0", arr);
8513        // tint 1.0 → gray 0.0 → black.
8514        let bytes = b"q /CS0 cs 1 scn 0 0 m 10 10 l 10 0 l h f Q\n";
8515        assert_eq!(first_fill_with_cs(bytes, &cs), (0, 0, 0));
8516        // tint 0.0 → gray 1.0 → white.
8517        let bytes = b"q /CS0 cs 0 scn 0 0 m 10 10 l 10 0 l h f Q\n";
8518        assert_eq!(first_fill_with_cs(bytes, &cs), (255, 255, 255));
8519    }
8520
8521    /// The special `/None` colorant produces no visible output
8522    /// (§8.6.6.4): `scn` yields no paint, so the prior (default black)
8523    /// fill stands and no spurious colour is read.
8524    #[test]
8525    fn separation_none_colorant_produces_no_paint() {
8526        let arr = separation(
8527            "None",
8528            Object::Name("DeviceGray".into()),
8529            type2(&[0.0], &[1.0], 1.0),
8530        );
8531        let cs = Dict::new().with("CS0", arr);
8532        let bytes = b"q /CS0 cs 0.5 scn 0 0 m 10 10 l 10 0 l h f Q\n";
8533        // No paint from the None colorant → commit_path's black fallback.
8534        assert_eq!(first_fill_with_cs(bytes, &cs), (0, 0, 0));
8535    }
8536
8537    /// A Separation whose alternate is a non-device (CIE-based) space
8538    /// stays `Unknown` — this round renders only device alternates, so
8539    /// the conservative black fallback applies.
8540    #[test]
8541    fn separation_nondevice_alternate_is_unknown() {
8542        let arr = separation(
8543            "Spot",
8544            Object::Name("Lab".into()),
8545            type2(&[0.0], &[1.0], 1.0),
8546        );
8547        assert_eq!(color_space_from_object(&arr), ColorSpaceKind::Unknown);
8548    }
8549
8550    /// A Separation whose tint transform is a Type 4 with no folded
8551    /// program body (unevaluable) stays `Unknown`.
8552    #[test]
8553    fn separation_unevaluable_tint_is_unknown() {
8554        let t4 = Object::Dict(
8555            Dict::new()
8556                .with("FunctionType", Object::Integer(4))
8557                .with("Domain", num_arr(&[0.0, 1.0]))
8558                .with("Range", num_arr(&[0.0, 1.0])),
8559        );
8560        let arr = separation("Spot", Object::Name("DeviceGray".into()), t4);
8561        assert_eq!(color_space_from_object(&arr), ColorSpaceKind::Unknown);
8562    }
8563
8564    /// A Separation tint operand outside `0.0..=1.0` is clamped into the
8565    /// colour range before the transform (§8.6.6.4).
8566    #[test]
8567    fn separation_tint_clamped_to_unit_range() {
8568        let tint = type2(&[0.0], &[1.0], 1.0); // gray identity
8569        let arr = separation("Spot", Object::Name("DeviceGray".into()), tint);
8570        let cs = Dict::new().with("CS0", arr);
8571        // 5.0 clamps to 1.0 → gray 1.0 → white.
8572        let bytes = b"q /CS0 cs 5 scn 0 0 m 10 10 l 10 0 l h f Q\n";
8573        assert_eq!(first_fill_with_cs(bytes, &cs), (255, 255, 255));
8574    }
8575
8576    // ── ExtGState `gs` resolution (round 125, ISO 32000-1 §8.4.5) ──
8577
8578    /// Helper: build a `/Resources /ExtGState` dictionary with a
8579    /// single named graphics-state parameter dict.
8580    fn ext_gstate_with(name: &str, dict: Dict) -> Dict {
8581        Dict::new().with(name, Object::Dict(dict))
8582    }
8583
8584    fn parse_with(input: &[u8], ext: &Dict) -> Group {
8585        parse_content_stream_with_resources(input, Some(ext)).unwrap()
8586    }
8587
8588    /// `LW` (line width) — Table 58.
8589    #[test]
8590    fn gs_applies_line_width_lw() {
8591        let ext = ext_gstate_with(
8592            "GS1",
8593            Dict::new()
8594                .with("Type", Object::Name("ExtGState".into()))
8595                .with("LW", Object::Real(3.5)),
8596        );
8597        let bytes = b"q /GS1 gs 0 0 m 10 10 l S Q\n";
8598        let root = parse_with(bytes, &ext);
8599        let Node::Group(g) = &root.children[0] else {
8600            panic!()
8601        };
8602        let Node::Path(p) = &g.children[0] else {
8603            panic!()
8604        };
8605        let s = p.stroke.as_ref().expect("stroke set");
8606        assert!((s.width - 3.5).abs() < 1e-3);
8607    }
8608
8609    /// `LC` + `LJ` + `ML` — cap, join, miter limit.
8610    #[test]
8611    fn gs_applies_lc_lj_ml() {
8612        let ext = ext_gstate_with(
8613            "GS1",
8614            Dict::new()
8615                .with("LC", Object::Integer(1)) // Round
8616                .with("LJ", Object::Integer(2)) // Bevel
8617                .with("ML", Object::Real(7.5)),
8618        );
8619        let bytes = b"q /GS1 gs 0 0 m 10 10 l S Q\n";
8620        let root = parse_with(bytes, &ext);
8621        let Node::Group(g) = &root.children[0] else {
8622            panic!()
8623        };
8624        let Node::Path(p) = &g.children[0] else {
8625            panic!()
8626        };
8627        let s = p.stroke.as_ref().expect("stroke set");
8628        assert!(matches!(s.cap, LineCap::Round));
8629        assert!(matches!(s.join, LineJoin::Bevel));
8630        assert!((s.miter_limit - 7.5).abs() < 1e-3);
8631    }
8632
8633    /// `D` — dash pattern as `[ [dashArray] dashPhase ]`.
8634    #[test]
8635    fn gs_applies_d_dash_pattern() {
8636        let ext = ext_gstate_with(
8637            "GS1",
8638            Dict::new().with(
8639                "D",
8640                Object::Array(vec![
8641                    Object::Array(vec![Object::Real(4.0), Object::Real(2.0)]),
8642                    Object::Real(1.0),
8643                ]),
8644            ),
8645        );
8646        let bytes = b"q /GS1 gs 0 0 m 10 10 l S Q\n";
8647        let root = parse_with(bytes, &ext);
8648        let Node::Group(g) = &root.children[0] else {
8649            panic!()
8650        };
8651        let Node::Path(p) = &g.children[0] else {
8652            panic!()
8653        };
8654        let s = p.stroke.as_ref().expect("stroke set");
8655        let dash = s.dash.as_ref().expect("dash set");
8656        assert_eq!(dash.array, vec![4.0, 2.0]);
8657        assert!((dash.offset - 1.0).abs() < 1e-3);
8658    }
8659
8660    /// `ca` — nonstroking alpha constant multiplies into the fill
8661    /// colour's alpha (§11.6.4.4).
8662    #[test]
8663    fn gs_applies_ca_to_fill_alpha() {
8664        let ext = ext_gstate_with("GS1", Dict::new().with("ca", Object::Real(0.5)));
8665        // 1 0 0 rg paints opaque red — gs ca=0.5 → final alpha 128.
8666        let bytes = b"q 1 0 0 rg /GS1 gs 0 0 m 10 10 l 10 0 l h f Q\n";
8667        let root = parse_with(bytes, &ext);
8668        let Node::Group(g) = &root.children[0] else {
8669            panic!()
8670        };
8671        let Node::Path(p) = &g.children[0] else {
8672            panic!()
8673        };
8674        let Some(Paint::Solid(c)) = &p.fill else {
8675            panic!("fill")
8676        };
8677        assert_eq!((c.r, c.g, c.b), (255, 0, 0));
8678        // 1.0 * 0.5 * 255 = 127.5 → rounds to 128.
8679        assert_eq!(c.a, 128);
8680    }
8681
8682    /// `CA` — stroking alpha constant lands on the stroke's paint.
8683    #[test]
8684    fn gs_applies_cap_ca_to_stroke_alpha() {
8685        let ext = ext_gstate_with("GS1", Dict::new().with("CA", Object::Real(0.25)));
8686        let bytes = b"q 0 1 0 RG /GS1 gs 0 0 m 10 10 l S Q\n";
8687        let root = parse_with(bytes, &ext);
8688        let Node::Group(g) = &root.children[0] else {
8689            panic!()
8690        };
8691        let Node::Path(p) = &g.children[0] else {
8692            panic!()
8693        };
8694        let s = p.stroke.as_ref().expect("stroke set");
8695        let Paint::Solid(c) = &s.paint else { panic!() };
8696        assert_eq!((c.r, c.g, c.b), (0, 255, 0));
8697        // 1.0 * 0.25 * 255 = 63.75 → rounds to 64.
8698        assert_eq!(c.a, 64);
8699    }
8700
8701    /// A `gs` against an undefined ExtGState name is a tolerated no-op
8702    /// — the existing stroke/colour state passes through unchanged.
8703    #[test]
8704    fn gs_unknown_name_is_no_op() {
8705        let ext = ext_gstate_with("GS1", Dict::new().with("LW", Object::Real(9.0)));
8706        let bytes = b"q 2.5 w /GS_OTHER gs 0 0 m 10 10 l S Q\n";
8707        let root = parse_with(bytes, &ext);
8708        let Node::Group(g) = &root.children[0] else {
8709            panic!()
8710        };
8711        let Node::Path(p) = &g.children[0] else {
8712            panic!()
8713        };
8714        let s = p.stroke.as_ref().expect("stroke");
8715        // The earlier `2.5 w` still wins — GS_OTHER isn't in the dict.
8716        assert!((s.width - 2.5).abs() < 1e-3);
8717    }
8718
8719    /// Multiple `gs` invocations cumulate (Table 58 — "results of gs
8720    /// shall be cumulative") so an earlier `LW` survives a later `gs`
8721    /// that touches only `CA`.
8722    #[test]
8723    fn multiple_gs_invocations_cumulate() {
8724        let mut ext = Dict::new();
8725        ext.set(
8726            "GW",
8727            Object::Dict(Dict::new().with("LW", Object::Real(4.0))),
8728        );
8729        ext.set(
8730            "GA",
8731            Object::Dict(Dict::new().with("CA", Object::Real(0.5))),
8732        );
8733        let bytes = b"q /GW gs /GA gs 1 0 0 RG 0 0 m 10 10 l S Q\n";
8734        let root = parse_with(bytes, &ext);
8735        let Node::Group(g) = &root.children[0] else {
8736            panic!()
8737        };
8738        let Node::Path(p) = &g.children[0] else {
8739            panic!()
8740        };
8741        let s = p.stroke.as_ref().expect("stroke");
8742        assert!((s.width - 4.0).abs() < 1e-3);
8743        let Paint::Solid(c) = &s.paint else { panic!() };
8744        assert_eq!(c.a, 128);
8745    }
8746
8747    /// Without the resource-aware entry point, `gs` is a tolerated
8748    /// no-op — the legacy `parse_content_stream` path must not change.
8749    #[test]
8750    fn legacy_parse_content_stream_drops_gs_operands() {
8751        let bytes = b"q 2.5 w /GS1 gs 0 0 m 10 10 l S Q\n";
8752        let root = parse_content_stream(bytes).unwrap();
8753        let Node::Group(g) = &root.children[0] else {
8754            panic!()
8755        };
8756        let Node::Path(p) = &g.children[0] else {
8757            panic!()
8758        };
8759        let s = p.stroke.as_ref().expect("stroke");
8760        assert!((s.width - 2.5).abs() < 1e-3);
8761    }
8762
8763    /// Unhandled Table 58 keys (BM, OP, SMask, RI, …) are tolerated
8764    /// silently — the spec explicitly allows "any combination of
8765    /// parameter entries" including ones a reader can't honour.
8766    #[test]
8767    fn gs_unknown_table_58_keys_are_tolerated() {
8768        let ext = ext_gstate_with(
8769            "GS1",
8770            Dict::new()
8771                .with("BM", Object::Name("Multiply".into()))
8772                .with("OP", Object::Bool(true))
8773                .with("RI", Object::Name("Perceptual".into()))
8774                .with("LW", Object::Real(2.0)),
8775        );
8776        let bytes = b"q /GS1 gs 0 0 m 10 10 l S Q\n";
8777        let root = parse_with(bytes, &ext);
8778        let Node::Group(g) = &root.children[0] else {
8779            panic!()
8780        };
8781        let Node::Path(p) = &g.children[0] else {
8782            panic!()
8783        };
8784        let s = p.stroke.as_ref().expect("stroke set");
8785        // The honoured LW reaches the stroke even though BM / OP / RI
8786        // were also present.
8787        assert!((s.width - 2.0).abs() < 1e-3);
8788    }
8789
8790    /// `apply_alpha` on a solid keeps RGB and scales the existing
8791    /// alpha — composes with any pre-set alpha rather than overwriting
8792    /// it.
8793    #[test]
8794    fn apply_alpha_composes_with_existing_alpha() {
8795        let base = Paint::Solid(Rgba::new(100, 200, 50, 200));
8796        let out = apply_alpha(base, 0.5);
8797        let Paint::Solid(c) = out else { panic!() };
8798        // 200/255 * 0.5 * 255 = 100.
8799        assert_eq!((c.r, c.g, c.b), (100, 200, 50));
8800        assert_eq!(c.a, 100);
8801    }
8802
8803    /// `apply_alpha` short-circuits at α=1.0 (no-op).
8804    #[test]
8805    fn apply_alpha_unit_is_identity() {
8806        let base = Paint::Solid(Rgba::new(10, 20, 30, 200));
8807        let out = apply_alpha(base, 1.0);
8808        let Paint::Solid(c) = out else { panic!() };
8809        assert_eq!(c.a, 200);
8810    }
8811
8812    /// `parse_dash_pair` decodes the `[ [dashArray] dashPhase ]`
8813    /// two-element shape Table 58 specifies.
8814    #[test]
8815    fn parse_dash_pair_two_element_array() {
8816        let obj = Object::Array(vec![
8817            Object::Array(vec![Object::Real(2.0), Object::Real(1.0)]),
8818            Object::Integer(3),
8819        ]);
8820        let (arr, off) = parse_dash_pair(&obj).expect("parses");
8821        assert_eq!(arr, vec![2.0, 1.0]);
8822        assert!((off - 3.0).abs() < 1e-3);
8823    }
8824
8825    /// `parse_dash_pair` rejects malformed shapes.
8826    #[test]
8827    fn parse_dash_pair_rejects_malformed() {
8828        // Not an array.
8829        assert!(parse_dash_pair(&Object::Integer(0)).is_none());
8830        // Wrong arity.
8831        assert!(parse_dash_pair(&Object::Array(vec![Object::Integer(0)])).is_none());
8832        // First element isn't an array.
8833        assert!(
8834            parse_dash_pair(&Object::Array(vec![Object::Integer(0), Object::Integer(0)])).is_none()
8835        );
8836    }
8837
8838    // ── Font resource plumbing + text show (round 128, ISO 32000-1 §9.4) ──
8839
8840    /// Helper: build a `/Resources /Font` dictionary with one named
8841    /// simple-font descriptor. The dict shape mirrors what
8842    /// `resolve_font_resources` hands back from the document walker.
8843    fn font_res_with(name: &str, dict: Dict) -> Dict {
8844        Dict::new().with(name, Object::Dict(dict))
8845    }
8846
8847    fn parse_full(input: &[u8], ext: Option<&Dict>, fonts: Option<&Dict>) -> ParsedContent {
8848        parse_content_stream_full(input, ext, fonts).unwrap()
8849    }
8850
8851    /// A plain `BT … Tj … ET` with `/F1 12 Tf` surfaces one
8852    /// [`ContentTextShow`] with the font name + size + decoded
8853    /// literal-string bytes attached.
8854    #[test]
8855    fn tj_emits_one_text_show_with_font_and_size() {
8856        let f1 = Dict::new()
8857            .with("Type", Object::Name("Font".into()))
8858            .with("Subtype", Object::Name("Type1".into()))
8859            .with("BaseFont", Object::Name("Helvetica".into()));
8860        let fonts = font_res_with("F1", f1);
8861        let bytes = b"BT /F1 12 Tf 72 712 Td (Hello) Tj ET\n";
8862        let p = parse_full(bytes, None, Some(&fonts));
8863        assert_eq!(p.text_shows.len(), 1);
8864        let show = &p.text_shows[0];
8865        assert_eq!(show.font_name, "F1");
8866        assert!((show.font_size - 12.0).abs() < 1e-3);
8867        assert_eq!(show.bytes, b"Hello");
8868        assert!((show.position.0 - 72.0).abs() < 1e-3);
8869        assert!((show.position.1 - 712.0).abs() < 1e-3);
8870        assert!(matches!(show.operator, TextShowOp::Tj));
8871        assert!(show.font_dict.is_some());
8872    }
8873
8874    /// `TJ` accepts `[ (s1) num1 (s2) num2 … ]`; the strings are
8875    /// concatenated in array order, numeric kerns dropped.
8876    #[test]
8877    fn tj_array_concatenates_strings_and_drops_kerns() {
8878        let fonts = font_res_with("F1", Dict::new());
8879        let bytes = b"BT /F1 10 Tf 0 0 Td [(Hel) -250 (lo) -120 (!)] TJ ET\n";
8880        let p = parse_full(bytes, None, Some(&fonts));
8881        assert_eq!(p.text_shows.len(), 1);
8882        let show = &p.text_shows[0];
8883        assert_eq!(show.bytes, b"Hello!");
8884        assert!(matches!(show.operator, TextShowOp::TJ));
8885    }
8886
8887    /// `'` (single-quote) does the implicit `T*` line-advance first.
8888    /// With `TL = 14` the y-step is `-14`. Form: `string '`.
8889    #[test]
8890    fn single_quote_does_implicit_t_star_then_show() {
8891        let fonts = font_res_with("F1", Dict::new());
8892        let bytes = b"BT /F1 12 Tf 14 TL 0 100 Td (first) Tj (second) ' ET\n";
8893        let p = parse_full(bytes, None, Some(&fonts));
8894        assert_eq!(p.text_shows.len(), 2);
8895        assert_eq!(p.text_shows[0].bytes, b"first");
8896        assert!((p.text_shows[0].position.1 - 100.0).abs() < 1e-3);
8897        assert_eq!(p.text_shows[1].bytes, b"second");
8898        // T* moves down by TL: y = 100 - 14 = 86.
8899        assert!((p.text_shows[1].position.1 - 86.0).abs() < 1e-3);
8900        assert!(matches!(p.text_shows[1].operator, TextShowOp::SingleQuote));
8901    }
8902
8903    /// `"` (double-quote) consumes its leading `aw ac` numbers then
8904    /// does the implicit `T*` + show. We don't track aw/ac but the
8905    /// line-advance must still fire. Form: `aw ac string "`.
8906    #[test]
8907    fn double_quote_does_implicit_t_star_then_show() {
8908        let fonts = font_res_with("F1", Dict::new());
8909        let bytes = b"BT /F1 12 Tf 10 TL 0 100 Td (first) Tj 1 2 (second) \" ET\n";
8910        let p = parse_full(bytes, None, Some(&fonts));
8911        assert_eq!(p.text_shows.len(), 2);
8912        assert_eq!(p.text_shows[1].bytes, b"second");
8913        assert!((p.text_shows[1].position.1 - 90.0).abs() < 1e-3);
8914        assert!(matches!(p.text_shows[1].operator, TextShowOp::DoubleQuote));
8915    }
8916
8917    /// `Tm` sets the text matrix verbatim — origin = (e, f).
8918    #[test]
8919    fn tm_sets_text_matrix_directly() {
8920        let fonts = font_res_with("F1", Dict::new());
8921        let bytes = b"BT /F1 10 Tf 1 0 0 1 50 600 Tm (P) Tj ET\n";
8922        let p = parse_full(bytes, None, Some(&fonts));
8923        assert_eq!(p.text_shows.len(), 1);
8924        assert!((p.text_shows[0].position.0 - 50.0).abs() < 1e-3);
8925        assert!((p.text_shows[0].position.1 - 600.0).abs() < 1e-3);
8926    }
8927
8928    /// `BT` resets the text matrix — runs from a prior `BT … ET`
8929    /// don't bleed into the next text object's position.
8930    #[test]
8931    fn bt_resets_text_matrix() {
8932        let fonts = font_res_with("F1", Dict::new());
8933        let bytes = b"BT /F1 12 Tf 100 200 Td (A) Tj ET BT /F1 12 Tf 0 0 Td (B) Tj ET\n";
8934        let p = parse_full(bytes, None, Some(&fonts));
8935        assert_eq!(p.text_shows.len(), 2);
8936        assert!((p.text_shows[0].position.0 - 100.0).abs() < 1e-3);
8937        // The second BT zeros out the matrix, then 0 0 Td adds (0,0).
8938        assert!(p.text_shows[1].position.0.abs() < 1e-3);
8939        assert!(p.text_shows[1].position.1.abs() < 1e-3);
8940    }
8941
8942    /// `Tj` against a font *name* that isn't in the resources dict
8943    /// still surfaces the show — `font_dict` is `None` so the
8944    /// consumer knows the font wasn't resolved.
8945    #[test]
8946    fn tj_with_unknown_font_name_still_emits_show_with_none_dict() {
8947        let fonts = font_res_with("F1", Dict::new());
8948        let bytes = b"BT /F_OTHER 12 Tf 0 0 Td (Hi) Tj ET\n";
8949        let p = parse_full(bytes, None, Some(&fonts));
8950        assert_eq!(p.text_shows.len(), 1);
8951        assert_eq!(p.text_shows[0].font_name, "F_OTHER");
8952        assert_eq!(p.text_shows[0].bytes, b"Hi");
8953        assert!(p.text_shows[0].font_dict.is_none());
8954    }
8955
8956    /// Without `font_resources` (the round-3 / round-125 entry
8957    /// points), text shows never emit — backward compatibility.
8958    #[test]
8959    fn legacy_entry_points_drop_tj_silently() {
8960        let bytes = b"BT /F1 12 Tf 0 0 Td (Hello) Tj ET\n";
8961        let r1 = parse_content_stream(bytes).unwrap();
8962        // No painted geometry — text doesn't reach the IR.
8963        assert!(r1.children.is_empty());
8964        let r2 = parse_content_stream_with_resources(bytes, None).unwrap();
8965        assert!(r2.children.is_empty());
8966    }
8967
8968    /// `Tj` *outside* a `BT … ET` block is silently ignored — §9.4 +
8969    /// Table 105 says text-state operators are only valid inside a
8970    /// text object.
8971    #[test]
8972    fn tj_outside_text_object_is_dropped() {
8973        let fonts = font_res_with("F1", Dict::new());
8974        // No BT — stray Tj must not emit.
8975        let bytes = b"(stray) Tj\n";
8976        let p = parse_full(bytes, None, Some(&fonts));
8977        assert_eq!(p.text_shows.len(), 0);
8978    }
8979
8980    /// Hex-string operands (`<48656C6C6F>` = `Hello`) decode through
8981    /// the same path as literal strings.
8982    #[test]
8983    fn hex_string_operand_decodes_for_tj() {
8984        let fonts = font_res_with("F1", Dict::new());
8985        let bytes = b"BT /F1 12 Tf 0 0 Td <48656C6C6F> Tj ET\n";
8986        let p = parse_full(bytes, None, Some(&fonts));
8987        assert_eq!(p.text_shows.len(), 1);
8988        assert_eq!(p.text_shows[0].bytes, b"Hello");
8989    }
8990
8991    /// Octal escape sequence `\101` = `'A'` (=0o101) in a literal
8992    /// string operand decodes to the right byte.
8993    #[test]
8994    fn literal_string_octal_escape() {
8995        let fonts = font_res_with("F1", Dict::new());
8996        let bytes = b"BT /F1 12 Tf 0 0 Td (\\101\\102\\103) Tj ET\n";
8997        let p = parse_full(bytes, None, Some(&fonts));
8998        assert_eq!(p.text_shows[0].bytes, b"ABC");
8999    }
9000
9001    /// Newline + tab + paren escapes round-trip.
9002    #[test]
9003    fn literal_string_named_escapes() {
9004        let fonts = font_res_with("F1", Dict::new());
9005        let bytes = b"BT /F1 12 Tf 0 0 Td (a\\nb\\tc\\(d\\)) Tj ET\n";
9006        let p = parse_full(bytes, None, Some(&fonts));
9007        assert_eq!(p.text_shows[0].bytes, b"a\nb\tc(d)");
9008    }
9009
9010    /// Painted geometry still lands in the IR even when a `BT … ET`
9011    /// runs in the same stream — text + paths coexist cleanly.
9012    #[test]
9013    fn text_and_path_coexist_in_one_stream() {
9014        let fonts = font_res_with("F1", Dict::new());
9015        let bytes = b"q 0 0 m 10 10 l 10 0 l h f BT /F1 12 Tf 0 0 Td (X) Tj ET Q\n";
9016        let p = parse_full(bytes, None, Some(&fonts));
9017        // One painted group with one path child.
9018        assert_eq!(p.root.children.len(), 1);
9019        let Node::Group(g) = &p.root.children[0] else {
9020            panic!()
9021        };
9022        assert!(matches!(g.children[0], Node::Path(_)));
9023        assert_eq!(p.text_shows.len(), 1);
9024        assert_eq!(p.text_shows[0].bytes, b"X");
9025    }
9026
9027    /// `read_hex_string` pads a trailing odd nibble with 0 per
9028    /// §7.3.4.3.
9029    #[test]
9030    fn hex_string_pads_trailing_odd_nibble() {
9031        let (end, bytes) = read_hex_string(b"<4>x", 0).unwrap();
9032        assert_eq!(end, 3);
9033        assert_eq!(bytes, vec![0x40]);
9034    }
9035
9036    /// `read_hex_string` skips whitespace and is case-insensitive
9037    /// on letters.
9038    #[test]
9039    fn hex_string_skips_whitespace_and_is_case_insensitive() {
9040        let (_end, bytes) = read_hex_string(b"<4a 5C>", 0).unwrap();
9041        assert_eq!(bytes, vec![0x4A, 0x5C]);
9042    }
9043
9044    // ── `sh` shading-paint event (round 259, ISO 32000-1 §8.7.4.5) ──
9045
9046    /// Helper: build a `/Resources /Shading` dictionary with one
9047    /// named shading. Mirrors `font_res_with` for the round-259
9048    /// shading-resources plumbing.
9049    fn shading_res_with(name: &str, dict: Dict) -> Dict {
9050        Dict::new().with(name, Object::Dict(dict))
9051    }
9052
9053    fn parse_with_shading(
9054        input: &[u8],
9055        ext: Option<&Dict>,
9056        fonts: Option<&Dict>,
9057        shadings: Option<&Dict>,
9058    ) -> ParsedContent {
9059        parse_content_stream_full_with_shading(input, ext, fonts, shadings).unwrap()
9060    }
9061
9062    /// `/Sh1 sh` with `/Resources /Shading /Sh1 = << /ShadingType 2 … >>`
9063    /// surfaces one [`ContentShading`] with the name + resolved dict.
9064    #[test]
9065    fn sh_emits_one_shading_event_with_resolved_dict() {
9066        // A minimal Type 2 (axial) shading dict per §8.7.4.5.3
9067        // Table 80 — we only check the dispatch surfaces it
9068        // verbatim; the round-259 walker doesn't interpret entries.
9069        let sh1 = Dict::new()
9070            .with("ShadingType", Object::Integer(2))
9071            .with("ColorSpace", Object::Name("DeviceRGB".into()))
9072            .with(
9073                "Coords",
9074                Object::Array(vec![
9075                    Object::Real(0.0),
9076                    Object::Real(0.0),
9077                    Object::Real(100.0),
9078                    Object::Real(0.0),
9079                ]),
9080            );
9081        let shadings = shading_res_with("Sh1", sh1);
9082        // `q ... /Sh1 sh ... Q` — paint the shading in the current
9083        // user space (no `cm`, so CTM = identity).
9084        let bytes = b"q /Sh1 sh Q\n";
9085        let p = parse_with_shading(bytes, None, None, Some(&shadings));
9086        assert_eq!(p.shadings.len(), 1);
9087        let s = &p.shadings[0];
9088        assert_eq!(s.name, "Sh1");
9089        let dict = s.shading_dict.as_ref().expect("resolved");
9090        // ShadingType entry made it through.
9091        let st = dict
9092            .entries()
9093            .iter()
9094            .find(|(k, _)| k == "ShadingType")
9095            .map(|(_, v)| v.clone());
9096        assert!(matches!(st, Some(Object::Integer(2))));
9097        // CTM is identity (no `cm` issued).
9098        assert!((s.ctm.a - 1.0).abs() < 1e-6);
9099        assert!((s.ctm.d - 1.0).abs() < 1e-6);
9100        assert!(s.ctm.b.abs() < 1e-6);
9101        assert!(s.ctm.c.abs() < 1e-6);
9102        assert!(s.ctm.e.abs() < 1e-6);
9103        assert!(s.ctm.f.abs() < 1e-6);
9104        // No clip in force.
9105        assert!(s.clip.is_none());
9106    }
9107
9108    /// `cm` issued before `sh` is captured in the event's CTM. The
9109    /// spec example in §8.7.4.5.4 paints `/Sh1 sh` after a
9110    /// `27.7843 0.0000 0.0000 -27.7843 310.2461 121.1521 cm` — the
9111    /// CTM is the composed matrix at the moment of paint.
9112    #[test]
9113    fn sh_captures_effective_ctm_from_cm() {
9114        let sh1 = Dict::new().with("ShadingType", Object::Integer(2));
9115        let shadings = shading_res_with("Sh1", sh1);
9116        // q ... cm ... /Sh1 sh ... Q
9117        let bytes = b"q 27.7843 0.0 0.0 -27.7843 310.2461 121.1521 cm /Sh1 sh Q\n";
9118        let p = parse_with_shading(bytes, None, None, Some(&shadings));
9119        assert_eq!(p.shadings.len(), 1);
9120        let s = &p.shadings[0];
9121        assert!((s.ctm.a - 27.7843).abs() < 1e-3);
9122        assert!(s.ctm.b.abs() < 1e-3);
9123        assert!(s.ctm.c.abs() < 1e-3);
9124        assert!((s.ctm.d - -27.7843).abs() < 1e-3);
9125        assert!((s.ctm.e - 310.2461).abs() < 1e-3);
9126        assert!((s.ctm.f - 121.1521).abs() < 1e-3);
9127    }
9128
9129    /// `cm` operators in nested `q` frames compose root-to-leaf —
9130    /// the event's CTM reflects every transform in force.
9131    #[test]
9132    fn sh_composes_nested_cm_across_q_frames() {
9133        let sh1 = Dict::new();
9134        let shadings = shading_res_with("Sh1", sh1);
9135        // Outer q: translate(10, 20). Inner q: translate(5, 0).
9136        // Effective CTM at `sh`: translate(15, 20).
9137        let bytes = b"q 1 0 0 1 10 20 cm q 1 0 0 1 5 0 cm /Sh1 sh Q Q\n";
9138        let p = parse_with_shading(bytes, None, None, Some(&shadings));
9139        assert_eq!(p.shadings.len(), 1);
9140        let s = &p.shadings[0];
9141        assert!((s.ctm.e - 15.0).abs() < 1e-3);
9142        assert!((s.ctm.f - 20.0).abs() < 1e-3);
9143        assert!((s.ctm.a - 1.0).abs() < 1e-3);
9144        assert!((s.ctm.d - 1.0).abs() < 1e-3);
9145    }
9146
9147    /// A `W n` clip committed before `sh` is captured in the
9148    /// event's `clip` slot.
9149    #[test]
9150    fn sh_captures_active_clip_path() {
9151        let sh1 = Dict::new();
9152        let shadings = shading_res_with("Sh1", sh1);
9153        // q ... 0 0 100 50 re W n /Sh1 sh Q — a rectangle clip
9154        // committed before the paint.
9155        let bytes = b"q 0 0 100 50 re W n /Sh1 sh Q\n";
9156        let p = parse_with_shading(bytes, None, None, Some(&shadings));
9157        assert_eq!(p.shadings.len(), 1);
9158        let s = &p.shadings[0];
9159        let clip = s.clip.as_ref().expect("clip in force");
9160        // The `re` operator expands into MoveTo + 3 LineTo + Close.
9161        assert!(!clip.commands.is_empty());
9162    }
9163
9164    /// `sh` against a shading name not in the resources dict still
9165    /// emits the event — `shading_dict` is `None` so the consumer
9166    /// knows the resource wasn't resolved. Mirrors the
9167    /// `Tj`-with-unknown-font tolerance contract.
9168    #[test]
9169    fn sh_with_unknown_name_still_emits_event_with_none_dict() {
9170        let shadings = shading_res_with("Sh1", Dict::new());
9171        let bytes = b"q /Other sh Q\n";
9172        let p = parse_with_shading(bytes, None, None, Some(&shadings));
9173        assert_eq!(p.shadings.len(), 1);
9174        let s = &p.shadings[0];
9175        assert_eq!(s.name, "Other");
9176        assert!(s.shading_dict.is_none());
9177    }
9178
9179    /// Without `shading_resources` plumbed in (the legacy entry
9180    /// points), `sh` still surfaces the event so callers see the
9181    /// operator + name + CTM + clip — only `shading_dict` is `None`.
9182    #[test]
9183    fn sh_without_shading_resources_emits_event_with_none_dict() {
9184        let bytes = b"q 1 0 0 1 50 60 cm /Sh1 sh Q\n";
9185        let p = parse_with_shading(bytes, None, None, None);
9186        assert_eq!(p.shadings.len(), 1);
9187        let s = &p.shadings[0];
9188        assert_eq!(s.name, "Sh1");
9189        assert!(s.shading_dict.is_none());
9190        assert!((s.ctm.e - 50.0).abs() < 1e-3);
9191        assert!((s.ctm.f - 60.0).abs() < 1e-3);
9192    }
9193
9194    /// Multiple `sh` events in stream order all surface; each
9195    /// event's CTM reflects the matrix at *its* moment of paint.
9196    #[test]
9197    fn sh_multiple_events_surface_in_stream_order() {
9198        let shadings = Dict::new()
9199            .with("Sh1", Object::Dict(Dict::new()))
9200            .with("Sh2", Object::Dict(Dict::new()));
9201        // Two `sh`s in two different `q` frames with different
9202        // transforms.
9203        let bytes = b"q 1 0 0 1 10 20 cm /Sh1 sh Q q 1 0 0 1 30 40 cm /Sh2 sh Q\n";
9204        let p = parse_with_shading(bytes, None, None, Some(&shadings));
9205        assert_eq!(p.shadings.len(), 2);
9206        assert_eq!(p.shadings[0].name, "Sh1");
9207        assert!((p.shadings[0].ctm.e - 10.0).abs() < 1e-3);
9208        assert!((p.shadings[0].ctm.f - 20.0).abs() < 1e-3);
9209        assert_eq!(p.shadings[1].name, "Sh2");
9210        assert!((p.shadings[1].ctm.e - 30.0).abs() < 1e-3);
9211        assert!((p.shadings[1].ctm.f - 40.0).abs() < 1e-3);
9212    }
9213
9214    /// `parse_content_stream_full` (no shading resources) keeps its
9215    /// existing surface — the new `shadings` slot is populated only
9216    /// when a `sh` operator fires, and the resolved dict slot stays
9217    /// `None` because the caller didn't plumb resources.
9218    #[test]
9219    fn parse_content_stream_full_still_drops_sh_with_none_dict() {
9220        // Goes through the legacy entry point — no shading
9221        // resources.
9222        let bytes = b"q /Sh1 sh Q\n";
9223        let p = parse_content_stream_full(bytes, None, None).unwrap();
9224        assert_eq!(p.shadings.len(), 1);
9225        assert_eq!(p.shadings[0].name, "Sh1");
9226        assert!(p.shadings[0].shading_dict.is_none());
9227    }
9228
9229    /// Content streams without any `sh` operator surface an empty
9230    /// `shadings` slot regardless of whether resources were
9231    /// plumbed in.
9232    #[test]
9233    fn shadings_empty_when_no_sh_operator() {
9234        let shadings = shading_res_with("Sh1", Dict::new());
9235        let bytes = b"q 100 100 m 200 200 l S Q\n";
9236        let p = parse_with_shading(bytes, None, None, Some(&shadings));
9237        assert!(p.shadings.is_empty());
9238    }
9239
9240    // ───────────── mesh shadings (§8.7.4.5.5–8) ──────────────
9241
9242    /// A tiny MSB-first bit writer mirroring [`BitReader`], used to
9243    /// hand-assemble mesh stream bodies in the tests.
9244    struct BitWriter {
9245        bytes: Vec<u8>,
9246        bit: u32,
9247    }
9248    impl BitWriter {
9249        fn new() -> Self {
9250            Self {
9251                bytes: Vec::new(),
9252                bit: 0,
9253            }
9254        }
9255        fn write(&mut self, value: u64, bits: u32) {
9256            for i in (0..bits).rev() {
9257                if self.bit == 0 {
9258                    self.bytes.push(0);
9259                }
9260                let b = ((value >> i) & 1) as u8;
9261                let last = self.bytes.len() - 1;
9262                self.bytes[last] |= b << (7 - self.bit);
9263                self.bit = (self.bit + 1) % 8;
9264            }
9265        }
9266        fn align(&mut self) {
9267            self.bit = 0;
9268        }
9269        fn finish(mut self) -> Vec<u8> {
9270            self.align();
9271            self.bytes
9272        }
9273    }
9274
9275    fn decode_rgb8() -> Object {
9276        // [ xmin xmax ymin ymax rmin rmax gmin gmax bmin bmax ] for an
9277        // 8-bit coordinate / colour DeviceRGB mesh over a 0..1 unit box.
9278        Object::Array(
9279            [0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0]
9280                .into_iter()
9281                .map(Object::Real)
9282                .collect(),
9283        )
9284    }
9285
9286    /// Type 4 free-form Gouraud mesh: one all-red / green / blue
9287    /// triangle decodes to three coloured vertices.
9288    #[test]
9289    fn mesh_type4_single_triangle_decodes_vertices() {
9290        let mut w = BitWriter::new();
9291        // f=0, (0,0) red ; ignored-flag, (1,0) green ; ignored, (0,1) blue.
9292        // 8-bit coords, 8-bit components, 8-bit flag.
9293        let vert = |w: &mut BitWriter, flag: u64, x: u64, y: u64, r: u64, g: u64, b: u64| {
9294            w.write(flag, 8);
9295            w.write(x, 8);
9296            w.write(y, 8);
9297            w.write(r, 8);
9298            w.write(g, 8);
9299            w.write(b, 8);
9300            w.align();
9301        };
9302        vert(&mut w, 0, 0, 0, 255, 0, 0);
9303        vert(&mut w, 0, 255, 0, 0, 255, 0);
9304        vert(&mut w, 0, 0, 255, 0, 0, 255);
9305        let data = w.finish();
9306        let dict = Dict::new()
9307            .with("ShadingType", Object::Integer(4))
9308            .with("ColorSpace", Object::Name("DeviceRGB".into()))
9309            .with("BitsPerCoordinate", Object::Integer(8))
9310            .with("BitsPerComponent", Object::Integer(8))
9311            .with("BitsPerFlag", Object::Integer(8))
9312            .with("Decode", decode_rgb8())
9313            .with("__MeshData", Object::HexString(data));
9314        let mesh = evaluate_mesh_shading(&dict, None).expect("mesh evaluated");
9315        let MeshShading::Triangles(tris) = mesh else {
9316            panic!("expected triangles")
9317        };
9318        assert_eq!(tris.len(), 1);
9319        let v = tris[0].vertices;
9320        assert!((v[0].point.x - 0.0).abs() < 1e-4 && (v[0].point.y - 0.0).abs() < 1e-4);
9321        assert_eq!((v[0].color.r, v[0].color.g, v[0].color.b), (255, 0, 0));
9322        assert!((v[1].point.x - 1.0).abs() < 1e-4);
9323        assert_eq!((v[1].color.r, v[1].color.g, v[1].color.b), (0, 255, 0));
9324        assert!((v[2].point.y - 1.0).abs() < 1e-4);
9325        assert_eq!((v[2].color.r, v[2].color.g, v[2].color.b), (0, 0, 255));
9326    }
9327
9328    /// Type 4 edge-flag continuation: a second vertex with `f=1` reuses
9329    /// (vb, vc) of the first triangle (§8.7.4.5.5 Figure 25).
9330    #[test]
9331    fn mesh_type4_edge_flag_continuation() {
9332        let mut w = BitWriter::new();
9333        let vert = |w: &mut BitWriter, flag: u64, x: u64, y: u64, r: u64, g: u64, b: u64| {
9334            w.write(flag, 8);
9335            w.write(x, 8);
9336            w.write(y, 8);
9337            w.write(r, 8);
9338            w.write(g, 8);
9339            w.write(b, 8);
9340            w.align();
9341        };
9342        vert(&mut w, 0, 0, 0, 255, 0, 0); // va
9343        vert(&mut w, 0, 255, 0, 0, 255, 0); // vb
9344        vert(&mut w, 0, 0, 255, 0, 0, 255); // vc
9345        vert(&mut w, 1, 255, 255, 255, 255, 0); // vd on side vbc
9346        let data = w.finish();
9347        let dict = Dict::new()
9348            .with("ShadingType", Object::Integer(4))
9349            .with("ColorSpace", Object::Name("DeviceRGB".into()))
9350            .with("BitsPerCoordinate", Object::Integer(8))
9351            .with("BitsPerComponent", Object::Integer(8))
9352            .with("BitsPerFlag", Object::Integer(8))
9353            .with("Decode", decode_rgb8())
9354            .with("__MeshData", Object::HexString(data));
9355        let MeshShading::Triangles(tris) = evaluate_mesh_shading(&dict, None).unwrap() else {
9356            panic!()
9357        };
9358        assert_eq!(tris.len(), 2);
9359        // Second triangle = (vb, vc, vd).
9360        let t2 = tris[1].vertices;
9361        assert!((t2[0].point.x - 1.0).abs() < 1e-4 && t2[0].point.y.abs() < 1e-4); // vb
9362        assert!(t2[1].point.x.abs() < 1e-4 && (t2[1].point.y - 1.0).abs() < 1e-4); // vc
9363        assert!((t2[2].point.x - 1.0).abs() < 1e-4 && (t2[2].point.y - 1.0).abs() < 1e-4); // vd
9364        assert_eq!((t2[2].color.r, t2[2].color.g, t2[2].color.b), (255, 255, 0));
9365    }
9366
9367    /// Type 5 lattice mesh: a 2×2 lattice (2 rows, 2 vertices/row)
9368    /// builds two triangles per the §8.7.4.5.6 triplet rule.
9369    #[test]
9370    fn mesh_type5_lattice_two_by_two() {
9371        let mut w = BitWriter::new();
9372        let vert = |w: &mut BitWriter, x: u64, y: u64, r: u64, g: u64, b: u64| {
9373            w.write(x, 8);
9374            w.write(y, 8);
9375            w.write(r, 8);
9376            w.write(g, 8);
9377            w.write(b, 8);
9378            w.align();
9379        };
9380        // Row 0: (0,0) red, (1,0) green. Row 1: (0,1) blue, (1,1) white.
9381        vert(&mut w, 0, 0, 255, 0, 0);
9382        vert(&mut w, 255, 0, 0, 255, 0);
9383        vert(&mut w, 0, 255, 0, 0, 255);
9384        vert(&mut w, 255, 255, 255, 255, 255);
9385        let data = w.finish();
9386        let dict = Dict::new()
9387            .with("ShadingType", Object::Integer(5))
9388            .with("ColorSpace", Object::Name("DeviceRGB".into()))
9389            .with("BitsPerCoordinate", Object::Integer(8))
9390            .with("BitsPerComponent", Object::Integer(8))
9391            .with("VerticesPerRow", Object::Integer(2))
9392            .with("Decode", decode_rgb8())
9393            .with("__MeshData", Object::HexString(data));
9394        let MeshShading::Triangles(tris) = evaluate_mesh_shading(&dict, None).unwrap() else {
9395            panic!()
9396        };
9397        // One cell → two triangles.
9398        assert_eq!(tris.len(), 2);
9399        // First triangle = (V00, V01, V10) = red, green, blue.
9400        let t = tris[0].vertices;
9401        assert_eq!((t[0].color.r, t[0].color.g, t[0].color.b), (255, 0, 0));
9402        assert_eq!((t[1].color.r, t[1].color.g, t[1].color.b), (0, 255, 0));
9403        assert_eq!((t[2].color.r, t[2].color.g, t[2].color.b), (0, 0, 255));
9404    }
9405
9406    /// Type 6 Coons patch (single patch, `f=0`): 12 boundary points +
9407    /// 4 corner colours decode; the four internal control points are
9408    /// derived, and corner colours land at p00/p03/p33/p30.
9409    #[test]
9410    fn mesh_type6_coons_single_patch() {
9411        let mut w = BitWriter::new();
9412        w.write(0, 8); // edge flag f=0
9413                       // 12 boundary points. Lay out a unit square traced in the
9414                       // Coons order (p00 p01 p02 p03 p13 p23 p33 p32 p31 p30 p20 p10).
9415        let pts: [(u64, u64); 12] = [
9416            (0, 0),     // p00
9417            (0, 85),    // p01
9418            (0, 170),   // p02
9419            (0, 255),   // p03
9420            (85, 255),  // p13
9421            (170, 255), // p23
9422            (255, 255), // p33
9423            (255, 170), // p32
9424            (255, 85),  // p31
9425            (255, 0),   // p30
9426            (170, 0),   // p20
9427            (85, 0),    // p10
9428        ];
9429        for (x, y) in pts {
9430            w.write(x, 8);
9431            w.write(y, 8);
9432        }
9433        // Four corner colours c1..c4 (p00 red, p03 green, p33 blue, p30 white).
9434        let cols: [(u64, u64, u64); 4] = [(255, 0, 0), (0, 255, 0), (0, 0, 255), (255, 255, 255)];
9435        for (r, g, b) in cols {
9436            w.write(r, 8);
9437            w.write(g, 8);
9438            w.write(b, 8);
9439        }
9440        w.align();
9441        let data = w.finish();
9442        let dict = Dict::new()
9443            .with("ShadingType", Object::Integer(6))
9444            .with("ColorSpace", Object::Name("DeviceRGB".into()))
9445            .with("BitsPerCoordinate", Object::Integer(8))
9446            .with("BitsPerComponent", Object::Integer(8))
9447            .with("BitsPerFlag", Object::Integer(8))
9448            .with("Decode", decode_rgb8())
9449            .with("__MeshData", Object::HexString(data));
9450        let MeshShading::Patches(patches) = evaluate_mesh_shading(&dict, None).unwrap() else {
9451            panic!("expected patches")
9452        };
9453        assert_eq!(patches.len(), 1);
9454        let p = &patches[0];
9455        // Corners decode.
9456        assert!((p.control_points[0][0].x).abs() < 1e-4); // p00 at (0,0)
9457        assert!((p.control_points[3][3].x - 1.0).abs() < 1e-4); // p33 at (1,1)
9458        assert_eq!(
9459            (
9460                p.corner_colors[0].r,
9461                p.corner_colors[0].g,
9462                p.corner_colors[0].b
9463            ),
9464            (255, 0, 0)
9465        );
9466        assert_eq!(
9467            (
9468                p.corner_colors[2].r,
9469                p.corner_colors[2].g,
9470                p.corner_colors[2].b
9471            ),
9472            (0, 0, 255)
9473        );
9474        // For a flat unit-square patch, the derived internal points lie
9475        // inside the unit square (sanity bound).
9476        for c in 1..=2 {
9477            for rr in 1..=2 {
9478                let ip = p.control_points[c][rr];
9479                assert!(ip.x > -0.5 && ip.x < 1.5, "internal x in range");
9480                assert!(ip.y > -0.5 && ip.y < 1.5, "internal y in range");
9481            }
9482        }
9483    }
9484
9485    /// Type 7 tensor patch (single patch, `f=0`): 16 control points +
9486    /// 4 corner colours decode in the Table 86 stream order.
9487    #[test]
9488    fn mesh_type7_tensor_single_patch() {
9489        let mut w = BitWriter::new();
9490        w.write(0, 8); // f=0
9491                       // 16 points in tensor stream order; we only check the four
9492                       // corners land at the right (col,row) slots.
9493                       // Order: p00 p01 p02 p03 p13 p23 p33 p32 p31 p30 p20 p10 p11 p12 p22 p21
9494        let pts: [(u64, u64); 16] = [
9495            (0, 0),     // p00
9496            (0, 85),    // p01
9497            (0, 170),   // p02
9498            (0, 255),   // p03
9499            (85, 255),  // p13
9500            (170, 255), // p23
9501            (255, 255), // p33
9502            (255, 170), // p32
9503            (255, 85),  // p31
9504            (255, 0),   // p30
9505            (170, 0),   // p20
9506            (85, 0),    // p10
9507            (85, 85),   // p11
9508            (85, 170),  // p12
9509            (170, 170), // p22
9510            (170, 85),  // p21
9511        ];
9512        for (x, y) in pts {
9513            w.write(x, 8);
9514            w.write(y, 8);
9515        }
9516        for (r, g, b) in [(255, 0, 0), (0, 255, 0), (0, 0, 255), (255, 255, 0)] {
9517            w.write(r, 8);
9518            w.write(g, 8);
9519            w.write(b, 8);
9520        }
9521        w.align();
9522        let data = w.finish();
9523        let dict = Dict::new()
9524            .with("ShadingType", Object::Integer(7))
9525            .with("ColorSpace", Object::Name("DeviceRGB".into()))
9526            .with("BitsPerCoordinate", Object::Integer(8))
9527            .with("BitsPerComponent", Object::Integer(8))
9528            .with("BitsPerFlag", Object::Integer(8))
9529            .with("Decode", decode_rgb8())
9530            .with("__MeshData", Object::HexString(data));
9531        let MeshShading::Patches(patches) = evaluate_mesh_shading(&dict, None).unwrap() else {
9532            panic!()
9533        };
9534        assert_eq!(patches.len(), 1);
9535        let p = &patches[0];
9536        // Tensor internal point p11 decodes to (85,85)→(~0.333,~0.333).
9537        assert!((p.control_points[1][1].x - 85.0 / 255.0).abs() < 1e-3);
9538        assert!((p.control_points[2][2].y - 170.0 / 255.0).abs() < 1e-3);
9539        assert_eq!(
9540            (
9541                p.corner_colors[3].r,
9542                p.corner_colors[3].g,
9543                p.corner_colors[3].b
9544            ),
9545            (255, 255, 0)
9546        );
9547    }
9548
9549    /// Type 6 Coons patch continuation (`f=1`): the second patch supplies
9550    /// only 8 new boundary points + 2 corner colours; the four shared
9551    /// boundary points and two corner colours are inherited from the
9552    /// previous patch's top edge (§8.7.4.5.7 Table 85, f=1).
9553    #[test]
9554    fn mesh_type6_coons_edge_flag_continuation() {
9555        let mut w = BitWriter::new();
9556        // Patch A (f=0): unit-square boundary, corners red/green/blue/white.
9557        w.write(0, 8);
9558        let pts_a: [(u64, u64); 12] = [
9559            (0, 0),
9560            (0, 85),
9561            (0, 170),
9562            (0, 255),
9563            (85, 255),
9564            (170, 255),
9565            (255, 255),
9566            (255, 170),
9567            (255, 85),
9568            (255, 0),
9569            (170, 0),
9570            (85, 0),
9571        ];
9572        for (x, y) in pts_a {
9573            w.write(x, 8);
9574            w.write(y, 8);
9575        }
9576        for (r, g, b) in [(255, 0, 0), (0, 255, 0), (0, 0, 255), (255, 255, 255)] {
9577            w.write(r, 8);
9578            w.write(g, 8);
9579            w.write(b, 8);
9580        }
9581        w.align();
9582        // Patch B (f=1): 8 new boundary points (the points after the
9583        // shared edge in Coons order) + 2 new corner colours.
9584        w.write(1, 8);
9585        for k in 0..8u64 {
9586            // Arbitrary distinct coordinates above the unit square.
9587            w.write(255, 8);
9588            w.write((k * 30).min(255), 8);
9589        }
9590        // c3, c4 of the new patch (yellow, magenta).
9591        for (r, g, b) in [(255, 255, 0), (255, 0, 255)] {
9592            w.write(r, 8);
9593            w.write(g, 8);
9594            w.write(b, 8);
9595        }
9596        w.align();
9597        let data = w.finish();
9598        let dict = Dict::new()
9599            .with("ShadingType", Object::Integer(6))
9600            .with("ColorSpace", Object::Name("DeviceRGB".into()))
9601            .with("BitsPerCoordinate", Object::Integer(8))
9602            .with("BitsPerComponent", Object::Integer(8))
9603            .with("BitsPerFlag", Object::Integer(8))
9604            .with("Decode", decode_rgb8())
9605            .with("__MeshData", Object::HexString(data));
9606        let MeshShading::Patches(patches) = evaluate_mesh_shading(&dict, None).unwrap() else {
9607            panic!()
9608        };
9609        assert_eq!(patches.len(), 2);
9610        let b = &patches[1];
9611        let a = &patches[0];
9612        // Patch B inherits patch A's top edge (p03 p13 p23 p33) as its
9613        // own p00 p01 p02 p03 (§8.7.4.5.8 Table 86 f=1 geometry).
9614        assert_eq!(b.control_points[0][0], a.control_points[0][3]); // B.p00 = A.p03
9615        assert_eq!(b.control_points[0][3], a.control_points[3][3]); // B.p03 = A.p33
9616                                                                    // Patch B inherits c1=c2prev (green), c2=c3prev (blue).
9617        assert_eq!(
9618            (
9619                b.corner_colors[0].r,
9620                b.corner_colors[0].g,
9621                b.corner_colors[0].b
9622            ),
9623            (0, 255, 0)
9624        );
9625        assert_eq!(
9626            (
9627                b.corner_colors[1].r,
9628                b.corner_colors[1].g,
9629                b.corner_colors[1].b
9630            ),
9631            (0, 0, 255)
9632        );
9633        // c3, c4 of patch B are the new pair (yellow, magenta).
9634        assert_eq!(
9635            (
9636                b.corner_colors[2].r,
9637                b.corner_colors[2].g,
9638                b.corner_colors[2].b
9639            ),
9640            (255, 255, 0)
9641        );
9642        assert_eq!(
9643            (
9644                b.corner_colors[3].r,
9645                b.corner_colors[3].g,
9646                b.corner_colors[3].b
9647            ),
9648            (255, 0, 255)
9649        );
9650    }
9651
9652    /// A shading with a `/Function` entry carries a single parametric
9653    /// value `t` per vertex; the function maps it to colour components
9654    /// (§8.7.4.5.5). A Type 2 exponential from black→white renders the
9655    /// midpoint vertex as mid-grey.
9656    #[test]
9657    fn mesh_type4_with_parametric_function() {
9658        let mut w = BitWriter::new();
9659        // 8-bit coords, 8-bit single parametric component, 8-bit flag.
9660        let vert = |w: &mut BitWriter, x: u64, y: u64, t: u64| {
9661            w.write(0, 8); // f=0
9662            w.write(x, 8);
9663            w.write(y, 8);
9664            w.write(t, 8);
9665            w.align();
9666        };
9667        vert(&mut w, 0, 0, 0); // t=0 → black
9668        vert(&mut w, 255, 0, 255); // t=1 → white
9669        vert(&mut w, 0, 255, 128); // t≈0.5 → mid grey
9670        let data = w.finish();
9671        // Type 2 exponential: 1-in / 3-out, C0=[0 0 0], C1=[1 1 1], N=1.
9672        let func = Dict::new()
9673            .with("FunctionType", Object::Integer(2))
9674            .with(
9675                "Domain",
9676                Object::Array(vec![Object::Real(0.0), Object::Real(1.0)]),
9677            )
9678            .with(
9679                "C0",
9680                Object::Array(vec![
9681                    Object::Real(0.0),
9682                    Object::Real(0.0),
9683                    Object::Real(0.0),
9684                ]),
9685            )
9686            .with(
9687                "C1",
9688                Object::Array(vec![
9689                    Object::Real(1.0),
9690                    Object::Real(1.0),
9691                    Object::Real(1.0),
9692                ]),
9693            )
9694            .with("N", Object::Real(1.0));
9695        // With a Function the Decode array has only one colour pair.
9696        let decode = Object::Array(
9697            [0.0, 1.0, 0.0, 1.0, 0.0, 1.0]
9698                .into_iter()
9699                .map(Object::Real)
9700                .collect(),
9701        );
9702        let dict = Dict::new()
9703            .with("ShadingType", Object::Integer(4))
9704            .with("ColorSpace", Object::Name("DeviceRGB".into()))
9705            .with("BitsPerCoordinate", Object::Integer(8))
9706            .with("BitsPerComponent", Object::Integer(8))
9707            .with("BitsPerFlag", Object::Integer(8))
9708            .with("Decode", decode)
9709            .with("Function", Object::Dict(func))
9710            .with("__MeshData", Object::HexString(data));
9711        let MeshShading::Triangles(tris) = evaluate_mesh_shading(&dict, None).unwrap() else {
9712            panic!()
9713        };
9714        let v = tris[0].vertices;
9715        assert_eq!((v[0].color.r, v[0].color.g, v[0].color.b), (0, 0, 0));
9716        assert_eq!((v[1].color.r, v[1].color.g, v[1].color.b), (255, 255, 255));
9717        // t=128/255 ≈ 0.502 → ~128 grey.
9718        assert!((v[2].color.r as i32 - 128).abs() <= 1);
9719        assert_eq!(v[2].color.r, v[2].color.g);
9720        assert_eq!(v[2].color.g, v[2].color.b);
9721    }
9722
9723    /// A Type 1–3 shading (axial) leaves `mesh` `None` — only Types 4–7
9724    /// carry mesh geometry.
9725    #[test]
9726    fn mesh_none_for_axial_shading() {
9727        let dict = Dict::new()
9728            .with("ShadingType", Object::Integer(2))
9729            .with("ColorSpace", Object::Name("DeviceRGB".into()));
9730        assert!(evaluate_mesh_shading(&dict, None).is_none());
9731    }
9732
9733    /// An `sh` paint of a Type 4 mesh surfaces the evaluated geometry on
9734    /// the `ContentShading` event (end-to-end through the `sh` operator).
9735    #[test]
9736    fn sh_surfaces_evaluated_mesh() {
9737        let mut w = BitWriter::new();
9738        let vert = |w: &mut BitWriter, x: u64, y: u64, r: u64, g: u64, b: u64| {
9739            w.write(0, 8);
9740            w.write(x, 8);
9741            w.write(y, 8);
9742            w.write(r, 8);
9743            w.write(g, 8);
9744            w.write(b, 8);
9745            w.align();
9746        };
9747        vert(&mut w, 0, 0, 255, 0, 0);
9748        vert(&mut w, 255, 0, 0, 255, 0);
9749        vert(&mut w, 0, 255, 0, 0, 255);
9750        let data = w.finish();
9751        let sh1 = Dict::new()
9752            .with("ShadingType", Object::Integer(4))
9753            .with("ColorSpace", Object::Name("DeviceRGB".into()))
9754            .with("BitsPerCoordinate", Object::Integer(8))
9755            .with("BitsPerComponent", Object::Integer(8))
9756            .with("BitsPerFlag", Object::Integer(8))
9757            .with("Decode", decode_rgb8())
9758            .with("__MeshData", Object::HexString(data));
9759        let shadings = shading_res_with("Sh1", sh1);
9760        let bytes = b"q /Sh1 sh Q\n";
9761        let p = parse_with_shading(bytes, None, None, Some(&shadings));
9762        assert_eq!(p.shadings.len(), 1);
9763        let mesh = p.shadings[0].mesh.as_ref().expect("mesh surfaced");
9764        let MeshShading::Triangles(tris) = mesh else {
9765            panic!()
9766        };
9767        assert_eq!(tris.len(), 1);
9768    }
9769
9770    // ─────────── gradient shadings (Types 1–3, §8.7.4.5.2–4) ───────────
9771
9772    /// Build a Type 2 (exponential) function dict from black→white.
9773    fn exp_black_to_white() -> Object {
9774        Object::Dict(
9775            Dict::new()
9776                .with("FunctionType", Object::Integer(2))
9777                .with(
9778                    "Domain",
9779                    Object::Array(vec![Object::Real(0.0), Object::Real(1.0)]),
9780                )
9781                .with(
9782                    "C0",
9783                    Object::Array(vec![
9784                        Object::Real(0.0),
9785                        Object::Real(0.0),
9786                        Object::Real(0.0),
9787                    ]),
9788                )
9789                .with(
9790                    "C1",
9791                    Object::Array(vec![
9792                        Object::Real(1.0),
9793                        Object::Real(1.0),
9794                        Object::Real(1.0),
9795                    ]),
9796                )
9797                .with("N", Object::Real(1.0)),
9798        )
9799    }
9800
9801    /// Type 2 axial shading: geometry + 64 colour stops from black to
9802    /// white across the default domain `[0, 1]` (§8.7.4.5.3).
9803    #[test]
9804    fn gradient_type2_axial_samples_stops() {
9805        let dict = Dict::new()
9806            .with("ShadingType", Object::Integer(2))
9807            .with("ColorSpace", Object::Name("DeviceRGB".into()))
9808            .with(
9809                "Coords",
9810                Object::Array(vec![
9811                    Object::Real(0.0),
9812                    Object::Real(0.0),
9813                    Object::Real(100.0),
9814                    Object::Real(0.0),
9815                ]),
9816            )
9817            .with("Function", exp_black_to_white())
9818            .with(
9819                "Extend",
9820                Object::Array(vec![Object::Bool(true), Object::Bool(false)]),
9821            );
9822        let g = evaluate_gradient_shading(&dict, None).expect("gradient");
9823        let ShadingGradient::Axial {
9824            coords,
9825            extend,
9826            stops,
9827        } = g
9828        else {
9829            panic!("expected axial")
9830        };
9831        assert_eq!(coords, [0.0, 0.0, 100.0, 0.0]);
9832        assert_eq!(extend, [true, false]);
9833        assert_eq!(stops.len(), 64);
9834        // First stop t=0 → black, last t=1 → white.
9835        assert_eq!((stops[0].r, stops[0].g, stops[0].b), (0, 0, 0));
9836        assert_eq!((stops[63].r, stops[63].g, stops[63].b), (255, 255, 255));
9837        // Monotonic increase (linear N=1 exponential).
9838        assert!(stops[32].r > stops[0].r && stops[32].r < stops[63].r);
9839    }
9840
9841    /// Type 3 radial shading: six-number `Coords` + stops (§8.7.4.5.4).
9842    #[test]
9843    fn gradient_type3_radial_samples_stops() {
9844        let dict = Dict::new()
9845            .with("ShadingType", Object::Integer(3))
9846            .with("ColorSpace", Object::Name("DeviceRGB".into()))
9847            .with(
9848                "Coords",
9849                Object::Array(
9850                    [0.0, 0.0, 0.0, 0.0, 0.0, 50.0]
9851                        .into_iter()
9852                        .map(Object::Real)
9853                        .collect(),
9854                ),
9855            )
9856            .with("Function", exp_black_to_white());
9857        let g = evaluate_gradient_shading(&dict, None).expect("gradient");
9858        let ShadingGradient::Radial {
9859            coords,
9860            extend,
9861            stops,
9862        } = g
9863        else {
9864            panic!("expected radial")
9865        };
9866        assert_eq!(coords, [0.0, 0.0, 0.0, 0.0, 0.0, 50.0]);
9867        assert_eq!(extend, [false, false]); // default
9868        assert_eq!(stops.len(), 64);
9869        assert_eq!((stops[0].r, stops[0].g, stops[0].b), (0, 0, 0));
9870    }
9871
9872    /// `shading_color_space` resolves a bare-name `/ColorSpace` against
9873    /// the page's `/Resources /ColorSpace` subdictionary (§8.7.4.5.2). A
9874    /// device name still short-circuits; an inline array is interpreted
9875    /// directly; a resource key resolves through the dict.
9876    #[test]
9877    fn shading_color_space_resolves_resource_key() {
9878        // Bare device name: no resource lookup needed.
9879        assert_eq!(
9880            shading_color_space(&Object::Name("DeviceRGB".into()), None),
9881            ColorSpaceKind::DeviceRgb
9882        );
9883        // A resource key `/CS0` → CalRGB.
9884        let cal_rgb = Object::Array(vec![
9885            Object::Name("CalRGB".into()),
9886            Object::Dict(Dict::new().with(
9887                "WhitePoint",
9888                Object::Array(vec![
9889                    Object::Real(0.9505),
9890                    Object::Real(1.0),
9891                    Object::Real(1.089),
9892                ]),
9893            )),
9894        ]);
9895        let res = Dict::new().with("CS0", cal_rgb);
9896        assert!(matches!(
9897            shading_color_space(&Object::Name("CS0".into()), Some(&res)),
9898            ColorSpaceKind::CalRgb { .. }
9899        ));
9900        // Unknown key with no resources stays Unknown.
9901        assert_eq!(
9902            shading_color_space(&Object::Name("CS9".into()), None),
9903            ColorSpaceKind::Unknown
9904        );
9905    }
9906
9907    /// An axial (Type 2) shading whose `/ColorSpace` is a *named*
9908    /// resource key resolving to a CIE-based CalGray space evaluates its
9909    /// gradient stops through that space instead of failing. Previously
9910    /// a name `/ColorSpace` collapsed to `Unknown` and dropped the
9911    /// gradient.
9912    #[test]
9913    fn gradient_named_resource_colour_space_resolves() {
9914        // CalGray colour function: single-component black→white ramp.
9915        let func = Object::Dict(
9916            Dict::new()
9917                .with("FunctionType", Object::Integer(2))
9918                .with(
9919                    "Domain",
9920                    Object::Array(vec![Object::Real(0.0), Object::Real(1.0)]),
9921                )
9922                .with("C0", Object::Array(vec![Object::Real(0.0)]))
9923                .with("C1", Object::Array(vec![Object::Real(1.0)]))
9924                .with("N", Object::Real(1.0)),
9925        );
9926        let dict = Dict::new()
9927            .with("ShadingType", Object::Integer(2))
9928            .with("ColorSpace", Object::Name("CSGray".into()))
9929            .with(
9930                "Coords",
9931                Object::Array(
9932                    [0.0, 0.0, 100.0, 0.0]
9933                        .into_iter()
9934                        .map(Object::Real)
9935                        .collect(),
9936                ),
9937            )
9938            .with("Function", func);
9939        let cal_gray = Object::Array(vec![
9940            Object::Name("CalGray".into()),
9941            Object::Dict(Dict::new().with(
9942                "WhitePoint",
9943                Object::Array(vec![
9944                    Object::Real(0.9505),
9945                    Object::Real(1.0),
9946                    Object::Real(1.089),
9947                ]),
9948            )),
9949        ]);
9950        let res = Dict::new().with("CSGray", cal_gray);
9951        // Without resources the name can't resolve → no gradient.
9952        assert!(evaluate_gradient_shading(&dict, None).is_none());
9953        // With resources the CalGray space resolves and stops sample.
9954        let g = evaluate_gradient_shading(&dict, Some(&res)).expect("gradient");
9955        let ShadingGradient::Axial { stops, .. } = g else {
9956            panic!("expected axial");
9957        };
9958        assert_eq!(stops.len(), 64);
9959        // t=0 → gray A=0 → black; t=1 → gray A=1 → white.
9960        assert_eq!((stops[0].r, stops[0].g, stops[0].b), (0, 0, 0));
9961        assert_eq!((stops[63].r, stops[63].g, stops[63].b), (255, 255, 255));
9962    }
9963
9964    /// Type 1 function-based shading: a 2-in / 3-out Type 4 calculator
9965    /// returning `(x, y, 0)` samples onto the domain grid (§8.7.4.5.2).
9966    #[test]
9967    fn gradient_type1_function_based_grid() {
9968        // A Type 4 (PostScript-calculator) program that discards its two
9969        // inputs and returns constant mid-grey `0.5 0.5 0.5`, so every
9970        // grid sample is independent of (x, y) and easy to verify.
9971        let program = b"{ pop pop 0.5 0.5 0.5 }".to_vec();
9972        let func = Dict::new()
9973            .with("FunctionType", Object::Integer(4))
9974            .with(
9975                "Domain",
9976                Object::Array(vec![
9977                    Object::Real(0.0),
9978                    Object::Real(1.0),
9979                    Object::Real(0.0),
9980                    Object::Real(1.0),
9981                ]),
9982            )
9983            .with(
9984                "Range",
9985                Object::Array(vec![
9986                    Object::Real(0.0),
9987                    Object::Real(1.0),
9988                    Object::Real(0.0),
9989                    Object::Real(1.0),
9990                    Object::Real(0.0),
9991                    Object::Real(1.0),
9992                ]),
9993            )
9994            .with("__Program", Object::HexString(program));
9995        let dict = Dict::new()
9996            .with("ShadingType", Object::Integer(1))
9997            .with("ColorSpace", Object::Name("DeviceRGB".into()))
9998            .with("Function", Object::Dict(func));
9999        let g = evaluate_gradient_shading(&dict, None).expect("gradient");
10000        let ShadingGradient::FunctionBased {
10001            domain,
10002            grid,
10003            samples,
10004            ..
10005        } = g
10006        else {
10007            panic!("expected function-based")
10008        };
10009        assert_eq!(domain, [0.0, 1.0, 0.0, 1.0]); // default
10010        assert_eq!(grid, (16, 16));
10011        assert_eq!(samples.len(), 256);
10012        // Constant mid-grey program → every sample ~128.
10013        for s in &samples {
10014            assert!((s.r as i32 - 128).abs() <= 1);
10015            assert_eq!(s.r, s.g);
10016            assert_eq!(s.g, s.b);
10017        }
10018    }
10019
10020    /// A Type 4–7 mesh shading leaves `gradient` `None`; a Type 1–3
10021    /// shading leaves `mesh` `None` — the two surfaces are exclusive.
10022    #[test]
10023    fn gradient_and_mesh_are_exclusive() {
10024        let axial = Dict::new()
10025            .with("ShadingType", Object::Integer(2))
10026            .with("ColorSpace", Object::Name("DeviceRGB".into()))
10027            .with(
10028                "Coords",
10029                Object::Array(vec![
10030                    Object::Real(0.0),
10031                    Object::Real(0.0),
10032                    Object::Real(1.0),
10033                    Object::Real(0.0),
10034                ]),
10035            )
10036            .with("Function", exp_black_to_white());
10037        assert!(evaluate_mesh_shading(&axial, None).is_none());
10038        assert!(evaluate_gradient_shading(&axial, None).is_some());
10039    }
10040
10041    /// `sh` of a Type 2 axial shading surfaces the gradient on the
10042    /// `ContentShading` event (end-to-end through the operator).
10043    #[test]
10044    fn sh_surfaces_evaluated_gradient() {
10045        let sh1 = Dict::new()
10046            .with("ShadingType", Object::Integer(2))
10047            .with("ColorSpace", Object::Name("DeviceRGB".into()))
10048            .with(
10049                "Coords",
10050                Object::Array(vec![
10051                    Object::Real(0.0),
10052                    Object::Real(0.0),
10053                    Object::Real(72.0),
10054                    Object::Real(0.0),
10055                ]),
10056            )
10057            .with("Function", exp_black_to_white());
10058        let shadings = shading_res_with("Sh1", sh1);
10059        let bytes = b"q /Sh1 sh Q\n";
10060        let p = parse_with_shading(bytes, None, None, Some(&shadings));
10061        assert_eq!(p.shadings.len(), 1);
10062        assert!(p.shadings[0].mesh.is_none());
10063        let g = p.shadings[0].gradient.as_ref().expect("gradient surfaced");
10064        assert!(matches!(g, ShadingGradient::Axial { .. }));
10065    }
10066
10067    /// Build a simple font with explicit per-code `/Widths` so the
10068    /// §9.4.4 advance can be exercised. Each ASCII code from
10069    /// `first_char` onward gets the given width (glyph-space units).
10070    fn simple_font_with_widths(first_char: i64, widths: &[i64]) -> Dict {
10071        let arr: Vec<Object> = widths.iter().map(|w| Object::Integer(*w)).collect();
10072        Dict::new()
10073            .with("Type", Object::Name("Font".into()))
10074            .with("Subtype", Object::Name("Type1".into()))
10075            .with("FirstChar", Object::Integer(first_char))
10076            .with("Widths", Object::Array(arr))
10077    }
10078
10079    /// Two consecutive `Tj` operators on the same line: the second
10080    /// show's origin equals the first plus the sum of the first
10081    /// string's glyph advances (§9.4.4). Without the advance both
10082    /// would report the same x.
10083    #[test]
10084    fn consecutive_tj_advances_text_matrix_by_widths() {
10085        // 'A' = 65, 'B' = 66 — first_char 65, widths 500 / 250
10086        // (glyph-space thousandths).
10087        let f1 = simple_font_with_widths(65, &[500, 250]);
10088        let fonts = font_res_with("F1", f1);
10089        // Font size 10 ⇒ each unit-thousandth contributes size/1000.
10090        let bytes = b"BT /F1 10 Tf 0 700 Td (A) Tj (B) Tj ET\n";
10091        let p = parse_full(bytes, None, Some(&fonts));
10092        assert_eq!(p.text_shows.len(), 2);
10093        // First show at the run origin.
10094        assert!((p.text_shows[0].position.0 - 0.0).abs() < 1e-3);
10095        // Second show advanced by 'A' width = 500/1000 * 10 = 5.0.
10096        assert!(
10097            (p.text_shows[1].position.0 - 5.0).abs() < 1e-3,
10098            "got {}",
10099            p.text_shows[1].position.0
10100        );
10101        assert!((p.text_shows[1].position.1 - 700.0).abs() < 1e-3);
10102    }
10103
10104    /// §9.6.5: a Type 3 font's `/Widths` are in glyph space and scaled
10105    /// into text space by the `/FontMatrix` horizontal component, not by
10106    /// the 1/1000 Type1 convention. A FontMatrix of `[0.01 …]` (ten
10107    /// times the default `0.001`) makes a stored width of 50 advance by
10108    /// `50 · 0.01 · size`, i.e. ten times what the /1000 rule would give.
10109    #[test]
10110    fn type3_font_advances_via_font_matrix() {
10111        let f1 = Dict::new()
10112            .with("Type", Object::Name("Font".into()))
10113            .with("Subtype", Object::Name("Type3".into()))
10114            .with("FirstChar", Object::Integer(65))
10115            .with("Widths", Object::Array(vec![Object::Integer(50)]))
10116            .with(
10117                "FontMatrix",
10118                Object::Array(vec![
10119                    Object::Real(0.01),
10120                    Object::Real(0.0),
10121                    Object::Real(0.0),
10122                    Object::Real(0.01),
10123                    Object::Real(0.0),
10124                    Object::Real(0.0),
10125                ]),
10126            );
10127        let fonts = font_res_with("F1", f1);
10128        // 'A' advance = width(50) · FontMatrix.a(0.01) · size(10) = 5.0.
10129        let bytes = b"BT /F1 10 Tf 0 700 Td (A) Tj (A) Tj ET\n";
10130        let p = parse_full(bytes, None, Some(&fonts));
10131        assert_eq!(p.text_shows.len(), 2);
10132        assert!(
10133            (p.text_shows[1].position.0 - 5.0).abs() < 1e-3,
10134            "got {}",
10135            p.text_shows[1].position.0
10136        );
10137    }
10138
10139    /// A Type 3 font with the default `/FontMatrix [0.001 …]` advances
10140    /// exactly like a Type1 font of the same `/Widths` — the 1/1000
10141    /// equivalence the default matrix encodes.
10142    #[test]
10143    fn type3_default_font_matrix_matches_type1() {
10144        let f1 = Dict::new()
10145            .with("Type", Object::Name("Font".into()))
10146            .with("Subtype", Object::Name("Type3".into()))
10147            .with("FirstChar", Object::Integer(65))
10148            .with("Widths", Object::Array(vec![Object::Integer(500)]))
10149            .with(
10150                "FontMatrix",
10151                Object::Array(vec![
10152                    Object::Real(0.001),
10153                    Object::Real(0.0),
10154                    Object::Real(0.0),
10155                    Object::Real(0.001),
10156                    Object::Real(0.0),
10157                    Object::Real(0.0),
10158                ]),
10159            );
10160        let fonts = font_res_with("F1", f1);
10161        // 500 · 0.001 · 10 = 5.0, same as the Type1 width-500 case.
10162        let bytes = b"BT /F1 10 Tf 0 700 Td (A) Tj (A) Tj ET\n";
10163        let p = parse_full(bytes, None, Some(&fonts));
10164        assert!((p.text_shows[1].position.0 - 5.0).abs() < 1e-3);
10165    }
10166
10167    /// Character spacing `Tc` adds to every glyph's advance (§9.3.2 /
10168    /// §9.4.4); word spacing `Tw` adds only to ASCII-space glyphs.
10169    #[test]
10170    fn tc_tw_feed_the_advance() {
10171        // Codes: space(32)=250, 'A'(65)=500. first_char 32, the
10172        // widths array spans 32..=65.
10173        let mut widths = vec![0i64; 66 - 32];
10174        widths[0] = 250; // space
10175        widths[65 - 32] = 500; // 'A'
10176        let f1 = simple_font_with_widths(32, &widths);
10177        let fonts = font_res_with("F1", f1);
10178        // Tc=2, Tw=3, size 10. Show "A A" (three glyphs); the second
10179        // Tj origin is the sum of all three advances:
10180        //   'A'  : (500/1000*10 + 2)        = 7
10181        //   ' '  : (250/1000*10 + 2 + 3)    = 7.5
10182        //   'A'  : (500/1000*10 + 2)        = 7
10183        // Second Tj origin = 7 + 7.5 + 7 = 21.5.
10184        let bytes = b"BT /F1 10 Tf 2 Tc 3 Tw 0 0 Td (A A) Tj (X) Tj ET\n";
10185        let p = parse_full(bytes, None, Some(&fonts));
10186        assert_eq!(p.text_shows.len(), 2);
10187        assert!(
10188            (p.text_shows[1].position.0 - 21.5).abs() < 1e-3,
10189            "got {}",
10190            p.text_shows[1].position.0
10191        );
10192    }
10193
10194    /// Horizontal scaling `Tz` scales the whole horizontal advance
10195    /// (§9.3.4 / §9.4.4).
10196    #[test]
10197    fn tz_scales_the_advance() {
10198        let f1 = simple_font_with_widths(65, &[1000]);
10199        let fonts = font_res_with("F1", f1);
10200        // Tz 50 ⇒ Th = 0.5. 'A' advance = 1000/1000*10*0.5 = 5.0.
10201        let bytes = b"BT /F1 10 Tf 50 Tz 0 0 Td (A) Tj (A) Tj ET\n";
10202        let p = parse_full(bytes, None, Some(&fonts));
10203        assert_eq!(p.text_shows.len(), 2);
10204        assert!(
10205            (p.text_shows[1].position.0 - 5.0).abs() < 1e-3,
10206            "got {}",
10207            p.text_shows[1].position.0
10208        );
10209    }
10210
10211    /// A `TJ` array applies the per-element kern adjustments (§9.4.3:
10212    /// `tx = −adj/1000 × Tfs × Th`) in addition to the glyph widths.
10213    #[test]
10214    fn tj_array_kern_adjusts_origin() {
10215        let f1 = simple_font_with_widths(65, &[1000, 1000]); // 'A','B'
10216        let fonts = font_res_with("F1", f1);
10217        // [ (A) -100 (B) ] : 'A' advance = 10, kern -(-100)/1000*10 =
10218        // +1, so total advance through the array before the next show
10219        // = 10 + 1 + (B advance 10) = 21.
10220        let bytes = b"BT /F1 10 Tf 0 0 Td [(A) -100 (B)] TJ (C) Tj ET\n";
10221        let p = parse_full(bytes, None, Some(&fonts));
10222        assert_eq!(p.text_shows.len(), 2);
10223        assert!(
10224            (p.text_shows[1].position.0 - 21.0).abs() < 1e-3,
10225            "got {}",
10226            p.text_shows[1].position.0
10227        );
10228    }
10229
10230    /// A composite (Type0 / Identity-H) font advances by two-byte
10231    /// CIDs read from the `/W` array, defaulting to `/DW` (§9.7.4.3).
10232    #[test]
10233    fn type0_cid_font_advances_by_w_array() {
10234        // Descendant CIDFont: DW 1000, W = [ 1 [500] ] ⇒ CID 1 = 500.
10235        let cidfont = Dict::new()
10236            .with("Type", Object::Name("Font".into()))
10237            .with("Subtype", Object::Name("CIDFontType2".into()))
10238            .with("DW", Object::Integer(1000))
10239            .with(
10240                "W",
10241                Object::Array(vec![
10242                    Object::Integer(1),
10243                    Object::Array(vec![Object::Integer(500)]),
10244                ]),
10245            );
10246        let f0 = Dict::new()
10247            .with("Type", Object::Name("Font".into()))
10248            .with("Subtype", Object::Name("Type0".into()))
10249            .with("Encoding", Object::Name("Identity-H".into()))
10250            .with("DescendantFonts", Object::Dict(cidfont));
10251        let fonts = font_res_with("F0", f0);
10252        // Two 2-byte codes: <0001> (CID 1, width 500) then <0002>
10253        // (CID 2, default 1000). Show <00010002>, then a second show.
10254        // Advance = (500/1000 + 1000/1000) * 10 = 15.
10255        let bytes = b"BT /F0 10 Tf 0 0 Td <00010002> Tj (X) Tj ET\n";
10256        let p = parse_full(bytes, None, Some(&fonts));
10257        assert_eq!(p.text_shows.len(), 2);
10258        assert!(
10259            (p.text_shows[1].position.0 - 15.0).abs() < 1e-3,
10260            "got {}",
10261            p.text_shows[1].position.0
10262        );
10263    }
10264
10265    // ── CIE-based colour spaces (§8.6.5.2–4) ──────────────────────
10266
10267    /// The D65 white point used throughout the §8.6.5 examples.
10268    const D65: [f32; 3] = [0.9505, 1.0000, 1.0890];
10269
10270    /// `srgb_encode` matches the IEC 61966-2-1 piecewise curve at its
10271    /// reference points: 0 → 0, 1 → 1, and the `0.0031308` linear-segment
10272    /// breakpoint maps continuously.
10273    #[test]
10274    fn srgb_encode_reference_points() {
10275        assert!((srgb_encode(0.0) - 0.0).abs() < 1e-6);
10276        assert!((srgb_encode(1.0) - 1.0).abs() < 1e-6);
10277        // At the breakpoint both branches agree to within rounding.
10278        let bp = 0.003_130_8;
10279        let lin = 12.92 * bp;
10280        assert!((srgb_encode(bp) - lin).abs() < 1e-4);
10281        // A mid value lands on the power segment (≈ 0.7354 for 0.5).
10282        assert!((srgb_encode(0.5) - 0.735_36).abs() < 1e-3);
10283    }
10284
10285    /// A CalGray colour space's full-on gray (A = 1.0) under the D65
10286    /// white point maps to (very near) white; A = 0.0 maps to black.
10287    #[test]
10288    fn cal_gray_endpoints() {
10289        let white = cal_gray_color(D65, 1.0, 1.0);
10290        assert_eq!((white.r, white.g, white.b), (255, 255, 255));
10291        let black = cal_gray_color(D65, 1.0, 0.0);
10292        assert_eq!((black.r, black.g, black.b), (0, 0, 0));
10293    }
10294
10295    /// A CalGray gamma > 1 darkens a mid gray relative to gamma 1 (the
10296    /// decode raises A to the gamma power before the white-point scale).
10297    #[test]
10298    fn cal_gray_gamma_darkens_midtones() {
10299        let g1 = cal_gray_color(D65, 1.0, 0.5).r;
10300        let g22 = cal_gray_color(D65, 2.2, 0.5).r;
10301        assert!(g22 < g1, "gamma 2.2 ({g22}) should darken vs 1.0 ({g1})");
10302    }
10303
10304    /// The §8.6.5.3 CalRGB example (D65, 1.8 gammas, Trinitron matrix):
10305    /// the all-zero colour is black; full-on (1,1,1) is light.
10306    #[test]
10307    fn cal_rgb_example_endpoints() {
10308        let matrix = [
10309            0.4497, 0.2446, 0.0252, 0.3163, 0.6720, 0.1412, 0.1845, 0.0833, 0.9227,
10310        ];
10311        let gamma = [1.8, 1.8, 1.8];
10312        let black = cal_rgb_color(gamma, matrix, [0.0, 0.0, 0.0]);
10313        assert_eq!((black.r, black.g, black.b), (0, 0, 0));
10314        let white = cal_rgb_color(gamma, matrix, [1.0, 1.0, 1.0]);
10315        // The matrix columns sum to ≈ D65, so (1,1,1) is near-white.
10316        assert!(white.r > 230 && white.g > 230 && white.b > 230);
10317        // A pure-red input (A only) yields a red-dominant device colour.
10318        let red = cal_rgb_color(gamma, matrix, [1.0, 0.0, 0.0]);
10319        assert!(red.r > red.g && red.r > red.b);
10320    }
10321
10322    /// Lab `g(x)` is continuous at the `6/29` breakpoint and cubes above
10323    /// it.
10324    #[test]
10325    fn lab_g_breakpoint_continuous() {
10326        let bp = 6.0 / 29.0;
10327        let cube = bp * bp * bp;
10328        assert!((lab_g(bp) - cube).abs() < 1e-6);
10329        // Above: g(0.5) = 0.125.
10330        assert!((lab_g(0.5) - 0.125).abs() < 1e-6);
10331    }
10332
10333    /// L* = 100 with a* = b* = 0 under D65 is the reference white;
10334    /// L* = 0 is black. Both achromatic.
10335    #[test]
10336    fn lab_neutral_axis() {
10337        let white = lab_color(D65, [100.0, 0.0, 0.0]);
10338        assert_eq!((white.r, white.g, white.b), (255, 255, 255));
10339        let black = lab_color(D65, [0.0, 0.0, 0.0]);
10340        assert_eq!((black.r, black.g, black.b), (0, 0, 0));
10341        // A neutral mid grey (L*=50, a=b=0) is achromatic: r≈g≈b.
10342        let grey = lab_color(D65, [50.0, 0.0, 0.0]);
10343        assert!(grey.r.abs_diff(grey.g) <= 2 && grey.g.abs_diff(grey.b) <= 2);
10344    }
10345
10346    /// Positive a* pushes the colour toward red/magenta (more red than
10347    /// green); positive b* toward yellow (more red+green than blue).
10348    #[test]
10349    fn lab_chroma_axes_direction() {
10350        let reddish = lab_color(D65, [60.0, 60.0, 0.0]);
10351        assert!(reddish.r > reddish.g, "+a* should be red-dominant");
10352        let yellowish = lab_color(D65, [80.0, 0.0, 70.0]);
10353        assert!(
10354            yellowish.r > yellowish.b && yellowish.g > yellowish.b,
10355            "+b* should be yellow (low blue)"
10356        );
10357    }
10358
10359    // ── CIE space resolution from the colour-space dictionary ─────
10360
10361    /// `[ /CalGray << /WhitePoint [..] /Gamma g >> ]` resolves to a
10362    /// `CalGray` carrying the white point + gamma; a missing Gamma
10363    /// defaults to 1.0; a missing/invalid WhitePoint collapses.
10364    #[test]
10365    fn cal_gray_resolves_from_array() {
10366        let arr = Object::Array(vec![
10367            Object::Name("CalGray".into()),
10368            Object::Dict(
10369                Dict::new()
10370                    .with(
10371                        "WhitePoint",
10372                        Object::Array(vec![
10373                            Object::Real(0.9505),
10374                            Object::Real(1.0),
10375                            Object::Real(1.089),
10376                        ]),
10377                    )
10378                    .with("Gamma", Object::Real(2.222)),
10379            ),
10380        ]);
10381        match color_space_from_object(&arr) {
10382            ColorSpaceKind::CalGray { white, gamma } => {
10383                assert!((white[1] - 1.0).abs() < 1e-6);
10384                assert!((gamma - 2.222).abs() < 1e-6);
10385            }
10386            other => panic!("expected CalGray, got {other:?}"),
10387        }
10388        // YW != 1.0 is non-conforming → Unknown.
10389        let bad = Object::Array(vec![
10390            Object::Name("CalGray".into()),
10391            Object::Dict(Dict::new().with(
10392                "WhitePoint",
10393                Object::Array(vec![
10394                    Object::Real(0.95),
10395                    Object::Real(0.5),
10396                    Object::Real(1.0),
10397                ]),
10398            )),
10399        ]);
10400        assert_eq!(color_space_from_object(&bad), ColorSpaceKind::Unknown);
10401    }
10402
10403    /// End-to-end: a `/Resources /ColorSpace /CS0 = [/CalGray …]`,
10404    /// `/CS0 cs 1 sc` paints white (A = 1.0 full gray).
10405    #[test]
10406    fn cal_gray_end_to_end_white() {
10407        let arr = Object::Array(vec![
10408            Object::Name("CalGray".into()),
10409            Object::Dict(Dict::new().with(
10410                "WhitePoint",
10411                Object::Array(vec![
10412                    Object::Real(0.9505),
10413                    Object::Real(1.0),
10414                    Object::Real(1.089),
10415                ]),
10416            )),
10417        ]);
10418        let cs = Dict::new().with("CS0", arr);
10419        let bytes = b"q /CS0 cs 1 sc 0 0 m 10 10 l 10 0 l h f Q\n";
10420        assert_eq!(first_fill_with_cs(bytes, &cs), (255, 255, 255));
10421    }
10422
10423    /// End-to-end: a `/Lab` space with the default Range, `1 sc`-style
10424    /// three-component `scn` of `100 0 0` paints white.
10425    #[test]
10426    fn lab_end_to_end_white() {
10427        let arr = Object::Array(vec![
10428            Object::Name("Lab".into()),
10429            Object::Dict(
10430                Dict::new()
10431                    .with(
10432                        "WhitePoint",
10433                        Object::Array(vec![
10434                            Object::Real(0.9505),
10435                            Object::Real(1.0),
10436                            Object::Real(1.089),
10437                        ]),
10438                    )
10439                    .with(
10440                        "Range",
10441                        Object::Array(vec![
10442                            Object::Integer(-128),
10443                            Object::Integer(127),
10444                            Object::Integer(-128),
10445                            Object::Integer(127),
10446                        ]),
10447                    ),
10448            ),
10449        ]);
10450        match color_space_from_object(&arr) {
10451            ColorSpaceKind::Lab { range, .. } => {
10452                assert_eq!(range, [-128.0, 127.0, -128.0, 127.0]);
10453            }
10454            other => panic!("expected Lab, got {other:?}"),
10455        }
10456        let cs = Dict::new().with("CS0", arr);
10457        let bytes = b"q /CS0 cs 100 0 0 scn 0 0 m 10 10 l 10 0 l h f Q\n";
10458        assert_eq!(first_fill_with_cs(bytes, &cs), (255, 255, 255));
10459    }
10460
10461    /// A CalRGB resource resolves and its `scn` reads three components;
10462    /// a missing Matrix defaults to identity, a missing Gamma to [1 1 1].
10463    #[test]
10464    fn cal_rgb_resolves_default_matrix() {
10465        let arr = Object::Array(vec![
10466            Object::Name("CalRGB".into()),
10467            Object::Dict(Dict::new().with(
10468                "WhitePoint",
10469                Object::Array(vec![
10470                    Object::Real(0.9505),
10471                    Object::Real(1.0),
10472                    Object::Real(1.089),
10473                ]),
10474            )),
10475        ]);
10476        match color_space_from_object(&arr) {
10477            ColorSpaceKind::CalRgb { gamma, matrix } => {
10478                assert_eq!(gamma, [1.0, 1.0, 1.0]);
10479                assert_eq!(matrix, [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]);
10480            }
10481            other => panic!("expected CalRgb, got {other:?}"),
10482        }
10483        // Identity matrix: (1,1,1) → XYZ (1,1,1), well above D65 white,
10484        // clamps to device white.
10485        let cs = Dict::new().with("CS0", arr);
10486        let bytes = b"q /CS0 cs 1 1 1 scn 0 0 m 10 10 l 10 0 l h f Q\n";
10487        let (r, g, b) = first_fill_with_cs(bytes, &cs);
10488        assert!(r > 230 && g > 230 && b > 230);
10489    }
10490
10491    // ── CIE-based alternates for Separation / DeviceN (§8.6.6.4–5) ─
10492
10493    /// `[ /CalGray << /WhitePoint [..] >> ]` as an inline object for use
10494    /// as a Separation / DeviceN alternate.
10495    fn cal_gray_obj() -> Object {
10496        Object::Array(vec![
10497            Object::Name("CalGray".into()),
10498            Object::Dict(Dict::new().with(
10499                "WhitePoint",
10500                Object::Array(vec![
10501                    Object::Real(0.9505),
10502                    Object::Real(1.0),
10503                    Object::Real(1.089),
10504                ]),
10505            )),
10506        ])
10507    }
10508
10509    fn lab_obj() -> Object {
10510        Object::Array(vec![
10511            Object::Name("Lab".into()),
10512            Object::Dict(Dict::new().with(
10513                "WhitePoint",
10514                Object::Array(vec![
10515                    Object::Real(0.9505),
10516                    Object::Real(1.0),
10517                    Object::Real(1.089),
10518                ]),
10519            )),
10520        ])
10521    }
10522
10523    /// A Separation over a CalGray alternate (§8.6.6.4 permits a
10524    /// CIE-based alternate). The tint transform maps `t → A` (the gray
10525    /// component); at full tint A = 1.0 → device white, at zero → black.
10526    #[test]
10527    fn separation_calgray_alternate_renders() {
10528        // 1-in / 1-out: C0 = 0.0, C1 = 1.0 (identity tint → gray A).
10529        let tint = type2(&[0.0], &[1.0], 1.0);
10530        let arr = separation("Spot", cal_gray_obj(), tint);
10531        match color_space_from_object(&arr) {
10532            ColorSpaceKind::Separation { alt, .. } => {
10533                assert!(matches!(*alt, ColorSpaceKind::CalGray { .. }));
10534            }
10535            other => panic!("expected Separation/CalGray, got {other:?}"),
10536        }
10537        let cs = Dict::new().with("CS0", arr);
10538        // tint 1.0 → A = 1.0 → white.
10539        let bytes = b"q /CS0 cs 1 scn 0 0 m 10 10 l 10 0 l h f Q\n";
10540        assert_eq!(first_fill_with_cs(bytes, &cs), (255, 255, 255));
10541        // tint 0.0 → A = 0.0 → black.
10542        let bytes = b"q /CS0 cs 0 scn 0 0 m 10 10 l 10 0 l h f Q\n";
10543        assert_eq!(first_fill_with_cs(bytes, &cs), (0, 0, 0));
10544    }
10545
10546    /// A two-colorant DeviceN over a Lab alternate. A Type 4 program
10547    /// maps the two tints to an L*a*b* triple: `(t0, t1) → (100·t0, 0,
10548    /// 0)` — a neutral grey ramp. At (1,*) L* = 100 → white.
10549    #[test]
10550    fn device_n_lab_alternate_renders() {
10551        // 2-in / 3-out. Stack starts [t0 t1]. Program:
10552        //   pop          → [t0]
10553        //   100 mul      → [100·t0]
10554        //   0 0          → [100·t0 0 0]  (L*, a*, b*)
10555        let tint = type4(
10556            &[0.0, 1.0, 0.0, 1.0],
10557            &[0.0, 100.0, -128.0, 127.0, -128.0, 127.0],
10558            "{ pop 100 mul 0 0 }",
10559        );
10560        let arr = device_n(&["C0", "C1"], lab_obj(), tint);
10561        match color_space_from_object(&arr) {
10562            ColorSpaceKind::DeviceN { alt, n_in, .. } => {
10563                assert_eq!(n_in, 2);
10564                assert!(matches!(*alt, ColorSpaceKind::Lab { .. }));
10565            }
10566            other => panic!("expected DeviceN/Lab, got {other:?}"),
10567        }
10568        let cs = Dict::new().with("CS0", arr);
10569        // (1, 0) → L* = 100, a*=b*=0 → white.
10570        let bytes = b"q /CS0 cs 1 0 scn 0 0 m 10 10 l 10 0 l h f Q\n";
10571        assert_eq!(first_fill_with_cs(bytes, &cs), (255, 255, 255));
10572        // (0, 0) → L* = 0 → black.
10573        let bytes = b"q /CS0 cs 0 0 scn 0 0 m 10 10 l 10 0 l h f Q\n";
10574        assert_eq!(first_fill_with_cs(bytes, &cs), (0, 0, 0));
10575    }
10576
10577    /// A DeviceN whose tint-transform output arity (2) doesn't match the
10578    /// CalRGB alternate's component count (3) is rejected at resolve time
10579    /// — the conservative black fallback applies.
10580    #[test]
10581    fn device_n_cie_alternate_arity_mismatch_rejected() {
10582        let cal_rgb = Object::Array(vec![
10583            Object::Name("CalRGB".into()),
10584            Object::Dict(Dict::new().with(
10585                "WhitePoint",
10586                Object::Array(vec![
10587                    Object::Real(0.9505),
10588                    Object::Real(1.0),
10589                    Object::Real(1.089),
10590                ]),
10591            )),
10592        ]);
10593        // 1-in / 2-out tint, but CalRGB needs 3 outputs.
10594        let tint = type2(&[0.0, 0.0], &[1.0, 1.0], 1.0);
10595        let arr = device_n(&["C0"], cal_rgb, tint);
10596        assert_eq!(color_space_from_object(&arr), ColorSpaceKind::Unknown);
10597    }
10598
10599    /// An `/Indexed` space with a CIE-based (CalRGB) base (§8.6.6.3
10600    /// permits a CIE base). The 2-entry colour table holds two CalRGB
10601    /// triples; entry 0 = (1,1,1) → a bright colour through the identity
10602    /// CalRGB (XYZ (1,1,1) is brighter than the D65 white, so the sRGB
10603    /// reduction is near-white but not pure white), entry 1 = (0,0,0) →
10604    /// black.
10605    #[test]
10606    fn indexed_calrgb_base_renders() {
10607        let cal_rgb = Object::Array(vec![
10608            Object::Name("CalRGB".into()),
10609            Object::Dict(Dict::new().with(
10610                "WhitePoint",
10611                Object::Array(vec![
10612                    Object::Real(0.9505),
10613                    Object::Real(1.0),
10614                    Object::Real(1.089),
10615                ]),
10616            )),
10617        ]);
10618        // hival = 1; table = [255 255 255  0 0 0] (entry 0 max, 1 black).
10619        let table = Object::HexString(vec![255, 255, 255, 0, 0, 0]);
10620        let arr = Object::Array(vec![
10621            Object::Name("Indexed".into()),
10622            cal_rgb,
10623            Object::Integer(1),
10624            table,
10625        ]);
10626        match color_space_from_object(&arr) {
10627            ColorSpaceKind::Indexed { base, hival, .. } => {
10628                assert_eq!(hival, 1);
10629                assert!(matches!(*base, ColorSpaceKind::CalRgb { .. }));
10630            }
10631            other => panic!("expected Indexed/CalRGB, got {other:?}"),
10632        }
10633        let cs = Dict::new().with("CS0", arr);
10634        // index 0 → entry (1,1,1) → bright near-white.
10635        let bytes = b"q /CS0 cs 0 scn 0 0 m 10 10 l 10 0 l h f Q\n";
10636        let (r, g, b) = first_fill_with_cs(bytes, &cs);
10637        assert!(r > 230 && g > 230 && b > 230, "entry 0 got ({r},{g},{b})");
10638        // index 1 → entry (0,0,0) → black.
10639        let bytes = b"q /CS0 cs 1 scn 0 0 m 10 10 l 10 0 l h f Q\n";
10640        assert_eq!(first_fill_with_cs(bytes, &cs), (0, 0, 0));
10641    }
10642
10643    /// An `/Indexed` space with a `/Lab` base: the table bytes decode
10644    /// through the L*/Range scaling. Entry 0's L* byte 255 → L*=100 with
10645    /// a*=b* mid-range (byte 128) → near-white; verifying the Lab branch
10646    /// of `indexed_color` is reached (no panic, a plausible bright
10647    /// colour).
10648    #[test]
10649    fn indexed_lab_base_decodes_table() {
10650        let lab = Object::Array(vec![
10651            Object::Name("Lab".into()),
10652            Object::Dict(
10653                Dict::new()
10654                    .with(
10655                        "WhitePoint",
10656                        Object::Array(vec![
10657                            Object::Real(0.9505),
10658                            Object::Real(1.0),
10659                            Object::Real(1.089),
10660                        ]),
10661                    )
10662                    .with(
10663                        "Range",
10664                        Object::Array(vec![
10665                            Object::Integer(-128),
10666                            Object::Integer(127),
10667                            Object::Integer(-128),
10668                            Object::Integer(127),
10669                        ]),
10670                    ),
10671            ),
10672        ]);
10673        // hival 0, one entry: L*-byte 255 (→100), a*/b* bytes 128
10674        // (→ ≈ -0.5, near-neutral). A bright near-white.
10675        let table = Object::HexString(vec![255, 128, 128]);
10676        let arr = Object::Array(vec![
10677            Object::Name("Indexed".into()),
10678            lab,
10679            Object::Integer(0),
10680            table,
10681        ]);
10682        let cs = Dict::new().with("CS0", arr);
10683        let bytes = b"q /CS0 cs 0 scn 0 0 m 10 10 l 10 0 l h f Q\n";
10684        let (r, g, b) = first_fill_with_cs(bytes, &cs);
10685        // L*=100 neutral → a bright achromatic colour.
10686        assert!(r > 230 && g > 230 && b > 230, "got ({r},{g},{b})");
10687    }
10688}