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};
20use pdf_render::{render, RenderSettings};
21
22const AXIS_EPSILON: f64 = 1e-5;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
26pub enum ColorMode {
27 #[default]
29 Srgb,
30 PreserveCmyk,
32 SimulateCmyk,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
38pub enum PixelFormat {
39 #[default]
41 Rgba8,
42 Cmyk8,
44}
45
46#[derive(Debug, Clone)]
51pub struct RenderConfig {
52 pub color_mode: ColorMode,
54 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#[derive(Debug, Clone)]
78pub struct RenderOptions {
79 pub dpi: f64,
81 pub background: [f32; 4],
83 pub render_annotations: bool,
85 pub width: Option<u16>,
87 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#[derive(Debug, Clone)]
105pub struct RenderedPage {
106 pub width: u32,
108 pub height: u32,
110 pub pixel_format: PixelFormat,
112 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
529pub(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
544pub(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
571pub(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}