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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum OutputFormat {
15 Png,
16 Jpeg { quality: u8 },
17 RawRgba,
19}
20
21pub struct RenderOptions {
22 pub dpi: f64,
24 pub background: [u8; 4],
26 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
40pub 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
59pub 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 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 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
111pub 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
123pub struct RenderedPixmap {
125 pub data: Vec<u8>,
127 pub width: u32,
129 pub height: u32,
131}
132
133pub 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
188pub 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 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#[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#[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
278pub 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 let base = match rotate % 360 {
291 90 | -270 => {
292 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 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 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 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 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 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 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}