Skip to main content

pdf_engine/
render.rs

1//! Page rendering with z-order compositing.
2//!
3//! Renders a PDF page to RGBA pixel data using the hayro rendering stack.
4
5use crate::color::{blend_cmyk, preserve_device_cmyk, rgba_to_cmyk_buffer};
6use kurbo::{Affine, BezPath, Point, Rect, Shape};
7use pdf_interpret::font::Glyph;
8use pdf_interpret::{
9    interpret_page, BlendMode, ClipPath, Context, Device, FillRule, GlyphDrawMode,
10    Image as PdfImage, InterpreterSettings, Paint, PathDrawMode, RasterImage, SoftMask,
11};
12use pdf_render::pdf_interpret::PageExt;
13use pdf_render::pdf_syntax::page::Page;
14use pdf_render::vello_cpu::color::palette::css::WHITE;
15use pdf_render::vello_cpu::color::{AlphaColor, Srgb};
16use pdf_render::vello_cpu::peniko::Fill as PenikoFill;
17use pdf_render::vello_cpu::{
18    Level, Pixmap, RenderContext, RenderMode, RenderSettings as CpuRenderSettings,
19};
20pub use pdf_render::RasterQuality;
21use pdf_render::{render, RenderSettings};
22
23const AXIS_EPSILON: f64 = 1e-5;
24
25/// Color space handling during rendering.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
27pub enum ColorMode {
28    /// Convert all colors to sRGB (default).
29    #[default]
30    Srgb,
31    /// Preserve exact DeviceCMYK values where the rasterizer can prove they survive unchanged.
32    PreserveCmyk,
33    /// Simulate CMYK ink on white paper via the embedded device-CMYK ICC profile.
34    SimulateCmyk,
35}
36
37/// Pixel layout of the rendered output buffer.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
39pub enum PixelFormat {
40    /// RGBA, 8 bits per channel, premultiplied alpha (default).
41    #[default]
42    Rgba8,
43    /// CMYK, 8 bits per channel (produced by [`ColorMode::PreserveCmyk`]).
44    Cmyk8,
45}
46
47/// High-level render configuration.
48///
49/// Combines color-mode policy and output DPI. For fine-grained control
50/// (forced dimensions, custom background colour) use [`RenderOptions`] directly.
51#[derive(Debug, Clone)]
52pub struct RenderConfig {
53    /// How CMYK colors are handled (default: [`ColorMode::Srgb`]).
54    pub color_mode: ColorMode,
55    /// Render resolution in dots per inch (default: 72).
56    pub dpi: u32,
57}
58
59impl Default for RenderConfig {
60    fn default() -> Self {
61        Self {
62            color_mode: ColorMode::default(),
63            dpi: 72,
64        }
65    }
66}
67
68impl From<&RenderConfig> for RenderOptions {
69    fn from(cfg: &RenderConfig) -> Self {
70        RenderOptions {
71            dpi: cfg.dpi as f64,
72            ..Default::default()
73        }
74    }
75}
76
77/// Options for rendering a page.
78#[derive(Debug, Clone)]
79pub struct RenderOptions {
80    /// Resolution in dots per inch (default: 72.0 = 1:1 with PDF points).
81    pub dpi: f64,
82    /// Background colour as `[r, g, b, a]` in 0.0..1.0 (default: opaque white).
83    pub background: [f32; 4],
84    /// Whether to render annotations (default: true).
85    pub render_annotations: bool,
86    /// Force output width in pixels (overrides DPI for width).
87    pub width: Option<u16>,
88    /// Force output height in pixels (overrides DPI for height).
89    pub height: Option<u16>,
90    /// Optional **opt-in** pixel budget. When `Some(n)` and the requested scale
91    /// would produce more than `n` pixels (width × height), the effective scale
92    /// is reduced proportionally so the output fits the budget. Default `None`
93    /// preserves the exact requested scale (byte-identical to prior behavior).
94    ///
95    /// This is an explicit, caller-controlled quality/performance trade-off — it
96    /// is never applied by default and the returned `RenderedPage` reports the
97    /// actual `width`/`height` so callers see the applied resolution.
98    pub max_pixels: Option<u32>,
99    /// Rasterization precision/speed trade-off (default [`RasterQuality::Quality`]).
100    ///
101    /// [`RasterQuality::Quality`] (default) uses the higher-precision f32
102    /// compositing pipeline and is **byte-identical** to historical output.
103    /// [`RasterQuality::Speed`] is an explicit opt-in: ~1.4–1.6× faster on
104    /// content-heavy pages, with sub-perceptual rounding differences where
105    /// blending/anti-aliasing/images compose. Never changes output by default.
106    pub quality: RasterQuality,
107}
108
109impl Default for RenderOptions {
110    fn default() -> Self {
111        Self {
112            dpi: 72.0,
113            background: [1.0, 1.0, 1.0, 1.0],
114            render_annotations: true,
115            width: None,
116            height: None,
117            max_pixels: None,
118            quality: RasterQuality::Quality,
119        }
120    }
121}
122
123/// A rendered page as pixel data.
124#[derive(Debug, Clone)]
125pub struct RenderedPage {
126    /// Width in pixels.
127    pub width: u32,
128    /// Height in pixels.
129    pub height: u32,
130    /// Pixel layout of the output buffer.
131    pub pixel_format: PixelFormat,
132    /// Pixel data, row-major, 4 bytes per pixel.
133    pub pixels: Vec<u8>,
134}
135
136struct CmykOverlay {
137    data: Vec<Option<[u8; 4]>>,
138    width: u32,
139    height: u32,
140}
141
142impl CmykOverlay {
143    fn new(width: u32, height: u32) -> Self {
144        Self {
145            data: vec![None; width as usize * height as usize],
146            width,
147            height,
148        }
149    }
150
151    fn apply_mask(&mut self, alpha_mask: &[u8], cmyk: [u8; 4]) {
152        for (slot, alpha) in self.data.iter_mut().zip(alpha_mask.iter().copied()) {
153            match alpha {
154                0 => {}
155                255 => *slot = Some(cmyk),
156                partial => {
157                    if let Some(existing) = *slot {
158                        *slot = Some(blend_cmyk(existing, cmyk, partial));
159                    } else {
160                        *slot = None;
161                    }
162                }
163            }
164        }
165    }
166
167    fn contaminate(&mut self, alpha_mask: &[u8]) {
168        for (slot, alpha) in self.data.iter_mut().zip(alpha_mask.iter().copied()) {
169            if alpha != 0 {
170                *slot = None;
171            }
172        }
173    }
174
175    fn set_exact_pixel(&mut self, x: u32, y: u32, cmyk: [u8; 4]) {
176        if x >= self.width || y >= self.height {
177            return;
178        }
179        let idx = y as usize * self.width as usize + x as usize;
180        self.data[idx] = Some(cmyk);
181    }
182
183    fn compose_with_rgba_fallback(self, rgba: &[u8]) -> Vec<u8> {
184        let mut out = rgba_to_cmyk_buffer(rgba);
185        for (idx, exact) in self.data.iter().enumerate() {
186            if let Some(exact) = exact {
187                let start = idx * 4;
188                out[start..start + 4].copy_from_slice(exact);
189            }
190        }
191        out
192    }
193}
194
195struct GroupState {
196    previous_opacity: f32,
197    unsupported: bool,
198}
199
200struct CmykOverlayDevice {
201    overlay: CmykOverlay,
202    clip_stack: Vec<ClipPath>,
203    current_opacity: f32,
204    current_soft_mask: bool,
205    current_blend_mode: BlendMode,
206    group_stack: Vec<GroupState>,
207    unsupported_depth: usize,
208    cpu_settings: CpuRenderSettings,
209}
210
211impl CmykOverlayDevice {
212    fn new(width: u16, height: u16) -> Self {
213        Self {
214            overlay: CmykOverlay::new(width as u32, height as u32),
215            clip_stack: Vec::new(),
216            current_opacity: 1.0,
217            current_soft_mask: false,
218            current_blend_mode: BlendMode::Normal,
219            group_stack: Vec::new(),
220            unsupported_depth: 0,
221            cpu_settings: CpuRenderSettings {
222                level: Level::new(),
223                num_threads: 0,
224                // f32 pipeline for the exact-CMYK mask path: keeps mask coverage
225                // byte-identical to historical output (both pipelines are compiled).
226                render_mode: RenderMode::OptimizeQuality,
227            },
228        }
229    }
230
231    fn finish(self) -> CmykOverlay {
232        self.overlay
233    }
234
235    fn exact_cmyk_for_paint(&self, paint: &Paint<'_>) -> Option<[u8; 4]> {
236        if !self.can_preserve_exact() {
237            return None;
238        }
239
240        match paint {
241            Paint::Color(color) => color
242                .device_cmyk_components()
243                .map(|[c, m, y, k]| preserve_device_cmyk(c, m, y, k)),
244            Paint::Pattern(_) => None,
245        }
246    }
247
248    fn paint_opacity(&self, paint: &Paint<'_>) -> f32 {
249        let local = match paint {
250            Paint::Color(color) => color.opacity(),
251            Paint::Pattern(_) => 1.0,
252        };
253        (local * self.current_opacity).clamp(0.0, 1.0)
254    }
255
256    fn can_preserve_exact(&self) -> bool {
257        self.unsupported_depth == 0
258            && !self.current_soft_mask
259            && self.current_blend_mode == BlendMode::Normal
260    }
261
262    fn handle_path_operation(
263        &mut self,
264        path: &BezPath,
265        transform: Affine,
266        paint: &Paint<'_>,
267        draw_mode: &PathDrawMode,
268        is_text: bool,
269    ) {
270        let alpha = self.paint_opacity(paint);
271        if alpha <= 0.0 {
272            return;
273        }
274
275        let mask = self.rasterize_path_mask(path, transform, draw_mode, alpha, is_text);
276        if let Some(cmyk) = self.exact_cmyk_for_paint(paint) {
277            self.overlay.apply_mask(&mask, cmyk);
278        } else {
279            self.overlay.contaminate(&mask);
280        }
281    }
282
283    fn rasterize_path_mask(
284        &self,
285        path: &BezPath,
286        transform: Affine,
287        draw_mode: &PathDrawMode,
288        alpha: f32,
289        is_text: bool,
290    ) -> Vec<u8> {
291        let mut ctx = RenderContext::new_with(
292            self.overlay.width as u16,
293            self.overlay.height as u16,
294            self.cpu_settings,
295        );
296        self.apply_clip_stack(&mut ctx);
297        ctx.set_paint(AlphaColor::<Srgb>::new([1.0, 1.0, 1.0, alpha]));
298        ctx.set_transform(transform);
299
300        match draw_mode {
301            PathDrawMode::Fill(fill_rule) => {
302                ctx.set_fill_rule(convert_fill_rule(*fill_rule));
303                ctx.fill_path(path);
304            }
305            PathDrawMode::Stroke(stroke_props) => {
306                ctx.set_stroke(stroke_for_path(transform, stroke_props, is_text));
307                ctx.stroke_path(path);
308            }
309        }
310
311        self.finish_mask(ctx)
312    }
313
314    fn rasterize_rect_mask(&self, rect: &Rect, transform: Affine, alpha: f32) -> Vec<u8> {
315        self.rasterize_path_mask(
316            &rect.to_path(0.1),
317            transform,
318            &PathDrawMode::Fill(FillRule::NonZero),
319            alpha,
320            false,
321        )
322    }
323
324    fn finish_mask(&self, mut ctx: RenderContext) -> Vec<u8> {
325        let mut pixmap = Pixmap::new(self.overlay.width as u16, self.overlay.height as u16);
326        ctx.flush();
327        ctx.render_to_pixmap(&mut pixmap);
328        pixmap
329            .data_as_u8_slice()
330            .chunks_exact(4)
331            .map(|px| px[3])
332            .collect()
333    }
334
335    fn apply_clip_stack(&self, ctx: &mut RenderContext) {
336        for clip in &self.clip_stack {
337            let old_transform = *ctx.transform();
338            ctx.set_fill_rule(convert_fill_rule(clip.fill));
339            ctx.set_transform(Affine::IDENTITY);
340            ctx.push_clip_path(&clip.path);
341            ctx.set_transform(old_transform);
342        }
343    }
344
345    fn handle_raster_image(&mut self, image: &RasterImage<'_>, transform: Affine) -> bool {
346        if !self.can_preserve_exact()
347            || (self.current_opacity - 1.0).abs() > f32::EPSILON
348            || self.clip_stack.len() > 1
349        {
350            return false;
351        }
352
353        let mut preserved = false;
354        image.with_device_cmyk(
355            |cmyk, alpha| {
356                if alpha.is_some() {
357                    return;
358                }
359                preserved = self.apply_axis_aligned_image(transform, cmyk);
360            },
361            None,
362        );
363        preserved
364    }
365
366    fn apply_axis_aligned_image(
367        &mut self,
368        transform: Affine,
369        cmyk: pdf_interpret::CmykData,
370    ) -> bool {
371        let transform = transform
372            * Affine::scale_non_uniform(cmyk.scale_factors.0 as f64, cmyk.scale_factors.1 as f64);
373        let [sx, shy, shx, sy, tx, ty] = transform.as_coeffs();
374        if shy.abs() > AXIS_EPSILON || shx.abs() > AXIS_EPSILON {
375            return false;
376        }
377        if sx.abs() <= AXIS_EPSILON || sy.abs() <= AXIS_EPSILON {
378            return false;
379        }
380
381        let bounds = (transform
382            * Rect::new(0.0, 0.0, cmyk.width as f64, cmyk.height as f64).to_path(0.1))
383        .bounding_box()
384        .intersect(Rect::new(
385            0.0,
386            0.0,
387            self.overlay.width as f64,
388            self.overlay.height as f64,
389        ));
390        if bounds.width() <= 0.0 || bounds.height() <= 0.0 {
391            return true;
392        }
393
394        let min_x = bounds.x0.floor().max(0.0) as u32;
395        let max_x = bounds.x1.ceil().min(self.overlay.width as f64) as u32;
396        let min_y = bounds.y0.floor().max(0.0) as u32;
397        let max_y = bounds.y1.ceil().min(self.overlay.height as f64) as u32;
398        let inv_sx = 1.0 / sx;
399        let inv_sy = 1.0 / sy;
400
401        for y in min_y..max_y {
402            for x in min_x..max_x {
403                let src_x = ((x as f64 + 0.5) - tx) * inv_sx;
404                let src_y = ((y as f64 + 0.5) - ty) * inv_sy;
405                if src_x < 0.0
406                    || src_x >= cmyk.width as f64
407                    || src_y < 0.0
408                    || src_y >= cmyk.height as f64
409                {
410                    continue;
411                }
412
413                let src_x = src_x.floor() as usize;
414                let src_y = src_y.floor() as usize;
415                let idx = (src_y * cmyk.width as usize + src_x) * 4;
416                self.overlay.set_exact_pixel(
417                    x,
418                    y,
419                    [
420                        cmyk.data[idx],
421                        cmyk.data[idx + 1],
422                        cmyk.data[idx + 2],
423                        cmyk.data[idx + 3],
424                    ],
425                );
426            }
427        }
428
429        true
430    }
431
432    fn handle_image_fallback(&mut self, image: &PdfImage<'_, '_>, transform: Affine, alpha: f32) {
433        if alpha <= 0.0 {
434            return;
435        }
436        let rect = Rect::new(0.0, 0.0, image.width() as f64, image.height() as f64);
437        let mask = self.rasterize_rect_mask(&rect, transform, alpha);
438        self.overlay.contaminate(&mask);
439    }
440}
441
442impl<'a> Device<'a> for CmykOverlayDevice {
443    fn set_soft_mask(&mut self, mask: Option<SoftMask<'a>>) {
444        self.current_soft_mask = mask.is_some();
445    }
446
447    fn set_blend_mode(&mut self, blend_mode: BlendMode) {
448        self.current_blend_mode = blend_mode;
449    }
450
451    fn draw_path(
452        &mut self,
453        path: &BezPath,
454        transform: Affine,
455        paint: &Paint<'a>,
456        draw_mode: &PathDrawMode,
457    ) {
458        self.handle_path_operation(path, transform, paint, draw_mode, false);
459    }
460
461    fn push_clip_path(&mut self, clip_path: &ClipPath) {
462        self.clip_stack.push(clip_path.clone());
463    }
464
465    fn push_transparency_group(
466        &mut self,
467        opacity: f32,
468        mask: Option<SoftMask<'a>>,
469        blend_mode: BlendMode,
470    ) {
471        let unsupported = mask.is_some() || blend_mode != BlendMode::Normal;
472        self.group_stack.push(GroupState {
473            previous_opacity: self.current_opacity,
474            unsupported,
475        });
476        self.current_opacity = (self.current_opacity * opacity).clamp(0.0, 1.0);
477        if unsupported {
478            self.unsupported_depth += 1;
479        }
480    }
481
482    fn draw_glyph(
483        &mut self,
484        glyph: &Glyph<'a>,
485        transform: Affine,
486        glyph_transform: Affine,
487        paint: &Paint<'a>,
488        draw_mode: &GlyphDrawMode,
489    ) {
490        match draw_mode {
491            GlyphDrawMode::Invisible => {}
492            GlyphDrawMode::Fill => match glyph {
493                Glyph::Outline(outline) => {
494                    self.handle_path_operation(
495                        &outline.outline(),
496                        transform * glyph_transform,
497                        paint,
498                        &PathDrawMode::Fill(FillRule::NonZero),
499                        true,
500                    );
501                }
502                Glyph::Type3(type3) => {
503                    type3.interpret(self, transform, glyph_transform, paint);
504                }
505            },
506            GlyphDrawMode::Stroke(stroke_props) => match glyph {
507                Glyph::Outline(outline) => {
508                    let path = glyph_transform * outline.outline();
509                    self.handle_path_operation(
510                        &path,
511                        transform,
512                        paint,
513                        &PathDrawMode::Stroke(stroke_props.clone()),
514                        true,
515                    );
516                }
517                Glyph::Type3(type3) => {
518                    type3.interpret(self, transform, glyph_transform, paint);
519                }
520            },
521        }
522    }
523
524    fn draw_image(&mut self, image: PdfImage<'a, '_>, transform: Affine) {
525        match &image {
526            PdfImage::Raster(raster) if self.handle_raster_image(raster, transform) => {}
527            PdfImage::Stencil(stencil) => {
528                let _ = stencil;
529                self.handle_image_fallback(&image, transform, self.current_opacity);
530            }
531            PdfImage::Raster(_) => {
532                self.handle_image_fallback(&image, transform, self.current_opacity);
533            }
534        }
535    }
536
537    fn pop_clip_path(&mut self) {
538        let _ = self.clip_stack.pop();
539    }
540
541    fn pop_transparency_group(&mut self) {
542        if let Some(state) = self.group_stack.pop() {
543            self.current_opacity = state.previous_opacity;
544            if state.unsupported {
545                self.unsupported_depth = self.unsupported_depth.saturating_sub(1);
546            }
547        }
548    }
549}
550
551/// Render a single page to RGBA pixels.
552pub(crate) fn render_page(
553    page: &Page<'_>,
554    options: &RenderOptions,
555    settings: &InterpreterSettings,
556) -> RenderedPage {
557    let (width, height, pixels) = render_rgba_pixels(page, options, settings);
558    RenderedPage {
559        width,
560        height,
561        pixel_format: PixelFormat::Rgba8,
562        pixels,
563    }
564}
565
566/// Render a single page using the higher-level color-mode configuration.
567///
568/// [`ColorMode::Srgb`] and [`ColorMode::SimulateCmyk`] both return RGBA output.
569/// [`ColorMode::PreserveCmyk`] preserves exact DeviceCMYK samples where the
570/// overlay pass can prove that the original values remain visible, and falls
571/// back to RGBA→CMYK elsewhere.
572pub(crate) fn render_page_with_config(
573    page: &Page<'_>,
574    config: &RenderConfig,
575    settings: &InterpreterSettings,
576) -> RenderedPage {
577    let options = RenderOptions::from(config);
578    match config.color_mode {
579        ColorMode::Srgb | ColorMode::SimulateCmyk => render_page(page, &options, settings),
580        ColorMode::PreserveCmyk => {
581            let (width, height, rgba) = render_rgba_pixels(page, &options, settings);
582            let overlay = build_cmyk_overlay(page, &options, settings, width, height);
583            RenderedPage {
584                width,
585                height,
586                pixel_format: PixelFormat::Cmyk8,
587                pixels: overlay.compose_with_rgba_fallback(&rgba),
588            }
589        }
590    }
591}
592
593/// Render a page as a thumbnail (fits within `max_dimension` on longest side).
594pub(crate) fn render_thumbnail(
595    page: &Page<'_>,
596    max_dimension: u32,
597    settings: &InterpreterSettings,
598) -> RenderedPage {
599    let (w, h) = page.render_dimensions();
600    let longest = w.max(h) as f64;
601    let scale = (max_dimension as f64 / longest) as f32;
602
603    let rs = RenderSettings {
604        x_scale: scale,
605        y_scale: scale,
606        bg_color: WHITE,
607        ..Default::default()
608    };
609
610    let pixmap = render(page, settings, &rs);
611    let pw = pixmap.width() as u32;
612    let ph = pixmap.height() as u32;
613    let pixels = pixmap.data_as_u8_slice().to_vec();
614
615    RenderedPage {
616        width: pw,
617        height: ph,
618        pixel_format: PixelFormat::Rgba8,
619        pixels,
620    }
621}
622
623fn build_cmyk_overlay(
624    page: &Page<'_>,
625    options: &RenderOptions,
626    settings: &InterpreterSettings,
627    width: u32,
628    height: u32,
629) -> CmykOverlay {
630    let scale = (options.dpi / 72.0) as f32;
631    let initial_transform =
632        Affine::scale_non_uniform(scale as f64, scale as f64) * page.initial_transform(true);
633    let mut isettings = settings.clone();
634    isettings.render_annotations = options.render_annotations;
635
636    let mut ctx = Context::new(
637        initial_transform,
638        Rect::new(0.0, 0.0, width as f64, height as f64),
639        page.xref(),
640        isettings.clone(),
641    );
642    let mut device = CmykOverlayDevice::new(width as u16, height as u16);
643
644    device.push_clip_path(&ClipPath {
645        path: Rect::new(0.0, 0.0, width as f64, height as f64).to_path(0.1),
646        fill: FillRule::NonZero,
647    });
648    device.push_transparency_group(1.0, None, BlendMode::Normal);
649    interpret_page(page, &mut ctx, &mut device);
650    device.pop_transparency_group();
651    device.pop_clip_path();
652
653    device.finish()
654}
655
656fn render_rgba_pixels(
657    page: &Page<'_>,
658    options: &RenderOptions,
659    settings: &InterpreterSettings,
660) -> (u32, u32, Vec<u8>) {
661    let mut scale = (options.dpi / 72.0) as f32;
662
663    // Opt-in pixel budget: clamp the effective scale so width*height <= max_pixels.
664    // Only active when `max_pixels` is Some AND `width`/`height` are not forced.
665    if let Some(budget) = options.max_pixels {
666        if options.width.is_none() && options.height.is_none() && budget > 0 && scale > 0.0 {
667            let (base_w, base_h) = page.render_dimensions();
668            let predicted = (base_w as f64 * scale as f64) * (base_h as f64 * scale as f64);
669            if predicted > budget as f64 {
670                let factor = (budget as f64 / predicted).sqrt() as f32;
671                scale *= factor;
672            }
673        }
674    }
675
676    let bg = AlphaColor::<Srgb>::new(options.background);
677
678    let rs = RenderSettings {
679        x_scale: scale,
680        y_scale: scale,
681        width: options.width,
682        height: options.height,
683        bg_color: bg,
684        quality: options.quality,
685    };
686
687    let mut isettings = settings.clone();
688    isettings.render_annotations = options.render_annotations;
689
690    let pixmap = render(page, &isettings, &rs);
691    let width = pixmap.width() as u32;
692    let height = pixmap.height() as u32;
693    let pixels = pixmap.data_as_u8_slice().to_vec();
694    (width, height, pixels)
695}
696
697fn stroke_for_path(
698    transform: Affine,
699    stroke_props: &pdf_interpret::StrokeProps,
700    is_text: bool,
701) -> kurbo::Stroke {
702    let threshold = if is_text { 0.25 } else { 1.0 };
703    let min_factor = max_factor(&transform);
704    let mut line_width = stroke_props.line_width.max(0.01);
705    let transformed_width = line_width * min_factor;
706
707    if transformed_width < threshold && transformed_width > 0.0 {
708        line_width /= transformed_width;
709        line_width *= threshold;
710    }
711
712    kurbo::Stroke {
713        width: line_width as f64,
714        join: stroke_props.line_join,
715        miter_limit: stroke_props.miter_limit as f64,
716        start_cap: stroke_props.line_cap,
717        end_cap: stroke_props.line_cap,
718        dash_pattern: stroke_props.dash_array.iter().map(|n| *n as f64).collect(),
719        dash_offset: stroke_props.dash_offset as f64,
720    }
721}
722
723fn max_factor(transform: &Affine) -> f32 {
724    let [a, b, c, d, _, _] = transform.as_coeffs();
725    let x_advance = Affine::new([a, b, c, d, 0.0, 0.0]) * Point::new(1.0, 0.0);
726    let y_advance = Affine::new([a, b, c, d, 0.0, 0.0]) * Point::new(0.0, 1.0);
727    x_advance
728        .to_vec2()
729        .length()
730        .max(y_advance.to_vec2().length()) as f32
731}
732
733fn convert_fill_rule(fill_rule: FillRule) -> PenikoFill {
734    match fill_rule {
735        FillRule::NonZero => PenikoFill::NonZero,
736        FillRule::EvenOdd => PenikoFill::EvenOdd,
737    }
738}
739
740#[cfg(test)]
741mod tests {
742    use super::*;
743
744    #[test]
745    fn render_options_defaults() {
746        let opts = RenderOptions::default();
747        assert!((opts.dpi - 72.0).abs() < f64::EPSILON);
748        assert!(opts.render_annotations);
749        assert!(opts.width.is_none());
750        assert!(opts.height.is_none());
751        assert!(opts.max_pixels.is_none());
752        // Default rasterization mode is Quality (f32 pipeline) — byte-identical
753        // to historical output. Speed (u8) is opt-in only.
754        assert_eq!(opts.quality, RasterQuality::Quality);
755    }
756
757    #[test]
758    fn render_config_defaults() {
759        let cfg = RenderConfig::default();
760        assert_eq!(cfg.color_mode, ColorMode::Srgb);
761        assert_eq!(cfg.dpi, 72);
762    }
763
764    #[test]
765    fn rendered_page_empty() {
766        let p = RenderedPage {
767            width: 10,
768            height: 20,
769            pixel_format: PixelFormat::Rgba8,
770            pixels: vec![0; 10 * 20 * 4],
771        };
772        assert_eq!(p.pixels.len(), 800);
773    }
774
775    #[test]
776    fn rgba_to_cmyk_black() {
777        let buf = crate::color::rgba_to_cmyk_buffer(&[0, 0, 0, 255]);
778        assert_eq!(buf, [0, 0, 0, 255]);
779    }
780
781    #[test]
782    fn rgba_to_cmyk_white() {
783        let buf = crate::color::rgba_to_cmyk_buffer(&[255, 255, 255, 255]);
784        assert_eq!(buf, [0, 0, 0, 0]);
785    }
786
787    #[test]
788    fn rgba_to_cmyk_buffer_stride() {
789        let buf = crate::color::rgba_to_cmyk_buffer(&[255, 0, 0, 255, 0, 0, 0, 255]);
790        assert_eq!(buf.len(), 8);
791    }
792
793    #[test]
794    fn render_config_into_options() {
795        let cfg = RenderConfig {
796            dpi: 150,
797            ..Default::default()
798        };
799        let opts = RenderOptions::from(&cfg);
800        assert!((opts.dpi - 150.0).abs() < f64::EPSILON);
801    }
802
803    #[test]
804    fn overlay_partial_pixel_without_prior_exact_falls_back() {
805        let mut overlay = CmykOverlay::new(1, 1);
806        overlay.apply_mask(&[128], [1, 2, 3, 4]);
807        assert_eq!(overlay.data, vec![None]);
808    }
809
810    #[test]
811    fn overlay_partial_pixel_blends_existing_exact() {
812        let mut overlay = CmykOverlay::new(1, 1);
813        overlay.apply_mask(&[255], [0, 0, 0, 0]);
814        overlay.apply_mask(&[128], [255, 128, 64, 32]);
815        assert_eq!(overlay.data, vec![Some([128, 64, 32, 16])]);
816    }
817}