1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
27pub enum ColorMode {
28 #[default]
30 Srgb,
31 PreserveCmyk,
33 SimulateCmyk,
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
39pub enum PixelFormat {
40 #[default]
42 Rgba8,
43 Cmyk8,
45}
46
47#[derive(Debug, Clone)]
52pub struct RenderConfig {
53 pub color_mode: ColorMode,
55 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#[derive(Debug, Clone)]
79pub struct RenderOptions {
80 pub dpi: f64,
82 pub background: [f32; 4],
84 pub render_annotations: bool,
86 pub width: Option<u16>,
88 pub height: Option<u16>,
90 pub max_pixels: Option<u32>,
99 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#[derive(Debug, Clone)]
125pub struct RenderedPage {
126 pub width: u32,
128 pub height: u32,
130 pub pixel_format: PixelFormat,
132 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 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
551pub(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
566pub(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
593pub(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 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 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}