diff --git a/crates/engine/src/cmd/fileio/tests_pdfedit.rs b/crates/engine/src/cmd/fileio/tests_pdfedit.rs index ca24ea97c..35d394540 100644 --- a/crates/engine/src/cmd/fileio/tests_pdfedit.rs +++ b/crates/engine/src/cmd/fileio/tests_pdfedit.rs @@ -69,6 +69,33 @@ fn pages_changed_elsewhere_open_as_artwork_with_a_warning() { assert_eq!(r["warnings"][0], load::EDITING_DAMAGED, "{r}"); } +/// `pdf` with its editing file's description rewritten by another app in UTF-16BE with a byte +/// order mark: "hash " and the hash it held, padded with spaces to its length. +fn utf16_described(pdf: &[u8]) -> Vec { + let from = b"(VectorCraft editing data; page hash "; + let at = pdf.windows(from.len()).position(|w| w == from).unwrap_or_else(|| panic!("no description")); + let (hash, len) = (&pdf[at + from.len()..at + from.len() + 16], from.len() + 17); + let mut desc = b"(\xFE\xFF".to_vec(); + desc.extend(b"hash ".iter().chain(hash).flat_map(|&c| [0, c])); + desc.push(b')'); + desc.resize(len, b' '); + patched(pdf, &pdf[at..at + len], &desc) +} + +#[test] +fn pages_changed_elsewhere_are_noticed_with_a_utf16_description() { + let mut s = rich(); + let pdf = b64(&s.execute("document.exportPdf", &json!({"compression": {"compressText": false}})).unwrap()); + let ai = b64(&s.execute("document.export", &json!({"format": "ai", "compress": false})).unwrap()); + for (name, file) in [("edited.pdf", pdf), ("edited.ai", ai)] { + let r = open(&mut s, name, &utf16_described(&file)); + assert_eq!((&r["warnings"], &r["restored"]), (&json!([]), &json!(true)), "{name}: the same pages: {r}"); + let edited = patched(&file, b"/MediaBox[0 0 300 200]", b"/MediaBox[0 0 300 201]"); + let r = open(&mut s, name, &utf16_described(&edited)); + assert_eq!((&r["warnings"][0], &r["restored"]), (&json!(load::EDITING_STALE), &json!(false)), "{name}: {r}"); + } +} + #[test] fn the_legacy_payload_name_is_accepted() { let mut s = rich(); diff --git a/crates/pdf/src/editing.rs b/crates/pdf/src/editing.rs index c87c57239..979a2de1a 100644 --- a/crates/pdf/src/editing.rs +++ b/crates/pdf/src/editing.rs @@ -13,6 +13,7 @@ use krilla::metadata::DateTime; use crate::PdfError; use crate::import_color::items; +use crate::import_scan::text_string; use crate::lab_spot::find; /// The embedded file holding the native document (its name among the PDF's attachments). @@ -84,8 +85,9 @@ pub(crate) fn editing_in(pdf: &Pdf) -> Option { let files = spec.get::>(b"EF")?; let stream = files.get::>(b"F").or_else(|| files.get::>(b"UF"))?; let data = stream.decoded().map(|d| d.into_owned()).unwrap_or_default(); - // The hash ends the description; editing data described otherwise is trusted. - let desc = spec.get::>(b"Desc").map(|d| String::from_utf8_lossy(d.as_bytes()).into_owned()).unwrap_or_default(); + // The hash ends the description (a text string, in any of its encodings); editing data + // described otherwise is trusted. + let desc = spec.get::>(b"Desc").map(|d| text_string(d.as_bytes())).unwrap_or_default(); let intact = match desc.rsplit_once("hash ") { Some((_, hash)) => hash.trim() == page_hash(pdf), None => true, @@ -119,11 +121,12 @@ fn editing_spec<'a>(node: &Dict<'a>, depth: usize, seen: &mut HashSet) -> bool { [&b"UF"[..], b"F"].into_iter().filter_map(|k| spec.get::>(k)).any(|name| { - let name = name.as_bytes(); - name == EDITING_FILE.as_bytes() || name == LEGACY_EDITING_FILE.as_bytes() + let name = text_string(name.as_bytes()); + name == EDITING_FILE || name == LEGACY_EDITING_FILE }) } diff --git a/crates/pdf/src/tests_editing.rs b/crates/pdf/src/tests_editing.rs index 1a441fb0f..512d4594b 100644 --- a/crates/pdf/src/tests_editing.rs +++ b/crates/pdf/src/tests_editing.rs @@ -72,6 +72,35 @@ fn changed_pages_are_noticed() { assert!(editing(&r.bytes).unwrap().intact); } +/// `pdf` with its editing file's description rewritten by another app as "hash " and the hash it +/// held, in the text string encoding `bom` starts (UTF-16BE or UTF-8), padded with spaces to its +/// length. +fn described(pdf: &[u8], bom: &[u8]) -> Vec { + let from = b"(VectorCraft editing data; page hash "; + let at = pdf.windows(from.len()).position(|w| w == from).unwrap_or_else(|| panic!("no description")); + let (hash, len) = (&pdf[at + from.len()..at + from.len() + 16], from.len() + 17); + let mut desc = [b"(", bom].concat(); + for &c in b"hash ".iter().chain(hash) { + if bom.len() == 2 { + desc.push(0); + } + desc.push(c); + } + desc.push(b')'); + desc.resize(len, b' '); + patched(pdf, &pdf[at..at + len], &desc) +} + +#[test] +fn the_hash_is_read_from_a_description_in_any_encoding() { + let r = pdf_with(Some(b"native"), false); + let resized = patched(&r.bytes, b"/MediaBox[0 0 100 100]", b"/MediaBox[0 0 100 101]"); + for bom in [&[0xFE, 0xFF][..], &[0xEF, 0xBB, 0xBF]] { + assert!(editing(&described(&r.bytes, bom)).unwrap().intact, "{bom:?}: the same pages"); + assert!(!editing(&described(&resized, bom)).unwrap().intact, "{bom:?}: another page size"); + } +} + #[test] fn the_legacy_name_is_read_and_plain_pdfs_have_no_editing_data() { let r = pdf_with(Some(b"legacy"), false); @@ -153,3 +182,20 @@ fn direct_name_tree_kids_are_all_searched() { let e = editing(&raw_pdf(&objects)).unwrap(); assert_eq!(e.data, b"hello"); } + +#[test] +fn a_file_name_written_as_utf16_is_found() { + let utf16: String = EDITING_FILE.bytes().map(|c| format!("00{c:02X}")).collect(); + for key in ["UF", "F"] { + let objects = [ + "<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles 3 0 R >> >>".to_string(), + "<< /Type /Pages /Kids [6 0 R] /Count 1 >>".to_string(), + "<< /Names [(a) 4 0 R] >>".to_string(), + format!("<< /Type /Filespec /{key} /EF << /F 5 0 R >> >>"), + "<< /Length 5 >>\nstream\nhello\nendstream".to_string(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 10 10] >>".to_string(), + ]; + let e = editing(&raw_pdf(&objects)).unwrap_or_else(|| panic!("/{key}: not found")); + assert_eq!(e.data, b"hello", "/{key}"); + } +}