mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-09 04:54:17 +00:00
M1: a file whose last update has no /Prev opens with all its pages and a repair note (core.repair-broken-files)
An update whose cross-reference section has no /Prev (ISO 32000-2 §7.5.6, §7.5.8.2) cuts the earlier revisions, and the page tree with them, off the chain. The signed PDFBOX-2720 and PDFBOX-2942 files have this shape: the document model found no page tree and the renderer showed 2 blank pages found by scanning for page dictionaries. When the chain reaches only the newest section, the file holds another startxref and the catalog's /Pages can't be read, both readers now rebuild the table from the file's objects, as for a damaged file: - cos: the existing reconstruction (newest in-file definition wins), a repair-log line saying why, and `Document::rebuilt_unlinked_update`. - hayro-syntax (44): `root_xref` repairs the table before the page tree is read; `Pdf::rebuilt_unlinked_update` says so. - render: an open warning, and the lazy structure reader accepts such a rebuilt file, so links, fields and bookmarks are read too. A save rewrites the file in full (main's rule for reconstructed files). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
f42fda7342
commit
6847b1ea7e
Generated
+1
@@ -4649,6 +4649,7 @@ dependencies = [
|
||||
"lopdf",
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-fonts",
|
||||
"pdfcraft-testkit",
|
||||
"serde",
|
||||
"skrifa 0.44.0",
|
||||
"thiserror 2.0.21",
|
||||
|
||||
@@ -48,6 +48,8 @@ pub struct Document {
|
||||
/// `fill_xref_gaps`.
|
||||
located: Arc<BTreeMap<u32, (u64, u16)>>,
|
||||
repair_log: Arc<Vec<String>>,
|
||||
/// See [`Self::rebuilt_unlinked_update`].
|
||||
unlinked_update: bool,
|
||||
cache: Arc<Mutex<state::ObjectCache>>,
|
||||
objstms: Arc<Mutex<HashMap<u32, Arc<ObjStm>>>>,
|
||||
/// What `/JBIG2Globals` streams of the file decode to, by object number: the images of a scan
|
||||
|
||||
@@ -88,6 +88,13 @@ impl Document {
|
||||
&self.repair_log
|
||||
}
|
||||
|
||||
/// Whether the file was rebuilt from its object headers because its last update has no `/Prev`
|
||||
/// and so cut its page tree off the cross-reference chain (ISO 32000-2 §7.5.6, §7.5.8.2). The
|
||||
/// renderer rebuilds such a file under the same conditions, so the two read the same objects.
|
||||
pub fn rebuilt_unlinked_update(&self) -> bool {
|
||||
self.unlinked_update
|
||||
}
|
||||
|
||||
/// Add a line to [`Self::repair_log`]: a leniency applied while reading that the user should
|
||||
/// know about (fidelity: never silent).
|
||||
pub fn note_repair(&mut self, line: String) {
|
||||
|
||||
@@ -21,6 +21,7 @@ impl Document {
|
||||
xref_oddities: Arc::default(),
|
||||
located: Arc::default(),
|
||||
repair_log: Arc::new(Vec::new()),
|
||||
unlinked_update: false,
|
||||
cache: Arc::default(),
|
||||
objstms: Arc::default(),
|
||||
jbig2_globals: Arc::default(),
|
||||
@@ -108,6 +109,7 @@ impl Document {
|
||||
xref_oddities: Arc::default(),
|
||||
located: Arc::default(),
|
||||
repair_log: Arc::new(Vec::new()),
|
||||
unlinked_update: false,
|
||||
cache: Arc::default(),
|
||||
objstms: Arc::default(),
|
||||
jbig2_globals: Arc::default(),
|
||||
@@ -132,6 +134,8 @@ impl Document {
|
||||
}
|
||||
let parsed = doc.read_xref_chain(&mut log);
|
||||
let mut authenticated = false;
|
||||
// The newest section cut the page tree off from the chain (see `update_cuts_off_the_pages`).
|
||||
let mut cut_off = false;
|
||||
let ok = match parsed {
|
||||
Ok((entries, trailer, revisions, oddities)) => {
|
||||
doc.entries = Arc::new(entries);
|
||||
@@ -145,17 +149,22 @@ impl Document {
|
||||
doc.authenticate(&enc, &credentials)?;
|
||||
authenticated = true;
|
||||
}
|
||||
doc.root_is_catalog()
|
||||
let catalog = doc.root_is_catalog();
|
||||
cut_off = catalog && doc.update_cuts_off_the_pages();
|
||||
catalog && !cut_off
|
||||
}
|
||||
Err(e) => {
|
||||
log.push(format!("cross-reference data unreadable ({e}); rebuilding from object headers"));
|
||||
false
|
||||
}
|
||||
};
|
||||
if cut_off {
|
||||
log.push(UNLINKED_UPDATE.into());
|
||||
doc.unlinked_update = true;
|
||||
} else if !ok && !doc.entries.is_empty() {
|
||||
log.push("cross-reference data does not lead to a valid catalog; rebuilding from object headers".into());
|
||||
}
|
||||
if !ok {
|
||||
if !doc.entries.is_empty() {
|
||||
log.push("cross-reference data does not lead to a valid catalog; rebuilding from object headers".into());
|
||||
}
|
||||
doc.reconstruct(&mut log)?;
|
||||
}
|
||||
// Header generations are plaintext, including in encrypted files. Inspect only the
|
||||
@@ -180,6 +189,24 @@ impl Document {
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
/// Whether the chain of cross-reference sections ends at the newest one (no `/Prev`, or one
|
||||
/// that isn't an offset) although the file holds earlier sections, and the catalog's `/Pages`
|
||||
/// is not among the objects that one section reaches. ISO 32000-2 §7.5.6 and §7.5.8.2 require
|
||||
/// the `/Prev`; without it the earlier revisions, page tree and all, are cut off, so the file
|
||||
/// is rebuilt from its object headers as a damaged one is (the newest definition of each
|
||||
/// object wins), and the repair log says why. (A file whose `/Root` itself is missing is
|
||||
/// rebuilt anyway.)
|
||||
fn update_cuts_off_the_pages(&self) -> bool {
|
||||
if self.revisions.len() != 1 {
|
||||
return false;
|
||||
}
|
||||
let Some(root) = self.root() else { return false };
|
||||
let Ok(catalog) = self.try_get_reference(root) else { return false };
|
||||
let Object::Dict(catalog) = &*catalog else { return false };
|
||||
// The page tree first: the scan for an earlier `startxref` reads the whole file.
|
||||
!catalog.get(b"Pages").is_some_and(|pages| matches!(&*self.resolve(pages), Object::Dict(_))) && earlier_sections(&self.data)
|
||||
}
|
||||
|
||||
fn authenticate(&mut self, enc: &Object, credentials: &Credentials<'_>) -> Result<(), CosError> {
|
||||
// Authentication can change how the same input is decoded.
|
||||
self.source_identity = Arc::new(());
|
||||
@@ -408,3 +435,12 @@ impl Document {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The repair-log line for a file rebuilt because its last update has no `/Prev`.
|
||||
const UNLINKED_UPDATE: &str = "the file's last update has no /Prev link to its earlier revisions, so their objects (the page tree among them) are unreachable through the cross-reference chain; rebuilding from object headers";
|
||||
|
||||
/// Whether `data` has more than one `startxref`: an update was appended to it, or it is
|
||||
/// linearized (whose first-page section links to the main one).
|
||||
fn earlier_sections(data: &[u8]) -> bool {
|
||||
find(data, b"startxref", 0, data.len()).and_then(|at| find(data, b"startxref", at.saturating_add(9), data.len())).is_some()
|
||||
}
|
||||
|
||||
@@ -1921,3 +1921,39 @@ fn the_editor_cache_charges_a_dictionary_for_the_values_it_holds() {
|
||||
let sum = cache.values().fold(0usize, |n, o| n.saturating_add(held_bytes(o, &doc.data, 0)));
|
||||
assert_eq!(cache.owned(), sum, "the kept total is the cache's own");
|
||||
}
|
||||
|
||||
/// The pages of `doc` in page-tree order, as the dictionaries the document holds.
|
||||
fn page_tree_pages(doc: &Document) -> Vec<Dict> {
|
||||
let catalog = doc.get(doc.root().expect("root"));
|
||||
let pages = doc.resolve(catalog.as_dict().and_then(|c| c.get(b"Pages")).expect("/Pages"));
|
||||
let kids = pages.as_dict().and_then(|p| p.get(b"Kids")).and_then(Object::as_array).cloned().unwrap_or_default();
|
||||
kids.iter().filter_map(|k| doc.resolve(k).as_dict().cloned()).collect()
|
||||
}
|
||||
|
||||
/// ISO 32000-2 §7.5.6 and §7.5.8.2: an update reaches the revisions before it through `/Prev`.
|
||||
/// One without it cuts them off, page tree and all. The file is rebuilt from its object headers
|
||||
/// as a damaged one is, so its page tree is found, the newest definition of each object wins, and
|
||||
/// the repair log says why; a save rewrites it in full, with every page.
|
||||
#[test]
|
||||
fn an_update_without_prev_is_rebuilt_with_its_page_tree_and_a_repair_note() {
|
||||
let doc = Document::open(Arc::new(pdfcraft_testkit::update_without_prev(false))).expect("the unlinked file opens");
|
||||
let pages = page_tree_pages(&doc);
|
||||
assert_eq!(pages.len(), 3);
|
||||
assert!(pages.first().is_some_and(|p| p.contains(b"Annots")), "the update's page wins: {pages:?}");
|
||||
assert!(doc.reconstructed() && doc.rebuilt_unlinked_update());
|
||||
assert!(doc.repair_log().iter().any(|l| l.contains("no /Prev")), "{:?}", doc.repair_log());
|
||||
let catalog = doc.get(doc.root().expect("root"));
|
||||
assert!(catalog.as_dict().is_some_and(|c| c.contains(b"Lang")), "the update's catalog wins");
|
||||
|
||||
let saved = crate::write_incremental(&doc, &crate::SaveOptions::default()).expect("saves");
|
||||
let reopened = Document::open(Arc::new(saved)).expect("the saved file opens");
|
||||
assert_eq!(page_tree_pages(&reopened).len(), 3);
|
||||
assert!(reopened.repair_log().is_empty(), "{:?}", reopened.repair_log());
|
||||
|
||||
// The same file with `/Prev` reads through its chain, as before.
|
||||
let linked = Document::open(Arc::new(pdfcraft_testkit::update_without_prev(true))).expect("the linked file opens");
|
||||
assert_eq!(page_tree_pages(&linked).len(), 3);
|
||||
assert!(!linked.reconstructed() && !linked.rebuilt_unlinked_update());
|
||||
assert_eq!(linked.revisions().len(), 2);
|
||||
assert!(linked.repair_log().is_empty(), "{:?}", linked.repair_log());
|
||||
}
|
||||
|
||||
@@ -29,5 +29,6 @@ bytemuck = { version = "1" }
|
||||
getrandom = { version = "0.4", features = ["wasm_js"] }
|
||||
|
||||
[dev-dependencies]
|
||||
pdfcraft-testkit = { workspace = true }
|
||||
# Builds the decompression-bomb streams in the raster tests.
|
||||
flate2 = { workspace = true }
|
||||
|
||||
@@ -80,6 +80,9 @@ pub fn inspect(bytes: Arc<Vec<u8>>, password: Option<&str>) -> Result<DocInfo, O
|
||||
Ok(p) => info.pages = p,
|
||||
Err(p) => return Err(OpenError::Invalid(format!("the page tree could not be read: {}", crate::raster::panic_message(&p)))),
|
||||
}
|
||||
// Never silent: the pages of a file whose last update has no `/Prev` were found by rebuilding
|
||||
// its cross-reference data (`pdfcraft-cos` rebuilds it under the same conditions and logs it).
|
||||
let unlinked = pdf.rebuilt_unlinked_update();
|
||||
let options = load_options(password);
|
||||
// Geometry no longer needs the renderer parser. Do not overlap its allocations with
|
||||
// structural inspection, including the compatibility fallback for damaged files.
|
||||
@@ -110,9 +113,17 @@ pub fn inspect(bytes: Arc<Vec<u8>>, password: Option<&str>) -> Result<DocInfo, O
|
||||
if info.pages.is_empty() {
|
||||
return Err(OpenError::Invalid("the document has no pages".into()));
|
||||
}
|
||||
if unlinked {
|
||||
info.warnings.push(UNLINKED_UPDATE.into());
|
||||
}
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
/// The warning for a file whose pages were found by rebuilding its cross-reference data because its
|
||||
/// last update has no `/Prev` (ISO 32000-2 §7.5.6, §7.5.8.2).
|
||||
const UNLINKED_UPDATE: &str =
|
||||
"This file's last update has no link (/Prev) to its earlier revisions; its pages were found by rebuilding the cross-reference data.";
|
||||
|
||||
// Commit auxiliary metadata only after inspection succeeds; renderer geometry is the fallback.
|
||||
pub(super) fn inspect_structure(info: &mut DocInfo, fill: impl FnOnce(&mut DocInfo) -> Result<(), String>) {
|
||||
let structure = catch_unwind(AssertUnwindSafe(|| {
|
||||
@@ -430,3 +441,24 @@ mod open_action_javascript_tests {
|
||||
assert!(!info.has_javascript, "a /GoTo /OpenAction is not JavaScript");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod unlinked_update_tests {
|
||||
use super::*;
|
||||
|
||||
/// An update without `/Prev` cuts the page tree off the chain; the pages come from the
|
||||
/// rebuilt table, the page tree's pages in its order, and the file says it was rebuilt.
|
||||
#[test]
|
||||
fn an_update_without_prev_shows_the_page_tree_and_says_it_was_rebuilt() {
|
||||
let info = inspect(Arc::new(pdfcraft_testkit::update_without_prev(false)), None).expect("the unlinked file opens");
|
||||
assert_eq!(info.pages.len(), 3, "{:?}", info.pages);
|
||||
assert_eq!(info.pages.iter().map(|p| p.width).collect::<Vec<_>>(), [200.0, 300.0, 400.0]);
|
||||
assert_eq!(info.warnings, [UNLINKED_UPDATE]);
|
||||
// The structure is read from the rebuilt table as well: the second page's link.
|
||||
assert_eq!(info.links.len(), 1, "{:?}", info.links);
|
||||
let linked = inspect(Arc::new(pdfcraft_testkit::update_without_prev(true)), None).expect("the linked file opens");
|
||||
assert_eq!(linked.pages.len(), 3);
|
||||
assert!(linked.warnings.is_empty(), "{:?}", linked.warnings);
|
||||
assert_eq!(linked.links.len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,7 +144,9 @@ pub(crate) struct LazyStructure {
|
||||
impl LazyStructure {
|
||||
pub(crate) fn new(bytes: Arc<Vec<u8>>, password: Option<&str>) -> std::result::Result<Self, String> {
|
||||
let source = Document::open_with_stream_limit(bytes, password, 256 << 20).map_err(|e| e.to_string())?;
|
||||
if !source.repair_log().is_empty() {
|
||||
// A file rebuilt because its last update has no `/Prev` is read here as rebuilt: the
|
||||
// renderer rebuilds it too, and the fallback reader would follow the cut-off chain.
|
||||
if !source.repair_log().is_empty() && !source.rebuilt_unlinked_update() {
|
||||
return Err("the structure requires compatibility repair".into());
|
||||
}
|
||||
let trailer = convert_dict(source.trailer(), 0).map_err(|e| e.to_string())?;
|
||||
|
||||
@@ -133,6 +133,82 @@ pub fn stream_obj(dict: &str, data: impl AsRef<[u8]>) -> Vec<u8> {
|
||||
out
|
||||
}
|
||||
|
||||
/// A three-page file (200, 300 and 400 points wide, in that order, the second with a link to
|
||||
/// `https://example.com/`) whose page tree sits in an object stream of its first revision, plus an update that rewrites the catalog and the first
|
||||
/// page in a cross-reference stream: with `/Prev` when `linked`, without it otherwise (the shape
|
||||
/// of the signed PDFBOX-2720 and PDFBOX-2942 files). Without `/Prev` a reader that follows the
|
||||
/// chain (ISO 32000-2 §7.5.6, §7.5.8.2) finds no page tree, and a scan for page dictionaries
|
||||
/// outside object streams finds only the rewritten page.
|
||||
pub fn update_without_prev(linked: bool) -> Vec<u8> {
|
||||
fn row(out: &mut Vec<u8>, kind: u8, field: usize, index: u16) {
|
||||
out.push(kind);
|
||||
out.extend_from_slice(&u32::try_from(field).unwrap_or(u32::MAX).to_be_bytes());
|
||||
out.extend_from_slice(&index.to_be_bytes());
|
||||
}
|
||||
let page = |width: u32, extra: &str| format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {width} 200] /Contents 4 0 R{extra} >>");
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let one = out.len();
|
||||
out.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
|
||||
let four = out.len();
|
||||
out.extend_from_slice(b"4 0 obj\n<< /Length 14 >>\nstream\n0 0 50 50 re f\nendstream\nendobj\n");
|
||||
// Objects 2 (the page tree), 3, 6 and 7 (the pages) and 10 (the link) in object stream 5.
|
||||
let members = [
|
||||
(2, "<< /Type /Pages /Kids [3 0 R 6 0 R 7 0 R] /Count 3 >>".to_string()),
|
||||
(3, page(200, "")),
|
||||
(6, page(300, " /Annots [10 0 R]")),
|
||||
(7, page(400, "")),
|
||||
(10, "<< /Type /Annot /Subtype /Link /Rect [10 10 60 60] /A << /S /URI /URI (https://example.com/) >> >>".to_string()),
|
||||
];
|
||||
let (mut header, mut body) = (String::new(), String::new());
|
||||
for (num, member) in &members {
|
||||
header.push_str(&format!("{num} {} ", body.len()));
|
||||
body.push_str(member);
|
||||
body.push(' ');
|
||||
}
|
||||
let five = out.len();
|
||||
out.extend_from_slice(
|
||||
format!(
|
||||
"5 0 obj\n<< /Type /ObjStm /N 5 /First {} /Length {} >>\nstream\n{header}{body}\nendstream\nendobj\n",
|
||||
header.len(),
|
||||
header.len() + body.len()
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
let eight = out.len();
|
||||
let mut rows = Vec::new();
|
||||
row(&mut rows, 0, 0, 65535);
|
||||
row(&mut rows, 1, one, 0);
|
||||
row(&mut rows, 2, 5, 0);
|
||||
row(&mut rows, 2, 5, 1);
|
||||
row(&mut rows, 1, four, 0);
|
||||
row(&mut rows, 1, five, 0);
|
||||
row(&mut rows, 2, 5, 2);
|
||||
row(&mut rows, 2, 5, 3);
|
||||
row(&mut rows, 1, eight, 0);
|
||||
row(&mut rows, 0, 0, 0);
|
||||
row(&mut rows, 2, 5, 4);
|
||||
out.extend_from_slice(format!("8 0 obj\n<< /Type /XRef /Size 11 /Root 1 0 R /W [1 4 2] /Length {} >>\nstream\n", rows.len()).as_bytes());
|
||||
out.extend_from_slice(&rows);
|
||||
out.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{eight}\n%%EOF\n").as_bytes());
|
||||
// The update: the catalog and the first page again, and a section for them and itself.
|
||||
let catalog = out.len();
|
||||
out.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R /Lang (en) >>\nendobj\n");
|
||||
let first = out.len();
|
||||
out.extend_from_slice(b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Annots [] >>\nendobj\n");
|
||||
let nine = out.len();
|
||||
let mut rows = Vec::new();
|
||||
row(&mut rows, 1, catalog, 0);
|
||||
row(&mut rows, 1, first, 0);
|
||||
row(&mut rows, 1, nine, 0);
|
||||
let prev = if linked { format!(" /Prev {eight}") } else { String::new() };
|
||||
out.extend_from_slice(
|
||||
format!("9 0 obj\n<< /Type /XRef /Size 11 /Root 1 0 R /Index [1 1 3 1 9 1] /W [1 4 2]{prev} /Length {} >>\nstream\n", rows.len()).as_bytes(),
|
||||
);
|
||||
out.extend_from_slice(&rows);
|
||||
out.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{nine}\n%%EOF\n").as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -46,8 +46,8 @@ milestone = "M1"
|
||||
status = "shipped"
|
||||
commands = ["file.open"]
|
||||
tools = ["doc_open"]
|
||||
evidence = ["crates/cos/src/document/tests.rs::damaged_xref_is_reconstructed", "crates/cos/src/parser.rs::indirect_stream_with_wrong_length_recovers", "crates/cos/src/document/tests.rs::self_referential_length_does_not_overflow", "crates/engine/src/tests.rs::a_repaired_file_is_rewritten_by_save_even_without_edits", "crates/automation/tests/it/automation.rs::saving_a_repaired_file_puts_the_repair_on_disk", "crates/engine/src/tests.rs::a_signed_repaired_file_keeps_its_bytes_when_saved_without_edits", "crates/cos/src/document/tests.rs::a_table_numbered_from_1_that_begins_with_object_0_is_read_from_0"]
|
||||
notes = "Save and doc_save write a reconstructed file in full even when nothing was edited, so the repair reaches the disk and the saved file opens without one (#1937); a signed one keeps its bytes, so its signatures stay valid. A repair does not mark the document as having unsaved changes, so closing it does not offer to save the repaired file. A classic table numbered 1 n that begins with object 0's free entry is read from 0 (#2676) and logged."
|
||||
evidence = ["crates/cos/src/document/tests.rs::damaged_xref_is_reconstructed", "crates/cos/src/parser.rs::indirect_stream_with_wrong_length_recovers", "crates/cos/src/document/tests.rs::self_referential_length_does_not_overflow", "crates/engine/src/tests.rs::a_repaired_file_is_rewritten_by_save_even_without_edits", "crates/automation/tests/it/automation.rs::saving_a_repaired_file_puts_the_repair_on_disk", "crates/engine/src/tests.rs::a_signed_repaired_file_keeps_its_bytes_when_saved_without_edits", "crates/cos/src/document/tests.rs::a_table_numbered_from_1_that_begins_with_object_0_is_read_from_0", "crates/cos/src/document/tests.rs::an_update_without_prev_is_rebuilt_with_its_page_tree_and_a_repair_note", "crates/render/src/inspect/core.rs::an_update_without_prev_shows_the_page_tree_and_says_it_was_rebuilt"]
|
||||
notes = "Save and doc_save write a reconstructed file in full even when nothing was edited, so the repair reaches the disk and the saved file opens without one (#1937); a signed one keeps its bytes, so its signatures stay valid. A repair does not mark the document as having unsaved changes, so closing it does not offer to save the repaired file. A classic table numbered 1 n that begins with object 0's free entry is read from 0 (#2676) and logged. A file whose last update has no /Prev (ISO 32000-2 §7.5.6, §7.5.8.2), so that its page tree is cut off the chain, is rebuilt from its object headers by the document model and the renderer alike, with a repair note and an open warning (the signed PDFBOX-2720 and PDFBOX-2942 files)."
|
||||
|
||||
[[feature]]
|
||||
id = "core.malformed-input-safety"
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+15
-4
@@ -17,6 +17,8 @@ pub struct Pdf {
|
||||
header_version: PdfVersion,
|
||||
pages: CachedPages,
|
||||
data: PdfData,
|
||||
/// PdfCraft patch: (hayro-syntax (44)) see [`Pdf::rebuilt_unlinked_update`].
|
||||
rebuilt_unlinked_update: bool,
|
||||
}
|
||||
|
||||
/// An error that occurred while loading a PDF file.
|
||||
@@ -47,12 +49,13 @@ impl Pdf {
|
||||
let data = data.into();
|
||||
let password = password.as_bytes();
|
||||
let version = find_version(data.as_ref()).unwrap_or(PdfVersion::Pdf10);
|
||||
let xref = match root_xref(data.clone(), password) {
|
||||
let (xref, rebuilt_unlinked_update) = match root_xref(data.clone(), password) {
|
||||
Ok(x) => x,
|
||||
Err(e) => match e {
|
||||
XRefError::Unknown => {
|
||||
fallback(data.clone(), password).ok_or(LoadPdfError::Invalid)?
|
||||
}
|
||||
XRefError::Unknown => (
|
||||
fallback(data.clone(), password).ok_or(LoadPdfError::Invalid)?,
|
||||
false,
|
||||
),
|
||||
XRefError::Encryption(e) => return Err(LoadPdfError::Decryption(e)),
|
||||
},
|
||||
};
|
||||
@@ -65,9 +68,17 @@ impl Pdf {
|
||||
header_version: version,
|
||||
pages,
|
||||
data,
|
||||
rebuilt_unlinked_update,
|
||||
})
|
||||
}
|
||||
|
||||
/// PdfCraft patch: (hayro-syntax (44)) whether the newest cross-reference section has no
|
||||
/// `/Prev`, so the page tree in the revisions before it could only be found by rebuilding the
|
||||
/// table from the file's objects.
|
||||
pub fn rebuilt_unlinked_update(&self) -> bool {
|
||||
self.rebuilt_unlinked_update
|
||||
}
|
||||
|
||||
/// Return the number of objects present in the PDF file.
|
||||
pub fn len(&self) -> usize {
|
||||
self.xref.len()
|
||||
|
||||
Vendored
+40
-5
@@ -50,19 +50,51 @@ pub(crate) enum XRefError {
|
||||
}
|
||||
|
||||
/// Parse the "root" xref from the PDF.
|
||||
pub(crate) fn root_xref(data: PdfData, password: &[u8]) -> Result<XRef, XRefError> {
|
||||
///
|
||||
/// PdfCraft patch: (hayro-syntax (44)) also whether the table was rebuilt because the newest
|
||||
/// section cuts off the page tree (see below).
|
||||
pub(crate) fn root_xref(data: PdfData, password: &[u8]) -> Result<(XRef, bool), XRefError> {
|
||||
let mut xref_map = FxHashMap::default();
|
||||
let xref_pos = find_last_xref_pos(data.as_ref()).ok_or(XRefError::Unknown)?;
|
||||
let trailer =
|
||||
populate_xref_impl(data.as_ref(), xref_pos, &mut xref_map).ok_or(XRefError::Unknown)?;
|
||||
// PdfCraft patch: (hayro-syntax (44)) the sections the chain reached, for the check below.
|
||||
let mut visited = BTreeSet::new();
|
||||
let trailer = populate_xref_impl_inner(data.as_ref(), xref_pos, &mut xref_map, &mut visited)
|
||||
.ok_or(XRefError::Unknown)?;
|
||||
|
||||
XRef::new(
|
||||
let xref = XRef::new(
|
||||
data.clone(),
|
||||
xref_map,
|
||||
XRefInput::TrailerDictData(trailer),
|
||||
false,
|
||||
password,
|
||||
)
|
||||
)?;
|
||||
// PdfCraft patch: (hayro-syntax (44)) an update whose section has no `/Prev` (ISO 32000-2
|
||||
// §7.5.6, §7.5.8.2) cuts off the revisions before it, and with them the page tree its catalog
|
||||
// names (the signed files of PDFBOX-2720 and PDFBOX-2942). The table is rebuilt by scanning
|
||||
// the file, as for a broken one, so the page tree is found rather than whatever page
|
||||
// dictionaries `Pages::new_brute_force` turns up. Only when the chain reached that one
|
||||
// section, the file holds another `startxref`, and the page tree can't be read: the
|
||||
// conditions `pdfcraft-cos` rebuilds under, so the two agree on the pages.
|
||||
let cut_off = visited.len() == 1
|
||||
&& xref
|
||||
.get::<Dict<'_>>(xref.trailer_data().pages_ref)
|
||||
.is_none()
|
||||
&& more_than_one_startxref(data.as_ref());
|
||||
if cut_off {
|
||||
warn!(
|
||||
"the newest xref section has no Prev and the page tree is unreachable; rebuilding the xref table"
|
||||
);
|
||||
xref.repair();
|
||||
}
|
||||
Ok((xref, cut_off))
|
||||
}
|
||||
|
||||
/// PdfCraft patch: (hayro-syntax (44)) whether `data` holds an earlier `startxref` than its last.
|
||||
fn more_than_one_startxref(data: &[u8]) -> bool {
|
||||
let needle = b"startxref";
|
||||
findr_needle(data, needle)
|
||||
.and_then(|last| data.get(..last))
|
||||
.is_some_and(|before| crate::util::find_needle(before, needle).is_some())
|
||||
}
|
||||
|
||||
/// Try to manually parse the PDF to build an xref table and trailer dictionary.
|
||||
@@ -980,6 +1012,9 @@ impl XRefEntry {
|
||||
}
|
||||
}
|
||||
|
||||
// PdfCraft patch: (hayro-syntax (44)) `root_xref` reads the chain itself now; the tests still
|
||||
// call this.
|
||||
#[cfg(test)]
|
||||
fn populate_xref_impl<'a>(data: &'a [u8], pos: usize, xref_map: &mut XrefMap) -> Option<&'a [u8]> {
|
||||
let mut visited = BTreeSet::new();
|
||||
populate_xref_impl_inner(data, pos, xref_map, &mut visited)
|
||||
|
||||
Reference in New Issue
Block a user