Some checks failed
email.yml / feat(pdf): page operations — insert, reorder, duplicate, delete, import (push) Failing after 0s
repo hygiene / hygiene (push) Has been cancelled
PDF engine / engine (push) Has been cancelled
PDF engine / makepad-integration (push) Has been cancelled
PDF engine / fuzz (push) Has been cancelled
Phase 5's page management, matching dart-pdf's `page_ops_test.dart`, `page_index_map_test.dart` and `import_source_test.dart`. A reader sees "page 3"; the file holds a tree of /Pages nodes with /Kids and /Count and /Parent back-pointers, any of which can be left stale. So the writer **flattens to a single level**: a one-level /Pages node with every page as a direct kid is valid, is what most producers emit, and removes the entire class of bug where an intermediate node's /Count no longer matches what is under it. Preserving an arbitrary tree shape through arbitrary reordering is far more code for nothing a reader can see. `PagePlan` accumulates operations and applies them together, so intermediate states never have to be valid — delete page 0 and insert a new one at 0 without the document momentarily having no first page. `PageIndexMap` reports where every page went, which is the only way to fix an outline entry, named destination or link annotation afterwards. What each operation carries matters and differs: - Reorder and delete rewrite only the kid array, so page objects and their resources are untouched. - Duplicate writes a new page dictionary that **shares** the original's resource references. Two pages naming one font object is normal; deep-copying would double the file and change nothing visible. - Import must deep-copy the page and everything it reaches, renumbered, because source object numbers mean nothing in the destination. /Parent is deliberately not followed — it leads back to the source's page tree and from there to every other page in that file. Inheritable attributes are resolved *before* a page is imported. /Resources, /MediaBox, /CropBox and /Rotate may live on an ancestor (Table 30) that is not coming with it, so a page imported without them renders at the wrong size with no fonts, and nothing reports an error. **Round-trip tested through the saved file**, which is Phase 5's exit criterion: 20 tests that save, re-parse, and assert on what a reader actually gets. Pages are identified by /MediaBox width rather than object number, because object numbers are exactly what a page-tree bug scrambles. Mutation testing changed two things. Seven defects injected: /Count left stale 1 fail /Count omitted entirely 1 fail imported /Parent not rewritten 1 fail inherited attributes not resolved 1 fail import does not deep-copy 3 fail duplicate loses /Contents 1 fail re-parenting skipped 1 fail The last two only fail because of tests the mutations forced: - **A stale /Count passed everything.** Our own parser walks /Kids and never reads /Count, so it cannot see the disagreement — but other readers trust /Count, and a document where the two differ opens with a different page count in different viewers. The test now reads the raw page-tree node instead of asking the document. - **Re-parenting could be deleted with every test still green**, because the flat fixture's pages already parent to the root. Added a nested fixture with an intermediate /Pages node supplying an inherited /MediaBox — the case where leaving /Parent stale means a page keeps inheriting from a node it is no longer under. Externally verified: a generated sample with pages swapped and duplicated passes `qpdf --check` with no warnings, and poppler reads 4 pages with the reordering visible in extracted text. Engine suite 1039 -> 1075. Remaining in Phase 5: flatten, object compaction, redaction, and outline and struct-tree editing.
643 lines
23 KiB
Rust
643 lines
23 KiB
Rust
//! Page operations, read back from the saved file.
|
|
//!
|
|
//! Phase 5's exit criterion is "edit-anything round-trip tests (modify →
|
|
//! save → re-parse → verify)". The unit tests in `page_ops.rs` check the
|
|
//! *plan*; these check the **file**, which is the only thing a reader ever
|
|
//! sees.
|
|
//!
|
|
//! The distinction matters more here than almost anywhere else in the
|
|
//! stack. A page-tree edit that is subtly wrong produces a document that
|
|
//! opens: `/Count` says five, the reader finds four, and which of those it
|
|
//! believes depends on the reader. Nothing errors. So every assertion here
|
|
//! goes through `PdfDocument::parse` on the saved bytes and checks what a
|
|
//! reader would actually get.
|
|
|
|
use nigig_pdf_cos::object::{PdfDict, PdfObj};
|
|
use nigig_pdf_document::page_ops::{apply_plan, import_pages, PageOpError, PagePlan};
|
|
use nigig_pdf_document::PdfDocument;
|
|
|
|
/// Build a document whose pages are individually identifiable.
|
|
///
|
|
/// Each page's `/MediaBox` width encodes its index — page `i` is
|
|
/// `100 + i` wide — so a reordering can be verified by reading sizes back
|
|
/// rather than by trusting object numbers. Object numbers are exactly what
|
|
/// a page-tree bug scrambles, so they cannot be the identity used to check
|
|
/// one.
|
|
fn document_with_pages(count: usize) -> Vec<u8> {
|
|
let mut out = String::from("%PDF-1.7\n");
|
|
let mut offsets = Vec::new();
|
|
|
|
let page_obj = |i: usize| 3 + i;
|
|
let content_obj = |i: usize, count: usize| 3 + count + i;
|
|
|
|
offsets.push(out.len());
|
|
out.push_str("1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
|
|
|
|
offsets.push(out.len());
|
|
let kids: Vec<String> = (0..count).map(|i| format!("{} 0 R", page_obj(i))).collect();
|
|
out.push_str(&format!(
|
|
"2 0 obj\n<< /Type /Pages /Count {count} /Kids [{}] >>\nendobj\n",
|
|
kids.join(" ")
|
|
));
|
|
|
|
for i in 0..count {
|
|
offsets.push(out.len());
|
|
out.push_str(&format!(
|
|
"{} 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {} 200] \
|
|
/Contents {} 0 R >>\nendobj\n",
|
|
page_obj(i),
|
|
100 + i,
|
|
content_obj(i, count)
|
|
));
|
|
}
|
|
|
|
for i in 0..count {
|
|
offsets.push(out.len());
|
|
let content = format!("BT (page {i}) Tj ET");
|
|
out.push_str(&format!(
|
|
"{} 0 obj\n<< /Length {} >>\nstream\n{content}\nendstream\nendobj\n",
|
|
content_obj(i, count),
|
|
content.len()
|
|
));
|
|
}
|
|
|
|
let startxref = out.len();
|
|
let total = 1 + 2 + count * 2;
|
|
out.push_str(&format!("xref\n0 {total}\n0000000000 65535 f \n"));
|
|
for off in &offsets {
|
|
out.push_str(&format!("{off:010} 00000 n \n"));
|
|
}
|
|
out.push_str(&format!(
|
|
"trailer\n<< /Size {total} /Root 1 0 R >>\nstartxref\n{startxref}\n%%EOF\n"
|
|
));
|
|
out.into_bytes()
|
|
}
|
|
|
|
/// The `/MediaBox` widths of every page, in order — the page identities.
|
|
fn page_widths(bytes: &[u8]) -> Vec<i64> {
|
|
let mut doc = PdfDocument::parse(bytes).expect("the saved file must parse");
|
|
(0..doc.page_count())
|
|
.map(|i| {
|
|
let page = doc.page(i).unwrap_or_else(|e| panic!("page {i}: {e}"));
|
|
page.media_box[2] as i64
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn parse(bytes: &[u8]) -> PdfDocument<'_> {
|
|
PdfDocument::parse(bytes).expect("parses")
|
|
}
|
|
|
|
#[test]
|
|
fn the_fixture_itself_reads_back_correctly() {
|
|
// If this fails, every other test in the file is meaningless.
|
|
let bytes = document_with_pages(4);
|
|
assert_eq!(page_widths(&bytes), vec![100, 101, 102, 103]);
|
|
}
|
|
|
|
#[test]
|
|
fn reordering_pages_survives_a_save_and_reparse() {
|
|
let bytes = document_with_pages(4);
|
|
let mut doc = parse(&bytes);
|
|
let mut plan = PagePlan::new(doc.page_count());
|
|
plan.set_order(&[3, 1, 0, 2]).expect("reorders");
|
|
|
|
let (saved, report) = apply_plan(&mut doc, &bytes, &plan).expect("applies");
|
|
assert_eq!(report.final_page_count, 4);
|
|
assert_eq!(
|
|
page_widths(&saved),
|
|
vec![103, 101, 100, 102],
|
|
"the saved page order does not match the plan"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn moving_one_page_survives_a_save_and_reparse() {
|
|
let bytes = document_with_pages(3);
|
|
let mut doc = parse(&bytes);
|
|
let mut plan = PagePlan::new(doc.page_count());
|
|
plan.move_page(0, 2).expect("moves");
|
|
|
|
let (saved, _) = apply_plan(&mut doc, &bytes, &plan).expect("applies");
|
|
assert_eq!(page_widths(&saved), vec![101, 102, 100]);
|
|
}
|
|
|
|
#[test]
|
|
fn deleting_a_page_survives_a_save_and_reparse() {
|
|
let bytes = document_with_pages(4);
|
|
let mut doc = parse(&bytes);
|
|
let mut plan = PagePlan::new(doc.page_count());
|
|
plan.delete(1).expect("deletes");
|
|
|
|
let (saved, report) = apply_plan(&mut doc, &bytes, &plan).expect("applies");
|
|
assert_eq!(report.final_page_count, 3);
|
|
assert_eq!(page_widths(&saved), vec![100, 102, 103]);
|
|
|
|
// The reader's own count must agree with the kid array. A stale
|
|
// /Count is the classic page-tree bug: readers disagree about how many
|
|
// pages the document has and none of them errors.
|
|
let saved_doc = parse(&saved);
|
|
assert_eq!(saved_doc.page_count(), 3, "/Count disagrees with /Kids");
|
|
}
|
|
|
|
#[test]
|
|
fn duplicating_a_page_produces_two_readable_copies() {
|
|
let bytes = document_with_pages(2);
|
|
let mut doc = parse(&bytes);
|
|
let mut plan = PagePlan::new(doc.page_count());
|
|
plan.duplicate(0).expect("duplicates");
|
|
|
|
let (saved, report) = apply_plan(&mut doc, &bytes, &plan).expect("applies");
|
|
assert_eq!(report.final_page_count, 3);
|
|
assert_eq!(
|
|
page_widths(&saved),
|
|
vec![100, 100, 101],
|
|
"the copy must sit directly after the original"
|
|
);
|
|
|
|
// Both copies must reach their content. A duplicate that shares the
|
|
// original's /Contents reference is correct; one that lost it renders
|
|
// blank, and blank is a valid page.
|
|
let mut saved_doc = parse(&saved);
|
|
for i in 0..2 {
|
|
let page = saved_doc.page(i).expect("page");
|
|
assert!(
|
|
!page.content_data.is_empty(),
|
|
"copy {i} has no content stream"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn inserting_a_new_page_survives_a_save_and_reparse() {
|
|
let bytes = document_with_pages(2);
|
|
let mut doc = parse(&bytes);
|
|
let mut plan = PagePlan::new(doc.page_count());
|
|
|
|
let mut page = PdfDict::new();
|
|
page.set(
|
|
"MediaBox",
|
|
PdfObj::Array(vec![
|
|
PdfObj::Int(0),
|
|
PdfObj::Int(0),
|
|
PdfObj::Int(555),
|
|
PdfObj::Int(200),
|
|
]),
|
|
);
|
|
plan.insert_new(1, page).expect("inserts");
|
|
|
|
let (saved, _) = apply_plan(&mut doc, &bytes, &plan).expect("applies");
|
|
assert_eq!(page_widths(&saved), vec![100, 555, 101]);
|
|
}
|
|
|
|
/// Every page must reach its own content after a reorder — not the
|
|
/// content of whichever page used to be at that index.
|
|
///
|
|
/// This is the assertion that catches a page tree rebuilt with the right
|
|
/// *shape* and the wrong *references*. Page order alone cannot see it:
|
|
/// the sizes come from the page dictionaries, which move together with
|
|
/// them; the content is a separate object and can be left behind.
|
|
#[test]
|
|
fn each_page_keeps_its_own_content_after_a_reorder() {
|
|
let bytes = document_with_pages(4);
|
|
let mut doc = parse(&bytes);
|
|
let mut plan = PagePlan::new(doc.page_count());
|
|
plan.set_order(&[2, 0, 3, 1]).expect("reorders");
|
|
let (saved, _) = apply_plan(&mut doc, &bytes, &plan).expect("applies");
|
|
|
|
let mut saved_doc = parse(&saved);
|
|
let expected = ["page 2", "page 0", "page 3", "page 1"];
|
|
for (i, want) in expected.iter().enumerate() {
|
|
let page = saved_doc.page(i).expect("page");
|
|
let text = String::from_utf8_lossy(&page.content_data).to_string();
|
|
assert!(
|
|
text.contains(want),
|
|
"page {i} should show {want:?} but its content is {text:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Every page's `/Parent` must point at the rebuilt page tree.
|
|
///
|
|
/// A page whose `/Parent` still names an old intermediate node inherits
|
|
/// that node's attributes — a `/MediaBox` or `/Resources` that no longer
|
|
/// applies — and a reader following `/Parent` upward can leave the tree
|
|
/// entirely.
|
|
#[test]
|
|
fn every_page_parents_to_the_rebuilt_tree() {
|
|
let bytes = document_with_pages(3);
|
|
let mut doc = parse(&bytes);
|
|
let mut plan = PagePlan::new(doc.page_count());
|
|
plan.set_order(&[2, 1, 0]).expect("reorders");
|
|
let (saved, _) = apply_plan(&mut doc, &bytes, &plan).expect("applies");
|
|
|
|
let mut saved_doc = parse(&saved);
|
|
let root_ref = saved_doc
|
|
.trailer()
|
|
.get("Root")
|
|
.and_then(|o| o.as_ref().copied())
|
|
.expect("root");
|
|
let root = saved_doc.resolve_ref(root_ref).expect("root object");
|
|
let pages_ref = root
|
|
.as_dict()
|
|
.and_then(|d| d.get("Pages"))
|
|
.and_then(|o| o.as_ref().copied())
|
|
.expect("pages ref");
|
|
|
|
for i in 0..saved_doc.page_count() {
|
|
let page_ref = saved_doc.page_object_ref(i).expect("page ref");
|
|
let page = saved_doc.resolve_ref(page_ref).expect("page object");
|
|
let parent = page
|
|
.as_dict()
|
|
.and_then(|d| d.get("Parent"))
|
|
.and_then(|o| o.as_ref().copied())
|
|
.unwrap_or_else(|| panic!("page {i} has no /Parent"));
|
|
assert_eq!(parent, pages_ref, "page {i} parents outside the tree");
|
|
}
|
|
}
|
|
|
|
/// `/Count` must equal the number of kids.
|
|
///
|
|
/// Our own parser walks `/Kids` and never reads `/Count`, so it cannot see
|
|
/// a stale one — mutation-testing proved that: setting `/Count` to a
|
|
/// constant 5 passed every other test in this file. Other readers *do*
|
|
/// trust `/Count`, and a document where the two disagree opens with the
|
|
/// wrong number of pages in one viewer and the right number in another.
|
|
///
|
|
/// So this reads the raw page-tree node rather than asking the document
|
|
/// how many pages it thinks there are.
|
|
#[test]
|
|
fn the_page_count_matches_the_kid_array() {
|
|
for order in [vec![0usize, 1, 2, 3], vec![3, 2], vec![1, 1, 1]] {
|
|
let bytes = document_with_pages(4);
|
|
let mut doc = parse(&bytes);
|
|
let mut plan = PagePlan::new(doc.page_count());
|
|
plan.set_order(&order).expect("reorders");
|
|
let (saved, _) = apply_plan(&mut doc, &bytes, &plan).expect("applies");
|
|
|
|
let mut saved_doc = parse(&saved);
|
|
let root_ref = saved_doc
|
|
.trailer()
|
|
.get("Root")
|
|
.and_then(|o| o.as_ref().copied())
|
|
.expect("root");
|
|
let root = saved_doc.resolve_ref(root_ref).expect("root object");
|
|
let pages_ref = root
|
|
.as_dict()
|
|
.and_then(|d| d.get("Pages"))
|
|
.and_then(|o| o.as_ref().copied())
|
|
.expect("pages ref");
|
|
let pages = saved_doc.resolve_ref(pages_ref).expect("pages node");
|
|
let dict = pages.as_dict().expect("pages is a dictionary");
|
|
|
|
let count = dict.get_int("Count").expect("/Count must be present");
|
|
let kids = dict
|
|
.get_array("Kids")
|
|
.map(|k| k.len())
|
|
.expect("/Kids must be present");
|
|
assert_eq!(
|
|
count as usize, kids,
|
|
"order {order:?}: /Count says {count} but there are {kids} kids"
|
|
);
|
|
assert_eq!(
|
|
kids,
|
|
order.len(),
|
|
"order {order:?}: the kid array is the wrong length"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Every kid in the rebuilt tree must be a distinct, resolvable page.
|
|
///
|
|
/// A rebuilt array that repeated a reference, or kept a dangling one from
|
|
/// a deleted page, still has the right *length*, so the count check above
|
|
/// cannot see it.
|
|
#[test]
|
|
fn every_kid_resolves_to_a_page_object() {
|
|
let bytes = document_with_pages(4);
|
|
let mut doc = parse(&bytes);
|
|
let mut plan = PagePlan::new(doc.page_count());
|
|
plan.delete(2).expect("deletes");
|
|
plan.move_page(0, 2).expect("moves");
|
|
let (saved, _) = apply_plan(&mut doc, &bytes, &plan).expect("applies");
|
|
|
|
let mut saved_doc = parse(&saved);
|
|
let root_ref = saved_doc
|
|
.trailer()
|
|
.get("Root")
|
|
.and_then(|o| o.as_ref().copied())
|
|
.expect("root");
|
|
let root = saved_doc.resolve_ref(root_ref).expect("root object");
|
|
let pages_ref = root
|
|
.as_dict()
|
|
.and_then(|d| d.get("Pages"))
|
|
.and_then(|o| o.as_ref().copied())
|
|
.expect("pages ref");
|
|
let pages = saved_doc.resolve_ref(pages_ref).expect("pages node");
|
|
let kids: Vec<PdfObj> = pages
|
|
.as_dict()
|
|
.and_then(|d| d.get_array("Kids"))
|
|
.expect("kids")
|
|
.to_vec();
|
|
|
|
for (i, kid) in kids.iter().enumerate() {
|
|
let r = kid
|
|
.as_ref()
|
|
.copied()
|
|
.unwrap_or_else(|| panic!("kid {i} is not an indirect reference"));
|
|
let obj = saved_doc
|
|
.resolve_ref(r)
|
|
.unwrap_or_else(|e| panic!("kid {i} does not resolve: {e}"));
|
|
assert_eq!(
|
|
obj.as_dict().and_then(|d| d.get_name("Type")),
|
|
Some("Page"),
|
|
"kid {i} is not a /Page"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// A page moved out of a *nested* tree must be re-parented.
|
|
///
|
|
/// The flat fixture above cannot test this: its pages already parent to
|
|
/// the root node, so the re-parenting branch never runs and mutation
|
|
/// testing showed it could be deleted with every other test still green.
|
|
/// A real document with an intermediate `/Pages` node is the case that
|
|
/// matters — leave the old `/Parent` in place and the page keeps
|
|
/// inheriting attributes from a node it is no longer under.
|
|
#[test]
|
|
fn a_page_from_a_nested_tree_is_reparented() {
|
|
// Root -> [intermediate -> [page A, page B]], with the intermediate
|
|
// node supplying a /MediaBox its children inherit.
|
|
let bytes = {
|
|
let mut out = String::from("%PDF-1.7\n");
|
|
let mut offsets = Vec::new();
|
|
offsets.push(out.len());
|
|
out.push_str("1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
|
|
offsets.push(out.len());
|
|
out.push_str("2 0 obj\n<< /Type /Pages /Count 2 /Kids [3 0 R] >>\nendobj\n");
|
|
offsets.push(out.len());
|
|
out.push_str(
|
|
"3 0 obj\n<< /Type /Pages /Parent 2 0 R /Count 2 /Kids [4 0 R 5 0 R] \
|
|
/MediaBox [0 0 321 654] >>\nendobj\n",
|
|
);
|
|
offsets.push(out.len());
|
|
out.push_str("4 0 obj\n<< /Type /Page /Parent 3 0 R >>\nendobj\n");
|
|
offsets.push(out.len());
|
|
out.push_str("5 0 obj\n<< /Type /Page /Parent 3 0 R >>\nendobj\n");
|
|
let startxref = out.len();
|
|
out.push_str("xref\n0 6\n0000000000 65535 f \n");
|
|
for off in &offsets {
|
|
out.push_str(&format!("{off:010} 00000 n \n"));
|
|
}
|
|
out.push_str(&format!(
|
|
"trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n{startxref}\n%%EOF\n"
|
|
));
|
|
out.into_bytes()
|
|
};
|
|
|
|
let mut doc = parse(&bytes);
|
|
assert_eq!(doc.page_count(), 2, "the nested fixture must parse");
|
|
|
|
let mut plan = PagePlan::new(doc.page_count());
|
|
plan.swap(0, 1).expect("swaps");
|
|
let (saved, _) = apply_plan(&mut doc, &bytes, &plan).expect("applies");
|
|
|
|
let mut saved_doc = parse(&saved);
|
|
let root_ref = saved_doc
|
|
.trailer()
|
|
.get("Root")
|
|
.and_then(|o| o.as_ref().copied())
|
|
.expect("root");
|
|
let root = saved_doc.resolve_ref(root_ref).expect("root object");
|
|
let pages_ref = root
|
|
.as_dict()
|
|
.and_then(|d| d.get("Pages"))
|
|
.and_then(|o| o.as_ref().copied())
|
|
.expect("pages ref");
|
|
|
|
for i in 0..saved_doc.page_count() {
|
|
let page_ref = saved_doc.page_object_ref(i).expect("page ref");
|
|
let page = saved_doc.resolve_ref(page_ref).expect("page object");
|
|
let parent = page
|
|
.as_dict()
|
|
.and_then(|d| d.get("Parent"))
|
|
.and_then(|o| o.as_ref().copied())
|
|
.unwrap_or_else(|| panic!("page {i} has no /Parent"));
|
|
assert_eq!(
|
|
parent, pages_ref,
|
|
"page {i} still parents to the old intermediate node, so it \
|
|
inherits from a node it is no longer under"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The original bytes must remain intact: an incremental save appends.
|
|
///
|
|
/// A signed or already-incrementally-updated document depends on its
|
|
/// earlier revisions being byte-identical. Rewriting them in place
|
|
/// invalidates every signature over them.
|
|
#[test]
|
|
fn the_original_revision_is_left_untouched() {
|
|
let bytes = document_with_pages(3);
|
|
let mut doc = parse(&bytes);
|
|
let mut plan = PagePlan::new(doc.page_count());
|
|
plan.delete(1).expect("deletes");
|
|
let (saved, _) = apply_plan(&mut doc, &bytes, &plan).expect("applies");
|
|
|
|
assert!(
|
|
saved.len() > bytes.len(),
|
|
"an incremental save must grow the file"
|
|
);
|
|
assert_eq!(
|
|
&saved[..bytes.len()],
|
|
&bytes[..],
|
|
"the original revision was modified rather than appended to"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_empty_plan_is_refused() {
|
|
let bytes = document_with_pages(2);
|
|
let mut doc = parse(&bytes);
|
|
let mut plan = PagePlan::new(doc.page_count());
|
|
// Reaching an empty plan requires bypassing delete()'s own guard.
|
|
plan.delete(0).expect("first delete is allowed");
|
|
assert_eq!(
|
|
plan.delete(0),
|
|
Err(PageOpError::WouldEmptyDocument),
|
|
"the last page cannot be deleted"
|
|
);
|
|
let (saved, _) = apply_plan(&mut doc, &bytes, &plan).expect("applies");
|
|
assert_eq!(page_widths(&saved), vec![101]);
|
|
}
|
|
|
|
// ------------------------------------------------------------ importing
|
|
|
|
#[test]
|
|
fn importing_a_page_appends_it_and_it_reads_back() {
|
|
let dest_bytes = document_with_pages(2);
|
|
let src_bytes = document_with_pages(3);
|
|
let mut dest = parse(&dest_bytes);
|
|
let mut src = parse(&src_bytes);
|
|
|
|
let (saved, report) = import_pages(&mut dest, &dest_bytes, &mut src, &[2]).expect("imports");
|
|
assert_eq!(report.final_page_count, 3);
|
|
assert!(
|
|
report.objects_copied > 0,
|
|
"importing must copy the page and what it reaches"
|
|
);
|
|
|
|
// The imported page is source page 2, which is 102 wide.
|
|
assert_eq!(page_widths(&saved), vec![100, 101, 102]);
|
|
}
|
|
|
|
/// The imported page must bring its content with it.
|
|
///
|
|
/// This is the assertion that catches the central import bug: copying the
|
|
/// page dictionary but not the objects it references. The result is a page
|
|
/// whose `/Contents` names an object number that means something else
|
|
/// entirely in the destination — usually another page's content, always
|
|
/// silently.
|
|
#[test]
|
|
fn an_imported_page_brings_its_content_stream() {
|
|
let dest_bytes = document_with_pages(1);
|
|
let src_bytes = document_with_pages(3);
|
|
let mut dest = parse(&dest_bytes);
|
|
let mut src = parse(&src_bytes);
|
|
|
|
let (saved, _) = import_pages(&mut dest, &dest_bytes, &mut src, &[1]).expect("imports");
|
|
let mut saved_doc = parse(&saved);
|
|
assert_eq!(saved_doc.page_count(), 2);
|
|
|
|
let imported = saved_doc.page(1).expect("the imported page");
|
|
let text = String::from_utf8_lossy(&imported.content_data).to_string();
|
|
assert!(
|
|
text.contains("page 1"),
|
|
"the imported page shows {text:?}, not its own source content"
|
|
);
|
|
|
|
// And the destination's own page must be undisturbed.
|
|
let original = saved_doc.page(0).expect("page 0");
|
|
let original_text = String::from_utf8_lossy(&original.content_data).to_string();
|
|
assert!(
|
|
original_text.contains("page 0"),
|
|
"importing overwrote the destination's own content: {original_text:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn importing_several_pages_keeps_their_order() {
|
|
let dest_bytes = document_with_pages(1);
|
|
let src_bytes = document_with_pages(4);
|
|
let mut dest = parse(&dest_bytes);
|
|
let mut src = parse(&src_bytes);
|
|
|
|
let (saved, report) = import_pages(&mut dest, &dest_bytes, &mut src, &[3, 1]).expect("imports");
|
|
assert_eq!(report.final_page_count, 3);
|
|
assert_eq!(page_widths(&saved), vec![100, 103, 101]);
|
|
}
|
|
|
|
#[test]
|
|
fn importing_no_pages_changes_nothing() {
|
|
let dest_bytes = document_with_pages(2);
|
|
let src_bytes = document_with_pages(2);
|
|
let mut dest = parse(&dest_bytes);
|
|
let mut src = parse(&src_bytes);
|
|
|
|
let (saved, report) = import_pages(&mut dest, &dest_bytes, &mut src, &[]).expect("no-op");
|
|
assert_eq!(report.final_page_count, 2);
|
|
assert_eq!(saved, dest_bytes, "a no-op import must not touch the file");
|
|
}
|
|
|
|
#[test]
|
|
fn importing_a_page_that_does_not_exist_is_refused() {
|
|
let dest_bytes = document_with_pages(1);
|
|
let src_bytes = document_with_pages(2);
|
|
let mut dest = parse(&dest_bytes);
|
|
let mut src = parse(&src_bytes);
|
|
|
|
let err = import_pages(&mut dest, &dest_bytes, &mut src, &[9]).expect_err("no page 9");
|
|
assert!(matches!(err, PageOpError::NoSuchPage { index: 9, .. }));
|
|
}
|
|
|
|
/// An imported page must not carry a `/Parent` pointing into the source.
|
|
///
|
|
/// Following it would lead a reader out of this document's page tree and
|
|
/// into object numbers that belong to something else.
|
|
#[test]
|
|
fn an_imported_page_parents_into_the_destination() {
|
|
let dest_bytes = document_with_pages(1);
|
|
let src_bytes = document_with_pages(2);
|
|
let mut dest = parse(&dest_bytes);
|
|
let mut src = parse(&src_bytes);
|
|
let (saved, _) = import_pages(&mut dest, &dest_bytes, &mut src, &[0]).expect("imports");
|
|
|
|
let mut saved_doc = parse(&saved);
|
|
let root_ref = saved_doc
|
|
.trailer()
|
|
.get("Root")
|
|
.and_then(|o| o.as_ref().copied())
|
|
.expect("root");
|
|
let root = saved_doc.resolve_ref(root_ref).expect("root object");
|
|
let pages_ref = root
|
|
.as_dict()
|
|
.and_then(|d| d.get("Pages"))
|
|
.and_then(|o| o.as_ref().copied())
|
|
.expect("pages ref");
|
|
|
|
let imported_ref = saved_doc.page_object_ref(1).expect("imported page ref");
|
|
let imported = saved_doc.resolve_ref(imported_ref).expect("imported page");
|
|
let parent = imported
|
|
.as_dict()
|
|
.and_then(|d| d.get("Parent"))
|
|
.and_then(|o| o.as_ref().copied())
|
|
.expect("the imported page must have a /Parent");
|
|
assert_eq!(
|
|
parent, pages_ref,
|
|
"the imported page parents into the source"
|
|
);
|
|
}
|
|
|
|
/// A page inheriting `/MediaBox` from its parent must keep that size when
|
|
/// imported — the ancestor it inherited from is not coming with it.
|
|
#[test]
|
|
fn an_imported_page_keeps_an_inherited_media_box() {
|
|
// A source whose page carries no /MediaBox of its own: it inherits
|
|
// one from the /Pages node, which is exactly the shape that breaks on
|
|
// import if the attribute is not resolved first.
|
|
let src = {
|
|
let mut out = String::from("%PDF-1.7\n");
|
|
let mut offsets = Vec::new();
|
|
offsets.push(out.len());
|
|
out.push_str("1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
|
|
offsets.push(out.len());
|
|
out.push_str(
|
|
"2 0 obj\n<< /Type /Pages /Count 1 /Kids [3 0 R] \
|
|
/MediaBox [0 0 333 444] >>\nendobj\n",
|
|
);
|
|
offsets.push(out.len());
|
|
out.push_str("3 0 obj\n<< /Type /Page /Parent 2 0 R >>\nendobj\n");
|
|
let startxref = out.len();
|
|
out.push_str("xref\n0 4\n0000000000 65535 f \n");
|
|
for off in &offsets {
|
|
out.push_str(&format!("{off:010} 00000 n \n"));
|
|
}
|
|
out.push_str(&format!(
|
|
"trailer\n<< /Size 4 /Root 1 0 R >>\nstartxref\n{startxref}\n%%EOF\n"
|
|
));
|
|
out.into_bytes()
|
|
};
|
|
|
|
let dest_bytes = document_with_pages(1);
|
|
let mut dest = parse(&dest_bytes);
|
|
let mut source = parse(&src);
|
|
assert_eq!(source.page_count(), 1, "the source fixture must parse");
|
|
|
|
let (saved, _) = import_pages(&mut dest, &dest_bytes, &mut source, &[0]).expect("imports");
|
|
let mut saved_doc = parse(&saved);
|
|
let imported = saved_doc.page(1).expect("the imported page");
|
|
assert_eq!(
|
|
imported.media_box[2] as i64, 333,
|
|
"the inherited /MediaBox was lost, so the page changed size"
|
|
);
|
|
}
|