mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 10:08:00 -06:00
feat(testkit): one way to write a synthetic PDF fixture
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. `pdfcraft-testkit` holds that envelope. It has no workspace dependencies on purpose, so the crates at the bottom of the layering can use it without a cycle, and it returns bytes rather than a parsed document for the same reason. The builder is byte-identical to the serialiser it replaces, asserted against a verbatim copy of the old code across versions and trailer variations, because a migration that changes a fixture's bytes silently changes what the test using it proves. Watched failing by breaking the free-entry generation: three tests caught it. The one intended difference is that an empty stream dictionary gives `<< /Length N >>` rather than a doubled space. Migrates cos, measure and redact: 111 copies in 55 files becomes 108 in 53. The rest are follow-ups rather than one sweep -- ui-egui alone is 27 of those files and is the most contested area in the repository. Some copies should stay: a test about a malformed cross-reference table needs to write its own bytes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
0754740fd9
commit
50629eb6bf
Generated
+7
@@ -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"
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
+11
-22
@@ -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<u8> {
|
||||
let mut objs: Vec<String> = vec!["<< /Type /Catalog /Pages 2 0 R >>".into()];
|
||||
let kids: Vec<String> = (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<u8> {
|
||||
|
||||
@@ -15,3 +15,6 @@ pdfcraft-content = { workspace = true }
|
||||
pdfcraft-annot = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
pdfcraft-testkit = { workspace = true }
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -19,3 +19,4 @@ pdfcraft-annot = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
pdfcraft-testkit = { workspace = true }
|
||||
|
||||
@@ -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<u8> {
|
||||
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<Vec<u8>>) -> 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]], "");
|
||||
|
||||
@@ -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]
|
||||
@@ -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.
|
||||
@@ -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<Vec<u8>>,
|
||||
trailer: Vec<String>,
|
||||
}
|
||||
|
||||
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<I: IntoIterator<Item = T>, 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<u8> {
|
||||
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::<String>();
|
||||
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<u8> {
|
||||
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<u8>], trailer_extra: &str) -> Vec<u8> {
|
||||
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<u8>> {
|
||||
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<u8>> = 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}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user