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