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