diff --git a/Cargo.lock b/Cargo.lock index 7d06a33c..1303758d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1304,6 +1304,12 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" +[[package]] +name = "fearless_simd" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3296eb97d869113eaf148a6bdf01884e90b3828ad7ae2340b0f93362fc158611" + [[package]] name = "find-msvc-tools" version = "0.1.14" @@ -1760,9 +1766,9 @@ dependencies = [ [[package]] name = "hayro-ccitt" -version = "0.3.0" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a" +checksum = "2866f0d976e76497480b853d5e258dab6e4b89ecfe991124e08f15dfa8fd03ad" [[package]] name = "hayro-cmap" @@ -1776,9 +1782,9 @@ dependencies = [ [[package]] name = "hayro-interpret" -version = "0.7.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2613d0406898995042d0794c4245b2f2fba1246490c8ac593769bba6551129d" +checksum = "ac9df074ff163a63864cec9b0bf640ccbff8abf4e44481df7369ce0ac172ffbe" dependencies = [ "bitflags 2.13.2", "hayro-cmap", @@ -1788,28 +1794,28 @@ dependencies = [ "phf", "rustc-hash 2.1.3", "siphasher", - "skrifa 0.42.1", + "skrifa 0.46.2", "smallvec", "yoke", ] [[package]] name = "hayro-jbig2" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69374b3668dd45aeb3d3145cda68f2c7b4f223aaa2511e67d076f1c7d741388d" +checksum = "000e025850f4592ddcaee85640db70704151f2bafd43b6f431aa5b6a2c6a35a9" dependencies = [ - "fearless_simd", + "fearless_simd 1.1.0", "hayro-ccitt", ] [[package]] name = "hayro-jpeg2000" -version = "0.3.5" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c75ab947623ef4ccaa7acf0579edf7cbb5a73838e3839a7be73335e522f433a1" +checksum = "122c5f834c37a2260b9c851bb8be685cf539b2a8b94b704a931ef7d2525d5835" dependencies = [ - "fearless_simd", + "fearless_simd 1.1.0", ] [[package]] @@ -1820,9 +1826,9 @@ checksum = "885c5ef0654933139a9b9546fc2c69e18d37f38aa2520f079092b1be18f1fcaa" [[package]] name = "hayro-syntax" -version = "0.7.2" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0edeafd70aa2db743de8ede8637d07ec87db05efe69cde371d03f1b185fcef27" +checksum = "6cdc818d8d265dcb42081d488f7599df4f73fb79e9d347ecedcd3c23809d2026" dependencies = [ "flate2", "hayro-ccitt", @@ -3833,6 +3839,16 @@ dependencies = [ "read-fonts 0.41.0", ] +[[package]] +name = "skrifa" +version = "0.46.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4febebda507fb889c545a37a135517769d1391941013561292897961d1887d6" +dependencies = [ + "bytemuck", + "read-fonts 0.43.3", +] + [[package]] name = "skrifa" version = "0.47.0" @@ -4986,7 +5002,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2" dependencies = [ "bytemuck", - "fearless_simd", + "fearless_simd 0.4.1", "guillotiere", "log", "peniko", @@ -5001,7 +5017,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbb2141a2bca6e6d598e471fd4d1d7eed8e020aad6a28187edda07f091a325dd" dependencies = [ "bytemuck", - "fearless_simd", + "fearless_simd 0.4.1", "guillotiere", "log", "peniko", diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 84a9cc85..bd79a7b2 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -538,17 +538,29 @@ proptest! { // ---------- PDF ---------- fn handmade_pdf(content: &str, resources: &str, media: &str) -> Vec { - let objs = [ + handmade_pdf_with(content, resources, media, &[]) +} + +/// The same, followed by the objects `more` (numbered from 5). +fn handmade_pdf_with(content: &str, resources: &str, media: &str, more: &[Vec]) -> Vec { + let page = [ "<< /Type /Catalog /Pages 2 0 R >>".to_string(), "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), format!("<< /Type /Page /Parent 2 0 R /MediaBox {media} /Contents 4 0 R /Resources {resources} >>"), format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len() + 1), ]; + pdf_of(&page.into_iter().map(String::into_bytes).chain(more.iter().cloned()).collect::>()) +} + +/// The PDF of the objects `objs` (numbered from 1, the catalog first). +fn pdf_of(objs: &[Vec]) -> Vec { let mut out = b"%PDF-1.7\n".to_vec(); let mut offsets = vec![]; for (i, o) in objs.iter().enumerate() { offsets.push(out.len()); - out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + out.extend_from_slice(o); + out.extend_from_slice(b"\nendobj\n"); } let xref = out.len(); out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); @@ -2157,10 +2169,15 @@ fn arb_ai_token() -> impl Strategy { /// The testkit's sample editing data, compressed as older `.ai` files compress it. fn ai_sample_compressed() -> Vec { + [&b"%AI12_CompressedData"[..], &deflated(&vectorcraft_testkit::ai::sample_data())].concat() +} + +/// `data` compressed (zlib). +fn deflated(data: &[u8]) -> Vec { use std::io::Write as _; let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); - e.write_all(&vectorcraft_testkit::ai::sample_data()).unwrap(); - [&b"%AI12_CompressedData"[..], &e.finish().unwrap()].concat() + e.write_all(data).unwrap(); + e.finish().unwrap() } proptest! { @@ -2533,6 +2550,281 @@ fn deep_pdf(content: &str, resources: &str) -> Vec { handmade_pdf(content, resources, "[0 0 100 100]") } +/// A stream object with the entries `dict`, its `data` compressed. +fn packed(dict: &str, data: &str) -> Vec { + let z = deflated(data.as_bytes()); + // (A length the test gives stands.) + let length = if dict.contains("/Length") { String::new() } else { format!(" /Length {}", z.len()) }; + [format!("<< {dict} /Filter /FlateDecode{length} >>\nstream\n").as_bytes(), &z, b"\nendstream"].concat() +} + +/// A page whose content is `content`, with `resources` and the objects `more` (numbered from 5). +fn pdf_with(content: &str, resources: &str, more: &[Vec]) -> Vec { + handmade_pdf_with(content, resources, "[0 0 100 100]", more) +} + +/// Arrays in arrays, and dictionaries in dictionaries, far deeper than any reader goes: as the +/// operands of content. +fn nested_operands() -> [String; 2] { + let (n, d) = (100_000, 50_000); + [format!("BT {}{} TJ ET 0 0 5 5 re f", "[".repeat(n), "]".repeat(n)), format!("/P {}{} DP 0 0 5 5 re f", "<< /A ".repeat(d), ">> ".repeat(d))] +} + +/// A page drawing through the compressed stream that `container` makes of each of +/// [`nested_operands`] (object 5), one file each. +fn nested_in(content: &str, resources: &str, container: impl Fn(&str) -> Vec>) -> Vec> { + nested_operands().iter().map(|deep| pdf_with(content, resources, &container(deep))).collect() +} + +const FORM: &str = "/Type /XObject /Subtype /Form /BBox [0 0 100 100]"; + +/// A Type 3 font (object 5) whose glyph `a` is object 6. +fn type3_font(resources: &str) -> Vec { + format!( + "<< /Type /Font /Subtype /Type3 /FontBBox [0 0 10 10] /FontMatrix [0.1 0 0 0.1 0 0] /CharProcs << /a 6 0 R >> \ + /Encoding << /Type /Encoding /Differences [97 /a] >> /FirstChar 97 /LastChar 97 /Widths [10] /Resources {resources} >>" + ) + .into_bytes() +} + +/// A page whose box is object 6, kept with object 7 (a number) in a compressed object stream +/// (object 5, with the entries `stream`): a file with a cross-reference stream, as it must be. +fn pdf_with_object_stream(boxed: &str, stream: &str) -> Vec { + let content = "0 0 5 5 re f"; + let inside = format!("{boxed} 77"); + let index = format!("6 0 7 {} ", boxed.len() + 1); + let objs = [ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox 6 0 R /Contents 4 0 R /Resources << >> >>".to_vec(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len() + 1).into_bytes(), + packed(&format!("/Type /ObjStm /N 2 /First {} {stream}", index.len()), &format!("{index}{inside}")), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + // One entry an object: its kind, then where it is (an offset, or the stream it is in and its + // place there). + let mut entries = vec![[0u8, 0, 0, 0, 0, 255, 255]]; + let at = |kind: u8, a: usize, b: u16| { + let (a, b) = ((a as u32).to_be_bytes(), b.to_be_bytes()); + [kind, a[0], a[1], a[2], a[3], b[0], b[1]] + }; + for (i, o) in objs.iter().enumerate() { + entries.push(at(1, out.len(), 0)); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + out.extend_from_slice(o); + out.extend_from_slice(b"\nendobj\n"); + } + entries.extend([at(2, 5, 0), at(2, 5, 1), at(1, out.len(), 0)]); + let xref = out.len(); + out.extend_from_slice(format!("8 0 obj\n<< /Type /XRef /Size 9 /Root 1 0 R /W [1 4 2] /Length {} >>\nstream\n", entries.len() * 7).as_bytes()); + out.extend_from_slice(&entries.concat()); + out.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{xref}\n%%EOF\n").as_bytes()); + out +} + +/// `n` objects from object 5 on, each made by `link` of the number of the next, then `last`. +fn chain(n: usize, link: impl Fn(usize) -> String, last: &str) -> Vec> { + (0..n).map(|i| link(i + 6).into_bytes()).chain([last.as_bytes().to_vec()]).collect() +} + +/// A page painting with the shading whose function is object 5. +fn pdf_with_function(more: &[Vec]) -> Vec { + let shading = "/Shading << /S << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 100 0] /Function 5 0 R >> >>"; + pdf_with("/S sh", &format!("<< {shading} >>"), more) +} + +/// A page filling in the colour space that is object 5. +fn pdf_with_color_space(more: &[Vec]) -> Vec { + pdf_with("/C cs 0 sc 0 0 5 5 re f /C CS 0 SC 0 0 5 5 re S", "<< /ColorSpace << /C 5 0 R >> >>", more) +} + +/// Every hostile PDF here opens as an error or as a document within the cap, on a small stack. +fn open_each(name: &str, files: impl IntoIterator> + Send + 'static) { + let name = name.to_string(); + on_a_small_stack(move || { + for (i, bytes) in files.into_iter().enumerate() { + open_deep(&format!("{name}-{i}.pdf"), &bytes); + // As `.ai` (the PDF part of one) and placed, too. + open_deep(&format!("{name}-{i}.ai"), &bytes); + let mut s = vectorcraft_engine::Session::new(); + s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap(); + let _ = no_panic(s.execute("file.place", &json!({"name": "deep.pdf", "dataBase64": vectorcraft_format::base64_encode(&bytes)}))); + assert!(!s.doc().unwrap().doc.too_deep(None)); + } + }); +} + +// Nesting where only the reader of the decoded data sees it: inside each kind of compressed +// stream a page draws through. + +#[test] +fn pdf_nesting_inside_a_compressed_form() { + open_each("form", nested_in("/F Do", "<< /XObject << /F 5 0 R >> >>", |deep| vec![packed(FORM, deep)])); +} + +#[test] +fn pdf_nesting_inside_a_compressed_pattern() { + let tile = "/PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Resources << >>"; + open_each("pattern", nested_in("/Pattern cs /P scn 0 0 50 50 re f", "<< /Pattern << /P 5 0 R >> >>", |deep| vec![packed(tile, deep)])); +} + +#[test] +fn pdf_nesting_inside_a_compressed_glyph() { + let glyph = |deep: &str| vec![type3_font("<< >>"), packed("", &format!("10 0 d0 {deep}"))]; + open_each("glyph", nested_in("BT /F1 12 Tf 10 10 Td (aa) Tj ET", "<< /Font << /F1 5 0 R >> >>", glyph)); +} + +#[test] +fn pdf_nesting_inside_a_compressed_soft_mask() { + let group = format!("{FORM} /Group << /S /Transparency /CS /DeviceGray >>"); + let state = "<< /ExtGState << /G << /Type /ExtGState /SMask << /S /Luminosity /G 5 0 R >> >> >> >>"; + open_each("mask", nested_in("/G gs 0 0 50 50 re f", state, move |deep| vec![packed(&group, deep)])); +} + +#[test] +fn pdf_nesting_inside_a_compressed_annotation() { + let annots = "<< >> /Annots [<< /Type /Annot /Subtype /Square /Rect [0 0 50 50] /AP << /N 5 0 R >> >>]"; + open_each("annotation", nested_in("0 0 5 5 re f", annots, |deep| vec![packed(FORM, deep)])); +} + +#[test] +fn pdf_nesting_inside_compressed_page_content() { + let files = nested_operands().map(|deep| { + let mut objs: Vec> = ["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 >>"].map(Vec::from).into(); + objs.push(b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents [4 0 R 4 0 R] /Resources << >> >>".to_vec()); + objs.push(packed("", &deep)); + pdf_of(&objs) + }); + open_each("content", files); +} + +#[test] +fn pdf_nesting_inside_an_object_stream() { + let (n, d) = (100_000, 50_000); + open_each( + "objects", + [ + // A plain one first: the file is read as it should be. + pdf_with_object_stream("[0 0 100 100]", ""), + pdf_with_object_stream(&format!("{}0 0 100 100{}", "[".repeat(n), "]".repeat(n)), ""), + pdf_with_object_stream(&format!("{}{}", "<< /A ".repeat(d), ">> ".repeat(d)), ""), + // Its length is an object kept in the stream itself. + pdf_with_object_stream("[0 0 100 100]", "/Length 7 0 R"), + // It extends itself. + pdf_with_object_stream("[0 0 100 100]", "/Extends 5 0 R"), + ], + ); + let plain = vectorcraft_engine::cmd::fileio::load("plain.pdf", &pdf_with_object_stream("[0 0 120 80]", "")).expect("an object stream is read"); + assert_eq!(plain.doc.artboards[0].rect.width(), 120.0); +} + +// What refers to itself, or to the next of its kind without end. + +#[test] +fn pdf_page_trees_that_hold_themselves() { + let page = |parent: usize| format!("<< /Type /Page /Parent {parent} 0 R /MediaBox [0 0 100 100] /Contents 4 0 R /Resources << >> >>"); + let content = "<< /Length 13 >>\nstream\n0 0 5 5 re f\nendstream"; + let own = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [2 0 R 3 0 R 2 0 R] /Count 1 >>".to_string(), + page(2), + content.to_string(), + ]; + // Thousands of nodes, each holding the next; the page is the last. + let n = 30_000; + let mut deep = vec!["<< /Type /Catalog /Pages 5 0 R >>".to_string(), "null".to_string(), "null".to_string(), content.to_string()]; + deep.extend((0..n).map(|i| format!("<< /Type /Pages /Kids [{} 0 R] /Count 1 >>", i + 6))); + deep.push(page(n + 4)); + // Two nodes holding each other. + let pair = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [5 0 R] /Count 1 >>".to_string(), + page(5), + content.to_string(), + "<< /Type /Pages /Kids [2 0 R 3 0 R] /Count 1 >>".to_string(), + ]; + let bytes = |objs: &[String]| pdf_of(&objs.iter().map(|o| o.clone().into_bytes()).collect::>()); + open_each("pages", [bytes(&own), bytes(&deep), bytes(&pair)]); +} + +#[test] +fn pdf_functions_that_hold_themselves() { + let stitch = |next: usize| format!("<< /FunctionType 3 /Domain [0 1] /Functions [{next} 0 R] /Bounds [] /Encode [0 1] >>"); + let ramp = "<< /FunctionType 2 /Domain [0 1] /C0 [1 0 0] /C1 [0 0 1] /N 1 >>"; + let calculator = "/FunctionType 4 /Domain [0 1] /Range [0 1 0 1 0 1]"; + let n = 100_000; + open_each( + "functions", + [ + pdf_with_function(&[stitch(5).into_bytes()]), + pdf_with_function(&[stitch(6).into_bytes(), stitch(5).into_bytes()]), + pdf_with_function(&chain(20_000, stitch, ramp)), + // A calculator function of procedures in procedures. + pdf_with_function(&[packed(calculator, &format!("{}{}", "{".repeat(n), "}".repeat(n)))]), + pdf_with_function(&[packed(calculator, &format!("{{ {} 0 0 0 {} }}", "true {".repeat(n), "} if ".repeat(n)))]), + ], + ); +} + +#[test] +fn pdf_color_spaces_that_hold_themselves() { + let indexed = |next: usize| format!("[/Indexed {next} 0 R 1 <000000ffffff>]"); + let tint = "<< /FunctionType 2 /Domain [0 1] /C0 [1 1 1] /C1 [0 0 0] /N 1 >>"; + let profile = |alternate: &str| format!("<< /N 3 /Alternate {alternate} /Length 4 >>\nstream\njunk\nendstream").into_bytes(); + open_each( + "spaces", + [ + pdf_with_color_space(&[indexed(5).into_bytes()]), + pdf_with_color_space(&[indexed(6).into_bytes(), indexed(5).into_bytes()]), + pdf_with_color_space(&chain(20_000, indexed, "/DeviceRGB")), + pdf_with_color_space(&[b"[/Separation /Ink 5 0 R 6 0 R]".to_vec(), tint.as_bytes().to_vec()]), + pdf_with_color_space(&[b"[/DeviceN [/Ink] 5 0 R 6 0 R]".to_vec(), tint.as_bytes().to_vec()]), + pdf_with_color_space(&[b"[/Pattern 5 0 R]".to_vec()]), + pdf_with_color_space(&[b"[/ICCBased 6 0 R]".to_vec(), profile("5 0 R")]), + pdf_with_color_space(&[b"[/ICCBased 6 0 R]".to_vec(), profile("[/ICCBased 6 0 R]")]), + // As the space of a shading and of an image. + pdf_with( + "/S sh", + "<< /Shading << /S << /ShadingType 2 /ColorSpace 5 0 R /Coords [0 0 9 0] /Function 6 0 R >> >> >>", + &[indexed(5).into_bytes(), tint.as_bytes().to_vec()], + ), + pdf_with( + "q 50 0 0 50 0 0 cm /I Do Q", + "<< /XObject << /I 6 0 R >> >>", + &[ + indexed(5).into_bytes(), + b"<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /BitsPerComponent 8 /ColorSpace 5 0 R /Length 1 >>\nstream\n0\nendstream" + .to_vec(), + ], + ), + ], + ); +} + +#[test] +fn pdf_art_that_draws_itself() { + let own = "<< /XObject << /F 5 0 R >> /Pattern << /P 5 0 R >> /Font << /F1 5 0 R >> /ExtGState << /G << /SMask << /S /Alpha /G 5 0 R >> >> >> >>"; + let tile = format!("/PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Resources {own}"); + let group = format!("{FORM} /Group << /S /Transparency >> /Resources {own}"); + open_each( + "itself", + [ + // A form drawing itself twice, a pattern painting its own tile, a glyph showing + // itself, and a mask masked by itself. + pdf_with("/F Do", own, &[packed(&format!("{FORM} /Resources {own}"), "0 0 5 5 re f /F Do 1 0 0 1 3 3 cm /F Do")]), + pdf_with("/Pattern cs /P scn 0 0 50 50 re f", own, &[packed(&tile, "/Pattern cs /P scn 0 0 5 5 re f")]), + pdf_with("BT /F1 12 Tf (a) Tj ET", own, &[type3_font(own), packed("", "10 0 d0 0 0 5 5 re f BT /F1 1 Tf (a) Tj ET")]), + pdf_with("/G gs 0 0 50 50 re f", own, &[packed(&group, "/G gs 0 0 5 5 re f")]), + // An image that is its own mask, and objects that are only a reference to each other. + pdf_with("q 50 0 0 50 0 0 cm /F Do Q", own, &[ + b"<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /BitsPerComponent 8 /ColorSpace /DeviceGray /SMask 5 0 R /Length 1 >>\nstream\n0\nendstream".to_vec(), + ]), + handmade_pdf_with("0 0 5 5 re f", "5 0 R", "6 0 R", &[b"6 0 R".to_vec(), b"5 0 R".to_vec()]), + handmade_pdf_with("0 0 5 5 re f", "<< >>", "[0 0 9 9]", &[b"<< /Length 5 0 R >>\nstream\nx\nendstream".to_vec()]), + ], + ); +} + /// An EPS whose program is `body`. fn deep_eps(body: &str) -> Vec { format!("%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n{body}\nshowpage\n%%EOF\n").into_bytes() diff --git a/crates/format/src/stream.rs b/crates/format/src/stream.rs index 60879c07..bed50860 100644 --- a/crates/format/src/stream.rs +++ b/crates/format/src/stream.rs @@ -66,7 +66,8 @@ impl Write for Out { const OUT_CHUNK: usize = 64 << 10; /// A gzip stream, written as [`flate2::write::GzEncoder`] at the default level writes it (the -/// same header, and the same bytes for a file without compressed images), except that data which +/// same header and the same data; the deflate blocks are the same too where flate2 deflates with +/// miniz_oxide, not with zlib-rs, which writes them by how the data is fed), except that data which /// is compressed already ([`Gz::packed`]) is stored as it is: the default level spends a second /// or more per photo on it for next to nothing, and makes it slower to unpack. Where the level /// changes the deflate blocks so far end with a sync flush and a fresh compressor writes on, diff --git a/crates/format/tests/streaming.rs b/crates/format/tests/streaming.rs index 81d935f8..6149f199 100644 --- a/crates/format/tests/streaming.rs +++ b/crates/format/tests/streaming.rs @@ -1,7 +1,7 @@ //! Saving and opening as streams: the bytes a save writes are those an in-memory save wrote -//! before (the same JSON and data after it, unpacked; the same gzip bytes when no image is -//! compressed already), files written the old way still open, entries are read whatever their -//! order, and the limits apply per part of the file. +//! before (the same JSON and data after it, unpacked; the same gzip header), files written the old +//! way still open, entries are read whatever their order, and the limits apply per part of the +//! file. // Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] @@ -67,13 +67,17 @@ fn compressed_files_unpack_to_the_plain_file() { } } +/// The deflate bytes themselves depend on flate2's backend: zlib-rs (which hayro turns on) writes +/// other blocks for data fed in other pieces than the in-memory save fed it, miniz_oxide didn't. #[test] -fn without_compressed_images_the_gzip_bytes_are_the_old_ones() { +fn without_compressed_images_the_gzip_stream_is_the_old_one() { let mut d = doc_with(&[("scan", "image/tiff", INLINE_MAX * 2), ("icon", "image/png", 100)]); d.title = "No photos".into(); for o in [SaveOptions::default(), SaveOptions { pdf: Some(vec![b'%'; INLINE_MAX]), ..SaveOptions::default() }] { let plain = save_with(&d, &o).unwrap(); - assert_eq!(save_with(&d, &SaveOptions { compress: true, ..o }).unwrap(), gzip(&plain)); + let (packed, old) = (save_with(&d, &SaveOptions { compress: true, ..o }).unwrap(), gzip(&plain)); + assert_eq!(packed.get(..10), old.get(..10), "the same header"); + assert_eq!(gunzip(&packed), plain); } } diff --git a/crates/pdf/Cargo.toml b/crates/pdf/Cargo.toml index 002863ae..e53fc21b 100644 --- a/crates/pdf/Cargo.toml +++ b/crates/pdf/Cargo.toml @@ -23,8 +23,8 @@ image = { workspace = true } # CMYK JPEGs (the image crate writes RGB and grey ones only). jpeg-encoder = { workspace = true } krilla = { workspace = true } -hayro-syntax = "0.7.2" -hayro-interpret = { version = "0.7.0", default-features = false, features = ["embed-fonts", "embed-cmaps"] } +hayro-syntax = "0.8.0" +hayro-interpret = { version = "0.8.0", default-features = false, features = ["embed-fonts", "embed-cmaps"] } log = { workspace = true } smallvec = "1" serde = { workspace = true } diff --git a/crates/pdf/src/import.rs b/crates/pdf/src/import.rs index d3116ce6..cc2bad67 100644 --- a/crates/pdf/src/import.rs +++ b/crates/pdf/src/import.rs @@ -3,12 +3,12 @@ use std::collections::{HashMap, HashSet}; use std::sync::{Arc, Mutex}; -use hayro_interpret::font::Glyph; +use hayro_interpret::font::{Glyph, GlyphRun as ShownGlyphs}; use hayro_interpret::hayro_cmap::BfString; use hayro_interpret::pattern::{Pattern, TilingPattern}; use hayro_interpret::{ - CacheKey, ClipPath, Context, Device, GlyphDrawMode, Image, ImageData, InterpreterCache, InterpreterSettings, InterpreterWarning, LumaData, - MaskType, PathDrawMode, SoftMask, StrokeProps, interpret_page, + CacheKey, ClipPath, Context, Device, DrawMode, DrawProps, Image, ImageData, ImageDrawProps, InterpreterCache, InterpreterSettings, + InterpreterWarning, LumaData, MaskType, SoftMask, StrokeProps, interpret_page, }; use hayro_syntax::object::Name; use kurbo::{Affine, BezPath, PathEl, Rect, Shape}; @@ -147,9 +147,15 @@ pub const PAST_PAGE_NOTE: &str = "art reaching past the page was clipped to it, /// Import a PDF, returning the document plus warnings about content that was approximated or skipped. pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result { + // The interpreter draws forms, patterns, glyphs and masks inside one another by recursion, in + // large stack frames: it caps that (50 deep), and gets room for the cap whatever thread asks. + vectorcraft_doc::depth::with_deep_stack(|| read(bytes, opts)) +} + +/// [`import_with_report`] on the thread that asks. +pub(crate) fn read(bytes: &[u8], opts: &ImportOptions) -> Result { let original = crate::pages::open(bytes, opts.password.as_deref())?; let picked = crate::pages::picked(opts, original.pages().len())?; - picked.iter().filter_map(|&n| original.pages().get(n)).try_for_each(crate::pages::content_too_deep)?; let mut ocgs = Ocgs::read(&original); let route = opts.layers && !ocgs.list.is_empty(); // Content that is off imports as hidden layers: an update of the file turns every group on @@ -167,7 +173,6 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result>> = Arc::default(); let sink2 = sink.clone(); @@ -176,6 +181,9 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result "a font could not be read; its text was skipped", InterpreterWarning::ImageDecodeFailure => "an image could not be decoded and was skipped", + // Annotations aren't drawn (`render_annotations` below), and a kind of warning + // added later has no message yet. + _ => return, }; if let Ok(mut v) = sink2.lock() && !v.iter().any(|x| x == msg) @@ -1262,8 +1270,8 @@ impl<'p> Builder<'p> { /// before it on the open line was stroked so too, is held with the line instead of ending it /// (an editor writes type on a path glyph by glyph, each followed by the stroke added to the /// type). `false`: it isn't one. - fn hold_glyph_stroke(&mut self, path: &BezPath, transform: Affine, paint: &hayro_interpret::Paint<'_>, draw_mode: &PathDrawMode) -> bool { - let PathDrawMode::Stroke(props) = draw_mode else { return false }; + fn hold_glyph_stroke(&mut self, path: &BezPath, transform: Affine, paint: &hayro_interpret::Paint<'_>, draw_mode: &DrawMode) -> bool { + let DrawMode::Stroke(props) = draw_mode else { return false }; if self.mask.is_some() || self.blend != BlendMode::Normal || matches!(paint, hayro_interpret::Paint::Pattern(_)) { return false; } @@ -1355,7 +1363,11 @@ impl<'p> Builder<'p> { Some(art) => { let [r, g, b, _] = m.background_color().to_rgba().components(); let backdrop = luminance(&Color::rgb(r, g, b)); - let inverted = m.transfer_function().is_some_and(|tf| tf.apply(0.0) > tf.apply(1.0)); + let inverted = m.transfer_function().is_some_and(|tf| { + let mut ends = [0, 255]; + tf.apply_to(&mut ends); + ends[0] > ends[1] + }); let id = self.id(); mask_spec(art, m.mask_type() == MaskType::Alpha, backdrop, inverted, page, |all| Node::group(id, all)) } @@ -1684,7 +1696,14 @@ fn resize_alpha(a: &LumaData, w: u32, h: u32) -> Vec { } } -impl<'a> Device<'a> for Builder<'_> { +/// What the interpreter draws, as the device below gets it. +impl<'a> Builder<'_> { + /// The soft mask and blend mode of what is drawn next. + fn set_state(&mut self, mask: Option>, blend_mode: hayro_interpret::BlendMode) { + self.set_soft_mask(mask); + self.set_blend_mode(blend_mode); + } + fn set_soft_mask(&mut self, mask: Option>) { let spec = mask.map(|m| self.soft_mask(&m)); let same = match (&spec, &self.mask) { @@ -1707,7 +1726,7 @@ impl<'a> Device<'a> for Builder<'_> { } } - fn draw_path(&mut self, path: &BezPath, transform: Affine, paint: &hayro_interpret::Paint<'a>, draw_mode: &PathDrawMode) { + fn path(&mut self, path: &BezPath, transform: Affine, paint: &hayro_interpret::Paint<'a>, draw_mode: &DrawMode) { self.flush_glyphs(); if self.hold_glyph_stroke(path, transform, paint, draw_mode) { self.pending = false; @@ -1729,19 +1748,19 @@ impl<'a> Device<'a> for Builder<'_> { hayro_interpret::Paint::Color(_) => None, }; // Patch and triangle meshes are gradient meshes. - if let (PathDrawMode::Fill(_), Some(sp)) = (draw_mode, shading) + if let (DrawMode::Fill(_), Some(sp)) = (draw_mode, shading) && let Some(meshes) = mesh_shading(sp, &mut self.colors) { self.draw_meshes(meshes, &bp, sp.opacity); return; } let pd = PathData::from_bezpath(&bp); - let stroke = matches!(draw_mode, PathDrawMode::Stroke(_)); + let stroke = matches!(draw_mode, DrawMode::Stroke(_)); let (paint, opacity) = self.paint(paint, stroke); // A gradient that doesn't extend past its ends paints only between them. let width = match draw_mode { - PathDrawMode::Stroke(p) => p.line_width as f64 * mean_scale(transform), - PathDrawMode::Fill(_) => 0.0, + DrawMode::Stroke(p) => p.line_width as f64 * mean_scale(transform), + _ => 0.0, }; let band = shading.and_then(|sp| extend_clip(sp, bp.bounding_box().inflate(width, width))); let wrap = |b: &mut Self, n: Node| match &band { @@ -1753,7 +1772,7 @@ impl<'a> Device<'a> for Builder<'_> { None => n, }; match draw_mode { - PathDrawMode::Fill(rule) => { + DrawMode::Fill(rule) => { let mut f = FillLayer::new(paint); (f.opacity, f.overprint) = (opacity, overprint); let mut n = Node::path(self.id(), pd, Appearance { items: vec![AppearanceItem::Fill(f)], ..Default::default() }); @@ -1764,7 +1783,7 @@ impl<'a> Device<'a> for Builder<'_> { let n = wrap(self, n); self.push_node(n); } - PathDrawMode::Stroke(props) => { + DrawMode::Stroke(props) => { let mut st = stroke_layer(paint, opacity, props, mean_scale(transform)); st.overprint = overprint; // Fill-then-stroke of the same path (the `B` operator, or a fill and then a stroke @@ -1799,6 +1818,71 @@ impl<'a> Device<'a> for Builder<'_> { let n = wrap(self, n); self.push_node(n); } + // (`draw_path` gives a fill and a stroke one after the other, and no path is invisible.) + DrawMode::FillAndStroke(..) | DrawMode::Invisible => {} + } + } + + /// One glyph: filled, or stroked with `stroke`. + fn glyph( + &mut self, + glyph: &Glyph<'a>, + transform: Affine, + glyph_transform: Affine, + paint: &hayro_interpret::Paint<'a>, + stroke: Option<&StrokeProps>, + ) { + self.pending = false; + match glyph { + Glyph::Outline(o) => { + let scale = mean_scale(transform); + let overprint = self.glyph_overprint(stroke.is_some()); + if self.type_glyph(o, transform * glyph_transform, scale, paint, stroke, overprint) { + return; + } + self.flush_text(); + let mut bp = o.outline(); + bp.apply_affine(transform * glyph_transform); + let (paint, opacity) = self.paint(paint, stroke.is_some()); + let same = self.glyphs.as_ref().is_some_and(|r| { + r.paint == paint + && r.opacity == opacity + && r.overprint == overprint + && r.stroke.as_ref().map(|s| s.line_width) == stroke.map(|s| s.line_width) + && (r.scale - scale).abs() < 1e-9 + }); + if !same { + self.flush_glyphs(); + self.glyphs = Some(GlyphRun { path: BezPath::new(), paint, opacity, stroke: stroke.cloned(), scale, overprint }); + } + if let Some(r) = &mut self.glyphs { + r.path.extend(bp.iter()); + } + self.warn("text was converted to outlines"); + } + Glyph::Type3(t3) => { + if self.nested >= MAX_NESTED { + return; + } + self.flush(); + self.nested += 1; + t3.interpret(self, transform, glyph_transform, paint); + self.nested -= 1; + } + } + } +} + +impl<'a> Device<'a> for Builder<'_> { + fn draw_path(&mut self, path: &BezPath, props: DrawProps<'a>, draw_mode: &DrawMode) { + self.set_state(props.soft_mask, props.blend_mode); + match draw_mode { + DrawMode::Invisible => {} + DrawMode::FillAndStroke(rule, stroke) => { + self.path(path, props.transform, &props.paint, &DrawMode::Fill(*rule)); + self.path(path, props.transform, &props.paint, &DrawMode::Stroke(stroke.clone())); + } + one => self.path(path, props.transform, &props.paint, one), } } @@ -1834,60 +1918,22 @@ impl<'a> Device<'a> for Builder<'_> { self.blend = BlendMode::Normal; } - fn draw_glyph( - &mut self, - glyph: &Glyph<'a>, - transform: Affine, - glyph_transform: Affine, - paint: &hayro_interpret::Paint<'a>, - draw_mode: &GlyphDrawMode, - ) { - self.pending = false; + fn draw_glyph_run(&mut self, glyph_run: &ShownGlyphs<'_, 'a>, props: DrawProps<'a>, draw_mode: &DrawMode) { + // (A run is filled or stroked: one filled and stroked is given twice.) let stroke = match draw_mode { - GlyphDrawMode::Invisible => return, - GlyphDrawMode::Fill => None, - GlyphDrawMode::Stroke(p) => Some(p.clone()), + DrawMode::Invisible => return, + DrawMode::Fill(_) => None, + DrawMode::Stroke(p) | DrawMode::FillAndStroke(_, p) => Some(p), }; - match glyph { - Glyph::Outline(o) => { - let scale = mean_scale(transform); - let overprint = self.glyph_overprint(stroke.is_some()); - if self.type_glyph(o, transform * glyph_transform, scale, paint, stroke.as_ref(), overprint) { - return; - } - self.flush_text(); - let mut bp = o.outline(); - bp.apply_affine(transform * glyph_transform); - let (paint, opacity) = self.paint(paint, stroke.is_some()); - let same = self.glyphs.as_ref().is_some_and(|r| { - r.paint == paint - && r.opacity == opacity - && r.overprint == overprint - && r.stroke.as_ref().map(|s| s.line_width) == stroke.as_ref().map(|s| s.line_width) - && (r.scale - scale).abs() < 1e-9 - }); - if !same { - self.flush_glyphs(); - self.glyphs = Some(GlyphRun { path: BezPath::new(), paint, opacity, stroke, scale, overprint }); - } - if let Some(r) = &mut self.glyphs { - r.path.extend(bp.iter()); - } - self.warn("text was converted to outlines"); - } - Glyph::Type3(t3) => { - if self.nested >= MAX_NESTED { - return; - } - self.flush(); - self.nested += 1; - t3.interpret(self, transform, glyph_transform, paint); - self.nested -= 1; - } + for g in glyph_run.glyphs() { + self.set_state(props.soft_mask.clone(), props.blend_mode); + self.glyph(g, props.transform, g.transform(), &props.paint, stroke); } } - fn draw_image(&mut self, image: Image<'a, '_>, transform: Affine) { + fn draw_image(&mut self, image: Image<'a, '_>, props: ImageDrawProps<'a>) { + self.set_state(props.soft_mask, props.blend_mode); + let transform = props.transform; self.flush_glyphs(); self.flush_text(); self.pending = false; @@ -1975,7 +2021,7 @@ impl<'a> Device<'a> for Builder<'_> { } } - fn pop_clip_path(&mut self) { + fn pop_clip(&mut self) { self.flush(); self.pending = false; self.pop_frame(); diff --git a/crates/pdf/src/import_color.rs b/crates/pdf/src/import_color.rs index 7390ecd0..4b7f4106 100644 --- a/crates/pdf/src/import_color.rs +++ b/crates/pdf/src/import_color.rs @@ -379,7 +379,7 @@ impl<'p> Colors<'p> { } /// An object written in place (references are visited as objects of their own). -fn direct(m: MaybeRef>) -> Option> { +pub(crate) fn direct(m: MaybeRef>) -> Option> { match m { MaybeRef::NotRef(o) => Some(o), MaybeRef::Ref(_) => None, diff --git a/crates/pdf/src/import_scan.rs b/crates/pdf/src/import_scan.rs index 2d09842e..b3a6d2c9 100644 --- a/crates/pdf/src/import_scan.rs +++ b/crates/pdf/src/import_scan.rs @@ -485,7 +485,8 @@ impl Walker<'_, '_> { { self.out.groups.push((g.get::(b"I").unwrap_or(false), g.get::(b"K").unwrap_or(false))); } - let inner = Resources::from_parent(d.get::>(b"Resources").unwrap_or_default(), res.clone()); + // (Its own resources, or without any those of what draws it: as the interpreter reads them.) + let inner = d.get::>(b"Resources").map(Resources::new).unwrap_or_else(|| res.clone()); self.save(); self.walk(TypedIter::new(data.as_ref()), &inner, depth + 1); self.restore(); diff --git a/crates/pdf/src/import_shading.rs b/crates/pdf/src/import_shading.rs index a5783cf0..c81438d4 100644 --- a/crates/pdf/src/import_shading.rs +++ b/crates/pdf/src/import_shading.rs @@ -34,7 +34,7 @@ pub(crate) fn shading_gradient(sp: &ShadingPattern, colors: &mut Colors<'_>) -> let space = colors.shading_space(cs, at(0.0)?.len()); // RGB to decide which samples are stops; the stops keep the shading's model. let sample = |t: f32| -> Option { - let [r, g, b, _] = cs.to_rgba(&at(t)?, 1.0, false).components(); + let [r, g, b, _] = cs.to_rgba(&at(t)?, 1.0).components(); Some(Color::rgb(round3(r), round3(g), round3(b))) }; // Sample densely, then drop samples that linear interpolation reproduces. @@ -147,7 +147,7 @@ fn mesh_color(v: &[f32], function: Option<&ShadingFunction>, sp: &ShadingPattern }; let space = colors.shading_space(&sp.shading.color_space, comps.len()); colors.native(space, &comps).map(|n| n.color).unwrap_or_else(|| { - let [r, g, b, _] = sp.shading.color_space.to_rgba(&comps, 1.0, false).components(); + let [r, g, b, _] = sp.shading.color_space.to_rgba(&comps, 1.0).components(); Color::rgb(round3(r), round3(g), round3(b)) }) } diff --git a/crates/pdf/src/pages.rs b/crates/pdf/src/pages.rs index 29817514..9d38e43a 100644 --- a/crates/pdf/src/pages.rs +++ b/crates/pdf/src/pages.rs @@ -1,12 +1,16 @@ //! The pages of a PDF to import: opening with a password, the pages picked, the box each page //! is cropped to ([`CropTo`]) and [`info`]. -use hayro_syntax::object::{Dict, Stream}; +use std::collections::HashMap; + +use hayro_syntax::object::{Array, Dict, MaybeRef, Name, Object, ObjectIdentifier, Stream, dict_or_stream}; use hayro_syntax::page::Page; +use hayro_syntax::xref::XRef; use hayro_syntax::{DecryptionError, LoadPdfError, Pdf}; use kurbo::{Affine, Rect}; use vectorcraft_doc::setup::MAX_BLEED; +use crate::import_color::direct; use crate::{Choice, CropTo, ImportOptions, PdfError}; /// What `info` tells about one page. @@ -33,76 +37,116 @@ pub fn is_postscript(bytes: &[u8]) -> bool { bytes.starts_with(b"%!PS") || bytes.starts_with(&[0xC5, 0xD0, 0xD3, 0xC6]) } -/// The deepest a file's arrays and dictionaries may nest. The PDF reader (hayro) reads them by -/// recursion, without a cap of its own: a file nested deeply enough overflows the stack, which is -/// an abort, not an error. Far beyond what a PDF writer makes. -const MAX_OBJECT_NESTING: usize = 256; +/// The most functions and colour spaces that may be built on one another (a function stitching +/// functions together, an indexed space on a space with an alternate). The interpreter (hayro) +/// builds them by recursion, without a cap of its own: one built on itself, or thousands in a +/// chain, overflows the stack, which is an abort, not an error. Files have two or three. +const MAX_LINKS: usize = 16; -fn too_deep() -> PdfError { - PdfError::Parse(format!("its arrays and dictionaries are nested more than {MAX_OBJECT_NESTING} deep")) +/// The deepest the procedures of a calculator function may nest: read by recursion, too. +const MAX_PROCEDURES: usize = 64; + +/// What the function or colour space `o` is built on: the functions a stitching function joins; +/// the base, alternate and tint of a colour space; the alternate of a colour profile. Nothing for +/// any other object. +fn links<'a>(o: &Object<'a>, xref: &'a XRef) -> Vec>> { + const SPACES: [&[u8]; 6] = [b"Indexed", b"I", b"Separation", b"DeviceN", b"Pattern", b"ICCBased"]; + if let Object::Array(a) = o { + let mut items = a.raw_iter(); + let kind = match items.next() { + Some(MaybeRef::NotRef(Object::Name(n))) => Some(n), + Some(MaybeRef::Ref(r)) => xref.get::>(r.into()), + _ => None, + }; + return if kind.is_some_and(|n| SPACES.contains(&n.as_ref())) { items.collect() } else { vec![] }; + } + let Some((d, _)) = dict_or_stream(o) else { return vec![] }; + let functions = d.get::>(b"Functions").map(|a| a.raw_iter().collect::>()).unwrap_or_default(); + functions.into_iter().chain(d.get_raw::>(b"Alternate")).collect() } -/// Do the arrays and dictionaries in the text of `pdf` nest deeper than [`MAX_OBJECT_NESTING`]? -/// Read as tokens (strings and comments hold none), an object at a time. The data of a stream -/// with a filter is skipped: it isn't PDF text until decoded, which this doesn't do, so nesting -/// inside compressed streams is not seen here. -fn nests_too_deep(pdf: &[u8]) -> bool { - let after = - |from: usize, what: &[u8]| pdf.get(from..).and_then(|rest| rest.windows(what.len()).position(|w| w == what)).map(|at| from + at + what.len()); - let word = |at: usize, w: &[u8]| pdf.get(at..).is_some_and(|rest| rest.starts_with(w)); - let (mut depth, mut filtered) = (0usize, false); - let mut i = 0; - while let Some(&c) = pdf.get(i) { - i += 1; +/// How many of its kind the function or colour space `o` is built on, one on the next (0 for any +/// other object); `None` past `room`, or when it is built on itself. `known`: the objects looked +/// at so far (`None` while one is being looked at, too). One `None` refuses the file, so that an +/// object is `None` only for the room there was where it was first met doesn't matter. +fn built_on<'a>(o: MaybeRef>, xref: &'a XRef, known: &mut HashMap>, room: usize) -> Option { + let (id, o) = match o { + MaybeRef::Ref(r) => { + let id = ObjectIdentifier::from(r); + if let Some(found) = known.get(&id) { + return *found; + } + let Some(o) = xref.get::>(id) else { return Some(0) }; + known.insert(id, None); + (Some(id), o) + } + MaybeRef::NotRef(o) => (None, o), + }; + let links = links(&o, xref); + let most = match room.checked_sub(1) { + _ if links.is_empty() => Some(0), + Some(room) => links.into_iter().try_fold(0, |most, on| built_on(on, xref, known, room).map(|n| most.max(n + 1))), + None => None, + }; + // (What it is built on may be known already, and deeper than there is room for here.) + let most = most.filter(|n| *n <= room); + if let Some(id) = id { + known.insert(id, most); + } + most +} + +/// Is `o` a calculator function whose procedures nest deeper than [`MAX_PROCEDURES`]? +fn procedures_too_deep(o: &Object<'_>) -> bool { + let Object::Stream(s) = o else { return false }; + if s.dict().get::(b"FunctionType") != Some(4) { + return false; + } + let Ok(program) = s.decoded() else { return false }; + let mut depth = 0usize; + program.iter().any(|c| { match c { - b'%' => i += pdf.get(i..).and_then(|rest| rest.iter().position(|c| matches!(c, b'\n' | b'\r'))).unwrap_or(pdf.len()), - b'(' => { - // A string: parentheses nest in it, a backslash takes the next byte. - let mut open = 1usize; - while open > 0 { - match pdf.get(i) { - None => return false, - Some(b'\\') => i += 1, - Some(b'(') => open += 1, - Some(b')') => open -= 1, - Some(_) => {} - } - i += 1; - } - } - b'[' => depth += 1, - b']' => depth = depth.saturating_sub(1), - b'<' if pdf.get(i) == Some(&b'<') => (depth, i) = (depth + 1, i + 1), - b'>' if pdf.get(i) == Some(&b'>') => (depth, i) = (depth.saturating_sub(1), i + 1), - // A string in hexadecimal. - b'<' => i = after(i, b">").unwrap_or(pdf.len()), - b'/' if word(i - 1, b"/Filter") => filtered = true, - b'e' if word(i - 1, b"endobj") => (depth, filtered) = (0, false), - b'e' if word(i - 1, b"endstream") => i += 8, - b's' if word(i - 1, b"stream") => { - depth = 0; - if std::mem::take(&mut filtered) { - i = after(i, b"endstream").unwrap_or(pdf.len()); - } - } + b'{' => depth += 1, + b'}' => depth = depth.saturating_sub(1), _ => {} } - if depth > MAX_OBJECT_NESTING { + depth > MAX_PROCEDURES + }) +} + +/// Does `pdf` hold functions or colour spaces built on one another past [`MAX_LINKS`] (or on +/// themselves), or a calculator function nested past [`MAX_PROCEDURES`]? Every object is looked +/// at, wherever it is used from: the interpreter builds them from resources, shadings, images, +/// patterns, masks and graphics states alike. +fn nests_without_end(pdf: &Pdf) -> bool { + let xref = pdf.xref(); + let mut known = HashMap::new(); + // The objects of the file, then what each holds directly (64 deep at most: the reader's cap). + let mut pending: Vec> = pdf.objects().into_iter().collect(); + while let Some(o) = pending.pop() { + if procedures_too_deep(&o) || built_on(MaybeRef::NotRef(o.clone()), xref, &mut known, MAX_LINKS).is_none() { return true; } + match &o { + Object::Array(a) => pending.extend(a.raw_iter().filter_map(direct)), + _ => pending.extend(dict_or_stream(&o).into_iter().flat_map(|(d, _)| d.entries()).filter_map(|(_, v)| direct(v))), + } } false } /// Read a PDF, decrypting it with `password` (none: the empty user password). The permissions /// password opens a file too. +/// +/// What a hostile file nests or loops is the reader's to refuse (hayro 0.8 reads arrays and +/// dictionaries 64 deep and no deeper, wherever they are: the file's text, an object stream, the +/// content of a page, form, pattern or glyph; and follows a page tree or a chain of object streams +/// once). What it reads beyond that is left out, as an object it can't read. What the interpreter +/// builds by recursion with no cap is refused here ([`nests_without_end`]). pub(crate) fn open(bytes: &[u8], password: Option<&str>) -> Result { if is_postscript(bytes) { return Err(PdfError::PostScript); } - if nests_too_deep(bytes) { - return Err(too_deep()); - } let password = password.unwrap_or_default(); let read = |pw: &str| Pdf::new_with_password(bytes.to_vec(), pw); read(password) @@ -119,12 +163,12 @@ pub(crate) fn open(bytes: &[u8], password: Option<&str>) -> Result PdfError::WrongPassword, e => PdfError::Parse(format!("{e:?}")), }) -} - -/// The error of a page whose content, decoded, nests arrays and dictionaries too deep (see -/// [`MAX_OBJECT_NESTING`]): its operands are read the same way as the file's objects. -pub(crate) fn content_too_deep(page: &Page<'_>) -> Result<(), PdfError> { - if page.page_stream().is_some_and(nests_too_deep) { Err(too_deep()) } else { Ok(()) } + // Twice: looking at every object makes the reader repair a damaged table of objects (once + // only), and the objects are other ones after that. + .and_then(|pdf| match (0..2).any(|_| nests_without_end(&pdf)) { + true => Err(PdfError::Parse("its functions or colour spaces are built on one another without end".into())), + false => Ok(pdf), + }) } /// The pages and their boxes. An encrypted PDF needs its `password` diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 29e7469b..f09f33f5 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -734,3 +734,46 @@ fn opacity_mask_exports_as_luminosity_soft_mask() { assert!(text.contains("/SMask"), "soft mask in an ExtGState"); assert!(text.contains("/Luminosity")); } + +/// A function or colour space built on itself is refused before the interpreter builds it (by +/// recursion, with no cap: a stack overflow), and those of real files are read. +#[test] +fn functions_and_colour_spaces_built_on_themselves_are_refused() { + use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf_with}; + let first = first_extra(1); + let (me, next) = (format!("{first} 0 R"), format!("{} 0 R", first + 1)); + let shaded = PdfPage { + resources: format!("/Shading << /S << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 100 0] /Function {me} >> >>"), + ..PdfPage::new(100.0, 100.0, "/S sh") + }; + let filled = PdfPage { resources: format!("/ColorSpace << /C {me} >>"), ..PdfPage::new(100.0, 100.0, "/C cs 0 sc 0 0 50 50 re f") }; + let stitch = |on: &str| format!("<< /FunctionType 3 /Domain [0 1] /Functions [{on}] /Bounds [] /Encode [0 1] >>"); + let indexed = |on: &str| format!("[/Indexed {on} 1 <000000ffffff>]"); + let ramp = "<< /FunctionType 2 /Domain [0 1] /C0 [1 0 0] /C1 [0 0 1] /N 1 >>"; + // `n` functions, each stitching the next, then a plain one. + let chain = |n: usize| (1..=n).map(|i| stitch(&format!("{} 0 R", first + i))).chain([ramp.to_string()]).collect::>(); + let calculator = + |program: &str| format!("<< /FunctionType 4 /Domain [0 1] /Range [0 1 0 1 0 1] /Length {} >>\nstream\n{program}\nendstream", program.len()); + for (what, page, objects) in [ + ("a function stitching itself", &shaded, vec![stitch(&me)]), + ("two functions stitching each other", &shaded, vec![stitch(&next), stitch(&me)]), + ("twenty functions, each stitching the next", &shaded, chain(20)), + ("procedures in procedures", &shaded, vec![calculator(&format!("{}{}", "{".repeat(100), "}".repeat(100)))]), + ("a colour space on itself", &filled, vec![indexed(&me)]), + ("two colour spaces on each other", &filled, vec![indexed(&next), indexed(&me)]), + ] { + let objects: Vec<&str> = objects.iter().map(String::as_str).collect(); + let e = import(&pdf_with(std::slice::from_ref(page), &objects, None)).err().map(|e| e.to_string()); + assert!(e.as_deref().is_some_and(|e| e.contains("built on one another without end")), "{what}: {e:?}"); + } + for (what, page, objects) in [ + ("a function stitching one", &shaded, vec![stitch(&next), ramp.to_string()]), + ("three functions, each stitching the next", &shaded, chain(3)), + ("a calculator function", &shaded, vec![calculator("{ dup 1 exch sub 0 }")]), + ("an indexed colour space", &filled, vec![indexed("/DeviceRGB")]), + ] { + let objects: Vec<&str> = objects.iter().map(String::as_str).collect(); + let d = import(&pdf_with(std::slice::from_ref(page), &objects, None)).unwrap_or_else(|e| panic!("{what}: {e}")); + assert!(d.layers.iter().any(|l| l.children().is_some_and(|c| !c.is_empty())), "{what}: its art is read"); + } +} diff --git a/crates/pdf/src/tests_import_layers.rs b/crates/pdf/src/tests_import_layers.rs index c2c46d21..82032f2c 100644 --- a/crates/pdf/src/tests_import_layers.rs +++ b/crates/pdf/src/tests_import_layers.rs @@ -287,7 +287,7 @@ fn a_sublayer_that_paints_first_brings_its_parent_layer() { #[test] fn hostile_layer_orders_stay_bounded() { // A group listed twice, under itself, and an order nested far deeper than layers go. - let deep = format!("{}{{2}}{}", "[".repeat(100), "]".repeat(100)); + let deep = format!("{}{{2}}{}", "[".repeat(40), "]".repeat(40)); let order = format!("[{{0}} [{{0}} {{1}}] {{1}} [{{0}}] {deep}]"); let d = import(&three_groups([&ocg("A", ""), &ocg("B", ""), &ocg("C", "")], &[], &format!("/Order {order}"))).unwrap(); assert_eq!(names(&d), ["A", "C"], "C, listed too deep, stays a top-level layer"); @@ -317,7 +317,8 @@ fn thousands_of_layers_hidden_ones_included_all_come_in() { /// Import `bytes` with the scan's operator limit lowered to `n`. fn import_with_max_ops(bytes: &[u8], n: usize) -> ImportReport { let old = crate::import_scan::TEST_MAX_OPS.replace(n); - let r = import_with_report(bytes, &ImportOptions::default()); + // (On this thread: the limit is this thread's.) + let r = crate::import::read(bytes, &ImportOptions::default()); crate::import_scan::TEST_MAX_OPS.set(old); r.unwrap() }