1pub mod color;
19pub mod content;
20pub mod font;
21pub mod function;
22pub mod geom;
23pub mod image;
24pub mod jbig2;
25pub mod jpx;
26pub mod objects;
27pub mod prepass;
28
29use std::rc::Rc;
30
31use ::image::RgbImage;
32use lopdf::Document;
33
34use crate::pdf_meta::PdfMeta;
35use geom::Mat;
36
37pub fn pixels_for_extent(extent: f64) -> u32 {
39 ((extent - 1e-6).ceil().max(1.0)) as u32
40}
41
42pub struct Renderer<'a> {
46 meta: &'a PdfMeta,
47 shared: Rc<content::Shared>,
48}
49
50impl<'a> Renderer<'a> {
51 pub fn new(meta: &'a PdfMeta) -> Renderer<'a> {
54 Renderer {
55 meta,
56 shared: Rc::new(content::Shared::default()),
57 }
58 }
59
60 pub fn with_bitmap_hint(meta: &'a PdfMeta, bitmap_hint: f64) -> Renderer<'a> {
65 Renderer {
66 meta,
67 shared: Rc::new(content::Shared::with_bitmap_hint(bitmap_hint)),
68 }
69 }
70
71 pub fn render_scaled(&self, index: usize, scale: f64) -> Option<RgbImage> {
74 let geom = self.meta.geometry(index)?;
75 let w = pixels_for_extent(f64::from(geom.width) * scale);
76 let h = pixels_for_extent(f64::from(geom.height) * scale);
77 self.render(index, w, h)
78 }
79
80 pub fn render(&self, index: usize, width: u32, height: u32) -> Option<RgbImage> {
84 self.render_with_hint(index, width, height, self.shared.bitmap_hint())
85 }
86
87 pub fn render_with_hint(
94 &self,
95 index: usize,
96 width: u32,
97 height: u32,
98 bitmap_hint: f64,
99 ) -> Option<RgbImage> {
100 if width == 0 || height == 0 || width > 1 << 15 || height > 1 << 15 {
101 return None;
102 }
103 let meta = self.meta;
104 let doc = meta.doc();
105 let pid = meta.page_id(index)?;
106 let page = doc.get_object(pid).ok()?.as_dict().ok()?;
107 let geom = meta.geometry(index)?;
108 let pb = crate::textparse::page_box(doc, pid);
109 let (cw, ch) = if geom.rotation == 90 || geom.rotation == 270 {
111 (height, width)
112 } else {
113 (width, height)
114 };
115 let sx = f64::from(cw) / f64::from(pb.w).max(1e-6);
116 let sy = f64::from(ch) / f64::from(pb.h).max(1e-6);
117 let base = Mat::new(
119 sx,
120 0.0,
121 0.0,
122 -sy,
123 -f64::from(pb.l) * sx,
124 f64::from(ch) + f64::from(pb.b) * sy,
125 );
126
127 let content = doc.get_page_content(pid);
128 let resources = page_resources(doc, pid);
129 let mut interp = content::Interp::new(doc, cw, ch, self.shared.clone(), bitmap_hint)?;
130 crate::timing::timed("render.content", || {
131 interp.run_page(&content, resources, base);
132 interp.run_widgets(page, base);
133 });
134 let canvas = interp.into_canvas();
135 let rgb = crate::timing::timed("render.finish", || to_rgb(&canvas));
136 Some(rotate(rgb, geom.rotation))
137 }
138}
139
140pub fn render_page(meta: &PdfMeta, index: usize, scale: f64) -> Option<RgbImage> {
142 Renderer::new(meta).render_scaled(index, scale)
143}
144
145pub fn render_page_sized(
147 meta: &PdfMeta,
148 index: usize,
149 width: u32,
150 height: u32,
151) -> Option<RgbImage> {
152 Renderer::new(meta).render(index, width, height)
153}
154
155fn page_resources(doc: &Document, pid: lopdf::ObjectId) -> Option<&lopdf::Dictionary> {
156 doc.get_page_resources(pid).ok().and_then(|(inline, ids)| {
157 inline.or_else(|| ids.into_iter().find_map(|id| doc.get_dictionary(id).ok()))
158 })
159}
160
161fn to_rgb(pm: &tiny_skia::Pixmap) -> RgbImage {
164 let (w, h) = (pm.width(), pm.height());
165 let mut out = RgbImage::new(w, h);
166 let data = pm.data();
167 for (i, px) in out.pixels_mut().enumerate() {
168 let a = u32::from(data[i * 4 + 3]);
169 let un = |c: u8| -> u8 {
170 if a == 0 {
171 255
172 } else if a == 255 {
173 c
174 } else {
175 let c = u32::from(c);
177 (c + (255 - a)).min(255) as u8
178 }
179 };
180 *px = ::image::Rgb([un(data[i * 4]), un(data[i * 4 + 1]), un(data[i * 4 + 2])]);
181 }
182 out
183}
184
185fn rotate(img: RgbImage, rotation: u16) -> RgbImage {
187 match rotation {
188 90 => ::image::imageops::rotate90(&img),
189 180 => ::image::imageops::rotate180(&img),
190 270 => ::image::imageops::rotate270(&img),
191 _ => img,
192 }
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198
199 fn root() -> std::path::PathBuf {
200 std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..")
201 }
202
203 #[test]
206 fn renders_the_corpus() {
207 let mut files = Vec::new();
212 for d in corpus_dirs() {
213 collect_pdfs(&root().join(d), &mut files);
214 }
215 files.sort();
216 assert!(!files.is_empty());
217 for f in files {
218 let bytes = std::fs::read(&f).unwrap();
219 let Some(meta) = PdfMeta::open(&bytes) else {
220 continue;
221 };
222 let renderer = Renderer::new(&meta);
223 for i in 0..meta.page_count().min(3) {
224 let g = meta.geometry(i).unwrap();
225 let img = renderer
226 .render_scaled(i, 1.0)
227 .unwrap_or_else(|| panic!("{}: page {} declined", f.display(), i + 1));
228 assert_eq!(
229 img.width(),
230 pixels_for_extent(f64::from(g.width)),
231 "{}",
232 f.display()
233 );
234 assert_eq!(
235 img.height(),
236 pixels_for_extent(f64::from(g.height)),
237 "{}",
238 f.display()
239 );
240 let dark = img.pixels().filter(|p| p[0] < 200).count();
241 assert!(dark > 0, "{} p{}: blank render", f.display(), i + 1);
242 }
243 }
244 }
245
246 #[test]
251 fn against_the_docling_parse_shim() {
252 if std::env::var_os("DOCLING_PARSE_RENDER_LIB").is_none() {
254 let lib = root().join(".docling-parse/lib");
255 if lib.is_dir() {
256 std::env::set_var("DOCLING_PARSE_RENDER_LIB", &lib);
257 }
258 }
259 std::env::set_var("DOCLING_RS_RENDERER", "docling-parse");
262 let Some(plugin) = crate::dparse_render::plugin() else {
263 eprintln!("docling-parse shim not installed — skipping the renderer comparison");
264 return;
265 };
266 let _ = plugin;
267 let mut files = Vec::new();
270 for d in corpus_dirs() {
271 collect_pdfs(&root().join(d), &mut files);
272 }
273 files.sort();
274 eprintln!("renderer vs shim: {} PDFs", files.len());
275 let mut worst = 0.0f64;
276 let mut total_mad = 0.0f64;
277 let mut n = 0usize;
278 for f in &files {
279 let bytes = std::fs::read(f).unwrap();
280 let Some(meta) = PdfMeta::open(&bytes) else {
281 eprintln!("{}: object model unreadable, skipped", f.display());
282 continue;
283 };
284 let renderer = Renderer::new(&meta);
285 let Some(dp) = crate::dparse_render::Doc::open_if_enabled(&bytes, None) else {
286 eprintln!("{}: the shim declined the file, skipped", f.display());
287 continue;
288 };
289 for i in 0..meta.page_count() {
290 for scale in [1.0f64, 2.0] {
291 let Ok(want) = dp.render(i, scale, 1.0) else {
292 continue;
293 };
294 let Some(got) = renderer.render(i, want.width(), want.height()) else {
295 continue;
296 };
297 let mad = mean_abs_diff(&got, &want);
298 worst = worst.max(mad);
299 total_mad += mad;
300 n += 1;
301 eprintln!(
302 "{} p{} @{scale}: mean |Δ| = {mad:.2}",
303 f.file_name().unwrap().to_string_lossy(),
304 i + 1
305 );
306 }
307 dp.release_page(i);
308 }
309 }
310 if n > 0 {
311 eprintln!(
312 "renderer vs shim: {n} renders, mean |Δ| {:.2}, worst {worst:.2}",
313 total_mad / n as f64
314 );
315 }
316 assert!(
320 worst < 10.0,
321 "a render diverged from the shim (mean |Δ| {worst:.1})"
322 );
323 if n > 0 {
324 assert!(
325 total_mad / (n as f64) < 2.0,
326 "the corpus drifted from the shim"
327 );
328 }
329 }
330
331 fn corpus_dirs() -> &'static [&'static str] {
334 if std::env::var("DOCLING_RS_SHIM_CORPUS").as_deref() == Ok("pdf") {
335 &["tests/data/pdf/sources"]
336 } else {
337 &[
338 "tests/data/pdf/sources",
339 "tests/data/scanned/sources",
340 "tests/data/latex/sources",
341 "tests/data/odf/sources",
342 ]
343 }
344 }
345
346 fn collect_pdfs(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
347 let Ok(rd) = std::fs::read_dir(dir) else {
348 return;
349 };
350 for e in rd.flatten() {
351 let p = e.path();
352 if p.is_dir() {
353 collect_pdfs(&p, out);
354 } else if p.extension().is_some_and(|e| e == "pdf") {
355 out.push(p);
356 }
357 }
358 }
359
360 fn mean_abs_diff(a: &RgbImage, b: &RgbImage) -> f64 {
361 if a.dimensions() != b.dimensions() {
362 return 255.0;
363 }
364 let sum: u64 = a
365 .as_raw()
366 .iter()
367 .zip(b.as_raw())
368 .map(|(x, y)| u64::from((i32::from(*x) - i32::from(*y)).unsigned_abs()))
369 .sum();
370 sum as f64 / a.as_raw().len().max(1) as f64
371 }
372}
373
374#[cfg(test)]
380mod synthetic {
381 use super::*;
382 use lopdf::{dictionary, Dictionary, Object, Stream};
383
384 fn synth(
387 doc: &mut Document,
388 media: [f32; 4],
389 page_extra: Dictionary,
390 resources: Dictionary,
391 content: &str,
392 ) -> Vec<u8> {
393 synth_bytes(doc, media, page_extra, resources, content.as_bytes())
394 }
395
396 fn synth_bytes(
397 doc: &mut Document,
398 media: [f32; 4],
399 page_extra: Dictionary,
400 resources: Dictionary,
401 content: &[u8],
402 ) -> Vec<u8> {
403 let pages_id = doc.new_object_id();
404 let res_id = doc.add_object(resources);
405 let content_id = doc.add_object(Stream::new(Dictionary::new(), content.to_vec()));
406 let mut page = dictionary! {
407 "Type" => "Page",
408 "Parent" => Object::Reference(pages_id),
409 "MediaBox" => media.iter().map(|&v| Object::Real(v)).collect::<Vec<_>>(),
410 "Contents" => Object::Reference(content_id),
411 "Resources" => Object::Reference(res_id),
412 };
413 for (k, v) in page_extra.into_iter() {
414 page.set(k, v);
415 }
416 let page_id = doc.add_object(page);
417 doc.objects.insert(
418 pages_id,
419 Object::Dictionary(dictionary! {
420 "Type" => "Pages",
421 "Kids" => vec![Object::Reference(page_id)],
422 "Count" => 1,
423 }),
424 );
425 let catalog_id = doc.add_object(dictionary! {
426 "Type" => "Catalog",
427 "Pages" => Object::Reference(pages_id),
428 });
429 doc.trailer.set("Root", Object::Reference(catalog_id));
430 let mut out = Vec::new();
431 doc.save_to(&mut out).unwrap();
432 out
433 }
434
435 fn page(resources: Dictionary, content: &str) -> Vec<u8> {
437 let mut doc = Document::with_version("1.5");
438 synth(
439 &mut doc,
440 [0.0, 0.0, 200.0, 100.0],
441 Dictionary::new(),
442 resources,
443 content,
444 )
445 }
446
447 fn page_with_contents(contents: &[&[u8]]) -> Vec<u8> {
448 let mut doc = Document::with_version("1.5");
449 let streams = contents
450 .iter()
451 .map(|content| {
452 Object::Reference(doc.add_object(Stream::new(Dictionary::new(), content.to_vec())))
453 })
454 .collect::<Vec<_>>();
455 synth(
456 &mut doc,
457 [0.0, 0.0, 200.0, 100.0],
458 dictionary! { "Contents" => streams },
459 Dictionary::new(),
460 "",
461 )
462 }
463
464 fn render(bytes: &[u8]) -> RgbImage {
465 let meta = PdfMeta::open(bytes).expect("object model");
466 render_page(&meta, 0, 1.0).expect("render")
467 }
468
469 fn px(img: &RgbImage, x: u32, y: u32) -> [u8; 3] {
470 img.get_pixel(x, y).0
471 }
472
473 fn near(a: [u8; 3], b: [u8; 3], tol: i32) -> bool {
474 a.iter()
475 .zip(b)
476 .all(|(&x, y)| (i32::from(x) - i32::from(y)).abs() <= tol)
477 }
478
479 const WHITE: [u8; 3] = [255, 255, 255];
480 const RED: [u8; 3] = [255, 0, 0];
481
482 fn ink_in(img: &RgbImage, x0: u32, y0: u32, x1: u32, y1: u32) -> usize {
484 let mut n = 0;
485 for y in y0..y1 {
486 for x in x0..x1 {
487 if px(img, x, y) != WHITE {
488 n += 1;
489 }
490 }
491 }
492 n
493 }
494
495 #[test]
496 fn fills_land_in_the_docling_parse_frame() {
497 let img = render(&page(Dictionary::new(), "1 0 0 rg 60 10 40 30 re f"));
500 assert_eq!((img.width(), img.height()), (200, 100));
501 assert_eq!(px(&img, 80, 75), RED);
502 assert_eq!(px(&img, 61, 61), RED);
503 assert_eq!(px(&img, 80, 55), WHITE);
504 assert_eq!(px(&img, 105, 75), WHITE);
505 assert_eq!(px(&img, 5, 5), WHITE);
506 }
507
508 #[test]
509 fn leading_comment_content_stream_does_not_hide_later_streams() {
510 let bytes = page_with_contents(&[
511 b"% CANON_PFINF_TYPE2_TEXTOFF\n",
512 b"1 0 0 rg 60 10 40 30 re f",
513 ]);
514 let img = render(&bytes);
515 assert_eq!(px(&img, 80, 75), RED);
516 }
517
518 #[test]
519 fn crop_box_is_the_canvas() {
520 let mut doc = Document::with_version("1.5");
523 let bytes = synth(
524 &mut doc,
525 [0.0, 0.0, 200.0, 100.0],
526 dictionary! { "CropBox" => vec![50.into(), 0.into(), 150.into(), 100.into()] },
527 Dictionary::new(),
528 "1 0 0 rg 60 10 40 30 re f",
529 );
530 let img = render(&bytes);
531 assert_eq!((img.width(), img.height()), (100, 100));
532 assert_eq!(px(&img, 30, 75), RED);
533 assert_eq!(px(&img, 5, 75), WHITE);
534 assert_eq!(px(&img, 55, 75), WHITE);
535 }
536
537 #[test]
538 fn rotate_transposes_the_canvas() {
539 let mut doc = Document::with_version("1.5");
542 let bytes = synth(
543 &mut doc,
544 [0.0, 0.0, 200.0, 100.0],
545 dictionary! { "Rotate" => 90 },
546 Dictionary::new(),
547 "1 0 0 rg 0 0 40 20 re f",
548 );
549 let img = render(&bytes);
550 assert_eq!((img.width(), img.height()), (100, 200));
551 assert_eq!(px(&img, 10, 20), RED);
552 assert_eq!(px(&img, 10, 45), WHITE);
553 assert_eq!(px(&img, 90, 190), WHITE);
554 let meta = PdfMeta::open(&bytes).unwrap();
555 let sized = render_page_sized(&meta, 0, 50, 100).unwrap();
556 assert_eq!((sized.width(), sized.height()), (50, 100));
557 assert_eq!(px(&sized, 5, 10), RED);
558 }
559
560 #[test]
561 fn hairline_strokes_are_one_pixel_wide() {
562 let img = render(&page(Dictionary::new(), "0 w 10 50.5 m 190 50.5 l S"));
564 assert!(ink_in(&img, 100, 49, 101, 51) >= 1);
565 assert_eq!(ink_in(&img, 100, 40, 101, 48), 0);
566 assert_eq!(ink_in(&img, 100, 52, 101, 60), 0);
567 let img = render(&page(Dictionary::new(), "4 w 10 50 m 190 50 l S"));
569 assert!(ink_in(&img, 100, 44, 101, 56) >= 4);
570 assert_eq!(ink_in(&img, 100, 40, 101, 47), 0);
571 }
572
573 #[test]
574 fn constant_alpha_blends_over_white() {
575 let res = dictionary! {
576 "ExtGState" => dictionary! {
577 "GS" => dictionary! { "ca" => 0.5, "CA" => 0.25 },
578 },
579 };
580 let img = render(&page(res, "/GS gs 0 g 20 20 160 60 re f"));
581 let p = px(&img, 100, 50);
582 assert!(near(p, [128, 128, 128], 2), "{p:?}");
583 }
584
585 #[test]
586 fn rect_clip_cuts_the_fill() {
587 let img = render(&page(
588 Dictionary::new(),
589 "q 0 0 100 100 re W n 0 0 1 rg 0 0 200 100 re f Q 0 0 200 10 re f",
590 ));
591 assert_eq!(px(&img, 50, 50), [0, 0, 255]);
592 assert_eq!(px(&img, 150, 50), WHITE);
593 assert_eq!(px(&img, 150, 95), [0, 0, 0]);
595 }
596
597 #[test]
598 fn shape_clip_follows_the_path() {
599 let img = render(&page(
601 Dictionary::new(),
602 "q 0 0 m 200 0 l 100 100 l h W n 0 1 0 rg 0 0 200 100 re f Q",
603 ));
604 assert_eq!(px(&img, 100, 50), [0, 255, 0]);
605 assert_eq!(px(&img, 100, 90), [0, 255, 0]);
606 assert_eq!(px(&img, 5, 5), WHITE);
607 assert_eq!(px(&img, 195, 5), WHITE);
608 }
609
610 #[test]
611 fn image_xobject_is_blitted_with_its_orientation() {
612 let data = vec![255, 0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0];
614 let mut doc = Document::with_version("1.5");
615 let im = doc.add_object(Stream::new(
616 dictionary! {
617 "Type" => "XObject", "Subtype" => "Image",
618 "Width" => 2, "Height" => 2, "BitsPerComponent" => 8,
619 "ColorSpace" => "DeviceRGB",
620 },
621 data,
622 ));
623 let bytes = synth(
624 &mut doc,
625 [0.0, 0.0, 200.0, 100.0],
626 Dictionary::new(),
627 dictionary! { "XObject" => dictionary! { "Im0" => Object::Reference(im) } },
628 "q 200 0 0 100 0 0 cm /Im0 Do Q",
629 );
630 let img = render(&bytes);
631 assert!(near(px(&img, 50, 25), RED, 6));
634 assert!(near(px(&img, 150, 25), [0, 255, 0], 6));
635 assert!(near(px(&img, 50, 75), [0, 0, 255], 6));
636 assert!(near(px(&img, 150, 75), [0, 0, 0], 6));
637 }
638
639 #[test]
640 fn inline_image_and_stencil_mask() {
641 let mut content = b"q 100 0 0 100 0 0 cm BI /W 1 /H 1 /CS /G /BPC 8 ID ".to_vec();
644 content.push(0x80);
645 content.extend_from_slice(
646 b" EI Q q 1 0 0 rg 100 0 0 100 100 0 cm BI /W 2 /H 1 /IM true /D [0 1] ID ",
647 );
648 content.push(0x40);
649 content.extend_from_slice(b" EI Q");
650 let mut doc = Document::with_version("1.5");
651 let bytes = synth_bytes(
652 &mut doc,
653 [0.0, 0.0, 200.0, 100.0],
654 Dictionary::new(),
655 Dictionary::new(),
656 &content,
657 );
658 let img = render(&bytes);
659 assert!(near(px(&img, 50, 50), [128, 128, 128], 1));
660 assert_eq!(px(&img, 125, 50), RED);
661 assert_eq!(px(&img, 175, 50), WHITE);
662 }
663
664 #[test]
665 fn axial_shading_interpolates() {
666 let res = dictionary! {
667 "Shading" => dictionary! {
668 "Sh" => dictionary! {
669 "ShadingType" => 2, "ColorSpace" => "DeviceGray",
670 "Coords" => vec![0.into(), 0.into(), 200.into(), 0.into()],
671 "Function" => dictionary! {
672 "FunctionType" => 2, "Domain" => vec![0.into(), 1.into()],
673 "C0" => vec![0.into()], "C1" => vec![1.into()], "N" => 1,
674 },
675 "Extend" => vec![true.into(), true.into()],
676 },
677 },
678 };
679 let img = render(&page(res, "/Sh sh"));
680 assert!(px(&img, 5, 50)[0] < 40);
681 assert!(px(&img, 195, 50)[0] > 215);
682 assert!(near(px(&img, 100, 50), [128, 128, 128], 12));
683 let res = dictionary! {
685 "Pattern" => dictionary! {
686 "P" => dictionary! {
687 "PatternType" => 2,
688 "Shading" => dictionary! {
689 "ShadingType" => 2, "ColorSpace" => "DeviceGray",
690 "Coords" => vec![0.into(), 0.into(), 200.into(), 0.into()],
691 "Function" => dictionary! {
692 "FunctionType" => 2, "Domain" => vec![0.into(), 1.into()],
693 "C0" => vec![0.into()], "C1" => vec![1.into()], "N" => 1,
694 },
695 },
696 },
697 },
698 };
699 let img = render(&page(res, "/Pattern cs /P scn 0 0 100 100 re f"));
700 assert!(px(&img, 5, 50)[0] < 40);
701 assert!(near(px(&img, 50, 50), [64, 64, 64], 12));
702 assert_eq!(px(&img, 150, 50), WHITE);
703 }
704
705 #[test]
706 fn tiling_pattern_repeats_its_cell() {
707 let mut doc = Document::with_version("1.5");
709 let pat = doc.add_object(Stream::new(
710 dictionary! {
711 "PatternType" => 1, "PaintType" => 1, "TilingType" => 1,
712 "BBox" => vec![0.into(), 0.into(), 20.into(), 20.into()],
713 "XStep" => 20, "YStep" => 20, "Resources" => Dictionary::new(),
714 },
715 b"0 g 0 0 10 10 re f".to_vec(),
716 ));
717 let bytes = synth(
718 &mut doc,
719 [0.0, 0.0, 200.0, 100.0],
720 Dictionary::new(),
721 dictionary! { "Pattern" => dictionary! { "P" => Object::Reference(pat) } },
722 "/Pattern cs /P scn 0 0 200 100 re f",
723 );
724 let img = render(&bytes);
725 let inked = ink_in(&img, 0, 0, 200, 100);
726 assert!((4000..=6500).contains(&inked), "{inked}");
727 assert_eq!(px(&img, 5, 95), [0, 0, 0]);
729 assert_eq!(px(&img, 15, 85), WHITE);
730 }
731
732 #[test]
733 fn form_xobject_matrix_and_bbox() {
734 let mut doc = Document::with_version("1.5");
737 let fx = doc.add_object(Stream::new(
738 dictionary! {
739 "Type" => "XObject", "Subtype" => "Form",
740 "BBox" => vec![0.into(), 0.into(), 50.into(), 50.into()],
741 "Matrix" => vec![1.into(), 0.into(), 0.into(), 1.into(), 100.into(), 0.into()],
742 },
743 b"1 0 0 rg -500 -500 1000 1000 re f".to_vec(),
744 ));
745 let bytes = synth(
746 &mut doc,
747 [0.0, 0.0, 200.0, 100.0],
748 Dictionary::new(),
749 dictionary! { "XObject" => dictionary! { "Fx" => Object::Reference(fx) } },
750 "/Fx Do",
751 );
752 let img = render(&bytes);
753 assert_eq!(px(&img, 125, 75), RED);
754 assert_eq!(px(&img, 75, 75), WHITE);
755 assert_eq!(px(&img, 125, 25), WHITE);
756 assert_eq!(px(&img, 175, 75), WHITE);
757 }
758
759 #[test]
760 fn transparency_group_alpha_reaches_its_contents() {
761 let mut doc = Document::with_version("1.5");
763 let fx = doc.add_object(Stream::new(
764 dictionary! {
765 "Type" => "XObject", "Subtype" => "Form",
766 "BBox" => vec![0.into(), 0.into(), 200.into(), 100.into()],
767 "Group" => dictionary! { "S" => "Transparency", "CS" => "DeviceRGB" },
768 },
769 b"0 g 0 0 200 100 re f".to_vec(),
770 ));
771 let bytes = synth(
772 &mut doc,
773 [0.0, 0.0, 200.0, 100.0],
774 Dictionary::new(),
775 dictionary! {
776 "XObject" => dictionary! { "Fx" => Object::Reference(fx) },
777 "ExtGState" => dictionary! { "GS" => dictionary! { "ca" => 0.5 } },
778 },
779 "/GS gs /Fx Do",
780 );
781 let img = render(&bytes);
782 assert!(near(px(&img, 100, 50), [128, 128, 128], 2));
783 }
784
785 #[test]
786 fn widgets_draw_their_appearance_unless_hidden() {
787 let mut doc = Document::with_version("1.5");
788 let ap = doc.add_object(Stream::new(
789 dictionary! {
790 "Type" => "XObject", "Subtype" => "Form",
791 "BBox" => vec![0.into(), 0.into(), 10.into(), 10.into()],
792 },
793 b"1 0 0 rg 0 0 10 10 re f".to_vec(),
794 ));
795 let widget = |x0: i64, flags: i64, subtype: &str| -> Object {
796 Object::Dictionary(dictionary! {
797 "Type" => "Annot", "Subtype" => subtype, "F" => flags,
798 "Rect" => vec![x0.into(), 20.into(), (x0 + 40).into(), 60.into()],
799 "AP" => dictionary! { "N" => Object::Reference(ap) },
800 })
801 };
802 let bytes = synth(
803 &mut doc,
804 [0.0, 0.0, 200.0, 100.0],
805 dictionary! {
806 "Annots" => vec![
807 widget(10, 4, "Widget"),
808 widget(60, 2, "Widget"),
809 widget(110, 4, "Square"),
810 ],
811 },
812 Dictionary::new(),
813 "",
814 );
815 let img = render(&bytes);
816 assert_eq!(px(&img, 30, 60), RED);
818 assert_eq!(px(&img, 12, 42), RED);
819 assert_eq!(px(&img, 48, 78), RED);
820 assert_eq!(px(&img, 30, 30), WHITE);
821 assert_eq!(px(&img, 80, 60), WHITE);
823 assert_eq!(px(&img, 130, 60), WHITE);
824 }
825
826 #[test]
827 fn text_paints_glyphs_from_a_fallback_face() {
828 let style = font::fallback::style_for("Helvetica", None, None);
829 if font::fallback::face(style).is_none() {
830 eprintln!("no fallback fonts on this host — skipping");
831 return;
832 }
833 let res = dictionary! {
834 "Font" => dictionary! {
835 "F1" => dictionary! {
836 "Type" => "Font", "Subtype" => "Type1", "BaseFont" => "Helvetica",
837 },
838 },
839 };
840 let img = render(&page(res.clone(), "BT /F1 40 Tf 20 30 Td (HELLO) Tj ET"));
841 assert!(ink_in(&img, 20, 40, 160, 70) > 200);
843 assert_eq!(ink_in(&img, 0, 0, 200, 35), 0);
844 assert_eq!(ink_in(&img, 0, 75, 200, 100), 0);
845 let img = render(&page(
848 res.clone(),
849 "BT 3 Tr /F1 40 Tf 20 30 Td (HELLO) Tj ET",
850 ));
851 assert_eq!(ink_in(&img, 0, 0, 200, 100), 0);
852 let wide = render(&page(res.clone(), "BT /F1 40 Tf 20 30 Td (HHHH) Tj ET"));
853 let narrow = render(&page(res, "BT /F1 40 Tf 50 Tz 20 30 Td (HHHH) Tj ET"));
854 let right = |img: &RgbImage| {
855 (0..200)
856 .rev()
857 .find(|&x| ink_in(img, x, 0, x + 1, 100) > 0)
858 .unwrap()
859 };
860 assert!(right(&wide) > right(&narrow) + 30);
861 }
862
863 #[test]
864 fn type3_glyphs_run_their_procedures() {
865 let mut doc = Document::with_version("1.5");
866 let square = doc.add_object(Stream::new(
867 Dictionary::new(),
868 b"1000 0 0 0 750 750 d1 0 0 750 750 re f".to_vec(),
869 ));
870 let font = doc.add_object(dictionary! {
871 "Type" => "Font", "Subtype" => "Type3",
872 "FontBBox" => vec![0.into(), 0.into(), 750.into(), 750.into()],
873 "FontMatrix" => vec![0.001.into(), 0.into(), 0.into(), 0.001.into(), 0.into(), 0.into()],
874 "CharProcs" => dictionary! { "square" => Object::Reference(square) },
875 "Encoding" => dictionary! {
876 "Type" => "Encoding",
877 "Differences" => vec![97.into(), Object::Name(b"square".to_vec())],
878 },
879 "FirstChar" => 97, "LastChar" => 97, "Widths" => vec![1000.into()],
880 "Resources" => Dictionary::new(),
881 });
882 let bytes = synth(
883 &mut doc,
884 [0.0, 0.0, 200.0, 100.0],
885 Dictionary::new(),
886 dictionary! { "Font" => dictionary! { "F1" => Object::Reference(font) } },
887 "BT /F1 40 Tf 20 20 Td 0 0 1 rg (aa) Tj ET",
888 );
889 let img = render(&bytes);
890 assert_eq!(px(&img, 35, 65), [0, 0, 255]);
892 assert_eq!(px(&img, 75, 65), [0, 0, 255]);
893 assert_eq!(px(&img, 55, 65), WHITE);
894 assert_eq!(px(&img, 105, 65), WHITE);
895 assert_eq!(px(&img, 35, 45), WHITE);
896 }
897
898 #[test]
899 fn cmyk_follows_the_docling_parse_model() {
900 let img = render(&page(Dictionary::new(), "0 0 0 1 k 0 0 200 100 re f"));
902 assert_eq!(px(&img, 100, 50), [35, 31, 32]);
903 }
904
905 #[test]
906 fn a_renderer_reuses_its_caches_across_renders() {
907 let data: Vec<u8> = (0..64 * 64 * 3).map(|i| (i % 251) as u8).collect();
910 let mut doc = Document::with_version("1.5");
911 let im = doc.add_object(Stream::new(
912 dictionary! {
913 "Type" => "XObject", "Subtype" => "Image",
914 "Width" => 64, "Height" => 64, "BitsPerComponent" => 8,
915 "ColorSpace" => "DeviceRGB",
916 },
917 data,
918 ));
919 let bytes = synth(
920 &mut doc,
921 [0.0, 0.0, 200.0, 100.0],
922 Dictionary::new(),
923 dictionary! { "XObject" => dictionary! { "Im0" => Object::Reference(im) } },
924 "q 100 0 0 50 50 25 cm /Im0 Do Q q 20 0 0 20 0 0 cm /Im0 Do Q",
925 );
926 let meta = PdfMeta::open(&bytes).unwrap();
927 let r = Renderer::new(&meta);
928 let a1 = r.render(0, 200, 100).unwrap();
929 let a2 = r.render(0, 400, 200).unwrap();
930 let a3 = r.render(0, 200, 100).unwrap();
931 assert_eq!(
932 a1.as_raw(),
933 render_page_sized(&meta, 0, 200, 100).unwrap().as_raw()
934 );
935 assert_eq!(
936 a2.as_raw(),
937 render_page_sized(&meta, 0, 400, 200).unwrap().as_raw()
938 );
939 assert_eq!(a1.as_raw(), a3.as_raw());
940 }
941
942 #[test]
947 fn jpx_image_renders_its_pixels() {
948 let jp2 = std::fs::read(
949 std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/data/jpx/gray_12x9.jp2"),
950 )
951 .unwrap();
952 let mut doc = Document::with_version("1.5");
953 let im = doc.add_object(Stream::new(
954 dictionary! {
955 "Type" => "XObject", "Subtype" => "Image",
956 "Width" => 12, "Height" => 9,
957 "ColorSpace" => "DeviceGray", "BitsPerComponent" => 8,
958 "Filter" => "JPXDecode",
959 },
960 jp2.clone(),
961 ));
962 let bytes = synth(
963 &mut doc,
964 [0.0, 0.0, 200.0, 100.0],
965 Dictionary::new(),
966 dictionary! { "XObject" => dictionary! { "Im0" => Object::Reference(im) } },
967 "q 120 0 0 90 40 5 cm /Im0 Do Q",
968 );
969 let img = render(&bytes);
970 let left = px(&img, 45, 50);
973 let right = px(&img, 155, 50);
974 assert!(left[0] < 40 && left == [left[0]; 3], "left pixel {left:?}");
975 assert!(
976 right[0] > 215 && right == [right[0]; 3],
977 "right pixel {right:?}"
978 );
979 assert_eq!(px(&img, 20, 50), WHITE, "outside the image stays white");
980
981 let rgba = std::fs::read(
985 std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/data/jpx/rgba_8x6.jp2"),
986 )
987 .unwrap();
988 for (smask_in_data, transparent_left) in [(0, false), (1, true)] {
989 let mut doc = Document::with_version("1.5");
990 let im = doc.add_object(Stream::new(
991 dictionary! {
992 "Type" => "XObject", "Subtype" => "Image",
993 "Width" => 8, "Height" => 6,
994 "ColorSpace" => "DeviceRGB", "BitsPerComponent" => 8,
995 "Filter" => "JPXDecode", "SMaskInData" => smask_in_data,
996 },
997 rgba.clone(),
998 ));
999 let bytes = synth(
1000 &mut doc,
1001 [0.0, 0.0, 200.0, 100.0],
1002 Dictionary::new(),
1003 dictionary! { "XObject" => dictionary! { "Im0" => Object::Reference(im) } },
1004 "q 160 0 0 60 20 20 cm /Im0 Do Q",
1005 );
1006 let img = render(&bytes);
1007 let p = px(&img, 30, 50);
1010 if transparent_left {
1011 assert!(near(p, WHITE, 3), "SMaskInData {smask_in_data}: {p:?}");
1012 } else {
1013 assert!(
1014 p != WHITE && p[2] > 100,
1015 "SMaskInData {smask_in_data}: {p:?}"
1016 );
1017 }
1018 }
1019 }
1020
1021 fn jbig2_fixture(name: &str) -> Vec<u8> {
1024 std::fs::read(
1025 std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1026 .join("tests/data/jbig2")
1027 .join(name),
1028 )
1029 .unwrap()
1030 }
1031
1032 fn jbig2_reference(name: &str) -> (usize, usize, Vec<u8>) {
1033 let img = ::image::load_from_memory(&jbig2_fixture(name))
1034 .unwrap()
1035 .to_luma8();
1036 let (w, h) = (img.width() as usize, img.height() as usize);
1037 let stride = w.div_ceil(8);
1038 let mut out = vec![0xffu8; stride * h];
1039 for (y, row) in img.rows().enumerate() {
1040 for (x, p) in row.enumerate() {
1041 if p.0[0] < 128 {
1042 out[y * stride + x / 8] &= !(0x80 >> (x % 8));
1043 }
1044 }
1045 }
1046 (w, h, out)
1047 }
1048
1049 fn page_with_image(stream: Stream, content: &str) -> RgbImage {
1052 let mut doc = Document::with_version("1.5");
1053 let im = doc.add_object(stream);
1054 let bytes = synth(
1055 &mut doc,
1056 [0.0, 0.0, 440.0, 180.0],
1057 Dictionary::new(),
1058 dictionary! { "XObject" => dictionary! { "Im0" => Object::Reference(im) } },
1059 content,
1060 );
1061 render(&bytes)
1062 }
1063
1064 fn non_white_share(img: &RgbImage) -> f64 {
1065 let n = img.pixels().filter(|p| p.0 != WHITE).count();
1066 n as f64 / (img.width() as f64 * img.height() as f64)
1067 }
1068
1069 #[test]
1074 fn jbig2_stencil_renders_like_its_flate_and_ccitt_twins() {
1075 let (w, h, bits) = jbig2_reference("generic_mmr.png");
1076 let content = "q 0 0 0 rg 326 0 0 218 57 -19 cm /Im0 Do Q";
1077 let mask = |filter: Dictionary, data: Vec<u8>| {
1078 let mut d = dictionary! {
1079 "Type" => "XObject", "Subtype" => "Image",
1080 "Width" => w as i64, "Height" => h as i64,
1081 "ImageMask" => true,
1082 };
1083 for (k, v) in filter.into_iter() {
1084 d.set(k, v);
1085 }
1086 Stream::new(d, data)
1087 };
1088 let flate = page_with_image(mask(Dictionary::new(), bits.clone()), content);
1089 let jbig2 = page_with_image(
1090 mask(
1091 dictionary! { "Filter" => "JBIG2Decode" },
1092 jbig2_fixture("generic_mmr.jbig2"),
1093 ),
1094 content,
1095 );
1096 let ccitt = page_with_image(
1097 mask(
1098 dictionary! {
1099 "Filter" => "CCITTFaxDecode",
1100 "DecodeParms" => dictionary! { "K" => -1, "Columns" => w as i64, "Rows" => h as i64 },
1101 },
1102 jbig2_fixture("generic_mmr.g4"),
1103 ),
1104 content,
1105 );
1106 let share = non_white_share(&flate);
1107 assert!(share > 0.05 && share < 0.95, "control share {share}");
1108 assert_eq!(jbig2.as_raw(), flate.as_raw(), "JBIG2 stencil vs Flate");
1109 assert_eq!(ccitt.as_raw(), flate.as_raw(), "CCITT stencil vs Flate");
1110 }
1111
1112 #[test]
1116 fn jbig2_gray_image_and_globals_render_their_pixels() {
1117 let content = "q 326 0 0 218 57 -19 cm /Im0 Do Q";
1118 let gray = |w: usize, h: usize, extra: Dictionary, data: Vec<u8>| {
1119 let mut d = dictionary! {
1120 "Type" => "XObject", "Subtype" => "Image",
1121 "Width" => w as i64, "Height" => h as i64,
1122 "ColorSpace" => "DeviceGray", "BitsPerComponent" => 1,
1123 };
1124 for (k, v) in extra.into_iter() {
1125 d.set(k, v);
1126 }
1127 Stream::new(d, data)
1128 };
1129 let (w, h, bits) = jbig2_reference("generic_mmr.png");
1130 let flate = page_with_image(gray(w, h, Dictionary::new(), bits), content);
1131 let jbig2 = page_with_image(
1132 gray(
1133 w,
1134 h,
1135 dictionary! { "Filter" => "JBIG2Decode" },
1136 jbig2_fixture("generic_mmr.jbig2"),
1137 ),
1138 content,
1139 );
1140 assert!(non_white_share(&flate) > 0.05);
1141 assert_eq!(jbig2.as_raw(), flate.as_raw(), "JBIG2 gray vs Flate");
1142
1143 let (w, h, bits) = jbig2_reference("symbol_global.png");
1145 let flate = page_with_image(gray(w, h, Dictionary::new(), bits), content);
1146 let mut doc = Document::with_version("1.5");
1147 let globals = doc.add_object(Stream::new(
1148 Dictionary::new(),
1149 jbig2_fixture("symbol_global.globals.jbig2"),
1150 ));
1151 let im = doc.add_object(gray(
1152 w,
1153 h,
1154 dictionary! {
1155 "Filter" => "JBIG2Decode",
1156 "DecodeParms" => dictionary! { "JBIG2Globals" => Object::Reference(globals) },
1157 },
1158 jbig2_fixture("symbol_global.jbig2"),
1159 ));
1160 let bytes = synth(
1161 &mut doc,
1162 [0.0, 0.0, 440.0, 180.0],
1163 Dictionary::new(),
1164 dictionary! { "XObject" => dictionary! { "Im0" => Object::Reference(im) } },
1165 content,
1166 );
1167 let with_globals = render(&bytes);
1168 assert!(non_white_share(&flate) > 0.02);
1169 assert_eq!(
1170 with_globals.as_raw(),
1171 flate.as_raw(),
1172 "JBIG2 + globals vs Flate"
1173 );
1174 }
1175
1176 #[test]
1180 fn undecodable_jbig2_draws_the_placeholder_even_as_a_stencil() {
1181 let content = "q 0 0 0 rg 200 0 0 100 120 40 cm /Im0 Do Q";
1182 for is_mask in [true, false] {
1183 let mut d = dictionary! {
1184 "Type" => "XObject", "Subtype" => "Image",
1185 "Width" => 20, "Height" => 10, "Filter" => "JBIG2Decode",
1186 };
1187 if is_mask {
1188 d.set("ImageMask", true);
1189 } else {
1190 d.set("ColorSpace", "DeviceGray");
1191 d.set("BitsPerComponent", 1);
1192 }
1193 let img = page_with_image(Stream::new(d, b"not a JBIG2 stream".to_vec()), content);
1194 let inside = px(&img, 220, 90);
1195 assert!(
1196 near(inside, [128, 128, 128], 2),
1197 "mask {is_mask}: inside the image {inside:?}"
1198 );
1199 assert_eq!(
1200 px(&img, 50, 90),
1201 WHITE,
1202 "mask {is_mask}: outside stays white"
1203 );
1204 }
1205 }
1206}