diff --git a/Cargo.lock b/Cargo.lock index 2984a688f..ae648efe6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3984,6 +3984,7 @@ dependencies = [ "pdfcraft-crypt", "pdfcraft-filters", "pdfcraft-oracle", + "pdfcraft-testkit", "proptest", "serde_json", "thiserror 2.0.21", @@ -4127,6 +4128,7 @@ dependencies = [ "pdfcraft-content", "pdfcraft-cos", "pdfcraft-model", + "pdfcraft-testkit", "serde", "thiserror 2.0.21", ] @@ -4215,6 +4217,7 @@ dependencies = [ "pdfcraft-fonts", "pdfcraft-forms", "pdfcraft-model", + "pdfcraft-testkit", "thiserror 2.0.21", ] @@ -4271,6 +4274,10 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "pdfcraft-testkit" +version = "0.8.0" + [[package]] name = "pdfcraft-ui-egui" version = "0.8.0" diff --git a/Cargo.toml b/Cargo.toml index 6940b9acb..84b2ed1f2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,7 @@ pdfcraft-sign = { path = "crates/sign", version = "0.8.0" } pdfcraft-optimize = { path = "crates/optimize", version = "0.8.0" } pdfcraft-a11y = { path = "crates/a11y", version = "0.8.0" } pdfcraft-oracle = { path = "crates/oracle", version = "0.8.0" } +pdfcraft-testkit = { path = "crates/testkit", version = "0.8.0" } pdfcraft-ocr = { path = "crates/ocr", version = "0.8.0" } pdfcraft-js = { path = "crates/js", version = "0.8.0" } pdfcraft-compare = { path = "crates/compare", version = "0.8.0" } diff --git a/crates/cos/Cargo.toml b/crates/cos/Cargo.toml index 1be1561e6..9ef9126ca 100644 --- a/crates/cos/Cargo.toml +++ b/crates/cos/Cargo.toml @@ -15,6 +15,7 @@ thiserror = { workspace = true } [dev-dependencies] pdfcraft-oracle = { workspace = true } +pdfcraft-testkit = { workspace = true } serde_json = { workspace = true } proptest = { workspace = true } hayro-syntax = { workspace = true } diff --git a/crates/cos/tests/writer.rs b/crates/cos/tests/writer.rs index 87462f467..a7f071e8e 100644 --- a/crates/cos/tests/writer.rs +++ b/crates/cos/tests/writer.rs @@ -4,34 +4,23 @@ use std::sync::Arc; use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full, write_incremental}; +use pdfcraft_testkit::Pdf; /// Four pages, a shared font, document info with a non-ASCII title. fn fixture() -> Vec { - let mut objs: Vec = vec!["<< /Type /Catalog /Pages 2 0 R >>".into()]; let kids: Vec = (0..4).map(|i| format!("{} 0 R", 4 + 2 * i)).collect(); - objs.push(format!("<< /Type /Pages /Kids [{}] /Count 4 /MediaBox [0 0 200 300] >>", kids.join(" "))); - objs.push("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into()); + let mut pdf = Pdf::new() + .version("1.4") + .obj("<< /Type /Catalog /Pages 2 0 R >>") + .obj(format!("<< /Type /Pages /Kids [{}] /Count 4 /MediaBox [0 0 200 300] >>", kids.join(" "))) + .obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"); for i in 0..4 { - objs.push(format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources << /Font << /F1 3 0 R >> >> >>", 5 + 2 * i)); - let body = format!("BT /F1 24 Tf 20 150 Td (Page {}) Tj ET", i + 1); - objs.push(format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len())); + pdf = pdf + .obj(format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources << /Font << /F1 3 0 R >> >> >>", 5 + 2 * i)) + .stream("", format!("BT /F1 24 Tf 20 150 Td (Page {}) Tj ET", i + 1)); } - objs.push("<< /Title (Caf\\351 \\(draft\\)) >>".into()); - let mut out = b"%PDF-1.4\n".to_vec(); - let mut offsets = Vec::new(); - 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()); - } - let xref = out.len(); - out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); - for o in offsets { - out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); - } - out.extend_from_slice( - format!("trailer\n<< /Size {} /Root 1 0 R /Info {} 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1, objs.len()).as_bytes(), - ); - out + let info = pdf.next_obj(); + pdf.obj("<< /Title (Caf\\351 \\(draft\\)) >>").trailer_entry(&format!("/Info {info} 0 R")).build() } fn title(doc: &Document) -> Vec { diff --git a/crates/measure/Cargo.toml b/crates/measure/Cargo.toml index eb2de9422..66f071c92 100644 --- a/crates/measure/Cargo.toml +++ b/crates/measure/Cargo.toml @@ -15,3 +15,6 @@ pdfcraft-content = { workspace = true } pdfcraft-annot = { workspace = true } serde = { workspace = true } thiserror = { workspace = true } + +[dev-dependencies] +pdfcraft-testkit = { workspace = true } diff --git a/crates/measure/src/tests.rs b/crates/measure/src/tests.rs index 20a730bed..6af32632f 100644 --- a/crates/measure/src/tests.rs +++ b/crates/measure/src/tests.rs @@ -1,30 +1,18 @@ use super::*; use pdfcraft_cos::{SaveOptions, write_full}; +use pdfcraft_testkit::Pdf; use std::sync::Arc; fn fixture(content: &str, attrs: &str, extra: &[&str]) -> Document { fixture_with("4 0 R", content, attrs, extra) } fn fixture_with(contents: &str, content: &str, attrs: &str, extra: &[&str]) -> Document { - let mut objects = vec![ - "<< /Type /Catalog /Pages 2 0 R >>".to_string(), - "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>".into(), - format!("<< /Type /Page /Parent 2 0 R /Contents {contents} /Resources << /XObject << /Nested 5 0 R >> >> {attrs} >>"), - format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), - ]; - objects.extend(extra.iter().map(|s| s.to_string())); - let mut bytes = b"%PDF-1.7\n".to_vec(); - let mut offsets = Vec::new(); - for (i, o) in objects.iter().enumerate() { - offsets.push(bytes.len()); - bytes.extend(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); - } - let xref = bytes.len(); - bytes.extend(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); - for o in offsets { - bytes.extend(format!("{o:010} 00000 n \n").as_bytes()); - } - bytes.extend(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); - Document::open(Arc::new(bytes)).unwrap() + let pdf = Pdf::new() + .obj("<< /Type /Catalog /Pages 2 0 R >>") + .obj("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>") + .obj(format!("<< /Type /Page /Parent 2 0 R /Contents {contents} /Resources << /XObject << /Nested 5 0 R >> >> {attrs} >>")) + .stream("", content) + .objs(extra); + Document::open(Arc::new(pdf.build())).unwrap() } fn close(a: f64, b: f64) { assert!((a - b).abs() < 1e-8, "{a} != {b}"); diff --git a/crates/redact/Cargo.toml b/crates/redact/Cargo.toml index 4aee20bfc..214c07f80 100644 --- a/crates/redact/Cargo.toml +++ b/crates/redact/Cargo.toml @@ -19,3 +19,4 @@ pdfcraft-annot = { workspace = true } thiserror = { workspace = true } [dev-dependencies] +pdfcraft-testkit = { workspace = true } diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 7fcbc4305..09723b763 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -2,32 +2,12 @@ use std::sync::Arc; use pdfcraft_annot::{Meta, NewAnnotation, Shape, Style, add_annotation, rect_quad}; use pdfcraft_cos::{Document, SaveOptions, write_full, write_incremental}; +use pdfcraft_testkit::{Pdf, stream_obj as stream}; use super::*; -fn stream(dict: &str, data: &[u8]) -> Vec { - let mut v = format!("<< {dict} /Length {} >>\nstream\n", data.len()).into_bytes(); - v.extend_from_slice(data); - v.extend_from_slice(b"\nendstream"); - v -} - fn pdf(objs: Vec>) -> Document { - let mut out = b"%PDF-1.7\n".to_vec(); - let mut offs = Vec::new(); - for (i, o) in objs.iter().enumerate() { - offs.push(out.len()); - 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 x = out.len(); - out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); - for o in offs { - out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); - } - out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes()); - Document::open(Arc::new(out)).unwrap() + Document::open(Arc::new(Pdf::new().objs(objs).build())).unwrap() } /// A font where every glyph is 500 units wide (so a 10 pt glyph advances 5 pt). @@ -252,7 +232,7 @@ fn vertical_writing_text_under_a_mark_fails_closed() { #[test] fn images_are_removed_or_have_their_pixels_cleared() { // Image 1 is fully covered; image 2 (4×1 gray, all white) is half covered. - let img = stream("/Type /XObject /Subtype /Image /Width 4 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", &[255, 255, 255, 255]); + let img = stream("/Type /XObject /Subtype /Image /Width 4 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", [255, 255, 255, 255]); let src = b"q 10 0 0 10 10 10 cm /Im1 Do Q q 40 0 0 10 100 100 cm /Im2 Do Q"; let mut doc = one_page(src, "/XObject << /Im1 7 0 R /Im2 7 0 R >>", vec![img]); mark(&mut doc, 0, &[[5.0, 5.0, 25.0, 25.0], [95.0, 95.0, 120.0, 115.0]], ""); diff --git a/crates/testkit/Cargo.toml b/crates/testkit/Cargo.toml new file mode 100644 index 000000000..4237f341b --- /dev/null +++ b/crates/testkit/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "pdfcraft-testkit" +description = "Synthetic PDF fixtures for PdfCraft's tests: one way to write the bytes, so the tests differ only where they mean to" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] diff --git a/crates/testkit/README.md b/crates/testkit/README.md new file mode 100644 index 000000000..44388b1b2 --- /dev/null +++ b/crates/testkit/README.md @@ -0,0 +1,57 @@ +# pdfcraft-testkit + +Synthetic PDF fixtures: **one way to write the bytes, so tests differ only where they mean to.** + +- **Layer:** test support (`Class::Testkit` in `xtask/src/layers.rs`). A dev-dependency only, never + compiled into the product. +- **No workspace dependencies**, deliberately. The crates at the bottom of the layering (`cos`, + `geom`, `filters`, `crypt`, `arlington`) can use it in their own tests without a cycle, which is + also why it hands back bytes rather than a parsed `Document`. + +## Why it exists + +55 files hand-rolled the same serialiser — number the objects, record each one's offset, emit a +classic cross-reference table and a trailer — **111 times over**. The object *bodies* are properly +the business of the crate under test. The envelope around them is not, and every copy is a place +for a fixture to be quietly wrong in a way that makes a test prove less than it appears to. + +## API + +```rust +use pdfcraft_testkit::Pdf; + +let bytes = Pdf::new() + .version("1.4") // default 1.7 + .obj("<< /Type /Catalog /Pages 2 0 R >>") // becomes 1 0 R + .obj("<< /Type /Pages /Kids [3 0 R] /Count 1 >>") + .obj("<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>") + .stream("", b"BT /F1 12 Tf (hi) Tj ET") // /Length computed + .trailer_entry("/Info 5 0 R") + .build(); +``` + +Objects are numbered from 1 in the order they are added, all at generation 0; the trailer always +names `1 0 R` as `/Root` and carries the right `/Size`. `next_obj()` gives the number the next +object will get, for forward references. `stream_obj(dict, data)` builds a stream body on its own, +for callers that assemble their object list first. + +## Byte-identical by construction + +`the_builder_is_byte_identical_to_the_serialiser_it_replaces` keeps a verbatim copy of the +hand-rolled serialiser and asserts the builder agrees with it, across versions and trailer +variations. Migrating a crate must not change a single byte of its fixtures — otherwise the test +that fixture feeds silently starts testing something else. + +The one intended difference: an empty stream dictionary gives `<< /Length N >>` rather than the +doubled space `<< /Length N >>` that some copies produced. Parsers accept both. + +## Migration status + +Three crates are migrated (`cos`, `measure`, `redact`), taking the count from 111 copies in 55 +files to **108 in 53**. The rest are follow-ups, deliberately not done in one sweep: `ui-egui` +alone accounts for 27 of those files and is the most contested area in the repository, so a single +large mechanical change there would rot before it landed. + +Not every copy should move. A test that is *about* a malformed or unusual cross-reference table +needs to write its own bytes; this builder is for the fixtures that are merely scaffolding for +something else. diff --git a/crates/testkit/src/lib.rs b/crates/testkit/src/lib.rs new file mode 100644 index 000000000..c9f5e55ef --- /dev/null +++ b/crates/testkit/src/lib.rs @@ -0,0 +1,253 @@ +//! Synthetic PDF fixtures: one way to write the bytes, so tests differ only where they mean to. +//! +//! 55 files across the workspace hand-rolled the same serialiser — number the objects, record each +//! one's offset, emit a classic cross-reference table and a trailer — 111 times over. The object +//! *bodies* are properly the business of the crate under test; the envelope around them is not, +//! and every copy of it is a place for a fixture to be subtly wrong in a way that makes a test +//! prove less than it looks like it proves. +//! +//! [`Pdf`] writes that envelope. It is deliberately dependency-free, so the crates at the bottom of +//! the layering (`cos`, `geom`, `filters`, `crypt`) can use it in their own tests without a cycle, +//! and it hands back bytes rather than a parsed document for the same reason. +//! +//! ``` +//! use pdfcraft_testkit::Pdf; +//! let bytes = Pdf::new() +//! .obj("<< /Type /Catalog /Pages 2 0 R >>") +//! .obj("<< /Type /Pages /Kids [3 0 R] /Count 1 >>") +//! .obj("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 300] /Contents 4 0 R >>") +//! .stream("", b"BT /F1 12 Tf (hi) Tj ET") +//! .build(); +//! assert!(bytes.starts_with(b"%PDF-1.7\n")); +//! assert!(bytes.ends_with(b"%%EOF\n")); +//! ``` +//! +//! This crate is test scaffolding (`Class::Testkit` in `xtask/src/layers.rs`): a dev-dependency +//! only, never compiled into the product. + +/// A small PDF with a classic cross-reference table, built one object at a time. +/// +/// Objects are numbered from 1 in the order they are added, all at generation 0, so the first +/// object added is `1 0 R`. The trailer names `1 0 R` as `/Root`, which is what every caller of +/// this builder wants; [`Pdf::trailer_entry`] adds the rest. +#[derive(Debug, Clone)] +pub struct Pdf { + version: String, + objects: Vec>, + trailer: Vec, +} + +impl Default for Pdf { + fn default() -> Self { + Self::new() + } +} + +impl Pdf { + /// An empty PDF 1.7 file. Add objects with [`Pdf::obj`]. + pub fn new() -> Self { + Self { version: "1.7".to_owned(), objects: Vec::new(), trailer: Vec::new() } + } + + /// Set the header version, as it appears after `%PDF-` (for example `"1.4"`). + #[must_use] + pub fn version(mut self, version: &str) -> Self { + self.version = version.to_owned(); + self + } + + /// Append one object body — everything between `N 0 obj` and `endobj`. + #[must_use] + pub fn obj(mut self, body: impl AsRef<[u8]>) -> Self { + self.objects.push(body.as_ref().to_vec()); + self + } + + /// Append several object bodies, in order. + #[must_use] + pub fn objs, T: AsRef<[u8]>>(mut self, bodies: I) -> Self { + self.objects.extend(bodies.into_iter().map(|b| b.as_ref().to_vec())); + self + } + + /// Append a stream object: `dict` holds any entries besides `/Length`, which is computed. + #[must_use] + pub fn stream(self, dict: &str, data: impl AsRef<[u8]>) -> Self { + self.obj(stream_obj(dict, data)) + } + + /// Add an entry to the trailer dictionary, such as `"/Info 9 0 R"`. `/Size` and `/Root` are + /// always written. + #[must_use] + pub fn trailer_entry(mut self, entry: &str) -> Self { + self.trailer.push(entry.to_owned()); + self + } + + /// The number the next object added will get, so a caller can refer forward to it. + /// + /// ``` + /// # use pdfcraft_testkit::Pdf; + /// let mut p = Pdf::new().obj("<< /Type /Catalog /Pages 2 0 R >>"); + /// assert_eq!(p.next_obj(), 2); + /// ``` + pub fn next_obj(&self) -> usize { + self.objects.len().saturating_add(1) + } + + /// Serialise the file. + pub fn build(&self) -> Vec { + let mut out = format!("%PDF-{}\n", self.version).into_bytes(); + let mut offsets = Vec::with_capacity(self.objects.len()); + for (i, body) in self.objects.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i.saturating_add(1)).as_bytes()); + out.extend_from_slice(body); + out.extend_from_slice(b"\nendobj\n"); + } + let startxref = out.len(); + let size = self.objects.len().saturating_add(1); + out.extend_from_slice(format!("xref\n0 {size}\n0000000000 65535 f \n").as_bytes()); + for offset in offsets { + out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + let extra = self.trailer.iter().map(|e| format!(" {e}")).collect::(); + out.extend_from_slice(format!("trailer\n<< /Size {size} /Root 1 0 R{extra} >>\nstartxref\n{startxref}\n%%EOF\n").as_bytes()); + out + } +} + +/// A stream object body with `/Length` filled in, for callers assembling object lists themselves. +/// +/// `dict` holds any entries besides `/Length`; an empty one gives `<< /Length N >>` rather than +/// the doubled space the hand-rolled copies produced, which parsers accept but readers trip over. +pub fn stream_obj(dict: &str, data: impl AsRef<[u8]>) -> Vec { + let data = data.as_ref(); + let dict = dict.trim(); + let sep = if dict.is_empty() { "" } else { " " }; + let mut out = format!("<<{sep}{dict} /Length {} >>\nstream\n", data.len()).into_bytes(); + out.extend_from_slice(data); + out.extend_from_slice(b"\nendstream"); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The serialiser the workspace had 111 copies of, kept here verbatim as the thing [`Pdf`] has + /// to agree with. Migrating a crate to the builder must not change a single byte of its + /// fixtures, or the test that fixture feeds silently starts testing something else. + fn hand_rolled(version: &str, objs: &[Vec], trailer_extra: &str) -> Vec { + let mut out = format!("%PDF-{version}\n").into_bytes(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + 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 x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R{trailer_extra} >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + out + } + + fn sample_objects() -> Vec> { + vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>".to_vec(), + stream_obj("", b"BT /F1 24 Tf 20 150 Td (Page 1) Tj ET"), + ] + } + + #[test] + fn the_builder_is_byte_identical_to_the_serialiser_it_replaces() { + let objs = sample_objects(); + for version in ["1.4", "1.7", "2.0"] { + let built = Pdf::new().version(version).objs(objs.clone()).build(); + assert_eq!(built, hand_rolled(version, &objs, ""), "version {version}"); + } + // ...including the trailer entries the copies varied on. + let built = Pdf::new().objs(objs.clone()).trailer_entry("/Info 5 0 R").build(); + assert_eq!(built, hand_rolled("1.7", &objs, " /Info 5 0 R")); + } + + /// Offsets must point at the `N 0 obj` that starts each object, counted from the file's first + /// byte. A fixture whose table is wrong tests the repair path, not the path it meant to. + #[test] + fn every_offset_in_the_table_points_at_its_object() { + let bytes = Pdf::new().objs(sample_objects()).build(); + let text = String::from_utf8_lossy(&bytes); + let table = text.split("xref\n0 ").nth(1).expect("an xref table"); + let rows: Vec<&str> = table.lines().skip(1).take_while(|l| l.ends_with(" n ") || l.ends_with(" f ")).collect(); + assert_eq!(rows.len(), 5, "free entry plus four objects: {rows:?}"); + for (i, row) in rows.iter().enumerate().skip(1) { + let offset: usize = row.split_whitespace().next().and_then(|n| n.parse().ok()).expect("an offset"); + let expected = format!("{i} 0 obj\n"); + assert!(bytes.get(offset..).is_some_and(|rest| rest.starts_with(expected.as_bytes())), "object {i} at {offset}"); + } + } + + /// `/Size` is one more than the object count (object 0 is the free head), and `startxref` + /// points at the table. + #[test] + fn the_trailer_agrees_with_the_body() { + let bytes = Pdf::new().objs(sample_objects()).build(); + let text = String::from_utf8_lossy(&bytes); + assert!(text.contains("/Size 5"), "{text}"); + let startxref: usize = text.rsplit("startxref\n").next().and_then(|t| t.lines().next()).and_then(|n| n.parse().ok()).expect("startxref"); + assert!(bytes.get(startxref..).is_some_and(|rest| rest.starts_with(b"xref\n")), "startxref {startxref}"); + } + + #[test] + fn stream_length_counts_the_data_not_the_keywords() { + let obj = stream_obj("/Filter /FlateDecode", b"abc"); + assert_eq!(obj, b"<< /Filter /FlateDecode /Length 3 >>\nstream\nabc\nendstream".to_vec()); + // Binary data, including NUL and bytes that are not UTF-8, survives untouched. + let data = [0u8, 0xFF, b'\n', 0x80]; + let obj = stream_obj("", data); + assert!(obj.ends_with(b"stream\n\0\xff\n\x80\nendstream"), "{obj:?}"); + // An empty dict gives one space, not two. + assert!(obj.starts_with(b"<< /Length 4 >>"), "{obj:?}"); + assert_eq!(stream_obj(" /Type /XObject ", b""), stream_obj("/Type /XObject", b"")); + } + + /// An empty file is still a parseable shape rather than something that panics or truncates. + #[test] + fn a_file_with_no_objects_is_still_well_formed() { + let bytes = Pdf::new().build(); + let text = String::from_utf8_lossy(&bytes); + assert!(text.starts_with("%PDF-1.7\n"), "{text}"); + assert!(text.contains("xref\n0 1\n0000000000 65535 f \n"), "{text}"); + assert!(text.contains("/Size 1 /Root 1 0 R"), "{text}"); + assert!(text.ends_with("%%EOF\n"), "{text}"); + } + + #[test] + fn next_obj_tracks_what_has_been_added() { + let p = Pdf::new(); + assert_eq!(p.next_obj(), 1); + let p = p.obj("<< >>").obj("<< >>"); + assert_eq!(p.next_obj(), 3); + // Forward references are the reason this exists: the page can name a stream not yet added. + let n = p.next_obj(); + let bytes = p.obj(format!("<< /Contents {n} 0 R >>")).build(); + assert!(String::from_utf8_lossy(&bytes).contains("/Contents 3 0 R")); + } + + /// Hostile and awkward object bodies must not panic or corrupt the offsets. + #[test] + fn odd_object_bodies_keep_the_table_honest() { + let bodies: Vec> = vec![Vec::new(), b"\n".to_vec(), vec![0u8; 1024], "<< /T (\u{1F4A5}) >>".as_bytes().to_vec(), b"endobj".to_vec()]; + let bytes = Pdf::new().objs(bodies.clone()).build(); + assert_eq!(bytes, hand_rolled("1.7", &bodies, "")); + // The offsets still land on their objects even when a body contains `endobj` itself. + let text = String::from_utf8_lossy(&bytes); + assert!(text.contains("/Size 6"), "{text}"); + } +}