diff --git a/ROADMAP.md b/ROADMAP.md index a4e2648bc..98936d099 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -190,6 +190,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-13 (M6, choice indices):** Multi-select choice fields write ascending `/I` option indices while preserving the caller's `/V` order. Synthetic forms and headless engine regressions cover reverse selection and save/reopen. Estimates unchanged. - **2026-10-12 (M10, Windows printing without PowerShell, #1107):** craft-print now prints on Windows in-process: printers from EnumPrinters/GetDefaultPrinter, jobs drawn as vectors by Windows.Data.Pdf into the XPS print API with a driver-validated PrintTicket (exact paper, orientation, no scaling), so printing works where App Control for Business or AppLocker run PowerShell in Constrained Language Mode. Portrait and landscape sheets still print as one job (turned with /Rotate); a sheet size the driver lacks is refused rather than shrunk. Pins craft-libs f03ec27 (craft-libs#28); deny.toml allows the storytold/winprint.rs fork and adds a webpki-root-certs licence exception and a RUSTSEC-2024-0370 ignore for winprint's build-dependencies. An earlier craft-print revision was checked in Windows Sandbox under enforced WDAC: printers listed, CLI and desktop jobs spooled, and no powershell.exe in the 4688 audit. The final pinned revision passed craft-libs Windows CI, including a real paused-queue job; that Sandbox run has not been repeated. Estimates unchanged. +- **2026-10-12 (M1, #1338):** A full rewrite used to build a new trailer from `/Root`, `/Info`, `/Encrypt` and `/ID`, so accepted unknown entries such as a custom string or `/Version` disappeared, including when an incremental save was forced to rewrite. Those entries are copied, and objects reachable only from them are written and renumbered with the rest of the file. Ordinary incremental saves are unchanged. Estimates unchanged. - **2026-10-12 (M4, #1310, #1311, #1312):** Undo back to the opened file left it marked unsaved, so recovery and close treated unchanged work as dirty. After a save, Revert followed the restored snapshot's older base and the next save dropped the latest saved revision. The last saved file is remembered on its own: undo and redo are clean only when they land on it, Revert opens it, and a later save appends to it. Estimates unchanged. - **2026-10-12 (M13, CLI robustness scans):** `pdfcraft-cli check` visits a directory once even when input roots overlap or directory symlinks lead back into the tree. PDFs reached through file symlinks remain accepted. Three real CLI regressions cover overlapping roots, a directory cycle and directory/file aliases. Estimates unchanged. - **2026-10-12 (M4, Type 3 extraction fidelity):** Extract and Split preserve shared image/resource dictionaries reached through Type 3 glyphs, including nested forms, patterns and soft masks. A bounded object-graph walk protects unknown glyph content; unreadable pages/forms retain their glyph resources, and shared explicit fonts are visited once. Separate page dictionaries still prune unused images. Synthetic save/reopen regressions and an opt-in Poppler pixel oracle cover resource ownership, nested forms, cycles, limits and repeated fonts. Estimates unchanged. diff --git a/crates/cos/src/writer.rs b/crates/cos/src/writer.rs index 34254bf37..f8d8fd3d8 100644 --- a/crates/cos/src/writer.rs +++ b/crates/cos/src/writer.rs @@ -4,7 +4,8 @@ //! as the file's last one (table or stream) and a trailer with `/Prev`. The original bytes are //! never modified, which keeps digital signatures over earlier revisions valid. //! * `write_full` writes only objects reachable from the trailer, renumbered densely, with a -//! cross-reference table or stream — the "Save As" / garbage-collecting path. +//! cross-reference table or stream — the "Save As" / garbage-collecting path. Unknown trailer +//! entries are copied, including objects reachable only from them. use std::borrow::Cow; use std::collections::{BTreeMap, HashMap, VecDeque}; @@ -257,6 +258,13 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result, CosErro queue.push_back(r); } } + // Unknown trailer entries are document data. Objects they alone reference must be written + // too, or a full rewrite would keep the key and drop its value. + for (key, value) in trailer_in.iter() { + if !trailer_key_rewritten(key) { + collect_refs(value, &mut |r| queue.push_back(r)); + } + } while let Some(r) = queue.pop_front() { if map.contains_key(&r) { continue; @@ -337,6 +345,11 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result, CosErro if let Some(id) = trailer_in.get(b"ID") { trailer.set(b"ID".to_vec(), id.clone()); } + for (key, value) in trailer_in.iter() { + if !trailer_key_rewritten(key) { + trailer.set(key.clone(), renumber(value, &map)); + } + } if reader.document.output_security().0.is_none() { ensure_id(&mut trailer, opts, reader.document.bytes()); } @@ -400,6 +413,15 @@ impl ObjectStreamBatch { } } +/// Trailer keys a full rewrite generates itself. Everything else is copied, with references +/// renumbered, so a save does not drop metadata the reader accepted. +fn trailer_key_rewritten(key: &[u8]) -> bool { + matches!( + key, + b"Size" | b"Prev" | b"XRefStm" | b"Type" | b"W" | b"Index" | b"Filter" | b"DecodeParms" | b"Length" | b"Root" | b"Info" | b"Encrypt" | b"ID" + ) +} + fn output_number(index: usize) -> Result { u32::try_from(index).ok().and_then(|n| n.checked_add(1)).ok_or_else(|| CosError::Syntax { offset: 0, detail: "too many objects to save".into() }) } diff --git a/crates/cos/tests/writer.rs b/crates/cos/tests/writer.rs index a7f071e8e..ec18b13f3 100644 --- a/crates/cos/tests/writer.rs +++ b/crates/cos/tests/writer.rs @@ -327,3 +327,68 @@ fn page_tree_loops_in_object_streams_do_not_crash_readers() { assert!(String::from_utf8_lossy(&packed).contains("/ObjStm")); assert_eq!(hayro_pages(&packed), 1); } + +/// Catalog, empty page tree, and trailer entries a reader must not invent: a direct name, a +/// direct string, and a string object reachable only from the trailer. +fn trailer_fixture() -> Vec { + let mut out = b"%PDF-1.4\n".to_vec(); + let mut offsets = Vec::new(); + for (i, body) in ["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "(only from the trailer)"].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(b"xref\n0 4\n0000000000 65535 f \n"); + for offset in offsets { + out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice( + format!("trailer\n<< /Size 4 /Root 1 0 R /Version /1.7 /Custom (keep me) /Note 3 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes(), + ); + out +} + +fn trailer_custom(doc: &Document) -> Option> { + doc.trailer().get(b"Custom").and_then(Object::as_string).map(|s| s.bytes.clone()) +} + +fn trailer_note(doc: &Document) -> Option> { + doc.trailer().get(b"Note").map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(|s| s.bytes.clone())) +} + +#[test] +fn full_save_preserves_unknown_trailer_data() { + let input = trailer_fixture(); + let doc = Document::open(Arc::new(input)).unwrap(); + assert_eq!(trailer_custom(&doc), Some(b"keep me".to_vec())); + assert_eq!(trailer_note(&doc).as_deref(), Some(b"only from the trailer".as_slice())); + assert_eq!(doc.trailer().get(b"Version").and_then(Object::as_name), Some(&b"1.7"[..])); + assert!(doc.repair_log().is_empty()); + assert!(!doc.full_save_required()); + + let inc = Document::open(Arc::new(write_incremental(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert_eq!(trailer_custom(&inc), trailer_custom(&doc)); + assert_eq!(trailer_note(&inc), trailer_note(&doc)); + assert_eq!(inc.trailer().get(b"Version"), doc.trailer().get(b"Version")); + assert!(inc.repair_log().is_empty()); + + for object_streams in [true, false] { + let opts = SaveOptions { object_streams, ..SaveOptions::default() }; + let bytes = write_full(&doc, &opts).unwrap(); + let back = Document::open(Arc::new(bytes)).unwrap(); + assert!(back.root().is_some()); + assert!(back.repair_log().is_empty(), "{:?}", back.repair_log()); + assert_eq!(trailer_custom(&back), trailer_custom(&doc)); + assert_eq!(trailer_note(&back), trailer_note(&doc)); + assert_eq!(back.trailer().get(b"Version"), doc.trailer().get(b"Version")); + } + + let mut required = doc.clone(); + required.require_full_save(); + let back = Document::open(Arc::new(write_incremental(&required, &SaveOptions::default()).unwrap())).unwrap(); + assert!(back.root().is_some()); + assert!(back.repair_log().is_empty(), "{:?}", back.repair_log()); + assert_eq!(trailer_custom(&back), trailer_custom(&doc)); + assert_eq!(trailer_note(&back), trailer_note(&doc)); + assert_eq!(back.trailer().get(b"Version"), doc.trailer().get(b"Version")); +} diff --git a/docs/roadmap.md b/docs/roadmap.md index 39c495db9..3acd0ca8b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -95,7 +95,7 @@ total matches the parity estimate. | M | Milestone | Plan size (h) | Done | Remaining (h, 2026-10-10) | Notes | |---|---|---|---|---|---| | M0 | Skeleton: workspace, xtask gates, CI | 15–30 | 98% | 1–2 | GitHub workflow, `deny.toml` (licence audit of every dependency), parity checklist (826 features, `xtask parity`) done. Missing: remaining crate stubs, testkit/oracle crates | -| M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 80% | 20–40 | Done:
- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;
- cos: parse and repair (including invalid xref object-number ranges), decoded-stream limits including unfiltered data, decrypt on load, re-encrypt on save, incremental and full writing.
Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.
Full saves now pack objects into compressed object streams. An unchanged incremental save of a classic table writes a zero-length cross-reference subsection instead of a section with no subsections (#1339). Fuzzing runs nightly (`xtask fuzz`). Missing: ≥ 250 tests, own image codecs | +| M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 80% | 20–40 | Done:
- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;
- cos: parse and repair (including invalid xref object-number ranges), decoded-stream limits including unfiltered data, decrypt on load, re-encrypt on save, incremental and full writing.
Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.
Full saves now pack objects into compressed object streams. An unchanged incremental save of a classic table writes a zero-length cross-reference subsection instead of a section with no subsections (#1339). A full rewrite keeps unknown trailer entries, including objects reachable only from them (#1338). Fuzzing runs nightly (`xtask fuzz`). Missing: ≥ 250 tests, own image codecs | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. Page marks, added content and print output retain small page-unit transform coefficients when serialized. | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. The renderer keeps its one-point crop-size floor in physical points after UserUnit scaling, with regression coverage for crop origins, rotations and f32 reciprocal rounding. Existing unit policy and nearly-zero fallback are retained. | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings | @@ -177,6 +177,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | M1 #1338: a full rewrite keeps unknown trailer entries and objects reachable only from them. Estimates unchanged. | | 2026-10-14 | minor | M14: Italian catalog completed (2,528 entries) and dropped from the ≈ 2,000–2,140-entry group; estimates unchanged | | 2026-10-12 | minor | M4 #1310 #1311 #1312: undo back to the opened file is clean, Revert returns to the last saved file, and the next save keeps that revision. Estimates unchanged. | | 2026-10-14 | minor | M5: reconciled PR #1457 with upstream main, preserving comment submission shortcuts and upstream roadmap progress. Estimates unchanged. |