mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-14 13:33:35 +00:00
M1: a hybrid update without /Prev is rebuilt by the renderer as by cos (core.repair-broken-files)
The renderer (hayro-syntax (44)) counted the sections its chain reached, and a hybrid section's /XRefStm stream (ISO 32000-2 §7.5.8.4) counted as one, so a hybrid update without /Prev was rebuilt by cos but not by the renderer. It now asks what cos asks: whether the newest section names an earlier one through /Prev. The /XRefStm stream belongs to the same update. Tests: a synthetic hybrid fixture (pdfcraft-testkit), with and without /Prev, in cos and render. 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
6847b1ea7e
commit
7de85d520c
@@ -1957,3 +1957,15 @@ fn an_update_without_prev_is_rebuilt_with_its_page_tree_and_a_repair_note() {
|
||||
assert_eq!(linked.revisions().len(), 2);
|
||||
assert!(linked.repair_log().is_empty(), "{:?}", linked.repair_log());
|
||||
}
|
||||
|
||||
/// A hybrid update (ISO 32000-2 §7.5.8.4) without `/Prev`: its `/XRefStm` stream belongs to the
|
||||
/// update, so the earlier revision is cut off just the same, and the file is rebuilt.
|
||||
#[test]
|
||||
fn a_hybrid_update_without_prev_is_rebuilt_too() {
|
||||
let doc = Document::open(Arc::new(pdfcraft_testkit::hybrid_update_without_prev(false))).expect("the unlinked file opens");
|
||||
assert_eq!(page_tree_pages(&doc).len(), 3);
|
||||
assert!(doc.rebuilt_unlinked_update(), "{:?}", doc.repair_log());
|
||||
let linked = Document::open(Arc::new(pdfcraft_testkit::hybrid_update_without_prev(true))).expect("the linked file opens");
|
||||
assert_eq!(page_tree_pages(&linked).len(), 3);
|
||||
assert!(!linked.rebuilt_unlinked_update() && linked.repair_log().is_empty(), "{:?}", linked.repair_log());
|
||||
}
|
||||
|
||||
@@ -461,4 +461,20 @@ mod unlinked_update_tests {
|
||||
assert!(linked.warnings.is_empty(), "{:?}", linked.warnings);
|
||||
assert_eq!(linked.links.len(), 1);
|
||||
}
|
||||
|
||||
/// A hybrid update's `/XRefStm` stream is part of that update, not an earlier section: without
|
||||
/// `/Prev` the file is rebuilt as the document model rebuilds it, and with it, it isn't.
|
||||
#[test]
|
||||
fn a_hybrid_update_without_prev_is_rebuilt_as_the_document_model_rebuilds_it() {
|
||||
let bytes = Arc::new(pdfcraft_testkit::hybrid_update_without_prev(false));
|
||||
let info = inspect(bytes.clone(), None).expect("the unlinked hybrid file opens");
|
||||
assert_eq!(info.pages.iter().map(|p| p.width).collect::<Vec<_>>(), [200.0, 300.0, 400.0]);
|
||||
assert_eq!(info.warnings, [UNLINKED_UPDATE]);
|
||||
assert!(pdfcraft_cos::Document::open(bytes).expect("cos opens it").rebuilt_unlinked_update());
|
||||
let bytes = Arc::new(pdfcraft_testkit::hybrid_update_without_prev(true));
|
||||
let linked = inspect(bytes.clone(), None).expect("the linked hybrid file opens");
|
||||
assert_eq!(linked.pages.len(), 3);
|
||||
assert!(linked.warnings.is_empty(), "{:?}", linked.warnings);
|
||||
assert!(!pdfcraft_cos::Document::open(bytes).expect("cos opens it").rebuilt_unlinked_update());
|
||||
}
|
||||
}
|
||||
|
||||
+58
-22
@@ -140,11 +140,63 @@ pub fn stream_obj(dict: &str, data: impl AsRef<[u8]>) -> Vec<u8> {
|
||||
/// 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 (mut out, eight) = unlinked_original();
|
||||
// The update: the catalog and the first page again, and a section for them and itself.
|
||||
let (catalog, first) = unlinked_rewrites(&mut out);
|
||||
let nine = out.len();
|
||||
let mut rows = Vec::new();
|
||||
xref_row(&mut rows, 1, catalog, 0);
|
||||
xref_row(&mut rows, 1, first, 0);
|
||||
xref_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
|
||||
}
|
||||
|
||||
/// [`update_without_prev`] with the update written as a hybrid-reference section (ISO 32000-2
|
||||
/// §7.5.8.4): a classic table whose trailer names a cross-reference stream with `/XRefStm`. That
|
||||
/// stream belongs to the same update; it is not an earlier section, so without `/Prev` the page
|
||||
/// tree is cut off just the same.
|
||||
pub fn hybrid_update_without_prev(linked: bool) -> Vec<u8> {
|
||||
let (mut out, eight) = unlinked_original();
|
||||
let (catalog, first) = unlinked_rewrites(&mut out);
|
||||
// The update's cross-reference stream, locating only itself.
|
||||
let nine = out.len();
|
||||
let mut rows = Vec::new();
|
||||
xref_row(&mut rows, 1, nine, 0);
|
||||
out.extend_from_slice(format!("9 0 obj\n<< /Type /XRef /Size 11 /Index [9 1] /W [1 4 2] /Length {} >>\nstream\n", rows.len()).as_bytes());
|
||||
out.extend_from_slice(&rows);
|
||||
out.extend_from_slice(b"\nendstream\nendobj\n");
|
||||
let table = out.len();
|
||||
out.extend_from_slice(format!("xref\n1 1\n{catalog:010} 00000 n \n3 1\n{first:010} 00000 n \n").as_bytes());
|
||||
let prev = if linked { format!(" /Prev {eight}") } else { String::new() };
|
||||
out.extend_from_slice(format!("trailer\n<< /Size 11 /Root 1 0 R /XRefStm {nine}{prev} >>\nstartxref\n{table}\n%%EOF\n").as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
/// One row of a cross-reference stream with `/W [1 4 2]`.
|
||||
fn xref_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());
|
||||
}
|
||||
|
||||
/// The update both fixtures append: the catalog and the first page again. Their offsets.
|
||||
fn unlinked_rewrites(out: &mut Vec<u8>) -> (usize, usize) {
|
||||
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");
|
||||
(catalog, first)
|
||||
}
|
||||
|
||||
/// The first revision of [`update_without_prev`], and the offset of its cross-reference stream.
|
||||
fn unlinked_original() -> (Vec<u8>, usize) {
|
||||
let row = xref_row;
|
||||
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();
|
||||
@@ -190,23 +242,7 @@ pub fn update_without_prev(linked: bool) -> Vec<u8> {
|
||||
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
|
||||
(out, eight)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -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", "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)."
|
||||
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", "crates/cos/src/document/tests.rs::a_hybrid_update_without_prev_is_rebuilt_too", "crates/render/src/inspect/core.rs::a_hybrid_update_without_prev_is_rebuilt_as_the_document_model_rebuilds_it"]
|
||||
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, (a hybrid section's /XRefStm stream counts as part of the same update) 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
+12
-11
@@ -56,10 +56,14 @@ pub(crate) enum XRefError {
|
||||
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)?;
|
||||
// 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)?;
|
||||
let trailer =
|
||||
populate_xref_impl(data.as_ref(), xref_pos, &mut xref_map).ok_or(XRefError::Unknown)?;
|
||||
// PdfCraft patch: (hayro-syntax (44)) whether the newest section names an earlier one. Its
|
||||
// `/XRefStm` (a hybrid file's stream, §7.5.8.4) belongs to the same update and doesn't count,
|
||||
// as `pdfcraft-cos` doesn't count it.
|
||||
let linked = Reader::new(trailer)
|
||||
.read_with_context::<Dict<'_>>(&ReaderContext::dummy())
|
||||
.is_some_and(|newest| prev_offset(data.as_ref(), &newest, &XrefMap::default()).is_some());
|
||||
|
||||
let xref = XRef::new(
|
||||
data.clone(),
|
||||
@@ -72,10 +76,10 @@ pub(crate) fn root_xref(data: PdfData, password: &[u8]) -> Result<(XRef, bool),
|
||||
// §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
|
||||
// dictionaries `Pages::new_brute_force` turns up. Only when that section names no earlier
|
||||
// one, 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 = !linked
|
||||
&& xref
|
||||
.get::<Dict<'_>>(xref.trailer_data().pages_ref)
|
||||
.is_none()
|
||||
@@ -1012,9 +1016,6 @@ 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