mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-08 03:54:35 +00:00
feat(cos): list the objects that could not be read in doc_info
An object whose load fails reads as null (ISO 32000-2 §7.3.10) and the
file opens, but nothing said so: the repair log records only
document-level repairs, so a file with an unparseable /Info showed no
repair and no reason for its missing title.
pdfcraft-cos Document now records each object whose load fails, with
its generation and the reason, in a set shared by clones and scans like
the object cache: at most 100, keyed by number, first reason kept, cut
to 160 characters, built only for an object not yet listed. It is reset
where the caches are (authenticate, reconstruct); edited objects drop
off a document's list. Document::unreadable_objects() gives neutral
lines ("object 4 0 could not be read: ..."), the engine passes them on,
and automation doc_info adds an unreadable_objects array, kept apart
from repairs so the "has been repaired" notice and the clean-file
checks keep their meaning.
Tests: a cos unit test for listing, dedup, clones, scans and edits; a
cos test for the 100 cap and the no-readable-copy reason; an end-to-end
doc_info test on a synthetic broken file. Each fails with the fix
reverted. The core.repair-log parity entry cites them and says what is
not listed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
This commit is contained in:
1 parent
168a3cb91e
commit
85df8ee668
12 files changed
+238
-7
No files matched your search
@@ -375,6 +375,9 @@ pub(crate) fn info(d: &Document) -> Value {
|
||||
})).collect::<Vec<_>>(),
|
||||
"warnings": i.warnings,
|
||||
"repairs": d.repair_log(),
|
||||
// Objects that could not be read (each reads as null): nothing was repaired, what they held is
|
||||
// lost. Last, so it includes what reading the fields above failed on.
|
||||
"unreadable_objects": d.unreadable_objects(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -142,7 +142,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
.cmd("file.open")
|
||||
.with(schema(json!({ "path": { "type": "string" }, "password": { "type": "string", "description": "User or owner password for encrypted files." } }), &["path"])),
|
||||
t("doc_list", "List open documents", "List the open documents with their ids, page counts and unsaved state.").ro().with(schema(json!({}), &[])),
|
||||
t("doc_info", "Inspect a document", "Metadata, page sizes and labels, bookmarks, annotations, form fields, links, layers, page transitions and display times, the destination it opens at, attachments, fonts, security and repair notes.")
|
||||
t("doc_info", "Inspect a document", "Metadata, page sizes and labels, bookmarks, annotations, form fields, links, layers, page transitions and display times, the destination it opens at, attachments, fonts, security, repair notes and the objects that could not be read.")
|
||||
.ro()
|
||||
.cmd("file.properties")
|
||||
.with(schema(json!({ "doc": doc() }), &["doc"])),
|
||||
|
||||
@@ -12,3 +12,4 @@ mod optimize_content_boundaries;
|
||||
mod redact_after_stray_q_form;
|
||||
mod redact_text_after_q;
|
||||
mod render_warnings;
|
||||
mod unreadable_objects;
|
||||
@@ -0,0 +1,64 @@
|
||||
//! `doc_info` lists the objects of a file that could not be read: each reads as null, so the file
|
||||
//! opens, but what it held is lost and nothing was repaired.
|
||||
|
||||
use pdfcraft_automation::{Automation, Content};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// A one-page PDF whose document information dictionary (object 4) is `info`.
|
||||
fn with_info(info: &str) -> Vec<u8> {
|
||||
let bodies = [
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >>",
|
||||
info,
|
||||
];
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, body) in bodies.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", bodies.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R /Info 4 0 R >>\nstartxref\n{xref}\n%%EOF\n", bodies.len() + 1).as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
fn info_of(a: &mut Automation, path: &str) -> Value {
|
||||
let doc = match a.call("doc_open", &json!({ "path": path })).unwrap().remove(0) {
|
||||
Content::Json(v) => v["doc"].as_u64().unwrap(),
|
||||
other => panic!("expected JSON: {other:?}"),
|
||||
};
|
||||
match a.call("doc_info", &json!({ "doc": doc })).unwrap().remove(0) {
|
||||
Content::Json(v) => v,
|
||||
other => panic!("expected JSON: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// An unparseable `/Info` is listed with its number and why, the rest of the file reads, and
|
||||
/// nothing is reported as repaired.
|
||||
#[test]
|
||||
fn doc_info_lists_an_object_that_could_not_be_read() {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-unreadable-objects-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("broken.pdf"), with_info("<< /Title (Report) /Author ) >>")).unwrap();
|
||||
std::fs::write(dir.join("sound.pdf"), with_info("<< /Title (Report) >>")).unwrap();
|
||||
let mut a = Automation::new().with_root(&dir).unwrap();
|
||||
|
||||
let info = info_of(&mut a, "broken.pdf");
|
||||
let lines = info["unreadable_objects"].as_array().unwrap();
|
||||
assert_eq!(lines.len(), 1, "{info}");
|
||||
let line = lines[0].as_str().unwrap();
|
||||
assert!(line.starts_with("object 4 0 could not be read: syntax error"), "{line}");
|
||||
assert_eq!(info["repairs"], json!([]), "nothing was repaired");
|
||||
assert_eq!(info["pages"].as_array().unwrap().len(), 1, "the rest of the file reads");
|
||||
|
||||
let info = info_of(&mut a, "sound.pdf");
|
||||
assert_eq!(info["unreadable_objects"], json!([]), "{info}");
|
||||
assert_eq!(info["title"], "Report");
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
@@ -24,7 +24,7 @@ pub(crate) use reader::find;
|
||||
#[cfg(test)]
|
||||
use reader::rfind;
|
||||
pub(crate) use state::ObjectReader;
|
||||
use state::{ObjStm, Slot};
|
||||
use state::{ObjStm, Slot, Unreadable};
|
||||
pub use state::{ReadState, SourceIdentity};
|
||||
#[cfg(test)]
|
||||
use state::{SECTION_READS, objstm_member, owned_bytes};
|
||||
@@ -49,6 +49,9 @@ pub struct Document {
|
||||
repair_log: Arc<Vec<String>>,
|
||||
cache: Arc<Mutex<HashMap<u32, Arc<Object>>>>,
|
||||
objstms: Arc<Mutex<HashMap<u32, Arc<ObjStm>>>>,
|
||||
/// See [`Self::unreadable_objects`]. Shared by clones, like the caches: it describes the
|
||||
/// source, and is reset wherever they are.
|
||||
unreadable: Arc<Mutex<Unreadable>>,
|
||||
/// What `/JBIG2Globals` streams of the file decode to, by object number: the images of a scan
|
||||
/// share them, and each would otherwise decode them again (see `Document::jbig2_globals`).
|
||||
jbig2_globals: Arc<Mutex<HashMap<u32, Arc<Vec<u8>>>>>,
|
||||
|
||||
@@ -86,6 +86,38 @@ impl Document {
|
||||
&self.repair_log
|
||||
}
|
||||
|
||||
/// Objects of the file that could not be read, lowest number first, as lines such as
|
||||
/// "object 11 0 could not be read: syntax error at byte 512: ...". [`Self::get`] reads each
|
||||
/// as null (ISO 32000-2 §7.3.10), so a document with one opens, but what it held is lost:
|
||||
/// unlike [`Self::repair_log`], nothing was repaired. Lists the objects read so far, by this
|
||||
/// document, its clones or a scan of it, that have not since been edited; past 100, a last
|
||||
/// line says there were more. Opening again, authenticating or rebuilding the index starts
|
||||
/// the list afresh, as it does the object cache.
|
||||
pub fn unreadable_objects(&self) -> Vec<String> {
|
||||
let Ok(unreadable) = self.unreadable.lock() else { return Vec::new() };
|
||||
unreadable.lines(|num| self.overlay.contains_key(&num))
|
||||
}
|
||||
|
||||
/// Record `num` as unreadable. `reason` is built only for an object not yet listed, so a file
|
||||
/// whose broken object is asked for again and again pays for it once.
|
||||
fn note_unreadable(&self, num: u32, reason: impl FnOnce() -> String) {
|
||||
// A poisoned lock only means the object goes unlisted; the read itself still fails.
|
||||
let Ok(mut unreadable) = self.unreadable.lock() else { return };
|
||||
if unreadable.wants(num) {
|
||||
unreadable.note(num, self.generation(num), &reason());
|
||||
}
|
||||
}
|
||||
|
||||
/// Why an object listed at `offset` could not be read: what parsing it there, without resolving
|
||||
/// an indirect `/Length`, found. A best account, not the only one: the reader also tried
|
||||
/// elsewhere.
|
||||
fn why_unreadable(&self, offset: usize) -> String {
|
||||
match parse_indirect_shared(&self.data, offset, &|_| None, &self.endstreams) {
|
||||
Err(e) => e.to_string(),
|
||||
Ok(_) => "no readable copy was found at its cross-reference offset or elsewhere in the file".into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a line to [`Self::repair_log`]: a leniency applied while reading that the user should
|
||||
/// know about (fidelity: never silent).
|
||||
pub fn note_repair(&mut self, line: String) {
|
||||
@@ -395,8 +427,15 @@ impl Document {
|
||||
// cycles such as `6 0 obj << /Length 6 0 R >>`. Bound the nesting on every path.
|
||||
let _guard =
|
||||
LoadGuard::enter(num).ok_or_else(|| CosError::Syntax { offset: 0, detail: format!("reference cycle while loading object {num}") })?;
|
||||
let obj = self.load(num, 0)?;
|
||||
let obj = Arc::new(obj);
|
||||
let obj = match self.load(num, 0) {
|
||||
Ok(obj) => Arc::new(obj),
|
||||
Err(e) => {
|
||||
if !matches!(e, CosError::Poisoned) {
|
||||
self.note_unreadable(num, || e.to_string());
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
// The editing cache is bounded like the scan reader's: re-parsing a file-backed view
|
||||
// is cheap, and returned objects outlive eviction.
|
||||
self.cache.lock().map_err(|_| CosError::Poisoned)?.insert(num, obj.clone());
|
||||
@@ -554,7 +593,10 @@ impl Document {
|
||||
.or_else(|| if self.header_offset == 0 { None } else { at(off.saturating_add(self.header_offset)) })
|
||||
.or_else(|| self.located.get(&num).and_then(|(o, _)| usize::try_from(*o).ok()).and_then(at))
|
||||
.or_else(|| self.scan_for(num).map(|(id, o)| self.decrypted(id, o)))
|
||||
.ok_or(CosError::MissingObject(num))
|
||||
.ok_or_else(|| {
|
||||
self.note_unreadable(num, || self.why_unreadable(off));
|
||||
CosError::MissingObject(num)
|
||||
})
|
||||
}
|
||||
Some(XrefEntry::InStream { stream, index }) => {
|
||||
let stm = self.objstm(stream)?;
|
||||
|
||||
@@ -19,6 +19,7 @@ impl Document {
|
||||
repair_log: Arc::new(Vec::new()),
|
||||
cache: Arc::default(),
|
||||
objstms: Arc::default(),
|
||||
unreadable: Arc::default(),
|
||||
jbig2_globals: Arc::default(),
|
||||
stream_limit: crate::object::MAX_DECODED,
|
||||
overlay: BTreeMap::new(),
|
||||
@@ -103,6 +104,7 @@ impl Document {
|
||||
repair_log: Arc::new(Vec::new()),
|
||||
cache: Arc::default(),
|
||||
objstms: Arc::default(),
|
||||
unreadable: Arc::default(),
|
||||
jbig2_globals: Arc::default(),
|
||||
stream_limit: stream_limit.min(crate::object::MAX_DECODED),
|
||||
overlay: BTreeMap::new(),
|
||||
@@ -200,6 +202,7 @@ impl Document {
|
||||
// Objects read while locating the catalog were read before decryption was possible.
|
||||
self.cache = Arc::default();
|
||||
self.objstms = Arc::default();
|
||||
self.unreadable = Arc::default();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#[cfg(test)]
|
||||
use super::state::SECTION_READS;
|
||||
use super::state::Unreadable;
|
||||
use super::xref::{
|
||||
MAX_COMPACT_XREF_BYTES, MAX_COMPACT_XREF_ROWS, Revision, XrefEntry, XrefIndex, XrefRange, XrefRevisionSection, XrefSection, XrefSectionData,
|
||||
};
|
||||
@@ -633,6 +634,7 @@ impl Document {
|
||||
}
|
||||
self.entries = Arc::new(XrefIndex::Materialized(Arc::new(entries.clone())));
|
||||
self.cache.lock().map_err(|_| CosError::Poisoned)?.clear();
|
||||
*self.unreadable.lock().map_err(|_| CosError::Poisoned)? = Unreadable::default();
|
||||
// Objects inside object streams.
|
||||
for num in objstms {
|
||||
if let Ok(o) = self.try_get(num)
|
||||
@@ -650,6 +652,7 @@ impl Document {
|
||||
let object_count = entries.len();
|
||||
self.entries = Arc::new(XrefIndex::Materialized(Arc::new(entries)));
|
||||
self.cache.lock().map_err(|_| CosError::Poisoned)?.clear();
|
||||
*self.unreadable.lock().map_err(|_| CosError::Poisoned)? = Unreadable::default();
|
||||
// Trailer: the last `trailer` dictionary or cross-reference stream dictionary with a
|
||||
// usable /Root (last in the file, whichever kind), else the catalog itself. One whose
|
||||
// /Root is a catalog (as `root_is_catalog` judges it) beats one whose /Root is another
|
||||
|
||||
@@ -61,6 +61,54 @@ pub(super) fn objstm_member(num: i64, offset: i64, first: Option<usize>, len: us
|
||||
(start < len).then_some((num, start))
|
||||
}
|
||||
|
||||
/// Objects the reader could not read, each with the reason (see `Document::unreadable_objects`).
|
||||
/// Bounded: past [`Unreadable::MAX`] objects only the fact that there were more is kept.
|
||||
#[derive(Debug, Default)]
|
||||
pub(super) struct Unreadable {
|
||||
pub(super) objects: BTreeMap<u32, (u16, String)>,
|
||||
pub(super) more: bool,
|
||||
}
|
||||
|
||||
impl Unreadable {
|
||||
pub(super) const MAX: usize = 100;
|
||||
/// Characters of a reason kept: a parser's message can quote the bytes it stopped at.
|
||||
const REASON: usize = 160;
|
||||
|
||||
/// Whether `num` is to be recorded: not listed yet and there is room. A full list notes that
|
||||
/// there were more, so a caller asks this before building the reason.
|
||||
pub(super) fn wants(&mut self, num: u32) -> bool {
|
||||
if self.objects.contains_key(&num) {
|
||||
return false;
|
||||
}
|
||||
if self.objects.len() >= Self::MAX {
|
||||
self.more = true;
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Record `num` once; the first reason given is kept.
|
||||
pub(super) fn note(&mut self, num: u32, generation: u16, reason: &str) {
|
||||
if self.wants(num) {
|
||||
self.objects.insert(num, (generation, reason.chars().take(Self::REASON).collect()));
|
||||
}
|
||||
}
|
||||
|
||||
/// One line per recorded object not `hidden`, then one saying there were more, if there were.
|
||||
pub(super) fn lines(&self, hidden: impl Fn(u32) -> bool) -> Vec<String> {
|
||||
let mut lines: Vec<String> = self
|
||||
.objects
|
||||
.iter()
|
||||
.filter(|(num, _)| !hidden(**num))
|
||||
.map(|(num, (generation, reason))| format!("object {num} {generation} could not be read: {reason}"))
|
||||
.collect();
|
||||
if self.more {
|
||||
lines.push(format!("more objects could not be read; only the first {} are listed", Self::MAX));
|
||||
}
|
||||
lines
|
||||
}
|
||||
}
|
||||
|
||||
/// Where an overlay slot came from.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(super) enum Slot {
|
||||
|
||||
@@ -1701,3 +1701,61 @@ fn a_stream_whose_length_is_itself_and_never_ends_loads_quickly() {
|
||||
});
|
||||
rx.recv_timeout(std::time::Duration::from_secs(20)).expect("loading must not retry exponentially");
|
||||
}
|
||||
|
||||
/// A file whose object 3 (its `/Info`) can't be parsed: the rest reads.
|
||||
fn with_unreadable_info() -> Vec<u8> {
|
||||
build(
|
||||
&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "<< /Title (Report) /Author ) >>"],
|
||||
"/Root 1 0 R /Info 3 0 R",
|
||||
)
|
||||
}
|
||||
|
||||
/// An object that can't be parsed reads as null and is listed once, by number and generation,
|
||||
/// with why. Clones and scans share the list, an edit takes the object off it (in that document
|
||||
/// only), and a sound file lists nothing.
|
||||
#[test]
|
||||
fn unreadable_objects_are_listed_with_the_reason() {
|
||||
let doc = Document::open(Arc::new(with_unreadable_info())).unwrap();
|
||||
assert!(doc.unreadable_objects().is_empty(), "nothing has read object 3 yet");
|
||||
assert_eq!(*doc.get(ObjRef::new(3, 0)), Object::Null);
|
||||
assert!(doc.try_get(3).is_err());
|
||||
let lines = doc.unreadable_objects();
|
||||
assert_eq!(lines.len(), 1, "read twice, listed once: {lines:?}");
|
||||
assert!(lines[0].starts_with("object 3 0 could not be read: syntax error"), "{lines:?}");
|
||||
let scanned = doc.clone();
|
||||
assert_eq!(scanned.scan_objects_checked().filter(|(_, o)| o.is_err()).count(), 1);
|
||||
assert_eq!(doc.unreadable_objects(), lines, "the scan found the same object");
|
||||
let mut edited = doc.clone();
|
||||
edited.set(ObjRef::new(3, 0), Object::Dict(Dict::new()));
|
||||
assert!(edited.unreadable_objects().is_empty(), "the edit replaced it");
|
||||
assert_eq!(doc.unreadable_objects(), lines);
|
||||
let sound =
|
||||
build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "<< /Title (Report) >>"], "/Root 1 0 R /Info 3 0 R");
|
||||
let sound = Document::open(Arc::new(sound)).unwrap();
|
||||
assert_eq!(sound.scan_objects_checked().filter(|(_, o)| o.is_err()).count(), 0);
|
||||
assert!(sound.unreadable_objects().is_empty());
|
||||
}
|
||||
|
||||
/// The list keeps at most 100 objects and then says there were more; an object whose
|
||||
/// cross-reference offset holds no object is listed with that as its reason.
|
||||
#[test]
|
||||
fn the_unreadable_object_list_is_bounded() {
|
||||
let mut bodies = vec!["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>"];
|
||||
bodies.extend(std::iter::repeat_n("<< /A ) >>", 120));
|
||||
let doc = Document::open(Arc::new(build(&bodies, "/Root 1 0 R"))).unwrap();
|
||||
assert_eq!(doc.scan_objects_checked().filter(|(_, o)| o.is_err()).count(), 120);
|
||||
let lines = doc.unreadable_objects();
|
||||
assert_eq!(lines.len(), 101, "{:?}", lines.last());
|
||||
assert!(lines[0].starts_with("object 3 0 could not be read"), "{}", lines[0]);
|
||||
assert_eq!(lines[100], "more objects could not be read; only the first 100 are listed");
|
||||
|
||||
let base = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "(three)"], "/Root 1 0 R");
|
||||
let text = String::from_utf8_lossy(&base).into_owned();
|
||||
let gone = text.replacen("3 0 obj\n(three)", "9 0 obj\n(three)", 1);
|
||||
let doc = Document::open(Arc::new(gone.into_bytes())).unwrap();
|
||||
assert!(doc.try_get(3).is_err());
|
||||
assert_eq!(
|
||||
doc.unreadable_objects(),
|
||||
["object 3 0 could not be read: no readable copy was found at its cross-reference offset or elsewhere in the file"]
|
||||
);
|
||||
}
|
||||
@@ -631,6 +631,12 @@ impl Document {
|
||||
pub fn repair_log(&self) -> Vec<String> {
|
||||
self.editor.as_ref().map(|e| e.cos.repair_log().to_vec()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Objects of the file that could not be read and read as null, one line each (see
|
||||
/// [`pdfcraft_cos::Document::unreadable_objects`]): only those read so far.
|
||||
pub fn unreadable_objects(&self) -> Vec<String> {
|
||||
self.editor.as_ref().map(|e| e.cos.unreadable_objects()).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// A title that names no document: what a web browser writes when it saves a blank page or a
|
||||
|
||||
@@ -70,8 +70,8 @@ milestone = "M1"
|
||||
status = "shipped"
|
||||
commands = []
|
||||
tools = ["doc_info"]
|
||||
evidence = ["crates/ui-egui/tests/it/viewer.rs::damaged_files_say_they_were_repaired"]
|
||||
notes = "a notice says the file was repaired; Document Properties ▸ Advanced lists the repairs; doc_info reports them"
|
||||
evidence = ["crates/ui-egui/tests/it/viewer.rs::damaged_files_say_they_were_repaired", "crates/cos/src/document/tests.rs::unreadable_objects_are_listed_with_the_reason", "crates/automation/tests/it/unreadable_objects.rs::doc_info_lists_an_object_that_could_not_be_read"]
|
||||
notes = "a notice says the file was repaired; Document Properties ▸ Advanced lists the repairs; doc_info reports them, and lists apart (unreadable_objects) each object that could not be read and reads as null, with its number and why: only objects read so far, including any object a scan, save or audit read, at most 100; a stream whose own data fails to decode is not listed, but the objects of an object stream that cannot be decoded are"
|
||||
|
||||
[[feature]]
|
||||
id = "core.xref-streams"
|
||||
|
||||
Reference in new issue
Block a user