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 render(bytes: &[u8]) -> RgbImage {
448 let meta = PdfMeta::open(bytes).expect("object model");
449 render_page(&meta, 0, 1.0).expect("render")
450 }
451
452 fn px(img: &RgbImage, x: u32, y: u32) -> [u8; 3] {
453 img.get_pixel(x, y).0
454 }
455
456 fn near(a: [u8; 3], b: [u8; 3], tol: i32) -> bool {
457 a.iter()
458 .zip(b)
459 .all(|(&x, y)| (i32::from(x) - i32::from(y)).abs() <= tol)
460 }
461
462 const WHITE: [u8; 3] = [255, 255, 255];
463 const RED: [u8; 3] = [255, 0, 0];
464
465 fn ink_in(img: &RgbImage, x0: u32, y0: u32, x1: u32, y1: u32) -> usize {
467 let mut n = 0;
468 for y in y0..y1 {
469 for x in x0..x1 {
470 if px(img, x, y) != WHITE {
471 n += 1;
472 }
473 }
474 }
475 n
476 }
477
478 #[test]
479 fn fills_land_in_the_docling_parse_frame() {
480 let img = render(&page(Dictionary::new(), "1 0 0 rg 60 10 40 30 re f"));
483 assert_eq!((img.width(), img.height()), (200, 100));
484 assert_eq!(px(&img, 80, 75), RED);
485 assert_eq!(px(&img, 61, 61), RED);
486 assert_eq!(px(&img, 80, 55), WHITE);
487 assert_eq!(px(&img, 105, 75), WHITE);
488 assert_eq!(px(&img, 5, 5), WHITE);
489 }
490
491 #[test]
492 fn crop_box_is_the_canvas() {
493 let mut doc = Document::with_version("1.5");
496 let bytes = synth(
497 &mut doc,
498 [0.0, 0.0, 200.0, 100.0],
499 dictionary! { "CropBox" => vec![50.into(), 0.into(), 150.into(), 100.into()] },
500 Dictionary::new(),
501 "1 0 0 rg 60 10 40 30 re f",
502 );
503 let img = render(&bytes);
504 assert_eq!((img.width(), img.height()), (100, 100));
505 assert_eq!(px(&img, 30, 75), RED);
506 assert_eq!(px(&img, 5, 75), WHITE);
507 assert_eq!(px(&img, 55, 75), WHITE);
508 }
509
510 #[test]
511 fn rotate_transposes_the_canvas() {
512 let mut doc = Document::with_version("1.5");
515 let bytes = synth(
516 &mut doc,
517 [0.0, 0.0, 200.0, 100.0],
518 dictionary! { "Rotate" => 90 },
519 Dictionary::new(),
520 "1 0 0 rg 0 0 40 20 re f",
521 );
522 let img = render(&bytes);
523 assert_eq!((img.width(), img.height()), (100, 200));
524 assert_eq!(px(&img, 10, 20), RED);
525 assert_eq!(px(&img, 10, 45), WHITE);
526 assert_eq!(px(&img, 90, 190), WHITE);
527 let meta = PdfMeta::open(&bytes).unwrap();
528 let sized = render_page_sized(&meta, 0, 50, 100).unwrap();
529 assert_eq!((sized.width(), sized.height()), (50, 100));
530 assert_eq!(px(&sized, 5, 10), RED);
531 }
532
533 #[test]
534 fn hairline_strokes_are_one_pixel_wide() {
535 let img = render(&page(Dictionary::new(), "0 w 10 50.5 m 190 50.5 l S"));
537 assert!(ink_in(&img, 100, 49, 101, 51) >= 1);
538 assert_eq!(ink_in(&img, 100, 40, 101, 48), 0);
539 assert_eq!(ink_in(&img, 100, 52, 101, 60), 0);
540 let img = render(&page(Dictionary::new(), "4 w 10 50 m 190 50 l S"));
542 assert!(ink_in(&img, 100, 44, 101, 56) >= 4);
543 assert_eq!(ink_in(&img, 100, 40, 101, 47), 0);
544 }
545
546 #[test]
547 fn constant_alpha_blends_over_white() {
548 let res = dictionary! {
549 "ExtGState" => dictionary! {
550 "GS" => dictionary! { "ca" => 0.5, "CA" => 0.25 },
551 },
552 };
553 let img = render(&page(res, "/GS gs 0 g 20 20 160 60 re f"));
554 let p = px(&img, 100, 50);
555 assert!(near(p, [128, 128, 128], 2), "{p:?}");
556 }
557
558 #[test]
559 fn rect_clip_cuts_the_fill() {
560 let img = render(&page(
561 Dictionary::new(),
562 "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",
563 ));
564 assert_eq!(px(&img, 50, 50), [0, 0, 255]);
565 assert_eq!(px(&img, 150, 50), WHITE);
566 assert_eq!(px(&img, 150, 95), [0, 0, 0]);
568 }
569
570 #[test]
571 fn shape_clip_follows_the_path() {
572 let img = render(&page(
574 Dictionary::new(),
575 "q 0 0 m 200 0 l 100 100 l h W n 0 1 0 rg 0 0 200 100 re f Q",
576 ));
577 assert_eq!(px(&img, 100, 50), [0, 255, 0]);
578 assert_eq!(px(&img, 100, 90), [0, 255, 0]);
579 assert_eq!(px(&img, 5, 5), WHITE);
580 assert_eq!(px(&img, 195, 5), WHITE);
581 }
582
583 #[test]
584 fn image_xobject_is_blitted_with_its_orientation() {
585 let data = vec![255, 0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0];
587 let mut doc = Document::with_version("1.5");
588 let im = doc.add_object(Stream::new(
589 dictionary! {
590 "Type" => "XObject", "Subtype" => "Image",
591 "Width" => 2, "Height" => 2, "BitsPerComponent" => 8,
592 "ColorSpace" => "DeviceRGB",
593 },
594 data,
595 ));
596 let bytes = synth(
597 &mut doc,
598 [0.0, 0.0, 200.0, 100.0],
599 Dictionary::new(),
600 dictionary! { "XObject" => dictionary! { "Im0" => Object::Reference(im) } },
601 "q 200 0 0 100 0 0 cm /Im0 Do Q",
602 );
603 let img = render(&bytes);
604 assert!(near(px(&img, 50, 25), RED, 6));
607 assert!(near(px(&img, 150, 25), [0, 255, 0], 6));
608 assert!(near(px(&img, 50, 75), [0, 0, 255], 6));
609 assert!(near(px(&img, 150, 75), [0, 0, 0], 6));
610 }
611
612 #[test]
613 fn inline_image_and_stencil_mask() {
614 let mut content = b"q 100 0 0 100 0 0 cm BI /W 1 /H 1 /CS /G /BPC 8 ID ".to_vec();
617 content.push(0x80);
618 content.extend_from_slice(
619 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 ",
620 );
621 content.push(0x40);
622 content.extend_from_slice(b" EI Q");
623 let mut doc = Document::with_version("1.5");
624 let bytes = synth_bytes(
625 &mut doc,
626 [0.0, 0.0, 200.0, 100.0],
627 Dictionary::new(),
628 Dictionary::new(),
629 &content,
630 );
631 let img = render(&bytes);
632 assert!(near(px(&img, 50, 50), [128, 128, 128], 1));
633 assert_eq!(px(&img, 125, 50), RED);
634 assert_eq!(px(&img, 175, 50), WHITE);
635 }
636
637 #[test]
638 fn axial_shading_interpolates() {
639 let res = dictionary! {
640 "Shading" => dictionary! {
641 "Sh" => dictionary! {
642 "ShadingType" => 2, "ColorSpace" => "DeviceGray",
643 "Coords" => vec![0.into(), 0.into(), 200.into(), 0.into()],
644 "Function" => dictionary! {
645 "FunctionType" => 2, "Domain" => vec![0.into(), 1.into()],
646 "C0" => vec![0.into()], "C1" => vec![1.into()], "N" => 1,
647 },
648 "Extend" => vec![true.into(), true.into()],
649 },
650 },
651 };
652 let img = render(&page(res, "/Sh sh"));
653 assert!(px(&img, 5, 50)[0] < 40);
654 assert!(px(&img, 195, 50)[0] > 215);
655 assert!(near(px(&img, 100, 50), [128, 128, 128], 12));
656 let res = dictionary! {
658 "Pattern" => dictionary! {
659 "P" => dictionary! {
660 "PatternType" => 2,
661 "Shading" => dictionary! {
662 "ShadingType" => 2, "ColorSpace" => "DeviceGray",
663 "Coords" => vec![0.into(), 0.into(), 200.into(), 0.into()],
664 "Function" => dictionary! {
665 "FunctionType" => 2, "Domain" => vec![0.into(), 1.into()],
666 "C0" => vec![0.into()], "C1" => vec![1.into()], "N" => 1,
667 },
668 },
669 },
670 },
671 };
672 let img = render(&page(res, "/Pattern cs /P scn 0 0 100 100 re f"));
673 assert!(px(&img, 5, 50)[0] < 40);
674 assert!(near(px(&img, 50, 50), [64, 64, 64], 12));
675 assert_eq!(px(&img, 150, 50), WHITE);
676 }
677
678 #[test]
679 fn tiling_pattern_repeats_its_cell() {
680 let mut doc = Document::with_version("1.5");
682 let pat = doc.add_object(Stream::new(
683 dictionary! {
684 "PatternType" => 1, "PaintType" => 1, "TilingType" => 1,
685 "BBox" => vec![0.into(), 0.into(), 20.into(), 20.into()],
686 "XStep" => 20, "YStep" => 20, "Resources" => Dictionary::new(),
687 },
688 b"0 g 0 0 10 10 re f".to_vec(),
689 ));
690 let bytes = synth(
691 &mut doc,
692 [0.0, 0.0, 200.0, 100.0],
693 Dictionary::new(),
694 dictionary! { "Pattern" => dictionary! { "P" => Object::Reference(pat) } },
695 "/Pattern cs /P scn 0 0 200 100 re f",
696 );
697 let img = render(&bytes);
698 let inked = ink_in(&img, 0, 0, 200, 100);
699 assert!((4000..=6500).contains(&inked), "{inked}");
700 assert_eq!(px(&img, 5, 95), [0, 0, 0]);
702 assert_eq!(px(&img, 15, 85), WHITE);
703 }
704
705 #[test]
706 fn form_xobject_matrix_and_bbox() {
707 let mut doc = Document::with_version("1.5");
710 let fx = doc.add_object(Stream::new(
711 dictionary! {
712 "Type" => "XObject", "Subtype" => "Form",
713 "BBox" => vec![0.into(), 0.into(), 50.into(), 50.into()],
714 "Matrix" => vec![1.into(), 0.into(), 0.into(), 1.into(), 100.into(), 0.into()],
715 },
716 b"1 0 0 rg -500 -500 1000 1000 re f".to_vec(),
717 ));
718 let bytes = synth(
719 &mut doc,
720 [0.0, 0.0, 200.0, 100.0],
721 Dictionary::new(),
722 dictionary! { "XObject" => dictionary! { "Fx" => Object::Reference(fx) } },
723 "/Fx Do",
724 );
725 let img = render(&bytes);
726 assert_eq!(px(&img, 125, 75), RED);
727 assert_eq!(px(&img, 75, 75), WHITE);
728 assert_eq!(px(&img, 125, 25), WHITE);
729 assert_eq!(px(&img, 175, 75), WHITE);
730 }
731
732 #[test]
733 fn transparency_group_alpha_reaches_its_contents() {
734 let mut doc = Document::with_version("1.5");
736 let fx = doc.add_object(Stream::new(
737 dictionary! {
738 "Type" => "XObject", "Subtype" => "Form",
739 "BBox" => vec![0.into(), 0.into(), 200.into(), 100.into()],
740 "Group" => dictionary! { "S" => "Transparency", "CS" => "DeviceRGB" },
741 },
742 b"0 g 0 0 200 100 re f".to_vec(),
743 ));
744 let bytes = synth(
745 &mut doc,
746 [0.0, 0.0, 200.0, 100.0],
747 Dictionary::new(),
748 dictionary! {
749 "XObject" => dictionary! { "Fx" => Object::Reference(fx) },
750 "ExtGState" => dictionary! { "GS" => dictionary! { "ca" => 0.5 } },
751 },
752 "/GS gs /Fx Do",
753 );
754 let img = render(&bytes);
755 assert!(near(px(&img, 100, 50), [128, 128, 128], 2));
756 }
757
758 #[test]
759 fn widgets_draw_their_appearance_unless_hidden() {
760 let mut doc = Document::with_version("1.5");
761 let ap = doc.add_object(Stream::new(
762 dictionary! {
763 "Type" => "XObject", "Subtype" => "Form",
764 "BBox" => vec![0.into(), 0.into(), 10.into(), 10.into()],
765 },
766 b"1 0 0 rg 0 0 10 10 re f".to_vec(),
767 ));
768 let widget = |x0: i64, flags: i64, subtype: &str| -> Object {
769 Object::Dictionary(dictionary! {
770 "Type" => "Annot", "Subtype" => subtype, "F" => flags,
771 "Rect" => vec![x0.into(), 20.into(), (x0 + 40).into(), 60.into()],
772 "AP" => dictionary! { "N" => Object::Reference(ap) },
773 })
774 };
775 let bytes = synth(
776 &mut doc,
777 [0.0, 0.0, 200.0, 100.0],
778 dictionary! {
779 "Annots" => vec![
780 widget(10, 4, "Widget"),
781 widget(60, 2, "Widget"),
782 widget(110, 4, "Square"),
783 ],
784 },
785 Dictionary::new(),
786 "",
787 );
788 let img = render(&bytes);
789 assert_eq!(px(&img, 30, 60), RED);
791 assert_eq!(px(&img, 12, 42), RED);
792 assert_eq!(px(&img, 48, 78), RED);
793 assert_eq!(px(&img, 30, 30), WHITE);
794 assert_eq!(px(&img, 80, 60), WHITE);
796 assert_eq!(px(&img, 130, 60), WHITE);
797 }
798
799 #[test]
800 fn text_paints_glyphs_from_a_fallback_face() {
801 let style = font::fallback::style_for("Helvetica", None, None);
802 if font::fallback::face(style).is_none() {
803 eprintln!("no fallback fonts on this host — skipping");
804 return;
805 }
806 let res = dictionary! {
807 "Font" => dictionary! {
808 "F1" => dictionary! {
809 "Type" => "Font", "Subtype" => "Type1", "BaseFont" => "Helvetica",
810 },
811 },
812 };
813 let img = render(&page(res.clone(), "BT /F1 40 Tf 20 30 Td (HELLO) Tj ET"));
814 assert!(ink_in(&img, 20, 40, 160, 70) > 200);
816 assert_eq!(ink_in(&img, 0, 0, 200, 35), 0);
817 assert_eq!(ink_in(&img, 0, 75, 200, 100), 0);
818 let img = render(&page(
821 res.clone(),
822 "BT 3 Tr /F1 40 Tf 20 30 Td (HELLO) Tj ET",
823 ));
824 assert_eq!(ink_in(&img, 0, 0, 200, 100), 0);
825 let wide = render(&page(res.clone(), "BT /F1 40 Tf 20 30 Td (HHHH) Tj ET"));
826 let narrow = render(&page(res, "BT /F1 40 Tf 50 Tz 20 30 Td (HHHH) Tj ET"));
827 let right = |img: &RgbImage| {
828 (0..200)
829 .rev()
830 .find(|&x| ink_in(img, x, 0, x + 1, 100) > 0)
831 .unwrap()
832 };
833 assert!(right(&wide) > right(&narrow) + 30);
834 }
835
836 #[test]
837 fn type3_glyphs_run_their_procedures() {
838 let mut doc = Document::with_version("1.5");
839 let square = doc.add_object(Stream::new(
840 Dictionary::new(),
841 b"1000 0 0 0 750 750 d1 0 0 750 750 re f".to_vec(),
842 ));
843 let font = doc.add_object(dictionary! {
844 "Type" => "Font", "Subtype" => "Type3",
845 "FontBBox" => vec![0.into(), 0.into(), 750.into(), 750.into()],
846 "FontMatrix" => vec![0.001.into(), 0.into(), 0.into(), 0.001.into(), 0.into(), 0.into()],
847 "CharProcs" => dictionary! { "square" => Object::Reference(square) },
848 "Encoding" => dictionary! {
849 "Type" => "Encoding",
850 "Differences" => vec![97.into(), Object::Name(b"square".to_vec())],
851 },
852 "FirstChar" => 97, "LastChar" => 97, "Widths" => vec![1000.into()],
853 "Resources" => Dictionary::new(),
854 });
855 let bytes = synth(
856 &mut doc,
857 [0.0, 0.0, 200.0, 100.0],
858 Dictionary::new(),
859 dictionary! { "Font" => dictionary! { "F1" => Object::Reference(font) } },
860 "BT /F1 40 Tf 20 20 Td 0 0 1 rg (aa) Tj ET",
861 );
862 let img = render(&bytes);
863 assert_eq!(px(&img, 35, 65), [0, 0, 255]);
865 assert_eq!(px(&img, 75, 65), [0, 0, 255]);
866 assert_eq!(px(&img, 55, 65), WHITE);
867 assert_eq!(px(&img, 105, 65), WHITE);
868 assert_eq!(px(&img, 35, 45), WHITE);
869 }
870
871 #[test]
872 fn cmyk_follows_the_docling_parse_model() {
873 let img = render(&page(Dictionary::new(), "0 0 0 1 k 0 0 200 100 re f"));
875 assert_eq!(px(&img, 100, 50), [35, 31, 32]);
876 }
877
878 #[test]
879 fn a_renderer_reuses_its_caches_across_renders() {
880 let data: Vec<u8> = (0..64 * 64 * 3).map(|i| (i % 251) as u8).collect();
883 let mut doc = Document::with_version("1.5");
884 let im = doc.add_object(Stream::new(
885 dictionary! {
886 "Type" => "XObject", "Subtype" => "Image",
887 "Width" => 64, "Height" => 64, "BitsPerComponent" => 8,
888 "ColorSpace" => "DeviceRGB",
889 },
890 data,
891 ));
892 let bytes = synth(
893 &mut doc,
894 [0.0, 0.0, 200.0, 100.0],
895 Dictionary::new(),
896 dictionary! { "XObject" => dictionary! { "Im0" => Object::Reference(im) } },
897 "q 100 0 0 50 50 25 cm /Im0 Do Q q 20 0 0 20 0 0 cm /Im0 Do Q",
898 );
899 let meta = PdfMeta::open(&bytes).unwrap();
900 let r = Renderer::new(&meta);
901 let a1 = r.render(0, 200, 100).unwrap();
902 let a2 = r.render(0, 400, 200).unwrap();
903 let a3 = r.render(0, 200, 100).unwrap();
904 assert_eq!(
905 a1.as_raw(),
906 render_page_sized(&meta, 0, 200, 100).unwrap().as_raw()
907 );
908 assert_eq!(
909 a2.as_raw(),
910 render_page_sized(&meta, 0, 400, 200).unwrap().as_raw()
911 );
912 assert_eq!(a1.as_raw(), a3.as_raw());
913 }
914
915 #[test]
920 fn jpx_image_renders_its_pixels() {
921 let jp2 = std::fs::read(
922 std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/data/jpx/gray_12x9.jp2"),
923 )
924 .unwrap();
925 let mut doc = Document::with_version("1.5");
926 let im = doc.add_object(Stream::new(
927 dictionary! {
928 "Type" => "XObject", "Subtype" => "Image",
929 "Width" => 12, "Height" => 9,
930 "ColorSpace" => "DeviceGray", "BitsPerComponent" => 8,
931 "Filter" => "JPXDecode",
932 },
933 jp2.clone(),
934 ));
935 let bytes = synth(
936 &mut doc,
937 [0.0, 0.0, 200.0, 100.0],
938 Dictionary::new(),
939 dictionary! { "XObject" => dictionary! { "Im0" => Object::Reference(im) } },
940 "q 120 0 0 90 40 5 cm /Im0 Do Q",
941 );
942 let img = render(&bytes);
943 let left = px(&img, 45, 50);
946 let right = px(&img, 155, 50);
947 assert!(left[0] < 40 && left == [left[0]; 3], "left pixel {left:?}");
948 assert!(
949 right[0] > 215 && right == [right[0]; 3],
950 "right pixel {right:?}"
951 );
952 assert_eq!(px(&img, 20, 50), WHITE, "outside the image stays white");
953
954 let rgba = std::fs::read(
958 std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/data/jpx/rgba_8x6.jp2"),
959 )
960 .unwrap();
961 for (smask_in_data, transparent_left) in [(0, false), (1, true)] {
962 let mut doc = Document::with_version("1.5");
963 let im = doc.add_object(Stream::new(
964 dictionary! {
965 "Type" => "XObject", "Subtype" => "Image",
966 "Width" => 8, "Height" => 6,
967 "ColorSpace" => "DeviceRGB", "BitsPerComponent" => 8,
968 "Filter" => "JPXDecode", "SMaskInData" => smask_in_data,
969 },
970 rgba.clone(),
971 ));
972 let bytes = synth(
973 &mut doc,
974 [0.0, 0.0, 200.0, 100.0],
975 Dictionary::new(),
976 dictionary! { "XObject" => dictionary! { "Im0" => Object::Reference(im) } },
977 "q 160 0 0 60 20 20 cm /Im0 Do Q",
978 );
979 let img = render(&bytes);
980 let p = px(&img, 30, 50);
983 if transparent_left {
984 assert!(near(p, WHITE, 3), "SMaskInData {smask_in_data}: {p:?}");
985 } else {
986 assert!(
987 p != WHITE && p[2] > 100,
988 "SMaskInData {smask_in_data}: {p:?}"
989 );
990 }
991 }
992 }
993
994 fn jbig2_fixture(name: &str) -> Vec<u8> {
997 std::fs::read(
998 std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
999 .join("tests/data/jbig2")
1000 .join(name),
1001 )
1002 .unwrap()
1003 }
1004
1005 fn jbig2_reference(name: &str) -> (usize, usize, Vec<u8>) {
1006 let img = ::image::load_from_memory(&jbig2_fixture(name))
1007 .unwrap()
1008 .to_luma8();
1009 let (w, h) = (img.width() as usize, img.height() as usize);
1010 let stride = w.div_ceil(8);
1011 let mut out = vec![0xffu8; stride * h];
1012 for (y, row) in img.rows().enumerate() {
1013 for (x, p) in row.enumerate() {
1014 if p.0[0] < 128 {
1015 out[y * stride + x / 8] &= !(0x80 >> (x % 8));
1016 }
1017 }
1018 }
1019 (w, h, out)
1020 }
1021
1022 fn page_with_image(stream: Stream, content: &str) -> RgbImage {
1025 let mut doc = Document::with_version("1.5");
1026 let im = doc.add_object(stream);
1027 let bytes = synth(
1028 &mut doc,
1029 [0.0, 0.0, 440.0, 180.0],
1030 Dictionary::new(),
1031 dictionary! { "XObject" => dictionary! { "Im0" => Object::Reference(im) } },
1032 content,
1033 );
1034 render(&bytes)
1035 }
1036
1037 fn non_white_share(img: &RgbImage) -> f64 {
1038 let n = img.pixels().filter(|p| p.0 != WHITE).count();
1039 n as f64 / (img.width() as f64 * img.height() as f64)
1040 }
1041
1042 #[test]
1047 fn jbig2_stencil_renders_like_its_flate_and_ccitt_twins() {
1048 let (w, h, bits) = jbig2_reference("generic_mmr.png");
1049 let content = "q 0 0 0 rg 326 0 0 218 57 -19 cm /Im0 Do Q";
1050 let mask = |filter: Dictionary, data: Vec<u8>| {
1051 let mut d = dictionary! {
1052 "Type" => "XObject", "Subtype" => "Image",
1053 "Width" => w as i64, "Height" => h as i64,
1054 "ImageMask" => true,
1055 };
1056 for (k, v) in filter.into_iter() {
1057 d.set(k, v);
1058 }
1059 Stream::new(d, data)
1060 };
1061 let flate = page_with_image(mask(Dictionary::new(), bits.clone()), content);
1062 let jbig2 = page_with_image(
1063 mask(
1064 dictionary! { "Filter" => "JBIG2Decode" },
1065 jbig2_fixture("generic_mmr.jbig2"),
1066 ),
1067 content,
1068 );
1069 let ccitt = page_with_image(
1070 mask(
1071 dictionary! {
1072 "Filter" => "CCITTFaxDecode",
1073 "DecodeParms" => dictionary! { "K" => -1, "Columns" => w as i64, "Rows" => h as i64 },
1074 },
1075 jbig2_fixture("generic_mmr.g4"),
1076 ),
1077 content,
1078 );
1079 let share = non_white_share(&flate);
1080 assert!(share > 0.05 && share < 0.95, "control share {share}");
1081 assert_eq!(jbig2.as_raw(), flate.as_raw(), "JBIG2 stencil vs Flate");
1082 assert_eq!(ccitt.as_raw(), flate.as_raw(), "CCITT stencil vs Flate");
1083 }
1084
1085 #[test]
1089 fn jbig2_gray_image_and_globals_render_their_pixels() {
1090 let content = "q 326 0 0 218 57 -19 cm /Im0 Do Q";
1091 let gray = |w: usize, h: usize, extra: Dictionary, data: Vec<u8>| {
1092 let mut d = dictionary! {
1093 "Type" => "XObject", "Subtype" => "Image",
1094 "Width" => w as i64, "Height" => h as i64,
1095 "ColorSpace" => "DeviceGray", "BitsPerComponent" => 1,
1096 };
1097 for (k, v) in extra.into_iter() {
1098 d.set(k, v);
1099 }
1100 Stream::new(d, data)
1101 };
1102 let (w, h, bits) = jbig2_reference("generic_mmr.png");
1103 let flate = page_with_image(gray(w, h, Dictionary::new(), bits), content);
1104 let jbig2 = page_with_image(
1105 gray(
1106 w,
1107 h,
1108 dictionary! { "Filter" => "JBIG2Decode" },
1109 jbig2_fixture("generic_mmr.jbig2"),
1110 ),
1111 content,
1112 );
1113 assert!(non_white_share(&flate) > 0.05);
1114 assert_eq!(jbig2.as_raw(), flate.as_raw(), "JBIG2 gray vs Flate");
1115
1116 let (w, h, bits) = jbig2_reference("symbol_global.png");
1118 let flate = page_with_image(gray(w, h, Dictionary::new(), bits), content);
1119 let mut doc = Document::with_version("1.5");
1120 let globals = doc.add_object(Stream::new(
1121 Dictionary::new(),
1122 jbig2_fixture("symbol_global.globals.jbig2"),
1123 ));
1124 let im = doc.add_object(gray(
1125 w,
1126 h,
1127 dictionary! {
1128 "Filter" => "JBIG2Decode",
1129 "DecodeParms" => dictionary! { "JBIG2Globals" => Object::Reference(globals) },
1130 },
1131 jbig2_fixture("symbol_global.jbig2"),
1132 ));
1133 let bytes = synth(
1134 &mut doc,
1135 [0.0, 0.0, 440.0, 180.0],
1136 Dictionary::new(),
1137 dictionary! { "XObject" => dictionary! { "Im0" => Object::Reference(im) } },
1138 content,
1139 );
1140 let with_globals = render(&bytes);
1141 assert!(non_white_share(&flate) > 0.02);
1142 assert_eq!(
1143 with_globals.as_raw(),
1144 flate.as_raw(),
1145 "JBIG2 + globals vs Flate"
1146 );
1147 }
1148
1149 #[test]
1153 fn undecodable_jbig2_draws_the_placeholder_even_as_a_stencil() {
1154 let content = "q 0 0 0 rg 200 0 0 100 120 40 cm /Im0 Do Q";
1155 for is_mask in [true, false] {
1156 let mut d = dictionary! {
1157 "Type" => "XObject", "Subtype" => "Image",
1158 "Width" => 20, "Height" => 10, "Filter" => "JBIG2Decode",
1159 };
1160 if is_mask {
1161 d.set("ImageMask", true);
1162 } else {
1163 d.set("ColorSpace", "DeviceGray");
1164 d.set("BitsPerComponent", 1);
1165 }
1166 let img = page_with_image(Stream::new(d, b"not a JBIG2 stream".to_vec()), content);
1167 let inside = px(&img, 220, 90);
1168 assert!(
1169 near(inside, [128, 128, 128], 2),
1170 "mask {is_mask}: inside the image {inside:?}"
1171 );
1172 assert_eq!(
1173 px(&img, 50, 90),
1174 WHITE,
1175 "mask {is_mask}: outside stays white"
1176 );
1177 }
1178 }
1179}