pdfrum_render/walk.rs
1//! The master walk: one page-object list, dispatched by kind.
2//!
3//! Two behaviours of the walk itself are load-bearing. The **cull test** is
4//! computed once per list from the inverse-transformed device clip box and
5//! uses **strict** inequalities, so an object exactly touching the clip edge
6//! is kept. And a **shading that fails is never retried**: every other kind
7//! falls back to a re-render that degenerates to an identical call on a
8//! bitmap device, so a failure is a skipped object and a diagnostic.
9
10// A dispatch `match` plus one free function per kind, each taking the small
11// `RenderCtx` record and the backend's own device, rather than the oracle's
12// thousand-line `CPDF_RenderStatus` with twenty-four members and a `Process*`
13// method per kind. The walk is generic over the backend rather than dynamic,
14// because `RasterBackend::snapshot` needs the concrete device to read pixels
15// back; `dyn RenderDevice` survives only where a device is genuinely
16// swappable — the Coons scratch buffer.
17
18use std::sync::Arc;
19
20use kurbo::{Affine, Rect, Shape};
21use pdfrum_common::{Deadline, Diagnostics, Operation};
22use pdfrum_page::{Page, PageObject, Visibility};
23
24use crate::clip;
25use crate::color::{Argb, ObjectKind, resolve_argb};
26use crate::ctx::{RenderCaches, RenderCtx};
27use crate::device::{ImageQuality, MAX_TARGET_DIMENSION, RasterBackend, RenderDevice};
28use crate::error::Error;
29use crate::group::{GroupFinish, GroupInputs, needs_backdrop, needs_offscreen};
30use crate::image::{
31 apply_stretched_mask, color_as_fill_mask, effective_quality, overprint_blend, resample_quality,
32 resample_quality_at, takes_other_transform, to_pixmap,
33};
34use crate::options::RenderOptions;
35use crate::paint::{PathPaint, draw_path};
36use crate::path::{IntRect, is_available_matrix, outer_rect};
37use crate::pattern::PatternClip;
38use crate::pixmap::{Pixmap, alpha_byte_rounding, alpha_byte_truncating};
39use crate::shading;
40use crate::text::{has_face, paint_kinds, stroke_text_matrices};
41use crate::transfer::TransferFunc;
42
43/// What one render call may reuse or restrict, beyond the page and the
44/// options.
45///
46/// A plain record with [`Default`], so a caller names only the parts it has:
47/// `RenderSession { caches: Some(&mut caches), ..Default::default() }`. Both
48/// fields are absent by default and both defaults are the conservative
49/// answer — fresh caches, nothing hidden — which is why [`render_page`] can
50/// be the whole API for a caller who wants neither.
51///
52/// It carries borrows rather than owning anything: the caches outlive the
53/// call by construction (that is what they are for), and the visibility tree
54/// belongs to the pre-pass that computed it.
55#[derive(Debug, Default)]
56pub struct RenderSession<'a> {
57 /// Caches to reuse across calls, instead of a fresh set per page: a
58 /// caller rendering many pages of one document flattens each glyph
59 /// outline once for the run rather than once per page.
60 ///
61 /// # Determinism
62 ///
63 /// Type-3 blue-zone snapping is order-dependent by design (see
64 /// [`RenderCaches`]), so a page rendered with a *warm* cache can differ
65 /// by a snapped pixel from one rendered cold. Reuse across pages of one
66 /// document is intended; reusing caches across *unrelated* documents
67 /// makes output depend on what was rendered before. For a byte-identical
68 /// baseline, leave this `None`.
69 pub caches: Option<&'a mut RenderCaches>,
70 /// Which objects optional content leaves visible. `None` draws them all.
71 ///
72 /// It comes from [`pdfrum_page::page_visibility`], a **pre-pass** over
73 /// the same `page` with the document's `/OCProperties`. Splitting it out
74 /// is what keeps the render API free of a resolver: deciding visibility
75 /// needs indirect-object lookup and a mutable evaluation cache, and
76 /// consuming the answer needs neither — it is one index per object.
77 pub visible: Option<&'a Visibility>,
78 /// When the render must have stopped. `None` — the default — is no
79 /// limit.
80 ///
81 /// Read once before the target is allocated and once per object during
82 /// the walk (nested lists included), so a page that has run out of time
83 /// stops at the next object; the render then fails with
84 /// [`Error::Limit`] rather than returning a pixmap with the rest of the
85 /// page missing. Costs one branch per object when unset. A borrow, like
86 /// the other two: the caller's `Limits` owns it.
87 pub deadline: Option<&'a Deadline>,
88}
89
90/// Render a page into a pixmap.
91///
92/// The target size comes from the page's display box under
93/// `opts.transform`; the background follows the oracle — opaque white for a
94/// page without transparency, fully transparent for one with it — unless
95/// overridden, and that choice is load-bearing rather than cosmetic.
96///
97/// Every page gets its own caches and every object is drawn. A caller that
98/// wants either of those different — a run over many pages, or a document
99/// with optional content — calls [`render_page_with`] instead, which is this
100/// function with a [`RenderSession`] the caller fills in.
101///
102/// # Errors
103///
104/// [`Error::TargetEmpty`] when the page's box under `opts.transform` is not
105/// at least one pixel on both axes (a zero, negative or non-finite size), and
106/// [`Error::TargetTooLarge`] when either axis exceeds
107/// [`MAX_TARGET_DIMENSION`]. Damage inside the page is reported through
108/// `diags` and never becomes an error.
109pub fn render_page<B: RasterBackend>(
110 page: &Page,
111 opts: &RenderOptions,
112 backend: &B,
113 diags: &mut Diagnostics,
114) -> Result<Pixmap, Error> {
115 render_page_with(page, opts, backend, RenderSession::default(), diags)
116}
117
118/// Render a page, reusing caller-owned caches and honouring optional content.
119///
120/// The general entry point: [`render_page`] is this with a default
121/// [`RenderSession`], and is the right call when neither of the session's two
122/// parts applies.
123///
124/// # Errors
125///
126/// As [`render_page`].
127pub fn render_page_with<B: RasterBackend>(
128 page: &Page,
129 opts: &RenderOptions,
130 backend: &B,
131 session: RenderSession<'_>,
132 diags: &mut Diagnostics,
133) -> Result<Pixmap, Error> {
134 let device = render_page_to_device_with(page, opts, backend, session, diags)?;
135 Ok(backend.finish(device))
136}
137
138/// Record a page onto a backend device without rasterizing the root target.
139///
140/// Same walk as [`render_page`], but the caller owns the device afterwards:
141/// a GPU embedder dispatches it into a texture with
142/// `VelloBackend::render_to_view` and never reads pixels; a CPU caller
143/// still calls [`RasterBackend::finish`]. Offscreen `finish` calls inside
144/// the walk — groups, masks, pattern cells — are unchanged.
145///
146/// ```
147/// use pdfrum_common::Diagnostics;
148/// use pdfrum_page::Page;
149/// use pdfrum_raster_tinyskia::TinySkiaBackend;
150/// use pdfrum_render::{RasterBackend, RenderOptions, render_page_to_device};
151///
152/// let backend = TinySkiaBackend;
153/// let device = render_page_to_device(
154/// &Page::empty(),
155/// &RenderOptions::default(),
156/// &backend,
157/// &mut Diagnostics::default(),
158/// )?;
159/// let pixmap = backend.finish(device);
160/// assert_eq!((pixmap.width(), pixmap.height()), (612, 792));
161/// # Ok::<(), pdfrum_render::Error>(())
162/// ```
163///
164/// # Errors
165///
166/// As [`render_page`].
167pub fn render_page_to_device<B: RasterBackend>(
168 page: &Page,
169 opts: &RenderOptions,
170 backend: &B,
171 diags: &mut Diagnostics,
172) -> Result<B::Device, Error> {
173 render_page_to_device_with(page, opts, backend, RenderSession::default(), diags)
174}
175
176/// [`render_page_to_device`] with a [`RenderSession`].
177///
178/// ```
179/// use pdfrum_common::Diagnostics;
180/// use pdfrum_page::Page;
181/// use pdfrum_raster_tinyskia::TinySkiaBackend;
182/// use pdfrum_render::{
183/// RasterBackend, RenderOptions, RenderSession, render_page_to_device_with,
184/// };
185///
186/// let backend = TinySkiaBackend;
187/// let device = render_page_to_device_with(
188/// &Page::empty(),
189/// &RenderOptions::default(),
190/// &backend,
191/// RenderSession::default(),
192/// &mut Diagnostics::default(),
193/// )?;
194/// let _ = backend.finish(device);
195/// # Ok::<(), pdfrum_render::Error>(())
196/// ```
197///
198/// # Errors
199///
200/// As [`render_page`].
201pub fn render_page_to_device_with<B: RasterBackend>(
202 page: &Page,
203 opts: &RenderOptions,
204 backend: &B,
205 session: RenderSession<'_>,
206 diags: &mut Diagnostics,
207) -> Result<B::Device, Error> {
208 let RenderSession {
209 caches,
210 visible,
211 deadline,
212 } = session;
213 // The two `None` arms need somewhere to live that outlasts the call, so
214 // each default is bound here and borrowed rather than built inline.
215 let mut fresh_caches = RenderCaches::new();
216 let all_visible = Visibility::all_visible();
217 let visible = visible.unwrap_or(&all_visible);
218 let caches = caches.unwrap_or(&mut fresh_caches);
219 render_page_inner(page, opts, backend, visible, caches, deadline, diags)
220}
221
222/// `Ok` unless `deadline` is set and has passed.
223fn check_deadline(deadline: Option<&Deadline>) -> Result<(), Error> {
224 match deadline {
225 Some(deadline) => deadline.check(Operation::Render).map_err(Error::Limit),
226 None => Ok(()),
227 }
228}
229
230/// The body both entry points share.
231fn render_page_inner<B: RasterBackend>(
232 page: &Page,
233 opts: &RenderOptions,
234 backend: &B,
235 visible: &Visibility,
236 caches: &mut RenderCaches,
237 deadline: Option<&Deadline>,
238 diags: &mut Diagnostics,
239) -> Result<B::Device, Error> {
240 let (w, h) = target_size(page, opts)?;
241 // Before the allocation, and again after the walk: a walk that stopped
242 // on the deadline has left the device half-drawn, and the second read is
243 // what turns that into an error rather than a returned pixmap.
244 check_deadline(deadline)?;
245 let clear = opts.background_for(needs_alpha_background(page));
246 let mut device = backend.new_target(w, h, clear);
247 let ctx = RenderCtx {
248 deadline,
249 ..RenderCtx::new(opts.clone(), page.transparency)
250 };
251 let to_device = page_matrix(page, opts);
252 let device_box = Rect::new(0.0, 0.0, f64::from(w), f64::from(h));
253
254 render_object_list(
255 &ctx,
256 &mut device,
257 backend,
258 caches,
259 &page.objects,
260 visible,
261 to_device,
262 device_box,
263 diags,
264 );
265 check_deadline(deadline)?;
266 Ok(device)
267}
268
269/// Whether the page renders onto a transparent background rather than white.
270///
271/// This is **not** whether the page declares a `/Group`. It is whether any
272/// `/ExtGState` on the page, or in a form it draws, names a blend mode above
273/// `Multiply` — the ones that read the backdrop. A page carrying a plain
274/// `/Group` still renders onto opaque white.
275#[must_use]
276pub fn needs_alpha_background(page: &Page) -> bool {
277 // Reading this as the `/Group` flag turns every such page's output from
278 // RGB to RGBA and, where nothing paints, from white to black — a
279 // whole-page difference, not a pixel one. The flag the oracle actually
280 // reports through `FPDFPage_HasTransparency` is
281 // `CPDF_PageObjectHolder::BackgroundAlphaNeeded`, set by the content
282 // parser in exactly one place (`cpdf_allstates.cpp:105-106`) and
283 // propagated up from a form to its holder
284 // (`cpdf_streamcontentparser.cpp:835-838`).
285 fn any_deep_blend(objects: &[PageObject]) -> bool {
286 objects.iter().any(|object| {
287 let deep = matches!(
288 object.state().general.blend,
289 pdfrum_page::BlendMode::Screen
290 | pdfrum_page::BlendMode::Overlay
291 | pdfrum_page::BlendMode::Darken
292 | pdfrum_page::BlendMode::Lighten
293 | pdfrum_page::BlendMode::ColorDodge
294 | pdfrum_page::BlendMode::ColorBurn
295 | pdfrum_page::BlendMode::HardLight
296 | pdfrum_page::BlendMode::SoftLight
297 | pdfrum_page::BlendMode::Difference
298 | pdfrum_page::BlendMode::Exclusion
299 | pdfrum_page::BlendMode::Hue
300 | pdfrum_page::BlendMode::Saturation
301 | pdfrum_page::BlendMode::Color
302 | pdfrum_page::BlendMode::Luminosity
303 );
304 // A form's own objects carry the flag up to their holder.
305 deep || match object {
306 PageObject::Form(f) => any_deep_blend(&f.object.objects),
307 _ => false,
308 }
309 })
310 }
311 any_deep_blend(&page.objects)
312}
313
314/// The device size a page renders at, and the errors that size can be.
315///
316/// Public so a caller can size a buffer, lay out a sheet or reject a page
317/// *before* paying for a render — [`render_page`] answers the same question
318/// only by doing the work.
319///
320/// The dimensions **truncate**, they do not round up: an A4 page's 595.276
321/// points become 595 device pixels, not 596. Rounding up instead costs a
322/// one-pixel border on every page whose size is not a whole number — a size
323/// mismatch rather than a pixel difference.
324///
325/// # Errors
326///
327/// [`Error::TargetEmpty`] when the page's box under `opts.transform` is not
328/// at least one pixel on both axes, and [`Error::TargetTooLarge`] when either
329/// axis exceeds [`MAX_TARGET_DIMENSION`].
330#[expect(
331 clippy::cast_possible_truncation,
332 clippy::cast_sign_loss,
333 reason = "the `is_finite` and `>= 1.0` guards run before the successful \
334 cast, and the `MAX_TARGET_DIMENSION` check runs after it; in \
335 the error arm `max(0.0)` floors the value and Rust's saturating \
336 float-to-int cast turns a huge or NaN size into a reported \
337 number rather than wrapping"
338)]
339pub fn target_size(page: &Page, opts: &RenderOptions) -> Result<(u32, u32), Error> {
340 let (pw, ph) = page.display_size();
341 let corners = opts
342 .transform
343 .transform_rect_bbox(Rect::new(0.0, 0.0, pw, ph));
344 let w = corners.width().trunc();
345 let h = corners.height().trunc();
346 if !w.is_finite() || !h.is_finite() || w < 1.0 || h < 1.0 {
347 return Err(Error::TargetEmpty {
348 width: w.max(0.0) as u32,
349 height: h.max(0.0) as u32,
350 });
351 }
352 let (w, h) = (w as u32, h as u32);
353 if w > MAX_TARGET_DIMENSION || h > MAX_TARGET_DIMENSION {
354 return Err(Error::TargetTooLarge {
355 width: w,
356 height: h,
357 limit: MAX_TARGET_DIMENSION,
358 });
359 }
360 Ok((w, h))
361}
362
363/// Page space to device space.
364///
365/// Three transforms compose, and the middle one is the easy omission: the
366/// page's display matrix normalises the crop box's origin and applies the
367/// `/Rotate`, but PDF user space is **y-up** and every device is y-down, so
368/// the y axis must be flipped about the page's height before the caller's
369/// own transform applies. Without it a page renders upside down, which the
370/// symmetric fixtures hide and the asymmetric ones do not.
371///
372/// The flip is about the **device** box, not the page box: the display
373/// matrix divides the *truncated integer* bitmap size by the page's float
374/// size, so an A4 page 841.89 points tall renders into 841 device rows with a
375/// y scale of `841 / 841.89`, not 1.
376#[must_use]
377pub fn page_matrix(page: &Page, opts: &RenderOptions) -> Affine {
378 // Flipping about the float height instead leaves a shear of up to a
379 // device pixel between the top of the page and the bottom. That is
380 // invisible while every glyph is filled at its true position — it moves a
381 // stem edge by a fraction of a count — and stops being invisible the
382 // moment glyph origins are snapped, since a y that was 0.49 off is then a
383 // whole row off.
384 let (page_w, page_h) = page.display_size();
385 // The device box the oracle fits the page into: the same truncation
386 // `target_size` performs, since that is the bitmap that gets allocated.
387 // A page that cannot be sized at all keeps the float box, which is what
388 // the render is about to reject anyway.
389 let device =
390 target_size(page, opts).map_or((page_w, page_h), |(w, h)| (f64::from(w), f64::from(h)));
391 let (dev_w, dev_h) = device;
392 if page_w <= 0.0 || page_h <= 0.0 || !dev_w.is_finite() || !dev_h.is_finite() {
393 return opts.transform * page.rotate.display_matrix(page.crop_box);
394 }
395 // `opts.transform` has already been consumed in choosing the device box,
396 // exactly as `pdfium_test` consumes its `--scale` in sizing the bitmap
397 // and then asks for the display matrix onto that size.
398 let fit = Affine::new([dev_w / page_w, 0.0, 0.0, -dev_h / page_h, 0.0, dev_h]);
399 fit * page.rotate.display_matrix(page.crop_box)
400}
401
402/// Walk one object list.
403///
404/// `device_box` is the device's own extent and is what the cull test works
405/// against, transformed back into object space once for the whole list —
406/// which is why an object's own matrix cannot change it.
407///
408/// `visible` describes *this* list, one entry per object in order. It is the
409/// pre-pass's answer and the walk only reads it — see
410/// [`render_page_with`]. A tree that hides nothing is the common
411/// case and costs one `is_none_or` per object.
412#[expect(
413 clippy::too_many_arguments,
414 reason = "the walk threads context, device, backend, caches and the \
415 visibility describing this list"
416)]
417pub fn render_object_list<B: RasterBackend>(
418 ctx: &RenderCtx<'_>,
419 device: &mut B::Device,
420 backend: &B,
421 caches: &mut RenderCaches,
422 objects: &[PageObject],
423 visible: &Visibility,
424 to_device: Affine,
425 device_box: Rect,
426 diags: &mut Diagnostics,
427) {
428 if !ctx.may_recurse() {
429 return;
430 }
431 let cull = cull_rect(to_device, device_box);
432 for (index, object) in objects.iter().enumerate() {
433 // The deadline is read per object — a draw call — and a list that is
434 // out of time returns; the enclosing list reads it again at its next
435 // object, so the whole walk unwinds without a flag.
436 if ctx.out_of_time() {
437 return;
438 }
439 // The visibility gate runs first, exactly where `RenderSingleObject`
440 // puts it (`cpdf_renderstatus.cpp:247`): before the clip is pushed,
441 // so a hidden object's clip never reaches the device either.
442 if !visible.visible(index) {
443 continue;
444 }
445 if let Some(cull) = cull
446 && crate::walkprofile::phase(crate::walkprofile::Phase::Cull, || culled(object, cull))
447 {
448 continue;
449 }
450 render_object(
451 ctx,
452 device,
453 backend,
454 caches,
455 object,
456 &visible.children(index),
457 to_device,
458 device_box,
459 diags,
460 );
461 }
462}
463
464/// The object-space rectangle the device clip box maps back to, computed once
465/// per list.
466fn cull_rect(to_device: Affine, device_box: Rect) -> Option<Rect> {
467 let det = to_device.determinant();
468 (det != 0.0 && det.is_finite())
469 .then(|| to_device.inverse().transform_rect_bbox(device_box))
470 .filter(|r| r.x0.is_finite() && r.y0.is_finite() && r.x1.is_finite() && r.y1.is_finite())
471}
472
473/// The cull test, with the **strict** inequalities `RenderObjectList` uses.
474///
475/// The progressive renderer spells the complement with `<=`/`>=`, so an
476/// object exactly touching the clip edge is kept there and dropped here.
477/// Only this spelling survives; recorded so nobody "fixes" it later.
478fn culled(object: &PageObject, cull: Rect) -> bool {
479 // A path's exact box is the expensive one and the cull almost never needs
480 // it, so the two cheap boxes that bracket it are tried first. See
481 // `path_cull_bounds`.
482 if let PageObject::Path(p) = object {
483 if let Some((inner, outer)) = path_cull_bounds(&p.object.path, p.object.matrix) {
484 // The endpoint box is a *subset* of the exact box, so an endpoint
485 // box that reaches the clip proves the exact box does — keep, no
486 // solve.
487 if !outside(inner, cull) {
488 return false;
489 }
490 // The control-point hull is a *superset*, so a hull entirely off
491 // the clip proves the exact box is too — cull, no solve.
492 if outside(outer, cull) {
493 return true;
494 }
495 }
496 // Either the bracket declined the path, or the answer lies in the band
497 // between its two boxes. Both need the cubic extrema.
498 return outside(path_bbox(&p.object.path, p.object.matrix), cull);
499 }
500 let Some(bbox) = object_bbox(object) else {
501 return false;
502 };
503 outside(bbox, cull)
504}
505
506/// The strict inequalities `RenderObjectList` culls with.
507///
508/// The progressive renderer spells the complement with `<=`/`>=`, so an object
509/// exactly touching the clip edge is kept there and dropped here.
510fn outside(bbox: Rect, cull: Rect) -> bool {
511 bbox.x0 > cull.x1 || bbox.x1 < cull.x0 || bbox.y0 > cull.y1 || bbox.y1 < cull.y0
512}
513
514/// Two boxes that bracket a transformed path's exact bounding box: the box of
515/// its **on-curve endpoints**, which is contained in it, and the box of **every
516/// control point**, which contains it. `None` when the path has no segments,
517/// and `None` when any coordinate is not finite.
518///
519/// Both are one pass over the elements with four `min`/`max` per point and no
520/// segment reconstruction, where the exact box — [`path_bbox`] — has to solve
521/// each cubic's extrema.
522///
523/// The bracket is what makes skipping the solve *exact* rather than
524/// approximate: a subset that reaches the clip proves the true box reaches it,
525/// and a superset that misses proves the true box misses. Only a path whose
526/// curve bulges across the clip edge while its endpoints and hull straddle it
527/// differently pays for the solve, and it still gets the same answer.
528///
529/// # Why a non-finite coordinate declines the whole path
530///
531/// `f64::min` and `f64::max` *drop* a NaN operand and return the other, and
532/// the first point of a box is taken rather than folded — so a NaN on a drawn
533/// **endpoint** lands in the inner box unfolded. `outside` compares with `>`
534/// and `<`, and every comparison against a NaN is false, so
535/// `!outside(inner, cull)` answered *keep* for every clip on the page.
536///
537/// The exact box does not agree. kurbo's extrema solve drops the NaN exactly
538/// as `min`/`max` do, so `path_bbox` comes back finite and `outside` answers
539/// it honestly: cull, for a clip the path's finite points miss. The bracket
540/// kept where the exact test culled — a missed cull rather than a wrong one,
541/// so nothing was ever drawn incorrectly, but a disagreement all the same, and
542/// agreeing is the bracket's whole contract.
543///
544/// A NaN *control* point is harmless by the same accident: it reaches only the
545/// outer box, where the fold drops it, and kurbo drops it too. The guard is
546/// written on finiteness rather than on which box a point reaches because that
547/// symmetry is a property of today's kurbo, not a promise.
548///
549/// So any non-finite coordinate declines the bracket and the path takes the
550/// exact spelling — the same policy [`cull_rect`] applies to a clip box it
551/// cannot invert finitely. No corpus document reaches it; a crafted one could.
552fn path_cull_bounds(path: &kurbo::BezPath, matrix: Affine) -> Option<(Rect, Rect)> {
553 // A path that does not open with a move is not a shape kurbo's `segments`
554 // reads the way this bracket assumes, so it takes the exact spelling.
555 if !matches!(path.elements().first(), Some(kurbo::PathEl::MoveTo(_))) {
556 return None;
557 }
558 let mut inner: Option<Rect> = None;
559 let mut outer: Option<Rect> = None;
560 // Set by `add` on the first non-finite transformed point, and checked once
561 // at the end: a path with a NaN in it is rare enough that bailing out of
562 // the loop early would buy nothing, and the flag keeps `add` an
563 // expression.
564 let mut finite = true;
565 let mut add = |bounds: &mut Option<Rect>, p: kurbo::Point| {
566 let p = matrix * p;
567 // After the transform, not before: an affine with a non-finite
568 // coefficient turns finite input non-finite, and it is the point the
569 // box is built from that has to be checked.
570 finite &= p.x.is_finite() && p.y.is_finite();
571 *bounds = Some(match *bounds {
572 Some(r) => Rect::new(r.x0.min(p.x), r.y0.min(p.y), r.x1.max(p.x), r.y1.max(p.y)),
573 None => Rect::new(p.x, p.y, p.x, p.y),
574 });
575 };
576 for el in path.elements() {
577 match *el {
578 // A `MoveTo` goes only into the superset. It *usually* starts a
579 // segment and so is usually in the exact box too — but a `MoveTo`
580 // immediately followed by another one starts no segment at all,
581 // and putting it in the subset would make the subset larger than
582 // the exact box on exactly that path. The superset is unharmed by
583 // a point the exact box does not have.
584 kurbo::PathEl::MoveTo(p) => add(&mut outer, p),
585 kurbo::PathEl::LineTo(p) => {
586 add(&mut inner, p);
587 add(&mut outer, p);
588 }
589 kurbo::PathEl::QuadTo(c, p) => {
590 add(&mut inner, p);
591 add(&mut outer, c);
592 add(&mut outer, p);
593 }
594 kurbo::PathEl::CurveTo(c1, c2, p) => {
595 add(&mut inner, p);
596 add(&mut outer, c1);
597 add(&mut outer, c2);
598 add(&mut outer, p);
599 }
600 kurbo::PathEl::ClosePath => {}
601 }
602 }
603 if !finite {
604 return None;
605 }
606 // A path with no drawn segment at all — one bare `MoveTo`, or a move and a
607 // close — has no endpoint box, and its exact box is `Rect::default()`
608 // rather than the point it names. The two disagree, so it goes the exact
609 // way; there is nothing to save on a path of one element anyway.
610 Some((inner?, outer?))
611}
612
613/// One object's own bounding box in the coordinate space its list is walked
614/// in, or `None` when it has no meaningful extent.
615fn object_bbox(object: &PageObject) -> Option<Rect> {
616 match object {
617 // The transformed box, taken segment by segment rather than by
618 // building a transformed copy of the path.
619 //
620 // `matrix * path` allocates a whole second `BezPath` — every element
621 // of it — and this function runs on *every object of every page* to
622 // decide a cull that then throws the copy away. On `vector_paths_1751`
623 // that is five thousand path allocations per render, none of which
624 // outlives the comparison two lines later. `segments()` walks the
625 // elements by reference, and a segment's own bounding box is exact
626 // (kurbo solves the cubic's extrema rather than hulling its control
627 // points), so the union is the same rectangle the clone produced.
628 PageObject::Path(p) => Some(path_bbox(&p.object.path, p.object.matrix)),
629 PageObject::Image(i) => Some(i.object.matrix.transform_rect_bbox(unit_rect())),
630 PageObject::Shading(s) => Some(s.object.bounds),
631 PageObject::Form(f) => f
632 .object
633 .bbox
634 .map(|b| f.object.matrix.transform_rect_bbox(b)),
635 // A text object's extent needs the font's metrics; the cull is an
636 // optimisation, so declining it is always safe.
637 PageObject::Text(_) => None,
638 }
639}
640
641/// The bounding box of `path` under `matrix`, without building a transformed
642/// copy of it.
643///
644/// Exactly what `(matrix * path).bounding_box()` returns, including the empty
645/// case: kurbo's own `bounding_box` unions its segments' boxes and answers
646/// `Rect::default()` — the degenerate rectangle at the origin — for a path
647/// with no segments, so a bare `MoveTo` culls against the origin here as it
648/// did before. Reproducing that rather than answering `None` keeps the cull
649/// decision identical on every path, which is what makes this a pure
650/// allocation change.
651fn path_bbox(path: &kurbo::BezPath, matrix: Affine) -> Rect {
652 let mut bbox: Option<Rect> = None;
653 for seg in path.segments() {
654 let seg_bb = (matrix * seg).bounding_box();
655 bbox = Some(match bbox {
656 Some(bb) => bb.union(seg_bb),
657 None => seg_bb,
658 });
659 }
660 bbox.unwrap_or_default()
661}
662
663fn unit_rect() -> Rect {
664 Rect::new(0.0, 0.0, 1.0, 1.0)
665}
666
667/// Render one object, through a transparency group when the predicate says
668/// so and directly otherwise.
669///
670/// `children` describes this object's own object list when it is a form, and
671/// is ignored for every other kind.
672#[expect(
673 clippy::too_many_arguments,
674 reason = "the walk threads context, device, backend, caches and a form's \
675 child visibility"
676)]
677pub fn render_object<B: RasterBackend>(
678 ctx: &RenderCtx<'_>,
679 device: &mut B::Device,
680 backend: &B,
681 caches: &mut RenderCaches,
682 object: &PageObject,
683 children: &Visibility,
684 to_device: Affine,
685 device_box: Rect,
686 diags: &mut Diagnostics,
687) {
688 let state = object.state();
689 let clips = crate::walkprofile::phase(crate::walkprofile::Phase::Clip, || {
690 clip::resolve(&state.clip, to_device, &mut caches.glyphs, &ctx.opts)
691 });
692 let pushed = clip::push(device, &clips);
693
694 let initial_alpha = ctx.initial_fill.map_or(1.0, |_| 1.0);
695 let inputs = GroupInputs::of(object, initial_alpha);
696 if needs_offscreen(inputs) && ctx.may_recurse() {
697 render_grouped(
698 ctx, device, backend, caches, object, children, inputs, to_device, device_box, diags,
699 );
700 } else {
701 render_direct(
702 ctx, device, backend, caches, object, children, to_device, device_box, diags,
703 );
704 }
705
706 clip::pop(device, pushed);
707}
708
709/// Render one object into its own buffer, then composite that buffer back.
710#[expect(
711 clippy::too_many_arguments,
712 reason = "the group path needs every input the direct one had"
713)]
714fn render_grouped<B: RasterBackend>(
715 ctx: &RenderCtx<'_>,
716 device: &mut B::Device,
717 backend: &B,
718 caches: &mut RenderCaches,
719 object: &PageObject,
720 children: &Visibility,
721 inputs: GroupInputs,
722 to_device: Affine,
723 device_box: Rect,
724 diags: &mut Diagnostics,
725) {
726 let state = object.state();
727 // The buffer is sized to the object's own device extent intersected with
728 // the device, so a group off the page costs nothing.
729 let bbox = object_bbox(object)
730 .map_or(device_box, |b| to_device.transform_rect_bbox(b))
731 .intersect(device_box);
732 let rect = outer_rect(bbox).intersect(outer_rect(device_box));
733 let (Ok(w), Ok(h)) = (u32::try_from(rect.width()), u32::try_from(rect.height())) else {
734 return;
735 };
736 if w == 0 || h == 0 || w > MAX_TARGET_DIMENSION || h > MAX_TARGET_DIMENSION {
737 return;
738 }
739
740 let transparency = match object {
741 PageObject::Form(f) => f.object.transparency,
742 _ => ctx.transparency,
743 };
744 let knockout = match object {
745 PageObject::Form(f) => f.object.transparency.knockout,
746 _ => false,
747 };
748 // Isolated groups (no backdrop copy, no knockout) can composite as a
749 // native layer: clip to the group's bbox, `push_layer`, walk on the
750 // parent. That is the GPU win — no `finish` of the group's content.
751 // Knockout still needs per-object pixmap combine, and non-isolated
752 // still needs `remove_backdrop`. CPU backends leave this off so the
753 // conformance board does not move.
754 if backend.composite_isolated_groups_as_layers() && !needs_backdrop(transparency) && !knockout {
755 render_grouped_as_layer(
756 ctx,
757 device,
758 backend,
759 caches,
760 object,
761 children,
762 inputs,
763 transparency,
764 to_device,
765 device_box,
766 rect,
767 diags,
768 );
769 return;
770 }
771 // Isolated means the group starts transparent; non-isolated means it
772 // starts from a copy of what is already on the page. `[oracle-bug]`:
773 // that copy is kept so it can be **removed again** below, which
774 // `cpdf_renderstatus.cpp` never does — see `Pixmap::remove_backdrop`.
775 let (mut sub, initial_backdrop) = if needs_backdrop(transparency) {
776 let cropped = snapshot_group_backdrop(backend, device, rect);
777 (backend.new_target_with_backdrop(&cropped), Some(cropped))
778 } else {
779 (backend.new_target(w, h, peniko::Color::TRANSPARENT), None)
780 };
781
782 let offset = Affine::translate((-f64::from(rect.left), -f64::from(rect.top)));
783 let inner_ctx = RenderCtx {
784 transparency,
785 in_group: true,
786 // The group does *not* inherit the parent's colour: `Initialize(null,
787 // null)` in the C++.
788 initial_fill: None,
789 initial_stroke: None,
790 ..ctx.deeper()
791 };
792 let inner_box = Rect::new(0.0, 0.0, f64::from(w), f64::from(h));
793 render_direct(
794 &inner_ctx,
795 &mut sub,
796 backend,
797 caches,
798 object,
799 children,
800 offset * to_device,
801 inner_box,
802 diags,
803 );
804 let mut pixels = backend.finish(sub);
805
806 // `[oracle-bug]`: take the initial backdrop back out before the group
807 // is composited over the very pixels it was copied from, per §11.4.6's
808 // `C = Cn + (Cn - C0) x (a0/agn - a0)`. It must happen *before* the alphas
809 // and the mask, which apply to the group's own contribution.
810 if let Some(backdrop) = &initial_backdrop {
811 pixels.remove_backdrop(backdrop);
812 }
813
814 // The mask first, then the group alpha, then the inherited one — in that
815 // order, and the last only outside an enclosing group.
816 if let Some(mask) = &state.general.soft_mask {
817 let rendered = render_soft_mask(&inner_ctx, backend, caches, mask, rect, to_device, diags);
818 if let Some(m) = rendered {
819 pixels.multiply_alpha_mask(&m);
820 }
821 }
822 // `transparency` is the **form's own**, not the enclosing one
823 // (`cpdf_renderstatus.cpp:646`, `:740-742`): the group alpha is applied
824 // exactly when the object being drawn declares a group, and a form drawn
825 // inside a page that declares one does not thereby inherit the multiply.
826 // Reading the enclosing flag instead drops the group alpha on any form
827 // whose own `/Group` is absent — and applies it to a non-form under a
828 // page that has one, where `inputs.group_alpha` is 1.0 and it is
829 // harmless, which is why this only ever showed up as a missing multiply.
830 GroupFinish::of(inputs, transparency, ctx.in_group).apply(&mut pixels);
831
832 // With a premultiplied RGBA target that is both readable and
833 // alpha-capable, PDFium's five-armed compositor collapses to one arm: a
834 // plain blended blit. The arms it does not take need an opaque target
835 // that cannot report alpha, which ours never is.
836 device.push_layer(state.general.blend, 1.0, None);
837 device.draw_image(
838 &pixels,
839 Affine::translate((f64::from(rect.left), f64::from(rect.top))),
840 ImageQuality::Nearest,
841 1.0,
842 );
843 device.pop();
844}
845
846/// Isolated group as a native layer on the parent device.
847///
848/// The group's content is clipped to its bbox so a blend mode that reads
849/// the backdrop cannot darken pixels the group never painted — the pixmap
850/// path gets that for free by sizing the buffer to the bbox.
851#[expect(
852 clippy::too_many_arguments,
853 reason = "the layer path takes the same inputs as the pixmap path plus \
854 the bbox it clips to"
855)]
856fn render_grouped_as_layer<B: RasterBackend>(
857 ctx: &RenderCtx<'_>,
858 device: &mut B::Device,
859 backend: &B,
860 caches: &mut RenderCaches,
861 object: &PageObject,
862 children: &Visibility,
863 inputs: GroupInputs,
864 transparency: pdfrum_page::Transparency,
865 to_device: Affine,
866 device_box: Rect,
867 rect: IntRect,
868 diags: &mut Diagnostics,
869) {
870 let state = object.state();
871 let inner_ctx = RenderCtx {
872 transparency,
873 in_group: true,
874 initial_fill: None,
875 initial_stroke: None,
876 ..ctx.deeper()
877 };
878 let finish = GroupFinish::of(inputs, transparency, ctx.in_group);
879 let mut layer_alpha = 1.0_f32;
880 if let Some(a) = finish.group_alpha {
881 layer_alpha *= a;
882 }
883 if let Some(a) = finish.initial_alpha {
884 layer_alpha *= a;
885 }
886
887 // Soft masks still rasterize to a coverage plane (that *is* pixels),
888 // then pad to the current target so `push_layer`'s device-sized
889 // invariant holds. The group's *content* stays on this device.
890 let mask = state.general.soft_mask.as_ref().and_then(|sm| {
891 let rendered = render_soft_mask(&inner_ctx, backend, caches, sm, rect, to_device, diags)?;
892 let (Ok(dw), Ok(dh)) = (
893 u32::try_from(outer_rect(device_box).width()),
894 u32::try_from(outer_rect(device_box).height()),
895 ) else {
896 return Some(rendered);
897 };
898 Some(rendered.placed_in(dw, dh, rect.left, rect.top))
899 });
900
901 device.push_clip_rect(rect.to_rect());
902 device.push_layer(state.general.blend, layer_alpha, mask.as_ref());
903 render_direct(
904 &inner_ctx, device, backend, caches, object, children, to_device, device_box, diags,
905 );
906 device.pop();
907 device.pop();
908}
909
910/// The parent pixels under a group's bbox, via [`RasterBackend::snapshot_rect`].
911fn snapshot_group_backdrop<B: RasterBackend>(
912 backend: &B,
913 device: &B::Device,
914 rect: IntRect,
915) -> Pixmap {
916 let origin_x = u32::try_from(rect.left).unwrap_or(0);
917 let origin_y = u32::try_from(rect.top).unwrap_or(0);
918 let width = u32::try_from(rect.width()).unwrap_or(0);
919 let height = u32::try_from(rect.height()).unwrap_or(0);
920 backend.snapshot_rect(device, origin_x, origin_y, width, height)
921}
922
923/// Render a soft mask's group and read it back as a device-sized coverage
924/// plane.
925///
926/// Four contracts, all pixel-visible:
927///
928/// - **The mask renders at exactly the clip rect's device resolution**, on the
929/// device's own pixel grid, so applying it never resamples.
930/// - **A luminosity buffer is opaque**, cleared to the `/BC` backdrop
931/// (default black), so an area the group never paints contributes the
932/// backdrop's luminosity rather than zero. That is what makes an unpainted
933/// corner of a `/BC`-white mask fully *reveal* rather than fully hide.
934/// - **An alpha buffer starts at nothing** and the group renders in alpha
935/// colour mode, where every drawing operation writes its alpha as gray.
936/// - **The readback uses the oracle's gray weights, not BT.709.** Both
937/// rasterizers ship a luminance helper and both use BT.709; neither may be
938/// used here.
939fn render_soft_mask<B: RasterBackend>(
940 ctx: &RenderCtx<'_>,
941 backend: &B,
942 caches: &mut RenderCaches,
943 mask: &pdfrum_page::SoftMask,
944 rect: IntRect,
945 to_device: Affine,
946 diags: &mut Diagnostics,
947) -> Option<crate::pixmap::AlphaMask> {
948 if !ctx.may_recurse() {
949 return None;
950 }
951 let (Ok(w), Ok(h)) = (u32::try_from(rect.width()), u32::try_from(rect.height())) else {
952 return None;
953 };
954 if w == 0 || h == 0 || w > MAX_TARGET_DIMENSION || h > MAX_TARGET_DIMENSION {
955 return None;
956 }
957 let mut device = backend.new_target(w, h, crate::softmask::backdrop(mask));
958 if !mask.objects.is_empty() {
959 let inner = RenderCtx {
960 opts: RenderOptions {
961 color_mode: match mask.kind {
962 // An alpha mask's group paints alpha as gray; a
963 // luminosity one paints its real colours and the
964 // readback greys them.
965 pdfrum_page::SoftMaskKind::Alpha => crate::options::ColorMode::Alpha,
966 pdfrum_page::SoftMaskKind::Luminosity => crate::options::ColorMode::Normal,
967 },
968 ..ctx.opts.clone()
969 },
970 // The group renders from a clean slate: `Initialize(null, null)`.
971 initial_fill: None,
972 initial_stroke: None,
973 type3: None,
974 in_group: true,
975 ..ctx.deeper()
976 };
977 // The mask's own matrix already places it; only the shift into the
978 // buffer's origin is added, so the mask lands on the device's grid.
979 let offset = Affine::translate((-f64::from(rect.left), -f64::from(rect.top)));
980 render_object_list(
981 &inner,
982 &mut device,
983 backend,
984 caches,
985 &mask.objects,
986 // A soft mask's group is its own object list, not the page's, so
987 // the page's visibility tree says nothing about it.
988 &Visibility::all_visible(),
989 offset * to_device,
990 Rect::new(0.0, 0.0, f64::from(w), f64::from(h)),
991 diags,
992 );
993 }
994 let rendered = backend.finish(device);
995 Some(crate::softmask::readback(mask, &rendered))
996}
997
998/// Dispatch one object to its handler.
999#[expect(
1000 clippy::too_many_arguments,
1001 reason = "the walk threads context, device, backend and caches"
1002)]
1003fn render_direct<B: RasterBackend>(
1004 ctx: &RenderCtx<'_>,
1005 device: &mut B::Device,
1006 backend: &B,
1007 caches: &mut RenderCaches,
1008 object: &PageObject,
1009 children: &Visibility,
1010 to_device: Affine,
1011 device_box: Rect,
1012 diags: &mut Diagnostics,
1013) {
1014 match object {
1015 PageObject::Path(p) => render_path(
1016 ctx, device, backend, caches, &p.object, &p.state, to_device, device_box, diags,
1017 ),
1018 PageObject::Text(t) => render_text(
1019 ctx, device, backend, caches, &t.object, &t.state, to_device, device_box, diags,
1020 ),
1021 PageObject::Image(i) => render_image(
1022 ctx, device, backend, caches, &i.object, &i.state, to_device, device_box, diags,
1023 ),
1024 PageObject::Shading(s) => render_shading(
1025 ctx, device, backend, &s.object, &s.state, to_device, device_box,
1026 ),
1027 PageObject::Form(f) => {
1028 let inner = RenderCtx {
1029 opts: form_options(&ctx.opts, f.object.live_edit),
1030 initial_fill: ctx.initial_fill,
1031 initial_stroke: ctx.initial_stroke,
1032 ..ctx.deeper()
1033 };
1034 // `[oracle-bug]`: a knockout group composites each of its own
1035 // objects against the group's **initial** backdrop rather than
1036 // against the accumulated result, so a later object *replaces* an
1037 // earlier one where they overlap instead of blending over it
1038 // (§11.6.6, table 147's `/K`).
1039 if f.object.transparency.knockout {
1040 render_knockout_form(
1041 &inner,
1042 device,
1043 backend,
1044 caches,
1045 &f.object.objects,
1046 children,
1047 to_device,
1048 device_box,
1049 diags,
1050 );
1051 return;
1052 }
1053 // The children's own matrices already carry the form's, because
1054 // `build_page` composes `/Matrix` into the CTM before recursing.
1055 // Composing it again here would apply it twice.
1056 render_object_list(
1057 &inner,
1058 device,
1059 backend,
1060 caches,
1061 &f.object.objects,
1062 children,
1063 to_device,
1064 device_box,
1065 diags,
1066 );
1067 }
1068 }
1069}
1070
1071/// `[oracle-bug]` Render a **knockout** group's objects (§11.6.6, `/K`).
1072///
1073/// PDFium never honours `/K`: the only `/K` read under `core/fpdfapi` is
1074/// CCITT's, and although knockout plumbing exists in `core/fxge/`,
1075/// `RenderDeviceDriverIface::SetGroupKnockout` is an empty body that the AGG
1076/// driver never overrides — so on the oracle's configuration a knockout group
1077/// renders as an ordinary one. pdf.js reads `/I` and `/K`
1078/// (`evaluator.js:523-524`) and implements knockout in earnest
1079/// (`canvas.js:499-534`, `:3310-3318`).
1080///
1081/// The rule §11.6.6 states is that every object in the group composites
1082/// against the group's **initial** backdrop rather than against the
1083/// accumulated result, so a later object *replaces* an earlier one where they
1084/// overlap. That is implemented here by rendering each object into its own
1085/// transparent buffer over that one backdrop and replacing the running result
1086/// wherever the object put coverage down — which is exactly "the last object
1087/// wins per pixel", and degenerates to the ordinary walk when nothing
1088/// overlaps.
1089#[expect(
1090 clippy::too_many_arguments,
1091 reason = "the knockout path needs every input the ordinary walk had"
1092)]
1093fn render_knockout_form<B: RasterBackend>(
1094 ctx: &RenderCtx<'_>,
1095 device: &mut B::Device,
1096 backend: &B,
1097 caches: &mut RenderCaches,
1098 objects: &[PageObject],
1099 children: &Visibility,
1100 to_device: Affine,
1101 device_box: Rect,
1102 diags: &mut Diagnostics,
1103) {
1104 let rect = outer_rect(device_box);
1105 let (Ok(w), Ok(h)) = (u32::try_from(rect.width()), u32::try_from(rect.height())) else {
1106 return;
1107 };
1108 if w == 0 || h == 0 || w > MAX_TARGET_DIMENSION || h > MAX_TARGET_DIMENSION {
1109 // Too large to buffer: fall back to the ordinary walk rather than
1110 // drawing nothing.
1111 render_object_list(
1112 ctx, device, backend, caches, objects, children, to_device, device_box, diags,
1113 );
1114 return;
1115 }
1116
1117 // Each object is composited against this, never against its predecessors.
1118 let mut result: Option<crate::pixmap::Pixmap> = None;
1119 let offset = Affine::translate((-f64::from(rect.left), -f64::from(rect.top)));
1120 let inner_box = Rect::new(0.0, 0.0, f64::from(w), f64::from(h));
1121
1122 for (index, object) in objects.iter().enumerate() {
1123 if !children.visible(index) {
1124 continue;
1125 }
1126 let mut sub = backend.new_target(w, h, peniko::Color::TRANSPARENT);
1127 render_object(
1128 ctx,
1129 &mut sub,
1130 backend,
1131 caches,
1132 object,
1133 children,
1134 offset * to_device,
1135 inner_box,
1136 diags,
1137 );
1138 let drawn = backend.finish(sub);
1139 match &mut result {
1140 None => result = Some(drawn),
1141 Some(acc) => acc.knockout_over(&drawn),
1142 }
1143 }
1144
1145 if let Some(pixels) = result {
1146 device.draw_image(
1147 &pixels,
1148 Affine::translate((f64::from(rect.left), f64::from(rect.top))),
1149 ImageQuality::Nearest,
1150 1.0,
1151 );
1152 }
1153}
1154
1155/// The options a form `XObject`'s contents render under.
1156///
1157/// One rule: a **live edit's** appearance draws its text with `ClearType`, and
1158/// every other form draws under the options it inherited.
1159///
1160/// That is where the oracle puts the decision too: on a page whose every
1161/// other run is grayscale, the text of the field being edited — and no other
1162/// text — carries subpixel antialiasing. The scope of the override is one
1163/// subtree, the same shape [`RenderOptions::for_type3_char_proc`] already
1164/// uses to force options around a single glyph procedure.
1165///
1166/// A caller's own [`RenderOptions::text_aa_override`] **wins**: it was set
1167/// deliberately for this render, where the flag on the object is a property of
1168/// the document. That ordering also makes the whole thing testable without a
1169/// document — see this module's tests.
1170fn form_options(opts: &RenderOptions, live_edit: bool) -> RenderOptions {
1171 if live_edit && opts.text_aa_override.is_none() {
1172 opts.for_text_run(crate::options::TextAa::LcdSubpixel)
1173 } else {
1174 opts.clone()
1175 }
1176}
1177
1178/// Resolve one object's fill and stroke colours.
1179fn colors(
1180 ctx: &RenderCtx<'_>,
1181 state: &pdfrum_page::GraphicsState,
1182 kind: ObjectKind,
1183) -> (crate::color::Argb, crate::color::Argb) {
1184 crate::walkprofile::phase(crate::walkprofile::Phase::Color, || {
1185 colors_inner(ctx, state, kind)
1186 })
1187}
1188
1189/// [`colors`] without the phase timer around it.
1190fn colors_inner(
1191 ctx: &RenderCtx<'_>,
1192 state: &pdfrum_page::GraphicsState,
1193 kind: ObjectKind,
1194) -> (crate::color::Argb, crate::color::Argb) {
1195 let transfer = state
1196 .general
1197 .transfer
1198 .as_ref()
1199 .map(|t| TransferFunc::new(t));
1200 // A type-3 char proc imposes its caller's colour on every uncoloured
1201 // operation, which is what makes a `d1` glyph take the text object's
1202 // colour rather than black.
1203 let fill = match ctx.type3 {
1204 Some(frame) if !frame.colored || state.fill.to_rgb().is_none() => frame.fill,
1205 _ => resolve_argb(
1206 &state.fill,
1207 state.general.fill_alpha,
1208 transfer.as_ref(),
1209 ctx.initial_fill,
1210 &ctx.opts,
1211 kind,
1212 false,
1213 ),
1214 };
1215 // `Type3CharMissingStrokeColor`: an uncoloured (`d1`) procedure, or a
1216 // coloured one that never set a stroke, takes the outer text fill for
1217 // strokes too: `t3_fill_color_`, not the stroke the char proc left
1218 // unset. Filling from the frame and stroking from `resolve_argb` is
1219 // what left an uncoloured glyph's outlines black.
1220 let stroke = match ctx.type3 {
1221 Some(frame) if !frame.colored || state.stroke.to_rgb().is_none() => frame.fill,
1222 _ => resolve_argb(
1223 &state.stroke,
1224 state.general.stroke_alpha,
1225 transfer.as_ref(),
1226 ctx.initial_stroke,
1227 &ctx.opts,
1228 kind,
1229 true,
1230 ),
1231 };
1232 (fill, stroke)
1233}
1234
1235/// Paint one object's geometry with the pattern its fill or stroke colour
1236/// names.
1237///
1238/// The uncoloured colour is resolved here rather than in `pattern.rs` because
1239/// it is a *colour* question — the `scn` operands read through the pattern
1240/// space's base — and because its two fallbacks differ by paint type: a
1241/// coloured tiling pattern whose colour will not resolve falls back to mid
1242/// grey, everything else to white.
1243#[expect(
1244 clippy::too_many_arguments,
1245 reason = "painting a pattern needs the context, device, backend, caches, \
1246 the object's state, which colour names it, its geometry, and \
1247 the page transform"
1248)]
1249fn paint_pattern<B: RasterBackend>(
1250 ctx: &RenderCtx<'_>,
1251 device: &mut B::Device,
1252 backend: &B,
1253 caches: &mut RenderCaches,
1254 state: &pdfrum_page::GraphicsState,
1255 stroking: bool,
1256 geometry: &PatternClip<'_>,
1257 to_device: Affine,
1258 device_box: Rect,
1259 diags: &mut Diagnostics,
1260) {
1261 let color = if stroking { &state.stroke } else { &state.fill };
1262 let Some(value) = color.pattern.as_ref() else {
1263 return;
1264 };
1265 let Some(pattern) = value.loaded.as_ref() else {
1266 return;
1267 };
1268 let alpha = if stroking {
1269 state.general.stroke_alpha
1270 } else {
1271 state.general.fill_alpha
1272 };
1273 let colored_tiling = matches!(&**pattern, pdfrum_page::Pattern::Tiling(t) if t.colored);
1274 let space = color
1275 .space
1276 .as_ref()
1277 .map_or(&pdfrum_page::ColorSpace::DeviceGray, |s| &**s);
1278 let rgb = pdfrum_page::uncolored_pattern_rgb(space, &value.components, colored_tiling);
1279 let [r, g, b] = rgb.to_bytes();
1280 let uncolored = Argb {
1281 a: alpha_byte_truncating(alpha),
1282 r,
1283 g,
1284 b,
1285 };
1286 crate::pattern::draw(
1287 ctx, device, backend, caches, pattern, geometry, to_device, device_box, alpha, uncolored,
1288 diags,
1289 );
1290}
1291
1292#[expect(
1293 clippy::too_many_arguments,
1294 reason = "the pattern arm needs the caches, device box and diagnostics the \
1295 ordinary draw does not"
1296)]
1297fn render_path<B: RasterBackend>(
1298 ctx: &RenderCtx<'_>,
1299 device: &mut B::Device,
1300 backend: &B,
1301 caches: &mut RenderCaches,
1302 object: &pdfrum_page::PathObject,
1303 state: &pdfrum_page::GraphicsState,
1304 to_device: Affine,
1305 device_box: Rect,
1306 diags: &mut Diagnostics,
1307) {
1308 let (fill, stroke) = colors(ctx, state, ObjectKind::Path);
1309 let mut fills = object.fill_rule != pdfrum_page::FillRule::None;
1310 let mut strokes = object.stroke;
1311
1312 // `ProcessPathPattern` runs *before* the ordinary draw and **drains**
1313 // pattern colours out of it: a pattern fill is painted by the pattern
1314 // machinery and the fill type is then set to none, and likewise for a
1315 // stroke, so a path with both is drawn twice and the residual ordinary
1316 // draw does nothing at all.
1317 //
1318 // The draining stands even where the pattern does not resolve. A pattern
1319 // colour has no components, so `to_rgb` reports none and the colour falls
1320 // back to black — which would paint a `scn`-with-no-paint-operator
1321 // rectangle solid black across the whole page where the oracle draws
1322 // nothing.
1323 let matrix = to_device * object.matrix;
1324 if fills && state.fill.is_pattern() {
1325 fills = false;
1326 paint_pattern(
1327 ctx,
1328 device,
1329 backend,
1330 caches,
1331 state,
1332 false,
1333 &PatternClip::Path {
1334 path: &object.path,
1335 to_device: matrix,
1336 stroking: false,
1337 rule: object.fill_rule.into(),
1338 stroke: &state.stroke_params,
1339 },
1340 to_device,
1341 device_box,
1342 diags,
1343 );
1344 }
1345 if strokes && state.stroke.is_pattern() {
1346 strokes = false;
1347 paint_pattern(
1348 ctx,
1349 device,
1350 backend,
1351 caches,
1352 state,
1353 true,
1354 &PatternClip::Path {
1355 path: &object.path,
1356 to_device: matrix,
1357 stroking: true,
1358 rule: object.fill_rule.into(),
1359 stroke: &state.stroke_params,
1360 },
1361 to_device,
1362 device_box,
1363 diags,
1364 );
1365 }
1366 if !fills && !strokes {
1367 return;
1368 }
1369
1370 // Under a forced colour scheme a fill may be converted into a stroke
1371 // wholesale — the only place the two swap roles.
1372 let (fills, strokes) = if matches!(ctx.opts.color_mode, crate::options::ColorMode::Forced(_))
1373 && ctx.opts.convert_fill_to_stroke
1374 && fills
1375 {
1376 (false, true)
1377 } else {
1378 (fills, strokes)
1379 };
1380 let paint = PathPaint {
1381 fill: fills.then_some(fill),
1382 stroke: strokes.then_some(stroke),
1383 rule: object.fill_rule.into(),
1384 text_mode: false,
1385 };
1386 draw_path(
1387 device,
1388 backend,
1389 &object.path,
1390 // `PathObject::matrix` is already the CTM in force when the path was
1391 // emitted — `pdfrum-page` folds every enclosing form's matrix into
1392 // it — so only the page-to-device transform is added here. Composing
1393 // `state.ctm` as well would apply the CTM twice.
1394 to_device * object.matrix,
1395 paint,
1396 &state.stroke_params,
1397 &ctx.opts,
1398 &mut caches.zero_area,
1399 );
1400}
1401
1402/// A run whose fill is a pattern and which is not stroked.
1403///
1404/// No glyph is drawn. The run's **bounding rectangle** becomes a path object
1405/// carrying the text's own colour and general state, and the run itself is
1406/// appended to a copy of the current clip path; that object then goes through
1407/// the ordinary single-object render, where the pattern machinery paints the
1408/// rectangle and the text clip cuts it back to the glyph shapes.
1409///
1410/// Two things follow from it being a *clip* rather than a mask, and both are
1411/// visible: the glyphs are filled winding whatever the run's own mode said,
1412/// and a run whose rectangle is empty paints nothing at all.
1413#[expect(
1414 clippy::too_many_arguments,
1415 reason = "a synthetic path object needs everything the real one does"
1416)]
1417fn render_pattern_text<B: RasterBackend>(
1418 ctx: &RenderCtx<'_>,
1419 device: &mut B::Device,
1420 backend: &B,
1421 caches: &mut RenderCaches,
1422 object: &pdfrum_page::TextObject,
1423 state: &pdfrum_page::GraphicsState,
1424 to_device: Affine,
1425 device_box: Rect,
1426 diags: &mut Diagnostics,
1427) {
1428 let Some(rect) = crate::text::run_rect(object, state) else {
1429 return;
1430 };
1431 // `path.mutable_clip_path().CopyClipPath(last_clip_path_)` then
1432 // `AppendTexts(&pCopy)`: the synthetic object's clip is the current one
1433 // plus this run. The *current* half is already on the device — the caller
1434 // pushed the text object's stack before dispatching here — so only the
1435 // run's own contribution is added, and only it is popped.
1436 let mut clip = pdfrum_page::ClipStack::new();
1437 if clip
1438 .push_text(vec![pdfrum_page::TextClipRun {
1439 object: object.clone(),
1440 char_space: state.text.char_space,
1441 word_space: state.text.word_space,
1442 }])
1443 .is_err()
1444 {
1445 return;
1446 }
1447 // The colour and general state are the text's; the clip is *not* carried
1448 // on the state, because it is pushed on the device around the draw rather
1449 // than resolved again inside it.
1450 let synthetic = pdfrum_page::GraphicsState {
1451 clip: pdfrum_page::ClipStack::new(),
1452 ctm: Affine::IDENTITY,
1453 ..state.clone()
1454 };
1455 // `RenderSingleObject` pushes the object's clip before drawing it, and the
1456 // whole point of this path is the clip it just built — so the clip has to
1457 // be pushed here rather than left to the caller, which has already pushed
1458 // the *text* object's stack and moved on.
1459 let clips = clip::resolve(&clip, to_device, &mut caches.glyphs, &ctx.opts);
1460 let pushed = clip::push(device, &clips);
1461 render_path::<B>(
1462 ctx,
1463 device,
1464 backend,
1465 caches,
1466 &pdfrum_page::PathObject {
1467 path: kurbo::Shape::to_path(&rect, 0.1),
1468 matrix: Affine::IDENTITY,
1469 fill_rule: pdfrum_page::FillRule::Winding,
1470 stroke: false,
1471 },
1472 &synthetic,
1473 to_device,
1474 device_box,
1475 diags,
1476 );
1477 clip::pop(device, pushed);
1478}
1479
1480/// A run whose colour is a pattern and which **is** stroked.
1481///
1482/// Where the unstroked arm replaces the run with its bounding rectangle, this
1483/// one keeps the glyphs: each outline becomes a path object of its own,
1484/// carrying the run's colour and stroke state, and goes through the ordinary
1485/// single-object render — so the pattern machinery paints each glyph's fill,
1486/// its stroke, or both, exactly as it would for a hand-written path.
1487///
1488/// The outline is handed over already in device space with an identity object
1489/// matrix, matching `SetPathMatrix(CFX_Matrix())`: the run's own text matrix
1490/// is folded into the glyph placement rather than left for `ProcessPath` to
1491/// apply. The stroke-CTM split the ordinary stroked-text path performs is
1492/// deliberately absent — `DrawTextPathWithPattern` composes `mtTextMatrix`
1493/// alone and never consults `text_state().GetCTM()`.
1494#[expect(
1495 clippy::too_many_arguments,
1496 reason = "a synthetic path object needs everything the real one does, plus \
1497 the paint kinds the run resolved to"
1498)]
1499fn render_pattern_text_stroked<B: RasterBackend>(
1500 ctx: &RenderCtx<'_>,
1501 device: &mut B::Device,
1502 backend: &B,
1503 caches: &mut RenderCaches,
1504 object: &pdfrum_page::TextObject,
1505 state: &pdfrum_page::GraphicsState,
1506 to_device: Affine,
1507 device_box: Rect,
1508 diags: &mut Diagnostics,
1509 kinds: crate::text::TextPaintKinds,
1510) {
1511 // The bitmap path is a fill-only optimisation and this arm always strokes,
1512 // so the placement is asked for outlines. The buffer is the session's,
1513 // lent out and given back, as in `render_text`.
1514 let mut glyphs = std::mem::take(&mut caches.placed_glyphs);
1515 crate::text::place_glyphs_into(
1516 &mut glyphs,
1517 object,
1518 state,
1519 &mut caches.glyphs,
1520 to_device,
1521 &ctx.opts,
1522 kinds,
1523 );
1524 // `path.set_filltype(fill ? kWinding : kNoFill)`: a stroke-only run
1525 // contributes no fill, and the pattern machinery reads the rule to decide
1526 // whether to paint one.
1527 let fill_rule = if kinds.fill {
1528 pdfrum_page::FillRule::Winding
1529 } else {
1530 pdfrum_page::FillRule::None
1531 };
1532 for glyph in &glyphs {
1533 let path = glyph.device_path();
1534 render_path::<B>(
1535 ctx,
1536 device,
1537 backend,
1538 caches,
1539 &pdfrum_page::PathObject {
1540 path,
1541 matrix: Affine::IDENTITY,
1542 fill_rule,
1543 stroke: true,
1544 },
1545 state,
1546 to_device,
1547 device_box,
1548 diags,
1549 );
1550 }
1551 caches.placed_glyphs = glyphs;
1552}
1553
1554#[expect(
1555 clippy::too_many_arguments,
1556 reason = "the type-3 arm needs the device box and diagnostics the ordinary \
1557 glyph draw does not"
1558)]
1559fn render_text<B: RasterBackend>(
1560 ctx: &RenderCtx<'_>,
1561 device: &mut B::Device,
1562 backend: &B,
1563 caches: &mut RenderCaches,
1564 object: &pdfrum_page::TextObject,
1565 state: &pdfrum_page::GraphicsState,
1566 to_device: Affine,
1567 device_box: Rect,
1568 diags: &mut Diagnostics,
1569) {
1570 let Some((font, _)) = &object.font else {
1571 return;
1572 };
1573 let Some(kinds) = paint_kinds(object.render_mode, has_face(font)) else {
1574 return;
1575 };
1576 if !kinds.fill && !kinds.stroke {
1577 return; // Tr 7 contributes only to the clip, which the stack owns.
1578 }
1579 // A type-3 font has no outlines to fill: each character is a content
1580 // stream, walked with the same machinery a form is.
1581 if font.type3().is_some() {
1582 render_type3_text(
1583 ctx, device, backend, caches, object, state, to_device, device_box, diags,
1584 );
1585 return;
1586 }
1587 // A pattern-coloured glyph run goes to `DrawTextPathWithPattern`, which
1588 // returns before the ordinary draw. The gate is upstream's `bPattern`:
1589 // the colour the run will actually use — the stroke colour for a stroked
1590 // run, the fill colour for a filled one — being a pattern.
1591 //
1592 // The two arms differ in what they hand the pattern machinery. Unstroked,
1593 // no glyph is drawn at all: the run becomes a synthetic path object of its
1594 // own bounding rectangle, with the run appended to a copy of the current
1595 // clip path, so the pattern paints the rectangle and the text clip cuts it
1596 // back to the glyph shapes. Stroked, the glyphs are kept and each outline
1597 // becomes a path object in its own right.
1598 let pattern_run =
1599 (kinds.stroke && state.stroke.is_pattern()) || (kinds.fill && state.fill.is_pattern());
1600 if pattern_run {
1601 if kinds.stroke {
1602 render_pattern_text_stroked(
1603 ctx, device, backend, caches, object, state, to_device, device_box, diags, kinds,
1604 );
1605 } else {
1606 render_pattern_text(
1607 ctx, device, backend, caches, object, state, to_device, device_box, diags,
1608 );
1609 }
1610 return;
1611 }
1612 let (fill, stroke) = colors(ctx, state, ObjectKind::Text);
1613 // `TextObject::matrix` already carries the CTM, so only the
1614 // page-to-device transform is added — composing `state.ctm` again would
1615 // apply it twice.
1616 // The session's placement buffer, lent out for this object and given back
1617 // below. `take` rather than a borrow because the loop needs `caches`
1618 // mutably again — for the bitmap cache and for the zero-area scratch — and
1619 // two mutable borrows of one record do not coexist. The buffer left behind
1620 // is empty, so a re-entrant walk (a form inside this text object, which
1621 // cannot happen, or a future caller for which it could) gets a valid empty
1622 // buffer rather than the one being iterated.
1623 let mut glyphs = std::mem::take(&mut caches.placed_glyphs);
1624 crate::walkprofile::phase(crate::walkprofile::Phase::Glyphs, || {
1625 crate::text::place_glyphs_into(
1626 &mut glyphs,
1627 object,
1628 state,
1629 &mut caches.glyphs,
1630 to_device,
1631 &ctx.opts,
1632 kinds,
1633 );
1634 });
1635 // `DrawTextPath` keeps the post-split text matrix on the path and the
1636 // CTM on the device matrix. `place_glyphs` has already placed into that
1637 // text space when stroking; this is the remaining transform, computed
1638 // once for the run.
1639 let stroke_device = kinds
1640 .stroke
1641 .then(|| stroke_text_matrices(object, state, to_device).1);
1642 for glyph in &glyphs {
1643 // The oracle's small-text path: an alpha bitmap, blitted whole, rather
1644 // than an outline filled where it lands. It is the majority of the
1645 // text in the corpus, and reproducing it is what closes the coverage
1646 // band. When it declines — an unhintable face is fine, but a glyph
1647 // too large or too degenerate to rasterize is not — the oracle skips
1648 // the glyph outright (`if (!glyph.glyph_) continue;`), and so does
1649 // this.
1650 if let Some(placement) = glyph.bitmap
1651 && kinds.fill
1652 && !kinds.stroke
1653 {
1654 draw_glyph_bitmap(
1655 device,
1656 GlyphBlitCaches {
1657 bitmaps: &mut caches.glyph_bitmaps,
1658 scratch: &mut caches.glyph_blit,
1659 },
1660 font,
1661 glyph,
1662 placement,
1663 fill,
1664 ctx.opts.effective_text_aa(),
1665 );
1666 continue;
1667 }
1668 let paint = PathPaint {
1669 fill: kinds.fill.then_some(fill),
1670 stroke: kinds.stroke.then_some(stroke),
1671 rule: crate::device::FillRule::Winding,
1672 // The flag that keeps a glyph stem out of the zero-area
1673 // hairline conversion.
1674 text_mode: true,
1675 };
1676 if let Some(device_m) = stroke_device {
1677 // The outline is already in the space line width is measured in;
1678 // composing `device_m * glyph.matrix` back into one transform
1679 // would put font-size/1000 into the stroke scale again.
1680 let path = glyph.device_path();
1681 draw_path(
1682 device,
1683 backend,
1684 &path,
1685 device_m,
1686 paint,
1687 &state.stroke_params,
1688 &ctx.opts,
1689 &mut caches.zero_area,
1690 );
1691 } else {
1692 draw_path(
1693 device,
1694 backend,
1695 &glyph.outline,
1696 glyph.matrix,
1697 paint,
1698 &state.stroke_params,
1699 &ctx.opts,
1700 &mut caches.zero_area,
1701 );
1702 }
1703 }
1704 // Back to the session, with its capacity, for the next text object.
1705 caches.placed_glyphs = glyphs;
1706}
1707
1708/// The two session-owned pieces a glyph blit needs: the bitmaps it may already
1709/// have rasterized, and the buffers it fills for this occurrence.
1710///
1711/// One argument rather than two because they are drawn from the same
1712/// [`crate::ctx::RenderCaches`] and are handed on together.
1713struct GlyphBlitCaches<'a> {
1714 bitmaps: &'a mut crate::glyph::BitmapCache,
1715 scratch: &'a mut crate::ctx::GlyphBlitScratch,
1716}
1717
1718/// Blit one glyph as an alpha bitmap.
1719///
1720/// The bitmap is rasterized about the glyph's **own** origin — the matrix's
1721/// translation is dropped — so that one bitmap serves the glyph wherever it
1722/// lands on the page, which is what makes the cache worth having and what makes
1723/// its key match the oracle's. The placement is then two integers and a phase.
1724///
1725/// The outline it rasterizes is the **hinted** one where the face has hinting
1726/// programs and a table directory, falling back to the unhinted outline
1727/// otherwise, because that is exactly the oracle's `!IsTtOt()` rule plus its
1728/// `FT_LOAD_PEDANTIC` retry.
1729fn draw_glyph_bitmap(
1730 device: &mut dyn RenderDevice,
1731 caches: GlyphBlitCaches<'_>,
1732 font: &pdfrum_font::Font,
1733 glyph: &crate::text::PlacedGlyph,
1734 placement: crate::text::BitmapPlacement,
1735 fill: Argb,
1736 text_aa: crate::options::TextAa,
1737) {
1738 if fill.is_invisible() {
1739 return;
1740 }
1741 // The glyph's shape in device pixels, with its origin at zero. Dropping the
1742 // translation is what the key's four coefficients already assume.
1743 let [a, b, c, d, _, _] = glyph.matrix.as_coeffs();
1744 let shape = Affine::new([a, b, c, d, 0.0, 0.0]);
1745 let key = crate::glyph::BitmapKey::new(glyph.key, shape);
1746
1747 // The bitmap side reads its *own* skew and embolden level, resolved
1748 // against the device matrix (`CFX_Face::RenderGlyph`, `cfx_face.cpp:769`
1749 // and `:806`), where the outline the glyph cache holds was adjusted with
1750 // the path side's (`LoadGlyphPath`, `:869` and `:886`). So the two are
1751 // asked separately and this one is applied here, in device pixels.
1752 //
1753 // `xx` and `xy` are the oracle's 16.16 `matrix.a / 64 * 65536` and
1754 // `matrix.c / 64 * 65536`; ours differ only in that `shape` maps 1000/em
1755 // rather than ems, which the ×1000 undoes.
1756 let ft = |v: f64| {
1757 #[expect(
1758 clippy::cast_possible_truncation,
1759 reason = "the C++ stores these in an FT_Fixed and truncates too; a \
1760 matrix past i32 belongs to a glyph the coordinate rule \
1761 has already rejected"
1762 )]
1763 let fixed = (v * 1000.0 / 64.0 * 65536.0) as i32;
1764 fixed
1765 };
1766 // A weight of 1400 or more is past the render table, where the C++ returns
1767 // a negative level and `RenderGlyph` bails out with no bitmap at all.
1768 let Some(synth) = (if glyph.fallback {
1769 font.glyph_fallback()
1770 .and_then(|fb| fb.render_synth(ft(a), ft(c), font.is_vertical()))
1771 } else {
1772 font.render_synth(ft(a), ft(c))
1773 }) else {
1774 return;
1775 };
1776
1777 let GlyphBlitCaches { bitmaps, scratch } = caches;
1778 let Some(lcd) = bitmaps.get_or_insert(key, || {
1779 // A hinted outline is worth up to ten counts a pixel at 6 pt and costs
1780 // a bytecode run, so it is requested only here — on a cache miss — and
1781 // never on the outline path, which the oracle also draws unhinted.
1782 //
1783 // The fallback is the cached outline, which already carries the *path*
1784 // side's adjustments — so it takes only the shear-and-dilate-free
1785 // mapping into device space, and `synth` is applied to the hinted
1786 // outline alone.
1787 let hinted = if glyph.fallback {
1788 font.glyph_fallback()
1789 .and_then(|fb| fb.hinted_path(glyph.key.gid))
1790 } else {
1791 font.hinted_glyph_path(glyph.key.gid)
1792 };
1793 match hinted {
1794 Some(hinted) => crate::glyph::render_lcd(&synth.apply(shape * hinted)),
1795 None => crate::glyph::render_lcd(&(shape * (*glyph.outline).clone())),
1796 }
1797 }) else {
1798 return;
1799 };
1800 // Both spellings place the bitmap the same way, so the corner is computed
1801 // once: the snapped origin plus FreeType's box, both whole numbers, so
1802 // there is nothing to resample either way.
1803 let corner = |left: i32, top: i32| {
1804 (
1805 placement.origin.x + f64::from(left),
1806 placement.origin.y + f64::from(top),
1807 )
1808 };
1809 if matches!(text_aa, crate::options::TextAa::LcdSubpixel) {
1810 // `normalize = false`: the triples stay apart and each becomes one
1811 // destination channel's coverage. It needs its own device call because
1812 // three alphas do not fit in one RGBA pixel.
1813 let bitmap = lcd.to_subpixel(placement.phase);
1814 if bitmap.is_empty() {
1815 return;
1816 }
1817 device.draw_glyph_lcd(&bitmap, corner(bitmap.left, bitmap.top), fill.to_peniko());
1818 return;
1819 }
1820 // Both halves write into buffers the session owns rather than allocating
1821 // per glyph occurrence: the bytes are this glyph's and this colour's and
1822 // are rewritten in full, so only the memory is reused.
1823 let (left, top) = (lcd.left, lcd.top);
1824 if !crate::glyph::recolour_glyph_into(&lcd, placement.phase, fill.to_peniko(), scratch) {
1825 return;
1826 }
1827 // `draw_image` maps the image's own pixel grid, so a plain translation puts
1828 // texel (0, 0) at the bitmap's top-left corner.
1829 device.draw_image(
1830 &scratch.pixels,
1831 Affine::translate(corner(left, top)),
1832 ImageQuality::Nearest,
1833 1.0,
1834 );
1835}
1836
1837/// An uncoloured Type 3 procedure whose one object is a stencil image.
1838///
1839/// `LoadBitmapFromSoleImageOfForm` lifts that image out and blits it as an
1840/// 8bpp mask in the text colour, dropping any clip the procedure accumulated.
1841/// Walking it as a char proc instead keeps the clip and places the image on
1842/// the fractional CTM, which is `bug_1746` and `type3.pdf`.
1843#[must_use]
1844fn sole_stencil(objects: &[PageObject]) -> Option<&pdfrum_page::ImageObject> {
1845 match objects {
1846 [PageObject::Image(content)]
1847 if content.object.is_mask || content.object.image.samples.is_stencil() =>
1848 {
1849 Some(&content.object)
1850 }
1851 _ => None,
1852 }
1853}
1854
1855/// An uncoloured procedure whose one object is a **colour** image.
1856///
1857/// `LoadBitmapFromSoleImageOfForm` realises that bitmap and `SetBitMask`
1858/// tints it with the outer text fill, so luminance is coverage rather than
1859/// a picture. The same snap, nearest-neighbour and dropped-clip as a
1860/// stencil sole-image.
1861#[must_use]
1862fn sole_color_image(objects: &[PageObject]) -> Option<&pdfrum_page::ImageObject> {
1863 match objects {
1864 [PageObject::Image(content)]
1865 if !content.object.is_mask && !content.object.image.samples.is_stencil() =>
1866 {
1867 Some(&content.object)
1868 }
1869 _ => None,
1870 }
1871}
1872
1873/// Draw a Type 3 sole-image the integer-stretch path could not handle.
1874///
1875/// A sheared image, a stencil with empty padding rows, and a colour image
1876/// all miss that path. This is the same snapped, nearest-neighbour image
1877/// draw as before: translation rounds to whole pixels, and a clip the
1878/// procedure accumulated is dropped with the form.
1879#[expect(
1880 clippy::too_many_arguments,
1881 reason = "the sole-image blit needs the same walk arguments as a char proc, \
1882 plus the image, the snapped matrix and the outer fill"
1883)]
1884#[expect(
1885 clippy::many_single_char_names,
1886 reason = "a..f are the affine matrix coefficients, named as in the PDF `cm` operands"
1887)]
1888fn render_type3_sole_stencil<B: RasterBackend>(
1889 ctx: &RenderCtx<'_>,
1890 device: &mut B::Device,
1891 backend: &B,
1892 caches: &mut RenderCaches,
1893 image: &pdfrum_page::ImageObject,
1894 state: &pdfrum_page::GraphicsState,
1895 char_to_device: Affine,
1896 fill: Argb,
1897 device_box: Rect,
1898 diags: &mut Diagnostics,
1899) {
1900 let [a, b, c, d, e, f] = char_to_device.as_coeffs();
1901 let snapped = Affine::new([a, b, c, d, e.round(), f.round()]);
1902 let inner = RenderCtx {
1903 opts: crate::options::RenderOptions {
1904 no_image_smooth: true,
1905 force_halftone: true,
1906 rect_aa: true,
1907 ..ctx.opts.clone()
1908 },
1909 type3: Some(crate::ctx::Type3Frame {
1910 fill,
1911 colored: false,
1912 }),
1913 initial_fill: Some(fill),
1914 initial_stroke: Some(fill),
1915 ..ctx.deeper()
1916 };
1917 render_image::<B>(
1918 &inner, device, backend, caches, image, state, snapped, device_box, diags,
1919 );
1920}
1921
1922/// Draw one type-3 text object, one glyph procedure at a time.
1923///
1924/// Four contracts, each of which changes pixels:
1925///
1926/// - **The colour comes from the *outer* text object**, and a `d1`
1927/// (uncoloured) procedure takes it for every drawing operation inside,
1928/// whatever colours the procedure sets. A `d0` (coloured) one keeps what it
1929/// sets and falls back to the text object's only where it sets none. That
1930/// is the [`Type3Frame`] the child context carries.
1931/// - **`bForceHalftone` and `bRectAA` are forced on**, and the second is
1932/// visible: `bRectAA` disables the axis-aligned rect snapping, so a
1933/// rectangle inside a glyph *is* antialiased where the same rectangle on the
1934/// page would not be.
1935/// - **A translucent procedure goes through its own buffer**, blitted with a
1936/// plain normal blend and no group semantics, so the procedure's own
1937/// overlapping strokes do not accumulate alpha against each other.
1938/// - **The recursion guard is a set of font identities, not a depth.** A font
1939/// may not appear twice anywhere in the ancestry, so a glyph that shows
1940/// text in its own font draws nothing rather than recursing sixty-four
1941/// levels first.
1942#[expect(
1943 clippy::too_many_arguments,
1944 reason = "a glyph procedure needs the context, device, backend, caches, \
1945 the text object, its state, and the page transform"
1946)]
1947fn render_type3_text<B: RasterBackend>(
1948 ctx: &RenderCtx<'_>,
1949 device: &mut B::Device,
1950 backend: &B,
1951 caches: &mut RenderCaches,
1952 object: &pdfrum_page::TextObject,
1953 state: &pdfrum_page::GraphicsState,
1954 to_device: Affine,
1955 device_box: Rect,
1956 diags: &mut Diagnostics,
1957) {
1958 let Some((font, _)) = &object.font else {
1959 return;
1960 };
1961 // The guard is by font identity and is checked before anything is drawn.
1962 if ctx.type3_font_is_active(font.id()) || !ctx.may_recurse() {
1963 return;
1964 }
1965 // `GetFillArgbForType3` skips the type-3 branch, so this is the outer
1966 // object's own colour even inside a nested procedure.
1967 let fill = resolve_argb(
1968 &state.fill,
1969 state.general.fill_alpha,
1970 state
1971 .general
1972 .transfer
1973 .as_ref()
1974 .map(|t| TransferFunc::new(t))
1975 .as_ref(),
1976 ctx.initial_fill,
1977 &ctx.opts,
1978 ObjectKind::Text,
1979 false,
1980 );
1981 if fill.is_invisible() {
1982 // A pattern-coloured run resolves to the `0xFFFFFFFF` sentinel, which
1983 // `GetFillArgbForType3` turns into a zero ARGB. There is no
1984 // `DrawTextPathWithPattern` for a type-3 run, so this is where such a
1985 // run stops: the glyphs paint nothing at all.
1986 return;
1987 }
1988 let mut ancestry: Vec<pdfrum_font::FontId> = ctx.type3_fonts.to_vec();
1989 ancestry.push(font.id());
1990
1991 // Sole-image stencils collect into one blit so overlapping glyphs share
1992 // a coverage plane. Anything else flushes first.
1993 let mut pending: Vec<crate::type3::PlacedMask> = Vec::new();
1994 for placed in crate::text::place_type3_chars(object, state, to_device) {
1995 let Some(metrics) = object.type3_metrics.get(&placed.code) else {
1996 continue;
1997 };
1998 if metrics.objects.is_empty() || !is_available_matrix(placed.matrix) {
1999 continue;
2000 }
2001 draw_type3_glyph(
2002 ctx,
2003 device,
2004 backend,
2005 caches,
2006 &mut pending,
2007 metrics,
2008 &placed,
2009 state,
2010 fill,
2011 &ancestry,
2012 device_box,
2013 diags,
2014 );
2015 }
2016 blit_type3_batch(device, &mut pending, fill);
2017}
2018
2019#[expect(
2020 clippy::too_many_arguments,
2021 reason = "one glyph's dispatch needs the walk, the pending stencil batch, \
2022 and the char-proc ancestry"
2023)]
2024fn draw_type3_glyph<B: RasterBackend>(
2025 ctx: &RenderCtx<'_>,
2026 device: &mut B::Device,
2027 backend: &B,
2028 caches: &mut RenderCaches,
2029 pending: &mut Vec<crate::type3::PlacedMask>,
2030 metrics: &pdfrum_page::Type3Metrics,
2031 placed: &crate::text::PlacedType3Char,
2032 state: &pdfrum_page::GraphicsState,
2033 fill: Argb,
2034 ancestry: &[pdfrum_font::FontId],
2035 device_box: Rect,
2036 diags: &mut Diagnostics,
2037) {
2038 if !metrics.colored {
2039 if let Some(image) = sole_stencil(&metrics.objects) {
2040 if let Some(mask) = type3_stencil_glyph(
2041 backend,
2042 caches,
2043 image,
2044 placed.matrix,
2045 ancestry.last().copied().unwrap_or(pdfrum_font::FontId(0)),
2046 ) {
2047 pending.push(mask);
2048 return;
2049 }
2050 blit_type3_batch(device, pending, fill);
2051 render_type3_sole_stencil(
2052 ctx,
2053 device,
2054 backend,
2055 caches,
2056 image,
2057 state,
2058 placed.matrix,
2059 fill,
2060 device_box,
2061 diags,
2062 );
2063 return;
2064 }
2065 if let Some(image) = sole_color_image(&metrics.objects) {
2066 blit_type3_batch(device, pending, fill);
2067 let tinted = pdfrum_page::ImageObject {
2068 image: Arc::new(color_as_fill_mask(&image.image, fill)),
2069 matrix: image.matrix,
2070 is_mask: false,
2071 oc: image.oc.clone(),
2072 source: image.source,
2073 };
2074 render_type3_sole_stencil(
2075 ctx,
2076 device,
2077 backend,
2078 caches,
2079 &tinted,
2080 state,
2081 placed.matrix,
2082 fill,
2083 device_box,
2084 diags,
2085 );
2086 return;
2087 }
2088 }
2089 blit_type3_batch(device, pending, fill);
2090 let inner = RenderCtx {
2091 opts: ctx.opts.for_type3_char_proc(),
2092 type3: Some(crate::ctx::Type3Frame {
2093 fill,
2094 colored: metrics.colored,
2095 }),
2096 type3_fonts: ancestry,
2097 initial_fill: Some(fill),
2098 initial_stroke: Some(fill),
2099 ..ctx.deeper()
2100 };
2101 if fill.a == 255 {
2102 render_object_list(
2103 &inner,
2104 device,
2105 backend,
2106 caches,
2107 &metrics.objects,
2108 &Visibility::all_visible(), // the font's objects, not the page's
2109 placed.matrix,
2110 device_box,
2111 diags,
2112 );
2113 return;
2114 }
2115 render_translucent_char_proc(
2116 &inner, device, backend, caches, metrics, placed, fill, device_box, diags,
2117 );
2118}
2119
2120fn type3_stencil_glyph<B: RasterBackend>(
2121 backend: &B,
2122 caches: &mut RenderCaches,
2123 image: &pdfrum_page::ImageObject,
2124 char_to_device: Affine,
2125 font: pdfrum_font::FontId,
2126) -> Option<crate::type3::PlacedMask> {
2127 let blues = caches.type3_blues.for_font_matrix(font, char_to_device);
2128 crate::type3::try_stretch(backend, image, char_to_device, blues)
2129 .and_then(|glyph| glyph.place(char_to_device))
2130}
2131
2132fn blit_type3_batch<D: crate::device::RenderDevice>(
2133 device: &mut D,
2134 pending: &mut Vec<crate::type3::PlacedMask>,
2135 fill: Argb,
2136) {
2137 crate::type3::blit_batch(device, pending, fill);
2138 pending.clear();
2139}
2140
2141/// One type-3 glyph procedure drawn through its own buffer.
2142///
2143/// A translucent glyph cannot paint straight onto the page: its procedure may
2144/// overlap itself, and compositing each stroke at the object's alpha would
2145/// darken the overlaps. So the procedure runs into a buffer sized to the
2146/// glyph's device extent, and the buffer is blitted once.
2147///
2148/// **The alpha rides on the procedure's own fill colour, and the blit is
2149/// opaque.** That is one factor of the object's alpha, applied once — but
2150/// *where* it is applied is the whole of it: painting the procedure opaquely
2151/// and scaling at the blit instead is also one factor, and gives a different
2152/// answer wherever the procedure covers a pixel partially. A half-covered edge
2153/// pixel painted at alpha 128 and blitted opaquely keeps that 128; painted
2154/// opaquely to coverage 128 and blitted at half alpha becomes 64. The oracle
2155/// does the former, and `bug_1746` — whose glyph is a fax-coded image mask
2156/// under `ca 0.5` — is where the two visibly disagree.
2157///
2158/// **The buffer is sized from the objects' own extent, not the declared
2159/// `/FontBBox`.** A `d1` box is its operands scaled by a thousand and put
2160/// through the font matrix; with the conventional thousandth matrix the two
2161/// cancel, and with an identity `/FontMatrix` they do not — `bug_1746`'s box
2162/// lands at x 8050 on a 200-pixel page, so the buffer misses the glyph
2163/// entirely and nothing is drawn at all.
2164#[expect(
2165 clippy::too_many_arguments,
2166 reason = "the same inputs the opaque path takes, plus the placed glyph and \
2167 the colour its alpha is read from"
2168)]
2169fn render_translucent_char_proc<B: RasterBackend>(
2170 ctx: &RenderCtx<'_>,
2171 device: &mut B::Device,
2172 backend: &B,
2173 caches: &mut RenderCaches,
2174 metrics: &pdfrum_page::Type3Metrics,
2175 placed: &crate::text::PlacedType3Char,
2176 fill: Argb,
2177 device_box: Rect,
2178 diags: &mut Diagnostics,
2179) {
2180 let bbox = placed
2181 .matrix
2182 .transform_rect_bbox(metrics.painted)
2183 .intersect(device_box);
2184 let rect = outer_rect(bbox).intersect(outer_rect(device_box));
2185 let (Ok(w), Ok(h)) = (u32::try_from(rect.width()), u32::try_from(rect.height())) else {
2186 return;
2187 };
2188 if w == 0 || h == 0 || w > MAX_TARGET_DIMENSION || h > MAX_TARGET_DIMENSION {
2189 return;
2190 }
2191 let mut sub = backend.new_target(w, h, peniko::Color::TRANSPARENT);
2192 let offset = Affine::translate((-f64::from(rect.left), -f64::from(rect.top)));
2193 let translucent = crate::ctx::Type3Frame {
2194 fill,
2195 colored: metrics.colored,
2196 };
2197 let inner = RenderCtx {
2198 type3: Some(translucent),
2199 initial_fill: Some(fill),
2200 initial_stroke: Some(fill),
2201 ..ctx.clone()
2202 };
2203 render_object_list(
2204 &inner,
2205 &mut sub,
2206 backend,
2207 caches,
2208 &metrics.objects,
2209 &Visibility::all_visible(), // the font's objects, not the page's
2210 offset * placed.matrix,
2211 Rect::new(0.0, 0.0, f64::from(w), f64::from(h)),
2212 diags,
2213 );
2214 let pixels = backend.finish(sub);
2215 device.draw_image(
2216 &pixels,
2217 Affine::translate((f64::from(rect.left), f64::from(rect.top))),
2218 ImageQuality::Nearest,
2219 1.0,
2220 );
2221}
2222
2223/// The resample quality a stencil-as-mask is drawn at.
2224///
2225/// A thin wrapper over [`resample_quality`] that guards the destination
2226/// extent, which the ordinary image path guards separately.
2227#[expect(
2228 clippy::cast_possible_truncation,
2229 reason = "`image_value_fits` rejects a non-finite extent and anything at \
2230 or above MAX_IMAGE_VALUE (2^28), so both rounded values are \
2231 well inside i64"
2232)]
2233fn mask_quality(
2234 image: &pdfrum_page::ImageData,
2235 opts: &RenderOptions,
2236 extent: Rect,
2237 matrix: Affine,
2238) -> ImageQuality {
2239 if !crate::image::image_value_fits(extent.width())
2240 || !crate::image::image_value_fits(extent.height())
2241 {
2242 return ImageQuality::Nearest;
2243 }
2244 resample_quality_at(
2245 image,
2246 opts,
2247 image.width,
2248 image.height,
2249 extent.width().round() as i64,
2250 extent.height().round() as i64,
2251 matrix,
2252 )
2253}
2254
2255/// Paint a stencil whose fill colour is a pattern.
2256///
2257/// The pattern is drawn into its own buffer over the stencil's device extent,
2258/// and the **stencil becomes that buffer's alpha** — so the pattern shows
2259/// through the set bits and nothing shows through the clear ones. Painting a
2260/// pattern colour through the ordinary image path instead paints the
2261/// pattern's *fallback* colour, which for a coloured tiling pattern is mid
2262/// grey.
2263///
2264/// The object's alpha is deliberately **not** applied at the blit: unlike
2265/// `DrawMaskedImage`, the pattern path has already consumed it inside the
2266/// tiling cell's inherited state or the shading's rounded alpha.
2267#[expect(
2268 clippy::too_many_arguments,
2269 reason = "the stencil path needs the context, device, backend, caches, the \
2270 image, its state and the page transform"
2271)]
2272fn render_pattern_stencil<B: RasterBackend>(
2273 ctx: &RenderCtx<'_>,
2274 device: &mut B::Device,
2275 backend: &B,
2276 caches: &mut RenderCaches,
2277 object: &pdfrum_page::ImageObject,
2278 state: &pdfrum_page::GraphicsState,
2279 to_device: Affine,
2280 device_box: Rect,
2281 diags: &mut Diagnostics,
2282) {
2283 if !ctx.may_recurse() {
2284 return;
2285 }
2286 let matrix = to_device * object.matrix;
2287 let bbox = matrix
2288 .transform_rect_bbox(unit_rect())
2289 .intersect(device_box);
2290 let rect = outer_rect(bbox).intersect(outer_rect(device_box));
2291 let (Ok(w), Ok(h)) = (u32::try_from(rect.width()), u32::try_from(rect.height())) else {
2292 return;
2293 };
2294 if w == 0 || h == 0 || w > MAX_TARGET_DIMENSION || h > MAX_TARGET_DIMENSION {
2295 return;
2296 }
2297 let offset = Affine::translate((-f64::from(rect.left), -f64::from(rect.top)));
2298 let inner_box = Rect::new(0.0, 0.0, f64::from(w), f64::from(h));
2299
2300 // The pattern, over the stencil's whole extent.
2301 let mut pattern_target = backend.new_target(w, h, peniko::Color::TRANSPARENT);
2302 let inner = RenderCtx { ..ctx.deeper() };
2303 paint_pattern(
2304 &inner,
2305 &mut pattern_target,
2306 backend,
2307 caches,
2308 state,
2309 false,
2310 // An image object clips by its transformed bounding box, which here is
2311 // the whole buffer.
2312 &PatternClip::Rect(inner_box),
2313 offset * to_device,
2314 inner_box,
2315 diags,
2316 );
2317 let mut pixels = backend.finish(pattern_target);
2318
2319 // The stencil, rasterized on the same grid so no resampling is needed
2320 // when it becomes the alpha. Its set bits are opaque white; the readback
2321 // takes the alpha channel, which is exactly the coverage.
2322 // The stencil is drawn as a coverage mask for a pattern, so its colour
2323 // is a placeholder and no transfer function applies to it.
2324 let stencil = to_pixmap(&object.image, Argb::opaque(255, 255, 255), None);
2325 if stencil.width() == 0 || stencil.height() == 0 {
2326 return;
2327 }
2328 let placement = offset
2329 * matrix
2330 * Affine::new([
2331 1.0 / f64::from(object.image.width),
2332 0.0,
2333 0.0,
2334 -1.0 / f64::from(object.image.height),
2335 0.0,
2336 1.0,
2337 ]);
2338 // The mask goes through the ordinary image renderer, so it gets the
2339 // ordinary resample selection — which is what puts a soft edge on a
2340 // scaled-up stencil rather than a hard one.
2341 let extent = placement.transform_rect_bbox(Rect::new(
2342 0.0,
2343 0.0,
2344 f64::from(object.image.width),
2345 f64::from(object.image.height),
2346 ));
2347 let quality = mask_quality(&object.image, &ctx.opts, extent, matrix);
2348 let (stencil, placement) =
2349 match crate::stretch::prescale(&stencil, placement, extent.width(), extent.height()) {
2350 Some((reduced, t)) => (reduced, t),
2351 None => (stencil, placement),
2352 };
2353 let mut mask_target = backend.new_target(w, h, peniko::Color::TRANSPARENT);
2354 mask_target.draw_image(
2355 &stencil,
2356 placement,
2357 effective_quality(quality, placement),
2358 1.0,
2359 );
2360 let mask = backend.finish(mask_target).alpha_mask();
2361 pixels.multiply_alpha_mask(&mask);
2362
2363 let blend = overprint_blend(object.image.family, &state.general);
2364 let layered = !matches!(blend, pdfrum_page::BlendMode::Normal);
2365 if layered {
2366 device.push_layer(blend, 1.0, None);
2367 }
2368 device.draw_image(
2369 &pixels,
2370 Affine::translate((f64::from(rect.left), f64::from(rect.top))),
2371 ImageQuality::Nearest,
2372 1.0,
2373 );
2374 if layered {
2375 device.pop();
2376 }
2377}
2378
2379#[expect(
2380 clippy::too_many_arguments,
2381 reason = "the pattern-stencil arm needs the backend, caches, device box \
2382 and diagnostics the ordinary image draw does not"
2383)]
2384fn render_image<B: RasterBackend>(
2385 ctx: &RenderCtx<'_>,
2386 device: &mut B::Device,
2387 backend: &B,
2388 caches: &mut RenderCaches,
2389 object: &pdfrum_page::ImageObject,
2390 state: &pdfrum_page::GraphicsState,
2391 to_device: Affine,
2392 device_box: Rect,
2393 diags: &mut Diagnostics,
2394) {
2395 let matrix = to_device * object.matrix;
2396 if !is_available_matrix(matrix) {
2397 return;
2398 }
2399 // `DrawPatternImage`: a stencil whose fill colour is a pattern paints the
2400 // *pattern* through the stencil, not a colour. The ordinary path would
2401 // paint the pattern's fallback colour — mid grey for a coloured tiling
2402 // one — across every set bit.
2403 if object.is_mask && state.fill.is_pattern() {
2404 render_pattern_stencil(
2405 ctx, device, backend, caches, object, state, to_device, device_box, diags,
2406 );
2407 return;
2408 }
2409 if uses_draw_masked_image(&object.image) {
2410 render_masked_image(ctx, device, backend, object, state, to_device, device_box);
2411 return;
2412 }
2413 let (fill, _) = colors(ctx, state, ObjectKind::Other);
2414 let image = &object.image;
2415 // `StartRenderDIBBase` runs a non-identity `/TR` over the image's own
2416 // samples, not only over the fill colour a stencil takes.
2417 let transfer = state
2418 .general
2419 .transfer
2420 .as_ref()
2421 .map(|t| TransferFunc::new(t));
2422 // The pixmap is the same for every draw of this image at this size, and
2423 // producing it is `O(source pixels)` twice over — so the geometry is
2424 // settled *first* and the pixels asked for last, from the cache. A
2425 // zero-dimension image produces a zero-dimension pixmap, which is what the
2426 // old `pixels.width() == 0` guard was testing; asking the image directly
2427 // says the same thing without decoding 25 million samples to find out.
2428 if image.width == 0 || image.height == 0 {
2429 return;
2430 }
2431 if draw_sheared_image(
2432 ctx,
2433 device,
2434 caches,
2435 object,
2436 state,
2437 matrix,
2438 device_box,
2439 fill,
2440 transfer.as_ref(),
2441 ) {
2442 return;
2443 }
2444 draw_snapped_image::<B>(ctx, device, caches, object, state, matrix);
2445}
2446
2447/// Stretch an axis-aligned image onto its device rect, reduce it if the
2448/// destination is smaller than the source, and blit.
2449#[expect(
2450 clippy::cast_possible_truncation,
2451 reason = "`image_value_fits` has already rejected a non-finite extent and \
2452 anything at or above MAX_IMAGE_VALUE (2^28), so both rounded \
2453 values are well inside i64"
2454)]
2455fn draw_snapped_image<B: RasterBackend>(
2456 ctx: &RenderCtx<'_>,
2457 device: &mut B::Device,
2458 caches: &mut RenderCaches,
2459 object: &pdfrum_page::ImageObject,
2460 state: &pdfrum_page::GraphicsState,
2461 matrix: Affine,
2462) {
2463 let image = &object.image;
2464 let (fill, _) = colors(ctx, state, ObjectKind::Other);
2465 let transfer = state
2466 .general
2467 .transfer
2468 .as_ref()
2469 .map(|t| TransferFunc::new(t));
2470 let transfer = transfer.as_ref();
2471 // The image's unit square maps through the object matrix, so the device
2472 // transform folds in the sample grid's own size and the y flip PDF's
2473 // image space needs.
2474 let placement = matrix
2475 * Affine::new([
2476 1.0 / f64::from(image.width),
2477 0.0,
2478 0.0,
2479 -1.0 / f64::from(image.height),
2480 0.0,
2481 1.0,
2482 ]);
2483 let corners = placement.transform_rect_bbox(Rect::new(
2484 0.0,
2485 0.0,
2486 f64::from(image.width),
2487 f64::from(image.height),
2488 ));
2489 if !crate::image::image_value_fits(corners.width())
2490 || !crate::image::image_value_fits(corners.height())
2491 {
2492 return;
2493 }
2494 let quality = resample_quality_at(
2495 image,
2496 &ctx.opts,
2497 image.width,
2498 image.height,
2499 corners.width().round() as i64,
2500 corners.height().round() as i64,
2501 matrix,
2502 );
2503 // A reduction is low-passed here rather than left to the backend's two-tap
2504 // kernel, which sees at most two of the many source pixels a shrunken
2505 // destination pixel covers. `Reduction` is that decision *and* the
2506 // placement it implies, taken once: where upstream's device-grid snap
2507 // applies the reduced pixmap already is the device pixels and never meets
2508 // the backend's sampler again, and where it does not the ceiled
2509 // footprint and the backend's kernel are what the draw keeps getting.
2510 let reduction = crate::stretch::reduction(
2511 placement,
2512 image.width,
2513 image.height,
2514 corners.width(),
2515 corners.height(),
2516 ctx.type3.is_none(),
2517 );
2518 let (out_w, out_h) = reduction.size(image.width, image.height);
2519 let placement = reduction.transform();
2520 // `to_pixmap` and the reduction are pure in `(image, fill, transfer, size)`
2521 // and were the largest single cost in the corpus, re-run on every render of
2522 // an image that had not changed. Cached
2523 // together, because a caller of one always wants the other: caching the
2524 // unreduced pixmap alone would keep the box filter running per draw *and*
2525 // hold the larger of the two buffers.
2526 let pixels = crate::walkprofile::phase(crate::walkprofile::Phase::Image, || {
2527 let key = crate::imagecache::PixmapRequest::for_image(image, fill, transfer, out_w, out_h);
2528 caches.images.get_or_render(object.source, key, || {
2529 if reduction.filters() {
2530 // The conversion and the reduction are one pull pipeline,
2531 // so neither the full-size RGBA pixmap nor the two
2532 // full-height intermediates are ever built.
2533 crate::stretch::convert_and_reduce(image, fill, transfer, out_w, out_h)
2534 } else {
2535 to_pixmap(image, fill, transfer)
2536 }
2537 })
2538 });
2539 let pixels = &*pixels;
2540 let blend = overprint_blend(image.family, &state.general);
2541 let layered = !matches!(blend, pdfrum_page::BlendMode::Normal);
2542 if layered {
2543 device.push_layer(blend, 1.0, None);
2544 }
2545 // The reduction lands on whole pixels, so when the placement left is a
2546 // whole-pixel translation the reduced pixmap *is* the device pixels and
2547 // the backend has nothing to resample. `Placement` says which case this
2548 // is; `Exact` cannot reach the filtered path because it does not carry a
2549 // transform to filter through.
2550 let placed = image_placement(placement, pixels, ctx.type3.is_some());
2551 device.draw_image(
2552 pixels,
2553 placed.transform_for(pixels.width(), pixels.height()),
2554 placed.quality(effective_quality(quality, placement)),
2555 state.general.fill_alpha,
2556 );
2557 if layered {
2558 device.pop();
2559 }
2560}
2561
2562/// Where one image draw lands, and on which grid.
2563///
2564/// Wraps [`crate::stretch::placement_for`] with the one case its geometry
2565/// cannot see: inside a type-3 char proc the target is a sub-bitmap whose
2566/// origin is the glyph's own outer rect, not the page's. Upstream's snap is
2567/// to the *device* integer grid (`cpdf_imagerenderer.cpp:658-664`), so
2568/// quantising there would land on the sub-target's grid and be requantised
2569/// again when that sub-target is blitted — two roundings where upstream has
2570/// one. `Exact` is unaffected: it is already a whole-pixel translation in
2571/// whichever target it was computed for.
2572fn image_placement(
2573 placement: Affine,
2574 pixels: &crate::pixmap::Pixmap,
2575 in_type3: bool,
2576) -> crate::stretch::Placement {
2577 match crate::stretch::placement_for(placement, pixels.width(), pixels.height()) {
2578 crate::stretch::Placement::Snapped(_) if in_type3 => {
2579 crate::stretch::Placement::Filtered(placement)
2580 }
2581 other => other,
2582 }
2583}
2584
2585/// Draw a sheared image by reverse-mapping dest pixels, then blit 1:1.
2586///
2587/// Returns `false` when the geometry is not drawable this way, so the caller
2588/// can fall through to the quad rasterizer.
2589#[expect(
2590 clippy::too_many_arguments,
2591 reason = "the walk threads context, device, caches, the image and the \
2592 transfer the hypot pixmap is keyed on"
2593)]
2594fn draw_sheared_image<D: RenderDevice>(
2595 ctx: &RenderCtx<'_>,
2596 device: &mut D,
2597 caches: &mut RenderCaches,
2598 object: &pdfrum_page::ImageObject,
2599 state: &pdfrum_page::GraphicsState,
2600 matrix: Affine,
2601 device_box: Rect,
2602 fill: Argb,
2603 transfer: Option<&TransferFunc>,
2604) -> bool {
2605 if !takes_other_transform(matrix) {
2606 return false;
2607 }
2608 let image = &object.image;
2609 let Some((hypot_w, hypot_h)) = crate::shear::hypot_size(matrix) else {
2610 return false;
2611 };
2612 let pass1 = resample_quality(
2613 image,
2614 &ctx.opts,
2615 image.width,
2616 image.height,
2617 i64::from(hypot_w),
2618 i64::from(hypot_h),
2619 );
2620 let key = crate::imagecache::PixmapRequest::for_image(image, fill, transfer, hypot_w, hypot_h);
2621 let src = caches
2622 .images
2623 .get_or_render(object.source, key, || to_pixmap(image, fill, transfer));
2624 let clip = outer_rect(device_box);
2625 let Some(mapped) = crate::shear::map_sheared(&src, matrix, clip, pass1) else {
2626 return false;
2627 };
2628 let blend = overprint_blend(image.family, &state.general);
2629 let layered = !matches!(blend, pdfrum_page::BlendMode::Normal);
2630 if layered {
2631 device.push_layer(blend, 1.0, None);
2632 }
2633 device.draw_image(
2634 &mapped.pixels,
2635 Affine::translate((f64::from(mapped.left), f64::from(mapped.top))),
2636 ImageQuality::Nearest,
2637 state.general.fill_alpha,
2638 );
2639 if layered {
2640 device.pop();
2641 }
2642 true
2643}
2644
2645/// Dest-space reverse-map of a base and its independent mask.
2646#[expect(
2647 clippy::too_many_arguments,
2648 reason = "the mask plane is a separate resolution from the base"
2649)]
2650fn draw_sheared_masked<D: RenderDevice>(
2651 ctx: &RenderCtx<'_>,
2652 device: &mut D,
2653 object: &pdfrum_page::ImageObject,
2654 state: &pdfrum_page::GraphicsState,
2655 matrix: Affine,
2656 device_box: Rect,
2657 mask_dict: &pdfrum_page::ImageData,
2658 mask_plane: &[u8],
2659) -> bool {
2660 if !takes_other_transform(matrix) {
2661 return false;
2662 }
2663 let image = &object.image;
2664 let Some((hypot_w, hypot_h)) = crate::shear::hypot_size(matrix) else {
2665 return false;
2666 };
2667 let (fill, _) = colors(ctx, state, ObjectKind::Other);
2668 let transfer = state
2669 .general
2670 .transfer
2671 .as_ref()
2672 .map(|t| TransferFunc::new(t));
2673 let base = to_pixmap(image, fill, transfer.as_ref());
2674 if base.width() == 0 || base.height() == 0 {
2675 return false;
2676 }
2677 let pass1 = resample_quality(
2678 image,
2679 &ctx.opts,
2680 image.width,
2681 image.height,
2682 i64::from(hypot_w),
2683 i64::from(hypot_h),
2684 );
2685 let mask_q = resample_quality(
2686 mask_dict,
2687 &ctx.opts,
2688 mask_dict.width,
2689 mask_dict.height,
2690 i64::from(hypot_w),
2691 i64::from(hypot_h),
2692 );
2693 let clip = outer_rect(device_box);
2694 let Some(mapped) = crate::shear::map_sheared(&base, matrix, clip, pass1) else {
2695 return false;
2696 };
2697 let Some((mask, mx, my)) = crate::shear::map_sheared_coverage(
2698 mask_plane,
2699 mask_dict.width,
2700 mask_dict.height,
2701 matrix,
2702 clip,
2703 mask_q,
2704 ) else {
2705 return false;
2706 };
2707 if mx != mapped.left
2708 || my != mapped.top
2709 || mask.width() != mapped.pixels.width()
2710 || mask.height() != mapped.pixels.height()
2711 {
2712 return false;
2713 }
2714 let mut pixels = mapped.pixels;
2715 fold_mask_and_blit(
2716 device,
2717 image,
2718 state,
2719 &mut pixels,
2720 &mask,
2721 mapped.left,
2722 mapped.top,
2723 );
2724 true
2725}
2726
2727/// `/Matte`, or a mask on a grid of its own, is a separate dest-space mask
2728/// multiply. A colour-key `/Mask` stays fused.
2729fn uses_draw_masked_image(image: &pdfrum_page::ImageData) -> bool {
2730 image.matte.is_some()
2731 || image
2732 .mask
2733 .as_ref()
2734 .is_some_and(|m| !crate::image::is_coregistered(m, image))
2735}
2736
2737/// Paint an image whose mask has a resolution of its own.
2738///
2739/// A mask is **never resolution-reduced**, so its
2740/// dimensions are its own and generally not the base's: `bug_1396266` puts a
2741/// 64×64 stencil on a 3×3 image, `bug_1236` a 100×100 `/SMask` on a 400×400
2742/// one. PDFium never reconciles the two grids. It renders the base into a
2743/// device-sized buffer, renders the mask into a second buffer over the same
2744/// device rect through the same matrix, and multiplies — so each is resampled
2745/// from its own resolution straight to the device and neither is ever sampled
2746/// at the other's coordinates.
2747///
2748/// Folding the mask into the base's pixels instead reads the wrong sample
2749/// everywhere the sizes differ, and — because
2750/// [`pdfrum_page::ImageMask::alpha_at`] reports out-of-range as opaque —
2751/// leaves the majority of a base larger than its mask completely unmasked.
2752fn render_masked_image<B: RasterBackend>(
2753 ctx: &RenderCtx<'_>,
2754 device: &mut B::Device,
2755 backend: &B,
2756 object: &pdfrum_page::ImageObject,
2757 state: &pdfrum_page::GraphicsState,
2758 to_device: Affine,
2759 device_box: Rect,
2760) {
2761 let image = &object.image;
2762 let Some(mask) = image.mask.as_ref() else {
2763 return;
2764 };
2765 let Some((mask_dict, mask_plane)) = crate::image::separate_mask(mask) else {
2766 return;
2767 };
2768 let matrix = to_device * object.matrix;
2769 if draw_sheared_masked(
2770 ctx,
2771 device,
2772 object,
2773 state,
2774 matrix,
2775 device_box,
2776 &mask_dict,
2777 &mask_plane,
2778 ) {
2779 return;
2780 }
2781 let bbox = matrix
2782 .transform_rect_bbox(unit_rect())
2783 .intersect(device_box);
2784 let rect = outer_rect(bbox).intersect(outer_rect(device_box));
2785 let (Ok(w), Ok(h)) = (u32::try_from(rect.width()), u32::try_from(rect.height())) else {
2786 return;
2787 };
2788 if w == 0 || h == 0 || w > MAX_TARGET_DIMENSION || h > MAX_TARGET_DIMENSION {
2789 return;
2790 }
2791 let offset = Affine::translate((-f64::from(rect.left), -f64::from(rect.top)));
2792
2793 // The base, over its own extent, with no mask folded in.
2794 let (fill, _) = colors(ctx, state, ObjectKind::Other);
2795 let transfer = state
2796 .general
2797 .transfer
2798 .as_ref()
2799 .map(|t| TransferFunc::new(t));
2800 let base = to_pixmap(image, fill, transfer.as_ref());
2801 if base.width() == 0 || base.height() == 0 {
2802 return;
2803 }
2804 let mut base_target = backend.new_target(w, h, peniko::Color::TRANSPARENT);
2805 let base_placement = offset * matrix * sample_grid(image.width, image.height);
2806 let base_extent = base_placement.transform_rect_bbox(Rect::new(
2807 0.0,
2808 0.0,
2809 f64::from(image.width),
2810 f64::from(image.height),
2811 ));
2812 let base_quality = mask_quality(image, &ctx.opts, base_extent, matrix);
2813 // The base and its mask are reduced independently, each toward its own
2814 // device footprint — which is the same footprint, reached from two
2815 // different resolutions. Neither ever passes through the other's grid.
2816 let (base, base_placement) = match crate::stretch::prescale(
2817 &base,
2818 base_placement,
2819 base_extent.width(),
2820 base_extent.height(),
2821 ) {
2822 Some((reduced, t)) => (reduced, t),
2823 None => (base, base_placement),
2824 };
2825 base_target.draw_image(
2826 &base,
2827 base_placement,
2828 effective_quality(base_quality, base_placement),
2829 1.0,
2830 );
2831 let mut pixels = backend.finish(base_target);
2832
2833 // The mask, over the *same* device rect through the *same* matrix, at its
2834 // own resolution — which is the whole point of the separate pass.
2835 let mask_placement = offset * matrix * sample_grid(mask_dict.width, mask_dict.height);
2836 let mask_extent = mask_placement.transform_rect_bbox(Rect::new(
2837 0.0,
2838 0.0,
2839 f64::from(mask_dict.width),
2840 f64::from(mask_dict.height),
2841 ));
2842 let mask_q = mask_quality(&mask_dict, &ctx.opts, mask_extent, matrix);
2843 // The reduction runs on the coverage plane and the pixmap is built from
2844 // the *reduced* plane — the same bytes prescaling the expanded pixmap
2845 // produces, over a quarter of the memory and, once the reduction fires, a
2846 // small fraction of the buffer. `crate::image::reduced_mask_pixmap` owns
2847 // that equivalence.
2848 let (mask_pixels, mask_placement) = crate::image::reduced_mask_pixmap(
2849 &mask_plane,
2850 mask_dict.width,
2851 mask_dict.height,
2852 mask_placement,
2853 mask_extent.width(),
2854 mask_extent.height(),
2855 );
2856 let mut mask_target = backend.new_target(w, h, peniko::Color::TRANSPARENT);
2857 mask_target.draw_image(
2858 &mask_pixels,
2859 mask_placement,
2860 effective_quality(mask_q, mask_placement),
2861 1.0,
2862 );
2863 let mask = backend.finish(mask_target).alpha_mask();
2864 fold_mask_and_blit(
2865 device,
2866 image,
2867 state,
2868 &mut pixels,
2869 &mask,
2870 rect.left,
2871 rect.top,
2872 );
2873}
2874
2875/// Fold coverage into the dest (matte then replace A, or multiply) and blit.
2876fn fold_mask_and_blit<D: RenderDevice>(
2877 device: &mut D,
2878 image: &pdfrum_page::ImageData,
2879 state: &pdfrum_page::GraphicsState,
2880 pixels: &mut Pixmap,
2881 mask: &crate::pixmap::AlphaMask,
2882 left: i32,
2883 top: i32,
2884) {
2885 if let Some(matte) = image.matte {
2886 // Dest RGB is premultiplied; `/Matte` reads it as a straight sample.
2887 // Recover the colour, un-premultiply against the stretched mask, then
2888 // let the mask replace dest alpha — scaling dest A by the mask would
2889 // count the base's coverage twice.
2890 apply_stretched_mask(pixels, mask, Some(matte.to_bytes()));
2891 } else {
2892 pixels.multiply_alpha_mask(mask);
2893 }
2894 let blend = overprint_blend(image.family, &state.general);
2895 let layered = !matches!(blend, pdfrum_page::BlendMode::Normal);
2896 if layered {
2897 device.push_layer(blend, 1.0, None);
2898 }
2899 device.draw_image(
2900 pixels,
2901 Affine::translate((f64::from(left), f64::from(top))),
2902 ImageQuality::Nearest,
2903 state.general.fill_alpha,
2904 );
2905 if layered {
2906 device.pop();
2907 }
2908}
2909
2910/// The transform from an image's sample grid to its unit square, with the y
2911/// flip PDF image space needs.
2912fn sample_grid(width: u32, height: u32) -> Affine {
2913 Affine::new([
2914 1.0 / f64::from(width),
2915 0.0,
2916 0.0,
2917 -1.0 / f64::from(height),
2918 0.0,
2919 1.0,
2920 ])
2921}
2922
2923fn render_shading<B: RasterBackend>(
2924 ctx: &RenderCtx<'_>,
2925 device: &mut B::Device,
2926 backend: &B,
2927 object: &pdfrum_page::ShadingObject,
2928 state: &pdfrum_page::GraphicsState,
2929 to_device: Affine,
2930 device_box: Rect,
2931) {
2932 let matrix = to_device * object.matrix;
2933 if !is_available_matrix(matrix) {
2934 return;
2935 }
2936 // A bare `sh` paints its whole clip region, with no geometry clip of its
2937 // own; the alpha here is **rounded**, unlike the truncation everywhere
2938 // else colour is resolved.
2939 let alpha = alpha_byte_rounding(state.general.fill_alpha);
2940 // A `sh` with no bounds of its own paints the whole device box.
2941 let mut bbox = if object.bounds.is_zero_area() {
2942 device_box
2943 } else {
2944 to_device.transform_rect_bbox(object.bounds)
2945 };
2946 if let Some(b) = object.shading.bbox {
2947 bbox = bbox.intersect(matrix.transform_rect_bbox(b));
2948 }
2949 let rect = outer_rect(bbox.intersect(device_box));
2950 if !rect.is_valid() {
2951 return;
2952 }
2953 draw_shading_into(ctx, device, backend, &object.shading, rect, matrix, alpha);
2954}
2955
2956/// Rasterize any of the seven shading types into `rect` and blit it.
2957///
2958/// The seventh and sixth need a rasterizer rather than a buffer, so they take
2959/// a scratch device; the other five are pure engine code. Both `sh` and a
2960/// shading *pattern* reach a shading exactly this way, and routing them
2961/// through one function is what keeps a Coons pattern from being the silent
2962/// no-op it was when only `sh` knew about the scratch path.
2963pub(crate) fn draw_shading_into<B: RasterBackend>(
2964 ctx: &RenderCtx<'_>,
2965 device: &mut B::Device,
2966 backend: &B,
2967 shading: &pdfrum_page::Shading,
2968 rect: IntRect,
2969 matrix: Affine,
2970 alpha: u8,
2971) {
2972 let at = Affine::translate((f64::from(rect.left), f64::from(rect.top)));
2973 match shading.kind() {
2974 pdfrum_page::ShadingKind::CoonsMesh | pdfrum_page::ShadingKind::TensorMesh => {
2975 let tensor = shading.kind() == pdfrum_page::ShadingKind::TensorMesh;
2976 let (Ok(w), Ok(h)) = (u32::try_from(rect.width()), u32::try_from(rect.height())) else {
2977 return;
2978 };
2979 if w == 0 || h == 0 || w > MAX_TARGET_DIMENSION || h > MAX_TARGET_DIMENSION {
2980 return;
2981 }
2982 // Every cell goes into the scratch at full opacity, so abutting
2983 // cells overpaint identically on both backends; the shading's
2984 // alpha is applied exactly once, at the blit below.
2985 let mut scratch = backend.new_target(w, h, peniko::Color::TRANSPARENT);
2986 // The mesh half of the shading phase. It was outside every bucket
2987 // until , which is why `shading_axial_radial`'s engine
2988 // residue looked like unattributed `pattern.rs` overhead: the
2989 // document draws 28368 patches through `fill_path` and none of the
2990 // engine-side work that decides them was counted anywhere. Like
2991 // `path prep`, the span covers the device calls it makes, which
2992 // the seam decorator has already charged to RASTER.
2993 crate::walkprofile::phase(crate::walkprofile::Phase::Patches, || {
2994 shading::draw_patches(&mut scratch, shading, rect, matrix, tensor);
2995 });
2996 let pixels = backend.finish(scratch);
2997 device.draw_image(&pixels, at, ImageQuality::Nearest, f32::from(alpha) / 255.0);
2998 }
2999 _ => {
3000 let Some(pixels) =
3001 crate::walkprofile::phase(crate::walkprofile::Phase::Shading, || {
3002 shading::draw_to_pixmap(shading, rect, matrix, alpha, &ctx.opts)
3003 })
3004 else {
3005 return;
3006 };
3007 device.draw_image(&pixels, at, ImageQuality::Nearest, 1.0);
3008 }
3009 }
3010}
3011
3012#[cfg(test)]
3013mod tests {
3014 use std::collections::BTreeMap;
3015
3016 use kurbo::BezPath;
3017 use pdfrum_page::ContentMarks;
3018 use pdfrum_page::{Content, GraphicsState, PathObject};
3019
3020 use super::*;
3021
3022 fn path_object(path: BezPath, state: GraphicsState) -> PageObject {
3023 PageObject::Path(Box::new(Content {
3024 object: PathObject {
3025 path,
3026 matrix: Affine::IDENTITY,
3027 fill_rule: pdfrum_page::FillRule::Winding,
3028 stroke: false,
3029 },
3030 state,
3031 marks: ContentMarks::new(),
3032 content_stream: Some(0),
3033 dirty: false,
3034 active: true,
3035 }))
3036 }
3037
3038 /// [`path_object`] with the object matrix the cull actually applies.
3039 fn path_object_with_matrix(path: BezPath, matrix: Affine) -> PageObject {
3040 PageObject::Path(Box::new(Content {
3041 object: PathObject {
3042 path,
3043 matrix,
3044 fill_rule: pdfrum_page::FillRule::Winding,
3045 stroke: false,
3046 },
3047 state: GraphicsState::default(),
3048 marks: ContentMarks::new(),
3049 content_stream: Some(0),
3050 dirty: false,
3051 active: true,
3052 }))
3053 }
3054
3055 fn rect(x0: f64, y0: f64, x1: f64, y1: f64) -> BezPath {
3056 let mut p = BezPath::new();
3057 p.move_to((x0, y0));
3058 p.line_to((x1, y0));
3059 p.line_to((x1, y1));
3060 p.line_to((x0, y1));
3061 p.close_path();
3062 p
3063 }
3064
3065 #[test]
3066 fn cull_uses_strict_inequalities() {
3067 let cull = Rect::new(0.0, 0.0, 10.0, 10.0);
3068 // Exactly touching the right edge is kept, not dropped.
3069 let touching = path_object(rect(10.0, 0.0, 20.0, 5.0), GraphicsState::default());
3070 assert!(!culled(&touching, cull));
3071 // Strictly beyond it is dropped.
3072 let beyond = path_object(rect(10.1, 0.0, 20.0, 5.0), GraphicsState::default());
3073 assert!(culled(&beyond, cull));
3074 }
3075
3076 /// The two-box bracket must never change a cull decision, only reach it
3077 /// sooner. Its specification is the exact box, so this compares against it
3078 /// over every path shape the cull can meet and every position of the clip
3079 /// relative to that shape — including the one the bracket exists to
3080 /// handle badly, a curve whose bulge crosses an edge its endpoints do not.
3081 #[test]
3082 fn the_cull_bracket_never_changes_the_answer() {
3083 let mut bulging = BezPath::new();
3084 bulging.move_to((0.0, 0.0));
3085 // Control points far above the curve, which peaks at y = 7.5.
3086 bulging.curve_to((0.0, 10.0), (10.0, 10.0), (10.0, 0.0));
3087
3088 let mut two_moves = BezPath::new();
3089 two_moves.move_to((100.0, 100.0)); // starts no segment
3090 two_moves.move_to((0.0, 0.0));
3091 two_moves.line_to((1.0, 1.0));
3092
3093 let mut bare_move = BezPath::new();
3094 bare_move.move_to((50.0, 50.0));
3095
3096 let mut move_close = BezPath::new();
3097 move_close.move_to((50.0, 50.0));
3098 move_close.close_path();
3099
3100 let shapes = [
3101 rect(0.0, 0.0, 10.0, 10.0),
3102 bulging,
3103 two_moves,
3104 bare_move,
3105 move_close,
3106 BezPath::new(),
3107 ];
3108 let matrices = [
3109 Affine::IDENTITY,
3110 Affine::translate((3.0, -4.0)),
3111 Affine::new([2.0, 0.5, -0.5, 2.0, 1.0, 1.0]),
3112 ];
3113 for shape in &shapes {
3114 for matrix in matrices {
3115 let object = path_object_with_matrix(shape.clone(), matrix);
3116 let exact = path_bbox(shape, matrix);
3117 // A grid of clips that slides across the shape a half unit at
3118 // a time, so every edge relation — clear, touching, straddling
3119 // — is exercised on both axes.
3120 let mut x = -12.0;
3121 while x < 14.0 {
3122 let mut y = -12.0;
3123 while y < 14.0 {
3124 let cull = Rect::new(x, y, x + 4.0, y + 4.0);
3125 assert_eq!(
3126 culled(&object, cull),
3127 outside(exact, cull),
3128 "bracket disagreed at {cull:?} on {shape:?} under {matrix:?}"
3129 );
3130 y += 0.5;
3131 }
3132 x += 0.5;
3133 }
3134 }
3135 }
3136 }
3137
3138 /// The two inputs the sliding-clip test above cannot reach: a clip of zero
3139 /// size, and a path carrying a coordinate that is not a number.
3140 ///
3141 /// The zero-size clip is the easy half — `outside` is unchanged by it, so
3142 /// a degenerate clip is only a rectangle like any other, and this pins
3143 /// that rather than assuming it.
3144 ///
3145 /// The NaN half is the one the fold has to get right. `f64::min` and
3146 /// `f64::max` drop a NaN operand and return the other, so a fold over a
3147 /// path with a NaN *endpoint* must not put that NaN into the inner box:
3148 /// `outside` compares with `>` and `<`, every comparison against a NaN
3149 /// being false, so `!outside(inner, cull)` would answer **keep** for every
3150 /// clip on the page. The exact box does not agree: kurbo's extrema solve
3151 /// drops the NaN the same way `min`/`max` do, so `path_bbox` is finite and
3152 /// `outside` answers it honestly. A bracket that kept where the exact
3153 /// test culled would change the answer, and that is the one thing this
3154 /// bracket may not do.
3155 ///
3156 /// The direction of a disagreement would be a *missed* cull, not a wrong
3157 /// one. No corpus file reaches it.
3158 #[test]
3159 fn a_degenerate_clip_and_a_non_finite_point_agree_with_the_exact_answer() {
3160 // A NaN on a drawn endpoint, which is what reaches the inner box. The
3161 // other coordinates are finite and far from the clips below, so the
3162 // exact box says "cull" clearly and the disagreement is unambiguous.
3163 let mut nan_endpoint = BezPath::new();
3164 nan_endpoint.move_to((100.0, 100.0));
3165 nan_endpoint.line_to((f64::NAN, 130.0));
3166
3167 // The same, with a finite endpoint after it, so the inner box is a
3168 // real rectangle with a NaN folded into it rather than a NaN point.
3169 let mut nan_midpoint = BezPath::new();
3170 nan_midpoint.move_to((100.0, 100.0));
3171 nan_midpoint.line_to((f64::NAN, 130.0));
3172 nan_midpoint.line_to((105.0, 105.0));
3173
3174 // A NaN control point, which reaches only the outer box. This one the
3175 // two always agreed on — kurbo drops it exactly as the fold does — and
3176 // it is here so a later reader can see that the guard is not what makes
3177 // them agree.
3178 let mut nan_control = BezPath::new();
3179 nan_control.move_to((100.0, 100.0));
3180 nan_control.curve_to((f64::NAN, 110.0), (120.0, 110.0), (130.0, 100.0));
3181
3182 let mut infinite_endpoint = BezPath::new();
3183 infinite_endpoint.move_to((100.0, 100.0));
3184 infinite_endpoint.line_to((f64::INFINITY, 100.0));
3185
3186 let mut ordinary = BezPath::new();
3187 ordinary.move_to((100.0, 100.0));
3188 ordinary.line_to((130.0, 130.0));
3189
3190 let shapes = [
3191 nan_endpoint.clone(),
3192 nan_midpoint,
3193 nan_control.clone(),
3194 infinite_endpoint.clone(),
3195 ordinary.clone(),
3196 rect(0.0, 0.0, 2.0, 2.0),
3197 ];
3198 for shape in &shapes {
3199 for matrix in [
3200 Affine::IDENTITY,
3201 Affine::new([2.0, 0.5, -0.5, 2.0, 1.0, 1.0]),
3202 ] {
3203 let object = path_object_with_matrix(shape.clone(), matrix);
3204 let exact = path_bbox(shape, matrix);
3205 // The clip the review asked for, plus a slide across the
3206 // region where the NaN paths' boxes actually live — the
3207 // disagreement was never at the origin.
3208 let mut x = -20.0;
3209 while x < 140.0 {
3210 let mut y = 90.0;
3211 while y < 140.0 {
3212 for (w, h) in [(0.0, 0.0), (30.0, 30.0)] {
3213 let cull = Rect::new(x, y, x + w, y + h);
3214 assert_eq!(
3215 culled(&object, cull),
3216 outside(exact, cull),
3217 "bracket disagreed at {cull:?} on {shape:?} under {matrix:?}"
3218 );
3219 }
3220 y += 2.5;
3221 }
3222 x += 2.5;
3223 }
3224 // The zero-size clip the review named, at the exact spot it
3225 // named it.
3226 let degenerate = Rect::new(1.0, 1.0, 1.0, 1.0);
3227 assert_eq!(
3228 culled(&object, degenerate),
3229 outside(exact, degenerate),
3230 "bracket disagreed on the degenerate clip"
3231 );
3232 }
3233 }
3234
3235 // And the mechanism directly, rather than only through the agreement
3236 // above: a non-finite coordinate declines the bracket, so it is the
3237 // exact test that answers.
3238 for declined in [&nan_endpoint, &nan_control, &infinite_endpoint] {
3239 assert!(
3240 path_cull_bounds(declined, Affine::IDENTITY).is_none(),
3241 "a non-finite coordinate must decline the bracket: {declined:?}"
3242 );
3243 }
3244 assert!(
3245 path_cull_bounds(&ordinary, Affine::IDENTITY).is_some(),
3246 "a finite path must still take the bracket"
3247 );
3248 // A finite path under a non-finite transform declines too: the check
3249 // is on the transformed point, which is the one the box is built from.
3250 assert!(
3251 path_cull_bounds(&ordinary, Affine::translate((f64::NAN, 0.0))).is_none(),
3252 "a non-finite matrix must decline the bracket"
3253 );
3254 }
3255
3256 #[test]
3257 fn the_allocation_free_bbox_reproduces_the_transformed_clone_exactly() {
3258 // The specification of `path_bbox` is the expression it replaced:
3259 // `(matrix * path.clone()).bounding_box()`. This is that expression,
3260 // required to agree at every shape the cull can meet — including the
3261 // curves, where a segment's box is solved rather than hulled, and the
3262 // segment-less paths, where kurbo answers the origin rather than
3263 // nothing.
3264 let mut curved = BezPath::new();
3265 curved.move_to((0.0, 0.0));
3266 curved.curve_to((10.0, 40.0), (30.0, -20.0), (40.0, 10.0));
3267 let mut quad = BezPath::new();
3268 quad.move_to((-3.0, 2.0));
3269 quad.quad_to((50.0, 60.0), (7.0, -8.0));
3270 let mut bare_move = BezPath::new();
3271 bare_move.move_to((5.0, 7.0));
3272
3273 let paths = [
3274 rect(1.0, 2.0, 3.0, 4.0),
3275 rect(-9.0, -9.0, -1.0, -1.0),
3276 curved,
3277 quad,
3278 bare_move,
3279 BezPath::new(),
3280 ];
3281 let matrices = [
3282 Affine::IDENTITY,
3283 Affine::scale(2.5),
3284 Affine::translate((13.0, -7.0)),
3285 Affine::rotate(0.7),
3286 Affine::scale_non_uniform(-1.0, 3.0),
3287 ];
3288 for path in &paths {
3289 for m in matrices {
3290 let want = (m * path.clone()).bounding_box();
3291 let got = path_bbox(path, m);
3292 assert_eq!(want, got, "path {path:?} under {m:?}");
3293 }
3294 }
3295 }
3296
3297 #[test]
3298 fn a_text_object_is_never_culled() {
3299 // Its extent needs the font's metrics, so declining the cull is the
3300 // safe answer — it is an optimisation, not a correctness rule.
3301 let obj = PageObject::Text(Box::new(Content {
3302 object: pdfrum_page::TextObject {
3303 segments: Box::new([]),
3304 position: kurbo::Point::ZERO,
3305 matrix: Affine::IDENTITY,
3306 font: None,
3307 font_source: None,
3308 render_mode: pdfrum_page::TextRenderMode::Fill,
3309 type3_metrics: BTreeMap::default(),
3310 },
3311 state: GraphicsState::default(),
3312 marks: ContentMarks::new(),
3313 content_stream: Some(0),
3314 dirty: false,
3315 active: true,
3316 }));
3317 assert!(!culled(&obj, Rect::new(1000.0, 1000.0, 1001.0, 1001.0)));
3318 }
3319
3320 #[test]
3321 fn a_degenerate_matrix_yields_no_cull_rect() {
3322 assert!(cull_rect(Affine::new([0.0; 6]), Rect::new(0.0, 0.0, 10.0, 10.0)).is_none());
3323 }
3324
3325 #[test]
3326 fn target_size_rejects_an_empty_page() {
3327 let mut page = Page::empty();
3328 page.crop_box = Rect::new(0.0, 0.0, 0.0, 0.0);
3329 page.media_box = page.crop_box;
3330 let err = target_size(&page, &RenderOptions::default()).expect_err("empty");
3331 assert!(matches!(err, Error::TargetEmpty { .. }));
3332 }
3333
3334 #[test]
3335 fn target_size_truncates_rather_than_rounding_up() {
3336 // A4 is 595.276 x 841.89 points; `pdfium_test` casts, so the bitmap
3337 // is 595x841. Rounding up costs a one-pixel border, which the
3338 // harness reports as a size mismatch rather than a pixel difference.
3339 let a4 = Rect::new(0.0, 0.0, 595.276, 841.89);
3340 let page = Page {
3341 media_box: a4,
3342 crop_box: a4,
3343 ..Page::empty()
3344 };
3345 assert_eq!(
3346 target_size(&page, &RenderOptions::default()).expect("renderable"),
3347 (595, 841)
3348 );
3349 }
3350
3351 #[test]
3352 fn the_page_matrix_fits_the_page_to_the_truncated_device_box() {
3353 // `GetDisplayMatrixForRect` divides the *integer* device rect by the
3354 // page's float size (`cpdf_page.cpp:216-218`), and
3355 // `CPDFSDK_RenderPageWithContext` passes it the truncated bitmap
3356 // size. So an A4 page 841.89 tall renders into 841 rows: page y = 0
3357 // lands on device row 841 and page y = 841.89 on row 0, exactly.
3358 let a4 = Rect::new(0.0, 0.0, 595.276, 841.89);
3359 let page = Page {
3360 media_box: a4,
3361 crop_box: a4,
3362 ..Page::empty()
3363 };
3364 let m = page_matrix(&page, &RenderOptions::default());
3365 let bottom = m * kurbo::Point::new(0.0, 0.0);
3366 let top = m * kurbo::Point::new(595.276, 841.89);
3367 assert!((bottom.y - 841.0).abs() < 1e-9, "page bottom at {bottom:?}");
3368 assert!((top.y - 0.0).abs() < 1e-9, "page top at {top:?}");
3369 assert!((top.x - 595.0).abs() < 1e-9, "page right at {top:?}");
3370 // Flipping about the float height instead leaves the top of the page
3371 // 0.89 device px out — invisible while glyphs were filled at their
3372 // true position, and a whole row once their origins are snapped.
3373 let midpage = m * kurbo::Point::new(0.0, 420.945);
3374 assert!((midpage.y - 420.5).abs() < 1e-9, "midpage at {midpage:?}");
3375 }
3376
3377 #[test]
3378 fn a_scaled_render_fits_the_page_to_its_own_truncated_box() {
3379 // The caller's transform is consumed in *sizing* the bitmap, exactly
3380 // as `pdfium_test` consumes `--scale`; the display matrix is then
3381 // built onto that size rather than composed on top of it.
3382 let a4 = Rect::new(0.0, 0.0, 595.276, 841.89);
3383 let page = Page {
3384 media_box: a4,
3385 crop_box: a4,
3386 ..Page::empty()
3387 };
3388 let opts = RenderOptions {
3389 transform: Affine::scale(2.0),
3390 ..RenderOptions::default()
3391 };
3392 let (w, h) = target_size(&page, &opts).expect("renderable");
3393 assert_eq!((w, h), (1190, 1683));
3394 let m = page_matrix(&page, &opts);
3395 let corner = m * kurbo::Point::new(595.276, 0.0);
3396 assert!((corner.x - 1190.0).abs() < 1e-9, "{corner:?}");
3397 assert!((corner.y - 1683.0).abs() < 1e-9, "{corner:?}");
3398 }
3399
3400 #[test]
3401 fn target_size_rejects_an_oversized_page() {
3402 let opts = RenderOptions {
3403 transform: Affine::scale(200.0),
3404 ..RenderOptions::default()
3405 };
3406 let err = target_size(&Page::empty(), &opts).expect_err("too large");
3407 assert!(matches!(
3408 err,
3409 Error::TargetTooLarge {
3410 limit: MAX_TARGET_DIMENSION,
3411 ..
3412 }
3413 ));
3414 }
3415
3416 #[test]
3417 fn crop_lifts_a_sub_rectangle() {
3418 let mut src = Pixmap::new(4, 4);
3419 src.set_pixel(2, 3, [1, 2, 3, 255]);
3420 let out = src.cropped(2, 2, 2, 2);
3421 assert_eq!((out.width(), out.height()), (2, 2));
3422 assert_eq!(out.pixel(0, 1), Some([1, 2, 3, 255]));
3423 }
3424
3425 #[test]
3426 fn crop_of_an_out_of_range_rect_is_transparent() {
3427 let src = Pixmap::filled(2, 2, peniko::Color::from_rgba8(9, 9, 9, 255));
3428 let out = src.cropped(10, 10, 2, 2);
3429 assert_eq!(out.pixel(0, 0), Some([0, 0, 0, 0]));
3430 }
3431
3432 #[test]
3433 fn only_a_live_edits_form_turns_clear_type_on() {
3434 use crate::options::TextAa;
3435 let page = RenderOptions::default();
3436 // An ordinary form — the file's own appearance, or any `Do` — inherits
3437 // the page's grayscale unchanged. This is the whole corpus.
3438 assert_eq!(
3439 form_options(&page, false).effective_text_aa(),
3440 TextAa::Grayscale
3441 );
3442 // The live edit's form, and only it, gets ClearType.
3443 assert_eq!(
3444 form_options(&page, true).effective_text_aa(),
3445 TextAa::LcdSubpixel
3446 );
3447 // And the page's own options are untouched either way, so the *next*
3448 // sibling form is grayscale again — the override is scoped to the
3449 // subtree, not latched for the rest of the page.
3450 assert_eq!(page.effective_text_aa(), TextAa::Grayscale);
3451 }
3452
3453 #[test]
3454 fn a_callers_own_text_aa_override_outranks_the_objects_flag() {
3455 use crate::options::TextAa;
3456 // A caller who asked for one thing for this whole render gets it: the
3457 // flag on the object describes the document, the override describes
3458 // the request, and the request is the more specific instruction.
3459 let forced = RenderOptions::default().for_text_run(TextAa::None);
3460 assert_eq!(
3461 form_options(&forced, true).effective_text_aa(),
3462 TextAa::None
3463 );
3464 }
3465
3466 #[test]
3467 fn the_live_edit_fold_changes_nothing_else_about_the_options() {
3468 use crate::options::TextAa;
3469 // The fold must be exactly one field. A form that quietly reset the
3470 // colour mode or the background would be a much larger bug than a
3471 // missing fringe, and would only show up on the one row that reaches
3472 // this branch.
3473 let page = RenderOptions {
3474 color_mode: crate::options::ColorMode::Gray,
3475 no_path_smooth: true,
3476 background: Some(peniko::Color::BLACK),
3477 ..RenderOptions::default()
3478 };
3479 let inner = form_options(&page, true);
3480 assert_eq!(inner.text_aa_override, Some(TextAa::LcdSubpixel));
3481 assert_eq!(inner.color_mode, page.color_mode);
3482 assert!(inner.no_path_smooth);
3483 assert_eq!(inner.background, page.background);
3484 assert_eq!(inner.text_aa, page.text_aa);
3485 }
3486}