diff --git a/ROADMAP.md b/ROADMAP.md index 68f1ca841..e111c25ec 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -221,6 +221,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-12 (M12, XFA scripts turned off):** When a runaway script turns an XFA form's scripts off, the app now says so: a toast as it happens (at open or after a click), and the notice bar from then on, ahead of its other notices, with a JavaScript Console button. Before, the error was only in the console, so calculations and buttons just stopped. A notice bar the user had closed comes back for it; closed again, it stays closed. The engine reports the state (`Document::xfa_scripts_off`, and `xfa_scripts_off` in `doc_info`) instead of the UI reading error text. Two kittest UI tests (a runaway at open; a runaway click after the bar was closed), engine assertions and a `doc_info` tool test. Overall unchanged. - **2026-10-12 (M14, Windows low-DPI font-hinting candidate):** The Windows policy disables symmetric rendering for embedded JetBrains Mono only. On the locked-font macOS probe, horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150% scaling, and is down 0.79% at 200%; Inter is pixel-identical. Logical layout hashes and 8 MiB atlas size match, repeated fractional offsets add no atlas dimensions, and baseline/candidate time and RSS ranges overlap. Windows runtime and hardware remain unverified; estimates unchanged. - **2026-10-12 (M7, #1200 candidate):** Bounded local GoToR file specifications open in desktop tabs; explicit numeric page views apply, while named destinations open at the default view with a warning. The automation tool resolves relative to the source and uses existing `--root` confinement. Appearance-only Link Properties edits preserve the existing action. URL/network paths remain refused; `/NewWindow` is a tab hint. Estimates unchanged. +- **2026-10-12 (M1, #1337):** COS opening repairs exhausted cross-reference entries and records the skipped row; allocation safely falls back to unused numbers for exhausted /Size hints and high edits. Classic-table and stream regressions reproduce the former panic; ordinary, zero-offset and repaired-range controls preserve opening and allocation. Estimates unchanged. - **2026-10-12 (M7, private image crops):** Exclusive image crops resize with their pixels across nested states, intervening operators and content streams; shared clips remain fixed. Synthetic engine/tool regressions and private image save/reopen checks; estimates unchanged. - **2026-10-12 (M12, accessibility finding locations):** Checker findings for figures, annotations and form widgets carry verified page bounds. Selecting one flashes its location; the headless check returns the same PDF-space rectangle. Malformed annotation rectangles remain page-only findings. Guided review and tagging estimates unchanged. diff --git a/crates/cos/README.md b/crates/cos/README.md index b3ac964d5..ab382219d 100644 --- a/crates/cos/README.md +++ b/crates/cos/README.md @@ -53,7 +53,7 @@ another. Edits never touch the original bytes. headers. Repairs are *recorded*, never silent, so the UI can say the file was damaged. - **Tolerant, never fatal.** The lexer works on a byte slice and a position; it allocates nothing for skipped data, bounds-checks every index, and caps nesting depth. Malformed input yields - `CosError`, never a panic — this crate is the first thing a hostile file meets. + `CosError`, never a panic — this crate is the first thing a hostile file meets. Exhausted xref entries are skipped with a repair log; exhausted /Size hints allocate unused numbers without overflowing or replacing existing objects. - **Lazy.** Objects are parsed on first use and cached. (Whole-file buffering is still the case; `ByteSource` streaming is `core.lazy-loading`, not yet done.) - **Encryption** is handled on load and save through `pdfcraft-crypt`: every standard-security diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index 5cd68b918..46b59abfc 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -257,6 +257,15 @@ impl XrefIndex { } } + /// Drop an entry for object number 4294967295, which no incremental `/Size` can cover. + /// Only a materialized index can hold one: the compact reader requires `max_num < /Size`. + fn remove_exhausted(&mut self) -> bool { + match self { + Self::Compact { .. } => false, + Self::Materialized(entries) => Arc::make_mut(entries).remove(&u32::MAX).is_some(), + } + } + fn max_num(&self) -> u32 { match self { Self::Compact { max_num, .. } => *max_num, @@ -593,9 +602,15 @@ impl Document { { doc.authenticate(&enc, &credentials)?; } + if Arc::make_mut(&mut doc.entries).remove_exhausted() { + Arc::make_mut(&mut doc.repair_log).push("skipped exhausted xref object number 4294967295".into()); + } let max = doc.entries.max_num(); - let size = doc.trailer.int(b"Size").unwrap_or(0).max(0) as u32; - doc.next_num = max.max(size.saturating_sub(1)) + 1; + let size = u32::try_from(doc.trailer.int(b"Size").unwrap_or(0).max(0)).unwrap_or(u32::MAX); + if size == u32::MAX { + Arc::make_mut(&mut doc.repair_log).push("exhausted trailer /Size; new objects use unused object numbers".into()); + } + doc.next_num = max.saturating_add(1).max(size).max(1); Ok(doc) } @@ -1155,19 +1170,29 @@ impl Document { let obj = self.adopt(obj.into()); self.overlay.insert(r.num, Slot::Set(r.generation, Arc::new(obj))); if r.num >= self.next_num { - self.next_num = r.num + 1; + self.next_num = r.num.saturating_add(1); } } /// Add a new indirect object and return its reference. pub fn add(&mut self, obj: impl Into) -> ObjRef { - let r = ObjRef::new(self.next_num, 0); - self.next_num += 1; + let r = ObjRef::new(self.allocate_num(), 0); let obj = self.adopt(obj.into()); self.overlay.insert(r.num, Slot::Set(0, Arc::new(obj))); r } + /// Find a never-used number, retaining the high-water mark for incremental /Size. + /// Exhausted hints fall back to gaps without replacing source or overlay objects. + fn allocate_num(&mut self) -> u32 { + let mut num = if self.next_num == u32::MAX { 1 } else { self.next_num.max(1) }; + while self.entries.lookup(num, &self.data).is_some() || self.overlay.contains_key(&num) { + num = if num == u32::MAX - 1 { 1 } else { num + 1 }; + } + self.next_num = self.next_num.max(num.saturating_add(1)); + num + } + /// Delete an object (its number becomes free with the next generation). pub fn free(&mut self, r: ObjRef) { self.overlay.insert(r.num, Slot::Freed(r.generation.saturating_add(1))); @@ -2063,6 +2088,75 @@ mod tests { assert!(doc.xref_entry_is_exact(1) && doc.xref_entry_is_exact(2)); } + #[test] + fn opening_an_exhausted_classic_xref_repairs_and_allocates() { + // The extra in-use row need not be loaded to exhaust the next object number. + let bytes = b"%PDF-1.7\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n2 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\nxref\n0 3\n0000000000 65535 f \n0000000009 00000 n \n0000000058 00000 n \n4294967295 1\n0000000058 00000 n \ntrailer\n<< /Size 3 /Root 1 0 R >>\nstartxref\n110\n%%EOF\n"; + assert_eq!(bytes.len(), 266); + let mut doc = Document::open(Arc::new(bytes.to_vec())).unwrap(); + assert_eq!(doc.root(), Some(ObjRef::new(1, 0))); + assert!(doc.repair_log().iter().any(|line| line.contains("exhausted xref"))); + assert_eq!(doc.add(Object::Null), ObjRef::new(3, 0)); + } + + #[test] + fn opening_an_exhausted_xref_stream_repairs_and_allocates() { + let mut bytes = + b"%PDF-1.7\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n2 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\n".to_vec(); + let offset = bytes.len(); + bytes.extend_from_slice(b"3 0 obj\n<< /Type /XRef /Size 4 /Root 1 0 R /W [1 4 2] /Index [0 3 4294967295 1] /Length 28 >>\nstream\n"); + for (kind, position, generation) in [(0_u8, 0_u32, 65535_u16), (1, 9, 0), (1, 58, 0), (1, 58, 0)] { + bytes.push(kind); + bytes.extend_from_slice(&position.to_be_bytes()); + bytes.extend_from_slice(&generation.to_be_bytes()); + } + bytes.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{offset}\n%%EOF\n").as_bytes()); + let mut doc = Document::open(Arc::new(bytes)).unwrap(); + assert_eq!(doc.root(), Some(ObjRef::new(1, 0))); + assert!(doc.repair_log().iter().any(|line| line.contains("exhausted xref"))); + assert_eq!(doc.add(Object::Null), ObjRef::new(4, 0)); + } + + #[test] + fn exhausted_size_and_high_edits_allocate_without_overwriting_objects() { + let ordinary = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>"], "/Root 1 0 R"); + for size in ["4294967295", "4294967296", "9223372036854775807"] { + let bytes = String::from_utf8(ordinary.clone()).unwrap().replace("/Size 3", &format!("/Size {size}")); + let mut doc = Document::open(Arc::new(bytes.into_bytes())).unwrap(); + assert!(doc.repair_log().iter().any(|line| line.contains("exhausted trailer"))); + assert_eq!(doc.add(Object::Int(42)), ObjRef::new(3, 0)); + doc.set(ObjRef::new(u32::MAX, 0), Object::Int(99)); + doc.set(ObjRef::new(u32::MAX - 1, 0), Object::Int(98)); + assert_eq!(doc.add(Object::Int(43)), ObjRef::new(4, 0)); + assert_eq!(doc.get(ObjRef::new(3, 0)).as_int(), Some(42)); + assert_eq!(doc.get(ObjRef::new(u32::MAX, 0)).as_int(), Some(99)); + assert_eq!(doc.get(ObjRef::new(1, 0)).as_dict().unwrap().name(b"Type"), Some(b"Catalog".as_slice())); + } + let mut doc = Document::new_empty(); + doc.set(ObjRef::new(u32::MAX - 2, 0), Object::Null); + assert_eq!(doc.add(Object::Null), ObjRef::new(u32::MAX - 1, 0)); + assert_eq!(doc.add(Object::Null), ObjRef::new(3, 0)); + assert_eq!(doc.add(Object::Null), ObjRef::new(4, 0)); + } + + #[test] + fn exhausted_xref_controls_preserve_ordinary_ignored_and_repaired_entries() { + let ordinary = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>"], "/Root 1 0 R"); + for (extra, repaired) in + [("", false), ("4294967295 1\n0000000000 00000 n \n", false), ("4294967295 2\n0000000058 00000 n \n0000000058 00000 n \n", true)] + { + let bytes = String::from_utf8(ordinary.clone()).unwrap().replacen("trailer\n", &format!("{extra}trailer\n"), 1); + let mut doc = Document::open(Arc::new(bytes.into_bytes())).unwrap(); + assert_eq!(doc.root(), Some(ObjRef::new(1, 0))); + assert_eq!(doc.get(ObjRef::new(1, 0)).as_dict().unwrap().name(b"Type"), Some(b"Catalog".as_slice())); + assert_eq!(!doc.repair_log().is_empty(), repaired); + if repaired { + assert!(doc.repair_log().iter().any(|line| line.contains("xref") && line.contains("range"))); + } + assert_eq!(doc.add(Object::Null), ObjRef::new(3, 0)); + } + } + #[test] fn invalid_xref_object_ranges_are_reconstructed() { // Original in-memory PDF: an intact catalog followed by a damaged xref section. diff --git a/docs/roadmap.md b/docs/roadmap.md index 70935194a..bce6f8e1c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -31,6 +31,7 @@ > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (M6: keep accepted form values when Tab reopens the same field before Save; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (M13: reject malformed CLI --root options before dispatch; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Traditional Chinese accessibility semantics and Taiwan form/table terminology; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** patch (M1 #1337: exhausted xref hints are repaired; allocation avoids overflow; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) Forward-looking: the milestones, the current focus and what comes next. The parity numbers behind it are in [target-app-parity.md](target-app-parity.md), the ranked work list in [gaps.md](gaps.md), @@ -197,6 +198,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | 2026-10-12 | minor | M3: case-insensitive Find matches normal, final and capital Greek sigma; exact search, word boundaries, glyph ranges and match limits have regression coverage. The tab-menu test uses Finder on macOS. Estimates unchanged. | | 2026-10-12 | minor | M14: Windows low-DPI JetBrains Mono hinting candidate; raster and layout evidence recorded without a Windows runtime claim. Estimates unchanged. | | 2026-10-12 | minor | M7/#1200 candidate: bounded local GoToR opening, root-confined automation, and action-preserving appearance edits; named destinations and window behavior remain partial; estimates unchanged. | +| 2026-10-12 | patch | M1 #1337: exhausted xref entries are repaired; allocation safely handles exhausted /Size and high edits; classic-table/stream regressions and opening/allocation controls. Estimates unchanged. | | 2026-10-12 | minor | Exclusive image crops resize with their pixels across nested states, intervening operators and content streams; shared clips remain fixed. Synthetic engine/tool regressions and private image save/reopen checks; estimates unchanged. | | 2026-10-11 | minor | M4: editable Organize page-range selection (#1092), synchronized with thumbnails and available through UI control. Estimates unchanged. | | 2026-10-11 | minor | M6 #1208: expose dropdown and list-box alignment; preserve existing values and cover cancel, undo/redo, save/reopen. Estimates unchanged. |