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:
mtlcl410
2026-11-07 22:22:47 +08:00
co-authored by Claude Opus 5.5
parent f42fda7342
commit 6847b1ea7e
13 changed files with 256 additions and 17 deletions
Generated
+1
View File
@@ -4649,6 +4649,7 @@ dependencies = [
"lopdf",
"pdfcraft-cos",
"pdfcraft-fonts",
"pdfcraft-testkit",
"serde",
"skrifa 0.44.0",
"thiserror 2.0.21",
+2
View File
@@ -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
+7
View File
@@ -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) {
+40 -4
View File
@@ -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()
}
+36
View File
@@ -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());
}
+1
View File
@@ -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 }
+32
View File
@@ -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);
}
}
+3 -1
View File
@@ -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())?;
+76
View File
@@ -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::*;
+2 -2
View File
@@ -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"
+1 -1
View File
File diff suppressed because one or more lines are too long
+15 -4
View File
@@ -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()
+40 -5
View File
@@ -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)