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