mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-27 19:48:14 -06:00
* M7: share Windows local-link fixture portability fixes (#1265) Use the identical test-only prerequisite already proposed in #1263: Unix-only mutable vectors, ordinary Windows drive paths and escaped PDF literal strings. This is shared CI support for the separate xref overflow fix; production link handling is unchanged. Generated with Codex. * M1: return an error for exhausted xref object numbers (#1337) Check the next-object addition and return CosError at the xref offset rather than overflowing or wrapping to object zero. Classic-table and stream regressions reproduced the original panic; ordinary, zero-offset and repaired-range controls preserve catalog access and allocation. Validation: 2732 passed, 0 failed, 13 ignored; strict all-target Clippy, fmt, 31 WASM crates, layering, assets, deny and parity passed. The rebuilt CLI opened and rendered a clean synthetic page. Generated with Codex. * M1: place overflow notes beside stable milestone entries (#1337) Keep the tested code unchanged while avoiding progress-note conflicts with newly landed main updates. Formatting and diff checks passed. Generated with Codex. * Merge main: adapt exhausted-xref repair to the compact xref index Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Brandon Thomas <echelon@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
Brandon Thomas
parent
953ac1483f
commit
2fa110e185
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Object>) -> 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.
|
||||
|
||||
@@ -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. |
|
||||
|
||||
Reference in New Issue
Block a user