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