//! Test files are generated here (no third-party samples). use super::*; use crate::write::pdf; const CONTENT: &str = "/OC /MC0 BDC\n0 1 0 rg 0 0 200 100 re f\nEMC\n\ /OC /MC1 BDC\nq 10 10 80 80 re W n\n1 0 0 rg 0 0 50 100 re f\nQ\n\ /Pattern cs /P0 scn 100 0 100 50 re f\n\ 0 0 1 RG 4 w 120 80 m 180 80 l S\n\ BT /F1 12 Tf (ignored) Tj ET\nEMC\n"; pub(crate) fn sample_pdf(object_stream: bool) -> Vec { let page = "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Resources << /Properties << /MC0 5 0 R /MC1 6 0 R >> /Pattern << /P0 7 0 R >> >> /Contents 4 0 R >>"; let pattern = "<< /PatternType 2 /Shading << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [100 0 200 0] /Function << /FunctionType 2 /Domain [0 1] /C0 [1 0 0] /C1 [0 0 1] /N 1 >> /Extend [true true] >> >>"; let mut objs = vec![ (1, "<< /Type /Catalog /Pages 2 0 R >>".to_string(), None), (2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), None), (3, page.to_string(), None), (4, "<< >>".to_string(), Some(CONTENT.as_bytes().to_vec())), (7, pattern.to_string(), None), ]; let ocg5 = "<< /Type /OCG /Name (Background) >>"; let ocg6 = "<< /Type /OCG /Name >>"; // "Art" in UTF-16BE if object_stream { let body = format!("{ocg5} {ocg6}"); let head = format!("5 0 6 {} ", ocg5.len() + 1); let data = format!("{head}{body}"); objs.push((8, format!("<< /Type /ObjStm /N 2 /First {} >>", head.len()), Some(data.into_bytes()))); } else { objs.push((5, ocg5.to_string(), None)); objs.push((6, ocg6.to_string(), None)); } pdf(&objs, 1) } fn px(doc: &Doc, x: i64, y: i64) -> [f32; 4] { let (w, h) = doc.pixel_size(); let img = effectcraft_svg::rasterize(doc, w, h, 1.0); img.get(x, y) } #[test] fn pdf_paths_clip_gradient_and_layers() { for objstm in [false, true] { let bytes = sample_pdf(objstm); assert_eq!(sniff(&bytes), Some(Format::Pdf)); assert_eq!(page_count(&bytes), 1); let doc = parse(&bytes).unwrap(); assert_eq!((doc.width, doc.height), (200.0, 100.0)); assert_eq!(layer_names(&doc), vec!["Background", "Art"], "object stream {objstm}"); assert!(doc.skipped.contains(&"text (no font)".to_string()), "{:?}", doc.skipped); let (w, h) = doc.pixel_size(); let img = effectcraft_svg::rasterize(&doc, w, h, 1.0); let at = |x: i64, y: i64| img.get(x, y); // Red rectangle clipped to x ≥ 10, over the green background. assert!(at(30, 50)[0] > 0.99 && at(30, 50)[1] < 0.01, "{:?}", at(30, 50)); assert!(at(5, 50)[1] > 0.99 && at(5, 50)[0] < 0.01, "clipped out: {:?}", at(5, 50)); assert!(at(70, 50)[1] > 0.99, "{:?}", at(70, 50)); // Axial shading, red → blue left to right (in the lower half: PDF y is up). let (l, r) = (at(110, 75), at(190, 75)); assert!(l[0] > 0.8 && l[2] < 0.2 && r[2] > 0.8 && r[0] < 0.2, "{l:?} {r:?}"); assert!(at(150, 25)[1] > 0.99, "the gradient stays in its rectangle: {:?}", at(150, 25)); // Stroked line (PDF y = 80 → 20 px from the top). assert!(at(150, 20)[2] > 0.99 && at(150, 20)[1] < 0.01, "{:?}", at(150, 20)); // One layer at a time. let bg = layer_doc(&doc, 0); assert!(px(&bg, 30, 50)[1] > 0.99); let art = layer_doc(&doc, 1); assert_eq!(px(&art, 5, 50)[3], 0.0); // Continuous rasterisation: twice the size stays sharp at the clip edge. let big = effectcraft_svg::rasterize(&doc, 400, 200, 2.0); assert!(big.get(21, 100)[0] > 0.99 && big.get(18, 100)[1] > 0.99); } } #[test] fn pdf_forms_rotation_cmyk_and_alpha() { let content = "q 2 0 0 2 0 0 cm /Fm0 Do Q\n/GS0 gs 0 0 0 1 k 0 0 10 10 re f\n"; let form = "0 1 1 0 k 0 0 20 20 re f"; let objs = vec![ (1, "<< /Type /Catalog /Pages 2 0 R >>".to_string(), None), (2, "<< /Type /Pages /Kids [3 0 R] /Count 1 /Rotate 90 >>".to_string(), None), ( 3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 50] /Resources << /XObject << /Fm0 5 0 R >> /ExtGState << /GS0 << /ca 0.5 >> >> >> /Contents 4 0 R >>" .to_string(), None, ), (4, "<< >>".to_string(), Some(content.as_bytes().to_vec())), (5, "<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] >>".to_string(), Some(form.as_bytes().to_vec())), ]; let doc = parse(&pdf(&objs, 1)).unwrap(); // Rotated a quarter turn: 50 × 100. assert_eq!((doc.width, doc.height), (50.0, 100.0)); let (w, h) = doc.pixel_size(); let img = effectcraft_svg::rasterize(&doc, w, h, 1.0); // The form (CMYK red, clipped to its 10×10 box, scaled ×2) covers PDF (0..20, 0..20); the // half-transparent black square covers PDF (0..10, 0..10). /Rotate 90: PDF (x, y) → (y, x). let red = img.get(15, 15); assert!(red[0] > 0.99 && red[1] < 0.01 && red[3] > 0.99, "{red:?}"); let dark = img.get(5, 5); assert!((dark[0] - 0.5).abs() < 0.02 && dark[3] > 0.99, "{dark:?}"); assert_eq!(img.get(30, 30)[3], 0.0, "form clipped to its bounding box"); } const EPS: &str = "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n\ /m {moveto} bind def /l {lineto} bind def\n\ /cm { 6 array astore concat } bind def\n\ /box { 4 dict begin /h exch def /w exch def /y exch def /x exch def x y m w 0 rlineto 0 h rlineto w neg 0 rlineto closepath end } bind def\n\ 1 0 0 setrgbcolor\n10 10 30 30 box fill\n\ gsave 0 0 1 setrgbcolor 70 50 20 0 360 arc fill grestore\n\ gsave 1 0 0 1 50 0 cm 0 1 0 setrgbcolor 0 0 10 10 box fill grestore\n\ 0 setgray 2 setlinewidth 0 90 m 100 90 l stroke\n\ /Helvetica findfont 12 scalefont setfont 5 5 moveto (text) show\n\ showpage\n%%EOF\n"; #[test] fn eps_postscript_subset() { let doc = parse(EPS.as_bytes()).unwrap(); assert_eq!((doc.width, doc.height), (100.0, 100.0)); assert!(doc.skipped.is_empty(), "{:?}", doc.skipped); assert!(shape_names(&doc).contains(&"Text: text".to_string()), "EPS text is drawn: {:?}", shape_names(&doc)); let at = |x, y| px(&doc, x, y); assert!(at(25, 75)[0] > 0.99 && at(25, 75)[3] > 0.99, "red box {:?}", at(25, 75)); assert!(at(70, 50)[2] > 0.99, "blue disc {:?}", at(70, 50)); assert!(at(55, 95)[1] > 0.99, "translated green box {:?}", at(55, 95)); let line = at(50, 10); assert!(line[3] > 0.99 && line[0] < 0.01, "black line {line:?}"); assert_eq!(at(95, 70)[3], 0.0); // DOS EPS binary header (with a fake TIFF preview after the PostScript). let ps = EPS.as_bytes(); let mut dos = vec![0xC5, 0xD0, 0xD3, 0xC6]; dos.extend_from_slice(&30u32.to_le_bytes()); dos.extend_from_slice(&(ps.len() as u32).to_le_bytes()); dos.extend_from_slice(&[0; 18]); dos.extend_from_slice(ps); dos.extend_from_slice(b"II*\0 not a real preview"); let d2 = parse(&dos).unwrap(); assert_eq!(d2.root.children.len(), doc.root.children.len()); assert_eq!(codec("art.eps", &dos), Some("EPS")); assert_eq!(codec("art.ai", &sample_pdf(false)), Some("AI")); assert_eq!(parse(b"hello"), Err(Error::NotVector)); } #[test] fn filters_decode() { assert_eq!(object::ascii85(b"<~87cURD]i,\"Ebo80~>"), b"Hello World!".to_vec()); let z = miniz_oxide::deflate::compress_to_vec_zlib(b"abc", 6); assert_eq!(object::inflate(&z, 1 << 20).unwrap(), b"abc"); } #[test] fn flate_bomb_is_bounded() { // A decompression bomb in miniature: 16 MiB of zeros deflate to about 16 KB. let zeros = vec![0u8; 16 << 20]; for bomb in [miniz_oxide::deflate::compress_to_vec_zlib(&zeros, 6), miniz_oxide::deflate::compress_to_vec(&zeros, 6)] { assert!(bomb.len() < 64 << 10); // Past the limit, as zlib and as raw deflate, it is undecodable: not inflated in full // and not salvaged as a truncated stream. assert_eq!(object::inflate(&bomb, 1 << 20).map(|v| v.len()), None); assert_eq!(object::inflate(&bomb, 32 << 20).map(|v| v.len()), Some(16 << 20)); } } #[test] fn truncated_flate_keeps_what_inflates() { let mut x = 1u32; let data: Vec = (0..20_000) .map(|_| { x = x.wrapping_mul(1_103_515_245).wrapping_add(12_345); (x >> 16) as u8 }) .collect(); let z = miniz_oxide::deflate::compress_to_vec_zlib(&data, 6); let part = object::inflate(&z[..z.len() / 2], 1 << 20).unwrap(); assert!(!part.is_empty() && part.len() < data.len()); assert_eq!(part, data[..part.len()]); } #[test] fn salvage_buffer_is_bounded_by_the_limit() { // Small inputs keep eight times their size and 1 KiB. assert_eq!(object::salvage_len(0, 1 << 20), 1024); assert_eq!(object::salvage_len(10_000, 1 << 20), 81_024); // Large ones stop at the limit (`1 << 28` fits a 32-bit `usize` too), without overflowing. assert_eq!(object::salvage_len(1 << 28, 1 << 20), 1 << 20); assert_eq!(object::salvage_len(usize::MAX, 1 << 20), 1 << 20); } #[test] fn nested_bomb_in_the_second_flate_filter_is_refused() { // `/Filter [/FlateDecode /FlateDecode]`: the first filter inflates, within the limit, to a zlib // bomb (padded to just under the limit) that the second would inflate to 16 MiB. The second // stops at the limit, so the stream is undecodable. const MAX: usize = 1 << 20; let flate = object::Obj::Name("FlateDecode".into()); let chain: object::Dict = [("Filter".to_string(), object::Obj::Array(vec![flate.clone(), flate]))].into_iter().collect(); let file = object::File::parse(b""); let mut nested = miniz_oxide::deflate::compress_to_vec_zlib(&vec![0u8; 16 << 20], 6); nested.resize(MAX - 1, 0); let raw = miniz_oxide::deflate::compress_to_vec_zlib(&nested, 6); assert_eq!(object::inflate(&raw, MAX).map(|v| v.len()), Some(MAX - 1)); assert_eq!(object::decode_stream_within(&file, &chain, &raw, MAX).map(|v| v.len()), None); } fn bounded_filter(dict: &str, raw: &[u8], max: usize) -> Option> { let object::Obj::Dict(d) = object::Lexer::new(dict.as_bytes(), 0).next().unwrap() else { panic!("not a dictionary") }; object::decode_stream_within(&object::File::parse(b""), &d, raw, max) } #[test] fn predictor_huge_columns_decode_to_nothing() { // A row of a petabyte used to be allocated before looking at the data (an allocation // failure aborts the process, so this test took the whole test binary down). let raw = miniz_oxide::deflate::compress_to_vec_zlib(&[0, 42], 6); let dict = "<< /Filter /FlateDecode /DecodeParms << /Predictor 12 /Columns 1000000000000000 >> >>"; assert_eq!(bounded_filter(dict, &raw, 16), Some(vec![])); } #[test] fn predictor_overflow_and_empty_rows_decode_to_nothing() { let raw = miniz_oxide::deflate::compress_to_vec_zlib(&[0, 42], 6); for parms in [ format!("/Columns {} /Colors 2 /BitsPerComponent 8", usize::MAX), format!("/Columns 1 /Colors {} /BitsPerComponent 8", usize::MAX), "/Columns 0".into(), // A row is two bytes plus its tag byte: longer than the two bytes of data. "/Columns 2".into(), ] { assert_eq!(bounded_filter(&format!("<< /Filter /FlateDecode /DecodeParms << /Predictor 12 {parms} >> >>"), &raw, 16), Some(vec![])); } } #[test] fn run_length_chain_is_bounded() { // Each stage expands repetitions of 0x81, which is itself an RL repeat code. let raw = [0x81, 0x81, 128]; let chain = "<< /Filter [/RunLengthDecode /RL /RunLengthDecode] >>"; assert_eq!(bounded_filter(chain, &raw, 128).map(|v| v.len()), None); assert_eq!(bounded_filter("<< /Filter /RL >>", &raw, 128), Some(vec![0x81; 128])); // An over-limit intermediate stage must fail even if the next stage shrinks it. assert_eq!(bounded_filter("<< /Filter [/RL /AHx] >>", &raw, 127), None); } fn lzw_codes(codes: impl IntoIterator, early: bool) -> Vec { let mut w = crate::ccitt::tests::W::default(); let (mut bits, mut entries, mut prev) = (9, 258, false); for code in codes { w.put(code, bits); match code { 256 => (bits, entries, prev) = (9, 258, false), 257 => break, _ => { if prev && entries < 4096 { entries += 1; } prev = true; if entries + usize::from(early) >= (1 << bits) && bits < 12 { bits += 1; } } } } w.bytes } #[test] fn lzw_long_run_is_bounded_and_full_table_decodes() { // Grow strings of A until the 12-bit table is full, then send literal A codes. // Both old and new implementations use well under 100 MB for this fixture. for early in [false, true] { let raw = lzw_codes([256, 65].into_iter().chain(258..=4095).chain(std::iter::repeat_n(65, 62_500)).chain([257]), early); assert!((95_000..105_000).contains(&raw.len())); let dict = format!("<< /Filter /LZWDecode /DecodeParms << /EarlyChange {} >> >>", usize::from(early)); assert_eq!(bounded_filter(&dict, &raw, 1 << 20).map(|v| v.len()), None); let expected = 3839 * 3840 / 2 + 62_500; assert_eq!(bounded_filter(&dict, &raw, expected), Some(vec![b'A'; expected])); // Clear restores the initial dictionary and code width after saturation. let cleared = lzw_codes([256, 65].into_iter().chain(258..=4095).chain([256, 66, 257]), early); let mut expected = vec![b'A'; 3839 * 3840 / 2]; expected.push(b'B'); assert_eq!(bounded_filter(&dict, &cleared, expected.len()), Some(expected)); } } #[test] fn ccitt_declared_and_unknown_rows_are_bounded() { // Eight white Group 4 rows. The old decoder pads declared missing rows to white. assert_eq!(bounded_filter("<< /Filter /CCF /DecodeParms << /K -1 /Columns 64 /Rows 1024 >> >>", &[0xFF], 16), None); assert_eq!(bounded_filter("<< /Filter /CCF /DecodeParms << /K -1 /Columns 64 >> >>", &[0xFF], 16), None); assert_eq!(bounded_filter("<< /Filter /CCF /DecodeParms << /K -1 /Columns 8 /Rows 8 >> >>", &[0xFF], 8), Some(vec![0xFF; 8])); // Padding below the limit remains unchanged. assert_eq!(bounded_filter("<< /Filter /CCF /DecodeParms << /K -1 /Columns 8 /Rows 16 >> >>", &[0xFF], 16), Some(vec![0xFF; 16])); // Without `/Rows` the size is never computed up front: 2^20 rows of 4096 bytes overflow a // 32-bit `usize`, but eight rows of this width decode on every target. assert_eq!(bounded_filter("<< /Filter /CCF /DecodeParms << /K -1 /Columns 32768 >> >>", &[0xFF], 32768), Some(vec![0xFF; 32768])); } #[test] fn ascii85_and_hex_outputs_are_bounded() { assert_eq!(bounded_filter("<< /Filter /A85 >>", b"zz", 7), None); assert_eq!(bounded_filter("<< /Filter /A85 >>", b"zz", 8), Some(vec![0; 8])); assert_eq!(bounded_filter("<< /Filter /A85 >>", b"87cURD]i,\"Ebo80", 12), Some(b"Hello World!".to_vec())); assert_eq!(bounded_filter("<< /Filter /A85 >>", b"87cURD]i,\"Ebo80", 11), None); assert_eq!(bounded_filter("<< /Filter /AHx >>", b"4142434>", 3), None); assert_eq!(bounded_filter("<< /Filter /AHx >>", b"4142434>", 4), Some(b"ABC@".to_vec())); } #[test] fn predictors_and_normal_lzw_decode_within_limit() { for predictor in [1, 2, 12] { let (data, expected) = if predictor == 12 { (vec![0, 10, 20, 2, 1, 2], vec![10, 20, 11, 22]) } else { (vec![10, 20], vec![10, 20]) }; let raw = miniz_oxide::deflate::compress_to_vec_zlib(&data, 6); let dict = format!("<< /Filter /FlateDecode /DecodeParms << /Predictor {predictor} /Columns 2 >> >>"); assert_eq!(bounded_filter(&dict, &raw, data.len()), Some(expected)); } for early in [false, true] { let raw = lzw_codes([256, 65, 66, 258, 257], early); assert_eq!(bounded_filter("<< /Filter /LZW >>", &raw, 4), Some(b"ABAB".to_vec())); assert_eq!(bounded_filter("<< /Filter /LZW >>", &raw, 3), None); } assert_eq!(bounded_filter("<< /Filter /RL >>", &[2, b'a', b'b', b'c', 254, b'd', 128], 6), Some(b"abcddd".to_vec())); assert_eq!(bounded_filter("<< /Filter /RL >>", &[2, b'a', b'b', b'c', 128], 2), None); assert_eq!(bounded_filter("<< /Filter /RL >>", &[128], 0), Some(vec![])); } // ------------------------------------------------------------------ M13.6: text, images, // patterns, soft masks, blend modes, pages pub(crate) type Objs = Vec<(u32, String, Option>)>; /// A one-page 200×100 PDF: `resources` is the page's resource dictionary body, `extra` more /// objects (numbered from 10). pub(crate) fn page_pdf(content: &str, resources: &str, extra: Objs) -> Vec { let mut objs: Objs = vec![ (1, "<< /Type /Catalog /Pages 2 0 R >>".into(), None), (2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), None), (3, format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Resources << {resources} >> /Contents 4 0 R >>"), None), (4, "<< >>".into(), Some(content.as_bytes().to_vec())), ]; objs.extend(extra); pdf(&objs, 1) } pub(crate) fn render(doc: &Doc) -> effectcraft_raster::Image { let (w, h) = doc.pixel_size(); effectcraft_svg::rasterize(doc, w, h, 1.0) } pub(crate) fn coverage(img: &effectcraft_raster::Image, x0: i64, y0: i64, x1: i64, y1: i64) -> f32 { let mut s = 0.0; for y in y0..y1 { for x in x0..x1 { s += img.get(x, y)[3]; } } s } pub(crate) fn shape_names(doc: &Doc) -> Vec { fn walk(g: &effectcraft_svg::Group, out: &mut Vec) { for c in &g.children { match c { Node::Group(s) => walk(s, out), Node::Shape(s) => out.push(s.name.clone()), Node::Image(i) => out.push(i.name.clone()), } } } let mut out = vec![]; walk(&doc.root, &mut out); out } /// Type 2 charstring operands. fn t2(v: &[i32]) -> Vec { let mut o = vec![]; for &x in v { if (-107..=107).contains(&x) { o.push((x + 139) as u8); } else { o.push(28); o.extend_from_slice(&(x as i16).to_be_bytes()); } } o } /// A CFF font with a 500-unit square glyph `square` (x 50..550, y 0..500, width 600). fn square_cff() -> Vec { let mut sq = t2(&[600, 50, 0]); sq.push(21); sq.extend(t2(&[500, 500, -500])); sq.push(6); sq.push(14); crate::cff::write_test_cff(&[(".notdef", vec![14]), ("square", sq)]) } const SQUARE_FONT: &str = "<< /Type /Font /Subtype /Type1 /BaseFont /Square /FirstChar 65 /LastChar 65 /Widths [600] /Encoding << /Differences [65 /square] >> /FontDescriptor 11 0 R >>"; pub(crate) fn square_font_objs() -> Objs { vec![ (10, SQUARE_FONT.into(), None), (11, "<< /Type /FontDescriptor /FontName /Square /Flags 32 /FontFile3 12 0 R >>".into(), None), (12, "<< /Subtype /Type1C >>".into(), Some(square_cff())), ] } #[test] fn text_with_an_embedded_cff_font_spacing_and_tj() { // Squares 10 pt per 100 units: A at 0..60 (square 5..55), Tc 10 and a TJ kern of −400 // (+40) put the second at 110 (square 115..165). let bytes = page_pdf("BT /F1 100 Tf 0 0 Td 10 Tc [(A) -400 (A)] TJ ET", "/Font << /F1 10 0 R >>", square_font_objs()); let doc = parse(&bytes).unwrap(); assert!(!doc.skipped.iter().any(|s| s.contains("text")), "{:?}", doc.skipped); assert!(shape_names(&doc).iter().any(|n| n.starts_with("Text")), "{:?}", shape_names(&doc)); let img = render(&doc); for (x, on) in [(30, true), (60, false), (105, false), (140, true), (170, false)] { assert_eq!(img.get(x, 75)[3] > 0.99, on, "x = {x}: {:?}", img.get(x, 75)); } // Squares are 50 pt tall: rows 50..100. assert_eq!(img.get(30, 45)[3], 0.0); } #[test] fn text_render_mode_clip() { let bytes = page_pdf("BT /F1 100 Tf 7 Tr 0 0 Td (A) Tj ET 1 0 0 rg 0 0 200 100 re f", "/Font << /F1 10 0 R >>", square_font_objs()); let img = render(&parse(&bytes).unwrap()); assert!(img.get(30, 75)[0] > 0.99 && img.get(30, 75)[3] > 0.99); assert_eq!(img.get(100, 75)[3], 0.0, "clipped to the glyph"); assert_eq!(img.get(30, 25)[3], 0.0); } #[test] fn text_with_an_embedded_type1_font() { // hsbw 50 600; 0 0 rmoveto; 500 hlineto 500 vlineto -500 hlineto closepath endchar let cs = vec![50 + 139, 248, 236, 13, 139, 139, 21, 248, 136, 6, 248, 136, 7, 252, 136, 6, 9, 14]; let font = crate::type1::write_test_type1(&[(".notdef", vec![139, 139, 13, 14]), ("box", cs)], &[(66, "box")]); let extra: Objs = vec![ (10, "<< /Type /Font /Subtype /Type1 /BaseFont /Box /FirstChar 66 /LastChar 66 /Widths [600] /FontDescriptor 11 0 R >>".into(), None), (11, "<< /Type /FontDescriptor /FontName /Box /Flags 4 /FontFile 12 0 R >>".into(), None), (12, "<< /Length1 0 /Length2 0 /Length3 0 >>".into(), Some(font)), ]; // The font's built-in encoding maps B (66) to `box`. let img = render(&parse(&page_pdf("BT /F1 100 Tf 0 0 Td (BB) Tj ET", "/Font << /F1 10 0 R >>", extra)).unwrap()); for (x, on) in [(30, true), (60, false), (90, true), (120, false)] { assert_eq!(img.get(x, 75)[3] > 0.99, on, "x = {x}"); } } #[test] fn text_with_a_type3_font() { let extra: Objs = vec![ ( 10, "<< /Type /Font /Subtype /Type3 /FontBBox [0 0 100 100] /FontMatrix [0.01 0 0 0.01 0 0] /CharProcs << /sq 11 0 R >> /Encoding << /Type /Encoding /Differences [65 /sq] >> /FirstChar 65 /LastChar 65 /Widths [100] >>".into(), None, ), (11, "<< >>".into(), Some(b"100 0 d0 0 0 100 100 re f".to_vec())), ]; let img = render(&parse(&page_pdf("BT 0 0 1 rg /F1 20 Tf 10 10 Td (AA) Tj ET", "/Font << /F1 10 0 R >>", extra)).unwrap()); // 20 pt squares from x = 10, advancing 20: user 10..30 and 30..50, y 10..30 (rows 70..90). assert!(img.get(20, 80)[2] > 0.99 && img.get(20, 80)[3] > 0.99, "{:?}", img.get(20, 80)); assert!(img.get(40, 80)[3] > 0.99); assert_eq!(img.get(55, 80)[3], 0.0); assert_eq!(img.get(20, 60)[3], 0.0); } #[test] fn text_with_standard_fonts_embedded_truetype_and_cid_fonts() { use skrifa::MetadataProvider; let inter = effectcraft_text::fonts::INTER_REGULAR.to_vec(); // Not embedded: Helvetica drawn with the bundled sans serif. let std14 = page_pdf("BT /F1 60 Tf 10 30 Td (Hi) Tj ET", "/Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >>", vec![]); let doc = parse(&std14).unwrap(); assert!(shape_names(&doc).contains(&"Text: Hi".to_string()), "{:?}", shape_names(&doc)); assert!(doc.skipped.is_empty(), "{:?}", doc.skipped); let a = render(&doc); let ink = coverage(&a, 0, 0, 200, 100); assert!(ink > 300.0, "{ink}"); // Ink only on the text line: baseline at row 70, cap height ≈ 44 px above it. assert!(coverage(&a, 0, 72, 200, 100) < 1.0 && coverage(&a, 0, 0, 200, 20) < 1.0); // The same face embedded (a TrueType font program, WinAnsiEncoding): the same outlines. let tt = page_pdf( "BT /F1 60 Tf 10 30 Td (Hi) Tj ET", "/Font << /F1 10 0 R >>", vec![ (10, "<< /Type /Font /Subtype /TrueType /BaseFont /ABCDEF+Inter /Encoding /WinAnsiEncoding /FontDescriptor 11 0 R >>".into(), None), (11, "<< /Type /FontDescriptor /Flags 32 /FontFile2 12 0 R >>".into(), None), (12, "<< >>".into(), Some(inter.clone())), ], ); let b = render(&parse(&tt).unwrap()); assert!((coverage(&b, 0, 0, 200, 100) - ink).abs() < 1.0, "{} vs {ink}", coverage(&b, 0, 0, 200, 100)); // A composite font: Identity-H codes are glyph ids of a CIDFontType2. let font = skrifa::FontRef::new(&inter).unwrap(); let gid = |c: char| font.charmap().map(c).unwrap().to_u32() as u16; let (h, i) = (gid('H'), gid('i')); let loc = skrifa::instance::LocationRef::default(); let upem = font.metrics(skrifa::instance::Size::unscaled(), loc).units_per_em as f32; let w = |g: u16| font.glyph_metrics(skrifa::instance::Size::unscaled(), loc).advance_width(skrifa::GlyphId::new(g as u32)).unwrap() / upem * 1000.0; let cid = page_pdf( &format!("BT /F1 60 Tf 10 30 Td <{h:04X}{i:04X}> Tj ET"), "/Font << /F1 10 0 R >>", vec![ (10, "<< /Type /Font /Subtype /Type0 /BaseFont /Inter /Encoding /Identity-H /DescendantFonts [13 0 R] >>".into(), None), ( 13, format!( "<< /Type /Font /Subtype /CIDFontType2 /BaseFont /Inter /CIDToGIDMap /Identity /W [{h} [{}] {i} [{}]] /FontDescriptor 11 0 R >>", w(h), w(i) ), None, ), (11, "<< /Type /FontDescriptor /Flags 32 /FontFile2 12 0 R >>".into(), None), (12, "<< >>".into(), Some(inter)), ], ); let c = render(&parse(&cid).unwrap()); assert!((coverage(&c, 0, 0, 200, 100) - ink).abs() < 1.0, "{} vs {ink}", coverage(&c, 0, 0, 200, 100)); } #[test] fn images_flate_smask_indexed_stencil_inline_and_dct() { let objs = |im: &str, data: Vec, extra: Objs| -> Vec { let mut e: Objs = vec![(10, im.to_string(), Some(data))]; e.extend(extra); page_pdf("q 100 0 0 50 0 0 cm /Im1 Do Q", "/XObject << /Im1 10 0 R >>", e) }; // RGB with a soft mask: red opaque, green transparent. let doc = parse(&objs( "<< /Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8 /SMask 11 0 R >>", vec![255, 0, 0, 0, 255, 0], vec![(11, "<< /Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 >>".into(), Some(vec![255, 0]))], )) .unwrap(); assert!(doc.skipped.is_empty(), "{:?}", doc.skipped); let img = render(&doc); assert!(img.get(25, 75)[0] > 0.95 && img.get(25, 75)[3] > 0.95, "{:?}", img.get(25, 75)); assert!(img.get(75, 75)[3] < 0.05, "{:?}", img.get(75, 75)); assert_eq!(img.get(25, 25)[3], 0.0, "the image is the unit square of user space"); // Indexed (palette in a string), 8 bits. let img = render( &parse(&objs( "<< /Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace [/Indexed /DeviceRGB 1 <0000FFFFFF00>] /BitsPerComponent 8 >>", vec![1, 0], vec![], )) .unwrap(), ); let (l, r) = (img.get(25, 75), img.get(75, 75)); assert!(l[0] > 0.95 && l[1] > 0.95 && l[2] < 0.05 && r[2] > 0.95 && r[0] < 0.05, "{l:?} {r:?}"); // ICCBased with an /Alternate space, 1-bit samples. let img = render( &parse(&objs( "<< /Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace [/ICCBased 11 0 R] /BitsPerComponent 1 >>", vec![0b0100_0000], vec![(11, "<< /N 1 /Alternate /DeviceGray >>".into(), Some(vec![0; 16]))], )) .unwrap(), ); assert!(img.get(25, 75)[0] < 0.05 && img.get(75, 75)[0] > 0.95); // Stencil mask painted in the fill colour (sample 0 paints). let stencil = page_pdf( "0 1 0 rg q 100 0 0 50 0 0 cm /Im1 Do Q", "/XObject << /Im1 10 0 R >>", vec![(10, "<< /Type /XObject /Subtype /Image /Width 2 /Height 1 /ImageMask true >>".into(), Some(vec![0b0100_0000]))], ); let img = render(&parse(&stencil).unwrap()); assert!(img.get(25, 75)[1] > 0.95 && img.get(25, 75)[3] > 0.95); assert!(img.get(75, 75)[3] < 0.05); // Inline image (abbreviated keys, ASCIIHex) in the top half. let inline = page_pdf("q 100 0 0 50 0 50 cm BI /W 2 /H 1 /CS /RGB /BPC 8 /F /AHx ID 00FF00FF0000> EI Q 0 0 1 rg 150 0 50 50 re f", "", vec![]); let doc = parse(&inline).unwrap(); let img = render(&doc); assert!(img.get(25, 25)[1] > 0.95 && img.get(75, 25)[0] > 0.95, "{:?} {:?}", img.get(25, 25), img.get(75, 25)); assert!(img.get(175, 75)[2] > 0.95, "content after EI still runs"); // DCT (JPEG): left half red, right half blue. let mut jpg = vec![]; let mut rgb = ::image::RgbImage::new(16, 16); for (x, _, p) in rgb.enumerate_pixels_mut() { *p = if x < 8 { ::image::Rgb([255, 0, 0]) } else { ::image::Rgb([0, 0, 255]) }; } ::image::DynamicImage::ImageRgb8(rgb).write_to(&mut std::io::Cursor::new(&mut jpg), ::image::ImageFormat::Jpeg).unwrap(); let doc = parse(&objs("<< /Type /XObject /Subtype /Image /Width 16 /Height 16 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode >>", jpg, vec![])) .unwrap(); assert!(doc.skipped.is_empty(), "{:?}", doc.skipped); let img = render(&doc); let (l, r) = (img.get(20, 75), img.get(80, 75)); assert!(l[0] > 0.85 && l[2] < 0.15 && r[2] > 0.85 && r[0] < 0.15, "{l:?} {r:?}"); } #[test] fn soft_masks_and_blend_modes() { // Luminosity: a form that is white on the left half, transparent (→ black backdrop) elsewhere. let lum = page_pdf( "q /GS1 gs 1 0 0 rg 0 0 200 100 re f Q 0 0 1 rg 0 0 20 20 re f", "/ExtGState << /GS1 << /SMask << /S /Luminosity /G 10 0 R >> >> >>", vec![( 10, "<< /Type /XObject /Subtype /Form /BBox [0 0 200 100] /Group << /S /Transparency /CS /DeviceGray >> >>".into(), Some(b"1 g 0 0 100 100 re f".to_vec()), )], ); let doc = parse(&lum).unwrap(); assert!(doc.skipped.is_empty(), "{:?}", doc.skipped); let img = render(&doc); assert!(img.get(50, 50)[0] > 0.99 && img.get(50, 50)[3] > 0.99, "{:?}", img.get(50, 50)); assert!(img.get(150, 50)[3] < 0.01, "{:?}", img.get(150, 50)); assert!(img.get(10, 90)[2] > 0.99, "the mask ends with Q"); // Alpha: the mask form's coverage. let alpha = page_pdf( "/GS1 gs 1 0 0 rg 0 0 200 100 re f", "/ExtGState << /GS1 << /SMask << /S /Alpha /G 10 0 R >> >> >>", vec![(10, "<< /Type /XObject /Subtype /Form /BBox [0 0 200 100] >>".into(), Some(b"0 0 0 rg 100 0 100 100 re f".to_vec()))], ); let img = render(&parse(&alpha).unwrap()); assert!(img.get(50, 50)[3] < 0.01 && img.get(150, 50)[0] > 0.99); // Multiply over a light blue backdrop; Screen in the right half. let blend = page_pdf( "0.5 0.5 1 rg 0 0 200 100 re f q /M gs 1 1 0 rg 0 0 100 100 re f Q q /S gs 0.5 0 0 rg 100 0 100 100 re f Q", "/ExtGState << /M << /BM /Multiply >> /S << /BM [/Screen] >> >>", vec![], ); let img = render(&parse(&blend).unwrap()); let m = img.get(50, 50); assert!((m[0] - 0.5).abs() < 0.01 && (m[1] - 0.5).abs() < 0.01 && m[2] < 0.01, "multiply {m:?}"); let s = img.get(150, 50); assert!((s[0] - 0.75).abs() < 0.01 && (s[1] - 0.5).abs() < 0.01 && (s[2] - 1.0).abs() < 0.01, "screen {s:?}"); } #[test] fn tiling_patterns_coloured_and_uncoloured() { let cell = b"1 0 0 rg 0 0 10 10 re f 0 0 1 rg 10 10 10 10 re f".to_vec(); let bytes = page_pdf( "/Pattern cs /P1 scn 0 0 100 100 re f /Cs1 cs 0 1 0 /P2 scn 100 0 100 100 re f", "/Pattern << /P1 10 0 R /P2 11 0 R >> /ColorSpace << /Cs1 [/Pattern /DeviceRGB] >>", vec![ (10, "<< /Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 20 20] /XStep 20 /YStep 20 /Resources << >> >>".into(), Some(cell)), ( 11, "<< /Type /Pattern /PatternType 1 /PaintType 2 /TilingType 1 /BBox [0 0 20 20] /XStep 20 /YStep 20 >>".into(), Some(b"1 0 0 rg 0 0 10 10 re f".to_vec()), ), ], ); let doc = parse(&bytes).unwrap(); assert!(doc.skipped.is_empty(), "{:?}", doc.skipped); let img = render(&doc); // PDF (x, y) → pixel (x, 100 − y). let at = |x: i64, y: i64| img.get(x, 100 - y); assert!(at(5, 5)[0] > 0.99 && at(25, 5)[0] > 0.99 && at(85, 45)[0] > 0.99, "{:?}", at(5, 5)); assert!(at(15, 15)[2] > 0.99 && at(55, 35)[2] > 0.99); assert_eq!(at(15, 5)[3], 0.0); // Uncoloured: the cell in the colour given with the pattern (green), its own colour ignored. assert!(at(105, 5)[1] > 0.99 && at(105, 5)[0] < 0.01, "{:?}", at(105, 5)); assert_eq!(at(115, 15)[3], 0.0); } /// Object 1 of a file holding just `objs`, read as a function. fn read_function(objs: Objs) -> color::Func { color::Func::read(&object::File::parse(&pdf(&objs, 1)), &object::Obj::Ref(1, 0)) } /// A sampled (type 0) function, read from a file holding just its stream. fn read_sampled_function(dict: &str, data: Vec) -> color::Func { read_function(vec![(1, dict.into(), Some(data))]) } /// Object 1 of a file holding just `objs`, read as a colour space. fn read_color_space(objs: Objs) -> color::Cs { color::color_space(&object::File::parse(&pdf(&objs, 1)), &object::Obj::Ref(1, 0), None) } #[test] fn sampled_function_over_the_sample_cap_is_unsupported() { // 4 Mi entries, as many as `/Size` allows; every `/Range` pair adds an output, 4 Mi more samples. let dict = |outputs: usize| format!("<< /FunctionType 0 /Domain [0 1] /Size [4194304] /Range [{}] /BitsPerSample 1 >>", "0 1 ".repeat(outputs)); // Five outputs ask for 20 Mi samples, past the per-table cap (16 Mi); refused by the sample // count before any decode, so this is cheap and no lost cap reaches the large table below. assert!(matches!(read_sampled_function(&dict(5), vec![]), color::Func::Unsupported)); // 32 outputs of an empty stream ask for 128 Mi zeros; also past the cap. Through parse() the // refused tint transform takes the Separation fallback: tint 0.25 paints grey 0.75, not black. let bytes = page_pdf("/Tint cs 0.25 scn 0 0 200 100 re f", "/ColorSpace << /Tint [/Separation /Ink /DeviceRGB 10 0 R] >>", vec![(10, dict(32), Some(vec![]))]); let doc = parse(&bytes).unwrap(); assert!(doc.skipped.is_empty(), "{:?}", doc.skipped); assert_eq!(px(&doc, 50, 50), [0.75, 0.75, 0.75, 1.0]); } #[test] fn sampled_function_one_input_two_outputs() { let func = read_sampled_function("<< /FunctionType 0 /Domain [0 1] /Size [4] /Range [0 1 -1 1] /BitsPerSample 8 >>", vec![0, 255, 85, 170, 170, 85, 255, 0]); assert!(matches!(func, color::Func::Sampled { outputs: 2, .. })); // 0.5 → coordinate 1.5, halfway between samples 1 and 2; the second output decodes to [-1, 1]. assert_eq!(func.eval(&[0.5]), vec![0.5, 0.0]); // Inputs outside the domain take the end samples. assert_eq!(func.eval(&[-1.0]), vec![0.0, 1.0]); assert_eq!(func.eval(&[2.0]), vec![1.0, -1.0]); } #[test] fn sampled_function_two_inputs() { let func = read_sampled_function( "<< /FunctionType 0 /Domain [0 1 0 1] /Size [3 3] /Range [0 1 0 1] /BitsPerSample 8 >>", vec![0, 0, 85, 0, 170, 0, 0, 85, 85, 85, 170, 85, 0, 170, 85, 170, 170, 170], ); assert!(matches!(func, color::Func::SampledN { outputs: 2, .. })); // Entry (i, j), the first input varying fastest, holds (i / 3, j / 3); (0.25, 0.75) → (0.5, 1.5). assert_eq!(func.eval(&[0.25, 0.75]), vec![1.0 / 6.0, 0.5]); assert_eq!(func.eval(&[0.0, 0.0]), vec![0.0, 0.0]); assert_eq!(func.eval(&[1.0, 1.0]), vec![2.0 / 3.0, 2.0 / 3.0]); } #[test] fn sampled_functions_have_at_most_32_inputs_and_outputs() { // Each evaluation allocates and loops over every output, and shadings evaluate millions of // times; no colour space has more than 32 components. let outputs = |n: usize| format!("<< /FunctionType 0 /Domain [0 1] /Size [2] /Range [{}] /BitsPerSample 8 >>", "0 1 ".repeat(n)); assert!(matches!(read_sampled_function(&outputs(32), vec![255; 64]), color::Func::Sampled { outputs: 32, .. })); assert_eq!(read_sampled_function(&outputs(33), vec![255; 66]), color::Func::Unsupported); let inputs = |m: usize| format!("<< /FunctionType 0 /Domain [{}] /Size [{}] /Range [0 1] /BitsPerSample 8 >>", "0 1 ".repeat(m), "1 ".repeat(m)); let f = read_sampled_function(&inputs(32), vec![255]); assert!(matches!(f, color::Func::SampledN { .. }), "{f:?}"); assert_eq!(f.eval(&[0.5; 32]), vec![1.0]); assert_eq!(read_sampled_function(&inputs(33), vec![255]), color::Func::Unsupported); } /// Object 10: a sampled function with one input, one output and samples 0 and 1. fn ramp() -> (u32, String, Option>) { (10, "<< /FunctionType 0 /Domain [0 1] /Size [2] /Range [0 1] /BitsPerSample 8 >>".into(), Some(vec![0, 255])) } /// A stitching function of `n` equal pieces, each function object 10. fn stitching_of_10(n: usize) -> String { let bounds: Vec = (1..n).map(|i| (i as f64 / n as f64).to_string()).collect(); format!("<< /FunctionType 3 /Domain [0 1] /Functions [{}] /Bounds [{}] /Encode [{}] >>", "10 0 R ".repeat(n), bounds.join(" "), "0 1 ".repeat(n)) } #[test] fn function_arrays_hold_at_most_32_functions() { // An array of one-output functions gives an output per function: 32 at most, as for a single // function. (A stitching function's pieces are NOT capped this way; see the 100-piece test.) let array = |n: usize| format!("[{}]", "10 0 R ".repeat(n)); assert!(matches!(read_function(vec![(1, array(32), None), ramp()]), color::Func::Array(fs) if fs.len() == 32)); assert_eq!(read_function(vec![(1, array(33), None), ramp()]), color::Func::Unsupported); } #[test] fn stitching_function_with_100_pieces_builds_and_evaluates() { // A repeating gradient (Cairo, Inkscape) lists one sub-function once per repeat, often far // more than 32 times; such a stitching function is valid and must build. Here object 10 (a // 0 → 1 ramp) is listed 100 times, read once and shared. let f = read_function(vec![(1, stitching_of_10(100), None), ramp()]); let color::Func::Stitch { funcs, .. } = &f else { panic!("{f:?}") }; assert_eq!(funcs.len(), 100); let table = |g: &color::Func| match g { color::Func::Sampled { samples, .. } => Some(samples.as_ptr()), _ => None, }; assert!(table(&funcs[0]).is_some()); assert!(funcs.iter().all(|g| g == &funcs[0] && table(g) == table(&funcs[0]))); // The sawtooth's ends: piece 0 at t = 0 and piece 99 at t = 1 both map through the ramp. assert_eq!(f.eval1(0.0), vec![0.0]); assert_eq!(f.eval1(1.0), vec![1.0]); } #[test] fn a_function_repeated_in_an_array_is_read_once() { // Read once per reference, 32 references to one 32 MiB table made 1 GiB. Now the object is // read once: the copies are equal and share its table. let table = |f: &color::Func| match f { color::Func::Sampled { samples, .. } => samples.as_ptr(), _ => std::ptr::null(), }; let array = read_function(vec![(1, format!("[{}]", "10 0 R ".repeat(32)), None), ramp()]); let stitch = read_function(vec![(1, stitching_of_10(32), None), ramp()]); for f in [&array, &stitch] { let (color::Func::Array(fs) | color::Func::Stitch { funcs: fs, .. }) = f else { panic!("{f:?}") }; assert_eq!(fs.len(), 32); assert!(!table(&fs[0]).is_null()); assert!(fs.iter().all(|g| g == &fs[0] && table(g) == table(&fs[0]))); } assert_eq!(array.eval1(0.5), vec![0.5; 32]); } #[test] fn saved_graphics_states_share_a_sampled_tint() { // `q` saves a copy of the graphics state, colour spaces included: the copies share the tint // transform's table. let tint = "<< /FunctionType 0 /Domain [0 1] /Size [4096] /Range [0 1 0 1 0 1] /BitsPerSample 8 >>"; let data: Vec = (0..4096u32).flat_map(|i| [(i >> 4) as u8, 0, 255 - (i >> 4) as u8]).collect(); let cs = read_color_space(vec![(1, "[/Separation /Ink /DeviceRGB 10 0 R]".into(), None), (10, tint.into(), Some(data.clone()))]); let table = |cs: &color::Cs| match cs { color::Cs::Tint { func: color::Func::Sampled { samples, .. }, .. } => samples.as_ptr(), _ => std::ptr::null(), }; assert!(!table(&cs).is_null()); assert_eq!(table(&cs.clone()), table(&cs)); // A thousand nested `q` around a fill in that space: tint 1 is the last entry, red. let content = format!("/Tint cs 1 scn {}0 0 200 100 re f {}", "q ".repeat(1000), "Q ".repeat(1000)); let doc = parse(&page_pdf(&content, "/ColorSpace << /Tint [/Separation /Ink /DeviceRGB 10 0 R] >>", vec![(10, tint.into(), Some(data))])).unwrap(); assert!(doc.skipped.is_empty(), "{:?}", doc.skipped); assert_eq!(px(&doc, 100, 50), [1.0, 0.0, 0.0, 1.0]); } /// A one-page PDF drawing image object 10 over the whole 200 × 100 page; `extra` holds more objects. fn image_page(dict: &str, data: Vec, extra: Objs) -> Vec { let mut objs: Objs = vec![(10, dict.into(), Some(data))]; objs.extend(extra); page_pdf("q 200 0 0 100 0 0 cm /Im1 Do Q", "/XObject << /Im1 10 0 R >>", objs) } /// Whether two colours match within rendering error. fn near(a: [f32; 4], b: [f32; 4]) -> bool { a.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.02) } #[test] fn indexed_space_reads_at_most_256_entries() { // hival 10^12 asked the image palette for 10^12 colours (24 TB); an indexed palette holds at // most 256 entries, so hival is 0 to 255. let im = "<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace [/Indexed /DeviceRGB 1000000000000 <00>] /BitsPerComponent 8 >>"; let doc = parse(&image_page(im, vec![200], vec![])).unwrap(); assert!(doc.skipped.is_empty(), "{:?}", doc.skipped); // Entry 200 is past the one-byte lookup string: black. assert!(near(px(&doc, 100, 50), [0.0, 0.0, 0.0, 1.0]), "{:?}", px(&doc, 100, 50)); let cs = read_color_space(vec![(1, "[/Indexed /DeviceRGB 1000000000000 <00>]".into(), None)]); assert!(matches!(cs, color::Cs::Indexed { hival: 255, .. }), "{cs:?}"); } #[test] fn indexed_images_with_4_and_256_entries() { let im = |cs: &str, w: usize| format!("<< /Type /XObject /Subtype /Image /Width {w} /Height 1 /ColorSpace {cs} /BitsPerComponent 8 >>"); let doc = parse(&image_page(&im("[/Indexed /DeviceRGB 3 ]", 4), vec![0, 1, 2, 3], vec![])).unwrap(); for (x, c) in [(25, [1.0, 0.0, 0.0, 1.0]), (75, [0.0, 1.0, 0.0, 1.0]), (125, [0.0, 0.0, 1.0, 1.0]), (175, [1.0; 4])] { assert!(near(px(&doc, x, 50), c), "x = {x}: {:?}", px(&doc, x, 50)); } // 256 entries, the lookup table in a stream: entry i is (i, 0, 255 − i). let lookup: Vec = (0..=255u8).flat_map(|i| [i, 0, 255 - i]).collect(); let doc = parse(&image_page(&im("[/Indexed /DeviceRGB 255 11 0 R]", 2), vec![255, 0], vec![(11, "<< >>".into(), Some(lookup))])).unwrap(); assert!(near(px(&doc, 50, 50), [1.0, 0.0, 0.0, 1.0]), "{:?}", px(&doc, 50, 50)); assert!(near(px(&doc, 150, 50), [0.0, 0.0, 1.0, 1.0]), "{:?}", px(&doc, 150, 50)); } /// Functions nested down the first element of arrays and stitching functions, outermost first. fn nested_functions(f: &color::Func) -> Vec<&color::Func> { let mut out = vec![f]; let mut f = f; while let color::Func::Array(fs) | color::Func::Stitch { funcs: fs, .. } = f && let Some(first) = fs.first() { out.push(first); f = first; } out } /// Colour spaces nested down Indexed bases and Separation or DeviceN alternates, outermost first. fn nested_spaces(cs: &color::Cs) -> Vec<&color::Cs> { let mut out = vec![cs]; let mut cs = cs; while let color::Cs::Indexed { base: next, .. } | color::Cs::Tint { alt: next, .. } = cs { cs = next; out.push(cs); } out } #[test] fn function_array_containing_itself_stops_at_depth_8() { // `[1 0 R]` as object 1: the reader recursed until the stack overflowed (an abort). let f = read_function(vec![(1, "[1 0 R]".into(), None)]); let chain = nested_functions(&f); assert_eq!(chain.len(), 9); assert!(chain[..8].iter().all(|f| matches!(f, color::Func::Array(_)))); assert_eq!(chain[8], &color::Func::Unsupported); // As a tint transform: the innermost function's 0.5, in DeviceGray. let tint = "/ColorSpace << /Tint [/Separation /Ink /DeviceGray 10 0 R] >>"; let doc = parse(&page_pdf("/Tint cs 1 scn 0 0 200 100 re f", tint, vec![(10, "[10 0 R]".into(), None)])).unwrap(); assert_eq!(px(&doc, 100, 50), [0.5, 0.5, 0.5, 1.0]); } #[test] fn stitching_function_containing_itself_stops_at_depth_8() { let stitch = |me: u32| format!("<< /FunctionType 3 /Domain [0 1] /Functions [{me} 0 R] /Bounds [] /Encode [0 1] >>"); let f = read_function(vec![(1, stitch(1), None)]); let chain = nested_functions(&f); assert_eq!(chain.len(), 9); assert!(chain[..8].iter().all(|f| matches!(f, color::Func::Stitch { .. }))); assert_eq!(chain[8], &color::Func::Unsupported); let tint = "/ColorSpace << /Tint [/Separation /Ink /DeviceGray 10 0 R] >>"; let doc = parse(&page_pdf("/Tint cs 1 scn 0 0 200 100 re f", tint, vec![(10, stitch(10), None)])).unwrap(); assert_eq!(px(&doc, 100, 50), [0.5, 0.5, 0.5, 1.0]); } #[test] fn colour_spaces_based_on_themselves_stop_at_depth_8() { // Object 1 as its own Indexed base, Separation alternate or ICCBased alternate: the reader // recursed until the stack overflowed (an abort). let indexed = read_color_space(vec![(1, "[/Indexed 1 0 R 1 <00>]".into(), None)]); let separation = read_color_space(vec![(1, "[/Separation /Ink 1 0 R << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >>]".into(), None)]); for cs in [&indexed, &separation] { let chain = nested_spaces(cs); assert_eq!(chain.len(), 9); assert!(chain[..8].iter().all(|c| matches!(c, color::Cs::Indexed { .. } | color::Cs::Tint { .. }))); assert_eq!(chain[8], &color::Cs::Gray); } // An ICCBased space reads as its alternate: DeviceGray once the depth runs out. let icc = read_color_space(vec![(1, "[/ICCBased 2 0 R]".into(), None), (2, "<< /N 3 /Alternate 1 0 R >>".into(), Some(vec![0; 16]))]); assert_eq!(icc, color::Cs::Gray); // A fill in the Indexed one paints black. let doc = parse(&page_pdf("/CS0 cs 0 scn 0 0 200 100 re f", "/ColorSpace << /CS0 10 0 R >>", vec![(10, "[/Indexed 10 0 R 1 <00>]".into(), None)])).unwrap(); assert_eq!(px(&doc, 100, 50), [0.0, 0.0, 0.0, 1.0]); } #[test] fn nesting_deeper_than_8_is_refused() { // Chains of 100 distinct objects, each nesting the next (real files nest two or three // levels): read down to depth 7, refused at depth 8. let functions = |n: u32| -> Objs { let stitch = |next: u32| format!("<< /FunctionType 3 /Domain [0 1] /Functions [{next} 0 R] /Bounds [] /Encode [0 1] >>"); (1..n).map(|i| (i, stitch(i + 1), None)).chain([(n, "<< /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >>".into(), None)]).collect() }; let f = read_function(functions(100)); let chain = nested_functions(&f); assert_eq!(chain.len(), 9); assert!(chain[..8].iter().all(|f| matches!(f, color::Func::Stitch { .. }))); assert_eq!(chain[8], &color::Func::Unsupported); // Three levels are read to the end. let f = read_function(functions(3)); assert_eq!(nested_functions(&f).len(), 3); assert_eq!(f.eval1(0.25), vec![0.25]); // Indexed bases. let spaces: Objs = (1..100).map(|i| (i, format!("[/Indexed {} 0 R 0 <00>]", i + 1), None)).chain([(100, "[/Indexed /DeviceRGB 0 ]".into(), None)]).collect(); let cs = read_color_space(spaces); let chain = nested_spaces(&cs); assert_eq!(chain.len(), 9); assert!(chain[..8].iter().all(|c| matches!(c, color::Cs::Indexed { .. }))); assert_eq!(chain[8], &color::Cs::Gray); } #[test] fn a_fanning_out_reference_graph_is_refused_within_the_budget() { // Eight objects, each an array of 32 references to the next, with a function at the bottom: // depth 8 alone would still expand 32^7 copies. Each object is read once per array but its // nodes are charged per copy, so the per-read node budget is spent after a few levels and the // whole function is refused — fast and with bounded memory, not 32^7 nodes. let objs: Objs = (1..=7u32) .map(|i| (i, format!("[{}]", format!("{} 0 R ", i + 1).repeat(32)), None)) .chain([(8, "<< /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >>".into(), None)]) .collect(); assert_eq!(read_function(objs), color::Func::Unsupported); } #[test] fn colour_space_chain_of_depth_3() { // Indexed → Separation → ICCBased → DeviceRGB: entry 0 is tint 1 (blue), entry 1 tint 0 (white). let doc = parse(&image_page( "<< /Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace [/Indexed 11 0 R 1 ] /BitsPerComponent 8 >>", vec![0, 1], vec![ (11, "[/Separation /Ink 12 0 R 13 0 R]".into(), None), (12, "[/ICCBased 14 0 R]".into(), None), (13, "<< /FunctionType 2 /Domain [0 1] /C0 [1 1 1] /C1 [0 0 1] /N 1 >>".into(), None), (14, "<< /N 3 /Alternate /DeviceRGB >>".into(), Some(vec![0; 16])), ], )) .unwrap(); assert!(doc.skipped.is_empty(), "{:?}", doc.skipped); assert!(near(px(&doc, 50, 50), [0.0, 0.0, 1.0, 1.0]), "{:?}", px(&doc, 50, 50)); assert!(near(px(&doc, 150, 50), [1.0; 4]), "{:?}", px(&doc, 150, 50)); } #[test] fn stitching_function_with_a_reversed_domain() { // `f64::clamp` panics on bounds in the wrong order, as `/Domain [1 0]` gives them. let exp = "<< /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >>"; let f = read_function(vec![(1, format!("<< /FunctionType 3 /Domain [1 0] /Functions [{exp}] /Bounds [] /Encode [0 1] >>"), None)]); assert_eq!(f.eval(&[0.25]), vec![0.0]); } #[test] fn sampled_function_with_a_reversed_domain() { let f = read_sampled_function("<< /FunctionType 0 /Domain [1 0] /Size [2] /Range [0 1] /BitsPerSample 8 >>", vec![0, 255]); assert_eq!(f.eval(&[0.25]), vec![0.0]); } #[test] fn calculator_function_with_a_reversed_range() { let f = read_function(vec![(1, "<< /FunctionType 4 /Domain [0 1] /Range [1 0] >>".into(), Some(b"{ 0.5 add }".to_vec()))]); assert_eq!(f.eval(&[0.25]), vec![0.75]); } #[test] fn multiple_pages_and_calculator_shadings() { let objs: Objs = vec![ (1, "<< /Type /Catalog /Pages 2 0 R >>".into(), None), (2, "<< /Type /Pages /Kids [3 0 R 5 0 R] /Count 2 >>".into(), None), (3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Contents 4 0 R >>".into(), None), (4, "<< >>".into(), Some(b"1 0 0 rg 0 0 200 100 re f".to_vec())), (5, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 50] /Resources << /Shading << /Sh 7 0 R >> >> /Contents 6 0 R >>".into(), None), (6, "<< >>".into(), Some(b"/Sh sh".to_vec())), (7, "<< /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 100 0] /Function 8 0 R >>".into(), None), (8, "<< /FunctionType 4 /Domain [0 1] /Range [0 1 0 1 0 1] >>".into(), Some(b"{ 0 0 }".to_vec())), ]; let bytes = pdf(&objs, 1); assert_eq!(page_count(&bytes), 2); let p1 = parse_page(&bytes, 0).unwrap(); assert_eq!((p1.width, p1.height), (200.0, 100.0)); let p2 = parse_page(&bytes, 1).unwrap(); assert_eq!((p2.width, p2.height), (100.0, 50.0)); assert!(p2.skipped.is_empty(), "{:?}", p2.skipped); let img = render(&p2); assert!(img.get(5, 25)[0] < 0.1 && img.get(95, 25)[0] > 0.9, "{:?} {:?}", img.get(5, 25), img.get(95, 25)); assert_eq!(parse_page(&bytes, 2), Err(Error::PageOutOfRange(2))); }