1pub mod fax;
42pub mod filters;
43pub use docling_core::jpeg;
44#[cfg(test)]
45mod jpeg_tests;
46pub mod stretch;
47
48use std::collections::HashMap;
49
50use image::RgbImage;
51use lopdf::{Dictionary, Document, Object, ObjectId};
52
53use crate::pdf_meta::PdfMeta;
54use stretch::{Options, Rect, Source};
55
56pub fn enabled() -> bool {
60 !matches!(
61 docling_core::env::nonempty("DOCLING_RS_SCAN_RASTER").as_deref(),
62 Some("pdfium") | Some("off") | Some("0")
63 )
64}
65
66pub fn render(meta: &PdfMeta, index: usize, width: u32, height: u32) -> Option<RgbImage> {
71 match render_inner(meta, index, width, height) {
72 Ok(img) => Some(img),
73 Err(why) => {
74 docling_core::debug_log!(
75 "docling-pdf: page {}: not rendered by the Rust raster ({why})",
76 index + 1
77 );
78 None
79 }
80 }
81}
82
83#[derive(Clone, Copy, Debug, PartialEq)]
85struct M {
86 a: f32,
87 b: f32,
88 c: f32,
89 d: f32,
90 e: f32,
91 f: f32,
92}
93
94impl M {
95 const ID: M = M {
96 a: 1.0,
97 b: 0.0,
98 c: 0.0,
99 d: 1.0,
100 e: 0.0,
101 f: 0.0,
102 };
103
104 fn then(self, r: M) -> M {
106 M {
107 a: self.a * r.a + self.b * r.c,
108 b: self.a * r.b + self.b * r.d,
109 c: self.c * r.a + self.d * r.c,
110 d: self.c * r.b + self.d * r.d,
111 e: self.e * r.a + self.f * r.c + r.e,
112 f: self.e * r.b + self.f * r.d + r.f,
113 }
114 }
115
116 fn apply(self, x: f32, y: f32) -> (f32, f32) {
117 (
118 self.a * x + self.c * y + self.e,
119 self.b * x + self.d * y + self.f,
120 )
121 }
122
123 fn from_array(v: &[f32]) -> Option<M> {
124 if v.len() != 6 || v.iter().any(|x| !x.is_finite()) {
125 return None;
126 }
127 Some(M {
128 a: v[0],
129 b: v[1],
130 c: v[2],
131 d: v[3],
132 e: v[4],
133 f: v[5],
134 })
135 }
136}
137
138#[derive(Clone, Copy, Debug)]
141struct FRect {
142 left: f32,
143 bottom: f32,
144 right: f32,
145 top: f32,
146}
147
148impl FRect {
149 fn from_points(pts: &[(f32, f32)]) -> FRect {
150 let mut r = FRect {
151 left: pts[0].0,
152 right: pts[0].0,
153 bottom: pts[0].1,
154 top: pts[0].1,
155 };
156 for &(x, y) in &pts[1..] {
157 r.left = r.left.min(x);
158 r.right = r.right.max(x);
159 r.bottom = r.bottom.min(y);
160 r.top = r.top.max(y);
161 }
162 r
163 }
164
165 fn normalize(&mut self) {
166 if self.left > self.right {
167 std::mem::swap(&mut self.left, &mut self.right);
168 }
169 if self.bottom > self.top {
170 std::mem::swap(&mut self.bottom, &mut self.top);
171 }
172 }
173
174 fn intersect(&mut self, o: FRect) {
176 self.normalize();
177 let mut o = o;
178 o.normalize();
179 self.left = self.left.max(o.left);
180 self.bottom = self.bottom.max(o.bottom);
181 self.right = self.right.min(o.right);
182 self.top = self.top.min(o.top);
183 if self.left > self.right || self.bottom > self.top {
184 *self = FRect {
185 left: 0.0,
186 bottom: 0.0,
187 right: 0.0,
188 top: 0.0,
189 };
190 }
191 }
192
193 fn outer(&self) -> Rect {
195 let mut r = Rect::new(
196 sat(self.left.floor()),
197 sat(self.bottom.floor()),
198 sat(self.right.ceil()),
199 sat(self.top.ceil()),
200 );
201 r.normalize();
202 r
203 }
204}
205
206fn sat(v: f32) -> i32 {
207 if v.is_nan() {
208 0
209 } else {
210 v.clamp(i32::MIN as f32, i32::MAX as f32) as i32
211 }
212}
213
214fn to_byte(v: f32) -> u8 {
217 (v.clamp(0.0, 1.0) * 255.0).round() as u8
218}
219
220struct Draw {
222 stream: ObjectId,
223 matrix: M,
225 clip: Rect,
227 fill: Option<[u8; 3]>,
230}
231
232#[derive(Clone, Copy, PartialEq, Eq)]
234enum FillCs {
235 Gray,
236 Rgb,
237 Other,
238}
239
240#[derive(Clone, Copy)]
242struct GState {
243 ctm: M,
244 clip: Rect,
245 text_render: i64,
247 fill: Option<[u8; 3]>,
250 fill_cs: FillCs,
251}
252
253struct Ctx<'a> {
257 doc: &'a Document,
258 obj2dev: M,
259 res: Option<&'a Dictionary>,
260 page_res: Option<&'a Dictionary>,
261 device: Rect,
262 depth: usize,
263}
264
265struct Walker {
266 draws: Vec<Draw>,
267 ops: usize,
268}
269
270const MAX_OPS: usize = 500_000;
271const MAX_DEPTH: usize = 12;
272
273fn deref<'a>(doc: &'a Document, obj: &'a Object) -> &'a Object {
274 match obj {
275 Object::Reference(id) => doc.get_object(*id).unwrap_or(obj),
276 o => o,
277 }
278}
279
280fn as_dict<'a>(doc: &'a Document, obj: &'a Object) -> Option<&'a Dictionary> {
281 match deref(doc, obj) {
282 Object::Dictionary(d) => Some(d),
283 Object::Stream(s) => Some(&s.dict),
284 _ => None,
285 }
286}
287
288fn num(o: &Object) -> Option<f32> {
289 match o {
290 Object::Integer(i) => Some(*i as f32),
291 Object::Real(r) => Some(*r),
292 _ => None,
293 }
294}
295
296fn nums(ops: &[Object]) -> Option<Vec<f32>> {
297 ops.iter().map(num).collect()
298}
299
300fn name_is(o: Option<&Object>, n: &[u8]) -> bool {
301 matches!(o, Some(Object::Name(v)) if v == n)
302}
303
304fn bool_or_int_true(doc: &Document, o: Option<&Object>) -> bool {
305 match o.map(|o| deref(doc, o)) {
306 Some(Object::Boolean(b)) => *b,
307 Some(Object::Integer(i)) => *i != 0,
308 Some(Object::Real(r)) => *r != 0.0,
309 _ => false,
310 }
311}
312
313fn find_resource<'a>(ctx: &Ctx<'a>, kind: &[u8], name: &[u8]) -> Option<&'a Object> {
316 [ctx.res, ctx.page_res]
317 .into_iter()
318 .flatten()
319 .find_map(|res| {
320 res.get(kind)
321 .ok()
322 .and_then(|o| as_dict(ctx.doc, o))
323 .and_then(|d| d.get(name).ok())
324 })
325}
326
327fn extgstate_is_neutral(doc: &Document, gs: &Dictionary) -> Result<(), String> {
330 for (k, v) in gs.iter() {
331 let v = deref(doc, v);
332 let ok = match k.as_slice() {
333 b"Type" | b"LW" | b"LC" | b"LJ" | b"ML" | b"D" | b"RI" | b"Font" | b"FL" | b"SM"
334 | b"SA" | b"OPM" | b"TK" => true,
335 b"CA" | b"ca" => num(v) == Some(1.0),
336 b"OP" | b"op" => matches!(v, Object::Boolean(false)),
337 b"BM" => match v {
338 Object::Name(n) => n == b"Normal" || n == b"Compatible",
339 Object::Array(a) => a.first().is_some_and(|o| {
340 name_is(Some(o), b"Normal") || name_is(Some(o), b"Compatible")
341 }),
342 _ => false,
343 },
344 b"SMask" => name_is(Some(v), b"None"),
345 b"AIS" => matches!(v, Object::Boolean(false)),
346 _ => false,
347 };
348 if !ok {
349 return Err(format!("ExtGState /{}", String::from_utf8_lossy(k)));
350 }
351 }
352 Ok(())
353}
354
355fn fill_cs_of(ctx: &Ctx<'_>, name: &[u8]) -> FillCs {
361 match name {
362 b"DeviceGray" | b"DeviceRGB" => {
363 let default: &[u8] = if name == b"DeviceGray" {
364 b"DefaultGray"
365 } else {
366 b"DefaultRGB"
367 };
368 if find_resource(ctx, b"ColorSpace", default).is_some() {
369 return FillCs::Other;
370 }
371 if name == b"DeviceGray" {
372 FillCs::Gray
373 } else {
374 FillCs::Rgb
375 }
376 }
377 b"DeviceCMYK" | b"Pattern" => FillCs::Other,
378 other => {
379 let Some(obj) = find_resource(ctx, b"ColorSpace", other) else {
380 return FillCs::Other;
381 };
382 match color_space(ctx.doc, obj, None, None) {
383 Ok(Cs::Gray) => FillCs::Gray,
384 Ok(Cs::Rgb {
385 cal_rgb_with_transform: false,
386 }) => FillCs::Rgb,
387 _ => FillCs::Other,
388 }
389 }
390 }
391}
392
393impl Walker {
394 fn rect_clip(&self, ctx: &Ctx<'_>, ctm: M, re: &[f32]) -> Result<Rect, String> {
398 let (x, y, w, h) = (re[0], re[1], re[2], re[3]);
399 let user = [(x, y), (x + w, y), (x + w, y + h), (x, y + h), (x, y)];
400 let mut dev = [(0f32, 0f32); 5];
401 for (i, &(px, py)) in user.iter().enumerate() {
402 let (ux, uy) = ctm.apply(px, py);
405 dev[i] = ctx.obj2dev.apply(ux, uy);
406 if i > 0 && dev[i].0 != dev[i - 1].0 && dev[i].1 != dev[i - 1].1 {
407 return Err("non-rectangular clip".into());
408 }
409 }
410 if dev[0].0 != dev[3].0 && dev[0].1 != dev[3].1 {
411 return Err("non-rectangular clip".into());
412 }
413 let mut r = FRect {
414 left: dev[0].0,
415 bottom: dev[0].1,
416 right: dev[2].0,
417 top: dev[2].1,
418 };
419 r.normalize();
420 r.intersect(FRect {
421 left: 0.0,
422 bottom: 0.0,
423 right: ctx.device.right as f32,
424 top: ctx.device.bottom as f32,
425 });
426 Ok(r.outer())
427 }
428
429 fn walk<'a>(&mut self, ctx: &Ctx<'a>, content: &[u8], init: GState) -> Result<(), String> {
430 if ctx.depth > MAX_DEPTH {
431 return Err("form nesting".into());
432 }
433 let ops = lopdf::content::Content::decode(content).map_err(|e| format!("content: {e}"))?;
434 let mut stack: Vec<GState> = Vec::new();
435 let mut st = init;
436 let mut path_rect: Option<Vec<f32>> = None;
438 let mut path_other = false;
439 let mut clip_pending = false;
440 let mut compat = 0usize;
441 for op in &ops.operations {
442 self.ops += 1;
443 if self.ops > MAX_OPS {
444 return Err("content too long".into());
445 }
446 let o = op.operator.as_str();
447 let args = &op.operands;
448 match o {
449 "q" => {
450 stack.push(st);
451 if stack.len() > 256 {
452 return Err("q nesting".into());
453 }
454 }
455 "Q" => {
456 if let Some(s) = stack.pop() {
457 st = s;
458 }
459 }
460 "cm" => {
461 let v = nums(args).ok_or("cm operands")?;
462 let m = M::from_array(&v).ok_or("cm matrix")?;
463 st.ctm = m.then(st.ctm);
465 }
466 "gs" => {
467 let name = match args.first() {
468 Some(Object::Name(n)) => n,
469 _ => return Err("gs operand".into()),
470 };
471 let gs = find_resource(ctx, b"ExtGState", name)
472 .and_then(|o| as_dict(ctx.doc, o))
473 .ok_or("gs resource")?;
474 extgstate_is_neutral(ctx.doc, gs)?;
475 }
476 "re" => {
478 let v = nums(args).ok_or("re operands")?;
479 if v.len() != 4 {
480 return Err("re operands".into());
481 }
482 if path_rect.is_some() || path_other {
483 path_other = true;
484 } else {
485 path_rect = Some(v);
486 }
487 }
488 "m" | "l" | "c" | "v" | "y" | "h" => path_other = true,
489 "W" | "W*" => clip_pending = true,
490 "n" => {
491 if clip_pending {
492 match (&path_rect, path_other) {
493 (Some(re), false) => {
494 let r = self.rect_clip(ctx, st.ctm, re)?;
495 st.clip.intersect(&r);
496 }
497 _ => return Err("non-rectangular clip".into()),
498 }
499 }
500 path_rect = None;
501 path_other = false;
502 clip_pending = false;
503 }
504 "f" | "F" | "f*" | "B" | "B*" | "b" | "b*" | "S" | "s" => {
505 return Err(format!("painted path ({o})"));
506 }
507 "sh" => return Err("shading".into()),
508 "BI" | "ID" | "EI" => return Err("inline image".into()),
509 "d0" | "d1" => return Err("type3 glyph".into()),
510 "BT" | "ET" | "Tc" | "Tw" | "Tz" | "TL" | "Tf" | "Ts" | "Td" | "TD" | "Tm"
512 | "T*" => {}
513 "Tr" => {
514 st.text_render = args.first().and_then(|o| o.as_i64().ok()).unwrap_or(0);
515 }
516 "Tj" | "TJ" | "'" | "\"" => {
517 if st.text_render != 3 {
518 return Err("visible text".into());
519 }
520 }
521 "g" => {
524 if let Some(v) = args.last().and_then(num) {
525 let b = to_byte(v);
526 st.fill = Some([b, b, b]);
527 }
528 st.fill_cs = FillCs::Gray;
529 }
530 "rg" => {
531 if let Some(v) = nums(args).filter(|v| v.len() >= 3) {
532 let n = v.len();
533 st.fill = Some([to_byte(v[n - 3]), to_byte(v[n - 2]), to_byte(v[n - 1])]);
534 }
535 st.fill_cs = FillCs::Rgb;
536 }
537 "k" => {
538 st.fill = None;
540 st.fill_cs = FillCs::Other;
541 }
542 "cs" => {
543 st.fill_cs = match args.first() {
546 Some(Object::Name(n)) => fill_cs_of(ctx, n),
547 _ => FillCs::Other,
548 };
549 }
550 "sc" | "scn" => {
551 if args.iter().any(|a| matches!(a, Object::Name(_))) {
552 st.fill = None;
554 } else {
555 let v: Vec<f32> = args.iter().filter_map(num).collect();
556 let v = if v.len() > 4 {
557 v[v.len() - 4..].to_vec()
558 } else {
559 v
560 };
561 match st.fill_cs {
562 FillCs::Gray if !v.is_empty() => {
563 let b = to_byte(v[0]);
564 st.fill = Some([b, b, b]);
565 }
566 FillCs::Rgb if v.len() >= 3 => {
567 st.fill = Some([to_byte(v[0]), to_byte(v[1]), to_byte(v[2])]);
568 }
569 FillCs::Gray | FillCs::Rgb => {}
571 FillCs::Other => st.fill = None,
572 }
573 }
574 }
575 "G" | "RG" | "K" | "CS" | "SC" | "SCN" | "w" | "J" | "j" | "M" | "d" | "ri"
577 | "i" | "BMC" | "BDC" | "EMC" | "MP" | "DP" => {}
578 "BX" => compat += 1,
579 "EX" => compat = compat.saturating_sub(1),
580 "Do" => {
581 let name = match args.first() {
582 Some(Object::Name(n)) => n,
583 _ => return Err("Do operand".into()),
584 };
585 let xobj = find_resource(ctx, b"XObject", name).ok_or("Do resource")?;
586 let (id, stream) = match xobj {
587 Object::Reference(id) => match ctx.doc.get_object(*id) {
588 Ok(Object::Stream(s)) => (*id, s),
589 _ => return Err("XObject reference".into()),
590 },
591 _ => return Err("direct XObject".into()),
594 };
595 let subtype = stream.dict.get(b"Subtype").ok().map(|o| deref(ctx.doc, o));
596 if name_is(subtype, b"Image") {
597 if st.clip.is_empty() {
598 continue;
599 }
600 self.draws.push(Draw {
601 stream: id,
602 matrix: st.ctm.then(ctx.obj2dev),
603 clip: st.clip,
604 fill: st.fill,
605 });
606 } else if name_is(subtype, b"Form") {
607 self.form(ctx, stream, st)?;
608 } else {
609 return Err("XObject subtype".into());
610 }
611 }
612 _ if compat > 0 => {}
613 other => return Err(format!("operator {other}")),
614 }
615 }
616 Ok(())
617 }
618
619 fn form<'a>(
624 &mut self,
625 ctx: &Ctx<'a>,
626 stream: &'a lopdf::Stream,
627 st: GState,
628 ) -> Result<(), String> {
629 let d = &stream.dict;
630 if let Some(group) = d.get(b"Group").ok().and_then(|o| as_dict(ctx.doc, o)) {
631 if bool_or_int_true(ctx.doc, group.get(b"I").ok())
634 || bool_or_int_true(ctx.doc, group.get(b"K").ok())
635 {
636 return Err("isolated/knockout transparency group".into());
637 }
638 }
639 if d.get(b"OC").is_ok() {
640 return Err("optional content".into());
641 }
642 let form_matrix = match d.get(b"Matrix").ok().map(|o| deref(ctx.doc, o)) {
643 Some(Object::Array(a)) => {
644 let v: Vec<f32> = a.iter().filter_map(|o| num(deref(ctx.doc, o))).collect();
645 M::from_array(&v).ok_or("form /Matrix")?
646 }
647 _ => M::ID,
648 };
649 let inner = Ctx {
650 doc: ctx.doc,
651 obj2dev: st.ctm.then(ctx.obj2dev),
652 res: d
653 .get(b"Resources")
654 .ok()
655 .and_then(|o| as_dict(ctx.doc, o))
656 .or(ctx.res),
657 page_res: ctx.page_res,
658 device: ctx.device,
659 depth: ctx.depth + 1,
660 };
661 let mut inner_state = GState {
662 ctm: form_matrix,
663 ..st
664 };
665 if let Some(Object::Array(b)) = d.get(b"BBox").ok().map(|o| deref(ctx.doc, o)) {
666 let v: Vec<f32> = b.iter().filter_map(|o| num(deref(ctx.doc, o))).collect();
667 if v.len() != 4 {
668 return Err("form /BBox".into());
669 }
670 let (l, r) = (v[0].min(v[2]), v[0].max(v[2]));
673 let (bt, tp) = (v[1].min(v[3]), v[1].max(v[3]));
674 let clip = self.rect_clip(&inner, form_matrix, &[l, bt, r - l, tp - bt])?;
675 inner_state.clip.intersect(&clip);
676 }
677 if inner_state.clip.is_empty() {
678 return Ok(());
679 }
680 let content = stream
681 .decompressed_content()
682 .map_err(|e| format!("form content: {e}"))?;
683 self.walk(&inner, &content, inner_state)
684 }
685}
686
687struct Decoded {
690 width: i32,
691 height: i32,
692 kind: Kind,
693 interpolate: bool,
694}
695
696enum Kind {
697 Bilevel {
702 data: Vec<u8>,
703 stride: usize,
704 palette: Option<[[u8; 3]; 2]>,
705 },
706 Mask { data: Vec<u8>, stride: usize },
709 Gray8(Vec<u8>),
711 Rgb8(Vec<u8>),
713}
714
715#[derive(Clone)]
717enum Cs {
718 Gray,
719 Rgb {
720 cal_rgb_with_transform: bool,
724 },
725 Indexed {
726 base: Base,
727 hival: i64,
728 table: Vec<u8>,
729 },
730}
731
732#[derive(Clone, Copy, PartialEq, Eq)]
733enum Base {
734 Gray,
735 Rgb,
736}
737
738fn color_space(
740 doc: &Document,
741 cs: &Object,
742 res: Option<&Dictionary>,
743 page_res: Option<&Dictionary>,
744) -> Result<Cs, String> {
745 match deref(doc, cs) {
746 Object::Name(n) => match n.as_slice() {
747 b"DeviceGray" | b"G" | b"CalGray" => Ok(Cs::Gray),
748 b"DeviceRGB" | b"RGB" | b"CalRGB" => Ok(Cs::Rgb {
749 cal_rgb_with_transform: false,
750 }),
751 b"DeviceCMYK" | b"CMYK" | b"Pattern" | b"I" | b"Indexed" => {
752 Err(format!("colour space /{}", String::from_utf8_lossy(n)))
753 }
754 other => {
755 for r in [res, page_res].into_iter().flatten() {
757 if let Some(o) = r
758 .get(b"ColorSpace")
759 .ok()
760 .and_then(|o| as_dict(doc, o))
761 .and_then(|d| d.get(other).ok())
762 {
763 return color_space(doc, o, None, None);
764 }
765 }
766 Err(format!("colour space /{}", String::from_utf8_lossy(other)))
767 }
768 },
769 Object::Array(a) => {
770 let fam = a.first().map(|o| deref(doc, o));
771 match fam {
772 Some(Object::Name(n)) if n == b"CalGray" => Ok(Cs::Gray),
773 Some(Object::Name(n)) if n == b"CalRGB" => {
774 let d = a.get(1).and_then(|o| as_dict(doc, o));
775 Ok(Cs::Rgb {
776 cal_rgb_with_transform: d
777 .is_some_and(|d| d.get(b"Gamma").is_ok() || d.get(b"Matrix").is_ok()),
778 })
779 }
780 Some(Object::Name(n)) if n == b"ICCBased" => {
781 let stream = a
782 .get(1)
783 .map(|o| deref(doc, o))
784 .and_then(|o| o.as_stream().ok())
785 .ok_or("ICCBased stream")?;
786 let n = stream
787 .dict
788 .get(b"N")
789 .ok()
790 .and_then(|o| deref(doc, o).as_i64().ok())
791 .unwrap_or(0);
792 let bytes = stream.decompressed_content().unwrap_or_default();
796 if n == 3
797 && bytes.len() == 3144
798 && bytes.get(400..417) == Some(b"sRGB IEC61966-2.1")
799 {
800 Ok(Cs::Rgb {
801 cal_rgb_with_transform: false,
802 })
803 } else {
804 Err("ICC profile (Little-CMS transform)".into())
805 }
806 }
807 Some(Object::Name(n)) if n == b"Indexed" || n == b"I" => {
808 if a.len() < 4 {
809 return Err("Indexed array".into());
810 }
811 let base = match color_space(doc, &a[1], res, page_res)? {
812 Cs::Gray => Base::Gray,
813 Cs::Rgb { .. } => Base::Rgb,
814 Cs::Indexed { .. } => return Err("Indexed over Indexed".into()),
815 };
816 let hival = deref(doc, &a[2]).as_i64().unwrap_or(0).clamp(0, 255);
818 let table = match deref(doc, &a[3]) {
819 Object::String(s, _) => s.clone(),
820 Object::Stream(s) => s
821 .decompressed_content()
822 .map_err(|e| format!("Indexed lookup: {e}"))?,
823 _ => return Err("Indexed lookup".into()),
824 };
825 Ok(Cs::Indexed { base, hival, table })
826 }
827 Some(Object::Name(n)) => {
828 Err(format!("colour space /{}", String::from_utf8_lossy(n)))
829 }
830 _ => Err("colour space".into()),
831 }
832 }
833 _ => Err("colour space".into()),
834 }
835}
836
837impl Cs {
838 fn rgb_of(&self, value: f32) -> [u8; 3] {
841 match self {
842 Cs::Gray | Cs::Rgb { .. } => {
843 let g = to_byte(value);
844 [g, g, g]
845 }
846 Cs::Indexed { base, hival, table } => {
847 let index = value as i32;
850 let n = if *base == Base::Gray { 1 } else { 3 };
851 if index < 0 || i64::from(index) > *hival {
852 return [0, 0, 0];
853 }
854 let start = index as usize * n;
855 if start + n > table.len() {
856 return [0, 0, 0];
857 }
858 let comp = |i: usize| f32::from(table[start + i]) / 255.0;
859 match base {
860 Base::Gray => {
861 let g = to_byte(comp(0));
862 [g, g, g]
863 }
864 Base::Rgb => [to_byte(comp(0)), to_byte(comp(1)), to_byte(comp(2))],
865 }
866 }
867 }
868 }
869}
870
871fn palette(cs: &Cs, bits: u32, decode: Option<&[f32]>) -> Result<Option<Vec<[u8; 3]>>, String> {
876 let max_data = ((1u32 << bits) - 1) as f32;
877 let (def_min, def_max) = match cs {
880 Cs::Indexed { .. } => (0.0f32, max_data),
881 _ => (0.0, 1.0),
882 };
883 let (dmin, step, default_decode) = match decode {
884 Some(d) if d.len() >= 2 => {
885 let (min, max) = (d[0], d[1]);
886 (
887 min,
888 (max - min) / max_data,
889 def_min == min && def_max == max,
890 )
891 }
892 Some(_) => return Err("Decode array".into()),
893 None => (def_min, (def_max - def_min) / max_data, true),
894 };
895 let stock_gray = matches!(cs, Cs::Gray);
896 if bits == 1 {
897 if default_decode && stock_gray {
898 return Ok(None);
899 }
900 let c0 = cs.rgb_of(dmin);
901 let c1 = match cs {
902 Cs::Indexed { hival: 0, .. } => [0, 0, 0],
903 _ => cs.rgb_of(dmin + step),
904 };
905 if c0 == [0, 0, 0] && c1 == [255, 255, 255] {
906 return Ok(None);
907 }
908 return Ok(Some(vec![c0, c1]));
909 }
910 if bits == 8 && default_decode && stock_gray {
911 return Ok(None);
912 }
913 Ok(Some(
914 (0..(1u32 << bits))
915 .map(|i| cs.rgb_of(dmin + step * i as f32))
916 .collect(),
917 ))
918}
919
920fn sample(row: &[u8], index: usize, bpc: u32) -> u8 {
922 match bpc {
923 8 => row[index],
924 _ => {
925 let bitpos = index * bpc as usize;
926 (row[bitpos / 8] >> (8 - bpc as usize - bitpos % 8)) & ((1u8 << bpc) - 1)
927 }
928 }
929}
930
931enum Rows {
933 Packed { data: Vec<u8>, bpc: u32 },
935 Fax(Vec<Option<Vec<u8>>>),
937 Jpeg(jpeg::Image),
939}
940
941fn decode_image(
945 ctx: &Ctx<'_>,
946 stream: &lopdf::Stream,
947 device: (u32, u32),
948) -> Result<Decoded, String> {
949 let doc = ctx.doc;
950 let d = &stream.dict;
951 let int = |k: &[u8]| d.get(k).ok().and_then(|o| deref(doc, o).as_i64().ok());
952 let width = int(b"Width").or_else(|| int(b"W")).ok_or("Width")?;
953 let height = int(b"Height").or_else(|| int(b"H")).ok_or("Height")?;
954 if width <= 0 || height <= 0 || width > 1 << 16 || height > 1 << 16 {
955 return Err("image size".into());
956 }
957 if d.get(b"SMask").is_ok() || d.get(b"Mask").is_ok() {
958 return Err("soft/colour-key mask".into());
959 }
960 let is_mask = bool_or_int_true(doc, d.get(b"ImageMask").ok().or_else(|| d.get(b"IM").ok()));
961 let interpolate =
962 bool_or_int_true(doc, d.get(b"Interpolate").ok().or_else(|| d.get(b"I").ok()));
963 let decode: Option<Vec<f32>> = match d
964 .get(b"Decode")
965 .ok()
966 .or_else(|| d.get(b"D").ok())
967 .map(|o| deref(doc, o))
968 {
969 Some(Object::Array(a)) => Some(a.iter().filter_map(|o| num(deref(doc, o))).collect()),
970 _ => None,
971 };
972 let (w, h) = (width as usize, height as usize);
973
974 let chain = filters::filters(doc, d);
975 let (data, codec) =
976 filters::apply(doc, &stream.content, &chain).map_err(|e| format!("filter {e:?}"))?;
977
978 let rows = match codec {
979 Some(codec) if codec.name == "DCTDecode" => {
980 if is_mask {
981 return Err("DCT image mask".into());
982 }
983 const DCT_SCALING_LIKE_PDFIUM_TIP: bool = false;
992 let (dw, dh) = (i64::from(device.0), i64::from(device.1));
993 let mut levels = 0u32;
994 if DCT_SCALING_LIKE_PDFIUM_TIP && dw > 0 && dh > 0 {
995 let ratio = (width / dw).min(height / dh).max(1);
996 levels = (63 - ratio.leading_zeros()).min(3);
997 }
998 let transform = codec
999 .parms
1000 .as_ref()
1001 .and_then(|p| p.get(b"ColorTransform").ok())
1002 .and_then(|o| deref(doc, o).as_i64().ok())
1003 .unwrap_or(1)
1004 != 0;
1005 let img =
1006 jpeg::decode(&data, transform, 1 << levels).map_err(|e| format!("JPEG {e:?}"))?;
1007 if img.width != w.div_ceil(1 << levels) || img.height != h.div_ceil(1 << levels) {
1008 return Err("JPEG size differs from the dictionary".into());
1009 }
1010 Rows::Jpeg(img)
1011 }
1012 Some(codec) if codec.name == "CCITTFaxDecode" => {
1013 let p = codec.parms.as_ref();
1014 let pi = |k: &[u8], default: i64| {
1015 p.and_then(|p| p.get(k).ok())
1016 .and_then(|o| deref(doc, o).as_i64().ok())
1017 .unwrap_or(default)
1018 };
1019 let pb = |k: &[u8]| p.is_some_and(|p| bool_or_int_true(doc, p.get(k).ok()));
1020 let mut rows_param = pi(b"Rows", 0);
1021 if rows_param > i64::from(u16::MAX) {
1022 rows_param = 0;
1023 }
1024 let params = fax::Params {
1025 k: pi(b"K", 0) as i32,
1026 end_of_line: pb(b"EndOfLine"),
1027 byte_align: pb(b"EncodedByteAlign"),
1028 black_is_1: pb(b"BlackIs1"),
1029 columns: pi(b"Columns", 1728).max(0) as usize,
1030 rows: rows_param.max(0) as usize,
1031 };
1032 if params.columns == 0 || params.columns > 65535 {
1033 return Err("CCITT columns".into());
1034 }
1035 if fax::pitch(params.columns) < w.div_ceil(8) {
1038 return Err("CCITT columns narrower than the image".into());
1039 }
1040 Rows::Fax(fax::decode(&data, ¶ms, h))
1041 }
1042 Some(codec) => return Err(format!("codec {}", codec.name)),
1043 None => {
1044 let bpc = if is_mask {
1045 1
1046 } else {
1047 int(b"BitsPerComponent")
1048 .or_else(|| int(b"BPC"))
1049 .unwrap_or(0)
1050 };
1051 let bpc = match bpc {
1052 1 | 2 | 4 | 8 => bpc as u32,
1053 other => return Err(format!("{other} bits per component")),
1054 };
1055 Rows::Packed { data, bpc }
1056 }
1057 };
1058
1059 if is_mask {
1060 let default_decode = decode
1063 .as_ref()
1064 .is_none_or(|d| d.first().is_none_or(|v| *v as i64 == 0));
1065 let stride = w.div_ceil(8);
1066 let mut out = vec![0u8; stride * h];
1067 match rows {
1068 Rows::Packed { data, .. } => {
1069 if data.len() < stride * h {
1070 return Err("truncated image data".into());
1071 }
1072 for (y, row) in out.chunks_exact_mut(stride).enumerate() {
1073 for (o, &s) in row.iter_mut().zip(&data[y * stride..y * stride + stride]) {
1074 *o = if default_decode { !s } else { s };
1075 }
1076 }
1077 }
1078 Rows::Fax(lines) => {
1079 for (y, row) in out.chunks_exact_mut(stride).enumerate() {
1080 if let Some(Some(line)) = lines.get(y) {
1081 for (o, &s) in row.iter_mut().zip(&line[..stride]) {
1082 *o = if default_decode { !s } else { s };
1083 }
1084 }
1085 }
1086 }
1087 Rows::Jpeg(_) => return Err("DCT image mask".into()),
1088 }
1089 return Ok(Decoded {
1090 width: width as i32,
1091 height: height as i32,
1092 kind: Kind::Mask { data: out, stride },
1093 interpolate,
1094 });
1095 }
1096
1097 let cs_obj = d
1098 .get(b"ColorSpace")
1099 .ok()
1100 .or_else(|| d.get(b"CS").ok())
1101 .ok_or("no ColorSpace")?;
1102 let cs = color_space(doc, cs_obj, ctx.res, ctx.page_res)?;
1103 let comps = match cs {
1104 Cs::Rgb { .. } => 3,
1105 _ => 1,
1106 };
1107 let default_rgb = decode
1108 .as_deref()
1109 .is_none_or(|dec| dec == [0.0, 1.0, 0.0, 1.0, 0.0, 1.0]);
1110
1111 let (out_w, out_h, kind) = match rows {
1112 Rows::Jpeg(img) => {
1113 let kind = match (img.channels, comps) {
1114 (1, 1) => match palette(&cs, 8, decode.as_deref())? {
1117 None => Kind::Gray8(img.data),
1118 Some(pal) => {
1119 Kind::Rgb8(img.data.iter().flat_map(|&v| pal[usize::from(v)]).collect())
1120 }
1121 },
1122 (3, 3) => rgb8(img.data, decode.as_deref(), default_rgb),
1123 _ => return Err("JPEG components vs colour space".into()),
1124 };
1125 (img.width as i32, img.height as i32, kind)
1126 }
1127 Rows::Fax(lines) => {
1128 if comps != 1 {
1131 return Err("CCITT with an RGB colour space".into());
1132 }
1133 let stride = w.div_ceil(8);
1134 let mut out = vec![0u8; stride * h];
1135 for (y, row) in out.chunks_exact_mut(stride).enumerate() {
1136 if let Some(Some(line)) = lines.get(y) {
1137 row.copy_from_slice(&line[..stride]);
1138 }
1139 }
1140 let pal = palette(&cs, 1, decode.as_deref())?.map(|p| [p[0], p[1]]);
1141 (
1142 width as i32,
1143 height as i32,
1144 Kind::Bilevel {
1145 data: out,
1146 stride,
1147 palette: pal,
1148 },
1149 )
1150 }
1151 Rows::Packed { data, bpc } => {
1152 let stride = (w * bpc as usize * comps).div_ceil(8);
1153 if data.len() < stride * h {
1154 return Err("truncated image data".into());
1155 }
1156 let kind = if comps == 3 {
1157 if bpc != 8 {
1158 return Err(format!("{bpc}-bit RGB"));
1159 }
1160 rgb8(data, decode.as_deref(), default_rgb)
1161 } else if bpc == 1 {
1162 let pal = palette(&cs, 1, decode.as_deref())?.map(|p| [p[0], p[1]]);
1163 Kind::Bilevel {
1164 data,
1165 stride,
1166 palette: pal,
1167 }
1168 } else {
1169 match palette(&cs, bpc, decode.as_deref())? {
1172 None => Kind::Gray8(data),
1173 Some(pal) => {
1174 let mut out = Vec::with_capacity(w * h * 3);
1175 for y in 0..h {
1176 let row = &data[y * stride..y * stride + stride];
1177 for x in 0..w {
1178 out.extend_from_slice(&pal[usize::from(sample(row, x, bpc))]);
1179 }
1180 }
1181 Kind::Rgb8(out)
1182 }
1183 }
1184 };
1185 (width as i32, height as i32, kind)
1186 }
1187 };
1188 Ok(Decoded {
1189 width: out_w,
1190 height: out_h,
1191 kind,
1192 interpolate,
1193 })
1194}
1195
1196fn rgb8(data: Vec<u8>, decode: Option<&[f32]>, default_rgb: bool) -> Kind {
1200 if default_rgb {
1201 return Kind::Rgb8(data);
1202 }
1203 let dec = decode.unwrap_or(&[0.0, 1.0, 0.0, 1.0, 0.0, 1.0]);
1204 let tables: Vec<[u8; 256]> = (0..3)
1205 .map(|c| {
1206 let (min, max) = (
1207 dec.get(2 * c).copied().unwrap_or(0.0),
1208 dec.get(2 * c + 1).copied().unwrap_or(1.0),
1209 );
1210 let step = (max - min) / 255.0;
1211 let mut t = [0u8; 256];
1212 for (v, slot) in t.iter_mut().enumerate() {
1213 *slot = ((min + step * v as f32).clamp(0.0, 1.0) * 255.0) as u8;
1214 }
1215 t
1216 })
1217 .collect();
1218 let mut out = data;
1219 for px in out.chunks_exact_mut(3) {
1220 for (c, v) in px.iter_mut().enumerate() {
1221 *v = tables[c][usize::from(*v)];
1222 }
1223 }
1224 Kind::Rgb8(out)
1225}
1226
1227enum Blend {
1229 Gray,
1230 Rgb,
1231 Ramp([[u8; 3]; 2]),
1234 Mask([u8; 3]),
1237}
1238
1239fn put(canvas: &mut RgbImage, x: usize, y: usize, px: &[u8], blend: &Blend) {
1240 if x >= canvas.width() as usize || y >= canvas.height() as usize {
1241 return;
1242 }
1243 let p = canvas.get_pixel_mut(x as u32, y as u32);
1244 match blend {
1245 Blend::Gray => *p = image::Rgb([px[0], px[0], px[0]]),
1246 Blend::Rgb => *p = image::Rgb([px[0], px[1], px[2]]),
1247 Blend::Ramp([p0, p1]) => {
1248 let v = i32::from(px[0]);
1249 let ramp = |c: usize| {
1250 (i32::from(p0[c]) + (i32::from(p1[c]) - i32::from(p0[c])) * v / 255) as u8
1251 };
1252 *p = image::Rgb([ramp(0), ramp(1), ramp(2)]);
1253 }
1254 Blend::Mask(fill) => {
1255 let a = i32::from(px[0]);
1256 if a == 0 {
1257 return;
1258 }
1259 for (d, &f) in p.0.iter_mut().zip(fill) {
1260 *d = ((i32::from(*d) * (255 - a) + i32::from(f) * a) / 255) as u8;
1261 }
1262 }
1263 }
1264}
1265
1266fn draw(
1269 canvas: &mut RgbImage,
1270 img: &Decoded,
1271 m: M,
1272 clip: Rect,
1273 fill: Option<[u8; 3]>,
1274) -> Result<(), String> {
1275 let unit = [
1276 m.apply(0.0, 1.0),
1277 m.apply(0.0, 0.0),
1278 m.apply(1.0, 1.0),
1279 m.apply(1.0, 0.0),
1280 ];
1281 let image_rect = FRect::from_points(&unit).outer();
1282 let mut clip_box = clip;
1283 clip_box.intersect(&image_rect);
1284 if clip_box.is_empty() {
1285 return Ok(());
1286 }
1287 let bytes = i64::from(img.width)
1289 * i64::from(img.height)
1290 * match img.kind {
1291 Kind::Bilevel { .. } | Kind::Mask { .. } => 0,
1292 Kind::Gray8(_) => 1,
1293 Kind::Rgb8(_) => 3,
1294 };
1295 let options = Options {
1296 bilinear: img.interpolate || bytes > 60_000_000,
1297 no_smoothing: false,
1298 };
1299 let (source, blend) = match &img.kind {
1300 Kind::Bilevel {
1301 data,
1302 stride,
1303 palette,
1304 } => (
1305 Source::Bilevel {
1306 data,
1307 stride: *stride,
1308 },
1309 match palette {
1310 Some(p) => Blend::Ramp(*p),
1311 None => Blend::Gray,
1312 },
1313 ),
1314 Kind::Mask { data, stride } => {
1315 let fill = fill.ok_or("stencil mask fill colour")?;
1317 (
1318 Source::Bilevel {
1319 data,
1320 stride: *stride,
1321 },
1322 Blend::Mask(fill),
1323 )
1324 }
1325 Kind::Gray8(v) => (
1326 Source::Gray8 {
1327 data: v,
1328 stride: img.width as usize,
1329 },
1330 Blend::Gray,
1331 ),
1332 Kind::Rgb8(v) => (
1333 Source::Rgb8 {
1334 data: v,
1335 stride: 3 * img.width as usize,
1336 },
1337 Blend::Rgb,
1338 ),
1339 };
1340 let rotated = (m.b.abs() >= 0.5 || m.a == 0.0) || (m.c.abs() >= 0.5 || m.d == 0.0);
1341 if rotated {
1342 if !(m.a.abs() < m.b.abs() / 20.0
1343 && m.d.abs() < m.c.abs() / 20.0
1344 && m.a.abs() < 0.5
1345 && m.d.abs() < 0.5)
1346 {
1347 return Err("general affine image placement".into());
1348 }
1349 let dest_width = image_rect.width();
1350 let dest_height = image_rect.height();
1351 let mut bitmap_clip = clip_box;
1352 bitmap_clip.offset(-image_rect.left, -image_rect.top);
1353 let flip_x = m.c > 0.0;
1354 let flip_y = m.b < 0.0;
1355 let bitmap_clip = bitmap_clip.swapped_clip_box(dest_width, dest_height, flip_x, flip_y);
1356 let s = stretch::stretch(
1357 &source,
1358 img.width,
1359 img.height,
1360 dest_height,
1361 dest_width,
1362 bitmap_clip,
1363 options,
1364 )
1365 .ok_or("stretch")?;
1366 let (cw, ch) = (clip_box.width() as usize, clip_box.height() as usize);
1368 if s.height != cw || s.width != ch {
1369 return Err("swapped clip size".into());
1370 }
1371 for line in 0..cw {
1372 let dx = clip_box.left as usize + if flip_x { cw - line - 1 } else { line };
1373 for i in 0..ch {
1374 let dy = if flip_y {
1375 clip_box.top as usize + ch - 1 - i
1376 } else {
1377 clip_box.top as usize + i
1378 };
1379 put(
1380 canvas,
1381 dx,
1382 dy,
1383 &s.data[(line * s.width + i) * s.channels..][..s.channels],
1384 &blend,
1385 );
1386 }
1387 }
1388 return Ok(());
1389 }
1390 let mut dest_width = image_rect.width();
1391 if m.a < 0.0 {
1392 dest_width = -dest_width;
1393 }
1394 let mut dest_height = image_rect.height();
1395 if m.d > 0.0 {
1396 dest_height = -dest_height;
1397 }
1398 if dest_width == 0 || dest_height == 0 {
1399 return Ok(());
1400 }
1401 let mut bitmap_clip = clip_box;
1402 bitmap_clip.offset(-image_rect.left, -image_rect.top);
1403 let s = stretch::stretch(
1404 &source,
1405 img.width,
1406 img.height,
1407 dest_width,
1408 dest_height,
1409 bitmap_clip,
1410 options,
1411 )
1412 .ok_or("stretch")?;
1413 for row in 0..s.height {
1414 for col in 0..s.width {
1415 put(
1416 canvas,
1417 clip_box.left as usize + col,
1418 clip_box.top as usize + row,
1419 &s.data[(row * s.width + col) * s.channels..][..s.channels],
1420 &blend,
1421 );
1422 }
1423 }
1424 Ok(())
1425}
1426
1427fn render_inner(meta: &PdfMeta, index: usize, width: u32, height: u32) -> Result<RgbImage, String> {
1428 if !enabled() {
1429 return Err("DOCLING_RS_SCAN_RASTER=pdfium".into());
1430 }
1431 if width == 0 || height == 0 || width > 1 << 15 || height > 1 << 15 {
1432 return Err("bitmap size".into());
1433 }
1434 let doc = meta.doc();
1435 let pid = meta.page_id(index).ok_or("page index")?;
1436 let page = doc
1437 .get_object(pid)
1438 .ok()
1439 .and_then(|o| o.as_dict().ok())
1440 .ok_or("page dict")?;
1441 if let Some(annots) = page
1445 .get(b"Annots")
1446 .ok()
1447 .map(|o| deref(doc, o))
1448 .and_then(|o| o.as_array().ok())
1449 {
1450 for a in annots {
1451 let Some(ad) = as_dict(doc, a) else { continue };
1452 let hidden = ad
1453 .get(b"F")
1454 .ok()
1455 .and_then(|o| deref(doc, o).as_i64().ok())
1456 .is_some_and(|f| f & 2 != 0);
1457 if hidden {
1458 continue;
1459 }
1460 if !name_is(ad.get(b"Subtype").ok().map(|o| deref(doc, o)), b"Link") {
1461 return Err("annotation".into());
1462 }
1463 }
1464 }
1465
1466 let pb = crate::textparse::page_box(doc, pid);
1470 let geom = meta.geometry(index).ok_or("geometry")?;
1471 let (l, b, r, t) = (pb.l, pb.b, pb.l + pb.w, pb.b + pb.h);
1472 let page_matrix = match geom.rotation {
1473 90 => M {
1474 a: 0.0,
1475 b: -1.0,
1476 c: 1.0,
1477 d: 0.0,
1478 e: -b,
1479 f: r,
1480 },
1481 180 => M {
1482 a: -1.0,
1483 b: 0.0,
1484 c: 0.0,
1485 d: -1.0,
1486 e: r,
1487 f: t,
1488 },
1489 270 => M {
1490 a: 0.0,
1491 b: 1.0,
1492 c: -1.0,
1493 d: 0.0,
1494 e: t,
1495 f: -l,
1496 },
1497 _ => M {
1498 a: 1.0,
1499 b: 0.0,
1500 c: 0.0,
1501 d: 1.0,
1502 e: -l,
1503 f: -b,
1504 },
1505 };
1506 let (pw, ph) = (geom.width, geom.height);
1507 if pw == 0.0 || ph == 0.0 {
1508 return Err("page size".into());
1509 }
1510 let (wf, hf) = (width as f32, height as f32);
1511 let display = M {
1513 a: wf / pw,
1514 b: 0.0 / pw,
1515 c: 0.0 / ph,
1516 d: (0.0 - hf) / ph,
1517 e: 0.0,
1518 f: hf,
1519 };
1520 let obj2dev = page_matrix.then(display);
1521 let device = Rect::new(0, 0, width as i32, height as i32);
1522
1523 let page_res = doc.get_page_resources(pid).ok().and_then(|(inline, ids)| {
1524 inline.or_else(|| ids.into_iter().find_map(|id| doc.get_dictionary(id).ok()))
1525 });
1526 let ctx = Ctx {
1527 doc,
1528 obj2dev,
1529 res: page_res,
1530 page_res,
1531 device,
1532 depth: 0,
1533 };
1534 let content = doc.get_page_content(pid);
1535 let mut walker = Walker {
1536 draws: Vec::new(),
1537 ops: 0,
1538 };
1539 walker.walk(
1540 &ctx,
1541 &content,
1542 GState {
1543 ctm: M::ID,
1544 clip: device,
1545 text_render: 0,
1546 fill: Some([0, 0, 0]),
1547 fill_cs: FillCs::Gray,
1548 },
1549 )?;
1550 if walker.draws.is_empty() {
1551 return Err("no image on the page".into());
1552 }
1553
1554 let mut canvas = RgbImage::from_pixel(width, height, image::Rgb([255, 255, 255]));
1555 let mut cache: HashMap<ObjectId, Decoded> = HashMap::new();
1556 for dr in &walker.draws {
1557 if let std::collections::hash_map::Entry::Vacant(e) = cache.entry(dr.stream) {
1558 let stream = doc
1559 .get_object(dr.stream)
1560 .ok()
1561 .and_then(|o| o.as_stream().ok())
1562 .ok_or("image stream")?;
1563 let decoded = crate::timing::timed("raster.decode", || {
1564 decode_image(&ctx, stream, (width, height))
1565 })?;
1566 e.insert(decoded);
1567 }
1568 let img = &cache[&dr.stream];
1569 crate::timing::timed("raster.stretch", || {
1570 draw(&mut canvas, img, dr.matrix, dr.clip, dr.fill)
1571 })?;
1572 }
1573 Ok(canvas)
1574}
1575
1576#[cfg(test)]
1577mod tests {
1578 use super::*;
1579 use lopdf::{dictionary, Stream};
1580
1581 fn root() -> std::path::PathBuf {
1582 std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..")
1583 }
1584
1585 fn fixture(rel: &str) -> Vec<u8> {
1586 std::fs::read(root().join(rel)).unwrap_or_else(|e| panic!("{rel}: {e}"))
1587 }
1588
1589 fn crate_fixture(rel: &str) -> Vec<u8> {
1590 let p = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(rel);
1591 std::fs::read(&p).unwrap_or_else(|e| panic!("{}: {e}", p.display()))
1592 }
1593
1594 const IMAGE_ONLY: &[&str] = &[
1596 "tests/data/scanned/sources/ocr_test.pdf",
1597 "tests/data/scanned/sources/ocr_test_rotated_90.pdf",
1598 "tests/data/scanned/sources/ocr_test_rotated_180.pdf",
1599 "tests/data/scanned/sources/ocr_test_rotated_270.pdf",
1600 "tests/data/scanned/sources/ocr_test_raster.pdf",
1601 "tests/data/scanned/sources/ocr_test_raster_rot_90.pdf",
1602 "tests/data/scanned/sources/ocr_test_raster_rot_180.pdf",
1603 "tests/data/scanned/sources/ocr_test_raster_rot_270.pdf",
1604 "tests/data/scanned/sources/nemotron_multipage.pdf",
1605 "tests/data/scanned/sources/scanned_chart_table.pdf",
1606 "tests/data/pdf/sources/docling-rs-demotion-repro.pdf",
1607 ];
1608
1609 fn sizes(w: f32, h: f32) -> [(u32, u32); 2] {
1613 [
1614 (
1615 (w * 3.0).round().max(1.0) as u32,
1616 (h * 3.0).round().max(1.0) as u32,
1617 ),
1618 (
1619 (w * 1.5).ceil().max(1.0) as u32,
1620 (h * 1.5).ceil().max(1.0) as u32,
1621 ),
1622 ]
1623 }
1624
1625 #[test]
1626 fn born_digital_pages_are_declined() {
1627 let meta = PdfMeta::open(&fixture("tests/data/pdf/sources/2206.01062.pdf")).unwrap();
1628 assert!(render(&meta, 0, 612, 792).is_none());
1629 let meta = PdfMeta::open(&fixture(
1631 "tests/data/scanned/sources/sample_with_rotation_mismatch.pdf",
1632 ))
1633 .unwrap();
1634 assert!(render(&meta, 0, 842, 595).is_none());
1635 }
1636
1637 #[test]
1638 fn image_only_pages_render() {
1639 for rel in IMAGE_ONLY {
1640 let meta = PdfMeta::open(&fixture(rel)).unwrap();
1641 let g = meta.geometry(0).unwrap();
1642 let (w, h) = sizes(g.width, g.height)[1];
1643 let img = render(&meta, 0, w, h).unwrap_or_else(|| panic!("{rel} declined"));
1644 assert_eq!((img.width(), img.height()), (w, h), "{rel}");
1645 let dark = img.pixels().filter(|p| p[0] < 128).count();
1647 assert!(
1648 dark > 0 && dark < (w * h / 2) as usize,
1649 "{rel}: {dark} dark px"
1650 );
1651 }
1652 }
1653
1654 #[cfg(feature = "pdfium")]
1658 fn oracle(
1659 label: &str,
1660 pdf: &[u8],
1661 index: usize,
1662 sizes: &[(u32, u32)],
1663 failures: &mut Vec<String>,
1664 ) -> bool {
1665 use pdfium_render::prelude::*;
1666 let pdfium = match crate::pdfium_backend::bind_for_tests() {
1667 Ok(p) => p,
1668 Err(e) => {
1669 eprintln!("pdfium not installed — skipping the raster oracle ({e:?})");
1670 return false;
1671 }
1672 };
1673 let meta = PdfMeta::open(pdf).unwrap_or_else(|| panic!("{label}: lopdf cannot open"));
1674 let doc = pdfium
1675 .load_pdf_from_byte_slice(pdf, None)
1676 .unwrap_or_else(|e| panic!("{label}: pdfium {e:?}"));
1677 let page = doc.pages().get(index as PdfPageIndex).unwrap();
1678 for &(w, h) in sizes {
1679 let want = page
1680 .render_with_config(
1681 &PdfRenderConfig::new()
1682 .set_target_width(w as i32)
1683 .set_target_height(h as i32),
1684 )
1685 .unwrap()
1686 .as_image()
1687 .into_rgb8();
1688 let Some(got) = render(&meta, index, w, h) else {
1689 failures.push(format!("{label} @{w}x{h}: declined"));
1690 continue;
1691 };
1692 if got.as_raw() != want.as_raw() {
1693 let diff = got
1694 .as_raw()
1695 .iter()
1696 .zip(want.as_raw())
1697 .filter(|(a, b)| a != b)
1698 .count();
1699 let max = got
1700 .as_raw()
1701 .iter()
1702 .zip(want.as_raw())
1703 .map(|(a, b)| (i32::from(*a) - i32::from(*b)).abs())
1704 .max()
1705 .unwrap_or(0);
1706 failures.push(format!(
1707 "{label} @{w}x{h}: {diff} of {} bytes differ (max |Δ| {max})",
1708 want.len()
1709 ));
1710 if std::env::var_os("DOCLING_RS_RASTER_DUMP").is_some() {
1711 let stem = label.replace(['/', ' '], "_");
1712 let _ = got.save(format!("/tmp/{stem}.{w}.rust.png"));
1713 let _ = want.save(format!("/tmp/{stem}.{w}.pdfium.png"));
1714 }
1715 }
1716 }
1717 true
1718 }
1719
1720 #[test]
1724 #[cfg(feature = "pdfium")]
1725 fn matches_pdfium_on_the_scanned_fixtures() {
1726 let mut failures = Vec::new();
1727 let mut pages = 0;
1728 for rel in IMAGE_ONLY {
1729 let bytes = fixture(rel);
1730 let meta = PdfMeta::open(&bytes).unwrap();
1731 for i in 0..meta.page_count() {
1732 let g = meta.geometry(i).unwrap();
1733 if !oracle(rel, &bytes, i, &sizes(g.width, g.height), &mut failures) {
1734 return;
1735 }
1736 pages += 1;
1737 }
1738 }
1739 assert!(failures.is_empty(), "{}", failures.join("\n"));
1740 assert!(pages >= IMAGE_ONLY.len(), "{pages} pages compared");
1741 }
1742
1743 fn synth_pdf(
1746 page: (f32, f32),
1747 rotate: i64,
1748 content: &str,
1749 images: Vec<(&str, Stream)>,
1750 ) -> Vec<u8> {
1751 let mut doc = Document::with_version("1.5");
1752 let pages_id = doc.new_object_id();
1753 let mut xobjs = Dictionary::new();
1754 for (name, stream) in images {
1755 let id = doc.add_object(Object::Stream(stream));
1756 xobjs.set(name, Object::Reference(id));
1757 }
1758 let res_id = doc.add_object(dictionary! { "XObject" => Object::Dictionary(xobjs) });
1759 let content_id = doc.add_object(Object::Stream(Stream::new(
1760 Dictionary::new(),
1761 content.as_bytes().to_vec(),
1762 )));
1763 let page_id = doc.add_object(dictionary! {
1764 "Type" => "Page",
1765 "Parent" => Object::Reference(pages_id),
1766 "MediaBox" => vec![0.into(), 0.into(), page.0.into(), page.1.into()],
1767 "Rotate" => rotate,
1768 "Contents" => Object::Reference(content_id),
1769 "Resources" => Object::Reference(res_id),
1770 });
1771 doc.objects.insert(
1772 pages_id,
1773 Object::Dictionary(dictionary! {
1774 "Type" => "Pages",
1775 "Kids" => vec![Object::Reference(page_id)],
1776 "Count" => 1,
1777 }),
1778 );
1779 let catalog_id = doc.add_object(dictionary! {
1780 "Type" => "Catalog",
1781 "Pages" => Object::Reference(pages_id),
1782 });
1783 doc.trailer.set("Root", Object::Reference(catalog_id));
1784 let mut out = Vec::new();
1785 doc.save_to(&mut out).unwrap();
1786 out
1787 }
1788
1789 fn image_stream(w: usize, h: usize, extra: Dictionary, data: Vec<u8>) -> Stream {
1790 let mut d = dictionary! {
1791 "Type" => "XObject",
1792 "Subtype" => "Image",
1793 "Width" => w as i64,
1794 "Height" => h as i64,
1795 };
1796 for (k, v) in extra.into_iter() {
1797 d.set(k, v);
1798 }
1799 Stream::new(d, data)
1800 }
1801
1802 fn reals(v: &[f32]) -> Object {
1803 Object::Array(v.iter().map(|&x| Object::Real(x)).collect())
1804 }
1805
1806 fn full_page(w: f32, h: f32) -> String {
1808 format!("q {w} 0 0 {h} 0 0 cm /Im0 Do Q")
1809 }
1810
1811 fn shapes_bits() -> (usize, usize, Vec<u8>) {
1813 let img = image::open(
1814 std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/data/fax/shapes.png"),
1815 )
1816 .unwrap()
1817 .to_luma8();
1818 let (w, h) = (img.width() as usize, img.height() as usize);
1819 let stride = w.div_ceil(8);
1820 let mut out = vec![0u8; stride * h];
1821 for y in 0..h {
1822 for x in 0..w {
1823 if img.get_pixel(x as u32, y as u32)[0] >= 128 {
1824 out[y * stride + x / 8] |= 1 << (7 - x % 8);
1825 }
1826 }
1827 }
1828 (w, h, out)
1829 }
1830
1831 fn noise(n: usize, seed: u32) -> Vec<u8> {
1833 let mut s = seed.wrapping_mul(2654435761).wrapping_add(12345);
1834 (0..n)
1835 .map(|_| {
1836 s ^= s << 13;
1837 s ^= s >> 17;
1838 s ^= s << 5;
1839 (s >> 24) as u8
1840 })
1841 .collect()
1842 }
1843
1844 fn synthesized_cases() -> Vec<(String, Vec<u8>, (f32, f32))> {
1846 let mut cases: Vec<(String, Vec<u8>, (f32, f32))> = Vec::new();
1847
1848 for name in [
1851 "rgb_420_big",
1852 "gray_444",
1853 "rgb_422",
1854 "rgb_444_progressive",
1855 "rgb_420_progressive",
1856 "gray_progressive",
1857 ] {
1858 let jpg = crate_fixture(&format!("tests/data/jpeg/{name}.jpg"));
1859 let info = jpeg::info(&jpg).unwrap();
1860 let (w, h) = (info.width as f32, info.height as f32);
1861 let cs = if info.components == 1 {
1862 "DeviceGray"
1863 } else {
1864 "DeviceRGB"
1865 };
1866 let stream = image_stream(
1867 info.width,
1868 info.height,
1869 dictionary! { "ColorSpace" => cs, "BitsPerComponent" => 8, "Filter" => "DCTDecode" },
1870 jpg,
1871 );
1872 cases.push((
1873 format!("jpeg {name}"),
1874 synth_pdf((w, h), 0, &full_page(w, h), vec![("Im0", stream)]),
1875 (w, h),
1876 ));
1877 }
1878
1879 let (sw, sh, bits) = shapes_bits();
1881 let (swf, shf) = (sw as f32, sh as f32);
1882 for (name, k) in [("shapes.g4", -1i64), ("shapes.g3", 0), ("shapes.g3_2d", 4)] {
1883 let data = crate_fixture(&format!("tests/data/fax/{name}"));
1884 let parms = |black: bool| {
1885 dictionary! { "K" => k, "Columns" => sw as i64, "Rows" => sh as i64, "BlackIs1" => black }
1886 };
1887 let gray = image_stream(
1888 sw,
1889 sh,
1890 dictionary! {
1891 "ColorSpace" => "DeviceGray", "BitsPerComponent" => 1,
1892 "Filter" => "CCITTFaxDecode", "DecodeParms" => parms(false),
1893 },
1894 data.clone(),
1895 );
1896 cases.push((
1897 format!("ccitt {name} gray"),
1898 synth_pdf((swf, shf), 0, &full_page(swf, shf), vec![("Im0", gray)]),
1899 (swf, shf),
1900 ));
1901 let inverted = image_stream(
1902 sw,
1903 sh,
1904 dictionary! {
1905 "ColorSpace" => "DeviceGray", "BitsPerComponent" => 1,
1906 "Filter" => "CCITTFaxDecode", "DecodeParms" => parms(true),
1907 "Decode" => reals(&[1.0, 0.0]),
1908 },
1909 data.clone(),
1910 );
1911 cases.push((
1912 format!("ccitt {name} blackis1+decode"),
1913 synth_pdf((swf, shf), 0, &full_page(swf, shf), vec![("Im0", inverted)]),
1914 (swf, shf),
1915 ));
1916 let mask = image_stream(
1917 sw,
1918 sh,
1919 dictionary! { "ImageMask" => true, "Filter" => "CCITTFaxDecode", "DecodeParms" => parms(false) },
1920 data,
1921 );
1922 cases.push((
1923 format!("ccitt {name} mask"),
1924 synth_pdf(
1925 (swf, shf),
1926 0,
1927 &format!("0.2 0.5 0.8 rg {}", full_page(swf, shf)),
1928 vec![("Im0", mask)],
1929 ),
1930 (swf, shf),
1931 ));
1932 }
1933
1934 let mask_stream = |decode: Option<[f32; 2]>| {
1938 let mut d = dictionary! { "ImageMask" => true };
1939 if let Some(dec) = decode {
1940 d.set("Decode", reals(&dec));
1941 }
1942 image_stream(sw, sh, d, bits.clone())
1943 };
1944 cases.push((
1945 "mask default black".into(),
1946 synth_pdf(
1947 (swf, shf),
1948 0,
1949 &full_page(swf, shf),
1950 vec![("Im0", mask_stream(None))],
1951 ),
1952 (swf, shf),
1953 ));
1954 cases.push((
1955 "mask decode10 rgb".into(),
1956 synth_pdf(
1957 (swf, shf),
1958 0,
1959 &format!("0.9 0.1 0.3 rg {}", full_page(swf, shf)),
1960 vec![("Im0", mask_stream(Some([1.0, 0.0])))],
1961 ),
1962 (swf, shf),
1963 ));
1964 cases.push((
1965 "mask cs sc clip twice".into(),
1966 synth_pdf(
1967 (swf, shf),
1968 0,
1969 &format!(
1970 "/DeviceGray cs 0.35 sc q 20 10 150 90 re W n {} Q q 60 0 0 40 120 80 cm /Im0 Do Q",
1971 full_page(swf, shf)
1972 ),
1973 vec![("Im0", mask_stream(None))],
1974 ),
1975 (swf, shf),
1976 ));
1977 cases.push((
1978 "mask rotated page".into(),
1979 synth_pdf(
1980 (swf, shf),
1981 90,
1982 &format!("0 0 1 rg {}", full_page(swf, shf)),
1983 vec![("Im0", mask_stream(None))],
1984 ),
1985 (shf, swf),
1986 ));
1987 cases.push((
1988 "mask interpolate".into(),
1989 synth_pdf(
1990 (swf, shf),
1991 0,
1992 &full_page(swf, shf),
1993 vec![(
1994 "Im0",
1995 image_stream(
1996 sw,
1997 sh,
1998 dictionary! { "ImageMask" => true, "Interpolate" => true },
1999 bits.clone(),
2000 ),
2001 )],
2002 ),
2003 (swf, shf),
2004 ));
2005
2006 let (iw, ih) = (203usize, 131usize);
2008 let indexed = |base: &str, hival: i64, table: Vec<u8>| -> Object {
2009 Object::Array(vec![
2010 "Indexed".into(),
2011 base.into(),
2012 hival.into(),
2013 Object::String(table, lopdf::StringFormat::Hexadecimal),
2014 ])
2015 };
2016 let raw = |label: &str, w: usize, h: usize, dict: Dictionary, data: Vec<u8>| {
2017 (
2018 label.to_string(),
2019 synth_pdf(
2020 (w as f32, h as f32),
2021 0,
2022 &full_page(w as f32, h as f32),
2023 vec![("Im0", image_stream(w, h, dict, data))],
2024 ),
2025 (w as f32, h as f32),
2026 )
2027 };
2028 cases.push(raw(
2029 "indexed 4-bit rgb",
2030 iw,
2031 ih,
2032 dictionary! { "ColorSpace" => indexed("DeviceRGB", 15, noise(48, 2)), "BitsPerComponent" => 4 },
2033 noise(iw.div_ceil(2) * ih, 1),
2034 ));
2035 cases.push(raw(
2036 "indexed 1-bit two colours",
2037 sw,
2038 sh,
2039 dictionary! { "ColorSpace" => indexed("DeviceRGB", 1, vec![200, 30, 30, 20, 40, 220]), "BitsPerComponent" => 1 },
2040 bits.clone(),
2041 ));
2042 cases.push(raw(
2043 "indexed 8-bit hival 40",
2044 iw,
2045 ih,
2046 dictionary! { "ColorSpace" => indexed("DeviceRGB", 40, noise(123, 3)), "BitsPerComponent" => 8 },
2047 noise(iw * ih, 4),
2048 ));
2049 cases.push(raw(
2050 "indexed 2-bit gray base",
2051 iw,
2052 ih,
2053 dictionary! { "ColorSpace" => indexed("DeviceGray", 3, vec![10, 90, 170, 250]), "BitsPerComponent" => 2 },
2054 noise(iw.div_ceil(4) * ih, 5),
2055 ));
2056 cases.push(raw(
2057 "gray 2-bit",
2058 iw,
2059 ih,
2060 dictionary! { "ColorSpace" => "DeviceGray", "BitsPerComponent" => 2 },
2061 noise(iw.div_ceil(4) * ih, 6),
2062 ));
2063 cases.push(raw(
2064 "gray 4-bit decode10",
2065 iw,
2066 ih,
2067 dictionary! { "ColorSpace" => "DeviceGray", "BitsPerComponent" => 4, "Decode" => reals(&[1.0, 0.0]) },
2068 noise(iw.div_ceil(2) * ih, 7),
2069 ));
2070 cases.push(raw(
2071 "gray 8-bit decode range",
2072 iw,
2073 ih,
2074 dictionary! { "ColorSpace" => "DeviceGray", "BitsPerComponent" => 8, "Decode" => reals(&[0.2, 0.8]) },
2075 noise(iw * ih, 8),
2076 ));
2077 cases.push(raw(
2078 "rgb 8-bit inverted decode",
2079 iw,
2080 ih,
2081 dictionary! {
2082 "ColorSpace" => "DeviceRGB", "BitsPerComponent" => 8,
2083 "Decode" => reals(&[1.0, 0.0, 1.0, 0.0, 1.0, 0.0]),
2084 },
2085 noise(iw * ih * 3, 9),
2086 ));
2087 cases.push(raw(
2088 "calgray 1-bit",
2089 sw,
2090 sh,
2091 dictionary! {
2092 "ColorSpace" => Object::Array(vec![
2093 "CalGray".into(),
2094 Object::Dictionary(dictionary! { "WhitePoint" => reals(&[0.9505, 1.0, 1.089]), "Gamma" => 2.2f32 }),
2095 ]),
2096 "BitsPerComponent" => 1,
2097 },
2098 bits.clone(),
2099 ));
2100 cases
2101 }
2102
2103 #[test]
2110 #[cfg(feature = "pdfium")]
2111 fn synthesized_pages_match_pdfium() {
2112 let mut failures = Vec::new();
2113 let mut compared = 0;
2114 for (label, pdf, (pw, ph)) in synthesized_cases() {
2115 let sizes: Vec<(u32, u32)> = [1.0f32, 2.5, 0.49, 0.24, 0.12]
2116 .iter()
2117 .map(|s| {
2118 (
2119 (pw * s).round().max(1.0) as u32,
2120 (ph * s).round().max(1.0) as u32,
2121 )
2122 })
2123 .collect();
2124 if !oracle(&label, &pdf, 0, &sizes, &mut failures) {
2125 return;
2126 }
2127 compared += sizes.len();
2128 }
2129 assert!(
2130 failures.is_empty(),
2131 "{}\n({compared} renders compared)",
2132 failures.join("\n")
2133 );
2134 eprintln!("{compared} synthesized renders byte-identical to pdfium");
2135 }
2136
2137 #[test]
2140 fn synthesized_kinds_render_without_pdfium() {
2141 for (label, pdf, (pw, ph)) in synthesized_cases() {
2142 let meta = PdfMeta::open(&pdf).unwrap();
2143 let (w, h) = ((pw * 0.49).round() as u32, (ph * 0.49).round() as u32);
2144 assert!(
2145 render(&meta, 0, w, h).is_some(),
2146 "{label} declined at {w}x{h}"
2147 );
2148 }
2149 let (sw, sh, bits) = shapes_bits();
2150 let pdf = synth_pdf(
2151 (sw as f32, sh as f32),
2152 0,
2153 &format!("0.2 0.5 0.8 rg {}", full_page(sw as f32, sh as f32)),
2154 vec![(
2155 "Im0",
2156 image_stream(sw, sh, dictionary! { "ImageMask" => true }, bits),
2157 )],
2158 );
2159 let meta = PdfMeta::open(&pdf).unwrap();
2160 let img = render(&meta, 0, sw as u32, sh as u32).expect("stencil mask renders");
2161 let painted = img
2162 .pixels()
2163 .filter(|p| **p == image::Rgb([51, 128, 204]))
2164 .count();
2165 let white = img
2166 .pixels()
2167 .filter(|p| **p == image::Rgb([255, 255, 255]))
2168 .count();
2169 assert!(
2170 painted > 100 && white > 1000,
2171 "painted {painted}, white {white}"
2172 );
2173 }
2174
2175 fn describe(doc: &Document, res: Option<&Dictionary>, content: &[u8], depth: usize) {
2176 let pad = " ".repeat(depth);
2177 let ops = lopdf::content::Content::decode(content).expect("content");
2178 let mut shown = std::collections::BTreeMap::<String, usize>::new();
2179 for op in &ops.operations {
2180 *shown.entry(op.operator.clone()).or_default() += 1;
2181 if matches!(
2182 op.operator.as_str(),
2183 "cm" | "Do" | "re" | "gs" | "Tr" | "W" | "n"
2184 ) {
2185 println!("{pad}{} {:?}", op.operator, op.operands);
2186 }
2187 }
2188 println!("{pad}ops: {shown:?}");
2189 let Some(res) = res else { return };
2190 let Some(xobjs) = res.get(b"XObject").ok().and_then(|o| as_dict(doc, o)) else {
2191 return;
2192 };
2193 for (name, obj) in xobjs.iter() {
2194 let Some(stream) = deref(doc, obj).as_stream().ok() else {
2195 continue;
2196 };
2197 let mut d = stream.dict.clone();
2198 d.remove(b"Length");
2199 println!("{pad}/{} {:?}", String::from_utf8_lossy(name), d);
2200 if name_is(d.get(b"Subtype").ok(), b"Form") {
2201 let data = stream.decompressed_content().unwrap_or_default();
2202 let fres = d.get(b"Resources").ok().and_then(|o| as_dict(doc, o));
2203 describe(doc, fres, &data, depth + 1);
2204 } else if let Ok(Object::Reference(cs)) = d.get(b"ColorSpace") {
2205 println!("{pad} ColorSpace -> {:?}", doc.get_object(*cs).ok());
2206 }
2207 }
2208 }
2209
2210 #[test]
2213 #[ignore]
2214 fn dump() {
2215 for rel in IMAGE_ONLY {
2216 let bytes = fixture(rel);
2217 let Some(doc) = crate::textparse::load_document(&bytes) else {
2218 continue;
2219 };
2220 let mut pages: Vec<_> = doc.get_pages().into_iter().collect();
2221 pages.sort_by_key(|(n, _)| *n);
2222 for (n, pid) in pages.into_iter().take(3) {
2223 println!("=== {rel} p{n}");
2224 let content = doc.get_page_content(pid);
2225 let res = doc.get_page_resources(pid).ok().and_then(|(inline, ids)| {
2226 inline.or_else(|| ids.into_iter().find_map(|id| doc.get_dictionary(id).ok()))
2227 });
2228 describe(&doc, res, &content, 0);
2229 }
2230 }
2231 }
2232}