diff --git a/crates/cos/src/document/reader.rs b/crates/cos/src/document/reader.rs
--- a/crates/cos/src/document/reader.rs
+++ b/crates/cos/src/document/reader.rs
@@ -619,10 +619,28 @@ impl Document {
         // 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) {
+        // Objects whose bytes (the last object of a number wins, as in `entries`) could hold an
+        // `/ObjStm` type; the others are not parsed for the object-stream check below.
+        let mut candidates: HashSet<u32> = HashSet::new();
+        let headers = object_headers(&data);
+        // Where `/XRef` occurs in the file, found in one pass.
+        let mut xref_marks = Vec::new();
+        let mut mark_from = 0;
+        while let Some(p) = find(&data, b"/XRef", mark_from, data.len()) {
+            xref_marks.push(p);
+            mark_from = p + 1;
+        }
+        for &(num, offset, generation, body) in &headers {
             entries.insert(num, XrefEntry::InFile { offset, generation });
             objstms.push(num);
-            if find(&data, b"/XRef", body, body.saturating_add(1024)).is_some()
+            if may_be_object_stream(&data, body) {
+                candidates.insert(num);
+            } else {
+                candidates.remove(&num);
+            }
+            // `/XRef` within the 1024 bytes after the header (the first one that is there).
+            let first = xref_marks.partition_point(|&p| p < body);
+            if xref_marks.get(first).is_some_and(|&p| p + 5 <= body.saturating_add(1024).min(data.len()))
                 && let Ok(at) = usize::try_from(offset)
             {
                 xref_streams.push(at);
@@ -635,7 +653,8 @@ impl Document {
         self.cache.lock().map_err(|_| CosError::Poisoned)?.clear();
         // Objects inside object streams.
         for num in objstms {
-            if let Ok(o) = self.try_get(num)
+            if candidates.contains(&num)
+                && let Ok(o) = self.try_get(num)
                 && let Object::Stream(s) = &*o
                 && s.dict.name(b"Type") == Some(b"ObjStm")
                 && let Ok(stm) = self.objstm(num)
@@ -732,6 +751,109 @@ fn shifted_subsection_end(data: &[u8], at: usize) -> Option<u32> {
     }
 }
 
+/// Longest dictionary [`may_be_object_stream`] reads before it gives up and says "maybe".
+const DICTIONARY_SCAN_LIMIT: usize = 1 << 20;
+
+/// Where the dictionary that starts at `data[from..]` (after `N G obj`) ends, reading it as the
+/// parser does: strings, hex strings, comments, names and nested dictionaries. `Some(None)`: the
+/// object is not a dictionary, so it is never a stream. `None`: the end is not certain (no end
+/// within the limit, a hex string with a bad digit, a `>>` inside an array); the caller must
+/// then treat the object as a possible object stream.
+fn dictionary_end(data: &[u8], from: usize) -> Option<Option<usize>> {
+    let limit = data.len().min(from.saturating_add(DICTIONARY_SCAN_LIMIT));
+    let mut i = from;
+    while i < limit && (is_whitespace(data[i]) || data[i] == b'%') {
+        if data[i] == b'%' {
+            while i < limit && !matches!(data[i], b'\n' | b'\r') {
+                i += 1;
+            }
+        } else {
+            i += 1;
+        }
+    }
+    if data.get(i..i + 2) != Some(b"<<") {
+        return if i < limit { Some(None) } else { None };
+    }
+    i += 2;
+    let (mut dicts, mut arrays) = (1usize, 0usize);
+    while i < limit {
+        match data[i] {
+            b'(' => {
+                let mut depth = 1usize;
+                i += 1;
+                while i < limit && depth > 0 {
+                    match data[i] {
+                        b'\\' => i += 1,
+                        b'(' => depth += 1,
+                        b')' => depth -= 1,
+                        _ => {}
+                    }
+                    i += 1;
+                }
+                if depth > 0 {
+                    return None;
+                }
+            }
+            b'<' if data.get(i + 1) == Some(&b'<') => {
+                dicts += 1;
+                i += 2;
+            }
+            b'<' => {
+                i += 1;
+                while i < limit && data[i] != b'>' {
+                    if !data[i].is_ascii_hexdigit() && !is_whitespace(data[i]) {
+                        return None;
+                    }
+                    i += 1;
+                }
+                i += 1;
+            }
+            b'>' if data.get(i + 1) == Some(&b'>') => {
+                if arrays > 0 {
+                    return None;
+                }
+                dicts -= 1;
+                i += 2;
+                if dicts == 0 {
+                    return Some(Some(i));
+                }
+            }
+            b'%' => {
+                while i < limit && !matches!(data[i], b'\n' | b'\r') {
+                    i += 1;
+                }
+            }
+            b'[' => {
+                arrays += 1;
+                i += 1;
+            }
+            b']' => {
+                arrays = arrays.saturating_sub(1);
+                i += 1;
+            }
+            _ => i += 1,
+        }
+    }
+    None
+}
+
+/// Whether the object whose body starts at `data[from..]` could be an object stream: its
+/// dictionary holds `ObjStm`, or a `#` (a name may hide its letters as `#xx`, as in
+/// `/Obj#53tm`). Only the dictionary is searched, not the data of a stream. Where the
+/// dictionary ends is read by [`dictionary_end`], so text inside a string (an object header, a
+/// `stream` keyword) is not taken for syntax; when that is not certain the object counts as a
+/// candidate, so a doubtful object is parsed as it always was.
+pub(super) fn may_be_object_stream(data: &[u8], from: usize) -> bool {
+    match dictionary_end(data, from) {
+        Some(None) => false,
+        None => true,
+        Some(Some(end)) => {
+            let dict = &data[from..end];
+            dict.contains(&b'#') || find(dict, b"ObjStm", 0, dict.len()).is_some()
+        }
+    }
+}
+
 /// 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)> {
@@ -808,7 +930,33 @@ pub(crate) fn find(hay: &[u8], needle: &[u8], from: usize, to: usize) -> Option<
     if from >= to || needle.is_empty() || to - from < needle.len() {
         return None;
     }
-    hay[from..to].windows(needle.len()).position(|w| w == needle).map(|p| p + from)
+    let hay = &hay[from..to];
+    // Look for the needle's rarest byte first, a plain byte scan, and compare the rest only there.
+    const COMMON: &[u8] = b" etaoinsrhldcumfpgwybvkxjqz";
+    let (k, rare) =
+        needle.iter().copied().enumerate().max_by_key(|&(_, b)| COMMON.iter().position(|&c| c == b).unwrap_or(COMMON.len())).unwrap_or((0, 0));
+    let mut i = k;
+    while let Some(p) = hay.get(i..).and_then(|rest| position_of(rest, rare)) {
+        let start = i + p - k;
+        if hay.get(start..start + needle.len()) == Some(needle) {
+            return Some(start + from);
+        }
+        i += p + 1;
+    }
+    None
+}
+
+/// The first position of `byte` in `hay`. Whole 32-byte blocks are tested without branching, which
+/// the compiler turns into vector compares; only a block that holds the byte is searched.
+fn position_of(hay: &[u8], byte: u8) -> Option<usize> {
+    const BLOCK: usize = 32;
+    let (blocks, rest) = hay.as_chunks::<BLOCK>();
+    for (i, block) in blocks.iter().enumerate() {
+        if block.iter().fold(false, |any, &b| any | (b == byte)) {
+            return block.iter().position(|&b| b == byte).map(|p| i * BLOCK + p);
+        }
+    }
+    rest.iter().position(|&b| b == byte).map(|p| blocks.len() * BLOCK + p)
 }
 
 pub(super) fn rfind(hay: &[u8], needle: &[u8], from: usize) -> Option<usize> {
diff --git a/crates/cos/src/document/tests.rs b/crates/cos/src/document/tests.rs
--- a/crates/cos/src/document/tests.rs
+++ b/crates/cos/src/document/tests.rs
@@ -1,3 +1,4 @@
+use super::reader::may_be_object_stream;
 use super::*;
 
 #[test]
@@ -1187,6 +1188,81 @@ fn a_prev_reference_to_an_object_holding_the_offset_reads_that_offset() {
 /// 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.
+/// Whether parsing `body` as an object gives a stream whose /Type is /ObjStm.
+fn parses_as_object_stream(file: &[u8]) -> bool {
+    matches!(parse_indirect(file, 0, &|_| None), Ok((_, Object::Stream(s))) if s.dict.name(b"Type") == Some(b"ObjStm"))
+}
+
+#[test]
+fn the_object_stream_pre_check_never_rejects_what_parses_as_one() {
+    // Two inputs from review: header-shaped text, and `>>` + `stream` + newline, inside a
+    // string that comes before the /Type.
+    for dict in [
+        "<< /Note (9 0 obj\n) /Type /ObjStm /N 0 /First 0 >>",
+        "<< /Note (>>stream\n) /Type /ObjStm /N 0 /First 0 >>",
+        "<< /Note (a (nested\n9 0 obj\nstream) \\) )) /Type /ObjStm >>",
+        "<< /Note <3e3e> /Other << /A (>>\nstream\n) >> /Type /ObjStm >>",
+    ] {
+        let file = format!("1 0 obj\n{dict}\nstream\nxx\nendstream\nendobj\n").into_bytes();
+        assert!(parses_as_object_stream(&file), "test input must be an object stream: {dict}");
+        assert!(may_be_object_stream(&file, 7), "{dict}");
+    }
+    // Ordinary streams stay out; their data is never read.
+    let plain = b"1 0 obj\n<< /Length 9 >>\nstream\nObjStm # \nendstream\nendobj\n";
+    assert!(!may_be_object_stream(plain, 7));
+    assert!(!may_be_object_stream(b"1 0 obj\n[ /ObjStm ]\nendobj\n", 7));
+    // Unterminated: not certain, so a candidate.
+    assert!(may_be_object_stream(b"1 0 obj\n<< /Note (open ", 7));
+    // Random dictionaries built from tricky pieces: whatever parses as an object stream is
+    // a candidate.
+    let pieces: [&str; 22] = [
+        "/Type /ObjStm",
+        "/Type/ObjStm",
+        "/Type /Obj#53tm",
+        "/T#79pe /ObjStm",
+        "(",
+        ")",
+        "\\",
+        "(>>stream\n)",
+        "(9 0 obj\n)",
+        "<3e3e>",
+        "<",
+        ">",
+        ">>",
+        "<<",
+        "[",
+        "]",
+        "% c\n",
+        "% >>\n",
+        "/N 0",
+        " ",
+        "\n",
+        "<zz>",
+    ];
+    let mut seed = 0x2545_F491_4F6C_DD1Du64;
+    let mut next = move || {
+        seed ^= seed << 13;
+        seed ^= seed >> 7;
+        seed ^= seed << 17;
+        seed
+    };
+    let mut hits = 0;
+    for _ in 0..200_000 {
+        let mut dict = String::from("<<");
+        for _ in 0..(next() % 9) {
+            dict.push_str(pieces[(next() % pieces.len() as u64) as usize]);
+            dict.push(' ');
+        }
+        dict.push_str(if next() % 8 == 0 { "" } else { ">>" });
+        let file = format!("1 0 obj\n{dict}\nstream\nxx\nendstream\nendobj\n").into_bytes();
+        if parses_as_object_stream(&file) {
+            hits += 1;
+            assert!(may_be_object_stream(&file, 7), "rejected an object stream: {dict:?}");
+        }
+    }
+    assert!(hits > 1000, "the generator must produce object streams ({hits})");
+}
+
 #[test]
 fn reconstruction_takes_the_trailer_from_a_cross_reference_stream() {
     let table = build(
diff --git a/crates/cos/tests/it/main.rs b/crates/cos/tests/it/main.rs
--- a/crates/cos/tests/it/main.rs
+++ b/crates/cos/tests/it/main.rs
@@ -4,5 +4,6 @@ mod bounded_writer;
 mod corpus;
 mod encryption;
 mod proptests;
+mod reconstruct;
 mod shared_streams;
 mod writer;
diff --git a/crates/cos/tests/it/reconstruct.rs b/crates/cos/tests/it/reconstruct.rs
new file mode 100644
--- /dev/null
+++ b/crates/cos/tests/it/reconstruct.rs
@@ -0,0 +1,62 @@
+//! Rebuilding the cross-reference index of a damaged file finds the objects inside object
+//! streams however the stream's type is written.
+
+use std::sync::Arc;
+
+use pdfcraft_cos::{Document, Object};
+
+/// A file whose `startxref` points nowhere, with three object streams (type written plainly, as
+/// `/Obj#53tm`, and after a string that contains the word `stream`), and one ordinary stream
+/// whose data mentions `ObjStm` and `#`.
+fn damaged() -> Vec<u8> {
+    let mut out = b"%PDF-1.5\n".to_vec();
+    let mut add = |n: u32, body: &[u8]| {
+        out.extend_from_slice(format!("{n} 0 obj\n").as_bytes());
+        out.extend_from_slice(body);
+        out.extend_from_slice(b"\nendobj\n");
+    };
+    let objstm = |dict_prefix: &str, members: &[(u32, &str)]| {
+        let mut header = String::new();
+        let mut body = String::new();
+        for (n, text) in members {
+            header.push_str(&format!("{n} {} ", body.len()));
+            body.push_str(text);
+            body.push('\n');
+        }
+        let mut b =
+            format!("<< {dict_prefix} /N {} /First {} /Length {} >>\nstream\n", members.len(), header.len(), header.len() + body.len()).into_bytes();
+        b.extend_from_slice(header.as_bytes());
+        b.extend_from_slice(body.as_bytes());
+        b.extend_from_slice(b"\nendstream");
+        b
+    };
+    add(1, b"<< /Type /Catalog /Pages 2 0 R >>");
+    add(2, b"<< /Type /Pages /Kids [] /Count 0 >>");
+    add(3, &objstm("/Type /ObjStm", &[(10, "<< /From 3 >>"), (11, "[3]")]));
+    add(4, &objstm("/Type /Obj#53tm", &[(12, "<< /From 4 >>")]));
+    add(5, &objstm("/Note (a stream) /Type /ObjStm", &[(13, "<< /From 5 >>")]));
+    let data = b"/Type /ObjStm # 14 0 << /From 6 >>";
+    let mut plain = format!("<< /Length {} >>\nstream\n", data.len()).into_bytes();
+    plain.extend_from_slice(data);
+    plain.extend_from_slice(b"\nendstream");
+    add(6, &plain);
+    // Strings before the type that look like an object header, or like the end of the
+    // dictionary and the `stream` keyword, and a hex string and a comment.
+    add(7, &objstm("/Note (\n99 0 obj\n) /Type /ObjStm", &[(15, "<< /From 7 >>")]));
+    add(8, &objstm("/Note (>>stream\n) /Type /ObjStm", &[(16, "<< /From 8 >>")]));
+    add(9, &objstm("/Note <3e3e> /Other << /A (a (b) \\) >>\nstream\n) >> % >>\nstream\n\n/Type /ObjStm", &[(17, "<< /From 9 >>")]));
+    out.extend_from_slice(b"trailer\n<< /Size 20 /Root 1 0 R >>\nstartxref\n99999999\n%%EOF\n");
+    out
+}
+
+#[test]
+fn objects_in_object_streams_are_found_however_the_type_is_written() {
+    let doc = Document::open(Arc::new(damaged())).expect("opens");
+    assert!(doc.reconstructed());
+    for n in [10, 11, 12, 13, 15, 16, 17] {
+        let o = doc.try_get(n).unwrap_or_else(|e| panic!("object {n}: {e}"));
+        assert!(matches!(&*o, Object::Dict(_) | Object::Array(_)), "object {n} is {o:?}");
+    }
+    // The data of an ordinary stream is not an object stream, whatever it says.
+    assert!(!doc.object_numbers().contains(&14));
+}
