mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 10:08:00 -06:00
crates/cos/src/document.rs: #2220 moved the header scan into object_headers; it also returns the position after `obj`, and reconstruct applies this PR's /XRef candidate check (same 1024-byte window) to each header there. Tests: kept both sides.
This commit is contained in:
+95
-11
@@ -1952,7 +1952,7 @@ impl Document {
|
||||
return;
|
||||
}
|
||||
let mut located = BTreeMap::new();
|
||||
for (num, offset, generation) in object_headers(&self.data) {
|
||||
for (num, offset, generation, _) in object_headers(&self.data) {
|
||||
located.insert(num, (offset, generation));
|
||||
}
|
||||
let mut filled = entries.to_map(&self.data);
|
||||
@@ -1989,9 +1989,17 @@ impl Document {
|
||||
let data = self.data.clone();
|
||||
let mut entries: BTreeMap<u32, XrefEntry> = BTreeMap::new();
|
||||
let mut objstms = Vec::new();
|
||||
for (num, offset, generation) in object_headers(&data) {
|
||||
// Offsets of objects that may be cross-reference streams: their dictionaries are the
|
||||
// trailers of files that have no `trailer` keyword (§7.5.8).
|
||||
let mut xref_streams = Vec::new();
|
||||
for (num, offset, generation, body) in object_headers(&data) {
|
||||
entries.insert(num, XrefEntry::InFile { offset, generation });
|
||||
objstms.push(num);
|
||||
if find(&data, b"/XRef", body, body.saturating_add(1024)).is_some()
|
||||
&& let Ok(at) = usize::try_from(offset)
|
||||
{
|
||||
xref_streams.push(at);
|
||||
}
|
||||
}
|
||||
if entries.is_empty() {
|
||||
return Err(CosError::Syntax { offset: 0, detail: "no objects found".into() });
|
||||
@@ -2015,20 +2023,44 @@ impl Document {
|
||||
let object_count = entries.len();
|
||||
self.entries = Arc::new(XrefIndex::Materialized(Arc::new(entries)));
|
||||
self.cache.lock().map_err(|_| CosError::Poisoned)?.clear();
|
||||
// Trailer: the last `trailer` dictionary with a usable /Root, else the catalog itself.
|
||||
let mut trailer = None;
|
||||
// Trailer: the last `trailer` dictionary or cross-reference stream dictionary with a
|
||||
// usable /Root (last in the file, whichever kind), else the catalog itself. One whose
|
||||
// /Root is a catalog (as `root_is_catalog` judges it) beats one whose /Root is another
|
||||
// dictionary, such as /Info, wherever it is; an FDF's /Root (no /Type, no /Pages) still
|
||||
// counts when nothing better is there.
|
||||
// A file whose only trailers are cross-reference streams (and whose `startxref` is gone
|
||||
// or points nowhere) keeps its /Info, /ID and /Encrypt this way.
|
||||
let rank = |at: usize, d: &Dict| {
|
||||
let root = self.try_get(d.reference(b"Root")?.num).ok()?;
|
||||
let Object::Dict(c) = &*root else { return None };
|
||||
Some((c.name(b"Type") == Some(b"Catalog") || c.contains(b"Pages"), at))
|
||||
};
|
||||
let mut trailer: Option<((bool, usize), Dict)> = None;
|
||||
let mut from = 0;
|
||||
while let Some(p) = find(&data, b"trailer", from, data.len()) {
|
||||
from = p + 7;
|
||||
let mut lx = Lexer::new(&data, from);
|
||||
if let Ok(Object::Dict(d)) = lx.object()
|
||||
&& d.reference(b"Root").is_some_and(|r| matches!(self.try_get(r.num).as_deref(), Ok(Object::Dict(_))))
|
||||
&& let Some(r) = rank(p, &d)
|
||||
&& trailer.as_ref().is_none_or(|(best, _)| r > *best)
|
||||
{
|
||||
trailer = Some(d);
|
||||
trailer = Some((r, d));
|
||||
}
|
||||
}
|
||||
for at in xref_streams {
|
||||
if trailer.as_ref().is_some_and(|((catalog, p), _)| *catalog && *p > at) {
|
||||
continue;
|
||||
}
|
||||
if let Ok((_, Object::Stream(s))) = parse_indirect_shared(&data, at, &|_| None)
|
||||
&& s.dict.name(b"Type") == Some(b"XRef")
|
||||
&& let Some(r) = rank(at, &s.dict)
|
||||
&& trailer.as_ref().is_none_or(|(best, _)| r > *best)
|
||||
{
|
||||
trailer = Some((r, s.dict));
|
||||
}
|
||||
}
|
||||
let trailer = match trailer {
|
||||
Some(t) => t,
|
||||
Some((_, t)) => t,
|
||||
None => {
|
||||
let catalog = self
|
||||
.entries
|
||||
@@ -2042,7 +2074,9 @@ impl Document {
|
||||
}
|
||||
};
|
||||
let mut trailer = trailer;
|
||||
for k in [&b"Prev"[..], b"XRefStm"] {
|
||||
// What a cross-reference stream's dictionary carries for itself goes, as the chain
|
||||
// readers drop it: a save writes its own.
|
||||
for k in [&b"Prev"[..], b"XRefStm", b"Type", b"W", b"Index", b"Filter", b"DecodeParms", b"Length"] {
|
||||
trailer.remove(k);
|
||||
}
|
||||
self.trailer = trailer;
|
||||
@@ -2071,8 +2105,9 @@ fn shifted_subsection_end(data: &[u8], at: usize) -> Option<u32> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Every `N G obj` header in `data`, in file order, as (number, offset, generation).
|
||||
fn object_headers(data: &[u8]) -> Vec<(u32, u64, u16)> {
|
||||
/// Every `N G obj` header in `data`, in file order, as (number, offset, generation, position
|
||||
/// after `obj`).
|
||||
fn object_headers(data: &[u8]) -> Vec<(u32, u64, u16, usize)> {
|
||||
let mut headers = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < data.len() {
|
||||
@@ -2087,7 +2122,7 @@ fn object_headers(data: &[u8]) -> Vec<(u32, u64, u16)> {
|
||||
let num = std::str::from_utf8(n).ok().and_then(|s| s.parse::<u32>().ok());
|
||||
let generation = std::str::from_utf8(g).ok().and_then(|s| s.parse::<u16>().ok()).unwrap_or(0);
|
||||
if let Some(num) = num {
|
||||
headers.push((num, i as u64, generation));
|
||||
headers.push((num, i as u64, generation, lx.pos));
|
||||
}
|
||||
i = lx.pos;
|
||||
continue;
|
||||
@@ -3321,6 +3356,55 @@ endobj
|
||||
assert_compact_matches_materialized(&doc);
|
||||
}
|
||||
|
||||
/// A file whose only trailers are cross-reference stream dictionaries, with its `startxref`
|
||||
/// out of reach (here a second file appended after it, cut off before its own
|
||||
/// cross-reference data, as in #2365), is rebuilt from its object headers. The rebuild used
|
||||
/// to look only for the `trailer` keyword, so /Info and /ID were lost, and a full save after
|
||||
/// editing one Info field dropped every other.
|
||||
#[test]
|
||||
fn reconstruction_takes_the_trailer_from_a_cross_reference_stream() {
|
||||
let table = build(
|
||||
&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "<< /Title (Kept) /Creator (Me) >>"],
|
||||
"/Root 1 0 R /Info 3 0 R",
|
||||
);
|
||||
let packed = crate::write_full(&Document::open(Arc::new(table)).unwrap(), &crate::SaveOptions::default()).unwrap();
|
||||
let text = String::from_utf8_lossy(&packed);
|
||||
assert!(text.contains("/XRef") && !text.contains("trailer"), "a cross-reference stream is the only trailer");
|
||||
let mut bytes = packed.clone();
|
||||
let appended = format!("%PDF-1.3\n90 0 obj\n<< /Length 5000 >>\nstream\n{}\nendstream\nendobj\n", "x".repeat(5000));
|
||||
bytes.extend_from_slice(appended.as_bytes());
|
||||
let doc = Document::open(Arc::new(bytes)).unwrap();
|
||||
assert!(doc.revisions().is_empty(), "rebuilt: {:?}", doc.repair_log());
|
||||
let info = doc.trailer().reference(b"Info").expect("the cross-reference stream's /Info");
|
||||
let field = |d: &Document, key: &[u8]| d.get(info).as_dict().unwrap().get(key).unwrap().as_string().unwrap().bytes.clone();
|
||||
assert_eq!(field(&doc, b"Title"), b"Kept");
|
||||
assert!(doc.trailer().get(b"ID").is_some(), "and its /ID");
|
||||
let mut edited = doc.clone();
|
||||
edited.update_dict(info, |d| d.set(b"Subject".to_vec(), Object::String(crate::PdfString::literal("new")))).unwrap();
|
||||
let saved = crate::write_full(&edited, &crate::SaveOptions::default()).unwrap();
|
||||
let back = Document::open(Arc::new(saved)).unwrap();
|
||||
assert!(back.repair_log().is_empty(), "{:?}", back.repair_log());
|
||||
let info = back.trailer().reference(b"Info").expect("the full save keeps /Info");
|
||||
let field = |key: &[u8]| back.get(info).as_dict().unwrap().get(key).unwrap().as_string().unwrap().bytes.clone();
|
||||
assert_eq!((field(b"Title"), field(b"Creator"), field(b"Subject")), (b"Kept".to_vec(), b"Me".to_vec(), b"new".to_vec()));
|
||||
}
|
||||
|
||||
/// A rebuild prefers a trailer whose /Root is the catalog (as `root_is_catalog` judges it): a
|
||||
/// later cross-reference stream whose /Root points at the Info dictionary does not replace an
|
||||
/// earlier good trailer, or a full save would write the Info dictionary as /Root.
|
||||
#[test]
|
||||
fn reconstruction_skips_a_trailer_whose_root_is_not_the_catalog() {
|
||||
let mut bytes =
|
||||
build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "<< /Title (Kept) >>"], "/Root 1 0 R /Info 3 0 R");
|
||||
bytes.extend_from_slice(b"4 0 obj\n<< /Type /XRef /Size 5 /W [1 1 1] /Root 3 0 R /Length 3 >>\nstream\nabc\nendstream\nendobj\n");
|
||||
let appended = format!("%PDF-1.3\n90 0 obj\n<< /Length 5000 >>\nstream\n{}\nendstream\nendobj\n", "x".repeat(5000));
|
||||
bytes.extend_from_slice(appended.as_bytes());
|
||||
let doc = Document::open(Arc::new(bytes)).unwrap();
|
||||
assert!(doc.repair_log().iter().any(|l| l.contains("rebuilt")), "{:?}", doc.repair_log());
|
||||
assert_eq!(doc.trailer().reference(b"Root").map(|r| r.num), Some(1), "the catalog, not the Info dictionary");
|
||||
assert_eq!(doc.trailer().reference(b"Info").map(|r| r.num), Some(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_xref_is_reconstructed() {
|
||||
let mut bytes = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>"], "/Root 1 0 R");
|
||||
|
||||
@@ -2439,6 +2439,71 @@ trailer << /Root 1 0 R >>
|
||||
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.
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+77
-8
@@ -1627,8 +1627,38 @@ impl Reader<'_> {
|
||||
Some((number, generation))
|
||||
}
|
||||
|
||||
/// Newest backwards-scanned `trailer` whose `/Root` references a scanned object.
|
||||
/// Newest backwards-scanned `trailer` whose `/Root` references a scanned object, one whose
|
||||
/// `/Root` is the catalog first.
|
||||
fn find_latest_trailer(&self, xref: &Xref) -> Option<(usize, Dictionary)> {
|
||||
// PdfCraft patch: (#2365) an in-file /Root must also match its generation, as
|
||||
// `get_offset` requires when the root is loaded.
|
||||
let usable = |dict: &Dictionary| {
|
||||
dict.get(b"Root")
|
||||
.and_then(Object::as_reference)
|
||||
.is_ok_and(|root| match xref.entries.get(&root.0) {
|
||||
Some(XrefEntry::Normal { generation, .. }) => *generation == root.1,
|
||||
entry => entry.is_some(),
|
||||
})
|
||||
};
|
||||
// PdfCraft patch: (#2365) a candidate whose /Root is the catalog (`/Type /Catalog` or
|
||||
// `/Pages`) beats one whose /Root is another dictionary, such as /Info, wherever it is;
|
||||
// the other kind is still taken when nothing better is there (an FDF's /Root).
|
||||
let is_catalog = |dict: &Dictionary| {
|
||||
let Ok(root) = dict.get(b"Root").and_then(Object::as_reference) else {
|
||||
return false;
|
||||
};
|
||||
let Some(XrefEntry::Normal { offset, generation }) = xref.entries.get(&root.0) else {
|
||||
return false;
|
||||
};
|
||||
if *generation != root.1 {
|
||||
return false;
|
||||
}
|
||||
parser::indirect_object(self.buffer, *offset as usize, None, self, &mut HashSet::new(), None)
|
||||
.ok()
|
||||
.and_then(|(_, object)| object.as_dict().map(|d| d.has_type(b"Catalog") || d.has(b"Pages")).ok())
|
||||
.unwrap_or(false)
|
||||
};
|
||||
let mut latest: Option<(bool, usize, Dictionary)> = None;
|
||||
let mut bound = self.buffer.len();
|
||||
for _ in 0..MAX_TRAILER_CANDIDATES {
|
||||
let Some(pos) = Reader::search_substring(&self.buffer[..bound], b"trailer", 0) else {
|
||||
@@ -1643,15 +1673,54 @@ impl Reader<'_> {
|
||||
let Ok((_, dict)) = parser::dictionary(&after_keyword[dict_start..]) else {
|
||||
continue;
|
||||
};
|
||||
if dict
|
||||
.get(b"Root")
|
||||
.and_then(Object::as_reference)
|
||||
.is_ok_and(|root| xref.entries.contains_key(&root.0))
|
||||
{
|
||||
return Some((pos, dict));
|
||||
if usable(&dict) {
|
||||
let catalog = is_catalog(&dict);
|
||||
if latest.is_none() || catalog {
|
||||
latest = Some((catalog, pos, dict));
|
||||
}
|
||||
if catalog {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
// PdfCraft patch: (#2365) a file whose only trailer is a cross-reference stream has
|
||||
// no `trailer` keyword, so the scan above found nothing and the rebuild failed; its
|
||||
// /Info and /ID were then lost. Such a stream's dictionary is the trailer, and the
|
||||
// latest one in the file wins, as a later `trailer` keyword would.
|
||||
const HEADER_WINDOW: usize = 32;
|
||||
const DICT_WINDOW: usize = 4096;
|
||||
for entry in xref.entries.values() {
|
||||
let XrefEntry::Normal { offset, .. } = entry else {
|
||||
continue;
|
||||
};
|
||||
let offset = *offset as usize;
|
||||
if latest.as_ref().is_some_and(|(catalog, pos, _)| *catalog && *pos >= offset) {
|
||||
continue;
|
||||
}
|
||||
let Some(head) = self.buffer.get(offset..) else {
|
||||
continue;
|
||||
};
|
||||
let Some(dict_start) = head[..cmp::min(head.len(), HEADER_WINDOW)]
|
||||
.windows(2)
|
||||
.position(|window| window == b"<<")
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let dict_bytes = &head[dict_start..cmp::min(head.len(), dict_start + DICT_WINDOW)];
|
||||
if !dict_bytes.windows(5).any(|window| window == b"/XRef") {
|
||||
continue;
|
||||
}
|
||||
let Ok((_, dict)) = parser::dictionary(&head[dict_start..]) else {
|
||||
continue;
|
||||
};
|
||||
if dict.has_type(b"XRef") && usable(&dict) {
|
||||
let rank = (is_catalog(&dict), offset);
|
||||
if latest.as_ref().is_none_or(|(catalog, pos, _)| rank > (*catalog, *pos)) {
|
||||
latest = Some((rank.0, offset, dict));
|
||||
}
|
||||
}
|
||||
}
|
||||
latest.map(|(_, pos, dict)| (pos, dict))
|
||||
}
|
||||
|
||||
/// Some generators write `startxref` (or trailer `Prev`) values that are
|
||||
|
||||
Reference in New Issue
Block a user