From 17d2493616637925cc3ad8e53876b6522c0f0e52 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Wed, 14 Oct 2026 09:40:58 +0700 Subject: [PATCH] Importers: PDF nesting inside compressed streams no longer crashes (hayro 0.8) hayro-syntax 0.8.0 reads arrays and dictionaries 64 deep and no deeper wherever they are (the file's text, object streams, the content of pages, forms, patterns, glyphs and masks), walks the page tree without recursion and follows a chain of object streams once: what 0.7.2 overflowed the stack on. hayro-interpret 0.8.0 comes with it (a new device interface: the importer's device takes it as before). Our raw pre-scan of the file's text and page content goes: the reader's own cap covers more, and the scan miscounted the bytes of unfiltered binary streams. What 0.8.0 still builds by recursion with no cap is refused when the file opens: functions and colour spaces built on themselves or on one another more than 16 deep, and calculator functions whose procedures nest more than 64 deep. The import runs on a 16 MB thread, as the SVG importer does. hayro-syntax 0.8 turns on flate2's zlib-rs backend, which the whole app then deflates with: a compressed native file is the same gzip stream as before (header and data), but its deflate blocks now depend on how the data is fed, so the streaming test checks the header and the unpacked data instead of the bytes. --- Cargo.lock | 46 ++-- crates/engine/tests/import_fuzz.rs | 300 +++++++++++++++++++++++++- crates/format/src/stream.rs | 3 +- crates/format/tests/streaming.rs | 14 +- crates/pdf/Cargo.toml | 4 +- crates/pdf/src/import.rs | 178 +++++++++------ crates/pdf/src/import_color.rs | 2 +- crates/pdf/src/import_scan.rs | 3 +- crates/pdf/src/import_shading.rs | 4 +- crates/pdf/src/pages.rs | 162 +++++++++----- crates/pdf/src/tests.rs | 43 ++++ crates/pdf/src/tests_import_layers.rs | 5 +- 12 files changed, 606 insertions(+), 158 deletions(-) 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() }