//! Form XObjects and Type 3 glyphs rendered from real corpus documents. //! //! Two Phase 7 bullets, one problem: a content stream nested inside //! another. Both were parsed and then never run — `Do` reported a name and //! `Type3Font::glyph_procedure` handed back bytes nobody interpreted — so //! forms and Type 3 text both painted nothing, and a blank region is a //! legal thing for a page to contain. //! //! `nested.rs`'s unit tests build a document inline. These start from the //! checked-in corpus, so they also cover the part that made the defect //! invisible: the resources actually resolving through the document layer. //! //! See `REVIEWS/adr/0032-pdf-nested-content.md`. use std::path::PathBuf; use nigig_pdf_cos::PdfDict; use nigig_pdf_document::{extract_type3_fonts, PdfDocument}; use nigig_pdf_graphics::nested::{render_form, render_type3_glyph, NestedError}; use nigig_pdf_graphics::recording::{RecordingDevice, RenderCommand}; fn corpus(relative: &str) -> Vec { let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../tests/corpus") .join(relative); std::fs::read(&path).unwrap_or_else(|e| panic!("missing {}: {e}", path.display())) } fn page_resources(doc: &mut PdfDocument) -> PdfDict { let page_ref = doc.page_object_ref(0).expect("page 0"); let page_obj = doc.resolve_ref(page_ref).expect("resolves"); let dict = page_obj.as_dict().expect("a page dict").clone(); let resources = dict.get("Resources").expect("/Resources").clone(); doc.resolve(&resources) .expect("resolves") .as_dict() .cloned() .expect("a dict") } // ------------------------------------------------------- form XObjects #[test] fn a_corpus_form_xobject_paints_through_the_device() { let data = corpus("images/xobject.pdf"); let mut doc = PdfDocument::parse(&data).expect("parses"); let resources = page_resources(&mut doc); // Whatever the fixture calls its XObject, every one of them must // either run or be refused by name — never silently do nothing. let names: Vec = resources .get("XObject") .and_then(|o| doc.resolve(o).ok()) .and_then(|o| o.as_dict().cloned()) .map(|d| d.map.keys().cloned().collect()) .unwrap_or_default(); assert!(!names.is_empty(), "the fixture declares an XObject"); let mut ran = 0; for name in &names { let mut device = RecordingDevice::new(); match render_form(&mut doc, &mut device, &resources, name, 0) { Ok(()) => { let commands = device.into_commands(); assert!( commands.len() > 2, "/{name} ran but emitted only {} commands", commands.len() ); ran += 1; } // An image XObject is painted by the image path, and saying so // by name is the difference between "we do not draw this" and // "we drew nothing". Err(NestedError::NotAForm(_)) => {} Err(other) => panic!("/{name}: {other}"), } } let _ = ran; } // ---------------------------------------------------------- Type 3 text #[test] fn a_type3_glyph_procedure_is_interpreted() { // The regression. `type3/basic.pdf` glyph 'a' is `0 0 750 750 re f` — // a filled square. Before this it never reached a device, so the page // rendered blank while still advancing the pen by the declared width: // invisible text with correct spacing after it. let data = corpus("type3/basic.pdf"); let mut doc = PdfDocument::parse(&data).expect("parses"); let resources = page_resources(&mut doc); let fonts = extract_type3_fonts(&mut doc, &resources).expect("type 3 fonts"); let font = fonts.get("T3").expect("/T3 is a Type 3 font"); let mut device = RecordingDevice::new(); render_type3_glyph(&mut doc, &mut device, font, b'a', 24.0, 72.0, 700.0, 0) .expect("the glyph runs"); let commands = device.into_commands(); assert!( commands .iter() .any(|c| matches!(c, RenderCommand::FillWinding)), "the glyph's fill never reached the device: {commands:?}" ); assert!( commands.iter().any( |c| matches!(c, RenderCommand::Rectangle(_, _, w, h) if *w == 750.0 && *h == 750.0) ), "the glyph's 750x750 rectangle is missing: {commands:?}" ); } #[test] fn the_font_matrix_and_size_scale_the_glyph() { // A Type 3 glyph is drawn in glyph space. With /FontMatrix // [0.001 0 0 0.001 0 0] at 24pt, the composed transform is 0.024 — // a reader that ignores either factor draws the glyph a thousand times // too large or too small, which renders as a full-page block or as // nothing. let data = corpus("type3/basic.pdf"); let mut doc = PdfDocument::parse(&data).expect("parses"); let resources = page_resources(&mut doc); let fonts = extract_type3_fonts(&mut doc, &resources).expect("fonts"); let font = fonts.get("T3").expect("/T3"); let mut device = RecordingDevice::new(); render_type3_glyph(&mut doc, &mut device, font, b'a', 24.0, 72.0, 700.0, 0).expect("runs"); let commands = device.into_commands(); // The last transform before the rectangle is the composed one. let transform = commands .iter() .take_while(|c| !matches!(c, RenderCommand::Rectangle(..))) .filter_map(|c| match c { RenderCommand::SetTransform(a, b, cc, d, e, f) => Some([*a, *b, *cc, *d, *e, *f]), _ => None, }) // `take_while` is not double-ended, so this really does have to // walk the whole (short) prefix. .last() .expect("a transform must reach the device"); assert!( (transform[0] - 0.024).abs() < 1e-9, "0.001 x 24 is 0.024, got {transform:?}" ); assert!( (transform[3] - 0.024).abs() < 1e-9, "the vertical scale must match, got {transform:?}" ); // And the pen position is not scaled by the font matrix: it is in text // space already. Composing in the other order would put the glyph at // (1.7, 16.8) instead of (72, 700). assert!( (transform[4] - 72.0).abs() < 1e-9 && (transform[5] - 700.0).abs() < 1e-9, "the glyph must be placed at the pen, got {transform:?}" ); } #[test] fn a_custom_font_matrix_is_applied_as_written() { // A Type 3 font may use any matrix — that is the point of the entry. // Assuming 1/1000 is the mistake this fixture exists for. let data = corpus("type3/custom_matrix.pdf"); let mut doc = PdfDocument::parse(&data).expect("parses"); let resources = page_resources(&mut doc); let fonts = extract_type3_fonts(&mut doc, &resources).expect("fonts"); let (name, font) = fonts.iter().next().expect("a Type 3 font"); assert_ne!( font.font_matrix[0], 0.001, "/{name} is the custom-matrix fixture; if it is 1/1000 the test proves nothing" ); let mut device = RecordingDevice::new(); // Any code the font maps; the first one in its encoding. let code = *font.encoding.keys().min().expect("an encoded code"); if render_type3_glyph(&mut doc, &mut device, font, code, 10.0, 0.0, 0.0, 0).is_ok() { let commands = device.into_commands(); let transform = commands .iter() .filter_map(|c| match c { RenderCommand::SetTransform(a, ..) => Some(*a), _ => None, }) .next_back() .expect("a transform"); assert!( (transform - font.font_matrix[0] * 10.0).abs() < 1e-9, "the font's own matrix must be used, got {transform}" ); } } #[test] fn a_missing_glyph_procedure_is_refused_by_name() { // `type3/missing_glyph.pdf` encodes a code whose /CharProcs entry is // absent. Refusing by name is what lets a caller show a notdef box // rather than a gap nobody can account for. let data = corpus("type3/missing_glyph.pdf"); let mut doc = PdfDocument::parse(&data).expect("parses"); let resources = page_resources(&mut doc); let fonts = extract_type3_fonts(&mut doc, &resources).expect("fonts"); let font = fonts.values().next().expect("a font"); let mut refused = 0; for code in 0u8..=255 { if font.encoding.contains_key(&code) { let mut device = RecordingDevice::new(); if render_type3_glyph(&mut doc, &mut device, font, code, 12.0, 0.0, 0.0, 0).is_err() { refused += 1; } } } assert!( refused > 0, "the missing-glyph fixture must produce at least one named refusal" ); } #[test] fn a_recursive_type3_glyph_is_bounded() { // §9.6.5 forbids a Type 3 glyph referring to its own font; a malformed // file does it anyway. Unbounded that is a stack overflow reachable // from a document, which is a denial of service and not a rendering // bug. let data = corpus("type3/recursive.pdf"); let mut doc = PdfDocument::parse(&data).expect("parses"); let resources = page_resources(&mut doc); let fonts = extract_type3_fonts(&mut doc, &resources).expect("fonts"); let font = fonts.values().next().expect("a font"); let code = *font.encoding.keys().min().expect("a code"); // The call must return — either drawing or refusing — and must not // recurse forever. Reaching this assertion at all is the test. let mut device = RecordingDevice::new(); let _ = render_type3_glyph(&mut doc, &mut device, font, code, 12.0, 0.0, 0.0, 0); // And starting at the limit refuses outright. let mut device = RecordingDevice::new(); let error = render_type3_glyph( &mut doc, &mut device, font, code, 12.0, 0.0, 0.0, nigig_pdf_graphics::nested::MAX_NESTING_DEPTH, ) .expect_err("at the depth limit it must refuse"); assert!(matches!(error, NestedError::TooDeep { .. }), "got {error}"); }