Skip to main content

justpdf_render/
render.rs

1use std::path::Path;
2
3use justpdf_core::page::{PageInfo, collect_pages};
4use justpdf_core::PdfDocument;
5
6use crate::device::PixmapDevice;
7use crate::error::{RenderError, Result};
8use crate::graphics_state::Matrix;
9use crate::interpreter::RenderInterpreter;
10use crate::svg_device::SvgRenderer;
11
12/// Output format for rendering.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum OutputFormat {
15    Png,
16    Jpeg { quality: u8 },
17    /// Raw RGBA pixel data (4 bytes per pixel, row-major, top-left origin).
18    RawRgba,
19}
20
21pub struct RenderOptions {
22    /// DPI for rendering (default: 72, which is 1:1 with PDF points).
23    pub dpi: f64,
24    /// Background color (RGBA). Default: white opaque.
25    pub background: [u8; 4],
26    /// Output format. Default: PNG.
27    pub format: OutputFormat,
28}
29
30impl Default for RenderOptions {
31    fn default() -> Self {
32        Self {
33            dpi: 72.0,
34            background: [255, 255, 255, 255],
35            format: OutputFormat::Png,
36        }
37    }
38}
39
40/// Render a single page of a PDF document to PNG bytes.
41///
42/// `page_index` is 0-based.
43pub fn render_page(
44    doc: &PdfDocument,
45    page_index: usize,
46    options: &RenderOptions,
47) -> Result<Vec<u8>> {
48    let pages = collect_pages(doc)?;
49    let page = pages
50        .get(page_index)
51        .ok_or_else(|| RenderError::InvalidDimensions {
52            detail: format!("page index {page_index} out of range (total: {})", pages.len()),
53        })?
54        .clone();
55
56    render_page_info(doc, &page, options)
57}
58
59/// Render a page given its PageInfo.
60pub fn render_page_info(
61    doc: &PdfDocument,
62    page: &PageInfo,
63    options: &RenderOptions,
64) -> Result<Vec<u8>> {
65    let media_box = page.crop_box.unwrap_or(page.media_box);
66    let page_width = media_box.width();
67    let page_height = media_box.height();
68
69    if page_width <= 0.0 || page_height <= 0.0 {
70        return Err(RenderError::InvalidDimensions {
71            detail: format!("page has zero/negative size: {page_width}x{page_height}"),
72        });
73    }
74
75    let scale = options.dpi / 72.0;
76    let pixel_width = (page_width * scale).ceil() as u32;
77    let pixel_height = (page_height * scale).ceil() as u32;
78
79    if pixel_width == 0 || pixel_height == 0 || pixel_width > 16384 || pixel_height > 16384 {
80        return Err(RenderError::InvalidDimensions {
81            detail: format!("pixel dimensions out of range: {pixel_width}x{pixel_height}"),
82        });
83    }
84
85    let mut device = PixmapDevice::new(pixel_width, pixel_height)?;
86
87    // Fill background
88    device.clear(tiny_skia::Color::from_rgba8(
89        options.background[0],
90        options.background[1],
91        options.background[2],
92        options.background[3],
93    ));
94
95    // Build the page transform:
96    // 1. Translate so media_box origin is at (0,0)
97    // 2. Flip Y axis (PDF Y goes up, pixel Y goes down)
98    // 3. Scale by DPI
99    let page_transform = compute_page_transform(&media_box, scale, page.rotate);
100
101    let mut interpreter = RenderInterpreter::new(doc, &mut device, page_transform);
102    interpreter.render_page(page)?;
103
104    match options.format {
105        OutputFormat::Png => device.encode_png(),
106        OutputFormat::Jpeg { quality } => device.encode_jpeg(quality),
107        OutputFormat::RawRgba => Ok(device.raw_rgba().to_vec()),
108    }
109}
110
111/// Render a page and save to a file.
112pub fn render_page_to_file(
113    doc: &PdfDocument,
114    page_index: usize,
115    options: &RenderOptions,
116    output_path: &Path,
117) -> Result<()> {
118    let png_data = render_page(doc, page_index, options)?;
119    std::fs::write(output_path, &png_data)?;
120    Ok(())
121}
122
123/// Rendered pixmap data with dimensions.
124pub struct RenderedPixmap {
125    /// Raw RGBA pixel data (4 bytes per pixel).
126    pub data: Vec<u8>,
127    /// Width in pixels.
128    pub width: u32,
129    /// Height in pixels.
130    pub height: u32,
131}
132
133/// Render a page and return the raw pixmap (RGBA data + dimensions).
134pub fn render_page_to_pixmap(
135    doc: &PdfDocument,
136    page_index: usize,
137    options: &RenderOptions,
138) -> Result<RenderedPixmap> {
139    let pages = collect_pages(doc)?;
140    let page = pages
141        .get(page_index)
142        .ok_or_else(|| RenderError::InvalidDimensions {
143            detail: format!("page index {page_index} out of range (total: {})", pages.len()),
144        })?
145        .clone();
146
147    let media_box = page.crop_box.unwrap_or(page.media_box);
148    let page_width = media_box.width();
149    let page_height = media_box.height();
150
151    if page_width <= 0.0 || page_height <= 0.0 {
152        return Err(RenderError::InvalidDimensions {
153            detail: format!("page has zero/negative size: {page_width}x{page_height}"),
154        });
155    }
156
157    let scale = options.dpi / 72.0;
158    let pixel_width = (page_width * scale).ceil() as u32;
159    let pixel_height = (page_height * scale).ceil() as u32;
160
161    if pixel_width == 0 || pixel_height == 0 || pixel_width > 16384 || pixel_height > 16384 {
162        return Err(RenderError::InvalidDimensions {
163            detail: format!("pixel dimensions out of range: {pixel_width}x{pixel_height}"),
164        });
165    }
166
167    let mut device = PixmapDevice::new(pixel_width, pixel_height)?;
168
169    device.clear(tiny_skia::Color::from_rgba8(
170        options.background[0],
171        options.background[1],
172        options.background[2],
173        options.background[3],
174    ));
175
176    let page_transform = compute_page_transform(&media_box, scale, page.rotate);
177
178    let mut interpreter = RenderInterpreter::new(doc, &mut device, page_transform);
179    interpreter.render_page(&page)?;
180
181    Ok(RenderedPixmap {
182        data: device.raw_rgba().to_vec(),
183        width: pixel_width,
184        height: pixel_height,
185    })
186}
187
188/// Render a single page of a PDF document to SVG string.
189///
190/// `page_index` is 0-based. Returns a complete SVG XML document.
191pub fn render_page_to_svg(
192    doc: &PdfDocument,
193    page_index: usize,
194) -> Result<String> {
195    let pages = collect_pages(doc)?;
196    let page = pages
197        .get(page_index)
198        .ok_or_else(|| RenderError::InvalidDimensions {
199            detail: format!("page index {page_index} out of range (total: {})", pages.len()),
200        })?
201        .clone();
202
203    let media_box = page.crop_box.unwrap_or(page.media_box);
204    let page_width = media_box.width();
205    let page_height = media_box.height();
206
207    if page_width <= 0.0 || page_height <= 0.0 {
208        return Err(RenderError::InvalidDimensions {
209            detail: format!("page has zero/negative size: {page_width}x{page_height}"),
210        });
211    }
212
213    // For SVG we use scale=1.0 (1pt = 1 SVG unit), no DPI scaling
214    let page_transform = compute_page_transform(&media_box, 1.0, page.rotate);
215
216    let renderer = SvgRenderer::new(doc, page_transform, page_width, page_height);
217    renderer.render_page(&page)
218}
219
220/// Render multiple pages in parallel using rayon.
221///
222/// Returns a `Vec<Result<Vec<u8>>>` where each entry corresponds to
223/// the rendered output of the page at the given index.
224/// Requires the `parallel` feature.
225#[cfg(feature = "parallel")]
226pub fn render_pages_parallel(
227    doc: &PdfDocument,
228    page_indices: &[usize],
229    options: &RenderOptions,
230) -> Vec<Result<Vec<u8>>> {
231    use rayon::prelude::*;
232
233    let pages = match collect_pages(doc) {
234        Ok(p) => p,
235        Err(e) => {
236            let msg = format!("failed to collect pages: {e}");
237            return page_indices
238                .iter()
239                .map(|_| {
240                    Err(RenderError::InvalidDimensions {
241                        detail: msg.clone(),
242                    })
243                })
244                .collect();
245        }
246    };
247
248    page_indices
249        .par_iter()
250        .map(|&idx| {
251            let page = pages
252                .get(idx)
253                .ok_or_else(|| RenderError::InvalidDimensions {
254                    detail: format!("page index {idx} out of range (total: {})", pages.len()),
255                })?;
256            render_page_info(doc, page, options)
257        })
258        .collect()
259}
260
261/// Render all pages in parallel using rayon.
262///
263/// Requires the `parallel` feature.
264#[cfg(feature = "parallel")]
265pub fn render_all_pages_parallel(
266    doc: &PdfDocument,
267    options: &RenderOptions,
268) -> Vec<Result<Vec<u8>>> {
269    let pages = match collect_pages(doc) {
270        Ok(p) => p,
271        Err(e) => return vec![Err(e.into())],
272    };
273
274    let indices: Vec<usize> = (0..pages.len()).collect();
275    render_pages_parallel(doc, &indices, options)
276}
277
278/// Compute the transform from PDF user space to device (pixel) space.
279pub fn compute_page_transform(
280    media_box: &justpdf_core::page::Rect,
281    scale: f64,
282    rotate: i64,
283) -> Matrix {
284    let w = media_box.width();
285    let h = media_box.height();
286
287    // Base transform: translate origin, flip Y, scale
288    // PDF: origin at lower-left, Y up
289    // Pixels: origin at upper-left, Y down
290    let base = match rotate % 360 {
291        90 | -270 => {
292            // Rotate 90°: swap width/height
293            Matrix {
294                a: 0.0,
295                b: -scale,
296                c: scale,
297                d: 0.0,
298                e: -media_box.lly * scale,
299                f: (media_box.llx + w) * scale,
300            }
301        }
302        180 | -180 => Matrix {
303            a: -scale,
304            b: 0.0,
305            c: 0.0,
306            d: scale,
307            e: (media_box.llx + w) * scale,
308            f: -media_box.lly * scale,
309        },
310        270 | -90 => Matrix {
311            a: 0.0,
312            b: scale,
313            c: -scale,
314            d: 0.0,
315            e: (media_box.lly + h) * scale,
316            f: -media_box.llx * scale,
317        },
318        _ => {
319            // 0° rotation (default)
320            Matrix {
321                a: scale,
322                b: 0.0,
323                c: 0.0,
324                d: -scale,
325                e: -media_box.llx * scale,
326                f: (media_box.lly + h) * scale,
327            }
328        }
329    };
330
331    base
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337
338    #[test]
339    fn test_render_options_default() {
340        let opts = RenderOptions::default();
341        assert_eq!(opts.dpi, 72.0);
342        assert_eq!(opts.background, [255, 255, 255, 255]);
343    }
344
345    #[cfg(feature = "parallel")]
346    #[test]
347    fn test_render_pages_parallel_empty() {
348        use std::path::Path;
349        let pdf_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../testpdf.pdf");
350        if !pdf_path.exists() {
351            eprintln!("skipping: testpdf.pdf not found");
352            return;
353        }
354        let doc = justpdf_core::PdfDocument::open(&pdf_path).expect("failed to open PDF");
355        let opts = RenderOptions::default();
356        // Empty indices should return empty results.
357        let results = render_pages_parallel(&doc, &[], &opts);
358        assert!(results.is_empty());
359    }
360
361    #[cfg(feature = "parallel")]
362    #[test]
363    fn test_render_pages_parallel_out_of_range() {
364        use std::path::Path;
365        let pdf_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../testpdf.pdf");
366        if !pdf_path.exists() {
367            eprintln!("skipping: testpdf.pdf not found");
368            return;
369        }
370        let doc = justpdf_core::PdfDocument::open(&pdf_path).expect("failed to open PDF");
371        let opts = RenderOptions::default();
372        // Out-of-range index should produce an error.
373        let results = render_pages_parallel(&doc, &[9999], &opts);
374        assert_eq!(results.len(), 1);
375        assert!(results[0].is_err());
376    }
377
378    #[cfg(feature = "parallel")]
379    #[test]
380    fn test_parallel_render_single_page() {
381        use std::path::Path;
382        let pdf_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../testpdf.pdf");
383        if !pdf_path.exists() {
384            eprintln!("skipping: testpdf.pdf not found");
385            return;
386        }
387        let doc = justpdf_core::PdfDocument::open(&pdf_path).expect("failed to open PDF");
388        let opts = RenderOptions::default();
389        // Parallel rendering with a single page should work fine.
390        let results = render_pages_parallel(&doc, &[0], &opts);
391        assert_eq!(results.len(), 1);
392        assert!(results[0].is_ok(), "single-page parallel render failed: {:?}", results[0].as_ref().err());
393    }
394
395    #[test]
396    fn test_page_transform_identity_at_72dpi() {
397        let media_box = justpdf_core::page::Rect {
398            llx: 0.0,
399            lly: 0.0,
400            urx: 100.0,
401            ury: 200.0,
402        };
403        let t = compute_page_transform(&media_box, 1.0, 0);
404        // Point (0, 200) in PDF = (0, 0) in pixels (top-left)
405        let (px, py) = t.transform_point(0.0, 200.0);
406        assert!((px - 0.0).abs() < 0.001);
407        assert!((py - 0.0).abs() < 0.001);
408
409        // Point (100, 0) in PDF = (100, 200) in pixels (bottom-right)
410        let (px, py) = t.transform_point(100.0, 0.0);
411        assert!((px - 100.0).abs() < 0.001);
412        assert!((py - 200.0).abs() < 0.001);
413    }
414}