address review: hash strings by their whole content, digesting each referenced string once

This commit is contained in:
Dylan M. Taylor
2026-11-03 15:06:49 +00:00
parent 7c90b3b067
commit 4ca084d53e
+45 -12
View File
@@ -103,23 +103,29 @@ fn plain_value(doc: &Document, r: ObjRef) -> Option<std::sync::Arc<Object>> {
matches!(*o, Object::Bool(_) | Object::Int(_) | Object::Real(_) | Object::Name(_) | Object::String(_)).then_some(o)
}
fn hash_obj(o: &Object, doc: &Document, map: &HashMap<ObjRef, ObjRef>, h: &mut impl Hasher) {
/// A string's bytes, digested once: a string is hashed by its whole content, so distinct strings
/// sharing a long prefix and suffix don't share a bucket (each object added to a bucket is
/// compared with all the others in it).
fn string_digest(bytes: &[u8]) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
bytes.hash(&mut h);
h.finish()
}
/// `strings` holds the digest of each string object a reference has led to, so a long string
/// named by many references is read once, not once per reference.
fn hash_obj(o: &Object, doc: &Document, map: &HashMap<ObjRef, ObjRef>, strings: &mut HashMap<ObjRef, u64>, h: &mut impl Hasher) {
match o {
Object::Null => 0u8.hash(h),
Object::Bool(b) => (1u8, b).hash(h),
Object::Int(i) => (2u8, i).hash(h),
Object::Real(f) => (3u8, f.to_bits()).hash(h),
Object::String(s) => {
// A long string hashes by its length and ends: one named by many references would
// otherwise be hashed whole once per reference. Equality still compares every byte.
let n = s.bytes.len();
(4u8, n, s.bytes.get(..256).unwrap_or(&s.bytes), s.bytes.get(n.saturating_sub(256)..).unwrap_or(&s.bytes)).hash(h)
}
Object::String(s) => (4u8, string_digest(&s.bytes)).hash(h),
Object::Name(n) => (5u8, n).hash(h),
Object::Array(a) => {
(6u8, a.len()).hash(h);
for x in a {
hash_obj(x, doc, map, h);
hash_obj(x, doc, map, strings, h);
}
}
Object::Dict(d) => {
@@ -128,20 +134,24 @@ fn hash_obj(o: &Object, doc: &Document, map: &HashMap<ObjRef, ObjRef>, h: &mut i
for (k, v) in d.iter() {
let mut eh = std::collections::hash_map::DefaultHasher::new();
k.hash(&mut eh);
hash_obj(v, doc, map, &mut eh);
hash_obj(v, doc, map, strings, &mut eh);
acc = acc.wrapping_add(eh.finish());
}
(7u8, d.len(), acc).hash(h);
}
Object::Stream(s) => {
8u8.hash(h);
hash_obj(&Object::Dict(s.dict.clone()), doc, map, h);
hash_obj(&Object::Dict(s.dict.clone()), doc, map, strings, h);
s.raw[..].hash(h);
}
Object::Ref(r) => {
let c = canon(map, *r);
match plain_value(doc, c) {
Some(v) => hash_obj(&v, doc, map, h),
Some(v) => match &*v {
// The same as the string written in place, digested once per object.
Object::String(s) => (4u8, *strings.entry(c).or_insert_with(|| string_digest(&s.bytes))).hash(h),
_ => hash_obj(&v, doc, map, strings, h),
},
None => (9u8, c.num, c.generation).hash(h),
}
}
@@ -233,6 +243,7 @@ pub fn dedupe_resources(doc: &mut Document, candidates: &[ObjRef], index_existin
all.into_iter().filter(|(r, o)| is_resource(o) || (listed.contains(r) && !matches!(**o, Object::Null))).collect();
let mut map: HashMap<ObjRef, ObjRef> = HashMap::new();
let mut strings: HashMap<ObjRef, u64> = HashMap::new();
// A fixpoint: each round can make referring objects equal. Depth is small in practice.
for _round in 0..16 {
let mut buckets: HashMap<u64, Vec<usize>> = HashMap::new();
@@ -242,7 +253,7 @@ pub fn dedupe_resources(doc: &mut Document, candidates: &[ObjRef], index_existin
continue;
}
let mut h = std::collections::hash_map::DefaultHasher::new();
hash_obj(o, doc, &map, &mut h);
hash_obj(o, doc, &map, &mut strings, &mut h);
let bucket = buckets.entry(h.finish()).or_default();
let target = bucket.iter().map(|j| &universe[*j]).find(|(_, other)| eq_obj(o, other, doc, &map)).map(|(t, _)| *t);
match target {
@@ -293,6 +304,28 @@ mod tests {
doc.add(Object::Dict(d))
}
/// Long strings hash by every byte: two that differ only in the middle land in different
/// buckets, while a string behind a reference hashes like the same string written in place.
#[test]
fn long_strings_hash_by_their_whole_content() {
let mut doc = Document::new_empty();
let map = HashMap::new();
let a = vec![b'x'; 2000];
let mut b = a.clone();
b[1000] = b'y';
let behind = doc.add(Object::String(pdfcraft_cos::PdfString::literal(a.clone())));
let mut strings = HashMap::new();
let mut hash = |o: &Object| {
let mut h = std::collections::hash_map::DefaultHasher::new();
hash_obj(o, &doc, &map, &mut strings, &mut h);
h.finish()
};
let direct = hash(&Object::String(pdfcraft_cos::PdfString::literal(a)));
assert_ne!(direct, hash(&Object::String(pdfcraft_cos::PdfString::literal(b))));
assert_eq!(direct, hash(&Object::Ref(behind)));
assert_eq!(direct, hash(&Object::Ref(behind)), "the cached digest is the same");
}
#[test]
fn merges_bottom_up_and_keeps_identity_objects() {
let mut doc = Document::new_empty();