1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
33pub enum ColorMode {
34 #[default]
36 Srgb,
37 PreserveCmyk,
39 SimulateCmyk,
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
45pub enum PixelFormat {
46 #[default]
48 Rgba8,
49 Cmyk8,
51}
52
53#[derive(Debug, Clone)]
58pub struct RenderConfig {
59 pub color_mode: ColorMode,
61 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#[derive(Debug, Clone)]
85pub struct RenderOptions {
86 pub dpi: f64,
88 pub background: [f32; 4],
90 pub render_annotations: bool,
92 pub width: Option<u16>,
94 pub height: Option<u16>,
96 pub max_pixels: Option<u32>,
105 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#[derive(Debug, Clone)]
132pub struct RenderedPage {
133 pub width: u32,
135 pub height: u32,
137 pub pixel_format: PixelFormat,
139 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 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
558pub(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
573pub(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
600pub(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 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 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}