nipdf_render/
lib.rs

1use educe::Educe;
2use either::Either;
3use euclid::Angle;
4use image::RgbaImage;
5use log::warn;
6use nipdf::{
7    file::{Page, Rectangle},
8    graphics::trans::{LogicDeviceToDeviceSpace, UserToUserSpace, logic_device_to_device},
9};
10use prescript::Result;
11use snafu::{OptionExt, ResultExt, whatever};
12use tiny_skia::{Color, Pixmap};
13
14mod render;
15mod shading;
16use render::{Render, State};
17mod into_skia;
18pub(crate) use into_skia::*;
19use num_traits::ToPrimitive;
20
21#[derive(Debug, Educe, Clone, Copy)]
22#[educe(Default)]
23pub struct PageDimension {
24    #[educe(Default = 1.0)]
25    zoom: f32,
26    width: u32,
27    height: u32,
28    // apply before ctm to handle crop_box/media_box left-bottom not at (0, 0) and page rotate
29    transform: UserToUserSpace,
30    rotate: i32,
31}
32
33impl PageDimension {
34    pub fn update(&mut self, dimension: &Rectangle, rotate: i32) {
35        self.rotate = rotate % 360;
36
37        let mut transform = UserToUserSpace::identity();
38        if dimension.left_x != 0.0 || dimension.lower_y != 0.0 {
39            transform = transform.then_translate((-dimension.left_x, -dimension.lower_y).into());
40        }
41        self.transform = transform;
42
43        // width and height are always positive, it is acceptable if it panic because of too
44        // large page size
45        #[allow(clippy::unwrap_used)]
46        {
47            self.width = dimension.width().to_u32().unwrap();
48            self.height = dimension.height().to_u32().unwrap();
49        }
50        if self.swap_wh() {
51            std::mem::swap(&mut self.width, &mut self.height);
52        }
53    }
54
55    pub fn canvas_width(&self) -> u32 {
56        // width and zoom are always positive, it is acceptable if it panic because of too
57        // large page size
58        #[allow(clippy::unwrap_used)]
59        (self.width as f32 * self.zoom).to_u32().unwrap()
60    }
61
62    pub fn canvas_height(&self) -> u32 {
63        // height and zoom are always positive, it is acceptable if it panic because of too
64        // large page size
65        #[allow(clippy::unwrap_used)]
66        (self.height as f32 * self.zoom).to_u32().unwrap()
67    }
68
69    fn swap_wh(&self) -> bool {
70        self.rotate.abs() == 90 || self.rotate.abs() == 270
71    }
72
73    pub fn logic_device_to_device(&self) -> LogicDeviceToDeviceSpace {
74        if self.rotate != 0 {
75            let (w, h) = if self.swap_wh() {
76                (self.height, self.width)
77            } else {
78                (self.width, self.height)
79            };
80
81            let r = logic_device_to_device(h, self.zoom);
82            r.then_translate((w as f32 * self.zoom * -0.5, h as f32 * self.zoom * -0.5).into())
83                .then_rotate(Angle::degrees(self.rotate as f32))
84                .then_translate((h as f32 * self.zoom * 0.5, w as f32 * self.zoom * 0.5).into())
85        } else {
86            logic_device_to_device(self.height, self.zoom)
87        }
88    }
89}
90/// Option for Render
91#[derive(Debug, Educe, Clone)]
92#[educe(Default)]
93pub struct RenderOption {
94    /// If crop is specified, the output canvas will be cropped to the specified rectangle.
95    crop: Option<Rectangle>,
96    #[educe(Default(expression = Color::WHITE))]
97    background_color: Color,
98    /// Initial state, used in paint_x_form to pass parent state to form Render.
99    state: Option<State>,
100    rotate: i32,
101    dimension: PageDimension,
102    /// If true, operations that result in errors will cause the render to fail immediately.
103    /// If false, errors will be logged and rendering will continue.
104    #[educe(Default = false)]
105    fail_fast: bool,
106}
107
108impl RenderOption {
109    pub fn create_canvas(&self) -> Result<Pixmap> {
110        let (w, h) = (
111            self.dimension.canvas_width(),
112            self.dimension.canvas_height(),
113        );
114        if w * h > 1024 * 1024 * 100 {
115            whatever!("page size too large: {}x{}", w, h);
116        }
117
118        let mut r = Pixmap::new(w, h).whatever_context("Failed create canvas")?;
119        if self.background_color.is_opaque() {
120            r.fill(self.background_color);
121        }
122        Ok(r)
123    }
124
125    /// Convert canvas to image, crop if crop option not None
126    pub fn to_image(&self, canvas: Pixmap) -> Result<RgbaImage> {
127        RgbaImage::from_raw(canvas.width(), canvas.height(), canvas.take())
128            .whatever_context("Failed create canvas")
129    }
130}
131#[derive(Educe)]
132#[educe(Default(new))]
133pub struct RenderOptionBuilder(RenderOption);
134
135impl RenderOptionBuilder {
136    /// Set zoom field, if zoom less than 0, it will be ignored.
137    pub fn zoom(mut self, zoom: f32) -> Self {
138        if zoom <= 0.0 {
139            warn!("zoom less than 0, ignored");
140            return self;
141        }
142        self.0.dimension.zoom = zoom;
143        self
144    }
145
146    pub fn page_box(mut self, dimension: &Rectangle, rotate_degree: i32) -> Self {
147        self.0.dimension.update(dimension, rotate_degree);
148        self
149    }
150
151    fn dimension(mut self, dimension: PageDimension) -> Self {
152        self.0.dimension = dimension;
153        self
154    }
155
156    pub fn crop(mut self, rect: Option<Rectangle>) -> Self {
157        self.0.crop = rect;
158        self
159    }
160
161    pub fn background_color(mut self, color: Color) -> Self {
162        self.0.background_color = color;
163        self
164    }
165
166    pub fn rotate(mut self, rotate: i32) -> Self {
167        self.0.rotate = rotate;
168        self
169    }
170
171    pub fn fail_fast(mut self, fail_fast: bool) -> Self {
172        self.0.fail_fast = fail_fast;
173        self
174    }
175
176    fn state(mut self, state: State) -> Self {
177        self.0.state = Some(state);
178        self
179    }
180
181    pub fn build(self) -> RenderOption {
182        self.0
183    }
184}
185
186pub fn render_page(page: &Page<'_>, option: RenderOptionBuilder) -> Result<RgbaImage> {
187    render_steps(page, option, None, false)
188}
189
190pub fn render_steps(
191    page: &Page<'_>,
192    option: RenderOptionBuilder,
193    steps: Option<usize>,
194    no_crop: bool,
195) -> Result<RgbaImage> {
196    let media_box = page.media_box().whatever_context("get page media box")?;
197    let crop_box = page.crop_box().whatever_context("get page crop box")?;
198    let mut canvas_box = crop_box;
199    // if canvas is empty, use default A4 size
200    if canvas_box.width() == 0.0 || canvas_box.height() == 0.0 {
201        canvas_box = Rectangle::from_xywh(0.0, 0.0, 597.6, 842.4);
202    }
203    let option = option
204        .page_box(&canvas_box, page.rotate())
205        .crop((!no_crop && need_crop(crop_box, media_box)).then_some(crop_box))
206        .rotate(page.rotate())
207        .build();
208    let content = page.content().whatever_context("get page content")?;
209    let ops = content
210        .operations()
211        .whatever_context("get page operations")?;
212    let mut canvas = option.create_canvas()?;
213    if !ops.is_empty() {
214        // skip render if no operations, fixes incorrect pdf files that no resources
215        let resource = page.resources().whatever_context("get page resources")?;
216        let mut renderer = Render::new(&mut canvas, option.clone(), &resource)?;
217
218        let iter = if let Some(steps) = steps {
219            Either::Left(ops.into_iter().take(steps))
220        } else {
221            Either::Right(ops.into_iter())
222        };
223
224        for op in iter {
225            match renderer.exec(op) {
226                Ok(_) => (),
227                Err(e) if option.fail_fast => return Err(e),
228                Err(e) => log::error!("Operation failed: {}", e),
229            }
230        }
231    }
232    option.to_image(canvas)
233}
234
235fn need_crop(crop: Rectangle, media: Rectangle) -> bool {
236    crop != media
237}
238
239#[cfg(test)]
240mod render_tests;