Files
pdfcraft/crates/render/src/inspect/tests.rs
T
Michael Higgins f51ce3cfcc fix(render): bound the objects lopdf's fallback parses from object streams and arrays (#5083)
## What this changes

lopdf's fallback loader (used by `inspect` when the lazy reader fails, and always by `attachment_data`) parsed every object stream eagerly, capping only decoded bytes. A stream of `0 0 0 …` inside the 256 MiB limit built ~120M `Object`s (10+ GB) and aborted. This bounds what it builds:

- **Per top-level object:** at most 4,194,304 objects (`parser::MAX_OBJECT_NODES`, the same as cos's `MAX_NODES`), counting every array element and dictionary value at any depth. One budget is threaded through `array` / `_dictionary` / `_direct_object`. An object is charged once it is built, so failed `alt` branches cost nothing, and an exhausted budget is a nom `Failure`.
- **Per object stream:** at most 1,000,000 index entries (cos's `/N` clamp), and the index list is no longer allocated from the whole block.
- **Per load:** at most 8,388,608 objects across all object streams, in an atomic `NodeBudget` shared by rayon workers. A member reserves up to its own byte length (every object takes at least one byte) and refunds what it doesn't use, so parallel members can't hold the budget between them. Once the budget is spent, the remaining members are skipped with a warning.
- **`convert` in `crates/render/src/structure.rs`:** the depth limit is now 128, one level per array or dictionary, matching cos's parser. Before, it was 100 with two levels per dictionary, so ~51 nested dictionaries alone forced the lopdf fallback.

Logged as lopdf patch (7) in `vendor/README.md`.

Fixes #5083

## How you verified it

`cargo fmt --check` on `pdfcraft-render` and `cargo clippy -p pdfcraft-render --all-targets -- -D warnings` (which compiles the vendored lopdf). Regression tests in `crates/render/src/inspect/tests.rs`, where lopdf patches are tested in-tree (the vendored lopdf's own tests need its missing `assets/example.pdf`):

- `object_stream_members_over_the_node_cap_are_skipped` (croquete1): a member that is one array of 4.3M elements is skipped at the per-object cap; a small member is kept.
- `object_stream_index_entries_past_the_clamp_are_ignored`: of 1,000,001 distinct members, the one past the 1,000,000-entry clamp is never built.
- `object_streams_of_one_load_share_their_node_budget`, `object_streams_loaded_in_parallel_share_their_node_budget` and `a_malformed_object_stream_member_refunds_its_reservation` (Harrison-Booth): a small shared `NodeBudget` bounds the members of several streams, serially and across threads, and a malformed member refunds what it didn't build. `NodeBudget` and `ObjectStream::new_within` are now public for this.

- [x] `cargo fmt --check`
- [x] clippy with `-D warnings` on the crates I changed
- [x] tests pass on the crates I changed (`cargo test -p pdfcraft-render --lib object_stream`: 9 passed, including the five new tests)

## Never-crash

- [x] No new `unwrap`/`expect`/`panic!`/`unreachable!`/`todo!` outside tests
- [x] No indexing or slicing with input-derived positions; the budget uses `checked_sub` and a `min`-bounded reservation; index entries and objects built are capped; nesting stays bounded (128 in `convert`)
- [x] No `unsafe`
- [x] The abort this fixes has synthetic regression tests (above)

## Provenance

- [x] No assets, fonts or copied code; the limits follow `pdfcraft_cos`'s own caps

## Security

- [x] No change to `--root`, MCP, the control channel or network access

## Parity and reach

- Not applicable: an internal robustness fix to the fallback loader, with no user-facing feature or parity entry. The vendored-patch log (`vendor/README.md`, lopdf (7)) is updated.

## Anything a reviewer should look at first

- `vendor/README.md` now lists the tests above for patch (7).
- Ordinary (not compressed) indirect objects are capped at 4M objects each but aren't charged to the per-load budget; their total is bounded only by file size.
- `get_compressed_object` still decodes and re-parses the whole stream per lookup (pre-existing); it now gets one object's budget.
- Objects nested 101–128 deep now reach the Inspector through the lazy reader rather than being skipped by the fallback. The Inspector's own walkers cap depth at 32 or less.


<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Bug Fixes**
  * Added limits to PDF object parsing and object-stream processing. Loading now parses only selected members, while lookups parse only the requested member. Members that exceed available limits are skipped in a deterministic order.
  * Increased the supported nesting depth for dictionary and object conversion, allowing more deeply nested content to be processed.
  * Documented that conversion failures can trigger eager loading as a fallback.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-11-11 07:48:14 +01:00

2924 lines
152 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#[test]
fn png_predictor_rows_longer_than_the_data_are_refused_before_allocating() {
// Vendored lopdf patch: a fuzzed `/Columns 4294967295` allocated two 4 GiB rows before
// reading any data. A row that cannot fit in the data is refused up front.
let e = lopdf::filters::png::decode_frame(&[2, 0, 0, 0], 1, 64 << 20).unwrap_err();
assert!(e.to_string().contains("longer than the data"), "{e}");
// A real frame still decodes: two 3-byte rows, the second Up-filtered.
assert_eq!(lopdf::filters::png::decode_frame(&[0, 1, 2, 3, 2, 1, 1, 1], 1, 3).unwrap(), [1, 2, 3, 2, 3, 4]);
}
#[test]
fn empty_png_predictor_frames_do_not_allocate_rows() {
// usize::MAX cannot be reserved, so the old code safely errors before allocating.
// An empty frame needs no rows regardless of the declared width.
assert!(lopdf::filters::png::decode_frame(&[], 1, usize::MAX).unwrap().is_empty());
}
fn tiff_predictor_stream(data: Vec<u8>, columns: i64, colors: i64, bits: i64) -> lopdf::Stream {
let mut params = lopdf::Dictionary::new();
params.set("Predictor", 2i64);
params.set("Columns", columns);
params.set("Colors", colors);
params.set("BitsPerComponent", bits);
let mut dict = lopdf::Dictionary::new();
dict.set("DecodeParms", params);
let mut stream = lopdf::Stream::new(dict, data);
stream.compress().unwrap();
assert_eq!(stream.dict.get(b"Filter").unwrap().as_name().unwrap(), b"FlateDecode");
stream
}
#[test]
fn tiff_subbyte_predictor_row_width_overflow_is_refused() {
// Each multiplication overflows before any scratch allocation in the old debug build.
for bits in [1, 2, 4] {
let stream = tiff_predictor_stream(vec![0; 64], i64::MAX, 3, bits);
assert!(
matches!(stream.decompressed_content_with_limit(64), Err(lopdf::Error::Decompress(lopdf::DecompressError::Predictor(_)))),
"{bits}-bit row"
);
}
}
#[test]
fn tiff_subbyte_predictor_scratch_is_bounded_by_available_samples() {
// 1/2-bit row widths fit usize on 32- and 64-bit hosts; Colors previously made Vec<u16>
// reject the capacity before allocating. A partial row has no preceding pixel.
for bits in [1, 2] {
let data = vec![0b1010_0110; 64];
let stream = tiff_predictor_stream(data.clone(), 1, i64::try_from(isize::MAX).unwrap(), bits);
assert_eq!(stream.decompressed_content_with_limit(64).unwrap(), data, "{bits}-bit row");
}
}
#[test]
fn tiff_subbyte_predictors_keep_components_rows_and_padding() {
// Repeated tiny rows compress through the public Stream API. Rows remain independent,
// differences wrap at each component depth, and trailing padding bits survive.
for (columns, colors, bits, encoded, decoded) in [
(16, 1, 1, vec![255, 170, 8, 255], vec![170, 204, 15, 85]),
(6, 1, 2, vec![85, 179], vec![108, 147]),
(3, 1, 4, vec![25, 16], vec![26, 176]),
(2, 2, 4, vec![18, 34], vec![18, 52]),
] {
let stream = tiff_predictor_stream(encoded.repeat(32), columns, colors, bits);
assert_eq!(stream.decompressed_content_with_limit(128).unwrap(), decoded.repeat(32), "{bits}-bit, {colors} colours");
}
}
// Xref-stream fixtures exercise indirect /Length lookup during document loading: stream 2's /Length is `3 0 R`,
// `containers` lists compressed (id, container, index) entries and `objects` the other objects in the file.
fn compressed_length_pdf(containers: &[(u32, u32, u16)], objects: &[(u32, &[u8])]) -> Vec<u8> {
let mut pdf = b"%PDF-1.7\n".to_vec();
let ids = containers.iter().flat_map(|&(id, container, _)| [id, container]).chain(objects.iter().map(|&(id, _)| id));
let xref_id = ids.max().unwrap_or(3) + 1;
let mut records = vec![[0u8; 7]; (xref_id + 1) as usize];
records[0] = [0, 0, 0, 0, 0, 255, 255];
let mut append = |id: u32, body: &[u8]| {
records[id as usize][0] = 1;
records[id as usize][1..5].copy_from_slice(&(pdf.len() as u32).to_be_bytes());
pdf.extend_from_slice(format!("{id} 0 obj\n").as_bytes());
pdf.extend_from_slice(body);
pdf.extend_from_slice(b"\nendobj\n");
};
append(1, b"<< /Type /Catalog >>");
append(2, b"<< /Length 3 0 R >>\nstream\nhello\nendstream");
for &(id, body) in objects {
append(id, body);
}
for &(id, container, index) in containers {
records[id as usize][0] = 2;
records[id as usize][1..5].copy_from_slice(&container.to_be_bytes());
records[id as usize][5..7].copy_from_slice(&index.to_be_bytes());
}
let xref_offset = pdf.len();
records[xref_id as usize][0] = 1;
records[xref_id as usize][1..5].copy_from_slice(&(xref_offset as u32).to_be_bytes());
pdf.extend_from_slice(
format!("{xref_id} 0 obj\n<< /Type /XRef /Size {} /Root 1 0 R /W [1 4 2] /Length {} >>\nstream\n", records.len(), records.len() * 7)
.as_bytes(),
);
for record in records {
pdf.extend_from_slice(&record);
}
pdf.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{xref_offset}\n%%EOF\n").as_bytes());
pdf
}
// Stream 2's /Length starts a chain of `hops` indirect /Length lookups, each naming a new object; only the last
// link's /Length is direct. Compressed: link k is the integer 3 + 2k (the length it measures) alone in the object
// stream 4 + 2k, whose own /Length is link k + 1. Plain: link k is the stream 3 + k, whose /Length is link k + 1.
fn length_chain_pdf(hops: u32, compressed: bool) -> Vec<u8> {
let length = |hop: u32, next: u32, data: &str| if hop + 1 < hops { format!("{next} 0 R") } else { data.len().to_string() };
let (mut containers, mut objects) = (Vec::new(), Vec::new());
let mut value = 5; // the length of stream 2's data, "hello"
for hop in 0..hops {
if compressed {
let (id, container) = (3 + 2 * hop, 4 + 2 * hop);
let index = format!("{id} 0 ");
let data = format!("{index}{value}");
let dict = format!("/Type /ObjStm /N 1 /First {} /Length {}", index.len(), length(hop, id + 2, &data));
objects.push((container, format!("<< {dict} >>\nstream\n{data}\nendstream")));
containers.push((id, container, 0));
value = data.len();
} else {
objects.push((3 + hop, format!("<< /Length {} >>\nstream\n\nendstream", length(hop, 4 + hop, ""))));
}
}
let objects: Vec<_> = objects.iter().map(|(id, body)| (*id, body.as_bytes())).collect();
compressed_length_pdf(&containers, &objects)
}
// Lenient loading keeps a document whose stream 2 has an unresolvable /Length: the catalog, and stream 2's
// dictionary without its data.
fn load_without_stream_2_data(pdf: &[u8]) -> Document {
let doc = Document::load_mem(pdf).unwrap();
assert!(doc.catalog().unwrap().has_type(b"Catalog"));
assert!(doc.get_object((2, 0)).unwrap().as_stream().unwrap().content.is_empty());
doc
}
#[test]
fn compressed_object_self_container_does_not_recurse() {
let doc = load_without_stream_2_data(&compressed_length_pdf(&[(3, 3, 0)], &[]));
assert!(doc.get_object((3, 0)).is_err());
}
#[test]
fn compressed_object_mutual_containers_do_not_recurse() {
let doc = load_without_stream_2_data(&compressed_length_pdf(&[(3, 4, 0), (4, 3, 0)], &[]));
assert!(doc.get_object((3, 0)).is_err());
assert!(doc.get_object((4, 0)).is_err());
}
#[test]
fn object_stream_length_cycle_does_not_recurse() {
let pdf = compressed_length_pdf(&[(3, 4, 0)], &[(4, b"<< /Type /ObjStm /N 1 /First 4 /Length 3 0 R >>\nstream\n3 0 5\nendstream")]);
let doc = load_without_stream_2_data(&pdf);
// The container's /Length is the object it holds: the container is kept without data, so 3 never loads.
assert!(doc.get_object((4, 0)).unwrap().as_stream().unwrap().content.is_empty());
assert!(doc.get_object((3, 0)).is_err());
}
#[test]
fn compressed_object_container_chain_does_not_recurse() {
let containers: Vec<_> = (3..100_003).map(|id| (id, id + 1, 0)).collect();
let pdf = compressed_length_pdf(&containers, &[(100_003, b"<< /Type /ObjStm /N 1 /First 4 /Length 5 >>\nstream\n3 0 5\nendstream")]);
let doc = load_without_stream_2_data(&pdf);
// The object stream in the file loads, but the xref puts 3 in the compressed container 4, which is refused.
assert_eq!(doc.get_object((100_003, 0)).unwrap().as_stream().unwrap().content, b"3 0 5");
assert!(doc.get_object((3, 0)).is_err());
}
#[test]
fn compressed_objects_and_indirect_stream_length_still_load() {
let pdf =
compressed_length_pdf(&[(3, 4, 0), (5, 4, 1)], &[(4, b"<< /Type /ObjStm /N 2 /First 8 /Length 17 >>\nstream\n3 0 5 2 5 (value)\nendstream")]);
let doc = Document::load_mem_with_options(&pdf, LoadOptions { strict: true, ..Default::default() }).unwrap();
assert_eq!(doc.get_object((3, 0)).unwrap().as_i64().unwrap(), 5);
assert_eq!(doc.get_object((5, 0)).unwrap().as_str().unwrap(), b"value");
assert_eq!(doc.get_object((2, 0)).unwrap().as_stream().unwrap().content, b"hello");
}
#[test]
fn compressed_length_in_a_container_with_an_indirect_length_still_loads() {
// Two hops, as in a valid file: stream 2's /Length (3) is compressed in container 4, whose own /Length (5) is
// a plain indirect integer.
let pdf = compressed_length_pdf(&[(3, 4, 0)], &[(4, b"<< /Type /ObjStm /N 1 /First 4 /Length 5 0 R >>\nstream\n3 0 5\nendstream"), (5, b"5")]);
let doc = Document::load_mem_with_options(&pdf, LoadOptions { strict: true, ..Default::default() }).unwrap();
assert_eq!(doc.get_object((3, 0)).unwrap().as_i64().unwrap(), 5);
assert_eq!(doc.get_object((5, 0)).unwrap().as_i64().unwrap(), 5);
assert_eq!(doc.get_object((2, 0)).unwrap().as_stream().unwrap().content, b"hello");
}
#[test]
fn compressed_length_chain_does_not_recurse() {
// Every container is an ordinary object in the file and every hop names a new object, so neither the
// container check nor the cycle history stops the chain; each hop recursed once more (a 512 KiB stack
// overflowed within 150 hops). Lookups past the cap fail: the first link never loads, while the last one,
// next to the direct /Length, does.
let hops = 5_000;
let doc = load_without_stream_2_data(&length_chain_pdf(hops, true));
assert!(doc.get_object((3, 0)).is_err());
assert!(doc.get_object((3 + 2 * (hops - 1), 0)).unwrap().as_i64().is_ok());
// The cap itself: loading link j reads its container's /Length, a chain of 2 * (hops - 1 - j) lookups, and
// 16 are allowed, so the 9th link from the end loads and the 10th does not.
assert!(doc.get_object((3 + 2 * (hops - 9), 0)).is_ok());
assert!(doc.get_object((3 + 2 * (hops - 10), 0)).is_err());
}
#[test]
fn plain_stream_length_chain_does_not_recurse() {
// The same recursion without object streams: each stream's /Length names the next stream (a 512 KiB stack
// overflowed within 200 hops). A stream is no length, so every link is kept as a stream without data.
let hops = 5_000;
let doc = load_without_stream_2_data(&length_chain_pdf(hops, false));
assert!((3..3 + hops).all(|id| doc.get_object((id, 0)).is_ok_and(|link| link.as_stream().is_ok_and(|s| s.content.is_empty()))));
}
fn indexed_object_stream(pairs: &[(u32, usize)], members: &[u8]) -> lopdf::Stream {
let mut content = Vec::new();
for &(id, offset) in pairs {
content.extend_from_slice(format!("{id} {offset} ").as_bytes());
}
let mut dict = Dictionary::new();
dict.set("Type", "ObjStm");
dict.set("N", pairs.len() as i64);
dict.set("First", content.len() as i64);
content.extend_from_slice(members);
lopdf::Stream::new(dict, content)
}
#[test]
fn object_stream_member_extents_bound_owned_strings() {
let mut members = vec![b'('; 32];
members.extend(vec![b'x'; 1 << 20]);
members.extend([b')'; 32]);
// Put the innermost (complete) string first in index order, then overlapping starts
// and duplicates. Only this first member at offset 31 may own the large string.
let mut pairs = vec![(100, 31)];
pairs.extend((0..31).map(|offset| (offset as u32 + 1, offset)));
pairs.extend([(200, 31), (201, 31)]);
let stream = indexed_object_stream(&pairs, &members);
let parsed = lopdf::ObjectStream::new_with_limit(&stream, Some(2 << 20)).unwrap();
assert_eq!(parsed.objects.get(&(100, 0)).unwrap().as_str().unwrap().len(), 1 << 20);
let total: usize = parsed.objects.values().filter_map(|object| object.as_str().ok()).map(<[u8]>::len).sum();
assert!(total <= stream.content.len(), "owned {total} string bytes from {} decoded bytes", stream.content.len());
assert!(!parsed.objects.contains_key(&(200, 0)));
assert!(!parsed.objects.contains_key(&(201, 0)));
}
/// Members are read at their indexed offsets, in any order and with any whitespace around
/// them; a number listed twice keeps its last entry.
#[test]
fn object_stream_member_extents_preserve_index_order_and_whitespace() {
let members = b" \n42\n /Name \n[true 7]\n << /Key (value) >>\n (last)";
// Unsorted offsets, and object 9 appears twice: its last index entry still wins.
let pairs = [(9, 0), (12, 21), (10, 5), (11, 12), (9, 41)];
let parsed = lopdf::ObjectStream::new(&indexed_object_stream(&pairs, members)).unwrap();
assert_eq!(parsed.objects.len(), 4);
assert_eq!(parsed.objects.get(&(9, 0)).unwrap().as_str().unwrap(), b"last");
assert_eq!(parsed.objects.get(&(10, 0)).unwrap().as_name().unwrap(), b"Name");
assert_eq!(parsed.objects.get(&(11, 0)).unwrap().as_array().unwrap(), &[Object::Boolean(true), Object::Integer(7)]);
assert_eq!(parsed.objects.get(&(12, 0)).unwrap().as_dict().unwrap().get(b"Key").unwrap().as_str().unwrap(), b"value");
}
/// lopdf patch (7), #5083: a decoded object-stream member could build any number of objects
/// (`0 0 0 ...` inside the decompression limit built ~120M and aborted). A member now builds at
/// most the per-object node cap (1 << 22), so one array with more elements than that is skipped
/// while a small member is kept. Without the node charge in the vendored parser the oversize
/// array builds in full, so object 11 is present and the last assertion fails.
#[test]
fn object_stream_members_over_the_node_cap_are_skipped() {
let small = b"[ 1 2 3 ]";
let mut big = vec![b'['];
for _ in 0..4_300_000 {
big.extend_from_slice(b"0 ");
}
big.push(b']');
let mut members = small.to_vec();
let big_off = members.len();
members.extend_from_slice(&big);
let stream = indexed_object_stream(&[(10, 0), (11, big_off)], &members);
let parsed = lopdf::ObjectStream::new_with_limit(&stream, Some(16 << 20)).unwrap();
assert!(parsed.objects.contains_key(&(10, 0)), "the small member is kept");
assert!(!parsed.objects.contains_key(&(11, 0)), "the oversize member is skipped");
}
/// lopdf patch (7), #5083: an object stream reads at most 1,000,000 index entries (cos's `/N`
/// clamp), so a huge index can't allocate an entry list or parse a member per entry. Of
/// 1,000,001 distinct one-byte members, the last is never built.
#[test]
fn object_stream_index_entries_past_the_clamp_are_ignored() {
let count = 1_000_001_u32;
let mut members = Vec::with_capacity(2 * count as usize);
let pairs: Vec<(u32, usize)> = (1..=count)
.map(|id| {
let at = members.len();
members.extend_from_slice(b"0 ");
(id, at)
})
.collect();
let parsed = lopdf::ObjectStream::new_with_limit(&indexed_object_stream(&pairs, &members), None).unwrap();
assert_eq!(parsed.objects.len(), 1_000_000);
assert!(parsed.objects.contains_key(&(1_000_000, 0)));
assert!(!parsed.objects.contains_key(&(count, 0)), "the entry past the clamp is ignored");
}
/// Six object streams, each holding one nested member of seven objects.
fn nested_object_streams() -> Vec<lopdf::Stream> {
(0..6).map(|i| indexed_object_stream(&[(100 + i, 0)], b"<< /A [1 [2]] /B << /C 3 >> >>")).collect()
}
/// lopdf patch (7), #5083: one load's object streams share a single node budget (the load-wide
/// `MAX_LOAD_NODES`, here 12 nodes so the test stays small), so later streams are skipped once
/// earlier ones spent it. A stream given a budget of its own would load all six.
#[test]
fn object_streams_of_one_load_share_their_node_budget() {
let streams = nested_object_streams();
let budget = lopdf::NodeBudget::new(12);
let loaded: usize = streams.iter().map(|s| lopdf::ObjectStream::new_within(s, None, &budget).unwrap().objects.len()).sum();
assert!(loaded > 0);
assert!(loaded < streams.len(), "the budget is shared by all streams");
assert!(budget.spent());
}
/// The shared budget holds across threads too, as rayon's workers load object streams in
/// parallel: members reserve their share before parsing, so together they can't overshoot.
#[test]
fn object_streams_loaded_in_parallel_share_their_node_budget() {
let streams = nested_object_streams();
let budget = &lopdf::NodeBudget::new(12);
let loaded: usize = std::thread::scope(|scope| {
let workers: Vec<_> =
streams.iter().map(|s| scope.spawn(move || lopdf::ObjectStream::new_within(s, None, budget).unwrap().objects.len())).collect();
workers.into_iter().map(|w| w.join().unwrap()).sum()
});
assert!(loaded > 0);
assert!(loaded < streams.len(), "the budget is shared by all streams");
}
/// A member that fails to parse gives back the nodes it reserved and did not build, so a
/// malformed member doesn't use up the budget of the members after it.
#[test]
fn a_malformed_object_stream_member_refunds_its_reservation() {
let stream = indexed_object_stream(&[(10, 0), (11, 3)], b"[1 [2]");
let budget = lopdf::NodeBudget::new(5);
let parsed = lopdf::ObjectStream::new_within(&stream, None, &budget).unwrap();
assert!(!parsed.objects.contains_key(&(10, 0)));
assert!(parsed.objects.contains_key(&(11, 0)));
assert!(!budget.spent(), "unused reservation from the malformed member is refunded");
}
/// A budget too small for every member builds them in index order, so the members a spent
/// budget skips are the same on every load. Here the first member takes four of the five
/// nodes, the second the last one, and the third is skipped. Parsed in parallel from the shared
/// budget, the second and third could find it held by the first and be skipped as well.
#[test]
fn a_tight_budget_builds_object_stream_members_in_index_order() {
let stream = indexed_object_stream(&[(10, 0), (11, 8), (12, 10)], b"[1 2 3] 7 8");
let budget = lopdf::NodeBudget::new(5);
let parsed = lopdf::ObjectStream::new_within(&stream, None, &budget).unwrap();
assert!(parsed.objects.contains_key(&(10, 0)));
assert!(parsed.objects.contains_key(&(11, 0)), "the second member is built from what the first left");
assert!(!parsed.objects.contains_key(&(12, 0)), "the third member finds the budget spent");
assert!(budget.spent());
}
/// A PDF with a cross-reference stream whose Info dictionary (object 4) sits in object stream 3
/// after `first`, a member listed before it.
fn info_in_object_stream_pdf(first: &[u8]) -> Vec<u8> {
let info = b"<< /Title (Kept) >>";
let index = format!("5 0 4 {} ", first.len() + 1);
let mut data = index.clone().into_bytes();
data.extend_from_slice(first);
data.push(b' ');
data.extend_from_slice(info);
let mut pdf = b"%PDF-1.5\n".to_vec();
let mut offsets = [0_u32; 7];
let mut append = |pdf: &mut Vec<u8>, id: usize, head: &[u8], body: &[u8], tail: &[u8]| {
offsets[id] = pdf.len() as u32;
pdf.extend_from_slice(format!("{id} 0 obj\n").as_bytes());
pdf.extend_from_slice(head);
pdf.extend_from_slice(body);
pdf.extend_from_slice(tail);
pdf.extend_from_slice(b"\nendobj\n");
};
append(&mut pdf, 1, b"<< /Type /Catalog /Pages 2 0 R >>", b"", b"");
append(&mut pdf, 2, b"<< /Type /Pages /Kids [] /Count 0 >>", b"", b"");
let head = format!("<< /Type /ObjStm /N 2 /First {} /Length {} >>\nstream\n", index.len(), data.len());
append(&mut pdf, 3, head.as_bytes(), &data, b"\nendstream");
let xref_offset = pdf.len() as u32;
offsets[6] = xref_offset;
let mut records = Vec::new();
for (id, &offset) in offsets.iter().enumerate() {
let (kind, field, index) = match id {
0 => (0_u8, 0_u32, 0xffff_u16),
4 => (2, 3, 1),
5 => (2, 3, 0),
_ => (1, offset, 0),
};
records.push(kind);
records.extend_from_slice(&field.to_be_bytes());
records.extend_from_slice(&index.to_be_bytes());
}
let head = format!("<< /Type /XRef /Size 7 /Root 1 0 R /Info 4 0 R /W [1 4 2] /Length {} >>\nstream\n", records.len());
pdf.extend_from_slice(format!("6 0 obj\n{head}").as_bytes());
pdf.extend_from_slice(&records);
pdf.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{xref_offset}\n%%EOF\n").as_bytes());
pdf
}
/// lopdf patch (7), #5083: a lookup in an object stream (lopdf's metadata reader, or an indirect
/// `/Length` read while loading) parses only the member it wants. It used to parse every member
/// from one object's budget, so a member before it of more than `MAX_OBJECT_NODES` objects spent
/// that budget and the wanted member was skipped: the Info dictionary read as missing.
#[test]
fn a_lookup_in_an_object_stream_parses_only_the_member_it_wants() {
let mut big = vec![b'['];
for _ in 0..4_300_000 {
big.extend_from_slice(b"0 ");
}
big.push(b']');
let metadata = Document::load_metadata_mem(&info_in_object_stream_pdf(&big)).unwrap();
assert_eq!(metadata.title.as_deref(), Some("Kept"));
// The fixture itself reads: with a small member before it, the Info dictionary was read before too.
let metadata = Document::load_metadata_mem(&info_in_object_stream_pdf(b"[1 2 3]")).unwrap();
assert_eq!(metadata.title.as_deref(), Some("Kept"));
}
#[test]
fn view_and_user_space_round_trip_for_every_rotation() {
for rotation in [0u16, 90, 180, 270] {
let (w, h) = if rotation % 180 == 0 { (200.0, 300.0) } else { (300.0, 200.0) };
let p = super::PageInfo { width: w, height: h, label: String::new(), crop: [10.0, 20.0, 210.0, 320.0], rotation };
for (x, y) in [(0.0, 0.0), (15.0, 40.0), (w, h)] {
let [ux, uy] = p.view_to_user(x, y);
let [vx, vy] = p.user_to_view(ux, uy);
assert!((vx - x).abs() < 1e-3 && (vy - y).abs() < 1e-3, "{rotation}: {x},{y} → {ux},{uy} → {vx},{vy}");
}
}
// Unrotated: the view's top-left is the crop box's top-left.
let p = super::PageInfo { width: 200.0, height: 300.0, label: String::new(), crop: [10.0, 20.0, 210.0, 320.0], rotation: 0 };
assert_eq!(p.view_to_user(0.0, 0.0), [10.0, 320.0]);
assert_eq!(p.view_rect_to_quad([0.0, 0.0, 10.0, 5.0]), [10.0, 320.0, 20.0, 320.0, 10.0, 315.0, 20.0, 315.0]);
}
/// #2583: a NoRotate comment is drawn upright with its top-left as displayed at the upper-left
/// corner of `/Rect`, its width across and its height down; a box resized as drawn maps back to
/// the `/Rect` that shows it. Other comments, and pages that aren't quarter-turned, keep `/Rect`.
#[test]
fn no_rotate_comments_are_shown_upright_from_their_corner_and_back() {
let mut note = super::Annotation {
page: 0,
subtype: "Text".into(),
author: None,
contents: None,
modified: None,
name: None,
in_reply_to: None,
parent: None,
grouped: false,
rect: [40.0, 50.0, 70.0, 70.0],
color: None,
index: 0,
state: None,
quads: Vec::new(),
locked: false,
intent: None,
fill_sign: false,
no_rotate: true,
points: Vec::new(),
text_box: None,
popup: None,
};
let rect = note.rect.map(f64::from);
for rotation in [0u16, 90, 180, 270, 45] {
let (w, h) = if rotation % 180 == 90 { (140.0, 200.0) } else { (200.0, 140.0) };
let p = super::PageInfo { width: w, height: h, label: String::new(), crop: [0.0, 0.0, 200.0, 140.0], rotation };
let shown = note.shown_rect(rotation);
let (a, b) = (p.user_to_view(shown[0], shown[1]), p.user_to_view(shown[2], shown[3]));
let [cx, cy] = p.user_to_view(40.0, 70.0);
let want = [cx, cy, cx + 30.0, cy + 20.0];
let got = [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])];
assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() < 1e-3), "{rotation}: shown {got:?}, want {want:?}");
assert_eq!(note.rect_for_shown(note.shown(rect, rotation), rotation), rect, "{rotation}");
// Moved by (5, -7) and resized to 50 × 10 as drawn, back to a /Rect.
let resized = [shown[0] as f64 + 5.0, shown[1] as f64 - 7.0, 0.0, 0.0];
let across = if rotation % 180 == 90 { [10.0, 50.0] } else { [50.0, 10.0] };
let resized = [resized[0], resized[1], resized[0] + across[0], resized[1] + across[1]];
let back = note.rect_for_shown(resized, rotation);
assert_eq!((back[2] - back[0], back[3] - back[1]), (50.0, 10.0), "{rotation}: {back:?}");
assert_eq!(note.shown(back, rotation), resized, "{rotation}");
}
note.no_rotate = false;
assert_eq!(note.shown_rect(90), note.rect);
assert_eq!(note.rect_for_shown([1.0, 2.0, 3.0, 4.0], 90), [1.0, 2.0, 3.0, 4.0]);
}
use super::*;
#[test]
fn roman_and_alpha_labels() {
assert_eq!(roman(4, MAX_LABEL_BYTES).as_deref(), Some("iv"));
assert_eq!(roman(1994, MAX_LABEL_BYTES).as_deref(), Some("mcmxciv"));
assert_eq!(alpha(1, MAX_LABEL_BYTES).as_deref(), Some("a"));
assert_eq!(alpha(27, MAX_LABEL_BYTES).as_deref(), Some("aa"));
assert_eq!(alpha(53, MAX_LABEL_BYTES).as_deref(), Some("aaa"));
}
// Synthetic label-only inspection fixtures: never construct an explosive branching tree.
fn label_info(nums: Vec<Object>, kids: Vec<Object>) -> DocInfo {
let mut doc = Document::with_version("1.7");
let mut tree = Dictionary::new();
tree.set("Nums", nums);
tree.set("Kids", kids);
doc.objects.insert((6, 0), Object::Dictionary(tree));
let mut catalog = Dictionary::new();
catalog.set("Type", Object::Name(b"Catalog".to_vec()));
catalog.set("PageLabels", Object::Reference((6, 0)));
doc.objects.insert((1, 0), Object::Dictionary(catalog));
doc.trailer.set("Root", Object::Reference((1, 0)));
let mut info = DocInfo {
pages: (1..=3).map(|n| PageInfo { width: 200.0, height: 300.0, crop: [0.0, 0.0, 200.0, 300.0], rotation: 0, label: n.to_string() }).collect(),
..Default::default()
};
Inspector::new(&doc).fill(&mut info);
info
}
fn label_spec(style: &str, prefix: Vec<u8>, first: i64) -> Object {
let mut d = Dictionary::new();
d.set("S", Object::Name(style.as_bytes().to_vec()));
d.set("P", Object::String(prefix, lopdf::StringFormat::Hexadecimal));
d.set("St", first);
Object::Dictionary(d)
}
fn assert_label_fallback(info: &DocInfo) {
assert_eq!(info.pages.iter().map(|p| p.label.as_str()).collect::<Vec<_>>(), ["1", "2", "3"]);
assert_eq!(info.warnings.len(), 1, "{:?}", info.warnings);
assert!(info.warnings[0].contains("Page labels"), "{:?}", info.warnings);
assert!(info.warnings[0].contains("physical page numbers"), "{:?}", info.warnings);
}
// Contributor-original, blank three-page PDFs. The public importer reads both labels and metadata.
fn page_prefix_space_pdf(prefix: Vec<u8>, decimal: bool, indirect: bool) -> Vec<u8> {
use lopdf::dictionary;
let mut doc = Document::with_version("1.7");
let pages_id = doc.new_object_id();
let kids: Vec<Object> = (0..3)
.map(|_| {
doc.add_object(dictionary! {
"Type" => "Page",
"Parent" => pages_id,
"MediaBox" => vec![0.into(), 0.into(), 200.into(), 300.into()],
"Resources" => Dictionary::new(),
})
.into()
})
.collect();
doc.objects.insert(pages_id, dictionary! { "Type" => "Pages", "Kids" => kids, "Count" => 3 }.into());
let prefix = Object::String(prefix, lopdf::StringFormat::Hexadecimal);
let prefix = if indirect { Object::Reference(doc.add_object(prefix)) } else { prefix };
let mut label = Dictionary::new();
label.set("P", prefix);
if decimal {
label.set("S", Object::Name(b"D".to_vec()));
}
let labels = dictionary! { "Nums" => vec![Object::Integer(0), Object::Dictionary(label)] };
let catalog_id = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages_id, "PageLabels" => labels });
let info_id = doc.add_object(dictionary! { "Title" => Object::string_literal(" Metadata title ") });
doc.trailer.set("Root", catalog_id);
doc.trailer.set("Info", info_id);
let mut bytes = Vec::new();
doc.save_to(&mut bytes).expect("write original page-prefix fixture");
bytes
}
#[test]
fn page_label_prefix_spaces_survive_public_inspection() {
let mut utf16 = vec![0xfe, 0xff];
utf16.extend(" Ω ".encode_utf16().flat_map(u16::to_be_bytes));
for (raw, prefix, decimal, indirect) in [
(b" leading".to_vec(), " leading", true, false),
(b"trailing ".to_vec(), "trailing ", true, false),
(b" both ".to_vec(), " both ", false, false),
(b" ".to_vec(), " ", false, false),
(b" Caf\xe9 ".to_vec(), " Café ", true, false),
(utf16.clone(), " Ω ", true, false),
(utf16, " Ω ", false, true),
(Vec::new(), "", true, false),
(Vec::new(), "", false, false),
] {
let bytes = page_prefix_space_pdf(raw, decimal, indirect);
let info = inspect(Arc::new(bytes), None).expect("public import of original page-prefix fixture");
let expected: Vec<String> = (1..=3)
.map(|n| {
if decimal {
format!("{prefix}{n}")
} else if prefix.is_empty() {
n.to_string()
} else {
prefix.to_owned()
}
})
.collect();
assert_eq!(info.pages.iter().map(|p| p.label.clone()).collect::<Vec<_>>(), expected, "{prefix:?}, decimal={decimal}, indirect={indirect}");
assert!(info.warnings.is_empty(), "{:?}", info.warnings);
assert_eq!(info.title.as_deref(), Some("Metadata title"), "page-prefix decoding must not change metadata normalization");
}
}
#[test]
fn page_label_limits_keep_normal_pdf_numbering() {
for (style, first, expected) in
[("r", 4, ["iv", "v", "vi"]), ("R", 1994, ["MCMXCIV", "MCMXCV", "MCMXCVI"]), ("a", 26, ["z", "aa", "bb"]), ("A", 52, ["ZZ", "AAA", "BBB"])]
{
let info = label_info(vec![Object::Integer(0), label_spec(style, Vec::new(), first)], Vec::new());
assert_eq!(info.pages.iter().map(|p| p.label.as_str()).collect::<Vec<_>>(), expected);
assert!(info.warnings.is_empty(), "{:?}", info.warnings);
}
}
#[test]
fn page_label_limits_reject_long_prefixes_without_partial_labels() {
for prefix in [vec![b'x'; 1025], vec![b'x'; 2051]] {
let nums = vec![Object::Integer(0), label_spec("D", b"ok-".to_vec(), 1), Object::Integer(1), label_spec("D", prefix, 1)];
assert_label_fallback(&label_info(nums, Vec::new()));
}
}
#[test]
fn page_label_limits_count_decoded_utf8_bytes() {
let mut prefix = vec![0xfe, 0xff];
for _ in 0..342 {
prefix.extend_from_slice(&0x2022u16.to_be_bytes());
}
assert_label_fallback(&label_info(vec![Object::Integer(0), label_spec("", prefix, 1)], Vec::new()));
}
#[test]
fn page_label_limits_bound_roman_and_alpha_output() {
// Just 1025 output bytes on the old path, so RED does not consume excessive memory.
for (style, first) in [("r", 1_025_000), ("a", 26 * 1024 + 1)] {
assert_label_fallback(&label_info(vec![Object::Integer(0), label_spec(style, Vec::new(), first)], Vec::new()));
}
}
#[test]
fn page_label_limits_detect_one_node_cycle() {
let nums = vec![Object::Integer(0), label_spec("D", b"custom-".to_vec(), 1)];
assert_label_fallback(&label_info(nums, vec![Object::Reference((6, 0))]));
}
#[test]
fn page_label_limits_accept_utf16_ascii_at_the_output_boundary() {
let mut prefix = vec![0xfe, 0xff];
for _ in 0..1023 {
prefix.extend_from_slice(&u16::from(b'x').to_be_bytes());
}
let info = label_info(vec![Object::Integer(0), label_spec("D", prefix, 1)], Vec::new());
assert!(info.warnings.is_empty(), "{:?}", info.warnings);
assert_eq!(info.pages[0].label, format!("{}1", "x".repeat(1023)));
assert!(info.pages.iter().all(|p| p.label.len() == MAX_LABEL_BYTES));
}
#[test]
fn page_label_limits_refuse_hostile_numbers_before_allocating() {
for style in ["r", "R", "a", "A"] {
assert_label_fallback(&label_info(vec![Object::Integer(0), label_spec(style, Vec::new(), i64::MAX)], Vec::new()));
}
// Decimal labels remain small even when their first number exceeds 32-bit usize.
let info = label_info(vec![Object::Integer(0), label_spec("D", Vec::new(), i64::MAX)], Vec::new());
assert_eq!(info.pages[0].label, i64::MAX.to_string());
assert_eq!(info.pages[1].label, (i64::MAX as u64 + 1).to_string());
assert!(info.warnings.is_empty());
}
#[test]
fn page_label_limits_bound_wide_number_trees() {
assert_label_fallback(&label_info(Vec::new(), vec![Object::Null; MAX_LABEL_TREE_WORK + 1]));
let pair = [Object::Integer(0), label_spec("D", Vec::new(), 1)];
let nums = (0..MAX_LABEL_TREE_WORK).flat_map(|_| pair.clone()).collect();
assert_label_fallback(&label_info(nums, Vec::new()));
}
#[test]
fn page_label_limits_bound_total_custom_output_atomically() {
let doc = Document::with_version("1.7");
let mut tree = Dictionary::new();
tree.set("Nums", vec![Object::Integer(0), label_spec("", vec![b'x'; MAX_LABEL_BYTES], 1)]);
let mut catalog = Dictionary::new();
catalog.set("PageLabels", Object::Dictionary(tree));
let count = MAX_LABEL_TOTAL_BYTES / MAX_LABEL_BYTES + 1;
let mut pages: Vec<_> =
(1..=count).map(|n| PageInfo { width: 200.0, height: 300.0, crop: [0.0, 0.0, 200.0, 300.0], rotation: 0, label: n.to_string() }).collect();
let err = Inspector::new(&doc).page_labels(&catalog, &mut pages).unwrap_err();
assert!(err.contains("4 MiB"), "{err}");
assert!(pages.iter().enumerate().all(|(i, p)| p.label == (i + 1).to_string()));
// Exact aggregate boundary remains supported.
pages.pop();
Inspector::new(&doc).page_labels(&catalog, &mut pages).unwrap();
assert!(pages.iter().all(|p| p.label.len() == MAX_LABEL_BYTES));
}
#[test]
fn page_label_limits_bound_the_total_of_decoded_prefixes() {
// `ranges` ranges naming one shared prefix object of `prefix` bytes, without a style.
let shared = |ranges: i64, prefix: usize| {
let mut doc = Document::with_version("1.7");
doc.objects.insert((7, 0), Object::String(vec![b'x'; prefix], lopdf::StringFormat::Literal));
let mut spec = Dictionary::new();
spec.set("P", Object::Reference((7, 0)));
doc.objects.insert((8, 0), Object::Dictionary(spec));
let mut tree = Dictionary::new();
tree.set("Nums", (0..ranges).flat_map(|i| [Object::Integer(i), Object::Reference((8, 0))]).collect::<Vec<_>>());
let mut catalog = Dictionary::new();
catalog.set("PageLabels", Object::Dictionary(tree));
(doc, catalog)
};
let mut pages: Vec<_> =
(1..=3).map(|n| PageInfo { width: 200.0, height: 300.0, crop: [0.0, 0.0, 200.0, 300.0], rotation: 0, label: n.to_string() }).collect();
// Each range decodes its prefix, so a small file sharing one prefix object asks for a
// copy per range: 5,000 ranges and a 1,000-byte prefix make 5 MB, past the 4 MiB total.
// Three pages make three labels, far below the label total: the prefixes alone refuse it.
let (doc, catalog) = shared(5000, 1000);
let err = Inspector::new(&doc).page_labels(&catalog, &mut pages).unwrap_err();
assert!(err.contains("prefixes exceed 4 MiB"), "{err}");
assert!(pages.iter().enumerate().all(|(i, p)| p.label == (i + 1).to_string()));
// Exactly 4 MiB of prefixes is still read.
let (doc, catalog) = shared((MAX_PREFIX_TOTAL_BYTES / MAX_LABEL_BYTES) as i64, MAX_LABEL_BYTES);
Inspector::new(&doc).page_labels(&catalog, &mut pages).unwrap();
assert!(pages.iter().all(|p| p.label.len() == MAX_LABEL_BYTES));
}
#[test]
fn page_label_limits_handle_prefix_only_and_reference_cycles() {
let info = label_info(vec![Object::Integer(0), label_spec("", b"prefix".to_vec(), i64::MAX)], Vec::new());
assert!(info.warnings.is_empty());
assert!(info.pages.iter().all(|p| p.label == "prefix"));
let mut doc = Document::with_version("1.7");
doc.objects.insert((6, 0), Object::Reference((6, 0)));
let mut catalog = Dictionary::new();
catalog.set("PageLabels", Object::Reference((6, 0)));
assert!(Inspector::new(&doc).page_labels(&catalog, &mut []).unwrap_err().contains("cyclic"));
}
#[test]
fn page_label_limits_bound_depth_and_count_combined_work() {
for depth in [MAX_LABEL_TREE_DEPTH, MAX_LABEL_TREE_DEPTH + 1] {
let mut child = Dictionary::new();
child.set("Nums", vec![Object::Integer(0), label_spec("D", b"custom-".to_vec(), 1)]);
let mut child = Object::Dictionary(child);
for _ in 1..depth {
let mut parent = Dictionary::new();
parent.set("Kids", vec![child]);
child = Object::Dictionary(parent);
}
let info = label_info(Vec::new(), vec![child]);
if depth == MAX_LABEL_TREE_DEPTH {
assert!(info.warnings.is_empty());
assert_eq!(info.pages[0].label, "custom-1");
} else {
assert_label_fallback(&info);
}
}
// Each array alone fits: their combined work still must be bounded.
let half = MAX_LABEL_TREE_WORK / 2;
let spec = label_spec("D", Vec::new(), 1);
let nums = (0..half).flat_map(|i| [Object::Integer(i as i64), spec.clone()]).collect();
assert_label_fallback(&label_info(nums, vec![Object::Null; half]));
}
#[test]
fn page_label_limits_keep_the_later_of_duplicate_ranges() {
let nums = vec![Object::Integer(0), label_spec("D", b"first-".to_vec(), 1), Object::Integer(0), label_spec("D", b"last-".to_vec(), 1)];
let info = label_info(nums, Vec::new());
assert_eq!(info.pages.iter().map(|p| p.label.as_str()).collect::<Vec<_>>(), ["last-1", "last-2", "last-3"]);
assert_eq!(info.warnings.len(), 1, "{:?}", info.warnings);
assert!(info.warnings[0].contains("twice"), "{:?}", info.warnings);
}
#[test]
fn page_label_limits_keep_other_ranges_when_one_is_too_long() {
// Page 1 is labelled normally; pages 2–3 would need over 1024 bytes of roman numerals.
let nums = vec![Object::Integer(0), label_spec("D", b"ok-".to_vec(), 1), Object::Integer(1), label_spec("r", Vec::new(), 1_025_000)];
let info = label_info(nums, Vec::new());
assert_eq!(info.pages.iter().map(|p| p.label.as_str()).collect::<Vec<_>>(), ["ok-1", "2", "3"]);
assert_eq!(info.warnings.len(), 1, "{:?}", info.warnings);
assert!(info.warnings[0].contains("physical page numbers"), "{:?}", info.warnings);
}
const ATTACHMENTS: &[u8] = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles << /Names [(notes.txt) 6 0 R] >> >> >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [4 0 R] /Resources << /Font << /F1 9 0 R >> >> >> endobj
4 0 obj << /Type /Annot /Subtype /FileAttachment /Rect [10 10 30 30] /Contents (data.csv) /FS 8 0 R >> endobj
5 0 obj << /Type /EmbeddedFile /Length 11 >> stream
hello notes
endstream endobj
6 0 obj << /Type /Filespec /F (notes.txt) /UF (notes.txt) /EF << /F 5 0 R >> /Desc (Doc-level) >> endobj
7 0 obj << /Type /EmbeddedFile /Length 5 >> stream
a,b,c
endstream endobj
8 0 obj << /Type /Filespec /F (data.csv) /EF << /F 7 0 R >> >> endobj
9 0 obj << /Type /Font /Subtype /Type1 /BaseFont /ABCDEF+Helvetica >> endobj
trailer << /Root 1 0 R >>
%%EOF";
#[test]
fn attachments_of_both_kinds_are_listed_and_extracted() {
let bytes = Arc::new(ATTACHMENTS.to_vec());
let info = inspect(bytes.clone(), None).expect("opens");
let names: Vec<_> = info.attachments.iter().map(|a| a.name.as_str()).collect();
assert!(names.contains(&"notes.txt") && names.contains(&"data.csv"), "{names:?}");
for a in &info.attachments {
let data = attachment_data(&bytes, None, a).expect("extracts");
let expected: &[u8] = if a.name == "notes.txt" { b"hello notes" } else { b"a,b,c" };
assert_eq!(data, expected, "{}", a.name);
}
// A non-embedded /Helvetica is reported as standing in for itself: the standard-14
// metrics are exact, so the substitution is named rather than left implicit.
assert_eq!(
info.fonts,
vec![FontInfo {
name: "Helvetica".into(),
kind: "Type1".into(),
embedded: false,
subset: true,
encoding: None,
substitute: Some("Helvetica".into()),
substitution: Some(pdfcraft_fonts::fallback::Why::NamedStandard14.note().into()),
}]
);
}
const LAYERS: &[u8] = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R 6 0 R 7 0 R] /D << /OFF [6 0 R] /RBGroups [[5 0 R 6 0 R] [7 0 R] 8 0 R] >> >> >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [10 0 R 11 0 R] >> endobj
5 0 obj << /Type /OCG /Name (Red) >> endobj
6 0 obj << /Type /OCG /Name (Green) >> endobj
7 0 obj << /Type /OCG /Name (Blue) >> endobj
8 0 obj [6 0 R 7 0 R] endobj
9 0 obj [/OFF 5 0 R] endobj
10 0 obj << /Type /Annot /Subtype /Link /Rect [10 10 50 30] /A << /S /SetOCGState /State [7 0 R /ON 6 0 R /Bogus 5 0 R /Toggle 5 0 R 1 7 0 R] /PreserveRB false >> >> endobj
11 0 obj << /Type /Annot /Subtype /Link /Rect [60 10 100 30] /A << /S /SetOCGState /State 9 0 R >> >> endobj
trailer << /Root 1 0 R >>
%%EOF";
/// `/Trans` and `/Dur` (ISO 32000-2 §12.4.4): read with the spec's defaults for what a page
/// leaves out, lenient about what is out of range, and only for pages that have either.
const TRANSITIONS: &[u8] = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R 5 0 R 6 0 R 7 0 R] /Count 5 /MediaBox [0 0 200 200] >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /Trans << /Type /Trans /S /Wipe /D 2.5 /Di 270 >> /Dur 5 >> endobj
4 0 obj << /Type /Page /Parent 2 0 R /Dur 3 >> endobj
5 0 obj << /Type /Page /Parent 2 0 R /Trans 8 0 R >> endobj
6 0 obj << /Type /Page /Parent 2 0 R /Trans << /S /Bogus /D (slow) /Di 45 /Dm /Q >> /Dur -1 >> endobj
7 0 obj << /Type /Page /Parent 2 0 R >> endobj
8 0 obj << /S /Fly /M /O /SS 0.5 /B true /Di /None /Dm /V >> endobj
trailer << /Root 1 0 R >>
%%EOF";
#[test]
fn page_transitions_and_display_times_are_read() {
use super::TransitionStyle as S;
let info = inspect(Arc::new(TRANSITIONS.to_vec()), None).expect("opens");
let t = &info.transitions;
assert_eq!(t.iter().map(|t| t.page).collect::<Vec<_>>(), [0, 1, 2, 3], "pages with /Trans or /Dur, in order; page 5 has neither");
assert_eq!((t[0].style, t[0].duration, t[0].direction, t[0].display), (Some(S::Wipe), 2.5, Some(270), Some(5.0)));
assert!((t[0].horizontal, t[0].inward, t[0].scale, t[0].opaque) == (true, true, 1.0, false), "defaults: {:?}", t[0]);
assert_eq!((t[1].style, t[1].duration, t[1].display), (None, 1.0, Some(3.0)), "/Dur without /Trans");
assert_eq!(
(t[2].style, t[2].inward, t[2].scale, t[2].opaque, t[2].direction, t[2].horizontal),
(Some(S::Fly), false, 0.5, true, None, false),
"an indirect /Trans: {:?}",
t[2]
);
// Out of range: an unknown style is the spec's default R, a non-numeric /D the default
// second, a direction that is not one of the five falls back to 0, a bad /Dm to
// horizontal, and a negative /Dur is no display time.
assert_eq!(
(t[3].style, t[3].duration, t[3].direction, t[3].horizontal, t[3].display),
(Some(S::Replace), 1.0, Some(0), true, None),
"{:?}",
t[3]
);
}
const CUSTOM_INFO: &[u8] = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >> endobj
3 0 obj << /Type /Page /Parent 2 0 R >> endobj
4 0 obj << /Title (Report) /Producer (Test) /Project (Apollo) /Revision 7 /Dept (R&D) /Trapped /False /Note <FEFF90E8> >> endobj
trailer << /Root 1 0 R /Info 4 0 R >>
%%EOF";
#[test]
fn custom_document_properties_are_listed_apart_from_the_standard_ones() {
let info = inspect(Arc::new(CUSTOM_INFO.to_vec()), None).expect("opens");
assert_eq!((info.title.as_deref(), info.producer.as_deref()), (Some("Report"), Some("Test")));
let mut custom = info.custom.clone();
custom.sort();
// Revision is a number, not text, so it is not listed; Trapped is a standard entry.
assert_eq!(
custom,
[("Dept".to_string(), "R&D".to_string()), ("Note".to_string(), "部".to_string()), ("Project".to_string(), "Apollo".to_string())]
);
}
#[test]
fn radio_button_layer_groups_are_read() {
let info = inspect(Arc::new(LAYERS.to_vec()), None).expect("opens");
let layers: Vec<_> = info.layers.iter().map(|l| (l.id, l.name.as_str(), l.visible)).collect();
assert_eq!(layers, [((5, 0), "Red", true), ((6, 0), "Green", false), ((7, 0), "Blue", true)]);
// A group of one constrains nothing; an indirect group is read.
assert_eq!(info.layer_groups, [vec![(5, 0), (6, 0)], vec![(6, 0), (7, 0)]]);
}
#[test]
fn initial_layer_visibility_is_the_default_configuration() {
// BaseState OFF: Red is listed ON; Green is listed both ON and OFF, and OFF wins; Blue
// isn't listed, so it takes the base state.
let pdf = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R 6 0 R 7 0 R] /D << /BaseState /OFF /ON [5 0 R 6 0 R] /OFF [6 0 R] >> >> >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >> endobj
5 0 obj << /Type /OCG /Name (Red) >> endobj
6 0 obj << /Type /OCG /Name (Green) >> endobj
7 0 obj << /Type /OCG /Name (Blue) >> endobj
trailer << /Root 1 0 R >>
%%EOF";
let info = inspect(Arc::new(pdf.to_vec()), None).expect("opens");
let layers: Vec<_> = info.layers.iter().map(|l| (l.name.as_str(), l.initial)).collect();
assert_eq!(layers, [("Red", true), ("Green", false), ("Blue", false)]);
}
/// ISO 32000-2 Table 99 `/Order`: the Layers panel presents the groups it lists, as a tree,
/// with nested arrays one level deeper and a leading text string as a label. Groups it
/// doesn't list are not presented (they stay in `layers` and keep their state), and without
/// `/Order` none are. Malformed entries are skipped; cycles and depth are bounded.
#[test]
fn the_layers_panel_presents_the_order_tree() {
let rows = |order: &str, extra: &str| {
let pdf = format!(
"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R 6 0 R 7 0 R 8 0 R] /D << /OFF [8 0 R] {order} >> >> >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >> endobj
5 0 obj << /Type /OCG /Name (A) >> endobj
6 0 obj << /Type /OCG /Name (B) >> endobj
7 0 obj << /Type /OCG /Name (C) >> endobj
8 0 obj << /Type /OCG /Name (D) >> endobj
9 0 obj << /Type /OCG /Name (Unlisted) >> endobj
{extra}
trailer << /Root 1 0 R >>
%%EOF"
);
let info = inspect(Arc::new(pdf.into_bytes()), None).expect("opens");
assert_eq!(info.layers.len(), 4, "every group in /OCGs keeps its state: {order}");
info.layer_order
.iter()
.map(|r| match &r.item {
LayerItem::Layer(i) => format!("{}{}", " ".repeat(r.depth), info.layers.get(*i).map_or("?", |l| l.name.as_str())),
LayerItem::Label(s) => format!("{}[{s}]", " ".repeat(r.depth)),
})
.collect::<Vec<_>>()
};
// A layer with sublayers, and a group not listed (D) that isn't presented.
assert_eq!(rows("/Order [5 0 R 6 0 R [7 0 R]]", ""), ["A", "B", " C"]);
// A labelled collection heads its own level, with a nested array under B.
assert_eq!(rows("/Order [5 0 R [(Set) 6 0 R [7 0 R]] 8 0 R]", ""), ["A", "[Set]", " B", " C", "D"]);
// Without /Order, or with an empty one, nothing is presented.
assert!(rows("", "").is_empty());
assert!(rows("/Order []", "").is_empty());
// An indirect /Order and nested array; a group outside /OCGs, a missing object and junk
// are skipped.
assert_eq!(rows("/Order 10 0 R", "10 0 obj [5 0 R 11 0 R 9 0 R 12 0 R 1 (x) /N] endobj\n11 0 obj [6 0 R] endobj"), ["A", " B"]);
// Cycles: an array holding itself, and an /Order holding itself.
assert_eq!(rows("/Order 10 0 R", "10 0 obj [5 0 R 10 0 R 11 0 R] endobj\n11 0 obj [6 0 R 11 0 R 10 0 R] endobj"), ["A", " B"]);
// Nesting past the limit is cut off; a long label is cut short.
let deep = format!("/Order [5 0 R {}6 0 R{}]", "[".repeat(40), "]".repeat(40));
assert_eq!(rows(&deep, ""), ["A"]);
let long = format!("/Order [[({}) 5 0 R]]", "x".repeat(1000));
let got = rows(&long, "");
assert_eq!(got.first().map(|s| s.len()), Some(MAX_LAYER_LABEL_CHARS + 2));
// Many rows stop at the cap.
let many = format!("/Order [{}]", "5 0 R ".repeat(MAX_LAYER_ORDER_ROWS + 10));
assert_eq!(rows(&many, "").len(), MAX_LAYER_ORDER_ROWS);
// One long indirect label heading many direct arrays is decoded once, and cut short in
// every row.
let shared = format!("/Order [{}]", "[13 0 R 5 0 R] ".repeat(100));
let label = format!("13 0 obj ({}) endobj", "y".repeat(10_000));
let before = LABEL_DECODES.with(std::cell::Cell::get);
let got = rows(&shared, &label);
assert_eq!(LABEL_DECODES.with(std::cell::Cell::get) - before, 1);
assert_eq!(got.len(), 200);
assert!(got.iter().step_by(2).all(|s| s.len() == MAX_LAYER_LABEL_CHARS + 2), "{:?}", got.first());
}
/// ISO 32000-2 §8.11.4.4: the View auto state (`/AS`) decides the initial state of the groups
/// it manages, over `/ON` and `/OFF`; the others keep the configuration's. Malformed entries
/// are ignored.
#[test]
fn initial_layer_visibility_applies_the_view_auto_state() {
let initial = |config: &str| {
let pdf = format!(
"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R 6 0 R 7 0 R] /D {config} >> >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >> endobj
5 0 obj << /Type /OCG /Name (ViewOff) /Usage << /View << /ViewState /OFF >> >> >> endobj
6 0 obj << /Type /OCG /Name (ViewOn) /Usage 8 0 R >> endobj
7 0 obj << /Type /OCG /Name (Plain) >> endobj
8 0 obj << /View << /ViewState /ON >> >> endobj
9 0 obj 9 0 R endobj
trailer << /Root 1 0 R >>
%%EOF"
);
let info = inspect(Arc::new(pdf.into_bytes()), None).expect("opens");
// Opened, so each is shown as it starts.
info.layers.iter().inspect(|l| assert_eq!(l.initial, l.visible, "{}", l.name)).map(|l| l.initial).collect::<Vec<_>>()
};
let managed = "/AS [<< /Event /View /Category [/View] /OCGs [5 0 R 6 0 R 7 0 R] >>]";
assert_eq!(initial(&format!("<< /OFF [6 0 R 7 0 R] {managed} >>")), [false, true, false]);
assert_eq!(initial("<< /OFF [6 0 R 7 0 R] >>"), [true, false, false], "without /AS");
assert_eq!(initial("<< /OFF [6 0 R] /AS [<< /Event /Print /Category [/View] /OCGs [5 0 R 6 0 R] >>] >>"), [true, false, true], "Print event");
assert_eq!(initial("<< /AS [<< /Event /View /Category [/Zoom] /OCGs [5 0 R] >>] >>"), [true, true, true], "Zoom only");
for bad in ["/AS 9 0 R", "/AS [9 0 R 1 null]", "/AS [<< /Event /View /OCGs [5 0 R] >>]", "/AS [<< /Event /View /Category [/View] /OCGs 9 0 R >>]"]
{
assert_eq!(initial(&format!("<< {bad} >>")), [true, true, true], "{bad}");
}
}
const ORPHAN_FIELDS: &[u8] = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [5 0 R 6 0 R] >> endobj
4 0 obj << /Fields [] >> endobj
5 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (alpha) /V (one) /Rect [10 100 90 120] >> endobj
6 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (beta) /Rect [10 60 90 80] >> endobj
trailer << /Root 1 0 R >>
%%EOF";
#[test]
fn fields_listed_only_on_the_pages_are_listed() {
let info = inspect(Arc::new(ORPHAN_FIELDS.to_vec()), None).expect("opens");
let listed: Vec<_> = info.fields.iter().map(|f| (f.name.as_str(), f.value.as_deref(), f.page)).collect();
assert_eq!(listed, [("alpha", Some("one"), Some(0)), ("beta", None, Some(0))], "{:?}", info.fields);
}
/// A widget whose `/Parent` names an object the file doesn't hold, or one that isn't a dictionary,
/// is listed as a field of its own: that parent reads as `null`, the same
/// as none (ISO 32000-2 §7.3.9).
#[test]
fn a_widget_whose_parent_is_missing_is_listed() {
let pdf = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [5 0 R 6 0 R] >> endobj
4 0 obj << /Fields [] >> endobj
5 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (alpha) /V (one) /Parent 40 0 R /Rect [10 100 90 120] >> endobj
6 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (beta) /Parent 7 0 R /Rect [10 60 90 80] >> endobj
7 0 obj [1 2 3] endobj
trailer << /Root 1 0 R >>
%%EOF";
let info = inspect(Arc::new(pdf.to_vec()), None).expect("opens");
let listed: Vec<_> = info.fields.iter().map(|f| (f.name.as_str(), f.value.as_deref(), f.page)).collect();
assert_eq!(listed, [("alpha", Some("one"), Some(0)), ("beta", None, Some(0))], "{:?}", info.fields);
}
/// A `Structure` that counts the objects read through it.
struct CountingReads<'a> {
doc: &'a Document,
reads: std::cell::Cell<usize>,
}
impl Structure for CountingReads<'_> {
fn trailer(&self) -> &Dictionary {
&self.doc.trailer
}
fn get_object(&self, id: ObjectId) -> lopdf::Result<&Object> {
self.reads.set(self.reads.get() + 1);
self.doc.get_object(id)
}
fn encrypted(&self) -> bool {
false
}
}
/// A font (or any dictionary whose `/Type` is present and isn't `/Annot`) a page lists in `/Annots`
/// isn't an annotation (ISO 32000-2 §12.5.2, Table 166) and isn't listed as a comment; the real ones
/// around it keep their place in the array, and an entry without `/Type` is still read as one. A
/// `/Type` that isn't a name (`/Type 42`) isn't `/Annot` either, while `/Type null` counts as absent.
#[test]
fn dictionaries_of_another_type_in_annots_are_not_annotations() {
let pdf = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [4 0 R 5 0 R 6 0 R 7 0 R 8 0 R 9 0 R] >> endobj
4 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj
5 0 obj << /Type /Annot /Subtype /Text /Rect [10 10 30 30] /Contents (typed) >> endobj
6 0 obj << /Type /Font /Subtype /TrueType /BaseFont /Arial >> endobj
7 0 obj << /Subtype /Square /Rect [40 40 60 60] /Contents (untyped) >> endobj
8 0 obj << /Type 42 /Subtype /Text /Rect [70 70 90 90] /Contents (number type) >> endobj
9 0 obj << /Type null /Subtype /Circle /Rect [100 100 120 120] /Contents (null type) >> endobj
trailer << /Root 1 0 R >>
%%EOF";
let info = inspect(Arc::new(pdf.to_vec()), None).expect("opens");
let mut listed: Vec<_> = info.annotations.iter().map(|a| (a.subtype.as_str(), a.index, a.contents.as_deref())).collect();
listed.sort_by_key(|a| a.1);
assert_eq!(listed, [("Text", 1, Some("typed")), ("Square", 3, Some("untyped")), ("Circle", 5, Some("null type"))], "{:?}", info.annotations);
}
/// `/QuadPoints` is read by the rule redaction apply uses (`pdfcraft_geom::quad_points`): a quad
/// with a non-number is left out rather than drawn with a zero, an indirect number is followed,
/// and a trailing partial quad is dropped.
#[test]
fn only_usable_quads_are_listed() {
let pdf = "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [5 0 R] >> endobj\n\
4 0 obj 40 endobj\n\
5 0 obj << /Type /Annot /Subtype /Redact /Rect [20 95 40 110] /QuadPoints \
[20 /oops 40 110 20 95 40 95 20 110 4 0 R 110 20 95 40 95 20 110 40 110] >> endobj\n\
trailer << /Root 1 0 R >>\n%%EOF";
let doc = Document::load_mem(pdf.as_bytes()).expect("loads");
let page = PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() };
let mut info = DocInfo { pages: vec![page], ..Default::default() };
Inspector::new(&doc).fill(&mut info);
let mark = info.annotations.iter().find(|a| a.subtype == "Redact").expect("listed");
assert_eq!(mark.quads, vec![[20.0, 110.0, 40.0, 110.0, 20.0, 95.0, 40.0, 95.0]]);
}
/// One comment referenced many times, or a page-shared `/Annots` array, is listed once, and many
/// small comments sharing one large `/Contents` string stop at the retained-bytes budget.
#[test]
fn repeated_annotations_are_listed_once_within_a_byte_budget() {
let small = "<< /Subtype /Text /Rect [0 0 1 1] /Contents 7 0 R >> ".repeat(1200);
let pdf = format!(
"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots 5 0 R >> endobj\n\
4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots 5 0 R >> endobj\n\
5 0 obj [{} {small}] endobj\n\
6 0 obj << /Type /Annot /Subtype /Text /Rect [0 0 1 1] /Contents (note) >> endobj\n\
7 0 obj ({}) endobj\ntrailer << /Root 1 0 R >>\n%%EOF",
"6 0 R ".repeat(1000),
"x".repeat(64 << 10),
);
let doc = Document::load_mem(pdf.as_bytes()).expect("loads");
let page = PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() };
let mut info = DocInfo { pages: vec![page.clone(), page], ..Default::default() };
Inspector::new(&doc).fill(&mut info);
let notes = &info.annotations;
assert_eq!(notes.iter().filter(|a| a.contents.as_deref() == Some("note")).count(), 1);
assert!(notes.iter().all(|a| a.page == 0), "the shared array is the first page's");
assert!(notes.len() > 1);
assert!(notes.iter().map(retained).sum::<usize>() <= MAX_ANNOTATION_BYTES);
}
/// A link reached through an indirect object that holds only a reference to it is the same
/// annotation as the link itself, and so is a page array reached the same way: each is listed once.
#[test]
fn an_annotation_reached_through_a_reference_chain_is_listed_once() {
let pdf = "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots 7 0 R >> endobj\n\
4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots 8 0 R >> endobj\n\
5 0 obj 6 0 R endobj\n\
6 0 obj << /Type /Annot /Subtype /Link /Rect [0 0 1 1] /A << /S /URI /URI (https://example.com) >> >> endobj\n\
7 0 obj [5 0 R 6 0 R] endobj\n\
8 0 obj 7 0 R endobj\n\
trailer << /Root 1 0 R >>\n%%EOF";
let mut doc = Document::load_mem(pdf.as_bytes()).expect("loads");
let mut saved = Vec::new();
doc.save_to(&mut saved).expect("saves");
let lazy = LazyStructure::new(Arc::new(saved), None).expect("opens");
let page = PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() };
for reader in [&doc as &dyn Structure, &lazy] {
let mut info = DocInfo { pages: vec![page.clone(), page.clone()], ..Default::default() };
Inspector::new(reader).fill(&mut info);
assert_eq!(info.links.len(), 1, "{:?}", info.links);
}
}
/// Comments list their `/C` as pdfcraft-annot draws and lists it: four entries with a null are
/// CMYK with the null as 0, one entry is grey, a reference among four entries is unreadable,
/// three numbers (some by reference) are RGB, and a text box keeps its three-number `/C`.
#[test]
fn comment_colours_are_listed_as_drawn() {
let pdf = "%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [4 0 R 5 0 R 6 0 R 7 0 R 8 0 R 9 0 R] >> endobj
4 0 obj << /Type /Annot /Subtype /Square /Rect [0 0 10 10] /C [1 0 0 null] >> endobj
5 0 obj << /Type /Annot /Subtype /Square /Rect [0 20 10 30] /C 10 0 R >> endobj
6 0 obj << /Type /Annot /Subtype /Square /Rect [0 40 10 50] /C [1 0 0 11 0 R] >> endobj
7 0 obj << /Type /Annot /Subtype /Square /Rect [0 60 10 70] /C [11 0 R 0 0] >> endobj
8 0 obj << /Type /Annot /Subtype /Circle /Rect [0 80 10 90] /C [0 0 0 1] >> endobj
9 0 obj << /Type /Annot /Subtype /FreeText /Rect [0 100 10 110] /C [0 1 0] >> endobj
10 0 obj [0.5] endobj
11 0 obj 1 endobj
trailer << /Root 1 0 R >>
%%EOF";
let mut doc = Document::load_mem(pdf.as_bytes()).expect("loads");
let mut saved = Vec::new();
doc.save_to(&mut saved).expect("saves");
let lazy = LazyStructure::new(Arc::new(saved), None).expect("opens");
let page = PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() };
for reader in [&doc as &dyn Structure, &lazy] {
let mut info = DocInfo { pages: vec![page.clone()], ..Default::default() };
Inspector::new(reader).fill(&mut info);
let mut listed: Vec<(f32, Option<[f32; 3]>)> = info.annotations.iter().map(|a| (a.rect[1], a.color)).collect();
listed.sort_by(|a, b| a.0.total_cmp(&b.0));
let colors: Vec<Option<[f32; 3]>> = listed.into_iter().map(|(_, c)| c).collect();
assert_eq!(colors, [Some([0.0, 1.0, 1.0]), Some([0.5, 0.5, 0.5]), None, Some([1.0, 0.0, 0.0]), Some([0.0, 0.0, 0.0]), Some([0.0, 1.0, 0.0])]);
}
}
/// A reply finds its comment where the listing keeps it: on the first page of a page-shared
/// `/Annots` array, whether its `/IRT` names the comment or an object holding a reference to it.
#[test]
fn a_reply_finds_its_comment_through_a_reference_chain_and_a_shared_array() {
let pdf = "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots 7 0 R >> endobj\n\
4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots 7 0 R >> endobj\n\
5 0 obj 6 0 R endobj\n\
6 0 obj << /Type /Annot /Subtype /Text /Rect [0 0 1 1] /Contents (comment) >> endobj\n\
7 0 obj [5 0 R 8 0 R 9 0 R] endobj\n\
8 0 obj << /Type /Annot /Subtype /Text /Rect [0 0 1 1] /Contents (to the comment) /IRT 6 0 R >> endobj\n\
9 0 obj << /Type /Annot /Subtype /Text /Rect [0 0 1 1] /Contents (through the chain) /IRT 5 0 R >> endobj\n\
trailer << /Root 1 0 R >>\n%%EOF";
let mut doc = Document::load_mem(pdf.as_bytes()).expect("loads");
let mut saved = Vec::new();
doc.save_to(&mut saved).expect("saves");
let lazy = LazyStructure::new(Arc::new(saved), None).expect("opens");
let page = PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() };
for reader in [&doc as &dyn Structure, &lazy] {
let mut info = DocInfo { pages: vec![page.clone(), page.clone()], ..Default::default() };
Inspector::new(reader).fill(&mut info);
let notes = &info.annotations;
assert_eq!(notes.len(), 3, "{notes:?}");
let comment = notes.iter().find(|a| a.contents.as_deref() == Some("comment")).expect("listed");
assert_eq!((comment.page, comment.index, comment.is_reply()), (0, 0, false));
for reply in ["to the comment", "through the chain"] {
let a = notes.iter().find(|a| a.contents.as_deref() == Some(reply)).expect("listed");
assert_eq!((a.page, a.parent), (0, Some((0, 0))), "{reply}");
}
}
}
/// A conversion rejected as too long, or trimmed short or to nothing, still spends the raw
/// length, so a string many entries share is converted only a few times.
#[test]
fn rejected_text_conversions_spend_the_budget() {
let mut budget = 10;
let mut conversions = 0;
for _ in 0..10 {
// Each 0xFF becomes the three-byte replacement character.
let kept = spend(&[0xFF; 5], &mut budget, |b| {
conversions += 1;
String::from_utf8_lossy(b).into_owned()
});
assert_eq!(kept, None);
}
assert_eq!((conversions, budget), (2, 0));
let pdf = "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
2 0 obj << /Type /Pages /Kids [] /Count 0 >> endobj\n\
trailer << /Root 1 0 R >>\n%%EOF";
let doc = Document::load_mem(pdf.as_bytes()).expect("loads");
let d = Dictionary::from_iter([("Contents", Object::string_literal(" "))]);
let mut budget = 10;
assert_eq!(Inspector::new(&doc).budgeted_text(&d, b"Contents", &mut budget), None);
assert_eq!(budget, 7);
// Text that trims short spends its raw length.
let d = Dictionary::from_iter([("Contents", Object::string_literal(" x"))]);
let mut budget = 10;
assert_eq!(Inspector::new(&doc).budgeted_text(&d, b"Contents", &mut budget).as_deref(), Some("x"));
assert_eq!(budget, 3);
}
/// An annotation attachment's `/Contents` spends the text budget only when it names the
/// attachment, so long descriptions of named files leave a later web address listed.
#[test]
fn a_named_attachment_leaves_its_contents_unspent() {
let files = "<< /Subtype /FileAttachment /Rect [0 0 1 1] /Contents 6 0 R /FS << /F (a.txt) >> >> ".repeat(20);
// Longer than what 20 MiB of charged `/Contents` would leave of the 16 MiB budget.
let uri = format!("https://example.com/{}", "a".repeat(2 << 20));
let pdf = format!(
"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [{files} \
<< /Subtype /Link /Rect [0 0 1 1] /A << /S /URI /URI ({uri}) >> >>] >> endobj\n\
6 0 obj ({}) endobj\n\
trailer << /Root 1 0 R >>\n%%EOF",
"x".repeat(1 << 20),
);
let doc = Document::load_mem(pdf.as_bytes()).expect("loads");
let page = PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() };
let mut info = DocInfo { pages: vec![page], ..Default::default() };
Inspector::new(&doc).fill(&mut info);
assert!(info.attachments.iter().all(|a| a.name == "a.txt"));
assert_eq!(info.links.len(), 1);
assert!(matches!(&info.links[0].target, LinkTarget::Uri(u) if *u == uri));
}
#[test]
fn link_hit_test_rejects_points_outside_the_rect_before_scanning_quads() {
// A quadrilateral reaching past `/Rect` (hand-built: `link_quads` keeps none beyond the
// slack) shows whether the bounds gate runs before the quadrilateral scan.
let link = Link {
page: 0,
index: 0,
rect: [0.0, 0.0, 10.0, 10.0],
quads: vec![[0.0, 0.0, 50.0, 0.0, 50.0, 10.0, 0.0, 10.0]],
target: LinkTarget::Other("URI".into()),
};
assert!(link.contains(5.0, 5.0), "inside the rect and the quadrilateral");
assert!(link.contains(10.005, 5.0), "within the slack `link_quads` accepts");
assert!(!link.contains(30.0, 5.0), "outside the rect: no quadrilateral is scanned");
assert!(!link.contains(f32::NAN, 5.0));
}
#[test]
fn a_link_whose_quads_have_no_area_is_active_over_its_rect() {
let rect = [10.0, 10.0, 200.0, 30.0];
let link = |values: &[f32]| Link {
page: 0,
index: 0,
rect,
quads: crate::link_quads(&values.iter().map(|v| Some(*v)).collect::<Vec<_>>(), rect),
target: LinkTarget::Other("URI".into()),
};
let flat = [10.0, 20.0, 60.0, 20.0, 60.0, 20.0, 10.0, 20.0];
assert!(link(&flat).contains(100.0, 25.0), "no usable quad: the whole /Rect");
let good = [10.0, 10.0, 60.0, 10.0, 60.0, 30.0, 10.0, 30.0];
let mixed: Vec<f32> = flat.iter().chain(good.iter()).copied().collect();
assert!(link(&mixed).contains(30.0, 25.0), "inside the usable quad");
assert!(!link(&mixed).contains(100.0, 25.0), "the usable quad still gates the /Rect");
}
fn links_of(catalog_extra: &str, annots: &str) -> DocInfo {
let pdf = format!(
"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R {catalog_extra} >> endobj\n\
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Annots [{annots}] >> endobj\n\
trailer << /Root 1 0 R >>\n%%EOF"
);
let doc = Document::load_mem(pdf.as_bytes()).expect("loads");
let page = PageInfo { width: 300.0, height: 300.0, crop: [0.0, 0.0, 300.0, 300.0], rotation: 0, label: String::new() };
let mut info = DocInfo { pages: vec![page], ..Default::default() };
Inspector::new(&doc).fill(&mut info);
info
}
fn uris(info: &DocInfo) -> Vec<String> {
info.links.iter().map(|l| if let LinkTarget::Uri(u) = &l.target { u.clone() } else { format!("{:?}", l.target) }).collect()
}
/// ISO 32000-2 §12.6.4.8, Table 211: a relative URI action is resolved against the catalog's
/// `/URI /Base`; absolute addresses, and a base that isn't a web address, change nothing.
#[test]
fn relative_uri_actions_resolve_against_the_catalog_base() {
let annots = "<< /Subtype /Link /Rect [0 0 10 10] /A << /S /URI /URI (page.html) >> >> \
<< /Subtype /Link /Rect [0 0 10 10] /A << /S /URI /URI (javascript:alert(1)) >> >> \
<< /Subtype /Link /Rect [0 0 10 10] /A << /S /URI /URI (https://example.org/abs) >> >>";
let info = links_of("/URI << /Base (https://example.com/base/) >>", annots);
assert_eq!(info.uri_base.as_deref(), Some("https://example.com/base/"));
assert_eq!(uris(&info), ["https://example.com/base/page.html", "javascript:alert(1)", "https://example.org/abs"]);
// An indirect URI dictionary is followed too.
let pdf_indirect = links_of("/URI 4 0 R >> endobj\n4 0 obj << /Base (https://example.com/b/) >> endobj\n5 0 obj <<", annots);
assert_eq!(uris(&pdf_indirect).first().map(String::as_str), Some("https://example.com/b/page.html"));
// Without a base, or with one that isn't a web address, the address stays as written.
assert_eq!(uris(&links_of("", annots)).first().map(String::as_str), Some("page.html"));
for base in ["/URI << /Base (file:///C:/docs/) >>", "/URI << /Base /NotAString >>", "/URI 7", "/URI << /Base (javascript:x//) >>"] {
assert_eq!(uris(&links_of(base, annots)).first().map(String::as_str), Some("page.html"), "{base}");
}
}
/// ISO 32000-2 §12.5.6.5, Table 176: a link is active in its `/QuadPoints`, or over its
/// `/Rect` when they are missing, malformed or reach outside it.
#[test]
fn links_are_active_in_their_quad_points() {
let link = |quads: &str| format!("<< /Subtype /Link /Rect [10 10 200 30] {quads} /A << /S /URI /URI (https://example.org/) >> >>");
let info = links_of("", &link("/QuadPoints [10 10 60 10 60 30 10 30]"));
let l = &info.links[0];
assert_eq!(l.quads, vec![[10.0, 10.0, 60.0, 10.0, 60.0, 30.0, 10.0, 30.0]]);
assert!(l.contains(30.0, 20.0), "the first word");
assert!(!l.contains(150.0, 20.0), "inside /Rect but outside the quad");
for fallback in [
"",
"/QuadPoints [10 10 60 10 60 30 10]",
"/QuadPoints [10 10 60 10 60 30 10 (x)]",
"/QuadPoints [10 10 260 10 260 30 10 30]",
"/QuadPoints 5",
] {
let info = links_of("", &link(fallback));
let l = &info.links[0];
assert!(l.quads.is_empty(), "{fallback}");
assert!(l.contains(150.0, 20.0) && !l.contains(250.0, 20.0), "{fallback}: active over /Rect");
}
let many = "10 10 60 10 60 30 10 30 ".repeat(crate::MAX_LINK_QUADS + 1);
assert!(links_of("", &link(&format!("/QuadPoints [{many}]"))).links[0].quads.is_empty(), "too many quads");
}
/// Links sharing one long `/QuadPoints` array, or a relative address resolved against a long
/// `/URI /Base`, keep their copies only within `MAX_LINK_ATTACHMENT_TEXT_BYTES`, and every
/// link is still listed.
#[test]
fn links_sharing_quad_points_or_a_long_base_keep_them_within_a_budget() {
let page = PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() };
let fill = |catalog_extra: &str, links: &str, object5: &str| {
let pdf = format!(
"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R {catalog_extra} >> endobj\n\
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [{links}] >> endobj\n\
5 0 obj {object5} endobj\n\
trailer << /Root 1 0 R >>\n%%EOF"
);
let doc = Document::load_mem(pdf.as_bytes()).expect("loads");
let mut info = DocInfo { pages: vec![page.clone()], ..Default::default() };
Inspector::new(&doc).fill(&mut info);
info
};
let count = MAX_LINK_ATTACHMENT_TEXT_BYTES / (crate::MAX_LINK_QUADS * std::mem::size_of::<[f32; 8]>()) + 20;
let quads = format!("[{}]", "10 10 60 10 60 30 10 30 ".repeat(crate::MAX_LINK_QUADS));
let links = "<< /Subtype /Link /Rect [0 0 100 100] /QuadPoints 5 0 R /A << /S /URI /URI (https://example.org/) >> >> ".repeat(count);
super::annotations::QUAD_ARRAYS_READ.with(|n| n.set(0));
let info = fill("", &links, &quads);
assert_eq!(info.links.len(), count);
let kept: usize = info.links.iter().map(|l| std::mem::size_of_val(l.quads.as_slice())).sum();
assert!(kept > 0 && kept <= MAX_LINK_ATTACHMENT_TEXT_BYTES, "{kept} quad bytes kept");
assert!(info.links.iter().any(|l| l.quads.is_empty()), "past the budget a link is active over its /Rect");
// Review on #4474: the shared array is read once for each link it fits, and once more to find
// that it no longer does; not again for every link after that.
let fitted = info.links.iter().filter(|l| !l.quads.is_empty()).count();
let read = super::annotations::QUAD_ARRAYS_READ.with(std::cell::Cell::get);
assert_eq!(read, fitted + 1, "{read} reads for {fitted} links within the budget, of {count}");
let base = format!("https://example.com/{}/", "b".repeat(crate::MAX_URI_BASE_BYTES - 32));
let count = MAX_LINK_ATTACHMENT_TEXT_BYTES / base.len() + 20;
let links = "<< /Subtype /Link /Rect [0 0 1 1] /A 5 0 R >> ".repeat(count);
let info = fill(&format!("/URI << /Base ({base}) >>"), &links, "<< /S /URI /URI (p.html) >>");
assert_eq!(info.links.len(), count);
let addresses = uris(&info);
let kept: usize = addresses.iter().map(String::len).sum();
assert!(kept <= MAX_LINK_ATTACHMENT_TEXT_BYTES, "{kept} address bytes kept");
assert_eq!(addresses.first(), Some(&format!("{base}p.html")));
assert!(addresses.iter().any(|u| u == "p.html"), "past the budget a link keeps its raw address");
}
/// Embedded files whose name-tree keys share one long string keep it, as key and as name, only
/// within `MAX_LINK_ATTACHMENT_TEXT_BYTES`.
#[test]
fn embedded_file_keys_sharing_a_long_string_keep_it_within_a_budget() {
let entries = "4 0 R << /Type /Filespec >> ".repeat(20);
let pdf = format!(
"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles << /Names [{entries}] >> >> >> endobj\n\
2 0 obj << /Type /Pages /Kids [] /Count 0 >> endobj\n\
4 0 obj ({}) endobj\n\
trailer << /Root 1 0 R >>\n%%EOF",
"x".repeat(1 << 20),
);
let doc = Document::load_mem(pdf.as_bytes()).expect("loads");
let mut info = DocInfo::default();
Inspector::new(&doc).fill(&mut info);
let kept: usize =
info.attachments.iter().map(|a| a.name.len() + if let AttachmentSource::Document { key } = &a.source { key.len() } else { 0 }).sum();
assert!(!info.attachments.is_empty());
// Past the budget an attachment still has the generic name.
let generic = info.attachments.len() * "attachment".len();
assert!(kept <= MAX_LINK_ATTACHMENT_TEXT_BYTES + generic, "{kept} bytes kept");
}
/// An embedded file the listing keeps is extracted, though earlier keys the listing could not
/// keep would fill a fresh key budget before it.
#[test]
fn a_listed_embedded_file_is_extracted_past_keys_the_listing_left_out() {
let skipped = "6 0 R 7 0 R ".repeat(9);
let pdf = format!(
"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles << /Names [{skipped}(b.txt) 9 0 R] >> >> >> endobj\n\
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [\
<< /Subtype /Link /Rect [0 0 1 1] /A << /S /URI /URI ({}) >> >>] >> endobj\n\
6 0 obj ({}) endobj\n\
7 0 obj << /Type /Filespec /F (a.txt) >> endobj\n\
8 0 obj << /Type /EmbeddedFile /Length 5 >> stream\nhello\nendstream endobj\n\
9 0 obj << /Type /Filespec /F (b.txt) /EF << /F 8 0 R >> >> endobj\n\
trailer << /Root 1 0 R >>\n%%EOF",
"u".repeat(15 << 20),
"k".repeat(2 << 20),
);
let bytes = Arc::new(pdf.into_bytes());
let info = inspect(bytes.clone(), None).expect("opens");
let listed = info.attachments.iter().find(|a| a.name == "b.txt").expect("b.txt is listed");
assert_eq!(attachment_data(&bytes, None, listed).as_deref(), Ok(&b"hello"[..]));
}
/// Links and attachments sharing one long string each keep a copy only within
/// `MAX_LINK_ATTACHMENT_TEXT_BYTES`, and every one is still listed.
#[test]
fn links_and_attachments_sharing_a_long_string_keep_it_within_a_budget() {
const EACH: usize = 20;
let links = "<< /Subtype /Link /Rect [0 0 1 1] /A 5 0 R >> ".repeat(EACH);
let files = "<< /Subtype /FileAttachment /Rect [0 0 1 1] /FS 7 0 R >> ".repeat(EACH);
let pdf = format!(
"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [{links} {files}] >> endobj\n\
5 0 obj << /S /URI /URI 6 0 R >> endobj\n\
6 0 obj ({}) endobj\n\
7 0 obj << /Type /Filespec /F (a.txt) /Desc 6 0 R >> endobj\n\
trailer << /Root 1 0 R >>\n%%EOF",
"x".repeat(1 << 20),
);
let doc = Document::load_mem(pdf.as_bytes()).expect("loads");
let page = PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() };
let mut info = DocInfo { pages: vec![page], ..Default::default() };
Inspector::new(&doc).fill(&mut info);
assert_eq!(info.links.len(), EACH);
assert_eq!(info.attachments.len(), EACH);
let uris = info.links.iter().map(|l| if let LinkTarget::Uri(u) = &l.target { u.len() } else { 0 });
let descriptions = info.attachments.iter().map(|a| a.description.as_ref().map_or(0, String::len));
let kept: usize = uris.chain(descriptions).sum();
assert!(kept > 0 && kept <= MAX_LINK_ATTACHMENT_TEXT_BYTES, "{kept} bytes kept");
assert!(info.links.iter().any(|l| matches!(&l.target, LinkTarget::Other(s) if s == "URI")));
}
/// Links and attachments after the comment budget fills are still listed, and a JavaScript link
/// among them still marks the document.
#[test]
fn links_and_attachments_past_the_comment_budget_are_listed() {
let small = "<< /Subtype /Text /Rect [0 0 1 1] /Contents 6 0 R >> ".repeat(1100);
let pdf = format!(
"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [{small} \
<< /Subtype /Link /Rect [0 0 1 1] /A << /S /JavaScript /JS (1) >> >>] >> endobj\n\
4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [\
<< /Subtype /FileAttachment /Rect [0 0 1 1] /FS << /Type /Filespec /F (a.txt) >> >>] >> endobj\n\
6 0 obj ({}) endobj\ntrailer << /Root 1 0 R >>\n%%EOF",
"x".repeat(64 << 10),
);
let doc = Document::load_mem(pdf.as_bytes()).expect("loads");
let page = PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() };
let mut info = DocInfo { pages: vec![page.clone(), page], ..Default::default() };
Inspector::new(&doc).fill(&mut info);
assert!(info.annotations.len() < 1100, "the comment budget filled");
assert_eq!(info.links.len(), 1);
assert!(info.has_javascript);
assert_eq!(info.attachments.len(), 1);
}
/// Widgets without `/P` are placed on the page that lists them with one pass over the pages'
/// `/Annots`, not a search of every page per field: that was O(fields × annotations), a
/// second to open a form of 8,000 fields. A widget two pages list is on the first of them.
#[test]
fn widgets_without_a_page_entry_are_placed_in_one_pass() {
const PAGES: usize = 100;
const PER_PAGE: usize = 10;
let field = |p: usize, k: usize| 4 + PAGES + p * PER_PAGE + k;
let mut pdf = String::from("%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 3 0 R >> endobj\n");
let kids: Vec<String> = (0..PAGES).map(|p| format!("{} 0 R", 4 + p)).collect();
pdf += &format!("2 0 obj << /Type /Pages /Kids [{}] /Count {PAGES} >> endobj\n", kids.join(" "));
let fields: Vec<String> = (0..PAGES).flat_map(|p| (0..PER_PAGE).map(move |k| format!("{} 0 R", field(p, k)))).collect();
pdf += &format!("3 0 obj << /Fields [{}] >> endobj\n", fields.join(" "));
for p in 0..PAGES {
let mut annots: Vec<String> = (0..PER_PAGE).map(|k| format!("{} 0 R", field(p, k))).collect();
// The last page lists the first page's first widget too.
if p == PAGES - 1 {
annots.push(format!("{} 0 R", field(0, 0)));
}
pdf += &format!("{} 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [{}] >> endobj\n", 4 + p, annots.join(" "));
}
for p in 0..PAGES {
for k in 0..PER_PAGE {
pdf += &format!("{} 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (f{p}_{k}) /Rect [10 10 90 30] >> endobj\n", field(p, k));
}
}
pdf += "trailer << /Root 1 0 R >>\n%%EOF";
let doc = Document::load_mem(pdf.as_bytes()).expect("loads");
let counting = CountingReads { doc: &doc, reads: std::cell::Cell::new(0) };
let mut info = DocInfo {
pages: (0..PAGES)
.map(|_| PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() })
.collect(),
..Default::default()
};
Inspector::new(&counting).fill(&mut info);
let placed: Vec<_> = info.fields.iter().map(|f| (f.name.clone(), f.page)).collect();
let want: Vec<_> = (0..PAGES).flat_map(|p| (0..PER_PAGE).map(move |k| (format!("f{p}_{k}"), Some(p)))).collect();
assert_eq!(placed, want);
// A handful of reads per page and field; searching the pages for each field read the
// page dictionaries about 50,000 times.
let reads = counting.reads.get();
assert!(reads < 20 * (PAGES + PAGES * PER_PAGE), "{reads} object reads");
}
/// #2365: the issue's fixture is two PDFs concatenated, the second cut off before its
/// cross-reference data, and the first's only trailer is a cross-reference stream. The
/// lazy reader refuses a file that needed repair, and lopdf's rebuild found no `trailer`
/// keyword and failed, so Document Properties showed every field empty. The rebuild now
/// takes the cross-reference stream's dictionary as the trailer.
#[test]
fn rebuilt_file_keeps_the_info_of_its_cross_reference_stream() {
let header = "%PDF-1.5\n";
let obj1 = "1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n";
let obj2 = "2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n";
let obj3 = "3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 10 10]>>endobj\n";
let obj4 = "4 0 obj<</Title(Kept)/Creator(Me)>>endobj\n";
let o1 = header.len();
let o2 = o1 + obj1.len();
let o3 = o2 + obj2.len();
let o4 = o3 + obj3.len();
let o5 = o4 + obj4.len();
let rows: Vec<u8> =
[[0u8, 0, 0, 255], [1, 0, o1 as u8, 0], [1, 0, o2 as u8, 0], [1, 0, o3 as u8, 0], [1, 0, o4 as u8, 0], [1, (o5 >> 8) as u8, o5 as u8, 0]]
.concat();
let mut bytes = format!("{header}{obj1}{obj2}{obj3}{obj4}").into_bytes();
bytes.extend_from_slice(format!("5 0 obj<</Type/XRef/Root 1 0 R/Info 4 0 R/Size 6/W[1 2 1]/Length {}>>stream\n", rows.len()).as_bytes());
bytes.extend_from_slice(&rows);
// A wrong startxref, and a second file after %%EOF: every reader rebuilds the table.
bytes.extend_from_slice(b"\nendstream\nendobj\nstartxref\n999999\n%%EOF\n%PDF-1.3\n");
bytes.extend_from_slice(&[b'x'; 1024]);
let info = inspect(Arc::new(bytes), None).unwrap();
assert_eq!(info.pages.len(), 1);
assert_eq!(info.title.as_deref(), Some("Kept"));
assert_eq!(info.creator.as_deref(), Some("Me"));
}
/// #2365 review: the rebuild prefers a trailer whose /Root is the catalog. A later
/// cross-reference stream whose /Root points at the Info dictionary used to replace the
/// earlier `trailer`, so Document Properties lost the catalog it should have read.
#[test]
fn rebuilt_file_prefers_a_trailer_whose_root_is_the_catalog() {
let mut bytes = b"%PDF-1.5\n".to_vec();
for body in [
"<</Type/Catalog/Pages 2 0 R>>",
"<</Type/Pages/Kids[3 0 R]/Count 1>>",
"<</Type/Page/Parent 2 0 R/MediaBox[0 0 10 10]>>",
"<</Title(Kept)/Creator(Me)>>",
] {
let n = bytes.iter().filter(|b| **b == b'\n').count();
bytes.extend_from_slice(format!("{n} 0 obj{body}endobj\n").as_bytes());
}
bytes.extend_from_slice(b"trailer\n<</Size 5/Root 1 0 R/Info 4 0 R>>\n");
bytes.extend_from_slice(b"5 0 obj<</Type/XRef/Root 4 0 R/Size 6/W[1 2 1]/Length 4>>stream\n\x01\x00\x09\x00\nendstream\nendobj\n");
// A wrong startxref, and a second file after %%EOF: every reader rebuilds the table.
bytes.extend_from_slice(b"startxref\n999999\n%%EOF\n%PDF-1.3\n");
bytes.extend_from_slice(&[b'x'; 1024]);
let info = inspect(Arc::new(bytes.clone()), None).unwrap();
assert_eq!(info.pages.len(), 1);
assert_eq!(info.title.as_deref(), Some("Kept"));
// A later stream whose /Root names the catalog's number with another generation does
// not count either: loading `1 1 R` finds no such object.
let at = bytes.windows(10).position(|w| w == b"/Root 4 0 ").unwrap();
bytes[at..at + 10].copy_from_slice(b"/Root 1 1 ");
let info = inspect(Arc::new(bytes), None).unwrap();
assert_eq!((info.pages.len(), info.title.as_deref()), (1, Some("Kept")));
}
#[test]
fn lazy_structure_matches_the_compatibility_inspector() {
// Each fixture as lopdf writes it, and with its objects in object streams.
let packed = |bytes: &[u8]| {
let doc = pdfcraft_cos::Document::open(Arc::new(bytes.to_vec())).unwrap();
pdfcraft_cos::write_full(&doc, &pdfcraft_cos::SaveOptions { object_streams: true, ..Default::default() }).unwrap()
};
let fixtures = [ATTACHMENTS, LAYERS, DESTS].into_iter().flat_map(|bytes| {
let mut original = Document::load_mem(bytes).unwrap();
let mut plain = Vec::new();
original.save_to(&mut plain).unwrap();
let stm = packed(&plain);
[plain, stm]
});
for bytes in fixtures {
let lazy = LazyStructure::new(Arc::new(bytes.clone()), None).unwrap();
let eager = Document::load_mem(&bytes).unwrap();
let mut a = inspect(Arc::new(bytes), None).unwrap();
let mut b = DocInfo { pages: a.pages.clone(), ..Default::default() };
let mut c = DocInfo { pages: a.pages.clone(), ..Default::default() };
Inspector::new(&lazy).fill(&mut b);
Inspector::new(&eager).fill(&mut c);
assert!(!lazy.failed());
assert_eq!(format!("{b:?}"), format!("{c:?}"));
a.file_size = 0;
a.pdf_version.clear();
assert_eq!(format!("{a:?}"), format!("{c:?}"));
}
}
#[test]
fn links_read_set_layer_actions() {
let info = inspect(Arc::new(LAYERS.to_vec()), None).expect("opens");
let targets: Vec<_> = info.links.iter().map(|l| l.target.clone()).collect();
use LayerOp::{Off, On, Toggle};
assert_eq!(
targets,
[
// Groups before the first name or after an unknown one are skipped, and so is
// anything that isn't a group.
LinkTarget::SetLayers { changes: vec![(On, (6, 0)), (Toggle, (5, 0)), (Toggle, (7, 0))], preserve_rb: false },
// An indirect /State; /PreserveRB defaults to true.
LinkTarget::SetLayers { changes: vec![(Off, (5, 0))], preserve_rb: true },
]
);
}
/// #4168: a Launch action naming a `.pdf` file is read as the link to that file it amounts
/// to, `\` separators included, and a stray `/D` (which Launch has no use for) doesn't stop
/// it. One naming anything else, with no file, or with `/Win` launch parameters stays an
/// action that isn't followed.
#[test]
fn launch_actions_that_name_a_pdf_open_it_like_a_remote_link() {
let pdf = "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /Annots [30 0 R 31 0 R 32 0 R 33 0 R 34 0 R 35 0 R 36 0 R 37 0 R 38 0 R] >> endobj\n\
30 0 obj << /Type /Annot /Subtype /Link /Rect [0 0 10 10] /A << /S /Launch /F (..\\\\se\\\\se_ST.pdf) >> >> endobj\n\
31 0 obj << /Type /Annot /Subtype /Link /Rect [0 20 10 30] /A << /S /Launch /F << /Type /Filespec /F (chapters/Two.PDF) >> /NewWindow true >> >> endobj\n\
32 0 obj << /Type /Annot /Subtype /Link /Rect [0 40 10 50] /A << /S /Launch /F (tool.exe) >> >> endobj\n\
33 0 obj << /Type /Annot /Subtype /Link /Rect [0 60 10 70] /A << /S /Launch /F (a.pdf) /Win << /F (cmd.exe) /P (/c x) >> >> >> endobj\n\
34 0 obj << /Type /Annot /Subtype /Link /Rect [0 80 10 90] /A << /S /Launch /Win << /F (b.pdf) >> >> >> endobj\n\
35 0 obj << /Type /Annot /Subtype /Link /Rect [0 100 10 110] /A << /S /Launch /F (.pdf) >> >> endobj\n\
36 0 obj << /Type /Annot /Subtype /Link /Rect [0 120 10 130] /A << /S /Launch /F (dir/a\\\\b.pdf) >> >> endobj\n\
37 0 obj << /Type /Annot /Subtype /Link /Rect [0 140 10 150] /A << /S /Launch /FS /URL /F (https://example.com/x.pdf) >> >> endobj\n\
38 0 obj << /Type /Annot /Subtype /Link /Rect [0 160 10 170] /A << /S /Launch /F (c.pdf) /D 0 >> >> endobj\n\
trailer << /Root 1 0 R >>\n%%EOF";
let info = inspect(Arc::new(pdf.as_bytes().to_vec()), None).expect("opens");
let targets: Vec<_> = info.links.iter().map(|l| l.target.clone()).collect();
let launch =
|file: &str, new_window| LinkTarget::RemoteGoTo { launch: true, file: file.into(), destination: RemoteDestination::Default, new_window };
let other = || LinkTarget::Other("Launch".into());
assert_eq!(
targets,
[
launch("../se/se_ST.pdf", None),
launch("chapters/Two.PDF", Some(true)),
other(),
other(),
other(),
other(),
launch("dir/a\\b.pdf", None),
other(),
launch("c.pdf", None),
]
);
}
#[test]
fn links_read_bounded_local_remote_go_to_actions() {
use DestView::{Fit, Xyz};
let too_long = "a".repeat(MAX_REMOTE_FILE_SPEC_BYTES + 1);
let wide_file = format!("<FEFF{}>", "4E00".repeat(1400));
let wide_name = format!("<FEFF{}>", "4E00".repeat(342));
let pdf = format!(
"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /Annots [99 0 R 30 0 R 31 0 R 32 0 R 33 0 R 34 0 R 35 0 R 36 0 R 37 0 R 38 0 R 39 0 R 40 0 R 41 0 R] >> endobj\n\
30 0 obj << /Type /Annot /Subtype /Link /Rect [0 0 10 10] /A << /S /GoToR /F (chapters/remote.pdf) /D [2 /XYZ 10 20 1.5] /NewWindow true >> >> endobj\n\
31 0 obj << /Type /Annot /Subtype /Link /Rect [0 20 10 30] /A << /S /GoToR /F << /Type /Filespec /F (fallback.pdf) /UF <FEFF0066006F006F002E007000640066> >> /D [0 /Fit] /NewWindow false >> >> endobj\n\
32 0 obj << /Type /Annot /Subtype /Link /Rect [0 40 10 50] /A << /S /GoToR /F (remote.pdf) >> >> endobj\n\
33 0 obj << /Type /Annot /Subtype /Link /Rect [0 60 10 70] /A << /S /GoToR /F (chapter.pdf) /D (chapter) >> >> endobj\n\
34 0 obj << /Type /Annot /Subtype /Link /Rect [0 80 10 90] /A << /S /GoToR /FS /URL /F (https://example.com) /D [0 /Fit] >> >> endobj\n\
35 0 obj << /Type /Annot /Subtype /Link /Rect [0 100 10 110] /A << /S /GoToR /F ({too_long}) /D [0 /Fit] >> >> endobj\n\
36 0 obj << /Type /Annot /Subtype /Link /Rect [0 120 10 130] /A << /S /GoToR /F (chapter.pdf) /D /chapter >> >> endobj\n\
37 0 obj << /Type /Annot /Subtype /Link /Rect [0 140 10 150] /A << /S /GoToR /F (chapter.pdf) /D [-1 /Fit] >> >> endobj\n\
38 0 obj << /Type /Annot /Subtype /Link /Rect [0 160 10 170] /A << /S /GoToR /F (chapter.pdf) /D [1.5 /Fit] >> >> endobj\n\
39 0 obj << /Type /Annot /Subtype /Link /Rect [0 180 10 190] /A << /S /GoToR /F (chapter.pdf) /D [9223372036854775807 /Fit] >> >> endobj\n\
40 0 obj << /Type /Annot /Subtype /Link /Rect [0 200 10 210] /A << /S /GoToR /F << /Type /Filespec /UF {wide_file} >> /D [0 /Fit] >> >> endobj\n\
41 0 obj << /Type /Annot /Subtype /Link /Rect [0 220 10 230] /A << /S /GoToR /F (chapter.pdf) /D {wide_name} >> >> endobj\n\
trailer << /Root 1 0 R >>\n%%EOF"
);
let info = inspect(Arc::new(pdf.into_bytes()), None).expect("opens");
let targets: Vec<_> = info.links.iter().map(|l| (l.index, l.target.clone())).collect();
let huge_page_target = usize::try_from(i64::MAX)
.map(|page| LinkTarget::RemoteGoTo {
launch: false,
file: "chapter.pdf".into(),
destination: RemoteDestination::Page { page, view: Fit },
new_window: None,
})
.unwrap_or_else(|_| LinkTarget::Other("GoToR".into()));
assert_eq!(
targets,
[
(
1,
LinkTarget::RemoteGoTo {
launch: false,
file: "chapters/remote.pdf".into(),
destination: RemoteDestination::Page { page: 2, view: Xyz { left: Some(10.0), top: Some(20.0), zoom: Some(1.5) } },
new_window: Some(true),
}
),
(
2,
LinkTarget::RemoteGoTo {
launch: false,
file: "foo.pdf".into(),
destination: RemoteDestination::Page { page: 0, view: Fit },
new_window: Some(false),
}
),
(3, LinkTarget::RemoteGoTo { launch: false, file: "remote.pdf".into(), destination: RemoteDestination::Default, new_window: None }),
(
4,
LinkTarget::RemoteGoTo {
launch: false,
file: "chapter.pdf".into(),
destination: RemoteDestination::Named("chapter".into()),
new_window: None
}
),
(5, LinkTarget::Other("GoToR".into())),
(6, LinkTarget::Other("GoToR".into())),
(
7,
LinkTarget::RemoteGoTo {
launch: false,
file: "chapter.pdf".into(),
destination: RemoteDestination::Named("chapter".into()),
new_window: None
}
),
(8, LinkTarget::Other("GoToR".into())),
(9, LinkTarget::Other("GoToR".into())),
(10, huge_page_target),
(11, LinkTarget::Other("GoToR".into())),
(12, LinkTarget::Other("GoToR".into())),
]
);
}
#[test]
fn destination_operands_become_views() {
use DestView::*;
let v = |kind: &[u8], ops: &[Option<f32>]| DestView::from_operands(Some(kind), ops);
// ISO 32000-2 Table 149, each type.
assert_eq!(v(b"XYZ", &[Some(0.0), Some(420.0), Some(0.0)]), Xyz { left: Some(0.0), top: Some(420.0), zoom: None });
assert_eq!(v(b"XYZ", &[Some(72.0), Some(500.0), Some(1.5)]), Xyz { left: Some(72.0), top: Some(500.0), zoom: Some(1.5) });
assert_eq!(v(b"Fit", &[]), Fit);
assert_eq!(v(b"FitB", &[]), Fit);
assert_eq!(v(b"FitH", &[Some(250.0)]), FitH { top: Some(250.0) });
assert_eq!(v(b"FitBH", &[None]), FitH { top: None });
assert_eq!(v(b"FitV", &[Some(120.0)]), FitV { left: Some(120.0) });
assert_eq!(v(b"FitBV", &[Some(5.0)]), FitV { left: Some(5.0) });
// FitR's corners are normalized.
assert_eq!(v(b"FitR", &[Some(110.0), Some(220.0), Some(10.0), Some(20.0)]), FitR { rect: [10.0, 20.0, 110.0, 220.0] });
// Missing operands are null; so are non-finite ones, and a zoom that isn't positive.
assert_eq!(v(b"XYZ", &[]), Xyz { left: None, top: None, zoom: None });
assert_eq!(v(b"XYZ", &[Some(f32::NAN), Some(f32::INFINITY), Some(f32::NEG_INFINITY)]), Xyz { left: None, top: None, zoom: None });
assert_eq!(v(b"XYZ", &[None, Some(-5.0), Some(-1.0)]), Xyz { left: None, top: Some(-5.0), zoom: None });
assert_eq!(v(b"XYZ", &[None, None, Some(f32::NAN)]), Xyz { left: None, top: None, zoom: None });
assert_eq!(v(b"FitH", &[Some(f32::NAN)]), FitH { top: None });
// A FitR without four usable numbers, an unknown type or no type: the top of the page.
assert_eq!(v(b"FitR", &[Some(1.0), Some(2.0)]), Top);
assert_eq!(v(b"FitR", &[Some(1.0), Some(2.0), Some(f32::INFINITY), Some(4.0)]), Top);
assert_eq!(v(b"Zoom", &[Some(3.0)]), Top);
assert_eq!(v(b"xyz", &[Some(3.0)]), Top);
assert_eq!(DestView::from_operands(None, &[Some(1.0)]), Top);
}
/// Two 300×400 pages. Bookmarks and links on page 1 go to page 2 in every destination form,
/// named (`/Names /Dests` and the PDF 1.1 `/Dests`) and malformed.
const DESTS: &[u8] = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R /Outlines 10 0 R /Names << /Dests 40 0 R >> /Dests << /old << /D [4 0 R /FitV 120] >> >> >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /Annots [30 0 R 31 0 R 32 0 R 33 0 R] >> endobj
4 0 obj << /Type /Page /Parent 2 0 R >> endobj
10 0 obj << /Type /Outlines /First 11 0 R /Last 20 0 R /Count 10 >> endobj
11 0 obj << /Title (xyz) /Parent 10 0 R /Next 12 0 R /Dest [4 0 R /XYZ 0 420 0] >> endobj
12 0 obj << /Title (fith) /Parent 10 0 R /Next 13 0 R /Dest [4 0 R /FitH 250] >> endobj
13 0 obj << /Title (named) /Parent 10 0 R /Next 14 0 R /Dest (sec) >> endobj
14 0 obj << /Title (old named) /Parent 10 0 R /Next 15 0 R /Dest /old >> endobj
15 0 obj << /Title (action) /Parent 10 0 R /Next 16 0 R /A << /S /GoTo /D [4 0 R /FitR 110 220 10 20] >> >> endobj
16 0 obj << /Title (wrong types) /Parent 10 0 R /Next 17 0 R /Dest [4 0 R /XYZ (left) /Top true] >> endobj
17 0 obj << /Title (short) /Parent 10 0 R /Next 18 0 R /Dest [4 0 R /FitR 1 2] >> endobj
18 0 obj << /Title (no type) /Parent 10 0 R /Next 19 0 R /Dest [4 0 R] >> endobj
19 0 obj << /Title (huge) /Parent 10 0 R /Next 20 0 R /Dest [4 0 R /XYZ 1000000000000 -5 -1] >> endobj
20 0 obj << /Title (no page) /Parent 10 0 R /Dest [/XYZ 0 420 0] >> endobj
30 0 obj << /Type /Annot /Subtype /Link /Rect [0 0 10 10] /Dest [4 0 R /XYZ 72 500 1.5] >> endobj
31 0 obj << /Type /Annot /Subtype /Link /Rect [0 20 10 30] /A << /S /GoTo /D (sec) >> >> endobj
32 0 obj << /Type /Annot /Subtype /Link /Rect [0 40 10 50] /Dest [4 0 R /FitBH 100] >> endobj
33 0 obj << /Type /Annot /Subtype /Link /Rect [0 60 10 70] /Dest [4 0 R /Fit] >> endobj
40 0 obj << /Names [(sec) << /D [4 0 R /XYZ null 300 2] >>] >> endobj
trailer << /Root 1 0 R >>
%%EOF";
#[test]
fn bookmarks_and_links_keep_their_destination_view() {
use DestView::*;
let info = inspect(Arc::new(DESTS.to_vec()), None).expect("opens");
let outline: Vec<_> = info.outline.iter().map(|o| (o.title.as_str(), o.page, o.view)).collect();
assert_eq!(
outline,
[
("xyz", Some(1), Xyz { left: Some(0.0), top: Some(420.0), zoom: None }),
("fith", Some(1), FitH { top: Some(250.0) }),
("named", Some(1), Xyz { left: None, top: Some(300.0), zoom: Some(2.0) }),
("old named", Some(1), FitV { left: Some(120.0) }),
("action", Some(1), FitR { rect: [10.0, 20.0, 110.0, 220.0] }),
// Hostile operands fall back without failing the bookmark.
("wrong types", Some(1), Xyz { left: None, top: None, zoom: None }),
("short", Some(1), Top),
("no type", Some(1), Top),
("huge", Some(1), Xyz { left: Some(1e12), top: Some(-5.0), zoom: None }),
("no page", None, Top),
]
);
let links: Vec<_> = info.links.iter().map(|l| l.target.clone()).collect();
assert_eq!(
links,
[
LinkTarget::Page(1, Xyz { left: Some(72.0), top: Some(500.0), zoom: Some(1.5) }),
LinkTarget::Page(1, Xyz { left: None, top: Some(300.0), zoom: Some(2.0) }),
LinkTarget::Page(1, FitH { top: Some(100.0) }),
LinkTarget::Page(1, Fit),
]
);
}
#[test]
fn outline_titles_decode_every_text_string_encoding() {
// Issue #142: CJK bookmark titles showed as mojibake or with a stray BOM.
let pdf = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R /Outlines 10 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj
3 0 obj << /Type /Page /Parent 2 0 R >> endobj
10 0 obj << /Type /Outlines /First 11 0 R /Last 16 0 R /Count 6 >> endobj
11 0 obj << /Title <FEFF7B2C4E007AE0> /Parent 10 0 R /Next 12 0 R /Dest [3 0 R /Fit] >> endobj
12 0 obj << /Title <EFBBBFE79BAEE5BD95> /Parent 10 0 R /Next 13 0 R /Dest [3 0 R /Fit] >> endobj
13 0 obj << /Title <EFBBBF41FF42> /Parent 10 0 R /Next 14 0 R /Dest [3 0 R /Fit] >> endobj
14 0 obj << /Title <E6A682E8BFB0> /Parent 10 0 R /Next 15 0 R /Dest [3 0 R /Fit] >> endobj
15 0 obj << /Title <436166E9> /Parent 10 0 R /Next 16 0 R /Dest [3 0 R /Fit] >> endobj
16 0 obj << /Title <FEFFFEFF0041> /Parent 10 0 R /Dest [3 0 R /Fit] >> endobj
trailer << /Root 1 0 R >>
%%EOF";
let info = inspect(Arc::new(pdf.to_vec()), None).expect("opens");
let titles: Vec<_> = info.outline.iter().map(|o| o.title.as_str()).collect();
// UTF-16BE, UTF-8 with BOM, invalid UTF-8 after a BOM (lossy, not dropped), BOM-less
// raw UTF-8, PDFDocEncoding Latin-1, and a doubled BOM.
assert_eq!(titles, ["第一章", "目录", "A\u{FFFD}B", "概述", "Café", "A"]);
}
#[test]
fn xfa_dynamic_render_is_read_from_config_acrobat7_only() {
let text = xfa_config_dynamic_render;
let config =
|inner: &str| format!("<config xmlns=\"http://www.xfa.org/schema/xci/3.0/\"><acrobat><acrobat7>{inner}</acrobat7></acrobat></config>");
// Designer's style: a line break before each `>`.
assert_eq!(
text("<config\n><acrobat\n><acrobat7\n><dynamicRender\n>forbidden</dynamicRender\n></acrobat7\n></acrobat\n></config\n>").as_deref(),
Some("forbidden")
);
assert_eq!(text(&config("<xci:dynamicRender> required </xci:dynamicRender>")).as_deref(), Some("required"));
// Commented out, in CDATA, or a longer name: not the element.
assert_eq!(text(&config("<!-- a > b <dynamicRender>forbidden</dynamicRender> -->")), None);
assert_eq!(text(&config("<![CDATA[ a > b <dynamicRender>forbidden</dynamicRender> ]]>")), None);
assert_eq!(text(&config("<dynamicRenderX>forbidden</dynamicRenderX>")), None);
// Self-closing, empty, or holding markup: no value.
assert_eq!(text(&config("<dynamicRender/>")), None);
assert_eq!(text(&config("<dynamicRender></dynamicRender>")), None);
assert_eq!(text(&config("<dynamicRender><b>forbidden</b></dynamicRender>")), None);
// Closing tags that don't match what they close: malformed, no value (review on #3192).
assert_eq!(text(&config("<dynamicRender>forbidden</other>")), None);
assert_eq!(text("<config><acrobat><acrobat7><dynamicRender>forbidden</dynamicRender></acrobat></acrobat7></config>"), None);
// The same name anywhere but config/acrobat/acrobat7 (form data, another config branch).
let xdp = "<xdp:xdp><xfa:datasets><xfa:data><dynamicRender>forbidden</dynamicRender></xfa:data></xfa:datasets><config><present><dynamicRender>forbidden</dynamicRender></present></config></xdp:xdp>";
assert_eq!(text(xdp), None);
// A whole XDP with the config after the template.
let xdp = format!("<xdp:xdp><template><subform/></template>{}</xdp:xdp>", config("<dynamicRender>forbidden</dynamicRender>"));
assert_eq!(text(&xdp).as_deref(), Some("forbidden"));
// A whole config path inside the form data, before the real config packet, is data: the
// real packet's value counts (review on #3192).
let xdp = format!(
"<?xml version=\"1.0\"?><xdp:xdp><xfa:datasets><xfa:data>{}</xfa:data></xfa:datasets>{}</xdp:xdp>",
config("<dynamicRender>forbidden</dynamicRender>"),
config("<dynamicRender>required</dynamicRender>")
);
assert_eq!(text(&xdp).as_deref(), Some("required"));
// Only in the data, or nested in the template: no configuration at all.
let xdp = format!("<xdp:xdp><xfa:datasets><xfa:data>{}</xfa:data></xfa:datasets></xdp:xdp>", config("<dynamicRender>forbidden</dynamicRender>"));
assert_eq!(text(&xdp), None);
let xdp = format!("<xdp:xdp><template>{}</template></xdp:xdp>", config("<dynamicRender>forbidden</dynamicRender>"));
assert_eq!(text(&xdp), None);
// acrobat7 must be a child of acrobat, not deeper.
assert_eq!(text("<config><acrobat><x><acrobat7><dynamicRender>forbidden</dynamicRender></acrobat7></x></acrobat></config>"), None);
// Cut short anywhere: no panic, nothing read.
let whole = config("<!-- x --><dynamicRender>forbidden</dynamicRender>");
for end in 0..whole.len() {
assert_eq!(text(&whole[..end]), None, "cut at {end}");
}
assert_eq!(text(&whole).as_deref(), Some("forbidden"));
}
#[test]
fn xfa_config_packets_are_read_in_utf16_too() {
let xml = "<config><acrobat><acrobat7><dynamicRender>forbidden</dynamicRender></acrobat7></acrobat></config>";
let le: Vec<u8> = xml.encode_utf16().flat_map(u16::to_le_bytes).collect();
let be: Vec<u8> = xml.encode_utf16().flat_map(u16::to_be_bytes).collect();
for bytes in [
le.clone(),
be.clone(),
[&[0xFF, 0xFE][..], &le].concat(),
[&[0xFE, 0xFF][..], &be].concat(),
[&[0xEF, 0xBB, 0xBF][..], xml.as_bytes()].concat(),
] {
assert_eq!(xfa_packet_text(&bytes), xml);
}
// An odd byte or a broken surrogate doesn't stop the scan.
assert!(xfa_packet_text(&[0xFF, 0xFE, b'<', 0, 0x00, 0xD8, b'x']).starts_with('<'));
}
/// #3180: an XFA form with no fields was always taken to be dynamic, so its pages were
/// replaced by a layout on open, even when its configuration says it is a static form.
#[test]
fn an_xfa_form_without_fields_is_static_when_its_config_forbids_dynamic_rendering() {
let xfa_of = |config: &str, catalog_extra: &str| {
let bodies = [
format!("<< /Type /Catalog /Pages 2 0 R {catalog_extra} /AcroForm << /Fields [] /XFA [(config) 4 0 R (template) 5 0 R] >> >>"),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".to_string(),
"<< /Type /Page /Parent 2 0 R >>".to_string(),
format!("<< /Length {} >>\nstream\n{config}\nendstream", config.len()),
"<< /Length 11 >>\nstream\n<template/>\nendstream".to_string(),
];
inspect(Arc::new(pdf_of(&bodies)), None).expect("opens").xfa
};
let config = |value: &str| {
format!(
r#"<config xmlns="http://www.xfa.org/schema/xci/3.0/"><acrobat><acrobat7><dynamicRender>{value}</dynamicRender></acrobat7></acrobat></config>"#
)
};
assert_eq!(xfa_of(&config("forbidden"), ""), Some(Xfa::Static));
assert_eq!(xfa_of(&config("required"), ""), Some(Xfa::Dynamic));
assert_eq!(xfa_of("<config/>", ""), Some(Xfa::Dynamic), "no field and no word from the config: dynamic, as before");
assert_eq!(xfa_of(&config("forbidden"), "/NeedsRendering true"), Some(Xfa::Dynamic), "/NeedsRendering still wins");
}
/// A file whose objects are `bodies` in order (object 1 first), with a cross-reference table.
fn pdf_of(bodies: &[String]) -> Vec<u8> {
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, body) in bodies.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", bodies.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 >>\nstartxref\n{xref}\n%%EOF\n", bodies.len() + 1).as_bytes());
out
}
/// A one-page file whose outline is `items` (objects 5, 6, … in order).
fn outline_file(items: &[String]) -> Vec<u8> {
let mut bodies = vec![
"<< /Type /Catalog /Pages 2 0 R /Outlines 4 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".to_string(),
"<< /Type /Page /Parent 2 0 R >>".to_string(),
format!("<< /Type /Outlines /First 5 0 R /Count {} >>", items.len()),
];
bodies.extend(items.iter().cloned());
pdf_of(&bodies)
}
/// The titles of `items`, with children in brackets (`Parent[Child]`).
fn shape(items: &[OutlineItem]) -> Vec<String> {
items.iter().map(|o| if o.children.is_empty() { o.title.clone() } else { format!("{}[{}]", o.title, shape(&o.children).join(",")) }).collect()
}
/// #3193: a bookmark's colour and style (ISO 32000-2 §12.3.3, Tables 151 and 152). `/C` is
/// three numbers within 0–1 (one outside is kept at the edge); black, the default, and a `/C`
/// that isn't three numbers read as no colour. `/F` bit 1 is italic, bit 2 bold.
#[test]
fn outline_items_carry_their_colour_and_style() {
let titles = [
("Plain", ""),
("Red", "/C [1 0 0]"),
("Italic", "/F 1"),
("Bold", "/F 2"),
("Both", "/C [0 0 1] /F 3"),
("Black", "/C [0 0 0]"),
("Bright", "/C [2 -1 0.5]"),
("Short", "/C [1 0]"),
("Long", "/C [1 0 0 0]"),
("Named", "/C [1 /x 0]"),
("Other bits", "/F 4"),
];
let n = titles.len();
let items: Vec<String> = titles
.iter()
.enumerate()
.map(|(k, (title, extra))| {
let prev = if k == 0 { String::new() } else { format!(" /Prev {} 0 R", 4 + k) };
let next = if k + 1 == n { String::new() } else { format!(" /Next {} 0 R", 6 + k) };
format!("<< /Title ({title}) /Parent 4 0 R{prev}{next} /Dest [3 0 R /Fit] {extra} >>")
})
.collect();
let info = inspect(Arc::new(outline_file(&items)), None).expect("opens");
let got: Vec<(&str, Option<[f32; 3]>, bool, bool)> = info.outline.iter().map(|o| (o.title.as_str(), o.color, o.italic, o.bold)).collect();
assert_eq!(
got,
[
("Plain", None, false, false),
("Red", Some([1.0, 0.0, 0.0]), false, false),
("Italic", None, true, false),
("Bold", None, false, true),
("Both", Some([0.0, 0.0, 1.0]), true, true),
("Black", None, false, false),
("Bright", Some([1.0, 0.0, 0.5]), false, false),
("Short", None, false, false),
("Long", None, false, false),
("Named", None, false, false),
("Other bits", None, false, false),
]
);
}
/// A bookmark's action (`/A`, ISO 32000-2 §12.3.3 Table 151) is read as a link's is. Only a
/// go-to's `/D` is a page of this document: a remote go-to's names a page or destination of the
/// other file (§12.6.4.3), so `[0 /FitH 200]` there is not this file's first page. A web address,
/// another PDF and actions PdfCraft doesn't run are kept for the bookmark to run or report. An
/// action without a type is still read as the go-to its `/D` suggests.
#[test]
fn bookmark_actions_are_read_as_links_read_them() {
use DestView::*;
let actions = [
("Go to", "/A << /S /GoTo /D [3 0 R /FitH 300] >>"),
("Web", "/A << /S /URI /URI (https://example.org/manual) >>"),
("Other file", "/A << /S /GoToR /F (other.pdf) /D [0 /FitH 200] /NewWindow true >>"),
("Other file, named", "/A << /S /GoToR /F (other.pdf) /D (sec) >>"),
("Launch", "/A << /S /Launch /F (other.pdf) >>"),
("Menu item", "/A << /S /Named /N /NextPage >>"),
("Script", "/A << /S /JavaScript /JS (app.alert\\(1\\)) >>"),
("No type", "/A << /D [3 0 R /Fit] >>"),
];
let n = actions.len();
let items: Vec<String> = actions
.iter()
.enumerate()
.map(|(k, (title, action))| {
let prev = if k == 0 { String::new() } else { format!(" /Prev {} 0 R", 4 + k) };
let next = if k + 1 == n { String::new() } else { format!(" /Next {} 0 R", 6 + k) };
format!("<< /Title ({title}) /Parent 4 0 R{prev}{next} {action} >>")
})
.collect();
let info = inspect(Arc::new(outline_file(&items)), None).expect("opens");
let got: Vec<_> = info.outline.iter().map(|o| (o.title.as_str(), o.page, o.view, o.action.clone())).collect();
let remote = |destination, new_window| Some(LinkTarget::RemoteGoTo { launch: false, file: "other.pdf".into(), destination, new_window });
assert_eq!(
got,
[
("Go to", Some(0), FitH { top: Some(300.0) }, None),
("Web", None, Top, Some(LinkTarget::Uri("https://example.org/manual".into()))),
("Other file", None, Top, remote(RemoteDestination::Page { page: 0, view: FitH { top: Some(200.0) } }, Some(true))),
("Other file, named", None, Top, remote(RemoteDestination::Named("sec".into()), None)),
(
"Launch",
None,
Top,
Some(LinkTarget::RemoteGoTo { launch: true, file: "other.pdf".into(), destination: RemoteDestination::Default, new_window: None })
),
("Menu item", None, Top, Some(LinkTarget::Other("Named".into()))),
("Script", None, Top, Some(LinkTarget::Other("JavaScript".into()))),
("No type", Some(0), Fit, None),
]
);
}
#[test]
fn outline_window_keeps_the_first_bookmarks_and_says_when_more_follow() {
const COUNT: usize = OUTLINE_WINDOW + 5;
let items: Vec<String> = (0..COUNT)
.map(|k| {
let prev = if k == 0 { String::new() } else { format!(" /Prev {} 0 R", 4 + k) };
let next = if k + 1 == COUNT { String::new() } else { format!(" /Next {} 0 R", 6 + k) };
format!("<< /Title (Item {k}) /Parent 4 0 R{prev}{next} /Dest [3 0 R /Fit] >>")
})
.collect();
let info = inspect(Arc::new(outline_file(&items)), None).expect("opens");
assert_eq!(info.outline.len(), OUTLINE_WINDOW);
assert_eq!(info.outline.last().map(|o| o.title.as_str()), Some("Item 9999"));
assert!(info.outline_more);
let one = inspect(Arc::new(outline_file(&["<< /Title (Only) /Parent 4 0 R /Dest [3 0 R /Fit] >>".into()])), None).expect("opens");
assert_eq!(shape(&one.outline), ["Only"]);
assert!(!one.outline_more);
}
#[test]
fn outline_window_nests_no_deeper_than_its_depth_limit() {
// A chain of 40 bookmarks, each the first child of the one before.
let items: Vec<String> = (0..40)
.map(|k| {
let parent = if k == 0 { 4 } else { 4 + k };
let first = if k + 1 == 40 { String::new() } else { format!(" /First {} 0 R", 6 + k) };
format!("<< /Title (Deep {k}) /Parent {parent} 0 R{first} /Dest [3 0 R /Fit] >>")
})
.collect();
let info = inspect(Arc::new(outline_file(&items)), None).expect("opens");
let mut levels = 0;
let mut level = info.outline.first();
while let Some(item) = level {
levels += 1;
level = item.children.first();
}
assert_eq!(levels, OUTLINE_DEPTH + 1);
assert!(info.outline_more);
}
#[test]
fn outline_window_ends_cycles_and_broken_links_without_looping() {
let listed = |items: &[&str]| {
let items: Vec<String> = items.iter().map(|s| s.to_string()).collect();
shape(&inspect(Arc::new(outline_file(&items)), None).expect("opens").outline)
};
assert_eq!(listed(&["<< /Title (Self) /Parent 4 0 R /Next 5 0 R >>"]), ["Self"]);
assert_eq!(listed(&["<< /Title (Self) /Parent 4 0 R /First 5 0 R >>"]), ["Self"]);
assert_eq!(listed(&["<< /Title (A) /Parent 4 0 R /Next 6 0 R >>", "<< /Title (B) /Parent 4 0 R /Next 5 0 R >>"]), ["A", "B"]);
assert_eq!(listed(&["<< /Title (A) /Parent 4 0 R /First 6 0 R >>", "<< /Title (B) /Parent 5 0 R /First 5 0 R >>"]), ["A[B]"]);
assert_eq!(listed(&["<< /Title (A) /Parent 4 0 R /Next 99 0 R >>"]), ["A"]);
assert_eq!(listed(&["<< /Title (A) /Parent 4 0 R /Next 6 0 R >>", "42"]), ["A"]);
}
#[test]
fn destination_points_map_to_the_displayed_page() {
// A crop box away from the origin: x 100..400, y 50..450.
let mut p = PageInfo { width: 300.0, height: 400.0, label: "1".into(), crop: [100.0, 50.0, 400.0, 450.0], rotation: 0 };
assert_eq!(p.dest_fraction(Some(100.0), Some(450.0), 0), [Some(0.0), Some(0.0)]);
assert_eq!(p.dest_fraction(Some(250.0), Some(350.0), 0), [Some(0.5), Some(0.25)]);
// Unspecified stays unspecified; off-page and huge values are clamped to the page.
assert_eq!(p.dest_fraction(None, Some(350.0), 0), [None, Some(0.25)]);
assert_eq!(p.dest_fraction(Some(-1e30), Some(1e30), 0), [Some(0.0), Some(0.0)]);
assert_eq!(p.dest_fraction(Some(f32::INFINITY), Some(f32::NAN), 0), [None, None]);
// /Rotate 90 (clockwise): user y runs left to right across the displayed page, user x
// top to bottom. A view rotation adds to it.
p.rotation = 90;
assert_eq!(p.dest_fraction(Some(250.0), Some(350.0), 0), [Some(0.75), Some(0.5)]);
assert_eq!(p.dest_fraction(None, Some(350.0), 0), [Some(0.75), None]);
assert_eq!(p.dest_fraction(Some(250.0), Some(350.0), 90), [Some(0.5), Some(0.75)]);
p.rotation = 180;
assert_eq!(p.dest_fraction(Some(250.0), Some(350.0), 0), [Some(0.5), Some(0.75)]);
p.rotation = 270;
assert_eq!(p.dest_fraction(Some(250.0), Some(350.0), 0), [Some(0.25), Some(0.5)]);
assert_eq!(p.dest_fraction(Some(250.0), Some(350.0), 90), [Some(0.5), Some(0.25)]);
// A degenerate crop box never divides by zero.
let flat = PageInfo { crop: [0.0, 0.0, 0.0, 0.0], rotation: 0, ..p };
assert!(flat.dest_fraction(Some(5.0), Some(-5.0), 0).iter().all(|f| f.is_some_and(|f| (0.0..=1.0).contains(&f))));
}
#[test]
fn a_displayed_point_becomes_the_xyz_position_that_goes_back_to_it() {
// Every rotation, on a crop box away from the origin: pinning a displayed point and
// reading the destination back lands on the same fraction of the displayed page.
for rotation in [0u16, 90, 180, 270] {
let (width, height) = if rotation.is_multiple_of(180) { (300.0, 400.0) } else { (400.0, 300.0) };
let p = PageInfo { width, height, label: "1".into(), crop: [100.0, 50.0, 400.0, 450.0], rotation };
let (x, y) = (0.25 * width, 0.75 * height);
let near = |got: [Option<f32>; 2], want: [Option<f32>; 2]| {
for (g, w) in got.iter().zip(want) {
match (g, w) {
(Some(g), Some(w)) => assert!((g - w).abs() < 1e-4, "{rotation}: {got:?} vs {want:?}"),
(None, None) => {}
_ => panic!("{rotation}: {got:?} vs {want:?}"),
}
}
};
let [l, t] = p.xyz_for_view(Some(x), Some(y));
assert!(l.is_some() && t.is_some(), "{rotation}: a point pins both");
near(p.dest_fraction(l, t, 0), [Some(0.25), Some(0.75)]);
// Only a height: the horizontal position is left alone, whichever user axis that is.
let [l, t] = p.xyz_for_view(None, Some(y));
assert_eq!((l.is_some(), t.is_some()), if rotation.is_multiple_of(180) { (false, true) } else { (true, false) }, "{rotation}");
near(p.dest_fraction(l, t, 0), [None, Some(0.75)]);
let [l, t] = p.xyz_for_view(Some(x), None);
near(p.dest_fraction(l, t, 0), [Some(0.25), None]);
assert_eq!(p.xyz_for_view(None, None), [None, None]);
assert_eq!(p.xyz_for_view(Some(f32::NAN), Some(f32::INFINITY)), [None, None], "not finite is not given");
}
// The top of an upright page is the crop box's top edge.
let p = PageInfo { width: 300.0, height: 400.0, label: "1".into(), crop: [100.0, 50.0, 400.0, 450.0], rotation: 0 };
assert_eq!(p.xyz_for_view(None, Some(0.0)), [None, Some(450.0)]);
assert_eq!(p.xyz_for_view(None, Some(100.0)), [None, Some(350.0)]);
}
#[test]
fn structure_failure_preserves_renderer_pages() {
for crash in [true, false] {
let mut info = DocInfo {
file_size: 321,
pdf_version: "1.7".into(),
pages: vec![PageInfo { width: 300.0, height: 200.0, label: "1".into(), crop: [10.0, 20.0, 210.0, 320.0], rotation: 90 }],
..Default::default()
};
inspect_structure(&mut info, |tmp| {
tmp.pages.clear();
tmp.title = Some("partially inspected".into());
if crash {
panic!("synthetic structure inspection failure");
}
Err("synthetic structure inspection failure".into())
});
assert_eq!(info.pages.len(), 1);
let page = &info.pages[0];
assert_eq!((page.width, page.height, page.label.as_str(), page.crop, page.rotation), (300.0, 200.0, "1", [10.0, 20.0, 210.0, 320.0], 90));
assert_eq!(info.file_size, 321);
assert_eq!(info.pdf_version, "1.7");
assert!(info.title.is_none());
assert_eq!(info.warnings.len(), 1);
assert!(info.warnings[0].contains("synthetic structure inspection failure"));
assert_eq!(info.warnings[0].contains("crashed"), crash);
}
}
#[test]
fn invalid_dates_with_unicode_are_unchanged() {
for input in ["", "D:", "D:20260930104", "D:202609x01045", "yesterday"] {
assert_eq!(pretty_date(input), input);
}
// Cover every position before the 12-byte prefix, including characters that
// straddle its end. None of these strings is an ASCII PDF date.
for character in ['é', '€', '😀'] {
for prefix_len in 0..12 {
let input = format!("D:{}{character}123456789012", "1".repeat(prefix_len));
assert_eq!(pretty_date(&input), input, "{character} after {prefix_len} digits");
}
}
}
#[test]
fn unicode_annotation_date_does_not_prevent_opening() {
use lopdf::dictionary;
let date = "D:12345678901éX";
let mut encoded_date = vec![0xFE, 0xFF];
encoded_date.extend(date.encode_utf16().flat_map(u16::to_be_bytes));
let mut doc = Document::with_version("1.7");
let pages_id = doc.new_object_id();
let annot_id = doc.add_object(dictionary! {
"Type" => "Annot",
"Subtype" => "Text",
"Rect" => vec![10.into(), 10.into(), 30.into(), 30.into()],
"M" => Object::String(encoded_date, lopdf::StringFormat::Hexadecimal),
});
let page_id = doc.add_object(dictionary! {
"Type" => "Page",
"Parent" => pages_id,
"MediaBox" => vec![0.into(), 0.into(), 200.into(), 300.into()],
"Annots" => vec![annot_id.into()],
});
doc.objects.insert(
pages_id,
dictionary! {
"Type" => "Pages",
"Kids" => vec![page_id.into()],
"Count" => 1,
}
.into(),
);
let catalog_id = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages_id });
doc.trailer.set("Root", catalog_id);
let mut bytes = Vec::new();
doc.save_to(&mut bytes).expect("write synthetic fixture");
let info = inspect(Arc::new(bytes), None).expect("opens despite a non-date /M string");
assert_eq!(info.pages.len(), 1);
assert_eq!((info.pages[0].width, info.pages[0].height), (200.0, 300.0));
assert_eq!(info.annotations.len(), 1);
assert_eq!(info.annotations[0].modified.as_deref(), Some(date));
assert!(info.warnings.is_empty(), "{:?}", info.warnings);
}
#[test]
fn carriage_returns_in_comment_text_become_line_breaks() {
use lopdf::dictionary;
let mut doc = Document::with_version("1.7");
let pages_id = doc.new_object_id();
let annot_id = doc.add_object(dictionary! {
"Type" => "Annot",
"Subtype" => "Text",
"Rect" => vec![10.into(), 10.into(), 30.into(), 30.into()],
// Acrobat ends lines in /Contents with CR; the interface only breaks lines at LF.
"Contents" => Object::string_literal("one\r\rtwo\r\nthree"),
});
let page_id = doc.add_object(dictionary! {
"Type" => "Page",
"Parent" => pages_id,
"MediaBox" => vec![0.into(), 0.into(), 200.into(), 300.into()],
"Annots" => vec![annot_id.into()],
});
doc.objects.insert(pages_id, dictionary! { "Type" => "Pages", "Kids" => vec![page_id.into()], "Count" => 1 }.into());
let catalog_id = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages_id });
doc.trailer.set("Root", catalog_id);
let mut bytes = Vec::new();
doc.save_to(&mut bytes).expect("write synthetic fixture");
let info = inspect(Arc::new(bytes), None).expect("opens");
assert_eq!(info.annotations[0].contents.as_deref(), Some("one\n\ntwo\nthree"));
}
/// A comment's pop-up (`/Popup`) is read with its place and state; one without reads none.
#[test]
fn a_comments_pop_up_is_read_with_its_place_and_state() {
let bytes = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 300] /Annots [4 0 R 5 0 R 6 0 R] >> endobj\n4 0 obj << /Type /Annot /Subtype /Text /Rect [20 260 40 280] /Contents (Hi) /Popup 5 0 R /Open true >> endobj\n5 0 obj << /Type /Annot /Subtype /Popup /Rect [190 160 50 280] /Parent 4 0 R /Open true >> endobj\n6 0 obj << /Type /Annot /Subtype /Square /Rect [10 10 60 60] >> endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n";
let info = inspect(Arc::new(bytes.to_vec()), None).expect("opens");
let note = info.annotations.iter().find(|a| a.subtype == "Text").expect("the note");
assert_eq!(note.popup, Some(Popup { rect: [50.0, 160.0, 190.0, 280.0], open: true }), "normalised, and open");
let square = info.annotations.iter().find(|a| a.subtype == "Square").expect("the square");
assert_eq!(square.popup, None);
assert!(info.annotations.iter().all(|a| a.subtype != "Popup"), "the pop-up is not a comment of its own");
// #2137 review: a /Popup naming another annotation, a pop-up of another comment, or one
// whose /Parent isn't a reference isn't this comment's pop-up; one without /Parent is.
for (target, extra, own) in [
("6 0 R", "", false),
("7 0 R", "7 0 obj << /Type /Annot /Subtype /Popup /Rect [0 0 50 50] /Parent 6 0 R >> endobj\n", false),
("7 0 R", "7 0 obj << /Type /Annot /Subtype /Popup /Rect [0 0 50 50] /Parent << >> >> endobj\n", false),
("7 0 R", "7 0 obj << /Type /Annot /Subtype /Popup /Rect [0 0 50 50] >> endobj\n", true),
] {
let text = String::from_utf8_lossy(bytes).replace("/Popup 5 0 R", &format!("/Popup {target}")).replace("trailer", &format!("{extra}trailer"));
let info = inspect(Arc::new(text.into_bytes()), None).expect("opens");
let note = info.annotations.iter().find(|a| a.subtype == "Text").expect("the note");
assert_eq!(note.popup.is_some(), own, "/Popup {target} {extra}");
}
// #2137 review: /Annots may list the note through an object that is only a reference to
// it; its pop-up's /Parent names the note itself, and is still its own.
let text = String::from_utf8_lossy(bytes).replace("/Annots [4 0 R", "/Annots [8 0 R").replace("trailer", "8 0 obj 4 0 R endobj\ntrailer");
let info = inspect(Arc::new(text.into_bytes()), None).expect("opens");
let note = info.annotations.iter().find(|a| a.subtype == "Text").expect("the note");
assert_eq!(note.popup, Some(Popup { rect: [50.0, 160.0, 190.0, 280.0], open: true }), "listed through 8 0 R");
}
#[test]
fn dates_are_prettified() {
assert_eq!(pretty_date("D:20260930104512-04'00'"), "2026-09-30 10:45");
assert_eq!(pretty_date("yesterday"), "yesterday");
}
#[test]
fn jspdf_high_precision_mediabox_does_not_overflow_dimensions() {
let pdf = b"%PDF-1.3
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 3874.9606299212600788 5493.5433070866147318] >> endobj
trailer << /Root 1 0 R >>
%%EOF";
let info = inspect(std::sync::Arc::new(pdf.to_vec()), None).expect("opens");
assert_eq!(info.pages.len(), 1);
let p = &info.pages[0];
assert!((p.width - 3874.96).abs() < 0.1, "width expected ~3874.96, got {}", p.width);
assert!((p.height - 5493.54).abs() < 0.1, "height expected ~5493.54, got {}", p.height);
}
mod group_tests {
use crate::inspect::{Annotation, MAX_GROUP_DEPTH, group_primaries, link_replies};
/// The annotation at `/Annots` position `index` of page 0, whose `/IRT` points at position
/// `parent`, with `/RT /Group` when `grouped`.
fn annot(index: usize, parent: Option<usize>, grouped: bool) -> Annotation {
Annotation {
page: 0,
subtype: "Circle".into(),
author: None,
contents: None,
modified: None,
name: Some(index.to_string()),
in_reply_to: parent.map(|p| p.to_string()),
parent: parent.map(|p| (0, p)),
grouped,
rect: [0.0; 4],
color: None,
index,
state: None,
quads: Vec::new(),
locked: false,
intent: None,
fill_sign: false,
no_rotate: false,
points: Vec::new(),
text_box: None,
popup: None,
}
}
/// `all` with its reply links settled, as both comment readers list it.
fn listed(mut all: Vec<Annotation>) -> Vec<Annotation> {
link_replies(&mut all);
all
}
/// ISO 32000-2 §12.5.6.2: a subordinate's primary is the annotation its `/IRT` points at,
/// past any other subordinate; a reply is no subordinate. A loop, or an `/IRT` to nothing
/// listed, leaves a comment of its own (as `link_replies` leaves such a reply), and an
/// over-long chain ends.
#[test]
fn group_primaries_follow_irt_to_the_first_non_subordinate() {
let all = listed(vec![
annot(0, None, false), // top
annot(1, Some(0), true), // mid
annot(2, Some(1), true), // low
annot(3, Some(0), false), // a reply
annot(4, Some(5), true), // a loop
annot(5, Some(4), true), // of two
annot(6, Some(6), true), // itself
annot(7, Some(99), true), // nothing listed
annot(8, Some(3), true), // grouped with the reply
]);
assert_eq!(group_primaries(&all), [None, Some(0), Some(0), None, None, None, None, None, Some(3)]);
assert!(all.iter().skip(4).take(4).all(Annotation::is_comment), "{all:?}");
// A chain longer than the cap has no primary; one within it does, whether the reply links
// are settled first or not.
for (len, found) in [(MAX_GROUP_DEPTH - 1, true), (MAX_GROUP_DEPTH + 1, false)] {
let mut chain = vec![annot(0, None, false)];
chain.extend((1..=len).map(|i| annot(i, Some(i - 1), true)));
assert_eq!(group_primaries(&chain).last().copied().flatten().is_some(), found, "{len}");
assert_eq!(group_primaries(&listed(chain)).last().copied().flatten().is_some(), found, "{len}, listed");
}
}
/// `group_primaries` is public and may be handed links `link_replies` has not settled: a loop
/// of subordinates (each other's `/IRT`, or its own) still ends, within `MAX_GROUP_DEPTH`
/// steps, with no primary.
#[test]
fn group_primaries_end_on_an_unsettled_loop() {
let all = vec![annot(0, Some(1), true), annot(1, Some(0), true), annot(2, Some(2), true), annot(3, None, false)];
assert_eq!(group_primaries(&all), [None, None, None, None]);
}
fn with(mut a: Annotation, subtype: &str, t: Option<&str>) -> Annotation {
a.subtype = subtype.into();
a.contents = t.map(Into::into);
a
}
/// A StrikeOut primary with no text lists its first Caret subordinate's (earlier Replace
/// Text groups kept the replacement on the caret only); a primary's own text always wins, a
/// subordinate of another type lends nothing, and a primary of another type borrows nothing
/// (ISO 32000-2 §12.5.6.2: the group's text is the primary's).
#[test]
fn a_primary_without_text_lists_its_carets() {
let mut all = listed(vec![
with(annot(0, None, false), "StrikeOut", None),
with(annot(1, Some(0), true), "Caret", Some("red")),
with(annot(2, Some(0), true), "Caret", Some("second")),
with(annot(3, None, false), "Caret", Some("kept")),
with(annot(4, Some(3), true), "Caret", Some("ignored")),
with(annot(5, Some(0), false), "Text", Some("a reply")),
with(annot(6, None, false), "StrikeOut", None),
with(annot(7, Some(6), true), "Circle", Some("not lent")),
with(annot(8, None, false), "Square", None),
with(annot(9, Some(8), true), "Caret", Some("not borrowed")),
]);
super::fill_group_text(&mut all);
let shown: Vec<Option<&str>> = all.iter().map(|a| a.contents.as_deref()).collect();
assert_eq!(
shown,
[
Some("red"),
Some("red"),
Some("second"),
Some("kept"),
Some("ignored"),
Some("a reply"),
None,
Some("not lent"),
None,
Some("not borrowed"),
]
);
}
/// Review on #4358: an empty caret ahead of one with text lends nothing, so the primary lists
/// the replacement rather than nothing.
#[test]
fn an_empty_caret_does_not_hide_the_next_carets_text() {
let mut all = listed(vec![
with(annot(0, None, false), "StrikeOut", None),
with(annot(1, Some(0), true), "Caret", Some("")),
with(annot(2, Some(0), true), "Caret", Some("new words")),
]);
super::fill_group_text(&mut all);
assert_eq!(all[0].contents.as_deref(), Some("new words"));
}
/// Many groups each get their own caret's text (one map lookup per caret, not a scan of the
/// groups seen so far).
#[test]
fn many_groups_each_list_their_own_caret() {
const N: usize = 20_000;
let mut all = Vec::with_capacity(2 * N);
for i in 0..N {
all.push(with(annot(i, None, false), "StrikeOut", None));
}
// Carets in reverse order, so a primary's caret is far from it in the list.
for (k, i) in (0..N).rev().enumerate() {
all.push(with(annot(N + k, Some(i), true), "Caret", Some(&format!("t{i}"))));
}
let mut all = listed(all);
super::fill_group_text(&mut all);
for (i, a) in all.iter().take(N).enumerate() {
assert_eq!(a.contents.as_deref(), Some(format!("t{i}").as_str()), "{i}");
}
}
}
/// #2137 review: a pop-up without a `/Rect` of four numbers reads as no pop-up, as
/// `pdfcraft_annot::summaries` reads it, so the two lists of comments agree.
#[test]
fn a_pop_up_without_a_four_number_rect_is_none() {
let bytes = "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 300] /Annots [4 0 R 5 0 R] >> endobj\n4 0 obj << /Type /Annot /Subtype /Text /Rect [20 260 40 280] /Contents (Hi) /Popup 5 0 R >> endobj\n5 0 obj << /Type /Annot /Subtype /Popup RECT /Parent 4 0 R /Open true >> endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n";
for (rect, read) in [("", false), ("/Rect [0 0 50]", false), ("/Rect (box)", false), ("/Rect [0 0 50 /x]", false), ("/Rect [0 0 50 60]", true)] {
let info = inspect(Arc::new(bytes.replace("RECT", rect).into_bytes()), None).expect("opens");
let note = info.annotations.iter().find(|a| a.subtype == "Text").expect("the note");
assert_eq!(note.popup.is_some(), read, "{rect:?}");
}
}
/// #2137 review: a pop-up `/Rect` with its corners reversed reads with them in order (ISO
/// 32000-2 §7.9.5), and one with a number too large for `f32` reads as no pop-up, as
/// `pdfcraft_annot::summaries` reads both.
#[test]
fn a_pop_up_rect_with_reversed_corners_is_put_in_order_and_a_huge_one_is_none() {
let bytes = "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 300] /Annots [4 0 R 5 0 R] >> endobj\n4 0 obj << /Type /Annot /Subtype /Text /Rect [20 260 40 280] /Contents (Hi) /Popup 5 0 R >> endobj\n5 0 obj << /Type /Annot /Subtype /Popup /Rect RECT /Parent 4 0 R /Open true >> endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n";
let popup = |rect: &str| {
let info = inspect(Arc::new(bytes.replace("RECT", rect).into_bytes()), None).expect("opens");
info.annotations.iter().find(|a| a.subtype == "Text").expect("the note").popup.map(|p| p.rect)
};
assert_eq!(popup("[150 200 50 100]"), Some([50.0, 100.0, 150.0, 200.0]), "reversed");
let huge = format!("1{}", "0".repeat(40));
assert_eq!(popup(&format!("[0 0 {huge} 100]")), None, "a number past f32");
assert_eq!(popup(&format!("[0 0 {huge}.0 100]")), None, "a real past f32");
}
/// Inspection keeps every object it reads, converted, for as long as it runs, and nothing it
/// reports comes from `/PieceInfo`. InDesign writes kilobytes of that private data into every
/// page, and a 1,412-page book held over a gigabyte of it (#5873). It is left out of what
/// inspection keeps, and what inspection reports is the same as for the page without it.
#[test]
fn inspection_keeps_no_private_page_data() {
use lopdf::dictionary;
let make = |private: bool| -> Vec<u8> {
let mut doc = Document::with_version("1.7");
let pages_id = doc.new_object_id();
let mut page = dictionary! {
"Type" => "Page",
"Parent" => pages_id,
"MediaBox" => vec![0.into(), 0.into(), 200.into(), 300.into()],
"Rotate" => 90,
};
if private {
let map: Dictionary = (0..50).map(|i| (i.to_string(), Object::Array(vec![Object::Integer(i), Object::Real(116.22)]))).collect();
page.set("PieceInfo", dictionary! { "InDesign" => dictionary! { "PageItemUIDToLocationDataMap" => map } });
}
let page_id = doc.add_object(page);
doc.objects.insert(pages_id, dictionary! { "Type" => "Pages", "Kids" => vec![page_id.into()], "Count" => 1 }.into());
let catalog = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages_id });
doc.trailer.set("Root", catalog);
let mut out = Vec::new();
doc.save_to(&mut out).unwrap();
out
};
let (with, without) = (make(true), make(false));
let lazy = LazyStructure::new(Arc::new(with.clone()), None).unwrap();
let page = lazy.get_object((2, 0)).unwrap().as_dict().unwrap();
assert!(!page.has(b"PieceInfo"), "the private data is not kept");
assert!(page.has(b"MediaBox") && page.has(b"Rotate") && page.has(b"Parent"), "the rest of the page is");
let report = |bytes: Vec<u8>| {
let mut info = inspect(Arc::new(bytes), None).unwrap();
info.file_size = 0;
format!("{info:?}")
};
assert_eq!(report(with), report(without), "what inspection reports does not change");
}
/// A `/Kids` entry that loops back, or names a root field that doesn't name this one as its
/// `/Parent`, is not a child (ISO 32000-2 §12.7.3, Table 224; §12.7.4.1, Table 226): the field
/// keeps its other kids and is listed with them.
#[test]
fn kids_that_loop_back_or_are_roots_are_not_children() {
let pdf = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [6 0 R 9 0 R 12 0 R 15 0 R] >> endobj
4 0 obj << /Fields [5 0 R 7 0 R 8 0 R 10 0 R] >> endobj
5 0 obj << /FT /Btn /Ff 49152 /T (size) /Kids [5 0 R 6 0 R] >> endobj
6 0 obj << /Type /Annot /Subtype /Widget /Parent 5 0 R /Rect [10 150 20 160] >> endobj
7 0 obj << /FT /Btn /Ff 49152 /T (left) /Kids [8 0 R 9 0 R] >> endobj
8 0 obj << /FT /Btn /Ff 49152 /T (right) /Kids [7 0 R 12 0 R] >> endobj
9 0 obj << /Type /Annot /Subtype /Widget /Parent 7 0 R /Rect [10 120 20 130] >> endobj
10 0 obj << /T (group) /Kids [13 0 R] >> endobj
12 0 obj << /Type /Annot /Subtype /Widget /Parent 8 0 R /Rect [10 90 20 100] >> endobj
13 0 obj << /FT /Tx /T (inner) /Parent 10 0 R /Kids [14 0 R] >> endobj
14 0 obj << /FT /Tx /T (deep) /Parent 13 0 R /Kids [13 0 R 15 0 R] >> endobj
15 0 obj << /Type /Annot /Subtype /Widget /Parent 14 0 R /Rect [10 60 90 80] >> endobj
trailer << /Root 1 0 R >>
%%EOF";
let info = inspect(Arc::new(pdf.to_vec()), None).expect("opens");
let listed: Vec<_> = info.fields.iter().map(|f| (f.name.as_str(), f.kind, f.page, f.rect.map(|r| r[1]))).collect();
assert_eq!(
listed,
[
("size", FieldKind::Radio, Some(0), Some(150.0)),
("left", FieldKind::Radio, Some(0), Some(120.0)),
("right", FieldKind::Radio, Some(0), Some(90.0)),
("group.inner.deep", FieldKind::Text, Some(0), Some(60.0)),
],
"{:?}",
info.fields
);
}
/// A field with no widget among its `/Kids` takes the page widget that names it as its
/// `/Parent`: a child names its parent (ISO 32000-2 §12.7.4.1, Table 226). Here `pair` and `other`
/// list each other (roots, so not children), and only a page widget says it belongs to `other`.
#[test]
fn a_field_takes_the_page_widget_naming_it_when_its_kids_list_none() {
let pdf = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 6 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [4 0 R 7 0 R 11 0 R 13 0 R] >> endobj
4 0 obj << /Type /Annot /Subtype /Widget /Parent 5 0 R /Rect [10 150 20 160] >> endobj
5 0 obj << /FT /Btn /Ff 49152 /T (radio) /Kids [5 0 R 7 0 R] >> endobj
6 0 obj << /Fields [5 0 R 8 0 R 9 0 R] >> endobj
7 0 obj << /Type /Annot /Subtype /Widget /Parent 5 0 R /Rect [10 120 20 130] >> endobj
8 0 obj << /FT /Btn /Ff 49152 /T (pair) /Kids [9 0 R 11 0 R] >> endobj
9 0 obj << /FT /Btn /Ff 49152 /T (other) /Kids [8 0 R] >> endobj
11 0 obj << /Type /Annot /Subtype /Widget /Parent 8 0 R /Rect [10 90 20 100] >> endobj
13 0 obj << /Type /Annot /Subtype /Widget /Parent 9 0 R /Rect [10 60 20 70] >> endobj
trailer << /Root 1 0 R >>
%%EOF";
let info = inspect(Arc::new(pdf.to_vec()), None).expect("opens");
let listed: Vec<_> = info.fields.iter().map(|f| (f.name.as_str(), f.page, f.rect.map(|r| r[1]))).collect();
assert_eq!(listed, [("radio", Some(0), Some(120.0)), ("pair", Some(0), Some(90.0)), ("other", Some(0), Some(60.0))], "{:?}", info.fields);
}
/// A kid with `/T` that a field's `/Kids` lists but whose `/Parent` doesn't name that field is
/// named up its own `/Parent` chain (ISO 32000-2 §12.7.4.2), as the form reader names it.
#[test]
fn a_named_kid_without_a_parent_keeps_its_own_name() {
let pdf = |parent: &str| {
format!(
"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] >> >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [5 0 R 6 0 R] >> endobj
4 0 obj << /FT /Btn /Ff 49152 /T (Group1) /Kids [6 0 R 5 0 R] >> endobj
5 0 obj << /Type /Annot /Subtype /Widget /T (Group1){parent} /Rect [10 100 30 120] >> endobj
6 0 obj << /Type /Annot /Subtype /Widget /T (Group1){parent} /Rect [40 100 60 120] >> endobj
trailer << /Root 1 0 R >>
%%EOF"
)
.into_bytes()
};
let names = |bytes: Vec<u8>| inspect(Arc::new(bytes), None).expect("opens").fields.iter().map(|f| (f.name.clone(), f.kind)).collect::<Vec<_>>();
assert_eq!(names(pdf("")), [("Group1".into(), FieldKind::Radio), ("Group1".into(), FieldKind::Radio)]);
assert_eq!(names(pdf(" /Parent 4 0 R")), [("Group1.Group1".into(), FieldKind::Radio), ("Group1.Group1".into(), FieldKind::Radio)]);
}
/// A kid named from its own `/Parent` chain (ISO 32000-2 §12.7.4.2) when that `/Parent` is a field
/// other than the one listing it: an indirect one, and one listed as a direct dictionary in
/// `/Fields` (which no `/Parent` can name).
#[test]
fn a_named_kid_whose_parent_is_another_field_is_named_under_it() {
let pdf = |listing: &str, fields: &str| {
format!(
"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [{fields}] >> >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [5 0 R] >> endobj
4 0 obj {listing} endobj
5 0 obj << /Type /Annot /Subtype /Widget /T (k) /Parent 6 0 R /Rect [10 100 30 120] >> endobj
6 0 obj << /T (Other) /Kids [5 0 R] >> endobj
trailer << /Root 1 0 R >>
%%EOF"
)
.into_bytes()
};
let names = |bytes: Vec<u8>| inspect(Arc::new(bytes), None).expect("opens").fields.iter().map(|f| f.name.clone()).collect::<Vec<_>>();
let listing = "<< /FT /Tx /T (Listing) /Kids [5 0 R] >>";
assert_eq!(names(pdf(listing, "4 0 R")), ["Other.k"]);
assert_eq!(names(pdf("null", listing)), ["Other.k"], "listed as a direct dictionary");
}
/// "A field's children in the hierarchy may also include widget annotations" (ISO 32000-2
/// §12.7.4): a nameless widget beside a named kid is listed as a field of the parent's name, at
/// the widget's place, as the form reader lists it.
#[test]
fn a_widget_beside_a_named_kid_is_a_field_of_the_parents_name() {
let pdf = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] >> >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [5 0 R 6 0 R] >> endobj
4 0 obj << /FT /Btn /Ff 49152 /T (2) /Kids [5 0 R 6 0 R] >> endobj
5 0 obj << /Type /Annot /Subtype /Widget /T (2) /Rect [10 100 30 120] >> endobj
6 0 obj << /Type /Annot /Subtype /Widget /Parent 4 0 R /Rect [40 100 60 120] >> endobj
trailer << /Root 1 0 R >>
%%EOF"
.to_vec();
let info = inspect(Arc::new(pdf), None).expect("opens");
let got: Vec<_> = info.fields.iter().map(|f| (f.name.clone(), f.kind, f.rect)).collect();
assert_eq!(
got,
[("2".into(), FieldKind::Radio, Some([10.0, 100.0, 30.0, 120.0])), ("2".into(), FieldKind::Radio, Some([40.0, 100.0, 60.0, 120.0]))]
);
}
/// The password values (`/O`, `/U`, `/OE`, `/UE`, `/Perms`) of an AES-256 (R6) protection with a
/// user password, as a save writes them.
fn r6_password_values() -> String {
let plain = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [] /Count 0 >> endobj\ntrailer << /Root 1 0 R >>\n%%EOF".to_vec();
let mut doc = pdfcraft_cos::Document::open(Arc::new(plain)).unwrap();
doc.set_encryption(&pdfcraft_cos::NewEncryption {
algorithm: pdfcraft_cos::Algorithm::Aes256,
user_password: "user pw",
owner_password: "owner pw",
permissions: -4,
encrypt_metadata: true,
seed: [5; 32],
})
.unwrap();
let saved = pdfcraft_cos::write_full(&doc, &pdfcraft_cos::SaveOptions::default()).unwrap();
let saved = pdfcraft_cos::Document::open_with_password(Arc::new(saved), Some("user pw")).unwrap();
let encrypt = saved.resolve(saved.trailer().get(b"Encrypt").unwrap());
let encrypt = encrypt.as_dict().unwrap();
["O", "U", "OE", "UE", "Perms"]
.iter()
.map(|k| {
let value = saved.resolve(encrypt.get(k.as_bytes()).unwrap());
let hex: String = value.as_string().unwrap().bytes.iter().map(|b| format!("{b:02X}")).collect();
format!("/{k} <{hex}>")
})
.collect::<Vec<_>>()
.join(" ")
}
/// A page with text in the clear and an attachment, `note.txt`, encrypted with `/StdCF` (through
/// `/EFF`, or a `/Crypt` filter of its own when `named`). `/StdCF` is authorised by `auth_event`,
/// under the handler `version`.
fn efopen_pdf_with(auth_event: &str, named: bool, version: &str) -> Vec<u8> {
let (eff, crypt) = if named { ("", " /Filter [/Crypt] /DecodeParms [<< /Name /StdCF >>]") } else { (" /EFF /StdCF", "") };
let encrypt = format!(
"<< /Filter /Standard {version} {} /P -4 /CF << /StdCF << /AuthEvent /{auth_event} /CFM /AESV3 /Length 32 >> >> /StmF /Identity /StrF /Identity{eff} >>",
r6_password_values()
);
let page = "BT /F1 12 Tf 20 100 Td (Visible page) Tj ET";
let objs = [
"<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles << /Names [(note.txt) 4 0 R] >> >> >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << /Font << /F1 7 0 R >> >> /Contents 6 0 R >>".to_string(),
"<< /Type /Filespec /F (note.txt) /EF << /F 5 0 R >> >>".to_string(),
format!("<< /Type /EmbeddedFile{crypt} /Length 32 >>\nstream\n0123456789abcdef0123456789abcdef\nendstream"),
format!("<< /Length {} >>\nstream\n{page}\nendstream", page.len()),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(),
encrypt,
];
let mut out = b"%PDF-1.7\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 /Encrypt {} 0 R /ID [<00> <00>] >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1, objs.len())
.as_bytes(),
);
out
}
fn efopen_pdf(auth_event: &str) -> Vec<u8> {
efopen_pdf_with(auth_event, false, "/V 5 /R 6")
}
/// ISO 32000-2 §7.6.6, Table 25: a crypt filter whose `/AuthEvent` is `/EFOpen` needs its password
/// only when an embedded file is opened. Such a document opens without one, its page reads, and
/// its attachment is listed; asking for the attachment's bytes is an error, not its encrypted data.
#[test]
fn a_document_encrypting_only_its_embedded_files_opens_without_a_password() {
let bytes = Arc::new(efopen_pdf("EFOpen"));
assert!(check_pages(bytes.clone(), None, 1).is_ok());
let info = inspect(bytes.clone(), None).expect("opens without a password");
assert_eq!(info.pages.len(), 1);
let mut renderer = crate::PageRenderer::new(bytes.clone(), crate::RenderConfig::default());
let text = renderer.render(crate::RenderRequest { page: 0, kind: crate::RequestKind::Text, ..Default::default() });
assert!(text.text.is_some_and(|page| page.plain_text().contains("Visible page")), "{:?}", text.error);
let listed = |name: &str| info.attachments.iter().find(|a| a.name == name).unwrap_or_else(|| panic!("{name} is listed")).clone();
let refused = attachment_data(&bytes, None, &listed("note.txt")).expect_err("encrypted bytes aren't the file");
assert!(refused.contains("password"), "{refused}");
// Authorised when the document opens, the same filter still needs the password.
let doc_open = Arc::new(efopen_pdf("DocOpen"));
assert!(matches!(inspect(doc_open.clone(), None), Err(OpenError::NeedsPassword)));
assert!(matches!(check_pages(doc_open, None, 1), Err(OpenError::NeedsPassword)));
}
/// From the review of the EFOpen change: an attachment naming its own EFOpen crypt filter, with no
/// `/EFF`, opens the same way; a handler older than crypt filters (V2) still needs the password.
#[test]
fn efopen_filters_named_by_the_file_open_and_older_handlers_do_not() {
let named = Arc::new(efopen_pdf_with("EFOpen", true, "/V 5 /R 6"));
let info = inspect(named.clone(), None).expect("opens without a password");
let note = info.attachments.iter().find(|a| a.name == "note.txt").expect("listed").clone();
assert!(attachment_data(&named, None, &note).is_err());
let legacy = Arc::new(efopen_pdf_with("EFOpen", false, "/V 2 /R 3 /Length 128"));
assert!(matches!(check_pages(legacy, None, 1), Err(OpenError::NeedsPassword)));
}