1pub mod color;
19pub mod content;
20pub mod font;
21pub mod function;
22pub mod geom;
23pub mod image;
24pub mod jpx;
25pub mod objects;
26pub mod prepass;
27
28use std::rc::Rc;
29
30use ::image::RgbImage;
31use lopdf::Document;
32
33use crate::pdf_meta::PdfMeta;
34use geom::Mat;
35
36pub fn pixels_for_extent(extent: f64) -> u32 {
38 ((extent - 1e-6).ceil().max(1.0)) as u32
39}
40
41pub struct Renderer<'a> {
45 meta: &'a PdfMeta,
46 shared: Rc<content::Shared>,
47}
48
49impl<'a> Renderer<'a> {
50 pub fn new(meta: &'a PdfMeta) -> Renderer<'a> {
53 Renderer {
54 meta,
55 shared: Rc::new(content::Shared::default()),
56 }
57 }
58
59 pub fn with_bitmap_hint(meta: &'a PdfMeta, bitmap_hint: f64) -> Renderer<'a> {
64 Renderer {
65 meta,
66 shared: Rc::new(content::Shared::with_bitmap_hint(bitmap_hint)),
67 }
68 }
69
70 pub fn render_scaled(&self, index: usize, scale: f64) -> Option<RgbImage> {
73 let geom = self.meta.geometry(index)?;
74 let w = pixels_for_extent(f64::from(geom.width) * scale);
75 let h = pixels_for_extent(f64::from(geom.height) * scale);
76 self.render(index, w, h)
77 }
78
79 pub fn render(&self, index: usize, width: u32, height: u32) -> Option<RgbImage> {
83 self.render_with_hint(index, width, height, self.shared.bitmap_hint())
84 }
85
86 pub fn render_with_hint(
93 &self,
94 index: usize,
95 width: u32,
96 height: u32,
97 bitmap_hint: f64,
98 ) -> Option<RgbImage> {
99 if width == 0 || height == 0 || width > 1 << 15 || height > 1 << 15 {
100 return None;
101 }
102 let meta = self.meta;
103 let doc = meta.doc();
104 let pid = meta.page_id(index)?;
105 let page = doc.get_object(pid).ok()?.as_dict().ok()?;
106 let geom = meta.geometry(index)?;
107 let pb = crate::textparse::page_box(doc, pid);
108 let (cw, ch) = if geom.rotation == 90 || geom.rotation == 270 {
110 (height, width)
111 } else {
112 (width, height)
113 };
114 let sx = f64::from(cw) / f64::from(pb.w).max(1e-6);
115 let sy = f64::from(ch) / f64::from(pb.h).max(1e-6);
116 let base = Mat::new(
118 sx,
119 0.0,
120 0.0,
121 -sy,
122 -f64::from(pb.l) * sx,
123 f64::from(ch) + f64::from(pb.b) * sy,
124 );
125
126 let content = doc.get_page_content(pid);
127 let resources = page_resources(doc, pid);
128 let mut interp = content::Interp::new(doc, cw, ch, self.shared.clone(), bitmap_hint)?;
129 crate::timing::timed("render.content", || {
130 interp.run_page(&content, resources, base);
131 interp.run_widgets(page, base);
132 });
133 let canvas = interp.into_canvas();
134 let rgb = crate::timing::timed("render.finish", || to_rgb(&canvas));
135 Some(rotate(rgb, geom.rotation))
136 }
137}
138
139pub fn render_page(meta: &PdfMeta, index: usize, scale: f64) -> Option<RgbImage> {
141 Renderer::new(meta).render_scaled(index, scale)
142}
143
144pub fn render_page_sized(
146 meta: &PdfMeta,
147 index: usize,
148 width: u32,
149 height: u32,
150) -> Option<RgbImage> {
151 Renderer::new(meta).render(index, width, height)
152}
153
154fn page_resources(doc: &Document, pid: lopdf::ObjectId) -> Option<&lopdf::Dictionary> {
155 doc.get_page_resources(pid).ok().and_then(|(inline, ids)| {
156 inline.or_else(|| ids.into_iter().find_map(|id| doc.get_dictionary(id).ok()))
157 })
158}
159
160fn to_rgb(pm: &tiny_skia::Pixmap) -> RgbImage {
163 let (w, h) = (pm.width(), pm.height());
164 let mut out = RgbImage::new(w, h);
165 let data = pm.data();
166 for (i, px) in out.pixels_mut().enumerate() {
167 let a = u32::from(data[i * 4 + 3]);
168 let un = |c: u8| -> u8 {
169 if a == 0 {
170 255
171 } else if a == 255 {
172 c
173 } else {
174 let c = u32::from(c);
176 (c + (255 - a)).min(255) as u8
177 }
178 };
179 *px = ::image::Rgb([un(data[i * 4]), un(data[i * 4 + 1]), un(data[i * 4 + 2])]);
180 }
181 out
182}
183
184fn rotate(img: RgbImage, rotation: u16) -> RgbImage {
186 match rotation {
187 90 => ::image::imageops::rotate90(&img),
188 180 => ::image::imageops::rotate180(&img),
189 270 => ::image::imageops::rotate270(&img),
190 _ => img,
191 }
192}
193
194#[cfg(test)]
195mod tests {
196 use super::*;
197
198 fn root() -> std::path::PathBuf {
199 std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..")
200 }
201
202 #[test]
205 fn renders_the_corpus() {
206 let mut files = Vec::new();
211 for d in corpus_dirs() {
212 collect_pdfs(&root().join(d), &mut files);
213 }
214 files.sort();
215 assert!(!files.is_empty());
216 for f in files {
217 let bytes = std::fs::read(&f).unwrap();
218 let Some(meta) = PdfMeta::open(&bytes) else {
219 continue;
220 };
221 let renderer = Renderer::new(&meta);
222 for i in 0..meta.page_count().min(3) {
223 let g = meta.geometry(i).unwrap();
224 let img = renderer
225 .render_scaled(i, 1.0)
226 .unwrap_or_else(|| panic!("{}: page {} declined", f.display(), i + 1));
227 assert_eq!(
228 img.width(),
229 pixels_for_extent(f64::from(g.width)),
230 "{}",
231 f.display()
232 );
233 assert_eq!(
234 img.height(),
235 pixels_for_extent(f64::from(g.height)),
236 "{}",
237 f.display()
238 );
239 let dark = img.pixels().filter(|p| p[0] < 200).count();
240 assert!(dark > 0, "{} p{}: blank render", f.display(), i + 1);
241 }
242 }
243 }
244
245 #[test]
250 fn against_the_docling_parse_shim() {
251 if std::env::var_os("DOCLING_PARSE_RENDER_LIB").is_none() {
253 let lib = root().join(".docling-parse/lib");
254 if lib.is_dir() {
255 std::env::set_var("DOCLING_PARSE_RENDER_LIB", &lib);
256 }
257 }
258 std::env::set_var("DOCLING_RS_RENDERER", "docling-parse");
261 let Some(plugin) = crate::dparse_render::plugin() else {
262 eprintln!("docling-parse shim not installed — skipping the renderer comparison");
263 return;
264 };
265 let _ = plugin;
266 let mut files = Vec::new();
269 for d in corpus_dirs() {
270 collect_pdfs(&root().join(d), &mut files);
271 }
272 files.sort();
273 eprintln!("renderer vs shim: {} PDFs", files.len());
274 let mut worst = 0.0f64;
275 let mut total_mad = 0.0f64;
276 let mut n = 0usize;
277 for f in &files {
278 let bytes = std::fs::read(f).unwrap();
279 let Some(meta) = PdfMeta::open(&bytes) else {
280 eprintln!("{}: object model unreadable, skipped", f.display());
281 continue;
282 };
283 let renderer = Renderer::new(&meta);
284 let Some(dp) = crate::dparse_render::Doc::open_if_enabled(&bytes, None) else {
285 eprintln!("{}: the shim declined the file, skipped", f.display());
286 continue;
287 };
288 for i in 0..meta.page_count() {
289 for scale in [1.0f64, 2.0] {
290 let Ok(want) = dp.render(i, scale, 1.0) else {
291 continue;
292 };
293 let Some(got) = renderer.render(i, want.width(), want.height()) else {
294 continue;
295 };
296 let mad = mean_abs_diff(&got, &want);
297 worst = worst.max(mad);
298 total_mad += mad;
299 n += 1;
300 eprintln!(
301 "{} p{} @{scale}: mean |Δ| = {mad:.2}",
302 f.file_name().unwrap().to_string_lossy(),
303 i + 1
304 );
305 }
306 dp.release_page(i);
307 }
308 }
309 if n > 0 {
310 eprintln!(
311 "renderer vs shim: {n} renders, mean |Δ| {:.2}, worst {worst:.2}",
312 total_mad / n as f64
313 );
314 }
315 assert!(
319 worst < 10.0,
320 "a render diverged from the shim (mean |Δ| {worst:.1})"
321 );
322 if n > 0 {
323 assert!(
324 total_mad / (n as f64) < 2.0,
325 "the corpus drifted from the shim"
326 );
327 }
328 }
329
330 fn corpus_dirs() -> &'static [&'static str] {
333 if std::env::var("DOCLING_RS_SHIM_CORPUS").as_deref() == Ok("pdf") {
334 &["tests/data/pdf/sources"]
335 } else {
336 &[
337 "tests/data/pdf/sources",
338 "tests/data/scanned/sources",
339 "tests/data/latex/sources",
340 "tests/data/odf/sources",
341 ]
342 }
343 }
344
345 fn collect_pdfs(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
346 let Ok(rd) = std::fs::read_dir(dir) else {
347 return;
348 };
349 for e in rd.flatten() {
350 let p = e.path();
351 if p.is_dir() {
352 collect_pdfs(&p, out);
353 } else if p.extension().is_some_and(|e| e == "pdf") {
354 out.push(p);
355 }
356 }
357 }
358
359 fn mean_abs_diff(a: &RgbImage, b: &RgbImage) -> f64 {
360 if a.dimensions() != b.dimensions() {
361 return 255.0;
362 }
363 let sum: u64 = a
364 .as_raw()
365 .iter()
366 .zip(b.as_raw())
367 .map(|(x, y)| u64::from((i32::from(*x) - i32::from(*y)).unsigned_abs()))
368 .sum();
369 sum as f64 / a.as_raw().len().max(1) as f64
370 }
371}
372
373#[cfg(test)]
379mod synthetic {
380 use super::*;
381 use lopdf::{dictionary, Dictionary, Object, Stream};
382
383 fn synth(
386 doc: &mut Document,
387 media: [f32; 4],
388 page_extra: Dictionary,
389 resources: Dictionary,
390 content: &str,
391 ) -> Vec<u8> {
392 synth_bytes(doc, media, page_extra, resources, content.as_bytes())
393 }
394
395 fn synth_bytes(
396 doc: &mut Document,
397 media: [f32; 4],
398 page_extra: Dictionary,
399 resources: Dictionary,
400 content: &[u8],
401 ) -> Vec<u8> {
402 let pages_id = doc.new_object_id();
403 let res_id = doc.add_object(resources);
404 let content_id = doc.add_object(Stream::new(Dictionary::new(), content.to_vec()));
405 let mut page = dictionary! {
406 "Type" => "Page",
407 "Parent" => Object::Reference(pages_id),
408 "MediaBox" => media.iter().map(|&v| Object::Real(v)).collect::<Vec<_>>(),
409 "Contents" => Object::Reference(content_id),
410 "Resources" => Object::Reference(res_id),
411 };
412 for (k, v) in page_extra.into_iter() {
413 page.set(k, v);
414 }
415 let page_id = doc.add_object(page);
416 doc.objects.insert(
417 pages_id,
418 Object::Dictionary(dictionary! {
419 "Type" => "Pages",
420 "Kids" => vec![Object::Reference(page_id)],
421 "Count" => 1,
422 }),
423 );
424 let catalog_id = doc.add_object(dictionary! {
425 "Type" => "Catalog",
426 "Pages" => Object::Reference(pages_id),
427 });
428 doc.trailer.set("Root", Object::Reference(catalog_id));
429 let mut out = Vec::new();
430 doc.save_to(&mut out).unwrap();
431 out
432 }
433
434 fn page(resources: Dictionary, content: &str) -> Vec<u8> {
436 let mut doc = Document::with_version("1.5");
437 synth(
438 &mut doc,
439 [0.0, 0.0, 200.0, 100.0],
440 Dictionary::new(),
441 resources,
442 content,
443 )
444 }
445
446 fn render(bytes: &[u8]) -> RgbImage {
447 let meta = PdfMeta::open(bytes).expect("object model");
448 render_page(&meta, 0, 1.0).expect("render")
449 }
450
451 fn px(img: &RgbImage, x: u32, y: u32) -> [u8; 3] {
452 img.get_pixel(x, y).0
453 }
454
455 fn near(a: [u8; 3], b: [u8; 3], tol: i32) -> bool {
456 a.iter()
457 .zip(b)
458 .all(|(&x, y)| (i32::from(x) - i32::from(y)).abs() <= tol)
459 }
460
461 const WHITE: [u8; 3] = [255, 255, 255];
462 const RED: [u8; 3] = [255, 0, 0];
463
464 fn ink_in(img: &RgbImage, x0: u32, y0: u32, x1: u32, y1: u32) -> usize {
466 let mut n = 0;
467 for y in y0..y1 {
468 for x in x0..x1 {
469 if px(img, x, y) != WHITE {
470 n += 1;
471 }
472 }
473 }
474 n
475 }
476
477 #[test]
478 fn fills_land_in_the_docling_parse_frame() {
479 let img = render(&page(Dictionary::new(), "1 0 0 rg 60 10 40 30 re f"));
482 assert_eq!((img.width(), img.height()), (200, 100));
483 assert_eq!(px(&img, 80, 75), RED);
484 assert_eq!(px(&img, 61, 61), RED);
485 assert_eq!(px(&img, 80, 55), WHITE);
486 assert_eq!(px(&img, 105, 75), WHITE);
487 assert_eq!(px(&img, 5, 5), WHITE);
488 }
489
490 #[test]
491 fn crop_box_is_the_canvas() {
492 let mut doc = Document::with_version("1.5");
495 let bytes = synth(
496 &mut doc,
497 [0.0, 0.0, 200.0, 100.0],
498 dictionary! { "CropBox" => vec![50.into(), 0.into(), 150.into(), 100.into()] },
499 Dictionary::new(),
500 "1 0 0 rg 60 10 40 30 re f",
501 );
502 let img = render(&bytes);
503 assert_eq!((img.width(), img.height()), (100, 100));
504 assert_eq!(px(&img, 30, 75), RED);
505 assert_eq!(px(&img, 5, 75), WHITE);
506 assert_eq!(px(&img, 55, 75), WHITE);
507 }
508
509 #[test]
510 fn rotate_transposes_the_canvas() {
511 let mut doc = Document::with_version("1.5");
514 let bytes = synth(
515 &mut doc,
516 [0.0, 0.0, 200.0, 100.0],
517 dictionary! { "Rotate" => 90 },
518 Dictionary::new(),
519 "1 0 0 rg 0 0 40 20 re f",
520 );
521 let img = render(&bytes);
522 assert_eq!((img.width(), img.height()), (100, 200));
523 assert_eq!(px(&img, 10, 20), RED);
524 assert_eq!(px(&img, 10, 45), WHITE);
525 assert_eq!(px(&img, 90, 190), WHITE);
526 let meta = PdfMeta::open(&bytes).unwrap();
527 let sized = render_page_sized(&meta, 0, 50, 100).unwrap();
528 assert_eq!((sized.width(), sized.height()), (50, 100));
529 assert_eq!(px(&sized, 5, 10), RED);
530 }
531
532 #[test]
533 fn hairline_strokes_are_one_pixel_wide() {
534 let img = render(&page(Dictionary::new(), "0 w 10 50.5 m 190 50.5 l S"));
536 assert!(ink_in(&img, 100, 49, 101, 51) >= 1);
537 assert_eq!(ink_in(&img, 100, 40, 101, 48), 0);
538 assert_eq!(ink_in(&img, 100, 52, 101, 60), 0);
539 let img = render(&page(Dictionary::new(), "4 w 10 50 m 190 50 l S"));
541 assert!(ink_in(&img, 100, 44, 101, 56) >= 4);
542 assert_eq!(ink_in(&img, 100, 40, 101, 47), 0);
543 }
544
545 #[test]
546 fn constant_alpha_blends_over_white() {
547 let res = dictionary! {
548 "ExtGState" => dictionary! {
549 "GS" => dictionary! { "ca" => 0.5, "CA" => 0.25 },
550 },
551 };
552 let img = render(&page(res, "/GS gs 0 g 20 20 160 60 re f"));
553 let p = px(&img, 100, 50);
554 assert!(near(p, [128, 128, 128], 2), "{p:?}");
555 }
556
557 #[test]
558 fn rect_clip_cuts_the_fill() {
559 let img = render(&page(
560 Dictionary::new(),
561 "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",
562 ));
563 assert_eq!(px(&img, 50, 50), [0, 0, 255]);
564 assert_eq!(px(&img, 150, 50), WHITE);
565 assert_eq!(px(&img, 150, 95), [0, 0, 0]);
567 }
568
569 #[test]
570 fn shape_clip_follows_the_path() {
571 let img = render(&page(
573 Dictionary::new(),
574 "q 0 0 m 200 0 l 100 100 l h W n 0 1 0 rg 0 0 200 100 re f Q",
575 ));
576 assert_eq!(px(&img, 100, 50), [0, 255, 0]);
577 assert_eq!(px(&img, 100, 90), [0, 255, 0]);
578 assert_eq!(px(&img, 5, 5), WHITE);
579 assert_eq!(px(&img, 195, 5), WHITE);
580 }
581
582 #[test]
583 fn image_xobject_is_blitted_with_its_orientation() {
584 let data = vec![255, 0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0];
586 let mut doc = Document::with_version("1.5");
587 let im = doc.add_object(Stream::new(
588 dictionary! {
589 "Type" => "XObject", "Subtype" => "Image",
590 "Width" => 2, "Height" => 2, "BitsPerComponent" => 8,
591 "ColorSpace" => "DeviceRGB",
592 },
593 data,
594 ));
595 let bytes = synth(
596 &mut doc,
597 [0.0, 0.0, 200.0, 100.0],
598 Dictionary::new(),
599 dictionary! { "XObject" => dictionary! { "Im0" => Object::Reference(im) } },
600 "q 200 0 0 100 0 0 cm /Im0 Do Q",
601 );
602 let img = render(&bytes);
603 assert!(near(px(&img, 50, 25), RED, 6));
606 assert!(near(px(&img, 150, 25), [0, 255, 0], 6));
607 assert!(near(px(&img, 50, 75), [0, 0, 255], 6));
608 assert!(near(px(&img, 150, 75), [0, 0, 0], 6));
609 }
610
611 #[test]
612 fn inline_image_and_stencil_mask() {
613 let mut content = b"q 100 0 0 100 0 0 cm BI /W 1 /H 1 /CS /G /BPC 8 ID ".to_vec();
616 content.push(0x80);
617 content.extend_from_slice(
618 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 ",
619 );
620 content.push(0x40);
621 content.extend_from_slice(b" EI Q");
622 let mut doc = Document::with_version("1.5");
623 let bytes = synth_bytes(
624 &mut doc,
625 [0.0, 0.0, 200.0, 100.0],
626 Dictionary::new(),
627 Dictionary::new(),
628 &content,
629 );
630 let img = render(&bytes);
631 assert!(near(px(&img, 50, 50), [128, 128, 128], 1));
632 assert_eq!(px(&img, 125, 50), RED);
633 assert_eq!(px(&img, 175, 50), WHITE);
634 }
635
636 #[test]
637 fn axial_shading_interpolates() {
638 let res = dictionary! {
639 "Shading" => dictionary! {
640 "Sh" => dictionary! {
641 "ShadingType" => 2, "ColorSpace" => "DeviceGray",
642 "Coords" => vec![0.into(), 0.into(), 200.into(), 0.into()],
643 "Function" => dictionary! {
644 "FunctionType" => 2, "Domain" => vec![0.into(), 1.into()],
645 "C0" => vec![0.into()], "C1" => vec![1.into()], "N" => 1,
646 },
647 "Extend" => vec![true.into(), true.into()],
648 },
649 },
650 };
651 let img = render(&page(res, "/Sh sh"));
652 assert!(px(&img, 5, 50)[0] < 40);
653 assert!(px(&img, 195, 50)[0] > 215);
654 assert!(near(px(&img, 100, 50), [128, 128, 128], 12));
655 let res = dictionary! {
657 "Pattern" => dictionary! {
658 "P" => dictionary! {
659 "PatternType" => 2,
660 "Shading" => dictionary! {
661 "ShadingType" => 2, "ColorSpace" => "DeviceGray",
662 "Coords" => vec![0.into(), 0.into(), 200.into(), 0.into()],
663 "Function" => dictionary! {
664 "FunctionType" => 2, "Domain" => vec![0.into(), 1.into()],
665 "C0" => vec![0.into()], "C1" => vec![1.into()], "N" => 1,
666 },
667 },
668 },
669 },
670 };
671 let img = render(&page(res, "/Pattern cs /P scn 0 0 100 100 re f"));
672 assert!(px(&img, 5, 50)[0] < 40);
673 assert!(near(px(&img, 50, 50), [64, 64, 64], 12));
674 assert_eq!(px(&img, 150, 50), WHITE);
675 }
676
677 #[test]
678 fn tiling_pattern_repeats_its_cell() {
679 let mut doc = Document::with_version("1.5");
681 let pat = doc.add_object(Stream::new(
682 dictionary! {
683 "PatternType" => 1, "PaintType" => 1, "TilingType" => 1,
684 "BBox" => vec![0.into(), 0.into(), 20.into(), 20.into()],
685 "XStep" => 20, "YStep" => 20, "Resources" => Dictionary::new(),
686 },
687 b"0 g 0 0 10 10 re f".to_vec(),
688 ));
689 let bytes = synth(
690 &mut doc,
691 [0.0, 0.0, 200.0, 100.0],
692 Dictionary::new(),
693 dictionary! { "Pattern" => dictionary! { "P" => Object::Reference(pat) } },
694 "/Pattern cs /P scn 0 0 200 100 re f",
695 );
696 let img = render(&bytes);
697 let inked = ink_in(&img, 0, 0, 200, 100);
698 assert!((4000..=6500).contains(&inked), "{inked}");
699 assert_eq!(px(&img, 5, 95), [0, 0, 0]);
701 assert_eq!(px(&img, 15, 85), WHITE);
702 }
703
704 #[test]
705 fn form_xobject_matrix_and_bbox() {
706 let mut doc = Document::with_version("1.5");
709 let fx = doc.add_object(Stream::new(
710 dictionary! {
711 "Type" => "XObject", "Subtype" => "Form",
712 "BBox" => vec![0.into(), 0.into(), 50.into(), 50.into()],
713 "Matrix" => vec![1.into(), 0.into(), 0.into(), 1.into(), 100.into(), 0.into()],
714 },
715 b"1 0 0 rg -500 -500 1000 1000 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! { "XObject" => dictionary! { "Fx" => Object::Reference(fx) } },
722 "/Fx Do",
723 );
724 let img = render(&bytes);
725 assert_eq!(px(&img, 125, 75), RED);
726 assert_eq!(px(&img, 75, 75), WHITE);
727 assert_eq!(px(&img, 125, 25), WHITE);
728 assert_eq!(px(&img, 175, 75), WHITE);
729 }
730
731 #[test]
732 fn transparency_group_alpha_reaches_its_contents() {
733 let mut doc = Document::with_version("1.5");
735 let fx = doc.add_object(Stream::new(
736 dictionary! {
737 "Type" => "XObject", "Subtype" => "Form",
738 "BBox" => vec![0.into(), 0.into(), 200.into(), 100.into()],
739 "Group" => dictionary! { "S" => "Transparency", "CS" => "DeviceRGB" },
740 },
741 b"0 g 0 0 200 100 re f".to_vec(),
742 ));
743 let bytes = synth(
744 &mut doc,
745 [0.0, 0.0, 200.0, 100.0],
746 Dictionary::new(),
747 dictionary! {
748 "XObject" => dictionary! { "Fx" => Object::Reference(fx) },
749 "ExtGState" => dictionary! { "GS" => dictionary! { "ca" => 0.5 } },
750 },
751 "/GS gs /Fx Do",
752 );
753 let img = render(&bytes);
754 assert!(near(px(&img, 100, 50), [128, 128, 128], 2));
755 }
756
757 #[test]
758 fn widgets_draw_their_appearance_unless_hidden() {
759 let mut doc = Document::with_version("1.5");
760 let ap = doc.add_object(Stream::new(
761 dictionary! {
762 "Type" => "XObject", "Subtype" => "Form",
763 "BBox" => vec![0.into(), 0.into(), 10.into(), 10.into()],
764 },
765 b"1 0 0 rg 0 0 10 10 re f".to_vec(),
766 ));
767 let widget = |x0: i64, flags: i64, subtype: &str| -> Object {
768 Object::Dictionary(dictionary! {
769 "Type" => "Annot", "Subtype" => subtype, "F" => flags,
770 "Rect" => vec![x0.into(), 20.into(), (x0 + 40).into(), 60.into()],
771 "AP" => dictionary! { "N" => Object::Reference(ap) },
772 })
773 };
774 let bytes = synth(
775 &mut doc,
776 [0.0, 0.0, 200.0, 100.0],
777 dictionary! {
778 "Annots" => vec![
779 widget(10, 4, "Widget"),
780 widget(60, 2, "Widget"),
781 widget(110, 4, "Square"),
782 ],
783 },
784 Dictionary::new(),
785 "",
786 );
787 let img = render(&bytes);
788 assert_eq!(px(&img, 30, 60), RED);
790 assert_eq!(px(&img, 12, 42), RED);
791 assert_eq!(px(&img, 48, 78), RED);
792 assert_eq!(px(&img, 30, 30), WHITE);
793 assert_eq!(px(&img, 80, 60), WHITE);
795 assert_eq!(px(&img, 130, 60), WHITE);
796 }
797
798 #[test]
799 fn text_paints_glyphs_from_a_fallback_face() {
800 let style = font::fallback::style_for("Helvetica", None, None);
801 if font::fallback::face(style).is_none() {
802 eprintln!("no fallback fonts on this host — skipping");
803 return;
804 }
805 let res = dictionary! {
806 "Font" => dictionary! {
807 "F1" => dictionary! {
808 "Type" => "Font", "Subtype" => "Type1", "BaseFont" => "Helvetica",
809 },
810 },
811 };
812 let img = render(&page(res.clone(), "BT /F1 40 Tf 20 30 Td (HELLO) Tj ET"));
813 assert!(ink_in(&img, 20, 40, 160, 70) > 200);
815 assert_eq!(ink_in(&img, 0, 0, 200, 35), 0);
816 assert_eq!(ink_in(&img, 0, 75, 200, 100), 0);
817 let img = render(&page(
820 res.clone(),
821 "BT 3 Tr /F1 40 Tf 20 30 Td (HELLO) Tj ET",
822 ));
823 assert_eq!(ink_in(&img, 0, 0, 200, 100), 0);
824 let wide = render(&page(res.clone(), "BT /F1 40 Tf 20 30 Td (HHHH) Tj ET"));
825 let narrow = render(&page(res, "BT /F1 40 Tf 50 Tz 20 30 Td (HHHH) Tj ET"));
826 let right = |img: &RgbImage| {
827 (0..200)
828 .rev()
829 .find(|&x| ink_in(img, x, 0, x + 1, 100) > 0)
830 .unwrap()
831 };
832 assert!(right(&wide) > right(&narrow) + 30);
833 }
834
835 #[test]
836 fn type3_glyphs_run_their_procedures() {
837 let mut doc = Document::with_version("1.5");
838 let square = doc.add_object(Stream::new(
839 Dictionary::new(),
840 b"1000 0 0 0 750 750 d1 0 0 750 750 re f".to_vec(),
841 ));
842 let font = doc.add_object(dictionary! {
843 "Type" => "Font", "Subtype" => "Type3",
844 "FontBBox" => vec![0.into(), 0.into(), 750.into(), 750.into()],
845 "FontMatrix" => vec![0.001.into(), 0.into(), 0.into(), 0.001.into(), 0.into(), 0.into()],
846 "CharProcs" => dictionary! { "square" => Object::Reference(square) },
847 "Encoding" => dictionary! {
848 "Type" => "Encoding",
849 "Differences" => vec![97.into(), Object::Name(b"square".to_vec())],
850 },
851 "FirstChar" => 97, "LastChar" => 97, "Widths" => vec![1000.into()],
852 "Resources" => Dictionary::new(),
853 });
854 let bytes = synth(
855 &mut doc,
856 [0.0, 0.0, 200.0, 100.0],
857 Dictionary::new(),
858 dictionary! { "Font" => dictionary! { "F1" => Object::Reference(font) } },
859 "BT /F1 40 Tf 20 20 Td 0 0 1 rg (aa) Tj ET",
860 );
861 let img = render(&bytes);
862 assert_eq!(px(&img, 35, 65), [0, 0, 255]);
864 assert_eq!(px(&img, 75, 65), [0, 0, 255]);
865 assert_eq!(px(&img, 55, 65), WHITE);
866 assert_eq!(px(&img, 105, 65), WHITE);
867 assert_eq!(px(&img, 35, 45), WHITE);
868 }
869
870 #[test]
871 fn cmyk_follows_the_docling_parse_model() {
872 let img = render(&page(Dictionary::new(), "0 0 0 1 k 0 0 200 100 re f"));
874 assert_eq!(px(&img, 100, 50), [35, 31, 32]);
875 }
876
877 #[test]
878 fn a_renderer_reuses_its_caches_across_renders() {
879 let data: Vec<u8> = (0..64 * 64 * 3).map(|i| (i % 251) as u8).collect();
882 let mut doc = Document::with_version("1.5");
883 let im = doc.add_object(Stream::new(
884 dictionary! {
885 "Type" => "XObject", "Subtype" => "Image",
886 "Width" => 64, "Height" => 64, "BitsPerComponent" => 8,
887 "ColorSpace" => "DeviceRGB",
888 },
889 data,
890 ));
891 let bytes = synth(
892 &mut doc,
893 [0.0, 0.0, 200.0, 100.0],
894 Dictionary::new(),
895 dictionary! { "XObject" => dictionary! { "Im0" => Object::Reference(im) } },
896 "q 100 0 0 50 50 25 cm /Im0 Do Q q 20 0 0 20 0 0 cm /Im0 Do Q",
897 );
898 let meta = PdfMeta::open(&bytes).unwrap();
899 let r = Renderer::new(&meta);
900 let a1 = r.render(0, 200, 100).unwrap();
901 let a2 = r.render(0, 400, 200).unwrap();
902 let a3 = r.render(0, 200, 100).unwrap();
903 assert_eq!(
904 a1.as_raw(),
905 render_page_sized(&meta, 0, 200, 100).unwrap().as_raw()
906 );
907 assert_eq!(
908 a2.as_raw(),
909 render_page_sized(&meta, 0, 400, 200).unwrap().as_raw()
910 );
911 assert_eq!(a1.as_raw(), a3.as_raw());
912 }
913
914 #[test]
919 fn jpx_image_renders_its_pixels() {
920 let jp2 = std::fs::read(
921 std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/data/jpx/gray_12x9.jp2"),
922 )
923 .unwrap();
924 let mut doc = Document::with_version("1.5");
925 let im = doc.add_object(Stream::new(
926 dictionary! {
927 "Type" => "XObject", "Subtype" => "Image",
928 "Width" => 12, "Height" => 9,
929 "ColorSpace" => "DeviceGray", "BitsPerComponent" => 8,
930 "Filter" => "JPXDecode",
931 },
932 jp2.clone(),
933 ));
934 let bytes = synth(
935 &mut doc,
936 [0.0, 0.0, 200.0, 100.0],
937 Dictionary::new(),
938 dictionary! { "XObject" => dictionary! { "Im0" => Object::Reference(im) } },
939 "q 120 0 0 90 40 5 cm /Im0 Do Q",
940 );
941 let img = render(&bytes);
942 let left = px(&img, 45, 50);
945 let right = px(&img, 155, 50);
946 assert!(left[0] < 40 && left == [left[0]; 3], "left pixel {left:?}");
947 assert!(
948 right[0] > 215 && right == [right[0]; 3],
949 "right pixel {right:?}"
950 );
951 assert_eq!(px(&img, 20, 50), WHITE, "outside the image stays white");
952
953 let rgba = std::fs::read(
957 std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/data/jpx/rgba_8x6.jp2"),
958 )
959 .unwrap();
960 for (smask_in_data, transparent_left) in [(0, false), (1, true)] {
961 let mut doc = Document::with_version("1.5");
962 let im = doc.add_object(Stream::new(
963 dictionary! {
964 "Type" => "XObject", "Subtype" => "Image",
965 "Width" => 8, "Height" => 6,
966 "ColorSpace" => "DeviceRGB", "BitsPerComponent" => 8,
967 "Filter" => "JPXDecode", "SMaskInData" => smask_in_data,
968 },
969 rgba.clone(),
970 ));
971 let bytes = synth(
972 &mut doc,
973 [0.0, 0.0, 200.0, 100.0],
974 Dictionary::new(),
975 dictionary! { "XObject" => dictionary! { "Im0" => Object::Reference(im) } },
976 "q 160 0 0 60 20 20 cm /Im0 Do Q",
977 );
978 let img = render(&bytes);
979 let p = px(&img, 30, 50);
982 if transparent_left {
983 assert!(near(p, WHITE, 3), "SMaskInData {smask_in_data}: {p:?}");
984 } else {
985 assert!(
986 p != WHITE && p[2] > 100,
987 "SMaskInData {smask_in_data}: {p:?}"
988 );
989 }
990 }
991 }
992}