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(®ion_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}