Merge pull request #2936 from dotCooCoo/fix/editing-data-hash-reads-utf16-desc

PDF and .ai import: editing data with a UTF-16 description is checked against its pages
This commit is contained in:
BFlat
2026-10-19 00:09:23 +07:00
committed by GitHub
3 changed files with 81 additions and 5 deletions
@@ -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<u8> {
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();
+8 -5
View File
@@ -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<Editing> {
let files = spec.get::<Dict<'_>>(b"EF")?;
let stream = files.get::<Stream<'_>>(b"F").or_else(|| files.get::<Stream<'_>>(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::<PdfString<'_>>(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::<PdfString<'_>>(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<ObjectIden
})
}
/// A file specification naming the editing file (current or legacy name).
/// A file specification naming the editing file (current or legacy name), in any text string
/// encoding.
fn is_editing_file(spec: &Dict<'_>) -> bool {
[&b"UF"[..], b"F"].into_iter().filter_map(|k| spec.get::<PdfString<'_>>(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
})
}
+46
View File
@@ -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<u8> {
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} <FEFF{utf16}> /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}");
}
}