diff --git a/Cargo.lock b/Cargo.lock index b43147daa..a95014924 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4010,6 +4010,7 @@ dependencies = [ "pdfcraft-render", "pdfcraft-xfa", "png 0.18.1", + "rustix 1.1.5", "serde_json", "sha2 0.11.0", "tempfile", @@ -4027,6 +4028,7 @@ dependencies = [ "pdfcraft-platform", "pdfcraft-preflight", "pdfcraft-render", + "png 0.18.1", "rustix 1.1.5", "serde_json", "winresource", diff --git a/ROADMAP.md b/ROADMAP.md index 48331cd61..8d5a34a80 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -13,6 +13,8 @@ > **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M2, #3601: missing spacing glyphs in non-embedded Japanese CID substitutes draw as blanks while PDF widths and Unicode stay unchanged; estimates unchanged) > **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M12 #3673: Character encoding accepts Identity-H/V with Adobe CJK collections; estimates unchanged) +> **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M11 #3507: image merging preserves content stream boundaries; estimates unchanged) +> **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (M8, gap 6 #2662: the redaction audit counts a cover over one word of a long line; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M5, PR #1971: bounded nested FDF export excludes source page/page-tree dictionaries; refreshed against upstream 4db6ac4a; estimates unchanged) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M7: the Edit text caret is as tall as the row it is on; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: CSV tab-containing headers; estimates unchanged) @@ -59,6 +61,7 @@ > **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M12 #2572: Every repeated page-master button resolves its own field to the shared XFA click script, including print buttons. Estimates unchanged) > **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M12 #2570: Dynamic XFA rich-text fields carry exData/maxLength into AcroForm MaxLen for reported and enforced Unicode character limits, retained after save/reopen. Estimates unchanged) > **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M4 #3309: bounded iterative named-destination pruning on page deletion; estimates unchanged) +> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M1 #2658: macOS atomic saves retain destination extended attributes, including Finder tags, and reject partial resource-fork reads after size changes. Metadata failures retain the original PDF and unsaved edit. Estimates unchanged) PdfCraft's one-page summary: how close it is to Adobe Acrobat Pro's offline feature set, and what comes next. Adobe's cloud services (Document Cloud, Acrobat Sign requests, shared review, the AI @@ -197,6 +200,12 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-21 (M3, #3808):** Page thumbnails in the Inspector can be resized independently from the document and Organize, with a slider, buttons and `thumbnail-zoom` control option. Selection, page following and reload are covered by regressions. Estimates unchanged. - **2026-10-22 (M2, #3107 / PR #3438):** Inline-image parsing falls back to the existing EI heuristic when missing declared samples, headers or terminators exceed the content-window budget, instead of waiting for an impossible allocation. Exact-length parsing for available samples and ordinary large streamed images is retained. Estimates unchanged. - **2026-10-21 (M14, Brazilian Portuguese):** Completed the `pt-br` catalog against the current 2,674-key union, including signature validation and accessibility messages; no build or compilation run. Localization remains partial because help and unpredictable engine/OS error details are not translated. +- **2026-10-19 (M11, #2178):** Optimize checks image and soft-mask dimensions and byte counts before decoding; hostile-dimension regressions pass. Estimates unchanged. + +- **2026-10-20 (M11, #3507):** Image merging keeps trailing comments, split operands and graphics state across content streams. Synthetic tool tests check saved text, rendered regions and exact image rows. + +- **2026-10-17 (M8, gap 6, #2662):** A filled cover over a long line counts when it hides half of any one word, not only 40% of the line's words or box, so a black box over one number in a long line — or an unapplied redaction mark over it — no longer audits clean while the number is still extractable; a sliver under half of every word it touches and under 40% of the line is still not flagged +- **2026-10-13 (M10, #1757):** Office export keeps each dotted-leader contents entry in one paragraph, with its page number, instead of inferring table cells from its separate text objects. - **2026-10-20 (M6, calculation order page):** Prepare a form ▸ Calculation order, a page of its own, lists the calculated fields in the order they calculate with their formulas and values, moved with Up/Down arrows (as Acrobat's Set Field Calculation Order), Alt+↑/↓ or by dragging. A card says what is wrong with the order and holds Fix order; a loop has a card of its own that opens one of its formulas. On the page, each calculated field shows its step, and arrows show what the field in focus reads and what reads it. Estimates unchanged. - **2026-10-20 (M6, calculation order):** The order a form's fields calculate in (`/CO`) is read, checked, fixed and set headlessly with `form_calculation_order`: it says which fields read one calculated later (they show stale values), which read each other in a loop, and which the PDF's order leaves out; Fix order moves only what must move, in one undo step, and the form recalculates. Editing a formula no longer moves its field to the end of the order, and a field the order lists twice calculates once. Estimates unchanged. - **2026-10-20 (M2, #3574):** Forms without Resources use page Properties for ActualText and layer visibility. Synthetic tests cover boundaries, nested/shared forms, and save/reopen. Estimates unchanged. @@ -238,6 +247,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-18 (M12, #2570):** Dynamic XFA rich-text fields carry exData/maxLength into AcroForm MaxLen for reported and enforced Unicode character limits, retained after save/reopen. Estimates unchanged. - **2026-10-19 (M4, #3309 review):** Page deletion keeps working with 60,000 named destinations, without a name-count traversal limit. Synthetic regressions verify every surviving target after deletion and restoration of the deleted page and its destinations by undo/redo, full/incremental save/reopen and unchanged rendering. Estimates unchanged. - **2026-10-18 (M4, #3309):** Deleting a page prunes named destinations without recursive calls. Cyclic and shared indirect nodes are visited once. Synthetic deep/cyclic graph, save/reopen and automation regressions cover the change. Estimates unchanged. +- **2026-10-18 (M1, #2658):** macOS atomic saves retain destination extended attributes, including Finder tags, and reject partial resource-fork reads after size changes. Metadata failures retain the original PDF and unsaved edit. Estimates unchanged. - **2026-10-15 (M7, Edit text caret):** In a text region that mixes sizes (a 24 pt title over 12 pt lines), the inline editor's caret on a small row was as tall as the region's first size and reached into the row below: egui draws the caret at least one row of the text field's font tall, and that font had the paragraph's first size. The field's font is now half the region's smallest size, so each row's own height decides. Kittest regression; checked in the app on a real document. Estimates unchanged. - **2026-10-16 (M3, #2781):** The Pages panel follows the current page, as Acrobat's page thumbnails do: going to a page whose thumbnail is out of sight (scrolling, Page Down, Go to page, a bookmark) scrolls the panel just enough to show it. A thumbnail already in view, or clicked, leaves the panel where it is, and a page change while the user scrolls the panel or drags thumbnails does not move it. Estimates unchanged. - **2026-10-16 (M12, #1645):** CLI info, text and render use the engine's dynamic XFA layout rather than placeholder pages. Synthetic two-page and encrypted-form regressions cover counts, text, pixels, copy permissions and unchanged input bytes. Estimates unchanged. @@ -280,6 +290,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-15 (M14, gap 24):** `misc.localization` and `misc.installers` move from planned to partial. Localization: 22 catalogs ship, but Chinese shows tofu in releases, Telugu has no release font and Arabic is not mirrored. Installers: macOS and Windows releases are signed, Linux packages are not. The Acrobat menu diff itself remains open. Estimates unchanged. - **2026-10-15 (M14, PNG export):** Export ▸ Image as PNG, `pdfcraft-cli render` and the `page_render` automation/MCP tool write an opaque page as RGB at deflate level 3 instead of RGBA at the default level 6: the files stay the same size and are written 3–5× faster (a 300-dpi scan drawn at 220 dpi: 805 → 167 ms; a text page at 220 dpi: 70 → 40 ms; an annual-report page at 150 dpi: 179 → 64 ms). A page with translucent pixels is still written as RGBA. The automation tools use the engine's encoder instead of a copy of it. Estimates unchanged. - **2026-10-15 (G14, Romanian localization):** Added Romanian (`ro`, Română) as an interface and date language with a 2,673-entry catalog covering every bundled key, and a CLDR plural rule (`plural_ro`: one / few / other, "20 de pagini"). Locale tags `ro` / `ro-RO` / `ro_RO.UTF-8` / `ro-MD` resolve, so Auto picks it on Romanian Windows. Coverage and plural tests added. Status `partial` like the other catalogs (no translated help). +- **2026-10-22 (M14, JPEG and TIFF export):** Export ▸ Image as JPEG or TIFF refuses a raster that is not exactly width × height pixels, as PNG export has since #2425. A raster a pixel short panicked inside the JPEG and TIFF encoders, and a partial pixel at the end was dropped and the rest written as a valid image. Rendered pages are always the right size, so what exports is unchanged. Estimates unchanged. - **2026-10-13 (G14, Vietnamese localization):** Added Vietnamese (`vi`, Tiếng Việt) as an interface and date language with a 2,591-entry catalog (`plural_none`) covering 100% of the current localizable key union (plus `theme`/`Appearance`). Locale tags `vi` / `vi-VN` / `vi_VN.UTF-8` resolve; calendar months use numeric `Tháng 1`…`12` / `tháng 1`…`12`. Native-speaker community review recorded. The twelve-language localization mean moves from ≈59% to ≈68%; weighted readiness ≈49% → ≈50%. Status stays `partial`: engine/OS details, document contents, filenames, CLI/MCP and external help stay outside the catalog by design. - **2026-10-13 (M3, typed zoom):** The right rail's zoom percentage is an editable field: type 150, 150% or 62.5 % and press Enter, Escape discards, and nonsense keeps the current zoom. The control channel's zoom option shares its parser, which now refuses zero, negative and non-finite values. @@ -624,6 +635,7 @@ Newest first: the date, what moved, and the new overall estimate. | 2026-10-20 | minor | M4: initial-view window and interface options resolve indirect boolean ViewerPreferences values; synthetic true/false and save/reopen regressions retain defaults for malformed entries. Estimates unchanged. | | 2026-10-20 | minor | M6, #3502: form detection preserves comments across content streams; estimates unchanged. | | 2026-10-20 | minor | M10, #3532 partial: vector printing retains existing appearances with absent Subtype; estimates unchanged. | +| 2026-10-20 | minor | M11 #3507: image merging preserves comments and state across content streams. Estimates unchanged. | | 2026-10-18 | minor | M5, PR #1971: nested source `/Page` and `/Pages` dictionaries export as null without traversing page content or the page tree. Synthetic saved/reopened three-page regression retains unrelated metadata and aliases. FDF import semantics, copied-graph limits and estimates unchanged. | | 2026-10-15 | minor | M5: budget retained FDF entries before cloning; discovery keeps lazy array owners and annotation positions, with alias/cache-eviction stress fixtures. Limits cover the copied graph; source parsing/page discovery retain existing COS/parser limits, with no total source-memory bound. FDF import semantics and estimates unchanged. | | 2026-10-15 | minor | M7: the Edit text caret is as tall as the row it is on in a region with several sizes; estimates unchanged. | diff --git a/apps/pdfcraft-cli/Cargo.toml b/apps/pdfcraft-cli/Cargo.toml index bab8c28a8..991cbf98f 100644 --- a/apps/pdfcraft-cli/Cargo.toml +++ b/apps/pdfcraft-cli/Cargo.toml @@ -23,6 +23,10 @@ pdfcraft-automation = { workspace = true } pdfcraft-platform = { workspace = true } base64 = "0.22" +[dev-dependencies] +# Decodes `render` output in tests/owner_password.rs; already a dependency of the engine. +png = "0.18" + [target.'cfg(unix)'.dependencies] # `geteuid` for `ui`'s control-file owner check: safe, so no `unsafe` libc call here. # Already in Cargo.lock through the GUI crates. diff --git a/apps/pdfcraft-cli/src/main.rs b/apps/pdfcraft-cli/src/main.rs index 5888e5111..14500a384 100644 --- a/apps/pdfcraft-cli/src/main.rs +++ b/apps/pdfcraft-cli/src/main.rs @@ -176,7 +176,12 @@ fn audit(args: &[String]) -> Result<(), CliError> { let findings = pdfcraft_audit::audit_redactions(&doc).map_err(|e| format!("{path}: {e}"))?; // Rectangles in points from the top-left of the displayed page, as `doc_audit_redactions` // reports them; the page geometry is read only when there is something to place. - let info = if findings.is_empty() { None } else { Some(inspect(bytes, password).map_err(|e| format!("{path}: {e}"))?) }; + let info = if findings.is_empty() { + None + } else { + let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password); + Some(inspect(bytes, render_password.as_deref()).map_err(|e| format!("{path}: {e}"))?) + }; let items: Vec = findings .into_iter() .map(|f| { @@ -299,7 +304,9 @@ fn info(args: &[String]) -> Result<(), CliError> { let path = *positional(args).first().ok_or("info: missing file")?; let bytes = read(path)?; let password = flag(args, "--password"); - let mut info = inspect(bytes.clone(), password).map_err(|e| e.to_string())?; + // The renderer takes only the user password of R2–R4 files; `Session::open` takes either. + let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password); + let mut info = inspect(bytes.clone(), render_password.as_deref()).map_err(|e| e.to_string())?; let pdf_pages = info.pages.len(); let acroform_fields = info.fields.len(); // Dynamic XFA forms: say what laying the template out gives (what the app shows). @@ -366,6 +373,9 @@ fn text(args: &[String]) -> Result<(), CliError> { } let password = flag(args, "--password"); let mut bytes = read(path)?; + // The renderer takes only the user password of R2–R4 files; `Session::open` takes either. + // Worked out from the file as read: `bytes` may become the session's display copy below. + let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password); // Printing the text is copying it, so ask what `text_extract` asks (#1645): without the // permissions password, a document that doesn't allow copying gives no text. let mut session = pdfcraft_engine::Session::new(); @@ -378,12 +388,12 @@ fn text(args: &[String]) -> Result<(), CliError> { bytes = doc.display.clone(); } } - let mut r = PageRenderer::new(bytes.clone(), RenderConfig { password: password.map(Arc::from), ..Default::default() }); + let mut r = PageRenderer::new(bytes.clone(), RenderConfig { password: render_password.as_deref().map(Arc::from), ..Default::default() }); // A document the renderer could not open (wrong or missing password, unparseable file) has // no pages, and "extract every page" of nothing would print nothing and exit 0 (#132). Ask // `inspect` why instead, so a protected file fails the same way `info` does. if r.page_count() == 0 { - let info = inspect(bytes.clone(), password).map_err(|e| format!("text: {e}"))?; + let info = inspect(bytes.clone(), render_password.as_deref()).map_err(|e| format!("text: {e}"))?; if !info.pages.is_empty() { return Err("text: the document could not be parsed".into()); } @@ -478,17 +488,31 @@ fn edit(args: &[String]) -> Result<(), CliError> { "--password" => password = Some(value), "--rotate" => { let (pages, deg) = value.split_once(':').ok_or("--rotate PAGES:DEGREES")?; - edits.push(Edit::RotatePages { pages: page_list(pages)?, degrees: deg.parse().map_err(|_| "bad degrees")? }); + let degrees: i64 = deg.parse().map_err(|_| format!("bad --rotate degrees: expected a whole number of degrees, got {deg:?}"))?; + // The same rule `rotate_pages` enforces, checked here so a bad angle is named + // with its option before any file is opened. + if degrees % 90 != 0 { + return Err(format!("bad --rotate degrees: expected a multiple of 90 (such as 90, -90 or 180), got {degrees}").into()); + } + edits.push(Edit::RotatePages { pages: page_list(pages)?, degrees }); } "--delete" => edits.push(Edit::DeletePages { pages: page_list(value)? }), "--move" => { let (pages, to) = value.split_once(':').ok_or("--move PAGES:TO")?; - let to: usize = to.parse().map_err(|_| "bad target")?; - edits.push(Edit::MovePages { pages: page_list(pages)?, to: to.checked_sub(1).ok_or("--move target must be at least 1")? }); + let target: usize = to + .parse() + .ok() + .filter(|target| *target > 0) + .ok_or_else(|| format!("bad --move target: expected a page number of at least 1, got {to:?}"))?; + edits.push(Edit::MovePages { pages: page_list(pages)?, to: target - 1 }); } "--insert-blank" => { - let at: usize = value.parse().map_err(|_| "bad position")?; - edits.push(Edit::InsertBlankPage { at: at.checked_sub(1).ok_or("--insert-blank must be at least 1")?, width: 612.0, height: 792.0 }); + let position: usize = value + .parse() + .ok() + .filter(|position| *position > 0) + .ok_or_else(|| format!("bad --insert-blank position: expected a page position of at least 1, got {value:?}"))?; + edits.push(Edit::InsertBlankPage { at: position - 1, width: 612.0, height: 792.0 }); } "--title" => edits.push(Edit::SetInfo { key: "Title".into(), value: value.into() }), "--author" => edits.push(Edit::SetInfo { key: "Author".into(), value: value.into() }), @@ -570,7 +594,16 @@ fn split(args: &[String]) -> Result<(), CliError> { use pdfcraft_engine::SplitBy; let path = *positional(args).first().ok_or("split: missing file")?; let by = match (flag(args, "--every"), flag(args, "--before")) { - (Some(n), None) => SplitBy::PageCount(n.parse().map_err(|_| "bad --every")?), + (Some(n), None) => { + // A count the splitter cannot hold is refused here, before the file is opened, + // rather than silently clamped to 1 further down. + let count = n + .parse::() + .ok() + .filter(|count| *count > 0) + .ok_or_else(|| format!("bad --every: expected a positive whole number of pages, got {n:?}"))?; + SplitBy::PageCount(count) + } (None, Some(list)) => SplitBy::Before(page_list(list)?), _ => return Err("split: give either --every N or --before PAGES".into()), }; @@ -590,12 +623,25 @@ fn split(args: &[String]) -> Result<(), CliError> { Ok(()) } +/// A `--dpi` the renderer can be asked for: a positive, finite number. `0`, a negative value +/// or `NaN` parse as an `f32` but reach the renderer as a scale it refuses with its own +/// internal message (and in `check`, passed on as text, they would turn every file into a +/// child that printed nothing), so the CLI names the option and the value instead. +fn parse_dpi(value: &str) -> Result { + value + .parse::() + .ok() + .filter(|dpi| dpi.is_finite() && *dpi > 0.0) + .ok_or_else(|| format!("bad --dpi: expected a positive number of dots per inch, got {value:?}")) +} + fn render(args: &[String]) -> Result<(), CliError> { validate_options("render", args, RENDER_OPTIONS)?; let path = *positional(args).first().ok_or("render: missing file")?; - let page: usize = flag(args, "--page").unwrap_or("1").parse().map_err(|_| "bad --page")?; - let page_index = page.checked_sub(1).ok_or("--page must be at least 1")?; - let dpi: f32 = flag(args, "--dpi").unwrap_or("96").parse().map_err(|_| "bad --dpi")?; + let page: usize = + flag(args, "--page").unwrap_or("1").parse().ok().filter(|page| *page > 0).ok_or("bad --page: expected a positive page number")?; + let page_index = page - 1; + let dpi = parse_dpi(flag(args, "--dpi").unwrap_or("96"))?; let out = flag(args, "--out").ok_or("render: missing --out (.png, .jpg, .tif or .pam)")?; // The file is what its name says (#248): a `.png` used to get a netpbm PAM stream. let format = match Path::new(out).extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).as_deref() { @@ -609,14 +655,18 @@ fn render(args: &[String]) -> Result<(), CliError> { // must agree with it (and with independent viewers), while keeping the file it reads intact. let bytes = read(path)?; let password = flag(args, "--password"); - let display = if inspect(bytes.clone(), password).is_ok_and(|info| info.xfa == Some(pdfcraft_render::Xfa::Dynamic)) { + // The renderer takes only the user password of R2–R4 files; `Session::open` and the display + // copy take either. + let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password); + let display = if inspect(bytes.clone(), render_password.as_deref()).is_ok_and(|info| info.xfa == Some(pdfcraft_render::Xfa::Dynamic)) { let mut session = pdfcraft_engine::Session::new(); let id = session.open(path, None, bytes, password).map_err(|e| e.to_string())?; session.get(id).ok_or("render: the document could not be opened")?.display.clone() } else { pdfcraft_engine::display_bytes_for(bytes, password) }; - let mut r = PageRenderer::new(display, RenderConfig { password: password.map(Arc::from), reject_oversize: true, ..Default::default() }); + let mut r = + PageRenderer::new(display, RenderConfig { password: render_password.as_deref().map(Arc::from), reject_oversize: true, ..Default::default() }); let p = r.render(RenderRequest { page: page_index, kind: RequestKind::Pixels, tile: None, scale: dpi / 72.0, tag: 0, preview: false }); if let Some(e) = p.error { return Err(e.into()); @@ -647,7 +697,7 @@ fn render(args: &[String]) -> Result<(), CliError> { fn check_one(args: &[String]) -> Result<(), CliError> { validate_options("check-one", args, CHECK_ONE_OPTIONS)?; let path = *positional(args).first().ok_or("check-one: missing file")?; - let dpi: f32 = flag(args, "--dpi").unwrap_or("36").parse().map_err(|_| "bad --dpi")?; + let dpi = parse_dpi(flag(args, "--dpi").unwrap_or("36"))?; let start = Instant::now(); let bytes = read(path)?; let (status, pages, failed, detail, warnings) = match inspect(bytes.clone(), None) { @@ -739,12 +789,23 @@ fn collect(paths: &[&str]) -> Vec { fn check(args: &[String]) -> Result<(), CliError> { validate_options("check", args, CHECK_OPTIONS)?; + // Every option is checked before the inputs are scanned, so a bad value is one message, + // not one problem per file: a zero timeout used to make every file time out, and a bad + // dpi used to reach each child, which printed nothing and was reported as a crash. + let timeout_value = flag(args, "--timeout").unwrap_or("20"); + let timeout = Duration::from_secs( + timeout_value + .parse::() + .ok() + .filter(|secs| *secs > 0) + .ok_or_else(|| format!("bad --timeout: expected a positive whole number of seconds, got {timeout_value:?}"))?, + ); + let dpi = flag(args, "--dpi").unwrap_or("36").to_string(); + parse_dpi(&dpi)?; let files = collect(&positional(args)); if files.is_empty() { return Err("check: no PDF files found".into()); } - let timeout = Duration::from_secs(flag(args, "--timeout").unwrap_or("20").parse().map_err(|_| "bad --timeout")?); - let dpi = flag(args, "--dpi").unwrap_or("36").to_string(); let exe = std::env::current_exe().map_err(|e| e.to_string())?; let mut results = Vec::new(); let mut counts: std::collections::BTreeMap = Default::default(); diff --git a/apps/pdfcraft-cli/tests/check.rs b/apps/pdfcraft-cli/tests/check.rs index a206df591..7a6236419 100644 --- a/apps/pdfcraft-cli/tests/check.rs +++ b/apps/pdfcraft-cli/tests/check.rs @@ -1,4 +1,5 @@ //! The robustness harness visits each directory once, including symlink aliases and cycles. +//! Its `--timeout` and `--dpi` options are checked before anything is scanned. use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; @@ -109,3 +110,22 @@ fn check_follows_directory_and_file_symlinks() { assert_eq!(results[0]["status"], "ok"); assert_eq!(results[0]["file"], file_alias.to_string_lossy().as_ref()); } + +/// `--timeout` and `--dpi` are checked before any input is scanned: a zero timeout used to +/// make every file time out at once, and a dpi that is not a positive finite number used to +/// reach each child, which printed nothing and was reported as a crash per file. +#[test] +fn a_bad_timeout_or_dpi_is_refused_before_scanning() { + let out = Command::new(BIN).args(["check", "no-such-input", "--timeout", "0"]).output().unwrap(); + assert_eq!(out.status.code(), Some(1)); + assert_eq!(out.stderr, b"pdfcraft-cli: bad --timeout: expected a positive whole number of seconds, got \"0\"\n"); + for value in ["abc", "0", "-3", "NaN"] { + let out = Command::new(BIN).args(["check", "no-such-input", "--dpi", value]).output().unwrap(); + assert_eq!(out.status.code(), Some(1), "--dpi {value}"); + assert_eq!( + out.stderr, + format!("pdfcraft-cli: bad --dpi: expected a positive number of dots per inch, got {value:?}\n").as_bytes(), + "--dpi {value}" + ); + } +} diff --git a/apps/pdfcraft-cli/tests/edit.rs b/apps/pdfcraft-cli/tests/edit.rs index b35b71ddd..3cd1442d8 100644 --- a/apps/pdfcraft-cli/tests/edit.rs +++ b/apps/pdfcraft-cli/tests/edit.rs @@ -141,9 +141,9 @@ mod one_based_arguments { for destination in ["output.pam", "new.pam", "input.pdf"] { std::fs::write(dir.0.join("output.pam"), b"keep destination").unwrap(); for (command, option, value, diagnostic) in [ - ("render", "--page", "0", "--page must be at least 1"), - ("edit", "--move", "1:0", "--move target must be at least 1"), - ("edit", "--insert-blank", "0", "--insert-blank must be at least 1"), + ("render", "--page", "0", "bad --page: expected a positive page number"), + ("edit", "--move", "1:0", "bad --move target: expected a page number of at least 1, got \"0\""), + ("edit", "--insert-blank", "0", "bad --insert-blank position: expected a page position of at least 1, got \"0\""), ] { let out = Command::new(BIN).current_dir(&dir.0).args([command, input, "--out", destination, option, value]).output().unwrap(); assert_eq!(out.status.code(), Some(1), "{command} {input} {option} {value}"); @@ -194,6 +194,33 @@ mod one_based_arguments { } } +/// A value the option cannot hold names the option and the value, and fails before the input +/// file is opened: a missing input still gets the option's own message. +#[test] +fn a_value_an_option_cannot_hold_is_named_before_the_file_is_opened() { + let dir = tmp("values", ""); + for (option, value, diagnostic) in [ + ("--rotate", "1:abc", "bad --rotate degrees: expected a whole number of degrees, got \"abc\""), + ("--rotate", "1:45", "bad --rotate degrees: expected a multiple of 90 (such as 90, -90 or 180), got 45"), + ("--rotate", "1:-45", "bad --rotate degrees: expected a multiple of 90 (such as 90, -90 or 180), got -45"), + ("--move", "1:abc", "bad --move target: expected a page number of at least 1, got \"abc\""), + ("--move", "1:0", "bad --move target: expected a page number of at least 1, got \"0\""), + ("--insert-blank", "abc", "bad --insert-blank position: expected a page position of at least 1, got \"abc\""), + ("--insert-blank", "0", "bad --insert-blank position: expected a page position of at least 1, got \"0\""), + ] { + let out = Command::new(BIN).current_dir(&dir).args(["edit", "missing.pdf", "--out", "out.pdf", option, value]).output().unwrap(); + assert_eq!(out.status.code(), Some(1), "{option} {value}"); + assert_eq!(out.stderr, format!("pdfcraft-cli: {diagnostic}\n").as_bytes(), "{option} {value}"); + assert!(out.stdout.is_empty(), "{option} {value}"); + assert!(!dir.join("out.pdf").exists(), "{option} {value}: no output may be written"); + } + // A negative multiple of 90 is still a rotation the engine accepts: the refusal above is + // the multiple-of-90 rule, not a sign problem (the missing input is the next error). + let out = Command::new(BIN).current_dir(&dir).args(["edit", "missing.pdf", "--out", "out.pdf", "--rotate", "1:-90"]).output().unwrap(); + assert_eq!(out.status.code(), Some(1)); + assert!(!String::from_utf8_lossy(&out.stderr).contains("--rotate degrees"), "{}", String::from_utf8_lossy(&out.stderr)); +} + /// A destination past the end must fail, not silently become an append (#1330). #[test] fn a_destination_past_the_end_fails_and_writes_nothing() { diff --git a/apps/pdfcraft-cli/tests/owner_password.rs b/apps/pdfcraft-cli/tests/owner_password.rs new file mode 100644 index 000000000..3542a7d4c --- /dev/null +++ b/apps/pdfcraft-cli/tests/owner_password.rs @@ -0,0 +1,154 @@ +//! `info`, `text` and `render` take the owner password of revision 2–4 files, as `run doc_open` +//! does (#2670, item 8). The renderer authenticates only user passwords of those revisions, so +//! the CLI must hand it the user password that owner authentication recovers. + +use std::process::Command; +use std::sync::Arc; + +use pdfcraft_cos::{Algorithm, NewEncryption}; + +const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli"); + +/// A one-page 200×300 pt PDF reading "Page 1", drawn by `content`. +fn fixture(content: &str) -> Vec { + let stream = format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()); + let objs: [&[u8]; 5] = [ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 200 300] >>", + b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + b"<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 3 0 R >> >> >>", + stream.as_bytes(), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + out.extend_from_slice(o); + out.extend_from_slice(b"\nendobj\n"); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.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 >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +const PAGE: &str = "BT /F1 24 Tf 20 150 Td (Page 1) Tj ET"; + +/// `fixture` protected with user password "user" and owner password "owner", allowing nothing, +/// so copying its text needs the owner password. +fn protected(algorithm: Algorithm) -> Vec { + protected_with(algorithm, PAGE) +} + +fn protected_with(algorithm: Algorithm, content: &str) -> Vec { + let mut doc = pdfcraft_cos::Document::open(Arc::new(fixture(content))).unwrap(); + doc.set_encryption(&NewEncryption { + algorithm, + user_password: "user", + owner_password: "owner", + permissions: 0, + encrypt_metadata: true, + seed: [3; 32], + }) + .unwrap(); + pdfcraft_cos::write_full(&doc, &Default::default()).unwrap() +} + +fn tmp(name: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("pdfcraft-cli-owner-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + d.join(name) +} + +const ALGORITHMS: [Algorithm; 4] = [Algorithm::Rc4_40, Algorithm::Rc4_128, Algorithm::Aes128, Algorithm::Aes256]; + +#[test] +fn info_accepts_the_owner_password() { + for alg in ALGORITHMS { + let path = tmp(&format!("info-{alg:?}.pdf")); + std::fs::write(&path, protected(alg)).unwrap(); + for pw in ["owner", "user"] { + let out = Command::new(BIN).args(["info", path.to_str().unwrap(), "--password", pw]).output().unwrap(); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(out.status.success(), "{alg:?} with {pw:?}: {stderr}"); + let json: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(json["pages"], 1, "{alg:?} with {pw:?}"); + assert_eq!(json["encrypted"], true, "{alg:?} with {pw:?}"); + } + let out = Command::new(BIN).args(["info", path.to_str().unwrap(), "--password", "wrong"]).output().unwrap(); + assert!(!out.status.success(), "{alg:?}: a wrong password still fails"); + assert!(String::from_utf8_lossy(&out.stderr).contains("incorrect"), "{alg:?}"); + } +} + +#[test] +fn text_accepts_the_owner_password() { + for alg in ALGORITHMS { + let path = tmp(&format!("text-{alg:?}.pdf")); + std::fs::write(&path, protected(alg)).unwrap(); + let out = Command::new(BIN).args(["text", path.to_str().unwrap(), "--password", "owner"]).output().unwrap(); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(out.status.success(), "{alg:?}: {stderr}"); + assert!(String::from_utf8_lossy(&out.stdout).contains("Page 1"), "{alg:?}"); + // The user password opens it, but the permissions still forbid copying. + let out = Command::new(BIN).args(["text", path.to_str().unwrap(), "--password", "user"]).output().unwrap(); + assert!(!out.status.success(), "{alg:?}: copying needs the owner password"); + assert!(String::from_utf8_lossy(&out.stderr).contains("don't allow copying"), "{alg:?}"); + } +} + +/// Asserts that `data` is a PNG of the fixture's 200×300 pt page at `dpi` with the text drawn: +/// a blank page of the right size fails too. +fn assert_rendered_page(data: &[u8], dpi: u32, what: &str) { + let mut reader = png::Decoder::new(std::io::Cursor::new(data)).read_info().unwrap_or_else(|e| panic!("{what}: {e}")); + let mut buf = vec![0; reader.output_buffer_size().unwrap()]; + let frame = reader.next_frame(&mut buf).unwrap(); + assert_eq!((frame.width, frame.height), (200 * dpi / 72, 300 * dpi / 72), "{what}"); + // `render` writes 8-bit RGBA (`pdfcraft_engine::export::encode_png`). + assert_eq!((frame.color_type, frame.bit_depth), (png::ColorType::Rgba, png::BitDepth::Eight), "{what}"); + // Opaque pixels with every colour channel below half intensity: ink, not white paper. + let inked = buf[..frame.buffer_size()].as_chunks::<4>().0.iter().filter(|[r, g, b, a]| [r, g, b].iter().all(|c| **c < 128) && *a > 128).count(); + assert!(inked > 100, "{what}: only {inked} inked pixels, the page is blank"); +} + +#[test] +fn render_accepts_the_owner_password() { + const DPI: u32 = 144; + for alg in ALGORITHMS { + let path = tmp(&format!("render-{alg:?}.pdf")); + std::fs::write(&path, protected(alg)).unwrap(); + for pw in ["owner", "user"] { + let png = tmp(&format!("render-{alg:?}-{pw}.png")); + let _ = std::fs::remove_file(&png); + let out = Command::new(BIN) + .args(["render", path.to_str().unwrap(), "--page", "1", "--dpi", &DPI.to_string()]) + .args(["--password", pw, "--out", png.to_str().unwrap()]) + .output() + .unwrap(); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(out.status.success(), "{alg:?} with {pw:?}: {stderr}"); + assert_rendered_page(&std::fs::read(&png).unwrap(), DPI, &format!("{alg:?} with {pw:?}")); + } + } +} + +#[test] +fn audit_places_findings_with_the_owner_password() { + // A black box over the text: a faux redaction, so `audit` reads the page geometry too. + let covered = format!("{PAGE} 0 g 10 140 120 40 re f"); + for alg in ALGORITHMS { + let path = tmp(&format!("audit-{alg:?}.pdf")); + std::fs::write(&path, protected_with(alg, &covered)).unwrap(); + let out = Command::new(BIN).args(["audit", path.to_str().unwrap(), "--password", "owner"]).output().unwrap(); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(!stderr.contains("incorrect"), "{alg:?}: {stderr}"); + let report: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap_or_else(|e| panic!("{alg:?}: {e}: {stderr}")); + assert_eq!(report["findings"][0]["covered_text"], "Page 1", "{alg:?}"); + // Top-left points on the 300 pt high page: the box spans y 140–180 from the bottom. + assert_eq!(report["findings"][0]["rect"], serde_json::json!([10.0, 120.0, 130.0, 160.0]), "{alg:?}"); + } +} diff --git a/apps/pdfcraft-cli/tests/render.rs b/apps/pdfcraft-cli/tests/render.rs index 695431b65..561f9fa78 100644 --- a/apps/pdfcraft-cli/tests/render.rs +++ b/apps/pdfcraft-cli/tests/render.rs @@ -88,3 +88,27 @@ fn an_unknown_extension_fails_and_writes_nothing() { assert!(stderr.contains(".png, .jpg, .tif or .pam"), "{stderr}"); assert!(!out.exists()); } + +/// `--page` and `--dpi` are checked before the file is opened. A page that is not a positive +/// whole number names the same expectation `text` names; a dpi that parses as an `f32` but +/// cannot be rendered (`0`, a negative value, `NaN`) names the option and the value, instead +/// of reaching the renderer as a scale it refuses with its own internal message. +#[test] +fn a_page_or_dpi_the_renderer_cannot_use_is_refused_before_reading_the_file() { + for (option, value, diagnostic) in [ + ("--page", "abc", "bad --page: expected a positive page number"), + ("--page", "0", "bad --page: expected a positive page number"), + ("--dpi", "abc", "bad --dpi: expected a positive number of dots per inch, got \"abc\""), + ("--dpi", "0", "bad --dpi: expected a positive number of dots per inch, got \"0\""), + ("--dpi", "-3", "bad --dpi: expected a positive number of dots per inch, got \"-3\""), + ("--dpi", "NaN", "bad --dpi: expected a positive number of dots per inch, got \"NaN\""), + ("--dpi", "inf", "bad --dpi: expected a positive number of dots per inch, got \"inf\""), + ] { + let out = tmp("refuse", "out.png"); + // `missing.pdf` is never read: the option's own message is the error. + let r = Command::new(BIN).args(["render", "missing.pdf", "--out", out.to_str().unwrap(), option, value]).output().unwrap(); + assert_eq!(r.status.code(), Some(1), "{option} {value}"); + assert_eq!(r.stderr, format!("pdfcraft-cli: {diagnostic}\n").as_bytes(), "{option} {value}"); + assert!(!out.exists(), "{option} {value}: no output may be written"); + } +} diff --git a/apps/pdfcraft-cli/tests/split.rs b/apps/pdfcraft-cli/tests/split.rs index 7a4a254f8..71523191d 100644 --- a/apps/pdfcraft-cli/tests/split.rs +++ b/apps/pdfcraft-cli/tests/split.rs @@ -1,4 +1,5 @@ //! `split --out-dir DIR` creates DIR when it does not exist yet (#249), and says so when it can't. +//! `--every` must be a positive whole number of pages, checked before the file is opened. use std::path::PathBuf; use std::process::{Command, Output}; @@ -74,3 +75,22 @@ fn an_out_dir_that_cannot_be_created_is_reported_as_such() { assert_eq!(std::fs::read(¬_a_dir).unwrap(), b"in the way"); let _ = std::fs::remove_dir_all(&dir); } + +/// `--every` must be a positive whole number of pages. `0` parses, but the engine used to +/// clamp it to 1 silently; refused here, before the file is opened, it cannot surprise anyone. +#[test] +fn a_non_positive_or_unparseable_every_is_refused_before_the_file_is_opened() { + let dir = workdir("every"); + for value in ["0", "abc"] { + let out_dir = dir.join("parts"); + let out = Command::new(BIN).args(["split", "missing.pdf", "--every", value, "--out-dir", out_dir.to_str().unwrap()]).output().unwrap(); + assert_eq!(out.status.code(), Some(1), "--every {value}"); + assert_eq!( + out.stderr, + format!("pdfcraft-cli: bad --every: expected a positive whole number of pages, got {value:?}\n").as_bytes(), + "--every {value}" + ); + assert!(!out_dir.exists(), "--every {value}: nothing may be written"); + } + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/contributors/people.toml b/contributors/people.toml index c5c292b17..3e1e6da57 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -162,3 +162,9 @@ sync_github_name = true [people.Lcoding15] real_name = "Livio Oswald" + +[people.Bode512] +sync_github_name = true + +[people.amoisttowe1] +real_name = "Nick Nyssen" diff --git a/crates/automation/Cargo.toml b/crates/automation/Cargo.toml index 26f36728c..bed579ab8 100644 --- a/crates/automation/Cargo.toml +++ b/crates/automation/Cargo.toml @@ -32,3 +32,6 @@ flate2.workspace = true pdfcraft-model = { workspace = true } pdfcraft-xfa = { workspace = true } image = { version = "0.25", default-features = false, features = ["jpeg"] } + +[target.'cfg(target_os = "macos")'.dev-dependencies] +rustix = { version = "1", features = ["fs"] } diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index 8e0c26fdd..02eef226b 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -173,7 +173,6 @@ impl Automation { let only = a.opt_int("page")?.map(|p| (p.max(1) - 1) as usize); let all = self.comments(a)?; let view = |c: &Annotation| -> Value { - let p = &doc.info.pages[c.page.min(doc.info.pages.len().saturating_sub(1))]; let mut v = json!({ "id": c.name, "page": c.page + 1, @@ -182,14 +181,17 @@ impl Automation { "author": c.author, "contents": c.contents, "modified": c.modified, - "rect": rect_to_view(p, c.rect), + // Unchanged on a page the viewer could not read (#2660), as `doc_info` reports it. + "rect": crate::view_rect(&doc.info, c.page, c.rect), "color": c.color.map(hex), }); // A line's ends, or the corners of connected lines, a polygon or a cloud, as // comment_edit's `points` takes them. + // Unchanged on a page the viewer could not read, as `rect` is. if !c.points.is_empty() { let round = |v: f32| (v * 100.0).round() / 100.0; - v["points"] = json!(c.points.iter().map(|q| p.user_to_view(q[0], q[1]).map(round)).collect::>()); + let page = doc.info.pages.get(c.page); + v["points"] = json!(c.points.iter().map(|q| page.map_or([q[0], q[1]], |p| p.user_to_view(q[0], q[1])).map(round)).collect::>()); } v }; @@ -595,7 +597,10 @@ impl Automation { pub(crate) fn comments_hide(&mut self, a: &Args) -> Result { let id = self.doc(a)?.id; let hidden = a.opt_bool("hidden")?.unwrap_or(true); - self.session.set_hide_comments(id, hidden); + if self.session.set_hide_comments(id, hidden) { + // The cached renderer drew with the old setting; page_render must read it again. + self.renderers.remove(&id); + } Ok(json!({ "hidden": hidden })) } diff --git a/crates/automation/src/content.rs b/crates/automation/src/content.rs index aa8615f29..8926a1368 100644 --- a/crates/automation/src/content.rs +++ b/crates/automation/src/content.rs @@ -73,9 +73,9 @@ impl Automation { .iter() .map(|it| { let index = per_page.entry(it.page).or_default(); - let ph = doc.info.pages[it.page].height as f64; - let mut v = - json!({ "page": it.page + 1, "index": *index + 1, "rect": to_view(it.content.rect(), ph).map(|x| (x * 100.0).round() / 100.0) }); + // Unchanged on a page the viewer could not read (#2660), like `crate::view_rect`. + let rect = doc.info.pages.get(it.page).map_or(it.content.rect(), |p| to_view(it.content.rect(), p.height as f64)); + let mut v = json!({ "page": it.page + 1, "index": *index + 1, "rect": rect.map(|x| (x * 100.0).round() / 100.0) }); *index += 1; match &it.content { AddedContent::Text(t) => { diff --git a/crates/automation/src/forms.rs b/crates/automation/src/forms.rs index baeb4dd52..aba207a70 100644 --- a/crates/automation/src/forms.rs +++ b/crates/automation/src/forms.rs @@ -417,7 +417,7 @@ impl Automation { let r: Vec = v.as_array().map(|x| x.iter().filter_map(Value::as_f64).collect()).unwrap_or_default(); let r = <[f64; 4]>::try_from(r).map_err(|_| ToolError::InvalidArgs("rect must be 4 numbers".into()))?; let page = f.widgets.first().and_then(|w| w.page).ok_or_else(|| failed(format!("{name} is not on a page")))?; - let p = &doc.info.pages[page]; + let p = doc.info.pages.get(page).ok_or_else(|| failed(format!("{name} is on page {}, which could not be read", page + 1)))?; let (u0, u1) = (p.view_to_user(r[0] as f32, r[1] as f32), p.view_to_user(r[2] as f32, r[3] as f32)); Some((0, [u0[0].min(u1[0]) as f64, u0[1].min(u1[1]) as f64, u0[0].max(u1[0]) as f64, u0[1].max(u1[1]) as f64])) } diff --git a/crates/automation/src/layers.rs b/crates/automation/src/layers.rs new file mode 100644 index 000000000..7cb0817a4 --- /dev/null +++ b/crates/automation/src/layers.rs @@ -0,0 +1,47 @@ +//! Layers (optional content groups) as a tool: the Layers panel's eye icon (#2986, AGENTS.md §3). +//! +//! A per-document view setting, like hidden comments: `page_render` draws with it, and the file +//! itself never changes. + +use serde_json::{Value, json}; + +use crate::{Args, Automation, Result, ToolError}; + +impl Automation { + /// Show or hide a layer, named by `layer`: its name as `doc_info` lists it, or its 1-based + /// position in that list. A name picks every layer that has it, since CAD exporters often + /// write one group per page or per drawing under the same name. + pub(crate) fn layer_set(&mut self, a: &Args) -> Result { + let doc = self.doc(a)?; + let id = doc.id; + let visible = a.opt_bool("visible")?.ok_or_else(|| ToolError::InvalidArgs("missing argument visible".into()))?; + let layers = &doc.info.layers; + let targets: Vec = match a.get("layer") { + Some(Value::String(name)) => layers.iter().enumerate().filter(|(_, l)| l.name == *name).map(|(i, _)| i).collect(), + Some(v) => match v.as_u64() { + Some(n) if n >= 1 && usize::try_from(n - 1).is_ok_and(|i| i < layers.len()) => vec![(n - 1) as usize], + Some(_) => Vec::new(), + None => return Err(ToolError::InvalidArgs("layer must be a layer name or its 1-based position in doc_info's layers".into())), + }, + None => return Err(ToolError::InvalidArgs("missing argument layer".into())), + }; + if targets.is_empty() { + let names: Vec<&str> = layers.iter().map(|l| l.name.as_str()).collect(); + return Err(ToolError::InvalidArgs(if names.is_empty() { + "the document has no layers".into() + } else { + format!("no such layer; the document's layers are: {}", names.join(", ")) + })); + } + let mut changed = false; + for i in targets { + changed |= self.session.set_layer_visible(id, i, visible); + } + if changed { + // The cached renderer drew with the old visibility; page_render must read it again. + self.renderers.remove(&id); + } + let layers = &self.doc(a)?.info.layers; + Ok(json!({ "layers": layers.iter().map(|l| json!({ "name": l.name, "visible": l.visible })).collect::>() })) + } +} diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 5b0d35489..e87505c9f 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -21,6 +21,7 @@ mod content; mod conventions; pub mod file_access; mod forms; +mod layers; mod links; #[cfg(feature = "mcp")] pub mod mcp; @@ -835,6 +836,14 @@ impl Automation { "print_readiness" => self.print_readiness(&a)?, "doc_export_office" => self.doc_export_office(&a)?, "pdfa_convert" => self.pdfa_convert(&a)?, + "standards_remove_id" => { + let standard = match a.str("standard")? { + "pdfa" => pdfcraft_engine::Standard::PdfA, + "pdfx" => pdfcraft_engine::Standard::PdfX, + other => return Err(ToolError::InvalidArgs(format!("standard must be pdfa or pdfx, not {other}"))), + }; + self.apply(&a, Edit::RemoveStandardId { standard })? + } "action_run" => self.action_run(&a)?, "doc_compare_report" => self.doc_compare_report(&a)?, "doc_compare_mark" => self.doc_compare_mark(&a)?, @@ -891,6 +900,7 @@ impl Automation { "comment_mark" => self.comment_mark(&a)?, "comment_lock" => self.comment_lock(&a)?, "comments_hide" => self.comments_hide(&a)?, + "layer_set" => self.layer_set(&a)?, "view_reading_colors" => self.view_reading_colors(&a)?, "comments_summarize" => self.comments_summarize(&a)?, "comment_edit" => self.comment_edit(&a)?, @@ -1918,11 +1928,11 @@ impl Automation { fn renderer(&mut self, id: DocId) -> Result<&mut PageRenderer> { let doc = self.session.get(id).ok_or_else(|| failed("no such document"))?; - let colors = self.session.reading_colors(id).unwrap_or_default(); - let fresh = || { - let config = RenderConfig { password: doc.password.as_deref().map(Arc::from), reading_colors: colors, ..Default::default() }; - (doc.display.clone(), PageRenderer::new(doc.display.clone(), config)) - }; + // The document's own view settings: layer visibility, hidden comments, reading colours + // (tools that change one drop this cache so the next render reads it again). + let config = + self.session.render_config(id).unwrap_or_else(|| RenderConfig { password: doc.password.as_deref().map(Arc::from), ..Default::default() }); + let fresh = || (doc.display.clone(), PageRenderer::new(doc.display.clone(), config.clone())); let entry = self.renderers.entry(id).or_insert_with(fresh); if !Arc::ptr_eq(&entry.0, &doc.display) { *entry = fresh(); diff --git a/crates/automation/src/links.rs b/crates/automation/src/links.rs index cd0fb9da0..8058d5639 100644 --- a/crates/automation/src/links.rs +++ b/crates/automation/src/links.rs @@ -70,9 +70,8 @@ impl Automation { .links() .iter() .map(|l| { - let p = &doc.info.pages[l.page]; - let (v0, v1) = (p.user_to_view(l.rect[0] as f32, l.rect[1] as f32), p.user_to_view(l.rect[2] as f32, l.rect[3] as f32)); - let rect = [v0[0].min(v1[0]), v0[1].min(v1[1]), v0[0].max(v1[0]), v0[1].max(v1[1])].map(|x| (x * 100.0).round() / 100.0); + // The link's page may be one the viewer could not read (#2660): rect unchanged then. + let rect = crate::view_rect(&doc.info, l.page, l.rect.map(|x| x as f32)); let mut v = json!({ "page": l.page + 1, "index": l.index + 1, "rect": rect, "visible": l.style.visible }); // Where an in-document link goes, as the viewer resolves it: named destinations // included, with the position on the page. diff --git a/crates/automation/src/signing.rs b/crates/automation/src/signing.rs index 94fc4bdec..c7a043c89 100644 --- a/crates/automation/src/signing.rs +++ b/crates/automation/src/signing.rs @@ -208,7 +208,7 @@ impl Automation { }; let rect = match a.nums::<4>("rect")? { Some(r) => { - let p = &doc.info.pages[page]; + let p = doc.info.pages.get(page).ok_or_else(|| bad("the document has no page to put the signature on"))?; let (u0, u1) = (p.view_to_user(r[0] as f32, r[1] as f32), p.view_to_user(r[2] as f32, r[3] as f32)); let u = [u0[0].min(u1[0]) as f64, u0[1].min(u1[1]) as f64, u0[0].max(u1[0]) as f64, u0[1].max(u1[1]) as f64]; if u[2] - u[0] < 4.0 || u[3] - u[1] < 4.0 { diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 550238b4e..4eca7a060 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -781,6 +781,11 @@ pub fn tools() -> Vec { comment_ref(json!({ "locked": { "type": "boolean", "description": "Default true." } })), &["doc"], )), + t("layer_set", "Show or hide a layer", "Show or hide a layer (optional content group), as the Layers panel's eye icon does. layer is its name as doc_info lists it (every layer with that name) or its 1-based position in that list. A view setting: page_render draws with it, and the file doesn't change. Returns every layer with its visibility.") + .with(schema( + json!({ "doc": doc(), "layer": { "type": ["string", "integer"], "description": "A layer name, or a 1-based position in doc_info's layers." }, "visible": { "type": "boolean" } }), + &["doc", "layer", "visible"], + )), t("comments_hide", "Hide all comments", "Hide or show every comment on the page (fields and links still draw). A view setting: the file doesn't change.") .cmd("comment.hide_all") .with(schema(json!({ "doc": doc(), "hidden": { "type": "boolean", "description": "Default true." } }), &["doc"])), @@ -1100,6 +1105,8 @@ pub fn tools() -> Vec { t("pdfa_convert", "Save as PDF/A", "Standards ▸ Save as PDF/A: fix what can be fixed for PDF/A-2b or 3b (XMP identification and metadata, an sRGB output intent, forbidden actions and JavaScript, annotation print flags, image interpolation, encryption). Returns what was fixed and what remains (e.g. fonts that aren't embedded). Undoable; save the document to keep it.") .cmd("standards.pdfa") .with(schema(json!({ "doc": doc(), "level": { "type": "string", "enum": ["2b", "3b"] } }), &["doc"])), + t("standards_remove_id", "Remove PDF/A or PDF/X identification", "Remove what identifies the document as PDF/A (its pdfaid: XMP properties) or PDF/X (GTS_PDFXVersion and GTS_PDFXConformance, in the document information and as pdfxid: XMP properties), e.g. after edits it no longer meets. Output intents and other metadata stay. Fails when the document declares nothing of the kind. Undoable.") + .with(schema(json!({ "doc": doc(), "standard": { "type": "string", "enum": ["pdfa", "pdfx"] } }), &["doc", "standard"])), t("action_list", "List actions", "Action Wizard: the built-in actions (name, description, steps) and every step an action can use (id, label, whether it takes a text argument).") .ro() .cmd("actions.wizard") diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index c95c670c6..b271c21fc 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -64,6 +64,65 @@ fn remote_link_pdf(file: &str, destination: Option<&str>) -> Vec { bytes } +#[cfg(target_os = "macos")] +#[test] +fn saving_a_pdf_keeps_the_destinations_extended_attributes() { + use rustix::fs::{XattrFlags, getxattr, setxattr}; + let dir = workdir("save-xattrs"); + let target = dir.join("a.pdf"); + let tags = b"PdfCraft\n6"; + let binary = b"\0\xffpdfcraft\0"; + let attributes: &[(&str, &[u8])] = &[("com.apple.metadata:_kMDItemUserTags", tags), ("com.pdfcraft.test", binary)]; + for (name, value) in attributes { + setxattr(&target, *name, value, XattrFlags::empty()).unwrap(); + } + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({"path": "a.pdf"}))["doc"].as_u64().unwrap(); + for full in [false, true] { + ok(&mut a, "page_rotate", json!({"doc": doc, "pages": [1], "degrees": 90})); + let saved = ok(&mut a, "doc_save", json!({"doc": doc, "full": full})); + assert_eq!(saved["incremental"], !full); + for (name, value) in attributes { + let mut bytes = [0u8; 256]; + let len = getxattr(&target, *name, &mut bytes[..]).unwrap(); + assert_eq!(&bytes[..len], *value, "full={full}, {name}"); + } + } + // Save As preserves the destination's metadata, not attributes from the open input file. + let other = dir.join("other.pdf"); + std::fs::write(&other, fixture(1)).unwrap(); + setxattr(&other, "com.pdfcraft.test", b"destination", XattrFlags::empty()).unwrap(); + ok(&mut a, "doc_save", json!({"doc": doc, "path": "other.pdf"})); + let mut bytes = [0u8; 256]; + let len = getxattr(&other, "com.pdfcraft.test", &mut bytes[..]).unwrap(); + assert_eq!(&bytes[..len], b"destination"); + assert_eq!(getxattr(&other, "com.apple.metadata:_kMDItemUserTags", &mut bytes[..]).unwrap_err(), rustix::io::Errno::NOATTR); + std::fs::remove_dir_all(dir).unwrap(); +} + +#[cfg(target_os = "macos")] +#[test] +fn a_metadata_preservation_error_keeps_the_pdf_and_unsaved_edit() { + use rustix::fs::{XattrFlags, setxattr}; + let dir = workdir("save-xattrs-error"); + let target = dir.join("a.pdf"); + let original = std::fs::read(&target).unwrap(); + setxattr(&target, "com.apple.ResourceFork", &vec![42u8; 16 * 1024 * 1024 + 1], XattrFlags::empty()).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({"path": "a.pdf"}))["doc"].as_u64().unwrap(); + ok(&mut a, "page_rotate", json!({"doc": doc, "pages": [1], "degrees": 90})); + let failure = err(&mut a, "doc_save", json!({"doc": doc})); + assert!(failure.to_string().contains("extended attributes"), "{failure}"); + assert!(failure.to_string().contains("Save As"), "{failure}"); + assert_eq!(std::fs::read(&target).unwrap(), original); + assert_eq!(ok(&mut a, "doc_info", json!({"doc": doc}))["document"]["dirty"], true); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 2, "only the two original PDFs remain"); + // A fresh name remains usable: metadata refusal doesn't discard the accepted edit. + ok(&mut a, "doc_save", json!({"doc": doc, "path": "copy.pdf"})); + assert_ne!(std::fs::read(dir.join("copy.pdf")).unwrap(), original); + std::fs::remove_dir_all(dir).unwrap(); +} + fn protected_fixture(password: &str) -> Vec { use pdfcraft_engine::{Edit, Protection, Session}; let mut session = Session::new(); @@ -5923,6 +5982,10 @@ fn pdfa_through_tools() { assert_eq!(r["declared"]["pdfa"], "PDF/A-3b", "{r}"); assert!(r["issues"].as_array().unwrap().iter().all(|i| i["fixable"] == false), "{r}"); assert!(a.call("pdfa_verify", &json!({ "doc": doc, "level": "9z" })).is_err()); + ok(&mut a, "standards_remove_id", json!({ "doc": doc, "standard": "pdfa" })); + let r = ok(&mut a, "pdfa_verify", json!({ "doc": doc })); + assert!(r["declared"]["pdfa"].is_null() && !r["declared"]["output_intents"].as_array().unwrap().is_empty(), "{r}"); + assert!(matches!(a.call("standards_remove_id", &json!({ "doc": doc, "standard": "pdfa" })), Err(ToolError::Failed(_))), "nothing left"); } #[test] @@ -7362,6 +7425,91 @@ fn pixel(pixels: &[u8], x: u32, y: u32) -> [u8; 4] { pixels[at..at + 4].try_into().unwrap() } +/// A 300 × 100 page: red in layer "Walls" (left), green in "Doors" (middle), blue in a second +/// group also named "Walls" (right), and a black Square comment along the bottom. +fn layered_pdf() -> Vec { + let content = b"/OC /W1 BDC 1 0 0 rg 10 30 80 60 re f EMC /OC /D BDC 0 1 0 rg 110 30 80 60 re f EMC /OC /W2 BDC 0 0 1 rg 210 30 80 60 re f EMC"; + let ap = b"0 g 0 0 280 10 re f"; + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R 6 0 R 7 0 R] /D << /Order [5 0 R 6 0 R 7 0 R] >> >> >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 100] /Contents 4 0 R /Annots [8 0 R] /Resources << /Properties << /W1 5 0 R /D 6 0 R /W2 7 0 R >> >> >>".to_vec(), + [format!("<< /Length {} >>\nstream\n", content.len()).as_bytes(), content, b"\nendstream"].concat(), + b"<< /Type /OCG /Name (Walls) >>".to_vec(), + b"<< /Type /OCG /Name (Doors) >>".to_vec(), + b"<< /Type /OCG /Name (Walls) >>".to_vec(), + b"<< /Type /Annot /Subtype /Square /Rect [10 2 290 12] /C [0 0 0] /AP << /N 9 0 R >> >>".to_vec(), + [format!("<< /Type /XObject /Subtype /Form /BBox [0 0 280 10] /Length {} >>\nstream\n", ap.len()).as_bytes(), ap, b"\nendstream"].concat(), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + out.extend_from_slice(o); + out.extend_from_slice(b"\nendobj\n"); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.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 >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +/// #2986: `layer_set` hides and shows layers in what `page_render` draws, by name (every group +/// with that name) or by position; `comments_hide` now changes `page_render` too. Neither +/// changes the file. +#[test] +fn layers_and_hidden_comments_change_what_page_render_draws() { + let dir = workdir("layer-set"); + std::fs::write(dir.join("layers.pdf"), layered_pdf()).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "layers.pdf" }))["doc"].as_u64().unwrap(); + let at = |a: &mut Automation, x: u32, y: u32| { + let (w, _, px) = a.render_pixels(doc, 1, 1.0).unwrap(); + let i = ((y * w + x) * 4) as usize; + [px.as_ref()[i], px.as_ref()[i + 1], px.as_ref()[i + 2]] + }; + // Points (50, 40) red, (150, 40) green, (250, 40) blue, (150, 93) the comment; y down. + assert_eq!( + [at(&mut a, 50, 40), at(&mut a, 150, 40), at(&mut a, 250, 40), at(&mut a, 150, 93)], + [[255, 0, 0], [0, 255, 0], [0, 0, 255], [0, 0, 0]] + ); + + let r = ok(&mut a, "layer_set", json!({ "doc": doc, "layer": "Walls", "visible": false })); + assert_eq!( + r["layers"], + json!([{ "name": "Walls", "visible": false }, { "name": "Doors", "visible": true }, { "name": "Walls", "visible": false }]) + ); + assert_eq!( + [at(&mut a, 50, 40), at(&mut a, 150, 40), at(&mut a, 250, 40)], + [[255, 255, 255], [0, 255, 0], [255, 255, 255]], + "both groups named Walls hide" + ); + + ok(&mut a, "layer_set", json!({ "doc": doc, "layer": 3, "visible": true })); + assert_eq!([at(&mut a, 50, 40), at(&mut a, 250, 40)], [[255, 255, 255], [0, 0, 255]], "position 3 is the second Walls only"); + + ok(&mut a, "comments_hide", json!({ "doc": doc })); + assert_eq!(at(&mut a, 150, 93), [255, 255, 255], "hidden comments are not drawn"); + ok(&mut a, "comments_hide", json!({ "doc": doc, "hidden": false })); + assert_eq!(at(&mut a, 150, 93), [0, 0, 0]); + + for (args, what) in [ + (json!({ "doc": doc, "layer": "Roof", "visible": false }), "an unknown name"), + (json!({ "doc": doc, "layer": 0, "visible": false }), "position 0"), + (json!({ "doc": doc, "layer": 4, "visible": false }), "a position past the end"), + (json!({ "doc": doc, "layer": true, "visible": false }), "a layer that is neither"), + (json!({ "doc": doc, "layer": "Doors" }), "no visible"), + ] { + assert!(matches!(a.call("layer_set", &args), Err(ToolError::InvalidArgs(_))), "{what} must be refused"); + } + let list = ok(&mut a, "doc_list", json!({})); + assert_eq!(list["documents"][0]["dirty"], json!(false), "a view setting leaves the document clean: {list}"); +} + /// The `view_reading_colors` tool changes what `page_render` draws — text and background remap, /// images never do — without touching the file, and its errors name what's wrong (#897). #[test] diff --git a/crates/automation/tests/hostile_args.rs b/crates/automation/tests/hostile_args.rs index 607696899..d393e3726 100644 --- a/crates/automation/tests/hostile_args.rs +++ b/crates/automation/tests/hostile_args.rs @@ -336,3 +336,146 @@ fn listing(dir: &Path) -> Vec<(PathBuf, u64)> { out.sort(); out } + +/// A six-page file whose fourth `/Kids` entry points at an object that does not exist (#2660), +/// with a link, a URI link and a comment on every page, and a form field and text added by +/// PdfCraft (its `/PCAdded` marker) on the last. Readers +/// disagree on such a file: the viewer stopped at the dangling entry and saw three pages, while +/// the editing layer sees all six, so annotations come back on pages the viewer does not have. +fn dangling_kid_pdf() -> Vec { + let pages: Vec = (0..6).map(|i| 10 + 2 * i).collect(); + let mut kids: Vec = pages[..3].to_vec(); + kids.push(77); // does not exist + kids.extend(&pages[3..]); + let kids: Vec = kids.iter().map(|k| format!("{k} 0 R")).collect(); + let mut objs: Vec<(u32, String)> = vec![ + (1, "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [50 0 R] >> >>".into()), + (2, format!("<< /Type /Pages /Kids [{}] /Count 6 >>", kids.join(" "))), + (90, "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into()), + ]; + for (i, &page) in pages.iter().enumerate() { + let text = format!("BT /F1 24 Tf 50 300 Td (Page {}) Tj ET", i + 1); + let (field, contents) = if i == 5 { (" 50 0 R", format!("[{} 0 R 60 0 R]", page + 1)) } else { ("", format!("{} 0 R", page + 1)) }; + objs.push(( + page, + format!( + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 400] /Resources << /Font << /F1 90 0 R >> >> /Contents {contents} \ + /Annots [<< /Type /Annot /Subtype /Link /Rect [10 10 100 40] /Dest [10 0 R /Fit] >> \ + << /Type /Annot /Subtype /Link /Rect [10 50 100 80] /A << /S /URI /URI (https://example.com/) >> >> \ + << /Type /Annot /Subtype /Text /Rect [200 200 220 220] /Contents (note) /T (author) >>{field}] >>" + ), + )); + objs.push((page + 1, format!("<< /Length {} >>\nstream\n{text}\nendstream", text.len()))); + } + objs.push((50, "<< /FT /Tx /T (name) /Type /Annot /Subtype /Widget /Rect [50 100 250 130] /P 20 0 R >>".into())); + let added = "BT /F1 12 Tf 72 80 Td (AddedSample) Tj ET"; + objs.push(( + 60, + format!( + "<< /PCMark /Added /PCAdded << /Kind /Text /Rect [70 70 150 100] /Text (AddedSample) /Size 12 >> /Length {} >>\nstream\n{added}\nendstream", + added.len() + ), + )); + objs.sort_by_key(|(n, _)| *n); + + let size = 100; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = vec![None; size]; + for (num, body) in &objs { + if let Some(slot) = offsets.get_mut(*num as usize) { + *slot = Some(out.len()); + } + out.extend_from_slice(format!("{num} 0 obj\n{body}\nendobj\n").as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {size}\n0000000000 65535 f \n").as_bytes()); + for offset in offsets.iter().skip(1) { + out.extend_from_slice( + match offset { + Some(o) => format!("{o:010} 00000 n \n"), + None => "0000000000 65535 f \n".to_string(), + } + .as_bytes(), + ); + } + out.extend_from_slice(format!("trailer << /Size {size} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes()); + out +} + +#[test] +fn no_tool_panics_when_the_viewer_sees_fewer_pages_than_the_file_has() { + // #2660: `link_list` indexed the viewer's pages with a page number from the editing layer + // ("index out of bounds: the len is 3 but the index is 3"). Every tool, on a fresh copy of a + // file where the two disagree, must answer with a result or an error. + let root = tmp("dangling-kid"); + // The JSON a call answers with, or the error it gave. + let answer = |a: &mut Automation, tool: &str, args: &Value| -> std::result::Result { + match catch_unwind(AssertUnwindSafe(|| a.call(tool, args))) { + Ok(Ok(content)) => content + .into_iter() + .find_map(|c| match c { + pdfcraft_automation::Content::Json(v) => Some(v), + pdfcraft_automation::Content::Png { .. } => None, + }) + .ok_or_else(|| format!("{tool} answered without JSON")), + Ok(Err(e)) => Err(format!("{tool} failed: {e:?}")), + Err(_) => Err(format!("{tool} panicked")), + } + }; + let pdf = dangling_kid_pdf(); + let mut failures = Vec::new(); + for def in tools() { + // Written again for every tool: tools that save write to `in.pdf`, the plausible path. + std::fs::write(root.join("in.pdf"), &pdf).expect("fixture"); + let mut a = Automation::new().with_root(&root).expect("root"); + let opened = a.call("doc_open", &json!({ "path": "in.pdf" })).expect("open the damaged file"); + let doc = opened + .into_iter() + .find_map(|c| match c { + pdfcraft_automation::Content::Json(v) => v["doc"].as_u64(), + pdfcraft_automation::Content::Png { .. } => None, + }) + .expect("the opened document has an id"); + if let Err(e) = guarded(&mut a, def.name, &Value::Object(plausible_with(&def, Some(doc)))) { + failures.push(e); + } + match def.name { + // The field sits on the sixth page; a rect is placed on the field's own page. That is + // refused, saying why, and the field is left as it was. + "form_set_props" => { + let fields = |a: &mut Automation| answer(a, "form_fields", &json!({ "doc": doc })); + let before = fields(&mut a); + match catch_unwind(AssertUnwindSafe(|| a.call(def.name, &json!({ "doc": doc, "field": "name", "rect": [10, 10, 60, 30] })))) { + Ok(Err(ToolError::Failed(m))) if m == "name is on page 6, which could not be read" => {} + Ok(Err(e)) => failures.push(format!("form_set_props gave the wrong error: {e:?}")), + Ok(Ok(_)) => failures.push("form_set_props placed a rect on a page the viewer could not read".into()), + Err(_) => failures.push("form_set_props panicked on a rect for the field on page 6".into()), + } + let after = fields(&mut a); + if before.is_err() || before != after { + failures.push(format!("form_set_props changed the field: {before:?} became {after:?}")); + } + } + // Listing still reports what is on the pages the viewer lacks, not just survives it. + "link_list" => match answer(&mut a, def.name, &json!({ "doc": doc })) { + Ok(v) if v["count"] == 12 => {} + other => failures.push(format!("link_list did not list the file's 12 links: {other:?}")), + }, + // The added text on page 6 is reported, its rect unchanged (user space), as + // `doc_info` reports rects on pages the viewer could not read. + "content_list" => match answer(&mut a, def.name, &json!({ "doc": doc })) { + Ok(v) + if v["items"].as_array().is_some_and(|items| { + items.iter().any(|it| it["page"] == 6 && it["type"] == "text" && it["rect"] == json!([70.0, 70.0, 150.0, 100.0])) + }) => {} + other => failures.push(format!("content_list did not report the added text on page 6 at [70, 70, 150, 100]: {other:?}")), + }, + _ => {} + } + } + for f in &failures { + eprintln!(" {f}"); + } + let _ = std::fs::remove_dir_all(&root); + assert!(failures.is_empty(), "{} failure(s); the first is: {}", failures.len(), failures.first().map_or("", String::as_str)); +} diff --git a/crates/automation/tests/optimize_content_boundaries.rs b/crates/automation/tests/optimize_content_boundaries.rs new file mode 100644 index 000000000..c37da05eb --- /dev/null +++ b/crates/automation/tests/optimize_content_boundaries.rs @@ -0,0 +1,165 @@ +//! Contributor-original PDFs exercise optimization across content stream boundaries. + +use std::sync::Arc; + +use pdfcraft_automation::{Automation, Content}; +use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream, write_full}; +use serde_json::{Value, json}; + +const COLORS: [[u8; 3]; 4] = [[220, 30, 40], [20, 190, 60], [30, 60, 220], [230, 190, 20]]; + +fn fixture(ending: &str, split_operands: bool) -> Vec { + let mut doc = Document::new_empty(); + let pages = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap(); + let font = doc.add(Object::Dict(Dict::from_iter([ + (b"Type".to_vec(), Object::name("Font")), + (b"Subtype".to_vec(), Object::name("Type1")), + (b"BaseFont".to_vec(), Object::name("Helvetica")), + ]))); + let mut xobjects = Dict::new(); + for (i, color) in COLORS.iter().enumerate() { + let dict = Dict::from_iter([ + (b"Type".to_vec(), Object::name("XObject")), + (b"Subtype".to_vec(), Object::name("Image")), + (b"Width".to_vec(), 20.into()), + (b"Height".to_vec(), 5.into()), + (b"BitsPerComponent".to_vec(), 8.into()), + (b"ColorSpace".to_vec(), Object::name("DeviceRGB")), + ]); + let image = doc.add(Object::Stream(Stream::flate(dict, &color.repeat(100)))); + xobjects.set(format!("Im{i}").into_bytes(), Object::Ref(image)); + } + // q and the translation stay active across both pieces. The optional split places + // the final two cm operands and its operator in the next stream. + let first = if split_operands { + "q 1 0 0 1".to_string() + } else { + format!("q 1 0 0 1 10 0 cm\nBT /F1 20 Tf 30 730 Td (FIRST) Tj ET\n% trailing comment{ending}") + }; + let mut second = if split_operands { + "10 0 cm\nBT /F1 20 Tf 30 730 Td (FIRST) Tj ET\n".to_string() + } else { + "BT /F1 20 Tf 30 400 Td (HIDDEN) Tj ET\n".to_string() + }; + for i in 0..4 { + second.push_str(&format!("q 20 0 0 5 100 {} cm /Im{i} Do Q\n", 200 + (3 - i) * 5)); + } + second.push_str("BT /F1 20 Tf 30 100 Td (LAST) Tj ET\nQ\n"); + let contents: Vec = + [first, second].into_iter().map(|piece| Object::Ref(doc.add(Object::Stream(Stream::from_raw(Dict::new(), piece.into_bytes()))))).collect(); + let resources = Dict::from_iter([ + (b"Font".to_vec(), Object::Dict(Dict::from_iter([(b"F1".to_vec(), Object::Ref(font))]))), + (b"XObject".to_vec(), Object::Dict(xobjects)), + ]); + let page = doc.add(Object::Dict(Dict::from_iter([ + (b"Type".to_vec(), Object::name("Page")), + (b"Parent".to_vec(), Object::Ref(pages)), + (b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 600.into(), 800.into()])), + (b"Resources".to_vec(), Object::Dict(resources)), + (b"Contents".to_vec(), Object::Array(contents)), + ]))); + doc.update_dict(pages, |dict| { + dict.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)])); + dict.set(b"Count".to_vec(), Object::Int(1)); + }) + .unwrap(); + write_full(&doc, &SaveOptions::default()).unwrap() +} + +fn call(a: &mut Automation, tool: &str, args: Value) -> Value { + let result = a.call(tool, &args).unwrap(); + let Content::Json(value) = &result[0] else { panic!("expected JSON from {tool}") }; + value.clone() +} + +fn check_page(a: &mut Automation, doc: u64, hidden: bool, phase: &str) { + let text = call(a, "text_extract", json!({"doc": doc})); + let words: Vec<_> = text["pages"][0]["text"].as_str().unwrap().split_whitespace().collect(); + assert_eq!(words, if hidden { vec!["FIRST", "HIDDEN", "LAST"] } else { vec!["FIRST", "LAST"] }, "{phase} text"); + let rendered = a.call("page_render", &json!({"doc": doc, "page": 1, "dpi": 72})).unwrap(); + let Content::Png { data, width, height } = &rendered[0] else { panic!("expected page PNG") }; + assert_eq!((*width, *height), (600, 800)); + let mut reader = png::Decoder::new(std::io::Cursor::new(data)).read_info().unwrap(); + let mut pixels = vec![0; reader.output_buffer_size().unwrap()]; + let info = reader.next_frame(&mut pixels).unwrap(); + let channels = info.color_type.samples(); + assert!(channels >= 3); + let pixel = |x: usize, y: usize| &pixels[(y * 600 + x) * channels..(y * 600 + x) * channels + 3]; + let dark = |left, top, right, bottom| { + (top..bottom).flat_map(|y| (left..right).map(move |x| (x, y))).filter(|&(x, y)| pixel(x, y).iter().any(|v| *v < 128)).count() + }; + assert!(dark(35, 45, 130, 75) > 30, "FIRST must draw at its translated position"); + assert!(dark(35, 675, 130, 705) > 30, "LAST must draw at its translated position"); + assert_eq!(dark(35, 375, 150, 405) > 0, hidden, "HIDDEN region must match the real comment ending"); + for (row, color) in COLORS.iter().enumerate() { + for y in 581 + row * 5..584 + row * 5 { + for x in 112..128 { + assert_eq!(pixel(x, y), color, "strip {row}, pixel {x},{y}"); + } + } + } + assert_eq!(pixel(105, 590), [255, 255, 255], "the cross-piece translation stays active"); +} + +fn check_saved_images(bytes: Vec, merged: bool) { + let doc = Document::open(Arc::new(bytes)).unwrap(); + let root = doc.get(doc.root().unwrap()); + let pages = doc.get(root.as_dict().unwrap().reference(b"Pages").unwrap()); + let kids = pages.as_dict().unwrap().get(b"Kids").unwrap().as_array().unwrap(); + let page = doc.resolve(&kids[0]); + let resources = doc.resolve(page.as_dict().unwrap().get(b"Resources").unwrap()); + let xobjects = doc.resolve(resources.as_dict().unwrap().get(b"XObject").unwrap()); + let images: Vec<_> = xobjects.as_dict().unwrap().iter().map(|(_, value)| doc.resolve(value)).collect(); + assert_eq!(images.len(), if merged { 1 } else { 4 }); + if merged { + let Object::Stream(image) = &*images[0] else { panic!("expected saved image") }; + assert_eq!((image.dict.int(b"Width"), image.dict.int(b"Height")), (Some(20), Some(20))); + let expected: Vec<_> = COLORS.iter().flat_map(|color| color.repeat(100)).collect(); + assert_eq!(image.decoded().unwrap(), expected, "saved RGB rows keep their strip order"); + } +} + +fn roundtrip(ending: &str, split_operands: bool, merge: bool) { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("input.pdf"), fixture(ending, split_operands)).unwrap(); + let mut a = Automation::new().with_root(dir.path()).unwrap(); + let doc = call(&mut a, "doc_open", json!({"path": "input.pdf"}))["doc"].as_u64().unwrap(); + let hidden = !ending.is_empty() && !split_operands; + check_page(&mut a, doc, hidden, "original"); + let report = call( + &mut a, + "doc_optimize", + json!({ + "doc": doc, "path": "output.pdf", "merge_fragments": merge, + "color": {"downsample": false, "compression": "retain"}, + "gray": {"downsample": false, "compression": "retain"} + }), + ); + assert_eq!(report["fragments_merged"], if merge { 4 } else { 0 }); + assert_eq!(report["merged_images"], if merge { 1 } else { 0 }); + let reopened = call(&mut a, "doc_open", json!({"path": "output.pdf"}))["doc"].as_u64().unwrap(); + check_page(&mut a, reopened, hidden, "reopened"); + check_saved_images(std::fs::read(dir.path().join("output.pdf")).unwrap(), merge); +} + +#[test] +fn fragment_merge_preserves_a_comment_across_content_pieces() { + roundtrip("", false, true); +} + +#[test] +fn fragment_merge_preserves_explicit_comment_line_endings() { + for ending in ["\n", "\r", "\r\n"] { + roundtrip(ending, false, true); + } +} + +#[test] +fn fragment_merge_preserves_split_operands_and_graphics_state() { + roundtrip("", true, true); +} + +#[test] +fn disabled_fragment_merge_preserves_content_pieces() { + roundtrip("", false, false); +} diff --git a/crates/cos/src/writer.rs b/crates/cos/src/writer.rs index 786dcd968..3db0566ff 100644 --- a/crates/cos/src/writer.rs +++ b/crates/cos/src/writer.rs @@ -49,8 +49,10 @@ fn write_real(r: f64, out: &mut Vec) { out.push(b'0'); return; } - if r.fract() == 0.0 && r.abs() < 1e15 { - let _ = write!(out, "{}", r as i64); + // A whole number is written as an integer only within the integer range Annex C gives + // (±2³¹−1), so `4e9` or `1e30` stays a real token for readers with 32-bit integers. + if r.fract() == 0.0 && r.abs() <= f64::from(i32::MAX) { + let _ = write!(out, "{}", r as i32); return; } // Plain decimal notation (§7.3.3 has no exponents). Rust's `Display` for f64 prints the @@ -58,6 +60,10 @@ fn write_real(r: f64, out: &mut Vec) { // round-trip exactly (rounding to a fixed number of places visibly shifted shading colours). let s = format!("{r}"); out.extend_from_slice(if s == "-0" { b"0" } else { s.as_bytes() }); + // A whole number past that range prints without a point; the trailing `.` keeps it a real. + if !s.contains('.') && s != "-0" { + out.push(b'.'); + } } fn write_name(n: &[u8], out: &mut Vec) { @@ -697,14 +703,38 @@ mod tests { } } + #[test] + fn whole_reals_past_the_integer_limit_stay_reals() { + for (r, text) in [ + (2_147_483_647.0, "2147483647"), + (-2_147_483_647.0, "-2147483647"), + (2_147_483_648.0, "2147483648."), + (4e9, "4000000000."), + (-4e9, "-4000000000."), + (1e30, "1000000000000000000000000000000."), + (595.0, "595"), + (-0.0, "0"), + ] { + let mut out = Vec::new(); + serialize(&Object::Real(r), &mut out); + assert_eq!(String::from_utf8_lossy(&out), text, "{r}"); + let back = roundtrip(&Object::Real(r)); + if r.abs() > f64::from(i32::MAX) { + assert_eq!(back, Object::Real(r), "{r} must read back as a real"); + } + } + } + proptest! { #[test] fn any_finite_real_round_trips(r in proptest::num::f64::NORMAL | proptest::num::f64::SUBNORMAL | proptest::num::f64::ZERO) { - prop_assume!(r.abs() < 1e15 || r.fract() == 0.0); let back = roundtrip(&Object::Real(r)); match back { Object::Real(b) => prop_assert_eq!(b, r), - Object::Int(i) => prop_assert_eq!(i as f64, r), + Object::Int(i) => { + prop_assert!(i.unsigned_abs() <= i32::MAX as u64, "{r} came back as integer {i}"); + prop_assert_eq!(i as f64, r); + } other => prop_assert!(false, "unexpected {other:?}"), } } diff --git a/crates/cos/tests/proptests.rs b/crates/cos/tests/proptests.rs new file mode 100644 index 000000000..4380dd0bd --- /dev/null +++ b/crates/cos/tests/proptests.rs @@ -0,0 +1,464 @@ +//! Property tests for `pdfcraft-cos` save fidelity — proptest coverage of two areas raised in +//! #1187. **This does not close #1187.** That issue asks for five Hegel properties; this adds +//! save/reopen and edit-history coverage using `proptest`, which is already a dev-dependency of this +//! crate (`Cargo.toml:20`), so no new dependency is needed to review it. Left for follow-ups: +//! #1187's reference-following comparison, its snapshot/undo state machine, the password property (the +//! one whose pilot found #1185) and its damaged-file byte splices. The object-level serialise → parse +//! round trip it also lists already exists inline in `src/writer.rs`. +//! +//! Covered: +//! +//! 1. **A full save keeps every value it wrote into the catalog**, in both output styles (objects +//! packed into object streams, §7.5.7; the classic cross-reference table otherwise), keeps the +//! catalog's own structure intact (`/Type /Catalog`, and a `/Pages` node whose `/Kids` array and +//! `/Count` agree — the fixture's tree is empty, so this pins shape and agreement, not rendered +//! pages), preserves dictionary +//! key order (which `README.md:75` promises is byte-stable), emits spec-shaped §7.5.4 cross-reference +//! rows, and reopens with nothing to repair. +//! 2. **An incremental save is a strict append**: the original bytes come out untouched at the front +//! (§7.5.6), the file keeps two revisions whose first still points at the base cross-reference and +//! opens standalone, the edit is visible while every existing value stays put in order, and it +//! reopens with nothing to repair. +//! 3. **A write/delete sequence equals a plain map**, checked after every step and again once saved and +//! reopened. A deleted key must stay gone — not return its old value, not read as null either. +//! Deletes are generated against keys that were just written, so the delete path cannot be skipped. +//! +//! ## Scope, stated honestly +//! Generated values are pure data — no streams and no indirect references — so this says nothing about +//! stream payloads or the renumbering a full save legitimately performs. Strings are compared as bytes, +//! so literal ↔ hexadecimal re-emission is not treated as a change (§7.3.6); the *choice* of spelling +//! is therefore not asserted, and `PdfString.hex` is not pinned here. Dictionary keys at the catalog +//! level *are* compared in order, so reversing them fails; nested dictionary order inside a value is +//! sorted away by [`canon`] and is not pinned. Generated reals stay within ±1e6: `write_real` +//! (`src/writer.rs:46`) turns a non-finite real into `0` and an integral real below 1e15 into integer +//! syntax, so [`canon`] applies the writer's documented normalisation to **both** sides rather than +//! forgiving a mismatch afterwards. + +use std::sync::Arc; + +use proptest::prelude::*; +use proptest::test_runner::TestCaseError; + +use pdfcraft_cos::{Dict, Document, Object, PdfString, SaveOptions, write_full, write_incremental}; + +/// The prefix every generated key carries. `Document::new_empty` puts `/Type` and `/Pages` in the +/// catalog; property 1 asserts those two structurally rather than ignoring them. +const KEY: &str = "px"; + +/// Generated keys go into the catalog in this deliberately unsorted sequence. Ascending insertion would make +/// "preserves dictionary key order" and "every dictionary sorted by key" the same observable, so a writer +/// that silently sorted would rewrite every saved byte and stay green. This scramble catches sorting of the +/// keys it names; it does NOT on its own pin where structural entries like `/Type` sit, because `saved` +/// filters to the `px` prefix - that gap is closed separately by [`catalog_keys`] asserting the full +/// sequence. Nested dictionary order is deliberately not pinned (see `canon`). Every permutation of the +/// generated keys is NOT drawn here either: one fixed order per case, chosen to make sorting detectable. +const INSERTION_ORDER: [usize; 8] = [5, 0, 7, 2, 6, 1, 4, 3]; + +/// A value reduced to a comparable spelling, with the writer's documented normalisations applied. +fn canon(o: &Object) -> String { + match o { + Object::Null => "null".into(), + Object::Bool(b) => format!("b{b}"), + Object::Int(i) => format!("i{i}"), + Object::Real(r) => { + if !r.is_finite() { + "i0".into() + } else if r.fract() == 0.0 && r.abs() < 1e15 { + format!("i{}", *r as i64) + } else { + format!("r{r}") + } + } + Object::Name(n) => format!("n{}", String::from_utf8_lossy(n)), + // Bytes, because a literal may be re-emitted as hexadecimal or the reverse; both spellings + // denote the same bytes (§7.3.6). The `hex` flag itself is deliberately not compared — see + // the scope note at the top. + Object::String(s) => format!("s{}", s.bytes.iter().map(|b| format!("{b:02x}")).collect::()), + Object::Array(a) => format!("[{}]", a.iter().map(canon).collect::>().join(",")), + Object::Dict(d) => { + // Sorted inside a value: nested key order is not pinned here. Catalog-level order is, and + // it is asserted in iteration order by `saved`. + let mut pairs: Vec<(String, String)> = d.iter().map(|(k, v)| (String::from_utf8_lossy(k).into_owned(), canon(v))).collect(); + pairs.sort(); + format!("{{{}}}", pairs.iter().map(|(k, v)| format!("{k}=>{v}")).collect::>().join(",")) + } + // Unreachable under the generators below, which build no streams and no references. Kept total + // so this compiles if someone widens them — at which point it should compare decoded bytes and + // resolved targets instead of a constant. + Object::Stream(_) => "stream".into(), + Object::Ref(r) => format!("ref{r}"), + } +} + +fn leaf() -> impl Strategy { + prop_oneof![ + Just(Object::Null), + any::().prop_map(Object::Bool), + (-1_000_000i64..1_000_000i64).prop_map(Object::Int), + (-1e6f64..1e6f64).prop_map(Object::Real), + // Non-finite reals, drawn explicitly. `write_real` (`src/writer.rs:46`) replaces them with + // `0`, and [`canon`] spells both sides that way, so this is what makes the normalisation + // described in the module header actually exercised rather than merely asserted in prose. + prop::sample::select(vec![f64::NAN, f64::INFINITY, f64::NEG_INFINITY]).prop_map(Object::Real), + // Raw bytes, including ones a literal string must escape and the NUL byte. + prop::collection::vec(any::(), 0..12).prop_map(|b| Object::String(PdfString::literal(b))), + "[A-Za-z][A-Za-z0-9]{0,6}".prop_map(|s| Object::name(&s)), + ] +} + +/// Nested values to a bounded depth, so generation cannot recurse without limit. +fn value(depth: usize) -> BoxedStrategy { + if depth == 0 { + return leaf().boxed(); + } + prop_oneof![ + leaf().boxed(), + prop::collection::vec(value(depth - 1), 0..5).prop_map(Object::Array).boxed(), + prop::collection::btree_map("[a-z]{1,4}", value(depth - 1), 0..5) + .prop_map(|m| { + let mut d = Dict::new(); + for (k, v) in m { + d.set(k.into_bytes(), v); + } + Object::Dict(d) + }) + .boxed(), + ] + .boxed() +} + +fn find(hay: &[u8], needle: &[u8]) -> Option { + hay.windows(needle.len()).position(|w| w == needle) +} + +/// Every catalog key, in the dictionary's own order - structural entries included. `saved` filters to +/// `px*`, which is why sorting of generated keys is visible there but `/Type` moving to the end was not; +/// this covers that. Read from memory before a save and from the parsed file afterwards, so the two sides +/// share only `Dict` iteration, not the serialiser under test. +fn catalog_keys(doc: &Document) -> Vec { + let root = match doc.root() { + Some(r) => r, + None => return Vec::new(), + }; + match doc.get(root).as_dict() { + Some(d) => d.iter().map(|(k, _)| String::from_utf8_lossy(k).into_owned()).collect(), + None => Vec::new(), + } +} + +/// The catalog's `px*` entries in **iteration order**, so a reordered save fails. +fn saved(doc: &Document) -> Result, TestCaseError> { + let root = doc.root().ok_or_else(|| TestCaseError::fail("no catalog in the saved file"))?; + let cat = doc.get(root); + let d = match cat.as_dict() { + Some(d) => d, + None => return Err(TestCaseError::fail("catalog is not a dictionary")), + }; + Ok(d.iter().filter(|(k, _)| k.starts_with(KEY.as_bytes())).map(|(k, v)| (String::from_utf8_lossy(k).into_owned(), canon(v))).collect()) +} + +/// The catalog's structure, which a reader needs whether or not it holds our test keys. +fn assert_catalog_structure(doc: &Document, style: &str) -> Result<(), TestCaseError> { + let root = doc.root().ok_or_else(|| TestCaseError::fail(format!("no catalog after the {style} save")))?; + let cat = doc.get(root); + let d = match cat.as_dict() { + Some(d) => d, + None => return Err(TestCaseError::fail(format!("catalog is not a dictionary after the {style} save"))), + }; + prop_assert_eq!(d.name(b"Type"), Some(&b"Catalog"[..]), "catalog /Type changed on the {} save", style); + let pages = match d.get(b"Pages") { + Some(p) => p, + None => return Err(TestCaseError::fail(format!("catalog lost /Pages on the {style} save"))), + }; + let resolved = doc.resolve(pages); + let pd = match resolved.as_dict() { + Some(d) => d, + None => return Err(TestCaseError::fail(format!("/Pages does not resolve to a dictionary on the {style} save"))), + }; + prop_assert_eq!(pd.name(b"Type"), Some(&b"Pages"[..]), "/Pages /Type changed on the {} save", style); + let kids = match pd.get(b"Kids").and_then(Object::as_array) { + Some(k) => k, + None => return Err(TestCaseError::fail(format!("/Pages /Kids is missing or not an array on the {style} save"))), + }; + // /Count has to agree with /Kids. A page tree whose count lies still opens, then misnumbers every + // page downstream, so checking presence alone would not be enough. This fixture's tree is empty, so + // what is pinned here is shape and agreement — not the existence of rendered pages. + prop_assert_eq!(pd.int(b"Count"), Some(kids.len() as i64), "/Pages /Count disagrees with its {} kids on the {} save", kids.len(), style); + Ok(()) +} + +/// §7.5.4: every entry of a classic cross-reference table is exactly 20 bytes, closed by a two-byte +/// end of line. Unpadded offsets still open in lenient readers and corrupt strict ones, so the byte +/// shape is asserted rather than inferred from a successful reopen. +fn assert_classic_xref_rows(bytes: &[u8]) -> Result<(), TestCaseError> { + // Anchored on the file's own startxref (§7.5.3) rather than the first "xref" text in the buffer: + // a generated document name could otherwise point the scan at prose. A startxref that does not land + // on the `xref` keyword means a cross-reference stream, which has no table to check here. + let Some(xoff) = startxref(bytes) else { return Err(TestCaseError::fail("no readable startxref in a classic-table save")) }; + let x = xoff as usize; + if bytes.get(x..x + 4) != Some(&b"xref"[..]) { + return Ok(()); + } + let nl = match bytes[x..].iter().position(|b| *b == b'\n') { + Some(n) => n, + None => return Err(TestCaseError::fail("xref keyword not followed by a newline")), + }; + let rest = &bytes[x + nl + 1..]; + let end = match find(rest, b"trailer") { + Some(e) => e, + None => return Err(TestCaseError::fail("cross-reference table with no trailer")), + }; + let mut rows = 0usize; + let mut expected_rows = 0usize; + for line in rest[..end].split(|b| *b == b'\n') { + if line.is_empty() { + continue; + } + // A CR before the line feed belongs to the two-byte terminator (§7.5.4), not to the keyword: + // stripped here so a CR+LF row is still recognised as a legal row rather than reported (wrongly) + // as "no cross-reference rows found". Producers do emit CR+LF terminators. + // + // What catches what: classification keys off the keyword, so a row whose keyword is garbage never + // reaches the width and length asserts below. Those rows are named by the `else` arm at the end + // of this loop; the row-count compare after it is only a backstop. The length assert is not the + // safety net for malformed keywords, and this comment used to imply that it was. + let fields: Vec<&[u8]> = line + .split(|b| *b == b' ') + .filter(|f| !f.is_empty()) + .map(|f| match f.last() { + Some(b'\r') => &f[..f.len() - 1], + _ => f, + }) + .collect(); + if fields.len() == 2 && fields.iter().all(|f| f.iter().all(u8::is_ascii_digit)) { + // ` ` subsection header. + expected_rows += std::str::from_utf8(fields[1]).ok().and_then(|s| s.parse::().ok()).unwrap_or(0); + continue; + } + if fields.len() == 3 && matches!(fields[2], b"n" | b"f") { + // Classified by keyword, not by width: an unpadded row must still be recognised as a row, so + // the failure names the real crime (wrong width) instead of the misleading "no rows found". + // Both keywords are legal — `n` in use, `f` free (the head of the free chain carries + // generation 65535). §7.5.4 fixes each field's width and the whole entry at 20 bytes. + prop_assert_eq!( + fields[0].len(), + 10, + "a cross-reference offset must be 10 digits (§7.5.4), got {:?} in row {:?}", + String::from_utf8_lossy(fields[0]), + String::from_utf8_lossy(line) + ); + prop_assert_eq!( + fields[1].len(), + 5, + "a cross-reference generation must be 5 digits (§7.5.4), got {:?} in row {:?}", + String::from_utf8_lossy(fields[1]), + String::from_utf8_lossy(line) + ); + // Exactly 20 bytes per entry in the file (§7.5.4). Both legal terminators land on 19 bytes + // for the line as split here: SP+LF, which is what this writer emits today, and CR+LF, whose + // terminator is the pair itself. A 21-byte entry (SP+CRLF, or a doubled CR) fails here. + prop_assert_eq!( + line.len(), + 19, + "a cross-reference row is {} bytes; §7.5.4 wants exactly 20 in the file (SP+LF or CR+LF): {:?}", + line.len(), + String::from_utf8_lossy(line) + ); + rows += 1; + } else { + // Inside the table region, a line is either a subsection header or an entry. Anything else is + // named here with its bytes quoted, instead of surfacing later as a bare row-count mismatch + // ("no cross-reference rows found") that blames the table for one bad line. + prop_assert_eq!( + fields.len(), + 3, + "unrecognised line in the cross-reference table, want ` n|f`: {:?}", + String::from_utf8_lossy(line) + ); + prop_assert!( + matches!(fields[2], b"n" | b"f"), + "illegal cross-reference keyword {:?} in row {:?}", + String::from_utf8_lossy(fields[2]), + String::from_utf8_lossy(line) + ); + } + } + prop_assert!(rows > 0, "no cross-reference rows found in a classic-table save"); + prop_assert_eq!(rows, expected_rows, "the subsection headers promised {} rows, the table holds {}", expected_rows, rows); + Ok(()) +} + +/// The `startxref` value of a file, which the next incremental update must point back at via `/Prev`. +/// The keyword is followed by whitespace, then the offset (§7.5.3), so leading whitespace is skipped. +fn startxref(bytes: &[u8]) -> Option { + let at = find(bytes, b"startxref")?; + let tail = &bytes[at + 9..]; + let digits: String = tail.iter().skip_while(|b| b.is_ascii_whitespace()).take_while(|b| b.is_ascii_digit()).map(|b| char::from(*b)).collect(); + digits.parse().ok() +} + +proptest! { + // 256 cases per property; the suite runs in well under a second, so coverage is free here. + #![proptest_config(ProptestConfig::with_cases(256))] + + /// 1. A full save keeps the values it wrote, their order, the catalog's structure and the shape of + /// its cross-reference table — in both output styles — and reopens with nothing to repair. + #[test] + fn full_save_keeps_the_catalog_intact(map in prop::collection::btree_map(0usize..8, value(3), 1..6)) { + let mut doc = Document::new_empty(); + let root = doc.root().expect("an empty document has a catalog"); + doc.update_dict(root, |d| { + for i in INSERTION_ORDER.into_iter().filter(|&i| map.contains_key(&i)) { + d.set(format!("{KEY}{i}").into_bytes(), map[&i].clone()); + } + }) + .map_err(|e| TestCaseError::fail(format!("cannot write the generated catalog: {e}")))?; + // Insertion order is this scrambled sequence filtered to the drawn keys: what must come back. + let expected: Vec<(String, String)> = INSERTION_ORDER.into_iter().filter(|&i| map.contains_key(&i)).map(|i| (format!("{KEY}{i}"), canon(&map[&i]))).collect(); + + // Full key sequence taken from memory BEFORE any byte is written - /Type and /Pages included. + let order_before = catalog_keys(&doc); + + // Right before any save, or the later assertion would prove nothing about saving. + prop_assert_eq!(saved(&doc)?, expected.clone(), "the in-memory catalog is wrong before saving"); + + for object_streams in [true, false] { + let style: &str = if object_streams { "object-stream" } else { "classic-table" }; + let opts = SaveOptions { object_streams, ..SaveOptions::default() }; + let bytes = write_full(&doc, &opts).map_err(|e| TestCaseError::fail(format!("full save failed: {e}")))?; + + if !object_streams { + assert_classic_xref_rows(&bytes).map_err(|e| TestCaseError::fail(format!("{style}: {e}")))?; + } + + let again = Document::open(Arc::new(bytes)).map_err(|e| TestCaseError::fail(format!("our own full save must reopen: {e}")))?; + + prop_assert!(again.repair_log().is_empty(), "a file we wrote must not need repair ({}); log: {:?}", style, again.repair_log()); + // Values *and* order: README.md promises dictionaries keep their key order. + prop_assert_eq!(saved(&again)?, expected.clone(), "the catalog changed on a {} save (values or order)", style); + // Structural position too: /Type and /Pages must not drift to the end or swap places. + prop_assert_eq!(catalog_keys(&again), order_before.clone(), "the catalog's full key sequence changed on a {} save", style); + assert_catalog_structure(&again, style)?; + } + } + + /// 2. An incremental save leaves the original bytes untouched, chains back through `/Prev`, and + /// reopens carrying every intended value plus the edit. The base is built **both** ways - + /// cross-reference stream and classic table - because the two are serialised by different code, and a + /// path that never runs cannot fail here. Expected contents are computed from the values we put in, + /// not read back out of the writer: comparing against our own earlier output would pass a writer that + /// is consistently wrong, which is the one thing a fidelity test must not do. + #[test] + fn incremental_save_appends_without_touching_the_original(map in prop::collection::btree_map(0usize..8, value(2), 1..5), marker in -1_000_000i64..1_000_000i64) { + let mut doc = Document::new_empty(); + let root = doc.root().expect("an empty document has a catalog"); + doc.update_dict(root, |d| { + for i in INSERTION_ORDER.into_iter().filter(|&i| map.contains_key(&i)) { + d.set(format!("{KEY}{i}").into_bytes(), map[&i].clone()); + } + }) + .map_err(|e| TestCaseError::fail(format!("cannot write the generated catalog: {e}")))?; + + // Our own record of what the catalog should hold, derived from the inputs before any byte is + // written. The edit key joins it below; order follows INSERTION_ORDER, not sorted order (§7.5.4). + let intended: Vec<(String, String)> = INSERTION_ORDER.into_iter().filter(|&i| map.contains_key(&i)).map(|i| (format!("{KEY}{i}"), canon(&map[&i]))).collect(); + + for object_streams in [true, false] { + let style: &str = if object_streams { "object-stream" } else { "classic-table" }; + let opts = SaveOptions { object_streams, ..SaveOptions::default() }; + + let base = write_full(&doc, &opts).map_err(|e| TestCaseError::fail(format!("{}: full save failed: {e}", style)))?; + let base_xref = startxref(&base).ok_or_else(|| TestCaseError::fail(format!("{}: the base file has no readable startxref", style)))?; + + let mut again = Document::open(Arc::new(base.clone())).map_err(|e| TestCaseError::fail(format!("{}: the saved file must reopen: {e}", style)))?; + // Independent of the writer's own readings: the reopen must show what we intended to write. + prop_assert_eq!(saved(&again)?, intended.clone(), "{}: the base catalog differs from what we put in", style); + + let root2 = again.root().expect("the reopened document has a catalog"); + again.update_dict(root2, |d| d.set(b"pxMark".to_vec(), Object::Int(marker))).map_err(|e| TestCaseError::fail(format!("{}: cannot apply the incremental edit: {e}", style)))?; + + let bytes = write_incremental(&again, &opts).map_err(|e| TestCaseError::fail(format!("{}: incremental save failed: {e}", style)))?; + prop_assert!(bytes.len() > base.len(), "{}: an incremental save must add bytes ({} over a base of {})", style, bytes.len(), base.len()); + prop_assert_eq!(bytes.get(..base.len()), Some(&base[..]), "{}: an incremental save must not rewrite the original bytes", style); + + let out = Document::open(Arc::new(bytes.clone())).map_err(|e| TestCaseError::fail(format!("{}: the incremental file must reopen: {e}", style)))?; + prop_assert!(out.repair_log().is_empty(), "{}: an increment we wrote must not need repair; log: {:?}", style, out.repair_log()); + + // The revision chain in the crate's own terms (Revision { xref_offset, is_stream }): two + // revisions, the first still pointing at the base cross-reference. Without that link no other + // reader can reach the earlier revision's objects, which is the classic incremental corruption. + // /Prev sits in the trailer for a table base and inside the cross-reference stream for a + // stream base, so it is not read here; `revisions` covers both (§7.5.3-7.5.6). + let revs = out.revisions(); + prop_assert_eq!(revs.len(), 2, "{}: an incremental save must leave two revisions: {:?}", style, revs); + prop_assert_eq!(revs[0].xref_offset, base_xref, "{}: the first revision must still point at the base xref {}", style, base_xref); + prop_assert!(revs[1].xref_offset > base_xref, "{}: the second revision must sit after the first: {:?}", style, revs); + let ends = out.revision_ends(); + prop_assert_eq!(ends.last().copied(), Some(bytes.len()), "{}: the last revision must run to the end of the file: {:?}", style, ends); + + // The base revision opens on its own. "Nothing was rewritten" buys the user this: cut the file + // at the append point and the earlier document still reads, with our intended values. + if let Some(end0) = ends.first().copied() { + let before_doc = Document::open(Arc::new(bytes[..end0].to_vec())).map_err(|e| TestCaseError::fail(format!("{}: the base revision must open alone: {e}", style)))?; + prop_assert_eq!(saved(&before_doc)?, intended.clone(), "{}: the base revision lost or changed values when opened without the increment", style); + } + + assert_catalog_structure(&out, style)?; + + let mut expected = intended.clone(); + expected.push(("pxMark".into(), canon(&Object::Int(marker)))); + prop_assert_eq!(saved(&out)?, expected.clone(), "{}: the increment lost a value, reordered the catalog, or dropped the edit", style); + } + } + + /// 3. Writes then deletes against a plain ordered model, checked after every step and again after + /// save and reopen. Deletes always target a key that exists, so the delete path is never empty. + #[test] + fn edit_sequence_equals_a_plain_map(plan in prop::collection::btree_map(0usize..8, value(2), 1..6).prop_flat_map(|sets| { + let keys: Vec = sets.keys().copied().collect(); + (Just(sets), prop::collection::vec(0usize..keys.len(), 1..=keys.len())).prop_map(move |(s, picks)| { + let mut dels: Vec = picks.into_iter().map(|i| keys[i]).collect(); + // Deduplicated: the same key drawn twice would make the second delete target a key that is + // already gone, which is legal PDF but not what this property means to assert. `picks` draws + // 1..=keys.len() over a non-empty key set, so `dels` cannot come back empty — the guarantee + // lives in the generator, not in an assume that could never fire. + dels.sort_unstable(); + dels.dedup(); + (s, dels) + }) + })) { + let (sets, deletes) = plan; + let mut doc = Document::new_empty(); + let root = doc.root().expect("an empty document has a catalog"); + + let mut model: Vec<(String, String)> = Vec::new(); + for (i, o) in &sets { + let key = format!("{KEY}{i}"); + doc.update_dict(root, |d| d.set(key.clone().into_bytes(), o.clone())).map_err(|e| TestCaseError::fail(format!("cannot write {key}: {e}")))?; + model.push((key.clone(), canon(o))); + prop_assert_eq!(saved(&doc)?, model.clone(), "after writing {} the catalog is wrong before saving", key); + } + + for i in &deletes { + let key = format!("{KEY}{i}"); + prop_assert!(model.iter().any(|(k, _)| k == &key), "the generator tried to delete a key that was never written: {}", key); + doc.update_dict(root, |d| { + d.remove(key.as_bytes()); + }) + .map_err(|e| TestCaseError::fail(format!("cannot delete {key}: {e}")))?; + model.retain(|(k, _)| k != &key); + prop_assert_eq!(saved(&doc)?, model.clone(), "after deleting {} it is still reachable", key); + } + + let bytes = write_full(&doc, &SaveOptions::default()).map_err(|e| TestCaseError::fail(format!("full save failed: {e}")))?; + let again = Document::open(Arc::new(bytes)).map_err(|e| TestCaseError::fail(format!("our own save must reopen: {e}")))?; + let got = saved(&again)?; + prop_assert_eq!(got.clone(), model.clone(), "the reopened catalog differs from the edit history (values or order)"); + for i in 0..8usize { + let key = format!("{KEY}{i}"); + if !model.iter().any(|(k, _)| k == &key) { + prop_assert!(!got.iter().any(|(g, _)| *g == key), "a deleted key came back after save+reopen: {}", key); + } + } + } +} diff --git a/crates/crypt/tests/qpdf_literals.rs b/crates/crypt/tests/qpdf_literals.rs new file mode 100644 index 000000000..8957063ac --- /dev/null +++ b/crates/crypt/tests/qpdf_literals.rs @@ -0,0 +1,326 @@ +//! Encryption numbers from an **outside implementation**. Six of the seven rows below are qpdf's own, +//! written by qpdf core 12.2.0 through pikepdf 9.11, and their `/O`, `/U`, `/OE`, `/UE`, `/Perms` +//! and `/ID` values were copied out of those files rather than computed here. The seventh row, +//! `crafted-r3-40bit-hand-derived`, is ours and says so where it is defined - no producer we can run +//! here writes revision 3 at 40 bits (qpdf core 12.2.0 emits `/Length 128` at `R=3`, measured below), +//! so nothing outside computed that one either. +//! +//! That distinction is the whole point. `create()` and `open()` share one derivation, so a wrong-but +//! self-consistent implementation round-trips cleanly against itself: across two review rounds we +//! broke the key derivation several ways and the unit suite stayed green (deleting Algorithm 2 step f +//! leaves all 17 unit tests passing while our rows fail). Quoting an outsider's bytes +//! breaks the circle - `open()` recomputes `/U` (or the revision 6 hash chain) and compares it to what +//! was stored, so a derivation that diverges cannot authenticate at all. +//! +//! Revisions covered: 2 (export-grade RC4, where the five-byte key makes a truncation error visible that +//! every pre-existing R3/R4 vector in this repository hides as a no-op), 3, and 4 and 6 each with metadata +//! encrypted *and* plain. For revision 4 that pairing is the only outside view of Algorithm 2 step +//! f's four `FF` bytes; revision 6 shows the same flag differently - its `/U` differs between the two +//! metadata variants, which is measured here rather than attributed to a named algorithm step. Only the +//! numbers are committed - never the PDFs, and no generator script that lives outside +//! this repository. To re-derive the six qpdf rows, take one blank 200x200 page and save it with +//! pikepdf 9.11 (qpdf core 12.2.0) using `Encryption(owner=OWNER, user=USER,` +//! `allow=Permissions(extract=False), R=, aes=…, metadata=…)` plus `save(..., static_id=True)`, +//! then read `/Encrypt` back. Below revision 4 both flags are forced rather than chosen, because pikepdf +//! refuses AES and refuses encrypted metadata there - so those two rows need `aes=False, metadata=False`, +//! while revisions 4 and 6 take `aes=True` with the case's own metadata flag. Run to run, every quoted +//! field of those rows reproduces at revisions 2-4, and whole files too for revisions 2 and 3; a +//! revision 4 file falls short of that by one stream, because `/CFM /AESV2` prepends a fresh random IV. +//! Revision 6 reproduces structurally only, its salts being new each run. What stays checked rather than +//! trusted - `open()` recovers the user password from `/O` and verifies `/U` at revisions 2-4 +//! (Algorithm 7), matches both stored hashes at revision 6 and unwraps `/OE`/`/UE` into the file key +//! there; this file then decrypts `/Perms` with that key, which authentication itself never reads. One +//! thing no assertion here verifies is the crypt-filter names: renaming a row's `/StmF` or replacing its +//! filter set leaves all three tests green, so those are held to review rather than to a test. For how +//! this repository treats qpdf as an oracle, see `crates/oracle/README.md`. + +use pdfcraft_crypt::{Auth, EncryptDict, Method, SecurityHandler, aes_cbc_decrypt}; + +const USER: &str = "open pw"; +const OWNER: &str = "owner pw"; + +/// One file qpdf wrote. Bytes are held as hex so the table stays a `const`: the literals on screen are +/// then exactly what qpdf emitted, with no runtime step in between to trust. +struct Case { + label: &'static str, + v: i64, + r: i64, + length_bits: i64, + p: i32, + encrypt_metadata: bool, + id0_hex: &'static str, + o_hex: &'static str, + u_hex: &'static str, + oe_hex: &'static str, + ue_hex: &'static str, + perms_hex: &'static str, + crypt_filters: fn() -> Vec<(Vec, Method)>, + stm_f: &'static str, + str_f: &'static str, + key_len_bytes: usize, +} + +impl Case { + fn dict(&self) -> EncryptDict { + EncryptDict { + filter: b"Standard".to_vec(), + v: self.v, + r: self.r, + length_bits: self.length_bits, + o: hex(self.o_hex), + u: hex(self.u_hex), + oe: hex(self.oe_hex), + ue: hex(self.ue_hex), + perms: hex(self.perms_hex), + p: self.p, + encrypt_metadata: self.encrypt_metadata, + crypt_filters: (self.crypt_filters)(), + stm_f: self.stm_f.as_bytes().to_vec(), + str_f: self.str_f.as_bytes().to_vec(), + ef_f: Vec::new(), + } + } + + fn id0(&self) -> Vec { + hex(self.id0_hex) + } +} + +fn hex(s: &str) -> Vec { + (0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect() +} + +fn no_filters() -> Vec<(Vec, Method)> { + Vec::new() +} + +fn aes128_filter() -> Vec<(Vec, Method)> { + vec![(b"StdCF".to_vec(), Method::Aes128)] +} + +fn aes256_filter() -> Vec<(Vec, Method)> { + vec![(b"StdCF".to_vec(), Method::Aes256)] +} + +/// `/ID[0]` is shared by every case because qpdf wrote them with `static_id`. +const CASES: &[Case] = &[ + Case { + label: "r2-40bit", + v: 1, + r: 2, + length_bits: 40, + p: -28, + encrypt_metadata: true, + id0_hex: "31415926535897932384626433832795", + o_hex: "166a894580aae60ae93fa9d83fc576f26f13dbe4574ce6c57497bec3ece7f125", + u_hex: "69ff2fb98261fd094174f93f0388859f356080ca486a38ce2d5ac23b98f5f89c", + oe_hex: "", + ue_hex: "", + perms_hex: "", + crypt_filters: no_filters, + stm_f: "", + str_f: "", + key_len_bytes: 5, + }, + Case { + label: "r3-128rc4", + v: 2, + r: 3, + length_bits: 128, + p: -1044, + encrypt_metadata: true, + id0_hex: "31415926535897932384626433832795", + o_hex: "2910182a30a271d410d8dada035cbd77c0b57a683bd55cd1cd9eee35a2b64848", + u_hex: "1b4b8fb3fd213b4eadc142ab9f385fc80122456a91bae5134273a6db134c87c4", + oe_hex: "", + ue_hex: "", + perms_hex: "", + crypt_filters: no_filters, + stm_f: "", + str_f: "", + key_len_bytes: 16, + }, + Case { + label: "r4-aes128-meta-enc", + v: 4, + r: 4, + length_bits: 128, + p: -1044, + encrypt_metadata: true, + id0_hex: "31415926535897932384626433832795", + o_hex: "2910182a30a271d410d8dada035cbd77c0b57a683bd55cd1cd9eee35a2b64848", + u_hex: "1b4b8fb3fd213b4eadc142ab9f385fc80122456a91bae5134273a6db134c87c4", + oe_hex: "", + ue_hex: "", + perms_hex: "", + crypt_filters: aes128_filter, + stm_f: "StdCF", + str_f: "StdCF", + key_len_bytes: 16, + }, + Case { + label: "r4-aes128-meta-plain", + v: 4, + r: 4, + length_bits: 128, + p: -1044, + encrypt_metadata: false, + id0_hex: "31415926535897932384626433832795", + o_hex: "2910182a30a271d410d8dada035cbd77c0b57a683bd55cd1cd9eee35a2b64848", + u_hex: "d6d363fc817815701f0242a581cb39e60122456a91bae5134273a6db134c87c4", + oe_hex: "", + ue_hex: "", + perms_hex: "", + crypt_filters: aes128_filter, + stm_f: "StdCF", + str_f: "StdCF", + key_len_bytes: 16, + }, + Case { + label: "r6-aes256-meta-enc", + v: 5, + r: 6, + length_bits: 256, + p: -1044, + encrypt_metadata: true, + id0_hex: "31415926535897932384626433832795", + o_hex: "1c791d66227fdfd7c1d225eb81fb3408d8bcfac1a0ff71cc57b10eb29029e5f6965cccb6b94e461d9da354a9dbd6eea7", + u_hex: "64ffbf79168ed7b325267cdba8dae211d4c2c5ebdffd339c7ac36c33e26f23b7f478c60ae42d50b5d20ceac56d659151", + oe_hex: "d2f6ef0bcc6ce93228be760c7eee8f4259af7a6044a4c4e04a0d48ab0893ef1e", + ue_hex: "963035aa24fb009e6d26b3cffd31492ffe6f4e563e4dcae8d7b7e3203c4a2f56", + perms_hex: "5675a86560873d2532e856a37045247e", + crypt_filters: aes256_filter, + stm_f: "StdCF", + str_f: "StdCF", + key_len_bytes: 32, + }, + Case { + label: "r6-aes256-meta-plain", + v: 5, + r: 6, + length_bits: 256, + p: -1044, + encrypt_metadata: false, + id0_hex: "31415926535897932384626433832795", + o_hex: "e227e586c4dc5a362c7271d960565cf3a37145e33af5bf5a08c73b52a1d819e152c737176dc08774e400798d6534e95e", + u_hex: "dbb84b847bb2d80865cc3c5a7c751d4c659d2c96ed1750f8be6f9c3b93fe3d11f494b43e5f17a21c85f5a4b171055afb", + oe_hex: "8c31f2392bc2a093850269a3404cbe01cea38e5e0fe308fe8ac50f5222cff103", + ue_hex: "2b0c44e4bddb3c9c01431b4ed3c7da42dbd1ef47e610d626ec2124535a0a4469", + perms_hex: "adbb69fb9fffde31ba90aaaf5eb4bdaf", + crypt_filters: aes256_filter, + stm_f: "StdCF", + str_f: "StdCF", + key_len_bytes: 32, + }, + // HAND-DERIVED, not produced by qpdf: an independent re-implementation of ISO Algorithms 2/3 + // (n = 5) computed this pair, cross-validated by having the same routine reproduce qpdf's + // n = 16 `/O` byte for byte. It exists because no producer we can run here emits R3 at 40 bits - + // qpdf core 12.2.0 writes `/Length 128` there, and this crate's own create() maps Rc4_40 to revision 2 - so + // the only route we have to `key_len` = 5 with the derivation loop RUNNING (r >= 3) is to craft + // it by hand. With that combination live, the loop's truncation stops being a no-op: mutating + // `Md5::digest(&digest[..n])` to hash the whole digest makes this password stop authenticating. + Case { + label: "crafted-r3-40bit-hand-derived", + v: 2, + r: 3, + length_bits: 40, + p: -1044, + encrypt_metadata: true, + id0_hex: "31415926535897932384626433832795", + o_hex: "50e50251fd20acb28ba50013142d19a47249d06b977d28b63cb3ce9d56fb5d50", + u_hex: "000756f24fe648256830aec6155ca6f100000000000000000000000000000000", + oe_hex: "", + ue_hex: "", + perms_hex: "", + crypt_filters: no_filters, + stm_f: "", + str_f: "", + key_len_bytes: 5, + }, +]; + +#[test] +fn outside_derived_dictionaries_authenticate_with_the_intended_role() { + for case in CASES { + let d = case.dict(); + let id0 = case.id0(); + + // The user password must open as a user. Owners are tried first, so an `Owner` here would mean + // the two passwords had collapsed onto one set of bytes - on these spellings they have not. + let user = SecurityHandler::open(d.clone(), &id0, Some(USER)) + .unwrap_or_else(|e| panic!("{}: qpdf's user password was refused by our reader: {e}", case.label)); + assert_eq!(user.auth(), Auth::User, "{}", case.label); + + // The owner password must open as owner: revisions 2-4 recover the user password from `/O` + // (Algorithm 7), revision 6 hashes against qpdf's own `/O`. + let owner = + SecurityHandler::open(d.clone(), &id0, Some(OWNER)).unwrap_or_else(|e| panic!("{}: qpdf's owner password was refused: {e}", case.label)); + assert_eq!(owner.auth(), Auth::Owner, "{}", case.label); + + // A wrong password may never open it; and the key must be exactly as wide as the revision + // promises - those five bytes are what makes a revision 2 truncation detectable at all. + assert!(SecurityHandler::open(d.clone(), &id0, Some("not the password")).is_err(), "{}: a wrong password opened", case.label); + assert_eq!(user.file_key().len(), case.key_len_bytes, "{}: file key width", case.label); + assert_eq!(owner.file_key(), user.file_key(), "{}: whichever password opens it, one key", case.label); + + // A user session must not claim owner rights. We deliberately do NOT assert individual + // permission flags here: which bit means "extract" moves between revision 2 and revisions 4 + // (qpdf wrote `/P = -28` for the export-grade file, where our accessor's bit-10 reading of + // "extract for accessibility" is a reserved field), and pinning that mapping belongs with the + // permissions tests, not with this oracle. What matters here is only that authentication, role + // and key width agree with what an outside implementation produced. + assert!(!user.permissions().owner, "{}: a user session claimed owner rights", case.label); + } +} + +#[test] +fn metadata_flag_from_outside_changes_the_file_key_it_certifies() { + // `/EncryptMetadata` false folds four set bytes into a revision 4 key (Algorithm 2, step f) and + // changes what revision 6 stores in `/U`. qpdf wrote both variants of each, so an implementation + // that ignored the flag would give identical keys. Internally this is no longer untested - + // `tests/proptests.rs` from #2358 varies the flag and asserts the keys differ - but internal tests + // still share our derivation, so what these rows add is a second implementation's own numbers. + for (enc_label, plain_label) in [("r4-aes128-meta-enc", "r4-aes128-meta-plain"), ("r6-aes256-meta-enc", "r6-aes256-meta-plain")] { + let a = CASES.iter().find(|c| c.label == enc_label).expect("encrypted-metadata fixture present"); + let b = CASES.iter().find(|c| c.label == plain_label).expect("plain-metadata fixture present"); + + let ha = SecurityHandler::open(a.dict(), &a.id0(), Some(USER)).unwrap_or_else(|e| panic!("{enc_label}: refused by our reader: {e}")); + let hb = SecurityHandler::open(b.dict(), &b.id0(), Some(USER)) + .unwrap_or_else(|e| panic!("{plain_label}: refused by our reader - is step f being ignored? {e}")); + + assert_ne!(ha.file_key(), hb.file_key(), "{enc_label} and {plain_label} must not share a key: the metadata flag is hashed in"); + assert!(ha.encrypts_metadata(), "{enc_label} reports encrypted metadata"); + assert!(!hb.encrypts_metadata(), "{plain_label} reports plain metadata, as qpdf wrote it"); + } + + // The outsider's numbers must differ across revisions; equality here would mean a transcription + // error, which would quietly void every assertion above. + let r2 = CASES.iter().find(|c| c.r == 2).unwrap(); + let r3 = CASES.iter().find(|c| c.r == 3).unwrap(); + assert_ne!(hex(r2.u_hex), hex(r3.u_hex), "revision 2 and revision 3 /U differ because the key width differs"); + assert_eq!(r2.u_hex.len(), 64, "revision 2 /U is RC4 over the padding: 32 bytes"); + assert_eq!(r3.u_hex.len(), 64, "revision 3 /U is 32 bytes: sixteen derived, plus padding qpdf fills (our crafted row leaves it zero)"); +} + +#[test] +fn qpdf_perms_decode_to_the_shape_both_implementations_agree_on() { + // `/Perms` is the one value in this dictionary that no test here verified before: `cos` writes it + // out and reads it back, but nothing decrypts it. Under qpdf's own bytes we can - `/P` + // little-endian, four set bytes, `T`/`F`, the `adb` sentinel (Algorithm 10 here; vendored lopdf + // checks the same fields as its Algorithm 13, though only three bytes of `/P`). + let mut checked = 0; + for case in CASES.iter().filter(|c| !c.perms_hex.is_empty()) { + let h = SecurityHandler::open(case.dict(), &case.id0(), Some(USER)) + .unwrap_or_else(|e| panic!("{}: refused before /Perms could be read: {e}", case.label)); + let open = aes_cbc_decrypt(h.file_key(), &[0u8; 16], &hex(case.perms_hex), false); + + assert_eq!(open.len(), 16, "{}: /Perms is one encrypted block", case.label); + assert_eq!(&open[..4], &case.p.to_le_bytes(), "{}: /P must round trip against qpdf's bytes", case.label); + assert_eq!(&open[4..8], &[0xFF; 4], "{}: the four bytes after /P are set", case.label); + assert_eq!(open[8], if case.encrypt_metadata { b'T' } else { b'F' }, "{}: metadata byte as qpdf wrote it", case.label); + assert_eq!(&open[9..12], b"adb", "{}: sentinel from qpdf's bytes", case.label); + assert_ne!(&open[12..16], &[0u8; 4], "{}: qpdf's filler is not frozen at zero", case.label); + checked += 1; + } + assert!(checked >= 2, "expected /Perms on both revision 6 cases; saw {checked}"); +} diff --git a/crates/edit/src/background.rs b/crates/edit/src/background.rs new file mode 100644 index 000000000..d1d50e450 --- /dev/null +++ b/crates/edit/src/background.rs @@ -0,0 +1,201 @@ +//! A page's background colour: the colour the page is painted with before anything else, as +//! Edit a PDF ▸ Background and many producers do it, for exports that keep a page colour (#2992). + +use pdfcraft_content::{Matrix, Op, Pieces}; +use pdfcraft_cos::{Dict, Document}; + +use crate::{EditError, Rgb}; + +/// How deep `q` may nest while looking for the background (deeper saves are not tracked). +const MAX_SAVES: usize = 64; + +/// How many clipping rectangles are kept; past that the clip counts as unknown. +const MAX_CLIPS: usize = 64; + +/// A rectangle as it lands on the page: its four corners in page space, in order. Under a +/// rotating or shearing CTM it is a parallelogram, so its bounding box says too much (#2994). +type Quad = [(f64, f64); 4]; + +/// The colour space a fill colour is given in. +#[derive(Clone, Copy, PartialEq)] +enum Space { + Gray, + Rgb, + Cmyk, + /// Anything else (Separation, ICC, patterns…): no plain colour can be read from it. + Other, +} + +#[derive(Clone)] +struct State { + ctm: Matrix, + space: Space, + fill: Option, + /// Fill opacity (`ca`), and whether a soft mask or a blend mode makes the fill other than a + /// plain paint. + alpha: f64, + plain: bool, + /// The clip, as the rectangles it is the intersection of, in page space; `None` when a clip + /// that isn't one rectangle was set (its area can't be told). + clip: Option>, +} + +/// The page's background colour: the colour of the first thing its content paints, when that is +/// an opaque fill, in a gray, RGB or CMYK colour, of a path with a rectangle that covers the +/// whole visible page (and no clip keeps it from doing so). `None` for a page that paints anything else +/// first, or nothing. Forms and later content are not looked at. +pub fn page_background(doc: &Document, page: usize) -> Result, EditError> { + let pages = pdfcraft_model::pages(doc); + let p = pages.get(page).ok_or(EditError::NoSuchPage(page))?; + let crop = p.crop(doc); + let resources = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); + let gstates = resources.get(b"ExtGState").map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()).unwrap_or_default(); + let streams = crate::text::content_streams(doc, &p.dict); + let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); + let ops = pieces.parse().ops; + + let mut st = State { ctm: Matrix::IDENTITY, space: Space::Gray, fill: Some([0.0; 3]), alpha: 1.0, plain: true, clip: Some(Vec::new()) }; + let mut saved: Vec = Vec::new(); + let mut lost_saves = 0usize; + // The path being built: its rectangles in page space, and whether it has anything else. + // Each rectangle of the current path: its box on the page, and whether it winds + // counterclockwise there (its width and height and the CTM's determinant agree in sign). + let mut rects: Vec<(Quad, bool)> = Vec::new(); + let mut curved = false; + let mut clip_pending = false; + for op in &ops { + match op.op.as_slice() { + b"q" => { + if saved.len() < MAX_SAVES { + saved.push(st.clone()); + } else { + lost_saves = lost_saves.saturating_add(1); + } + } + b"Q" => { + if lost_saves > 0 { + lost_saves -= 1; + } else if let Some(s) = saved.pop() { + st = s; + } + } + b"cm" => { + if let Some(m) = Matrix::from_operands(&op.operands) { + st.ctm = m.then(&st.ctm); + } + } + b"g" => (st.space, st.fill) = (Space::Gray, op.nums::<1>().map(|[g]| [g; 3])), + b"rg" => (st.space, st.fill) = (Space::Rgb, op.nums::<3>()), + b"k" => (st.space, st.fill) = (Space::Cmyk, op.nums::<4>().map(cmyk)), + b"cs" => { + st.space = match op.name(0) { + Some(b"DeviceGray" | b"G") => Space::Gray, + Some(b"DeviceRGB" | b"RGB") => Space::Rgb, + Some(b"DeviceCMYK" | b"CMYK") => Space::Cmyk, + _ => Space::Other, + }; + st.fill = None; + } + b"sc" | b"scn" => st.fill = colour(st.space, op), + b"gs" => { + if let Some(g) = op.name(0).and_then(|n| gstates.get(n)).map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()) { + apply_gstate(doc, &g, &mut st); + } + } + b"re" => match op.nums::<4>() { + Some([x, y, w, h]) => { + let [a, b, c, d, _, _] = st.ctm.0; + rects.push((quad(&st.ctm, [x, y, x + w, y + h]), (w * h) * (a * d - b * c) > 0.0)); + } + None => curved = true, + }, + b"m" | b"l" | b"c" | b"v" | b"y" | b"h" => curved = true, + b"W" | b"W*" => clip_pending = true, + b"n" => { + if clip_pending { + st.clip = match (st.clip.take(), rects.as_slice(), curved) { + (Some(mut c), [(r, _)], false) if c.len() < MAX_CLIPS => { + c.push(*r); + Some(c) + } + _ => None, + }; + } + (rects, curved, clip_pending) = (Vec::new(), false, false); + } + b"f" | b"F" | b"f*" | b"B" | b"B*" => { + let opaque = st.plain && st.alpha >= 0.999; + // A second rectangle can cut a hole in the one that covers the page: under the + // even-odd rule any second one, under nonzero one wound the other way (a + // frame-style page border). + let even_odd = matches!(op.op.as_slice(), b"f*" | b"B*"); + let solid = match rects.as_slice() { + [_] => true, + [(_, first), rest @ ..] => !even_odd && rest.iter().all(|(_, ccw)| ccw == first), + [] => false, + }; + let covers = !curved + && solid + && st.clip.as_ref().is_some_and(|c| c.iter().all(|q| covers(q, &crop))) + && rects.iter().any(|(r, _)| covers(r, &crop)); + return Ok(st.fill.filter(|_| opaque && covers && !clip_pending).map(|c| c.map(|v| v.clamp(0.0, 1.0)))); + } + // Anything else painted first: no page background. + b"S" | b"s" | b"b" | b"b*" | b"Tj" | b"TJ" | b"'" | b"\"" | b"Do" | b"sh" | b"BI" => return Ok(None), + _ => {} + } + } + Ok(None) +} + +/// CMYK to RGB, as a plain conversion without colour management. +fn cmyk([c, m, y, k]: [f64; 4]) -> Rgb { + [(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)] +} + +/// The colour `sc`/`scn` sets in `space`, if it is a plain one. +fn colour(space: Space, op: &Op) -> Option { + match (space, op.operands.len()) { + (Space::Gray, 1) => op.nums::<1>().map(|[g]| [g; 3]), + (Space::Rgb, 3) => op.nums::<3>(), + (Space::Cmyk, 4) => op.nums::<4>().map(cmyk), + _ => None, + } +} + +/// The fill opacity, soft mask and blend mode an ExtGState sets. +fn apply_gstate(doc: &Document, g: &Dict, st: &mut State) { + if let Some(ca) = g.get(b"ca").and_then(|v| doc.resolve(v).as_f64()) { + st.alpha = if ca.is_finite() { ca } else { 0.0 }; + } + if let Some(mask) = g.get(b"SMask") { + st.plain &= doc.resolve(mask).as_name() == Some(b"None"); + } + if let Some(bm) = g.get(b"BM") { + let normal = |n: Option<&[u8]>| matches!(n, Some(b"Normal" | b"Compatible")); + st.plain &= match &*doc.resolve(bm) { + pdfcraft_cos::Object::Array(a) => a.first().is_some_and(|n| normal(doc.resolve(n).as_name())), + other => normal(other.as_name()), + }; + } +} + +/// The page-space corners of a user-space rectangle under `ctm`, in order around it. +fn quad(ctm: &Matrix, r: [f64; 4]) -> Quad { + [ctm.apply(r[0], r[1]), ctm.apply(r[2], r[1]), ctm.apply(r[2], r[3]), ctm.apply(r[0], r[3])] +} + +/// Whether `outer` covers `page`, give or take half a percent of its size: each corner of the +/// page, pulled in by that much, lies inside the parallelogram `outer`. +fn covers(outer: &Quad, page: &[f64; 4]) -> bool { + let (w, h) = ((page[2] - page[0]).abs(), (page[3] - page[1]).abs()); + let (tx, ty) = (w * 0.005, h * 0.005); + let (x0, y0, x1, y1) = (page[0].min(page[2]) + tx, page[1].min(page[3]) + ty, page[0].max(page[2]) - tx, page[1].max(page[3]) - ty); + let cross = |a: (f64, f64), b: (f64, f64), p: (f64, f64)| (b.0 - a.0) * (p.1 - a.1) - (b.1 - a.1) * (p.0 - a.0); + // Twice the signed area: its sign says which way the corners run. + let area: f64 = (0..4).map(|i| cross((0.0, 0.0), outer[i], outer[(i + 1) % 4])).sum(); + if !area.is_finite() || area.abs() < f64::EPSILON || outer.iter().any(|p| !(p.0.is_finite() && p.1.is_finite())) { + return false; + } + [(x0, y0), (x1, y0), (x1, y1), (x0, y1)].iter().all(|&p| (0..4).all(|i| cross(outer[i], outer[(i + 1) % 4], p) * area.signum() >= 0.0)) +} diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index babb835eb..d3db00fe3 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -979,6 +979,8 @@ pub fn marks_present(doc: &Document) -> Vec { added::scan_markers(doc).marks } +mod background; +pub use background::page_background; pub mod objects; pub use objects::{EditableObject, MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget, editable_objects, move_objects}; diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index bf44aa921..5b76060c5 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -6238,3 +6238,118 @@ fn actual_text_pairs_past_the_marked_content_cap() { assert_eq!(reading, [expected]); } } + +/// #2992: the colour a page is painted with before anything else is its background, for exports +/// that keep a page colour. Edit a PDF ▸ Background's fill is found under the text it goes +/// behind; a page that paints anything else first, a fill that doesn't cover the page, a +/// translucent or clipped one, or a colour that isn't plain gray, RGB or CMYK has none. +#[test] +fn a_page_painted_first_with_a_full_page_fill_has_that_background() { + let mut doc = fixture(); + assert_eq!(page_background(&doc, 0).unwrap(), None, "text first"); + add_background(&mut doc, &[0, 1], &Background { color: [0.0, 0.5, 0.5], opacity: 1.0, ..Background::default() }, false).unwrap(); + let doc = reopen(&doc); + let teal = page_background(&doc, 0).unwrap().expect("Background's fill"); + assert!(teal.iter().zip([0.0, 0.5, 0.5]).all(|(a, b)| (a - b).abs() < 1e-6), "{teal:?}"); + assert!(page_background(&doc, 1).unwrap().is_some(), "on a turned page too"); + assert_eq!(page_background(&doc, 2).unwrap(), None, "a page with no content"); + assert!(page_background(&doc, 9).is_err()); + + let page = |content: &str, resources: &str| { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /CropBox [0 0 500 700] /Contents 4 0 R /Resources << {resources} >> >>"), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(b"xref\n0 5\n0000000000 65535 f \n"); + for o in offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size 5 /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n").as_bytes()); + page_background(&Document::open(Arc::new(out)).unwrap(), 0).unwrap() + }; + let gs = "/ExtGState << /Half << /ca 0.5 >> /Mul << /BM /Multiply >> /Full << /ca 1 /SMask /None >> >>"; + assert_eq!(page("1 g 0 0 600 800 re f", ""), Some([1.0; 3]), "a producer's white page fill"); + assert_eq!(page("0 0 1 0 k 0 0 500 700 re f", ""), Some([1.0, 1.0, 0.0]), "CMYK, covering the crop box"); + assert_eq!(page("/DeviceRGB cs 1 0 0 sc 0 0 500 700 re f", ""), Some([1.0, 0.0, 0.0]), "cs and sc"); + assert_eq!(page("/Full gs q 2 0 0 2 0 0 cm 0 1 0 rg 0 0 300 400 re f Q", gs), Some([0.0, 1.0, 0.0]), "under a cm"); + assert_eq!(page("1 0 0 rg 0 0 300 800 re f", ""), None, "half the page"); + assert_eq!(page("1 0 0 rg 0 0 300 800 re 300 0 300 800 re f", ""), None, "two halves are not one covering rectangle"); + assert_eq!(page("1 0 0 rg 0 0 300 800 re 0 0 600 800 re f", ""), Some([1.0, 0.0, 0.0]), "one of the rectangles covers it"); + // #2994 review: a second rectangle that cuts a hole: any one under even-odd, one wound the + // other way under nonzero. + assert_eq!(page("1 0 0 rg 0 0 600 800 re 100 100 200 200 re f*", ""), None, "even-odd hole"); + assert_eq!(page("1 0 0 rg 0 0 600 800 re 300 100 -200 200 re f", ""), None, "nonzero, reversed inner rectangle"); + assert_eq!(page("1 0 0 rg 0 0 600 800 re 100 100 200 200 re f", ""), Some([1.0, 0.0, 0.0]), "nonzero, same winding: no hole"); + assert_eq!(page("1 0 0 rg q -1 0 0 1 600 0 cm 0 0 600 800 re Q 100 100 200 200 re f", ""), None, "reversed by a mirroring cm"); + assert_eq!(page("/Half gs 1 0 0 rg 0 0 600 800 re f", gs), None, "translucent"); + assert_eq!(page("/Mul gs 1 0 0 rg 0 0 600 800 re f", gs), None, "blended"); + assert_eq!(page("0 0 100 100 re W n 1 0 0 rg 0 0 600 800 re f", ""), None, "clipped to a corner"); + assert_eq!(page("0 0 600 800 re W n 1 0 0 rg 0 0 600 800 re f", ""), Some([1.0, 0.0, 0.0]), "a page-sized clip"); + assert_eq!(page("/Pattern cs /P1 scn 0 0 600 800 re f", ""), None, "a pattern"); + assert_eq!(page("BT ET 0 0 600 800 re S 1 0 0 rg 0 0 600 800 re f", ""), None, "a stroke first"); + assert_eq!(page("0 0 m 600 0 l 600 800 l 0 800 l h f", ""), None, "a path that isn't a rectangle"); + assert_eq!(page("1 0 0 rg 0 0 600 800 re 100 100 m 200 100 l 200 200 l h f*", ""), None, "a page fill with a hole cut in it"); + // #2994 review: a rectangle turned 45° about the crop box's centre covers it only if the crop + // box's corners are inside it; its bounding box covering the crop box isn't enough. This one + // reaches 500 from the centre along each axis (its box covers the 500 × 700 crop box) but + // 354 along the diagonals, short of the corners. One reaching 707 covers them. + let turned = "0.70710678 0.70710678 -0.70710678 0.70710678 250 350 cm"; + assert_eq!(page(&format!("1 0 0 rg {turned} -353.553 -353.553 707.107 707.107 re f"), ""), None, "a turned fill short of the corners"); + assert_eq!(page(&format!("1 0 0 rg {turned} -500 -500 1000 1000 re f"), ""), Some([1.0, 0.0, 0.0]), "a turned fill over them"); + // The same for a clip. + assert_eq!(page(&format!("{turned} -353.553 -353.553 707.107 707.107 re W n 1 0 0 rg -2000 -2000 4000 4000 re f"), ""), None, "a turned clip"); + assert_eq!( + page(&format!("{turned} -500 -500 1000 1000 re W n 1 0 0 rg -2000 -2000 4000 4000 re f"), ""), + Some([1.0, 0.0, 0.0]), + "a turned clip over the corners" + ); + // Clips add up: two that each cover the page leave it covered, one that doesn't among them + // doesn't. + assert_eq!(page("0 0 600 800 re W n 0 0 550 750 re W n 1 0 0 rg 0 0 600 800 re f", ""), Some([1.0, 0.0, 0.0]), "two covering clips"); + assert_eq!(page("0 0 600 800 re W n 0 0 300 750 re W n 1 0 0 rg 0 0 600 800 re f", ""), None, "the second clip doesn't cover"); + // Past 64 clipping rectangles the clip counts as unknown. + let clips = "0 0 600 800 re W n ".repeat(65); + assert_eq!(page(&format!("{clips}1 0 0 rg 0 0 600 800 re f"), ""), None, "too many clips to follow"); + // A rectangle drawn the other way round (negative width) covers the page all the same. + assert_eq!(page("1 0 0 rg 600 0 -600 800 re f", ""), Some([1.0, 0.0, 0.0]), "drawn clockwise"); + // The crop box counts as covered within half a percent of its size (2.5 × 3.5 here), the + // edge of that included. + assert_eq!(page("1 0 0 rg 2 3 496 694 re f", ""), Some([1.0, 0.0, 0.0]), "within the tolerance"); + assert_eq!(page("1 0 0 rg 2.5 3.5 495 693 re f", ""), Some([1.0, 0.0, 0.0]), "on the tolerance's edge"); + assert_eq!(page("1 0 0 rg 3 4 494 692 re f", ""), None, "past the tolerance"); +} + +#[test] +fn reading_keeps_dotted_leader_entries_whole_and_in_order() { + let mut content = String::new(); + for (title, page, y) in [("Art.1. Scope", "3", 700), ("Art.2. Eligibility", "7", 680), ("Art.3. Application", "12", 660)] { + content.push_str(&format!( + "BT /F1 12 Tf 72 {y} Td ({title}) Tj ET \ + BT /F1 12 Tf 250 {y} Td (....................) Tj ET \ + BT /F1 12 Tf 500 {y} Td ({page}) Tj ET\n" + )); + } + content.push_str("BT /F1 12 Tf 72 640 Td (Appendix) Tj ET BT /F1 12 Tf 250 640 Td (\\205\\205\\205) Tj ET BT /F1 12 Tf 500 640 Td (iv) Tj ET\n"); + content.push_str("BT /F1 12 Tf 72 600 Td (Ordinary body text.) Tj ET BT /F1 12 Tf 72 582 Td (Still the same paragraph.) Tj ET"); + let doc = text_page(&content); + let blocks: Vec<_> = text::reading_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect(); + assert_eq!( + blocks, + [ + "Art.1. Scope .................... 3", + "Art.2. Eligibility .................... 7", + "Art.3. Application .................... 12", + "Appendix ……… iv", + "Ordinary body text. Still the same paragraph.", + ] + ); +} diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 6c5986772..93a5eec99 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -615,7 +615,7 @@ struct Ts { unreadable: Option>, } -fn content_streams(doc: &Document, page: &Dict) -> Vec<(Object, Vec)> { +pub(crate) fn content_streams(doc: &Document, page: &Dict) -> Vec<(Object, Vec)> { let list: Vec = match page.get(b"Contents") { None => Vec::new(), Some(c) => match &*doc.resolve(c) { diff --git a/crates/edit/src/text_regions.rs b/crates/edit/src/text_regions.rs index a5fdca293..928280d5d 100644 --- a/crates/edit/src/text_regions.rs +++ b/crates/edit/src/text_regions.rs @@ -71,8 +71,62 @@ pub(super) fn group_blocks(lines: &[TextLine]) -> Vec { /// Exporters currently represent a block with one style. Keep their style boundaries until /// they support rich runs, while edit selection may encompass the complete visual region. +/// Dotted-leader entries stay on one row and use the title run's style. pub(super) fn group_reading_blocks(lines: &[TextLine]) -> Vec { - group_layout(lines, false) + let mut blocks = Vec::new(); + let mut start = 0; + let mut offset = 0; + for row in lines.chunk_by(|a, b| same_plane(a, b) && (a.origin.baseline - b.origin.baseline).abs() < a.size.min(b.size) * 0.2) { + let mut text = String::new(); + for (i, line) in row.iter().enumerate() { + if !text.ends_with(char::is_whitespace) + && !line.text.starts_with(char::is_whitespace) + && i.checked_sub(1).and_then(|i| row.get(i)).is_some_and(|prev| line.rect[0] - prev.rect[2] > line.size * 0.12) + { + text.push(' '); + } + text.push_str(&line.text); + } + if leader_entry(&text) + && let Some(first) = row.first() + { + // Tab leaders are a single reading row, even when the title, dots and page + // number are separate text objects far apart. Do not merge neighbouring entries. + blocks.extend(group_layout(lines.get(start..offset).unwrap_or_default(), false)); + let mut line = first.clone(); + line.text = text; + line.rect = row.iter().map(|line| line.rect).reduce(union).unwrap_or(first.rect); + blocks.extend(group_layout(std::slice::from_ref(&line), false)); + start = offset + row.len(); + } + offset += row.len(); + } + blocks.extend(group_layout(lines.get(start..).unwrap_or_default(), false)); + blocks +} + +/// Dotted leaders end in a page number, with a title before the dots. A dot in an article +/// number, or an ellipsis in ordinary prose, is not enough to make a contents entry. +fn leader_entry(text: &str) -> bool { + let text = text.trim_end(); + let prefix = text.trim_end_matches(|c: char| { + c.is_ascii_digit() || matches!(c, 'i' | 'v' | 'x' | 'l' | 'c' | 'd' | 'm' | 'I' | 'V' | 'X' | 'L' | 'C' | 'D' | 'M') + }); + if prefix.len() == text.len() { + return false; + } + let title = prefix.trim_end_matches(|c: char| c.is_whitespace() || matches!(c, '.' | '…')); + let dots = prefix + .get(title.len()..) + .unwrap_or_default() + .chars() + .map(|c| match c { + '.' => 1, + '…' => 3, + _ => 0, + }) + .sum::(); + dots >= 3 && title.chars().any(char::is_alphabetic) } /// Text-space units → user space along the baseline. It is the vertical scale ([`Origin::k`]) diff --git a/crates/engine/src/compare.rs b/crates/engine/src/compare.rs index f0ed2b4b8..50508e20e 100644 --- a/crates/engine/src/compare.rs +++ b/crates/engine/src/compare.rs @@ -228,7 +228,9 @@ impl crate::Document { }) }) .collect(); - pdfcraft_export::Page { width: info.width as f64, height: info.height as f64, blocks, images } + // The page colour (Edit a PDF ▸ Background, a producer's page fill) for formats that keep one. + let background = pdfcraft_edit::page_background(cos, i).ok().flatten(); + pdfcraft_export::Page { width: info.width as f64, height: info.height as f64, blocks, images, background } }) .collect(); (pages, truncated) diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index a1820e2d9..01159df80 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -147,15 +147,7 @@ pub fn encode_image(width: u32, height: u32, premultiplied: &[u8], format: Image /// (4.46 → 4.47 MB), a text page at 220 dpi 70 → 40 ms (0.85 → 0.89 MB), and an annual /// report page at 150 dpi 179 → 64 ms (1.41 → 1.38 MB). pub fn encode_png(width: u32, height: u32, premultiplied: &[u8]) -> Result, String> { - // Checked here, not left to the encoder: `as_chunks` below drops a partial trailing pixel, - // and the opaque path would write the rest as a valid image. - let expected = usize::try_from(width).ok().zip(usize::try_from(height).ok()).and_then(|(w, h)| w.checked_mul(h)?.checked_mul(4)); - if expected != Some(premultiplied.len()) { - return Err(match expected { - Some(n) => format!("a {width}×{height} raster is {n} bytes of RGBA, got {}", premultiplied.len()), - None => format!("a {width}×{height} raster is too large"), - }); - } + check_raster(width, height, premultiplied)?; let pixels = premultiplied.as_chunks::<4>().0; let opaque = pixels.iter().all(|px| px[3] == 255); let (color, data) = if opaque { @@ -213,6 +205,22 @@ impl ImageFormat { } } +/// Checks that `premultiplied` is exactly `width × height` RGBA pixels, and at least one, +/// before any encoder sees it: `as_chunks` drops a partial trailing pixel, so the rest would be +/// written as a valid image, and the JPEG and TIFF encoders panic on a buffer that doesn't fit +/// the size. +fn check_raster(width: u32, height: u32, premultiplied: &[u8]) -> Result<(), String> { + if width == 0 || height == 0 { + return Err(format!("a {width}×{height} raster has no pixels")); + } + let expected = usize::try_from(width).ok().zip(usize::try_from(height).ok()).and_then(|(w, h)| w.checked_mul(h)?.checked_mul(4)); + match expected { + Some(n) if n == premultiplied.len() => Ok(()), + Some(n) => Err(format!("a {width}×{height} raster is {n} bytes of RGBA, got {}", premultiplied.len())), + None => Err(format!("a {width}×{height} raster is too large")), + } +} + /// Premultiplied RGBA composited over white → RGB. fn over_white(premultiplied: &[u8]) -> Vec { premultiplied @@ -230,6 +238,7 @@ fn over_white(premultiplied: &[u8]) -> Vec { pub fn encode_jpeg(width: u32, height: u32, premultiplied: &[u8], quality: u8, dpi: f64) -> Result, String> { use image::ImageEncoder; use image::codecs::jpeg::{JpegEncoder, PixelDensity}; + check_raster(width, height, premultiplied)?; let rgb = over_white(premultiplied); let mut out = Vec::new(); let mut enc = JpegEncoder::new_with_quality(&mut out, quality.clamp(1, 100)); @@ -246,6 +255,7 @@ pub fn encode_tiff(width: u32, height: u32, premultiplied: &[u8], dpi: f64) -> R use tiff::encoder::{Compression, Rational, TiffEncoder, colortype::RGB8}; use tiff::tags::{ResolutionUnit, Tag}; let err = |e: tiff::TiffError| e.to_string(); + check_raster(width, height, premultiplied)?; let rgb = over_white(premultiplied); let mut out = std::io::Cursor::new(Vec::new()); let mut tiff = TiffEncoder::new(&mut out).map_err(err)?.with_compression(Compression::Lzw); @@ -287,8 +297,10 @@ mod image_name_tests { } #[cfg(test)] -mod png_tests { - use super::encode_png; +mod raster_tests { + use super::{ImageFormat, encode_image, encode_png}; + + const FORMATS: [ImageFormat; 3] = [ImageFormat::Png, ImageFormat::Jpeg { quality: 90 }, ImageFormat::Tiff]; /// The colour type and pixels of a PNG. fn decode(png: &[u8]) -> (png::ColorType, Vec) { @@ -318,8 +330,9 @@ mod png_tests { assert_eq!(pixels, [128, 0, 64, 128, 0, 0, 0, 0, 10, 20, 30, 255]); } - /// A buffer that is not exactly `width × height` RGBA pixels is an error, opaque or not. The - /// opaque path used to drop a partial trailing pixel and write the rest as a valid image. + /// A buffer that is not exactly `width × height` RGBA pixels is an error in every format, + /// opaque or not. A partial trailing pixel used to be dropped and the rest written as a + /// valid image, and a buffer too short for its size panicked in the JPEG and TIFF encoders. #[test] fn rasters_of_the_wrong_length_are_rejected() { let cases: [(&str, u32, u32, &[u8]); 7] = [ @@ -331,8 +344,22 @@ mod png_tests { ("empty", 1, 1, &[]), ("a size whose byte count overflows", u32::MAX, u32::MAX, &[10, 20, 30, 255]), ]; - for (what, width, height, data) in cases { - assert!(encode_png(width, height, data).is_err(), "{what}"); + for format in FORMATS { + for (what, width, height, data) in cases { + assert!(encode_image(width, height, data, format, 72.0).is_err(), "{}, {what}", format.label()); + } + } + } + + /// A raster of no pixels is the right length (no bytes), and refused before any encoder sees + /// it, in every format. + #[test] + fn rasters_of_no_pixels_are_an_error() { + for format in FORMATS { + for (width, height) in [(0, 0), (0, 3), (3, 0)] { + let refused = encode_image(width, height, &[], format, 72.0).err(); + assert_eq!(refused, Some(format!("a {width}×{height} raster has no pixels")), "{}", format.label()); + } } } } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 0cfa463a5..c0fe65de7 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -49,6 +49,7 @@ pub use pdfcraft_organize::{BoxSpec, FieldConflicts, PageBox, SplitBy, Transitio /// One file produced by a split: (1-based first page, last page, PDF bytes). pub use pdfcraft_organize::LabelStyle; +pub use pdfcraft_organize::Standard; pub use pdfcraft_organize::view::{InitialView, Layout as InitialLayout, Magnification, Navigation}; pub use pdfcraft_cos::Algorithm; @@ -957,6 +958,24 @@ pub fn display_bytes_for(bytes: Arc>, password: Option<&str>) -> Arc>, password: Option<&str>) -> Option { + let password = password?; + let recovered = + guard(|| pdfcraft_cos::Document::open_with_password(bytes, Some(password)).ok().and_then(|d| recovered_render_password(&d))).ok().flatten(); + Some(recovered.unwrap_or_else(|| password.to_owned())) +} + +/// The user password owner authentication recovered from an R2–R4 `cos`, as the renderer takes it. +fn recovered_render_password(cos: &pdfcraft_cos::Document) -> Option { + cos.security().and_then(|s| s.recovered_user_password()).map(|user| renderer_password(&user)) +} + /// Fails when the renderer still draws text under an applied redaction mark. /// /// Redaction decides what lies under a mark from the positions its own interpreter computes, and @@ -1613,6 +1632,10 @@ pub enum Edit { ConvertPdfA { level: pdfcraft_preflight::Level, }, + /// Remove what identifies the document as meeting `standard` (PDF/A or PDF/X). + RemoveStandardId { + standard: Standard, + }, /// What a script (button or console) changed in form fields: values, read-only, required /// and visibility. ApplyScriptChanges { @@ -1952,6 +1975,7 @@ pub const EDIT_LABELS: &[&str] = &[ "Run JavaScript", "Run form script", "Save as {level}", + "Remove {standard} identification", "Edit script of {name}", "Delete document JavaScript", "Edit document JavaScript", @@ -2086,6 +2110,7 @@ impl Edit { Edit::XfaEvent { .. } => Label::of("Run form script"), // The level is the standard's own name ("PDF/A-2b"), so it goes in as it stands. Edit::ConvertPdfA { level } => Label::with("Save as {level}", level.label()), + Edit::RemoveStandardId { standard } => Label::with("Remove {standard} identification", standard.label()), Edit::SetFieldScript { name, .. } => Label::with("Edit script of {name}", name.as_str()), Edit::SetDocumentScript { script: None, .. } => Label::of("Delete document JavaScript"), Edit::SetDocumentScript { .. } => Label::of("Edit document JavaScript"), @@ -2284,6 +2309,7 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed | Edit::SetDocumentScript { .. } | Edit::SetFieldScript { .. } | Edit::ConvertPdfA { .. } + | Edit::RemoveStandardId { .. } | Edit::EditTextLine { .. } | Edit::EditTextBlock { .. } | Edit::RegisterTextFont { .. } @@ -2599,6 +2625,7 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> Edit::ConvertPdfA { level } => { pdfcraft_preflight::convert(doc, *level).map_err(|e| EditError::Invalid(e.to_string()))?; } + Edit::RemoveStandardId { standard } => pdfcraft_organize::remove_standard_id(doc, *standard)?, Edit::SetDocumentScript { name, script } => pdfcraft_forms::set_document_script(doc, name, script.as_deref())?, Edit::AddOcrText { page, words } => { pdfcraft_edit::stamp(doc, *page, "OCR", pdfcraft_ocr::text_layer(words))?; @@ -3462,10 +3489,10 @@ impl Session { Ok(info) => (info, password.map(str::to_owned)), Err(OpenError::WrongPassword) => { let user = match &cos { - Ok(Ok(d)) => d.security().and_then(|s| s.recovered_user_password()), + Ok(Ok(d)) => recovered_render_password(d), _ => None, }; - let user = renderer_password(&user.ok_or(OpenError::WrongPassword)?); + let user = user.ok_or(OpenError::WrongPassword)?; (inspect(bytes.clone(), Some(&user))?, Some(user)) } Err(e) => return Err(e), @@ -4862,6 +4889,11 @@ impl Session { write_full(doc, &opts).map(Arc::new).map_err(|e| EditError::Write(e.to_string())) } + /// Whether a changed document still needs a recovery snapshot. This does not consume it. + pub fn has_pending_autosave(&self) -> bool { + self.docs.iter().any(|d| d.dirty && d.generation != d.snapshot_generation) + } + /// Documents with unsaved changes made since the last call: their current working file, /// for crash recovery. Encrypted documents stay encrypted in the snapshot. pub fn autosave_snapshots(&mut self) -> Vec { @@ -4969,6 +5001,12 @@ impl Session { Some(self.get(id)?.config.reading_colors) } + /// How the document's pages are drawn for viewing: its password and view settings (layer + /// visibility, hidden comments, reading colours). `None` when no such document is open. + pub fn render_config(&self, id: DocId) -> Option { + Some(self.get(id)?.config.clone()) + } + /// Acrobat's Summarize Comments ("Comments only" layout): a new PDF listing every comment /// with its number, author, type, date, text and replies, grouped by page. pub fn summarize_comments(&self, id: DocId, sort: SummarySort) -> Result>, EditError> { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 258d37390..0df176226 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1427,12 +1427,42 @@ fn owner_password_of_older_revisions_opens_the_viewer_too() { } } +/// Render paths outside a session (the CLI's `info`, `text`, `render`) get the password the +/// renderer takes: the recovered user password for an R2–R4 owner password, else as given. +#[test] +fn render_password_for_recovers_the_user_password_of_older_revisions() { + for alg in [pdfcraft_cos::Algorithm::Rc4_40, pdfcraft_cos::Algorithm::Rc4_128, pdfcraft_cos::Algorithm::Aes128, pdfcraft_cos::Algorithm::Aes256] { + let mut doc = pdfcraft_cos::Document::open(Arc::new(fixture(1))).unwrap(); + doc.set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: alg, + user_password: "u", + owner_password: "o", + permissions: 0, + encrypt_metadata: true, + seed: [1; 32], + }) + .unwrap(); + let bytes = Arc::new(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap()); + let owner = crate::render_password_for(bytes.clone(), Some("o")); + let expected = if alg == pdfcraft_cos::Algorithm::Aes256 { "o" } else { "u" }; + assert_eq!(owner.as_deref(), Some(expected), "{alg:?}"); + assert!(pdfcraft_render::inspect(bytes.clone(), owner.as_deref()).is_ok(), "{alg:?}: the renderer opens it"); + assert_eq!(crate::render_password_for(bytes.clone(), Some("u")).as_deref(), Some("u"), "{alg:?}"); + assert_eq!(crate::render_password_for(bytes.clone(), Some("wrong")).as_deref(), Some("wrong"), "{alg:?}"); + assert_eq!(crate::render_password_for(bytes, None), None, "{alg:?}"); + } +} + #[test] fn autosave_snapshots_only_changed_documents() { let (mut s, id) = session_with(2); + assert!(!s.has_pending_autosave()); assert!(s.autosave_snapshots().is_empty(), "clean documents are not snapshotted"); s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap(); + assert!(s.has_pending_autosave()); + assert!(s.has_pending_autosave(), "checking a deadline must not consume a snapshot"); let snaps = s.autosave_snapshots(); + assert!(!s.has_pending_autosave()); assert_eq!(snaps.len(), 1); assert_eq!(snaps[0].bytes, s.get(id).unwrap().bytes, "the working file"); assert!(s.autosave_snapshots().is_empty(), "nothing new since the last snapshot"); @@ -7023,3 +7053,50 @@ fn scripted_display_changes_survive_warm_cache_and_new_edit() { assert!(next.output.errors.is_empty()); } } + +/// #2992: a page coloured with Edit a PDF ▸ Background exports to Word with that page colour, +/// behind its text. +#[test] +fn exporting_to_word_keeps_the_page_background() { + let mut s = Session::new(); + let text = s.create_from_text("t", "Blue text on teal").unwrap(); + let id = s.open("t.pdf", None, text, None).unwrap(); + assert_eq!(s.get(id).unwrap().export_pages()[0].background, None, "no background yet"); + s.apply( + id, + Edit::AddBackground { + pages: vec![0], + settings: Background { color: [0.0, 0.5, 0.5], opacity: 1.0, ..Background::default() }, + replace: false, + file: None, + }, + ) + .unwrap(); + let d = s.get(id).unwrap(); + let page = &d.export_pages()[0]; + assert!(page.background.is_some_and(|c| c.iter().zip([0.0, 0.5, 0.5]).all(|(a, b)| (a - b).abs() < 1e-6)), "{:?}", page.background); + assert!(page.blocks.iter().any(|b| b.text.contains("Blue text")), "the text is still there"); + let docx = d.export_office(compare::OfficeFormat::Docx); + assert!(docx.windows(b"word/settings.xml".len()).any(|w| w == b"word/settings.xml"), "Word is told to show the page colour"); +} + +#[test] +fn exporting_office_keeps_dotted_leader_entries_as_paragraphs() { + let mut content = String::new(); + for (title, page, y) in [("Art.1. Scope", "3", 200), ("Art.2. Eligibility", "7", 180), ("Art.3. Application", "12", 160)] { + content.push_str(&format!( + "BT /F1 12 Tf 72 {y} Td ({title}) Tj ET \ + BT /F1 12 Tf 250 {y} Td (....................) Tj ET \ + BT /F1 12 Tf 500 {y} Td ({page}) Tj ET\n" + )); + } + let pdf = one_line_page("/Font << /F1 4 0 R >>", &content); + let mut session = Session::new(); + let id = session.open("contents.pdf", None, Arc::new(pdf), None).unwrap(); + let doc = session.get(id).unwrap(); + let html = String::from_utf8(doc.export_office(compare::OfficeFormat::Html)).unwrap(); + assert!(!html.contains(""), "contents entries are paragraphs: {html}"); + let entries = ["Art.1. Scope .................... 3", "Art.2. Eligibility .................... 7", "Art.3. Application .................... 12"]; + let positions: Vec<_> = entries.iter().map(|entry| html.find(&format!("

{entry}

")).expect("one paragraph per entry")).collect(); + assert!(positions.windows(2).all(|pair| pair[0] < pair[1]), "reading order: {html}"); +} diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 30299b2d7..f3030ad58 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -49,6 +49,9 @@ pub struct Page { pub height: f64, pub blocks: Vec, pub images: Vec, + /// The colour the whole page is painted before its content (a page background), as sRGB + /// components in 0..=1; `None` for a page with no background of its own. + pub background: Option<[f64; 3]>, } /// One cell of a detected table. `span` is how many grid columns the cell covers (a header @@ -662,6 +665,14 @@ fn section(page: &Page) -> String { ) } +/// The page colour of a Word document: Word has one background for the whole document, so it +/// is written only when every page has the same, non-white background (#2992). +fn word_background(pages: &[Page]) -> Option { + let first = rgb8(pages.first()?.background?); + let same = pages.iter().all(|p| p.background.map(rgb8) == Some(first)); + (same && first != [255, 255, 255]).then(|| format!("{:02X}{:02X}{:02X}", first[0], first[1], first[2])) +} + /// A Word document (.docx, Office Open XML). Ordinary pages flow as paragraphs and tables; /// pages with rotated text, or text that overlaps artwork, use floating shapes to retain their layout. pub fn docx(pages: &[Page], title: &str) -> Vec { @@ -728,10 +739,13 @@ pub fn docx(pages: &[Page], title: &str) -> Vec { } after_table = false; } + // Word shows a document background only when its settings say so (`settings.xml` below). + let page_colour = word_background(pages); + let background = page_colour.as_deref().map(|c| format!("")).unwrap_or_default(); let doc = format!( "\n\ {body}\ +xmlns:wp=\"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing\" xmlns:v=\"urn:schemas-microsoft-com:vml\" xmlns:w10=\"urn:schemas-microsoft-com:office:word\">{background}{body}\ " ); let mut types = String::from( @@ -742,6 +756,11 @@ xmlns:wp=\"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawin \ ", ); + if page_colour.is_some() { + types.push_str( + "", + ); + } types.push_str(""); let rels = "\n\ \ @@ -756,6 +775,9 @@ xmlns:wp=\"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawin i + 1 )); } + if page_colour.is_some() { + doc_rels.push_str(""); + } doc_rels.push_str(""); let styles = "\n\ \ @@ -773,6 +795,9 @@ xmlns:wp=\"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawin z.add("word/document.xml", doc.as_bytes(), true); z.add("word/styles.xml", styles.as_bytes(), true); z.add("word/_rels/document.xml.rels", doc_rels.as_bytes(), true); + if page_colour.is_some() { + z.add("word/settings.xml", b"\n", true); + } for (name, im) in &media { z.add(&format!("word/media/{name}"), &im.bytes, false); } @@ -912,6 +937,7 @@ mod tests { size: [200.0, 150.0], rotation: 0.0, }], + background: None, } } @@ -1007,6 +1033,30 @@ mod tests { assert_eq!(xml.matches(">Meter").count(), 2); } + /// #2992: pages that share a background colour get it as the Word document's page colour + /// (with the setting that makes Word show it); differing or white backgrounds don't. + #[test] + fn docx_keeps_a_shared_page_background() { + let teal = Page { background: Some([0.0, 0.5, 0.5]), ..page() }; + let zip = docx(&[teal.clone(), teal.clone()], "Teal"); + let xml = part(&zip, "word/document.xml"); + assert!(xml.contains(""), "{xml}"); + assert!(part(&zip, "word/settings.xml").contains("")); + assert!(part(&zip, "[Content_Types].xml").contains("/word/settings.xml")); + assert!(part(&zip, "word/_rels/document.xml.rels").contains("Target=\"settings.xml\"")); + let other = Page { background: Some([1.0, 0.0, 0.0]), ..page() }; + for (pages, why) in [ + (vec![teal.clone(), other], "different colours"), + (vec![teal.clone(), page()], "a page without one"), + (vec![Page { background: Some([1.0; 3]), ..page() }], "white"), + (vec![page()], "none"), + ] { + let zip = docx(&pages, "Plain"); + assert!(!part(&zip, "word/document.xml").contains(" String { let at = |i: usize, n: usize| zip[i..i + n].iter().rev().fold(0usize, |v, b| v << 8 | *b as usize); @@ -1134,6 +1184,7 @@ mod tests { }, ], images: Vec::new(), + background: None, } } @@ -1214,6 +1265,7 @@ mod tests { }) .collect(), images: Vec::new(), + background: None, }; let grid_cols = |p: &Page| part(&docx(std::slice::from_ref(p), "Grid"), "word/document.xml").matches(""), "running text became a table: {h}"); @@ -1267,7 +1320,7 @@ mod tests { blocks.push(block(lead, 72.0, y, 211.0, y + 12.0)); blocks.push(blue("https://example.org/event", 212.5, y, 360.0, y + 12.0)); } - let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new() }; + let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None }; let h = html(std::slice::from_ref(&p), "Event"); assert!(!h.contains("
"), "sentences became a table: {h}"); assert_eq!(h.matches("https://example.org/event").count(), 3, "{h}"); @@ -1284,7 +1337,7 @@ mod tests { blocks.push(Block { size: 8.0, ..block(sup, 106.0, y + 5.0, 113.0, y + 13.0) }); blocks.push(block(&format!("{},000", 30 - 5 * n), 300.0, y, 340.0, y + 12.0)); } - let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new() }; + let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None }; let h = html(std::slice::from_ref(&p), "Prizes"); assert!(h.contains("
"), "the prize table was lost: {h}"); assert!(h.contains("30,000") && h.contains("15,000"), "{h}"); @@ -1311,6 +1364,7 @@ mod tests { para("Second right block, still a tall flowing paragraph rather than a cell.", 320.0, 400.0), ], images: Vec::new(), + background: None, }; let h = html(&[p], "Paper"); assert!(!h.contains("
"), "tall two-column text must stay paragraphs: {h}"); diff --git a/crates/export/src/pptx.rs b/crates/export/src/pptx.rs index fc02c20c1..1a7250d9e 100644 --- a/crates/export/src/pptx.rs +++ b/crates/export/src/pptx.rs @@ -396,6 +396,7 @@ mod tests { size: [228.0, 150.0], rotation: 0.0, }], + background: None, } } @@ -522,6 +523,7 @@ mod tests { cell("100%", 200.0, 320.0), ], images: Vec::new(), + background: None, }; let bytes = pptx(&[sample_page(), table_page], "Two"); let parts = entries(&bytes); @@ -619,7 +621,7 @@ mod tests { let images = (0..40) .map(|k| Image { ext: "png", bytes: vec![0; 4], rect: [0.0, -1e300, 1e-300, 1e300 - k as f64], size: [1.0, 1.0], rotation: 0.0 }) .collect(); - let p = Page { width: f64::MAX, height: f64::NAN, blocks, images }; + let p = Page { width: f64::MAX, height: f64::NAN, blocks, images, background: None }; let bytes = pptx(&[p], "Hostile"); let slide = part(&bytes, "ppt/slides/slide1.xml"); assert!(slide.contains("Title") && slide.contains(""), "the slide is still written"); @@ -698,6 +700,7 @@ mod tests { cell("100%", 200.0, 320.0, 100.0, 12.0), ], images: Vec::new(), + background: None, }; let bytes = pptx(&[p], "Table Check"); let slide_xml = part(&bytes, "ppt/slides/slide1.xml"); diff --git a/crates/optimize/README.md b/crates/optimize/README.md index 32915af6b..3d517eef3 100644 --- a/crates/optimize/README.md +++ b/crates/optimize/README.md @@ -18,7 +18,8 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going drawn with nothing else between them), become one lossless image at their finest resolution, which the image settings then resample; the strips are dropped by Clean Up. A real page printed this way went from 4.3 MB to 231 KB (Standard) and 161 KB (Mobile). Not merged yet: - side-by-side tiles, rotated strips, masks. + side-by-side tiles, rotated strips, masks. Content arrays use the shared space boundary + policy, so trailing comments, split operands and graphics state keep their meaning after merging. - **Images:** the effective resolution of each image is measured where pages draw it (the CTM through form XObjects, up to 12 deep; the smallest over all uses). If a use can't be measured (forms nested deeper, or drawn too many times), no image is resampled. Colour and grayscale images above a diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index 8cfa82674..6182b0892 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -2,7 +2,7 @@ use std::collections::{HashMap, HashSet}; -use pdfcraft_content::Matrix; +use pdfcraft_content::{Matrix, Pieces}; use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream}; use crate::{Compression, ImageSettings, OptimizeError, Report, Settings, Stage}; @@ -26,17 +26,16 @@ pub(crate) fn page_resources(doc: &Document, page: ObjRef) -> Option { pub(crate) fn content_bytes(doc: &Document, contents: &Object) -> Vec { match &*doc.resolve(contents) { Object::Stream(s) => s.decoded().unwrap_or_default(), - Object::Array(a) => a - .iter() - .flat_map(|c| match &*doc.resolve(c) { - Object::Stream(s) => { - let mut v = s.decoded().unwrap_or_default(); - v.push(b'\n'); - v - } - _ => Vec::new(), - }) - .collect(), + Object::Array(a) => { + let pieces: Vec<_> = a + .iter() + .filter_map(|c| match &*doc.resolve(c) { + Object::Stream(s) => Some(s.decoded().unwrap_or_default()), + _ => None, + }) + .collect(); + Pieces::join(&pieces).data().to_vec() + } _ => Vec::new(), } } @@ -230,15 +229,19 @@ fn is_dct(s: &Stream) -> Option { /// 8-bit samples of an image (`n` components), decoded. pub(crate) fn pixels(s: &Stream, n: usize) -> Option> { - let (w, h) = (s.dict.int(b"Width")? as usize, s.dict.int(b"Height")? as usize); + let (w, h) = (u32::try_from(s.dict.int(b"Width")?).ok()?, u32::try_from(s.dict.int(b"Height")?).ok()?); + if w == 0 || h == 0 || u64::from(w) * u64::from(h) > MAX_PIXELS { + return None; + } + let bytes = usize::try_from(w).ok()?.checked_mul(usize::try_from(h).ok()?)?.checked_mul(n)?; if is_dct(s)? { let img = image::load_from_memory_with_format(&s.raw, image::ImageFormat::Jpeg).ok()?; // Adobe CMYK/YCCK JPEGs aren't handled (the colour space check excludes them). let v = if n == 1 { img.into_luma8().into_raw() } else { img.into_rgb8().into_raw() }; - (v.len() == w * h * n).then_some(v) + (v.len() == bytes).then_some(v) } else { let v = s.decoded().ok()?; - (v.len() >= w * h * n).then(|| v[..w * h * n].to_vec()) + Some(v.get(..bytes)?.to_vec()) } } @@ -316,7 +319,7 @@ fn process(doc: &Document, s: &Stream, ppi: f64, settings: &ImageSettings, n: us return None; } let was_dct = is_dct(s)?; - let (w, h) = (d.int(b"Width")? as u32, d.int(b"Height")? as u32); + let (w, h) = (u32::try_from(d.int(b"Width")?).ok()?, u32::try_from(d.int(b"Height")?).ok()?); if w == 0 || h == 0 || (w as u64) * (h as u64) > MAX_PIXELS { return None; } @@ -347,7 +350,7 @@ fn process(doc: &Document, s: &Stream, ppi: f64, settings: &ImageSettings, n: us if m.dict.int(b"BitsPerComponent") != Some(8) || m.dict.contains(b"Matte") || m.dict.contains(b"Decode") { return None; } - let (mw, mh) = (m.dict.int(b"Width")? as u32, m.dict.int(b"Height")? as u32); + let (mw, mh) = (u32::try_from(m.dict.int(b"Width")?).ok()?, u32::try_from(m.dict.int(b"Height")?).ok()?); let mpx = pixels(m, 1)?; let mpx = resample(&mpx, 1, mw, mh, nw, nh)?; smask = Some((mr, image_stream(&m.dict, &mpx, 1, nw, nh, Compression::Flate, false)?)); @@ -409,3 +412,24 @@ pub(crate) fn run( } Ok(()) } + +#[cfg(test)] +mod dimension_tests { + use super::*; + + #[test] + fn pixel_reader_rejects_hostile_dimensions_before_decoding() { + for (width, height, channels) in [(-1, 1, 1), (0, 1, 1), (i64::from(u32::MAX) + 101, 1, 1), (80_000_001, 1, 1), (2, 2, usize::MAX)] { + let mut dict = Dict::new(); + dict.set(b"Width".to_vec(), Object::Int(width)); + dict.set(b"Height".to_vec(), Object::Int(height)); + let stream = Stream::from_raw(dict, vec![0; 4]); + assert!(pixels(&stream, channels).is_none(), "{width} x {height} x {channels}"); + } + let mut dict = Dict::new(); + dict.set(b"Width".to_vec(), Object::Int(2)); + dict.set(b"Height".to_vec(), Object::Int(2)); + let stream = Stream::from_raw(dict, vec![1, 2, 3, 4, 5]); + assert_eq!(pixels(&stream, 1), Some(vec![1, 2, 3, 4])); + } +} diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index 2a83e8d6a..fd1ef3cd9 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -1022,3 +1022,65 @@ fn images_are_not_resampled_when_some_uses_could_not_be_measured() { let refs = pdfcraft_annot::page_refs(&doc).unwrap(); assert_eq!(effective_resolutions(&doc, &refs)[&img], f64::INFINITY); } + +#[test] +fn image_resolution_keeps_comments_across_content_pieces() { + for ending in ["", "\n", "\r", "\r\n"] { + let mut doc = Document::new_empty(); + let visible = image(&mut doc, 20, 20, 3, false, None); + let hidden = image(&mut doc, 20, 20, 3, false, None); + let page = page(&mut doc, &[("Visible", visible), ("Hidden", hidden)], ""); + let first = doc + .add(Object::Stream(Stream::from_raw(Dict::new(), format!("q 72 0 0 72 0 0 cm /Visible Do Q\n% trailing comment{ending}").into_bytes()))); + let second = doc.add(Object::Stream(Stream::from_raw(Dict::new(), b"q 144 0 0 144 0 0 cm /Hidden Do Q\n".to_vec()))); + doc.update_dict(page, |dict| { + dict.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(first), Object::Int(17), Object::Ref(second)])); + }) + .unwrap(); + let measured = effective_resolutions(&doc, &[page]); + assert_eq!(measured.get(&visible), Some(&20.0)); + if ending.is_empty() { + assert_eq!(measured.len(), 1); + assert_eq!(measured.get(&hidden), None, "a comment must hide the next piece's first line"); + } else { + assert_eq!(measured.len(), 2); + assert_eq!(measured.get(&hidden), Some(&10.0), "a real line ending exposes the image"); + } + } +} + +#[test] +fn a_hostile_image_size_is_left_alone_not_multiplied_into_overflow() { + // /Width and /Height are file-controlled: 2^32 + 100 wraps to 100 when read as u32, while the + // real values overflow `w * h * n`. The image must be skipped, not sized by a wrapped product. + let mut doc = Document::new_empty(); + let side = i64::from(u32::MAX) + 101; + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(side)); + d.set(b"Height".to_vec(), Object::Int(side)); + d.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + let hostile = doc.add(Object::Stream(Stream::flate(d, &[7u8; 256]))); + page(&mut doc, &[("Im", hostile)], "q 1 0 0 1 10 10 cm /Im Do Q"); + let report = optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!(report.images_resampled, 0, "{report:?}"); + assert_eq!(stream(&doc, hostile).dict.int(b"Width"), Some(side), "the image is left as it was"); +} + +#[test] +fn a_hostile_soft_mask_leaves_its_image_unchanged() { + let mut doc = Document::new_empty(); + let mask = image(&mut doc, 400, 400, 1, false, None); + let mut hostile = stream(&doc, mask); + hostile.dict.set(b"Width".to_vec(), Object::Int(i64::from(u32::MAX) + 401)); + doc.set(mask, Object::Stream(hostile.clone())); + let photo = image(&mut doc, 400, 400, 3, false, Some(mask)); + let before = stream(&doc, photo); + page(&mut doc, &[("Photo", photo)], "q 72 0 0 72 10 10 cm /Photo Do Q"); + let report = optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!(report.images_resampled, 0, "{report:?}"); + assert_eq!(stream(&doc, photo), before); + assert_eq!(stream(&doc, mask), hostile); +} diff --git a/crates/organize/src/lib.rs b/crates/organize/src/lib.rs index 3053efd7e..6a696dd4d 100644 --- a/crates/organize/src/lib.rs +++ b/crates/organize/src/lib.rs @@ -704,5 +704,40 @@ fn set_info_entry(doc: &mut Document, key: &[u8], entry: Option) -> Resu Ok(()) } +/// A standard a document can declare it meets, for [`remove_standard_id`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Standard { + PdfA, + PdfX, +} + +impl Standard { + pub fn label(self) -> &'static str { + match self { + Standard::PdfA => "PDF/A", + Standard::PdfX => "PDF/X", + } + } +} + +/// Remove what identifies the document as meeting `standard`: the `pdfaid:` XMP properties of +/// ISO 19005, or ISO 15930's `GTS_PDFXVersion` and `GTS_PDFXConformance` in the document +/// information and as `pdfxid:` XMP properties. Output intents and other metadata stay. Refused +/// when the document declares nothing of the kind. +pub fn remove_standard_id(doc: &mut Document, standard: Standard) -> Result<(), OrganizeError> { + let (props, info_keys): (&[&str], &[&str]) = match standard { + Standard::PdfA => (&["pdfaid:part", "pdfaid:conformance", "pdfaid:amd", "pdfaid:rev"], &[]), + Standard::PdfX => (&["pdfxid:GTS_PDFXVersion", "pdfxid:GTS_PDFXConformance"], &["GTS_PDFXVersion", "GTS_PDFXConformance"]), + }; + let mut removed = xmp::remove_properties(doc, props)?; + for key in info_keys { + if info(doc, key).is_some() { + set_info_entry(doc, key.as_bytes(), None)?; + removed = true; + } + } + if removed { Ok(()) } else { Err(OrganizeError::Invalid(format!("the document doesn't declare {}", standard.label()))) } +} + #[cfg(test)] mod tests; diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index 868fcde9e..7a80e38e4 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -1187,6 +1187,37 @@ fn malformed_output_intents_and_identification_do_not_fail() { assert!(!xmp.contains('\0') && !xmp.contains("GTS_PDFXConformance"), "{xmp}"); } +#[test] +fn removing_the_pdfx_identification_keeps_the_output_intent_and_other_claims() { + let mut x4 = doc_x4("FOGRA51", "icc"); + assert!(matches!(remove_standard_id(&mut x4, Standard::PdfA), Err(OrganizeError::Invalid(_)))); + assert!(!x4.is_modified(), "a refused removal changes nothing"); + remove_standard_id(&mut x4, Standard::PdfX).unwrap(); + let x = pdfx_of(&full_roundtrip(&x4)); + assert_eq!((x.version, x.intents), (None, vec![("FOGRA51".to_string(), b"icc".to_vec())])); + assert!(x.xmp.is_some_and(|x| !x.contains("pdfxid:GTS") && x.contains("pdfuaid:part=\"1\"")), "PDF/UA's claim stays"); + assert!(matches!(remove_standard_id(&mut x4, Standard::PdfX), Err(OrganizeError::Invalid(_))), "nothing left to remove"); + + let mut x1a = doc_print("CGATS TR 001", "icc", "/GTS_PDFXVersion (PDF/X-1:2001) /GTS_PDFXConformance (PDF/X-1a:2001)", None); + remove_standard_id(&mut x1a, Standard::PdfX).unwrap(); + assert_eq!((info(&x1a, "GTS_PDFXVersion"), info(&x1a, "GTS_PDFXConformance")), (None, None)); +} + +#[test] +fn removing_the_pdfa_identification_removes_its_xmp_properties_in_either_form() { + let xmp = "\ + 2Report\ + "; + let mut doc = doc_print("sRGB IEC61966-2.1", "icc", "/Title (Report)", Some(xmp)); + remove_standard_id(&mut doc, Standard::PdfA).unwrap(); + let x = pdfx_of(&full_roundtrip(&doc)); + let packet = x.xmp.unwrap(); + assert!(!packet.contains("pdfaid:part") && !packet.contains("pdfaid:conformance"), "{packet}"); + assert!(packet.contains(">Report"), "the rest of the packet stays: {packet}"); + assert_eq!(x.intents.len(), 1, "the output intent stays"); +} + #[test] fn duplicate_pages_become_independent_copies() { let out = full_roundtrip(&extract_pages(&doc_b(), &[0, 0]).unwrap()); diff --git a/crates/organize/src/xmp.rs b/crates/organize/src/xmp.rs index 3efa8d265..bbcbbcf83 100644 --- a/crates/organize/src/xmp.rs +++ b/crates/organize/src/xmp.rs @@ -54,21 +54,42 @@ fn property(key: &str) -> Option<(&'static str, &'static str, Shape)> { /// that can't be edited safely. pub(crate) fn sync_info(doc: &mut Document, key: &str, value: &str) -> Result<(), OrganizeError> { let Some((name, ns, shape)) = property(key) else { return Ok(()) }; - let Some(root) = doc.root() else { return Ok(()) }; + edit_packet(doc, |xmp| set_property(xmp, name, ns, shape, value)).map(|_| ()) +} + +/// Remove every occurrence of the properties `names` from the catalog's XMP packet; whether any +/// was there. +pub(crate) fn remove_properties(doc: &mut Document, names: &[&str]) -> Result { + edit_packet(doc, |xmp| { + let mut out: Option = None; + for name in names { + // Each removal shortens the packet, so this ends. + while let Some(next) = set_property(out.as_deref().unwrap_or(xmp), name, "", Shape::Text, "") { + out = Some(next); + } + } + out + }) +} + +/// Rewrite the catalog's XMP packet with `edit` (`None`: unchanged); whether it changed. Files +/// without XMP and packets that can't be edited safely are left alone. +fn edit_packet(doc: &mut Document, edit: impl FnOnce(&str) -> Option) -> Result { + let Some(root) = doc.root() else { return Ok(false) }; // A metadata stream is always indirect (§7.3.8); anything else is left alone. - let Some(r) = doc.get(root).as_dict().and_then(|c| c.reference(b"Metadata")) else { return Ok(()) }; + let Some(r) = doc.get(root).as_dict().and_then(|c| c.reference(b"Metadata")) else { return Ok(false) }; let obj = doc.get(r); - let Object::Stream(stream) = &*obj else { return Ok(()) }; - let Ok(bytes) = stream.decoded_within(MAX_XMP) else { return Ok(()) }; - let Ok(xmp) = String::from_utf8(bytes) else { return Ok(()) }; - let Some(updated) = set_property(&xmp, name, ns, shape, value) else { return Ok(()) }; + let Object::Stream(stream) = &*obj else { return Ok(false) }; + let Ok(bytes) = stream.decoded_within(MAX_XMP) else { return Ok(false) }; + let Ok(xmp) = String::from_utf8(bytes) else { return Ok(false) }; + let Some(updated) = edit(&xmp) else { return Ok(false) }; // Written unfiltered, as PDF/A asks of metadata streams, so other tools can read it. let mut dict = stream.dict.clone(); for k in [&b"Filter"[..], b"DecodeParms", b"DL", b"Length"] { dict.remove(k); } doc.set(r, Object::Stream(Stream::from_raw(dict, updated.into_bytes()))); - Ok(()) + Ok(true) } /// `xmp` with the property `name` (namespace `ns`) set to `value`, or removed when `value` is diff --git a/crates/platform/README.md b/crates/platform/README.md index 6b23752b5..ea7af7a07 100644 --- a/crates/platform/README.md +++ b/crates/platform/README.md @@ -1,7 +1,11 @@ # pdfcraft-platform -Layer L7 holds operating-system services that both the desktop app (`pdfcraft-ui-egui`) and the automation tools (`pdfcraft-automation`) need. Those two crates sit on the same layer and can't depend on each other, so a helper they share lives here instead of being copied into each (`xtask/src/layers.rs` allows both edges). It uses only the standard library and has no UI dependencies. +Layer L7 holds operating-system services that both the desktop app (`pdfcraft-ui-egui`) and the automation tools (`pdfcraft-automation`) need. Those two crates sit on the same layer and can't depend on each other, so a helper they share lives here instead of being copied into each (`xtask/src/layers.rs` allows both edges). It has no UI dependencies; macOS filesystem calls use the safe `rustix` wrapper. `staging` creates the temporary file an atomic save writes before renaming it over the target. The file is opened with `create_new`, so anything already at the name (a file, a hard link, a symbolic link even when dangling, a folder) is skipped and never written through. On Windows a folder at the name fails with "access denied" rather than "already exists", and is skipped only when something is actually there. Names carry an unpredictable 64-bit suffix from `staging_suffixes` (the standard library's keyed hash, currently SipHash-1-3, keyed from the operating system's random source), which stops a name being planted in advance but is not a CSPRNG; fixed suffixes are only for tests. At most 16 names are tried, and then the save is refused. The target's name is cut to 128 bytes on a character boundary so the staging name fits every system's limit. Each caller keeps its own name form (`.{name}.{suffix}.pdfcraft-tmp` for automation, `.{name}.pdfcraft-{suffix}.tmp` for the desktop save). Writing, closing the file before the rename, renaming and removing the staging file on failure stay with the caller. Synthetic tests pin both name forms, the 128-byte cut, the attempt limit and refusal when no name is left. The planted-file, link, folder and failed-rename tests stay with each caller, where they exercise the whole save. + +On macOS, `write_atomically_with` copies the existing regular destination's accessible extended attributes to the open staging file after writing the data and before flushing and renaming. Finder tags, comments, binary values and empty attributes therefore survive both incremental and full saves (#2658). Save As retains attributes of an existing destination; a new destination does not inherit metadata from the source PDF. Missing files and filesystems without xattr support keep working. The metadata read does not follow the destination's symlink or block on a FIFO. macOS manages the new file's `com.apple.provenance`; it is not copied. Kernel-hidden compression attributes are not requested. + +Attribute copying is bounded: 64 KiB of names, 1024 attributes, 16 MiB per value and 64 MiB total. Each value must fill the initially queried size and retain that size in a second query after reading: a resource fork may return a partial prefix on growth rather than an error. A read/copy error or exceeded bound returns an error suggesting Save As; the original bytes and metadata stay intact and the staging file is removed. Flush errors retain the same guarantee. Tests cover exact metadata before flush and after repeated saves, empty/binary values, oversized resource forks, a simulated partial read of a growing fork and symlink isolation. Automation covers incremental/full saves, Save As and retaining an unsaved edit after metadata refusal. The staging writer also retains Unix permissions and, where supported, Windows/macOS creation dates; attribute copying runs before the creation date is restored. ACLs and hard-link identity are not preserved. diff --git a/crates/platform/src/staging.rs b/crates/platform/src/staging.rs index fee0a1633..0b727449c 100644 --- a/crates/platform/src/staging.rs +++ b/crates/platform/src/staging.rs @@ -101,6 +101,8 @@ pub fn staging_suffixes() -> impl Iterator { /// that is replaced keeps its permissions, so a private file stays private; a new file gets the /// usual mode for a new file. On Windows and macOS a replaced file keeps its creation date, where /// the volume stores one. +/// On macOS, accessible extended attributes of an existing regular destination are copied +/// before flushing; a copy failure refuses the save rather than losing metadata (#2658). pub fn write_atomically(path: &Path, bytes: &[u8], form: StagingName) -> std::io::Result<()> { write_atomically_with(path, bytes, form, staging_suffixes(), std::fs::File::sync_all) } @@ -138,6 +140,10 @@ pub fn write_atomically_with( { let mut file = file; file.write_all(bytes)?; + #[cfg(target_os = "macos")] + preserve_target_xattrs(path, &file).map_err(|e| { + std::io::Error::new(e.kind(), format!("could not preserve the file's extended attributes; save a copy with Save As: {e}")) + })?; #[cfg(any(windows, target_os = "macos"))] keep_created(&file, created); sync_staged(&file, sync_all)?; @@ -150,6 +156,73 @@ pub fn write_atomically_with( result } +/// Copy accessible macOS attributes to the staging descriptor before publication. The kernel +/// hides compression bookkeeping unless SHOWCOMPRESSION is requested; leave that option off. +/// Provenance is managed by macOS for the new file, not writable document metadata. +#[cfg(target_os = "macos")] +fn preserve_target_xattrs(path: &Path, staged: &std::fs::File) -> std::io::Result<()> { + use rustix::fs::{Mode, OFlags, XattrFlags, fgetxattr, flistxattr, fsetxattr, open}; + use rustix::io::Errno; + + // A new destination has nothing to retain. Replacing a symlink has always replaced the + // link, not its referent: do not open it to read metadata from a different file. NONBLOCK + // also prevents a FIFO at the destination from hanging the metadata read. + let source = match open(path, OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW | OFlags::NONBLOCK, Mode::empty()) { + Ok(fd) => std::fs::File::from(fd), + Err(Errno::NOENT | Errno::LOOP) => return Ok(()), + Err(e) => return Err(e.into()), + }; + if !source.metadata()?.is_file() { + return Ok(()); + } + + // Metadata is untrusted too: bound both allocations and total copying, including forks. + const MAX_NAMES_BYTES: usize = 64 * 1024; + const MAX_ATTRIBUTES: usize = 1024; + const MAX_VALUE_BYTES: usize = 16 * 1024 * 1024; + const MAX_TOTAL_BYTES: usize = 64 * 1024 * 1024; + let mut names = vec![0u8; MAX_NAMES_BYTES]; + let len = match flistxattr(&source, names.as_mut_slice()) { + Ok(len) => len, + Err(Errno::NOTSUP) => return Ok(()), // No attributes exist on this filesystem. + Err(Errno::RANGE) => return Err(std::io::Error::other("extended attribute name list exceeds 64 KiB")), + Err(e) => return Err(e.into()), + }; + let names = names.get(..len).ok_or_else(|| std::io::Error::other("extended attribute name list exceeds 64 KiB"))?; + let mut total = 0usize; + for (index, name) in names.split(|b| *b == 0).filter(|name| !name.is_empty()).enumerate() { + if index >= MAX_ATTRIBUTES { + return Err(std::io::Error::other("extended attribute count exceeds 1024")); + } + if name == b"com.apple.provenance" { + continue; + } + let name = std::ffi::CString::new(name).map_err(|_| std::io::Error::other("invalid extended attribute name"))?; + let size = fgetxattr(&source, &name, &mut [] as &mut [u8])?; + if size > MAX_VALUE_BYTES || size > MAX_TOTAL_BYTES.saturating_sub(total) { + return Err(std::io::Error::other("extended attributes exceed the 16 MiB per-value or 64 MiB total limit")); + } + // Resource forks may return a prefix instead of ERANGE on growth; check the size again. + let value = read_xattr_value(size, |buffer| Ok(fgetxattr(&source, &name, buffer)?))?; + fsetxattr(staged, &name, &value, XattrFlags::empty())?; + total = total.saturating_add(value.len()); + } + Ok(()) +} + +/// Read an already size-bounded attribute, refusing a partial result or a changed post-read size. +#[cfg(target_os = "macos")] +fn read_xattr_value(size: usize, mut read: impl FnMut(&mut [u8]) -> std::io::Result) -> std::io::Result> { + let mut value = vec![0u8; size.max(1)]; + let len = read(value.as_mut_slice())?; + let after = read(&mut [])?; + if len != size || after != size { + return Err(std::io::Error::other("extended attribute changed size while saving")); + } + value.truncate(len); + Ok(value) +} + /// Give the staging file the creation date of the file it replaces, where the system keeps one /// that a program can set (Windows, macOS). Best effort: a volume that can't store it leaves the /// new file's own date, as before. @@ -304,6 +377,182 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[cfg(target_os = "macos")] + #[test] + fn atomic_saves_keep_finder_tags_and_binary_extended_attributes() { + use rustix::fs::{XattrFlags, fgetxattr, setxattr}; + use std::os::macos::fs::FileTimesExt as _; + let dir = test_dir("xattrs"); + let target = dir.join("out.pdf"); + let attributes: &[(&str, &[u8])] = &[ + ("com.apple.metadata:_kMDItemUserTags", b"PdfCraft\n6"), + ("com.pdfcraft.test.binary", b"\0\xff\x80original\0"), + ("com.pdfcraft.test.empty", b""), + ]; + std::fs::write(&target, b"OLD").unwrap(); + for (name, value) in attributes { + setxattr(&target, *name, value, XattrFlags::empty()).unwrap(); + } + let created = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_577_934_245); + let original = std::fs::OpenOptions::new().write(true).open(&target).unwrap(); + original.set_times(std::fs::FileTimes::new().set_created(created)).unwrap(); + assert_eq!(original.metadata().unwrap().created().unwrap(), created); + drop(original); + // Both desktop and automation name forms, including a second save of the replacement. + for form in [StagingName::TagThenSuffix, StagingName::SuffixThenTag] { + write_atomically_with(&target, b"NEW", form, [7], |staged| { + assert_eq!(staged.metadata().unwrap().created().unwrap(), created, "creation date must survive attribute copying before flush"); + for (name, value) in attributes { + let mut bytes = [0u8; 256]; + let len = fgetxattr(staged, *name, &mut bytes[..]).unwrap(); + assert_eq!(&bytes[..len], *value, "metadata must be present before flush: {name}"); + } + Ok(()) + }) + .unwrap(); + let saved = std::fs::File::open(&target).unwrap(); + assert_eq!(saved.metadata().unwrap().created().unwrap(), created); + for (name, value) in attributes { + let mut bytes = [0u8; 256]; + let len = fgetxattr(&saved, *name, &mut bytes[..]).unwrap(); + assert_eq!(&bytes[..len], *value); + } + assert_eq!(std::fs::read(&target).unwrap(), b"NEW"); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1); + } + std::fs::remove_dir_all(dir).unwrap(); + } + + #[cfg(target_os = "macos")] + #[test] + fn a_failed_flush_keeps_the_original_extended_attributes() { + use rustix::fs::{XattrFlags, getxattr, setxattr}; + let dir = test_dir("xattrs-flush"); + let target = dir.join("out.pdf"); + std::fs::write(&target, b"OLD").unwrap(); + setxattr(&target, "com.pdfcraft.test", b"original", XattrFlags::empty()).unwrap(); + let e = write_atomically_with(&target, b"NEW", StagingName::TagThenSuffix, [9], |_| Err(std::io::Error::other("flush refused"))).unwrap_err(); + assert!(e.to_string().contains("flush refused")); + assert_eq!(std::fs::read(&target).unwrap(), b"OLD"); + let mut value = [0u8; 64]; + let len = getxattr(&target, "com.pdfcraft.test", &mut value[..]).unwrap(); + assert_eq!(&value[..len], b"original"); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1); + std::fs::remove_dir_all(dir).unwrap(); + } + + /// Model an HFS-style partial read while using real macOS metadata and the atomic writer. + /// The shared read validator must reject growth before the staging file can be published. + #[cfg(target_os = "macos")] + #[test] + fn a_growing_resource_fork_refuses_publication_and_keeps_original() { + use rustix::fs::{XattrFlags, fgetxattr, fsetxattr, getxattr, setxattr}; + let dir = test_dir("xattrs-growing-fork"); + let target = dir.join("out.pdf"); + let name = "com.apple.ResourceFork"; + for form in [StagingName::TagThenSuffix, StagingName::SuffixThenTag] { + std::fs::write(&target, b"ORIGINAL PDF").unwrap(); + setxattr(&target, name, b"ABC", XattrFlags::empty()).unwrap(); + let source = std::fs::File::open(&target).unwrap(); + let result = write_atomically_with(&target, b"REPLACEMENT PDF", form, [23], |staged| { + let size = fgetxattr(&source, name, &mut [] as &mut [u8])?; + let value = read_xattr_value(size, |buffer| { + if buffer.is_empty() { + return Ok(fgetxattr(&source, name, buffer)?); + } + // HFS can return the prefix that fits instead of ERANGE. APFS need not + // have that behaviour: model the kernel response, not a timing race. + setxattr(&target, name, b"ABCD", XattrFlags::empty())?; + buffer.get_mut(..3).unwrap().copy_from_slice(b"ABC"); + Ok(3) + })?; + fsetxattr(staged, name, &value, XattrFlags::empty())?; + Ok(()) + }); + let error = result.expect_err("a short prefix of the grown fork must not be published"); + assert!(error.to_string().contains("changed size"), "{error}"); + assert_eq!(std::fs::read(&target).unwrap(), b"ORIGINAL PDF"); + let mut bytes = [0u8; 8]; + let len = getxattr(&target, name, &mut bytes[..]).unwrap(); + assert_eq!(&bytes[..len], b"ABCD", "the original's grown fork must remain intact"); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1, "staging files are removed on refusal"); + } + std::fs::remove_dir_all(dir).unwrap(); + } + + /// Stable empty/binary values are accepted; short reads, growth and failed rechecks are refused. + #[cfg(target_os = "macos")] + #[test] + fn attribute_reads_require_a_complete_value_and_stable_size() { + for bytes in [&b""[..], &b"\0\xffB"[..]] { + let value = read_xattr_value(bytes.len(), |buffer| { + if !buffer.is_empty() { + buffer.get_mut(..bytes.len()).unwrap().copy_from_slice(bytes); + } + Ok(bytes.len()) + }) + .unwrap(); + assert_eq!(value, bytes); + } + for (before, read_len, after) in [(3, 2, 3), (3, 3, 4), (0, 1, 1), (3, 2, 2)] { + let result = read_xattr_value(before, |buffer| Ok(if buffer.is_empty() { after } else { read_len })); + assert!(result.unwrap_err().to_string().contains("changed size")); + } + let result = read_xattr_value(3, |buffer| { + if buffer.is_empty() { Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "size recheck refused")) } else { Ok(3) } + }); + assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::PermissionDenied); + } + + #[cfg(target_os = "macos")] + #[test] + fn oversized_extended_attributes_refuse_the_save_without_replacing_the_target() { + use rustix::fs::{XattrFlags, getxattr, setxattr}; + let dir = test_dir("xattrs-limit"); + let target = dir.join("out.pdf"); + std::fs::write(&target, b"OLD").unwrap(); + // A resource fork can exceed the ordinary attribute limit on macOS. No fixture file + // is committed; only the OS metadata of this temporary synthetic file holds the bytes. + let fork = vec![42u8; 16 * 1024 * 1024 + 1]; + setxattr(&target, "com.apple.ResourceFork", &fork, XattrFlags::empty()).unwrap(); + let mut flushed = false; + let e = write_atomically_with(&target, b"NEW", StagingName::TagThenSuffix, [10], |_| { + flushed = true; + Ok(()) + }) + .unwrap_err(); + assert!(e.to_string().contains("extended attributes"), "{e}"); + assert!(e.to_string().contains("Save As"), "{e}"); + assert!(!flushed, "metadata failure must stop before flush and rename"); + assert_eq!(std::fs::read(&target).unwrap(), b"OLD"); + let size = getxattr(&target, "com.apple.ResourceFork", &mut [] as &mut [u8]).unwrap(); + assert_eq!(size, fork.len()); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[cfg(target_os = "macos")] + #[test] + fn replacing_a_symlink_does_not_copy_attributes_from_its_referent() { + use rustix::fs::{XattrFlags, getxattr, setxattr}; + let dir = test_dir("xattrs-symlink"); + let outside = test_dir("xattrs-outside").join("private.pdf"); + std::fs::write(&outside, b"PRIVATE").unwrap(); + setxattr(&outside, "com.pdfcraft.test", b"private metadata", XattrFlags::empty()).unwrap(); + let target = dir.join("out.pdf"); + std::os::unix::fs::symlink(&outside, &target).unwrap(); + write_atomically(&target, b"NEW", StagingName::TagThenSuffix).unwrap(); + assert_eq!(std::fs::read(&outside).unwrap(), b"PRIVATE"); + assert_eq!(std::fs::read(&target).unwrap(), b"NEW"); + assert!(!target.symlink_metadata().unwrap().file_type().is_symlink()); + let mut bytes = [0u8; 64]; + assert_eq!(getxattr(&target, "com.pdfcraft.test", &mut bytes[..]).unwrap_err(), rustix::io::Errno::NOATTR); + let len = getxattr(&outside, "com.pdfcraft.test", &mut bytes[..]).unwrap(); + assert_eq!(&bytes[..len], b"private metadata"); + std::fs::remove_dir_all(dir).unwrap(); + std::fs::remove_dir_all(outside.parent().unwrap()).unwrap(); + } + /// #1790: a replaced file keeps its permissions, and its staging file already has them while /// it is written; a new file gets the same mode as any other new file. #[cfg(unix)] diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index debad4535..fa5d32c40 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -915,6 +915,18 @@ pub fn serve(client: ControlClient) -> std::io::Result { serve_with(client, LIMITS) } +/// How long the accepting thread waits after a failed accept. Accepting can keep failing, for +/// example while the process has no file descriptors left, and retrying at once would keep a +/// core busy until it recovers. The single-instance listener waits the same way. +#[cfg(not(target_arch = "wasm32"))] +const ACCEPT_RETRY: std::time::Duration = std::time::Duration::from_millis(100); + +/// The connections `incoming` accepts, calling `wait` after each failed accept. +#[cfg(not(target_arch = "wasm32"))] +fn accepted(incoming: impl Iterator>, mut wait: impl FnMut()) -> impl Iterator { + incoming.filter_map(move |stream| stream.map_err(|_| wait()).ok()) +} + #[cfg(not(target_arch = "wasm32"))] fn serve_with(client: ControlClient, limits: Limits) -> std::io::Result { use std::io::{BufReader, Write}; @@ -925,7 +937,7 @@ fn serve_with(client: ControlClient, limits: Limits) -> std::io::Result std::io::Result { #[cfg(test)] mod transport_tests { + /// A failed accept is followed by a wait before the next one, so a listener that keeps + /// failing does not spin, and the connections around the failures still come through. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn a_failed_accept_waits_before_the_next() { + let failed = || Err(std::io::Error::other("no file descriptors left")); + let incoming = vec![failed(), Ok(1), failed(), failed(), Ok(2)]; + let mut waits = 0; + let connections: Vec = super::accepted(incoming.into_iter(), || waits += 1).collect(); + assert_eq!(connections, [1, 2]); + assert_eq!(waits, 3, "one wait per failed accept"); + } + /// Before authenticating, a line that isn't JSON gets a parse error and closes the /// connection, as [`super::serve`] documents for any first request other than `auth`. #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index e7d5bcdce..a78b4f095 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2406,6 +2406,7 @@ action wizard Start Iniciar Change Subject Cambiar el asunto Change Keywords Cambiar las palabras clave Save as {level} Guardar como {level} + Remove {standard} identification Quitar la identificación {standard} Create links Crear enlaces # Multi-object selection and movement (#844) diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index 4e51040ad..1029c5166 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -2731,6 +2731,7 @@ key Space Tarpas # AI-assisted additions for concurrently merged UI features (2026-10-15). # These additions have not had separate native-speaker review. Mouse wheel scroll speed Pelės ratuko slinkimo greitis + Remove {standard} identification Pašalinti {standard} identifikaciją The password must have at least 6 characters. Slaptažodį turi sudaryti bent 6 simboliai. This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. Šis failas diske pasikeitė. Įkelkite jį iš naujo, kad pamatytumėte naują versiją; neįrašyti pakeitimai bus prarasti. theme Appearance Išvaizda diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 6945f219e..c9f570fdd 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2708,6 +2708,7 @@ key Space Space Redaction mark appearance Hình thức dấu xóa nội dung nhạy cảm Remove Crop Bỏ xén Remove this leaf tag from reading order and mark its direct content as an artifact Xóa thẻ lá này khỏi thứ tự đọc và đánh dấu nội dung trực tiếp của nó là thành phần trang trí + Remove {standard} identification Gỡ thông tin nhận dạng {standard} Resolution: Độ phân giải: Save anyway Vẫn lưu Save over the changed file? Lưu đè lên tệp đã thay đổi? diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c434b8d0c..c056143f5 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -3241,7 +3241,7 @@ impl eframe::App for PdfCraftApp { // macOS: quit through `terminate:` instead of dropping the window (Touch Bar crash, #1136). craft_quit::on_close_request(ctx); let now = ctx.input(|i| i.time); - self.autosave_tick(now); + self.autosave_tick(ctx, now); // Reload documents whose files a build rewrote (#431). #[cfg(not(target_arch = "wasm32"))] self.watch_files(); diff --git a/crates/ui-egui/src/recovery.rs b/crates/ui-egui/src/recovery.rs index 3d5f78ad1..17a3410d1 100644 --- a/crates/ui-egui/src/recovery.rs +++ b/crates/ui-egui/src/recovery.rs @@ -312,7 +312,7 @@ impl PdfCraftApp { } /// Autosave when the interval has passed (called every frame). - pub(crate) fn autosave_tick(&mut self, now: f64) { + pub(crate) fn autosave_tick(&mut self, ctx: &egui::Context, now: f64) { if self.recovery.is_none() { return; } @@ -320,6 +320,14 @@ impl PdfCraftApp { self.last_autosave = now; self.autosave_now(); } + // An idle native window has no input-driven frames. Wake it at the existing + // autosave deadline, without keeping clean or already-snapshotted documents awake. + if self.session.has_pending_autosave() { + let remaining = (self.last_autosave + AUTOSAVE_SECS - now).clamp(0.0, AUTOSAVE_SECS); + if let Ok(delay) = std::time::Duration::try_from_secs_f64(remaining) { + ctx.request_repaint_after(delay); + } + } } /// Reopen recovered documents (`keys`), as unsaved changes at their original paths. @@ -407,3 +415,133 @@ impl PdfCraftApp { } } } + +#[cfg(test)] +mod idle_tests { + use super::*; + use std::time::Duration; + + struct TestDir(PathBuf); + + impl TestDir { + fn new() -> Self { + let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let path = std::env::temp_dir().join(format!("pdfcraft-idle-recovery-{}-{nonce}", std::process::id())); + std::fs::create_dir(&path).unwrap(); + Self(path) + } + fn path(&self) -> &Path { + &self.0 + } + } + + impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + fn frame(app: &mut PdfCraftApp, ctx: &egui::Context, now: f64) -> Duration { + let mut out = ctx.run_ui(egui::RawInput { time: Some(now), ..Default::default() }, |ui| { + app.autosave_tick(ui.ctx(), now); + }); + // Headless timer test: acknowledge texture uploads without a GPU backend. + out.textures_delta.clear(); + out.viewport_output[&egui::ViewportId::ROOT].repaint_delay + } + + #[test] + fn idle_dirty_document_requests_its_deadline_and_writes_without_input() { + let dir = TestDir::new(); + let store = RecoveryStore::new(dir.path()); + let mut app = PdfCraftApp::new(); + app.enable_recovery(store.clone()); + let bytes = app.session.create_blank(200.0, 300.0, 1).unwrap(); + let id = app.session.open_new("Untitled.pdf", bytes).unwrap(); + let ctx = egui::Context::default(); + let mut delay = Duration::ZERO; + for _ in 0..4 { + delay = frame(&mut app, &ctx, 15.0); + } + assert!(delay > Duration::ZERO && delay <= Duration::from_secs(45), "idle deadline: {delay:?}"); + assert!(store.list().is_empty()); + frame(&mut app, &ctx, 60.0); + assert_eq!(store.list().len(), 1, "timer frame writes the snapshot without input"); + assert!(app.session.get(id).unwrap().dirty, "autosave does not mark the document saved"); + assert!(!app.session.has_pending_autosave()); + for _ in 0..4 { + delay = frame(&mut app, &ctx, 61.0); + } + assert_eq!(delay, Duration::MAX, "unchanged snapshot needs no periodic wake"); + } + + #[test] + fn second_edit_after_autosave_schedules_next_deadline_and_updates_snapshot() { + let dir = TestDir::new(); + let store = RecoveryStore::new(dir.path()); + let mut app = PdfCraftApp::new(); + app.enable_recovery(store.clone()); + let bytes = app.session.create_blank(200.0, 300.0, 1).unwrap(); + let id = app.session.open_new("Untitled.pdf", bytes).unwrap(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, 60.0); + let entries = store.list(); + assert_eq!(entries.len(), 1); + let key = entries[0].key.clone(); + let first = store.read(&key).unwrap(); + assert!(!app.session.has_pending_autosave()); + let mut delay = Duration::ZERO; + for _ in 0..4 { + delay = frame(&mut app, &ctx, 61.0); + } + assert_eq!(delay, Duration::MAX); + + app.session.apply(id, pdfcraft_engine::Edit::SetInfo { key: "Title".into(), value: "Second edit".into() }).unwrap(); + assert!(app.session.has_pending_autosave()); + for _ in 0..4 { + delay = frame(&mut app, &ctx, 75.0); + } + assert!(delay > Duration::ZERO && delay <= Duration::from_secs(45), "second edit wakes by 120 seconds: {delay:?}"); + assert_eq!(store.read(&key).unwrap(), first, "editing does not overwrite the snapshot before its deadline"); + frame(&mut app, &ctx, 119.0); + assert_eq!(store.read(&key).unwrap(), first); + frame(&mut app, &ctx, 120.0); + let entries = store.list(); + assert_eq!(entries.len(), 1, "the same document replaces its existing snapshot"); + assert_eq!(entries[0].key, key); + let second = store.read(&key).unwrap(); + assert_ne!(second, first); + let mut recovered = pdfcraft_engine::Session::new(); + let restored = recovered.open("Snapshot.pdf", None, second.into(), None).unwrap(); + assert_eq!(recovered.get(restored).unwrap().info_value("Title"), Some("Second edit".into())); + assert!(app.session.get(id).unwrap().dirty); + assert!(!app.session.has_pending_autosave()); + for _ in 0..4 { + delay = frame(&mut app, &ctx, 121.0); + } + assert_eq!(delay, Duration::MAX, "the second snapshot also lets the window sleep"); + } + + #[test] + fn clean_documents_and_disabled_recovery_do_not_schedule_autosave() { + for recovery_enabled in [false, true] { + let dir = TestDir::new(); + let mut app = PdfCraftApp::new(); + if recovery_enabled { + app.enable_recovery(RecoveryStore::new(dir.path())); + } + let bytes = app.session.create_blank(200.0, 300.0, 1).unwrap(); + if recovery_enabled { + app.session.open("Clean.pdf", None, bytes, None).unwrap(); + } else { + app.session.open_new("Unsaved.pdf", bytes).unwrap(); + } + let ctx = egui::Context::default(); + let mut delay = Duration::ZERO; + for _ in 0..4 { + delay = frame(&mut app, &ctx, 15.0); + } + assert_eq!(delay, Duration::MAX); + } + } +} diff --git a/crates/ui-egui/src/standards_ui.rs b/crates/ui-egui/src/standards_ui.rs index 1d409f200..5fc4b3d35 100644 --- a/crates/ui-egui/src/standards_ui.rs +++ b/crates/ui-egui/src/standards_ui.rs @@ -53,7 +53,16 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool let mut standards: Vec = declared.pdfa.iter().map(|(p, c)| format!("PDF/A-{p}{}", c.to_lowercase())).collect(); standards.extend(declared.pdfua.map(|p| format!("PDF/UA-{p}"))); let shown = if standards.is_empty() { tl!("none").to_string() } else { standards.join(", ") }; - ui.label(crate::i18n::fmt(tl!("Declared conformance: {shown}"), &[("shown", &shown)])); + let removal = pdfcraft_engine::Edit::RemoveStandardId { standard: pdfcraft_engine::Standard::PdfA }; + let mut remove = false; + ui.horizontal(|ui| { + ui.label(crate::i18n::fmt(tl!("Declared conformance: {shown}"), &[("shown", &shown)])); + // Worded as Edit ▸ Undo will name it. + remove = declared.pdfa.is_some() && widgets::pill_button(ui, &crate::i18n::edit_label(&removal.label()), false).clicked(); + }); + if remove && app.apply_edit(removal) { + app.pdfa.issues = None; + } if !declared.output_intents.is_empty() { ui.label( egui::RichText::new(crate::i18n::fmt(tl!("Output intent: {list}"), &[("list", &declared.output_intents.join(", "))])).color(t.text_muted), diff --git a/crates/ui-egui/tests/compare.rs b/crates/ui-egui/tests/compare.rs index 22f65dfab..ea93c8e3b 100644 --- a/crates/ui-egui/tests/compare.rs +++ b/crates/ui-egui/tests/compare.rs @@ -197,6 +197,7 @@ fn pdfa_dialog_verifies_and_converts() { assert!(h.state_mut().execute("standards.pdfa")); h.run_steps(2); h.get_by_label("Declared conformance: none"); + assert!(h.query_by_label("Remove PDF/A identification").is_none(), "nothing to remove yet"); h.get_by_label("Verify").click(); h.run_steps(3); h.get_by_label("The document has no XMP metadata"); @@ -207,6 +208,10 @@ fn pdfa_dialog_verifies_and_converts() { assert!(issues.iter().all(|i| !i.fixable), "{issues:?}"); let id = h.state().active_ids().unwrap().1; assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Save as PDF/A-2b")); + h.get_by_label("Remove PDF/A identification").click(); + h.run_steps(3); + h.get_by_label("Declared conformance: none"); + assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Remove PDF/A identification")); } /// A one-page PDF whose XMP declares the given `pdfaid`/`pdfuaid` attributes. diff --git a/docs/gaps.md b/docs/gaps.md index 96869e3b1..fa0261039 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -3,6 +3,7 @@ > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-19 · **Change:** minor (gap 2: composite fonts with `/ToUnicode /Identity-H` read as Unicode, hayro-interpret patch 44; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (gap 6 #3673: Character encoding CJK mapping routes corrected; no readiness re-estimate) +> **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (gap 6: #2662 redaction audit counts a cover over one word of a long line; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (gap 12: reading order is scored, not just word F1) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M1: raw-deflate fallback preserves decoding-limit errors; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (gap 14: Vietnamese catalog shipped; Hindi/Indonesian/Korean and Arabic RTL remain) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -143,7 +144,9 @@ update the numbers. read, selected and scrolled into view (#1910). Stamps placed on a rotated page read upright (#1398). Another batch keeps a spaced XFA radio value in the datasets (#1319), replaces only the selected pages when they are not adjacent (#1305), and leaves unrelated page streams in place - when a figure is marked decorative (#1324). The rest of the backlog is untriaged. + when a figure is marked decorative (#1324). A black box over one word of a long line, and an + unapplied redaction mark over it, audit as a cover instead of a clean bill (#2662). The rest of + the backlog is untriaged. - **Fix verified (2026-10-14):** raw-deflate fallback returns a decoding-limit error instead of accepting an empty partial stream when a misleading zlib header triggers a retry. Synthetic filter and COS stream regressions cover refusal at the limit and decoding within it. @@ -397,6 +400,7 @@ update the numbers. | 2026-10-19 | minor | Gap 2: a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (WSCAD) reads its codes as Unicode, so such pages have text to select, find and edit (hayro-interpret patch 44, `view.render-cid-fonts`). Estimates unchanged. | | 2026-10-15 | minor | Gap 21: the side-by-side view had already shipped; the Compare panel filters text changes and visual differences. Estimate 10–20 h → 8–16 h | | 2026-10-20 | minor | Gap 6 #3673: Character encoding accepts Identity-H/V with Adobe CJK collections, or a predefined non-Identity CMap; negative and indirect-reference regressions and a read-only automation test passed after failing before the fix. Per-glyph validation and embedded-font extraction remain separate. Scores and estimates unchanged. | +| 2026-10-17 | minor | Gap 6: the redaction audit counts a cover over one word of a long line instead of a clean bill (#2662, fixed by #2698). Estimates unchanged. | | 2026-10-15 | minor | Gap 12: the text oracle scores reading order as well as word F1, which is a bag of words and cannot see order; the first measurement separated 18 improvements from 6 regressions in a change F1 called unchanged on 26 of 29 files. Estimates unchanged. | | 2026-10-14 | minor | M1: raw-deflate fallback preserves decoding-limit errors in strict and tolerant decoding; synthetic filter and COS stream regressions. Estimates unchanged. | | 2026-10-15 | patch | Gap 24: record localization and installers as partial (unusable Chinese, Telugu and Arabic; unsigned Linux packages) | diff --git a/docs/roadmap.md b/docs/roadmap.md index 738dbbda6..172fb9205 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -11,6 +11,9 @@ > **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M10, #3532 partial: vector printing retains existing appearances with absent Subtype; estimates unchanged) > **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M12 #3673: Character encoding accepts Identity-H/V with Adobe CJK collections; estimates unchanged) +> **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M11 #3507: image merging preserves content stream boundaries; estimates unchanged) +> **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (M8, gap 6 #2662: the redaction audit counts a cover over one word of a long line; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M10: dotted-leader contents entries export as complete paragraphs; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M5, PR #1971: bounded nested FDF export excludes source page/page-tree dictionaries; refreshed against upstream 4db6ac4a; estimates unchanged) > **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** patch (M2 #3086: dotted TOC leaders no longer hide word spaces; estimates unchanged) @@ -38,6 +41,7 @@ > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M3 candidate: native opens retain chosen tools through password retries and defer startup intake while preparation is pending; 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:** minor (M4 #1310 #1311 #1312: undo, revert and the next save keep the last saved revision; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-15 · **Change:** minor (M14: PNG export writes opaque pages as RGB at deflate level 3, the same size 3–5× faster; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-22 · **Last updated:** 2026-10-22 · **Change:** minor (M14: JPEG and TIFF export refuse a raster of the wrong length instead of panicking; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M3 #1980: exact quit modifiers preserve Ctrl+Alt+Q email input; M3 candidate: native opens retain chosen tools through password retries and defer startup intake while preparation is pending; 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:** minor (M13: CLI check visits each directory once through aliases and cycles; 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:** minor (M4: Extract and Split preserve shared Type 3 glyph resources; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -70,6 +74,7 @@ > **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M12 #2572: Every repeated page-master button resolves its own field to the shared XFA click script, including print buttons. Estimates unchanged) > **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M12 #2570: Dynamic XFA rich-text fields carry exData/maxLength into AcroForm MaxLen for reported and enforced Unicode character limits, retained after save/reopen. Estimates unchanged) > **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M4 #3309: bounded iterative named-destination pruning on page deletion; estimates unchanged) +> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M1 #2658: macOS atomic saves retain destination extended attributes, including Finder tags, and reject partial resource-fork reads after size changes. Metadata failures retain the original PDF and unsaved edit. Estimates unchanged) 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), @@ -79,6 +84,11 @@ live in `plan/execution-plan.md` (local-only, gitignored). Keep it current: update the milestone row and the [ROADMAP.md](../ROADMAP.md) progress log at the end of every session; re-estimate when a milestone lands. +Recovery scheduling (#2663): a native window with a pending dirty snapshot requests a +repaint at the existing 60-second autosave deadline, even with no mouse or keyboard input. +Clean and already-snapshotted documents do not schedule recovery wakeups. Snapshot contents, +encryption and storage-write retry behavior are unchanged; this does not resolve #1803. + ## Alpha gate The stage standard's gate: Acrobat Pro's everyday core workflows must each work end to end on the @@ -138,6 +148,8 @@ total matches the parity estimate. | 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. Forms without Resources use page Properties for ActualText and layer visibility; own dictionaries stop fallback. Oversized missing inline-image samples, including header and terminator overhead, no longer pin the content window (#3107 / PR #3438); normal large streamed images retain deterministic sample boundaries. Radial luminosity fades have synthetic coverage for the existing #2132 mask guard, including per-pixel references, antialiased clips, previews, scales and tiles; no additional production fix was needed. | | 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. Dotted TOC leaders no longer inflate letter tracking or hide word spaces (#3086); phrase search and selection are covered. Short tracked ellipses retain their gap samples and remain searchable. | | 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. Missing spacing glyphs in non-embedded Japanese CID substitutes render blank without changing PDF advances or Unicode (#3601). | +| 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`). Raw-deflate fallback preserves decoding-limit errors, including through tolerant COS stream decoding. Done since: #2658: macOS atomic saves retain destination extended attributes, including Finder tags, and reject partial resource-fork reads after size changes. Metadata failures retain the original PDF and unsaved edit. 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 | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). #3808: the Pages inspector offers a 50–150% thumbnail slider and buttons, also available through `ui.set`. Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. #3149: page-list changes discard render demand based on old geometry; actual raster dimensions are checked against the GPU limit before upload. Synthetic crash and continued-rendering regressions cover the replacement path. Save-prompt shortcuts yield to dialogs above them; stacked link-consent UI regression covers Escape and Return. | @@ -171,12 +183,15 @@ total matches the parity estimate. | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Existing simple field-font encodings and Differences are respected when generating appearances; an unencodable value uses a separate Helvetica appearance font without changing the source font. Synthetic reopen regressions cover punctuation, subset fallback and multiline text. Exact font metrics/layout and broader Unicode fallback remain incomplete. | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Tab and Shift-Tab wrapping to the same field or another widget of that field refresh the newly opened editor from the accepted value before Save. Synthetic UI regressions cover both directions, same-frame Save, validation refusal and undo/redo. Renaming a field renames it in the calculations that read it (field lists, notation, literal `getField` names), in one undo step, and says how many changed. Prepare a form ▸ Calculation order, a page of its own: each calculated field's formula and value, what is wrong (fields that calculate too early, loops, fields missing from `/CO`) and Fix order, Up/Down, Alt+↑/↓ and drag, and on the page each field's step and arrows to and from the field in focus (`form_calculation_order` headlessly). | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | -| M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.
Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Redaction retires inherited resource dictionaries, so a parent `/Pages` can't keep a removed XObject reachable; unredacted pages are left untouched. Image redaction removes alternate images and unsupported masks only on affected images. Changed marked-content spans lose replacement descriptions; untouched spans keep theirs. Hidden-text cleanup retires unused named descriptions across inherited/shared page and form resources (64 focused tests and scoped Rust 1.96 Clippy/wasm checks pass; publication CI pending). Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding | +| M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.
Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Redaction retires inherited resource dictionaries, so a parent `/Pages` can't keep a removed XObject reachable; unredacted pages are left untouched. Image redaction removes alternate images and unsupported masks only on affected images. Changed marked-content spans lose replacement descriptions; untouched spans keep theirs. Hidden-text cleanup retires unused named descriptions across inherited/shared page and form resources (64 focused tests and scoped Rust 1.96 Clippy/wasm checks pass; publication CI pending). The redaction audit counts a filled cover that hides half of any one word of a long line (#2662), applied or not. Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding | | M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.
Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites, page validation overlays separate from original signature artwork (baked Not Verified captions remain; #79 open), snapshot tabs that reuse instead of multiplying. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 | | M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).
Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Lossless image export applies per-component Decode, including Indexed mapping (#1201 partial). Existing 16-bit precision and color-management limits remain. #3532 partial: vector printing retains existing appearances with absent Subtype through a print-owned Form copy; source artwork stays unchanged. #3120: Office export reads form XObjects 50 levels deep, as the renderer does, and up to 50 000 a page; pages past that are listed in `truncated_pages`. Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import #2156 candidate: last selected print paper persists across sessions; other print preferences unchanged. | | M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA | | M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds.
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded, text-only auto-tag proposal tool for one page. Marking a figure decorative rewrites only that stream (#1324). An XFA radio item whose value contains a space is written back unchanged (#1319). Missing: writing proposed tags and parent tree, field autotag, visual Tags/Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA
Reading colours: pending page backgrounds follow the configured colour (#2059). | | M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds.
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded, text-only auto-tag proposal tool for one page. Marking a figure decorative rewrites only that stream (#1324). An XFA radio item whose value contains a space is written back unchanged (#1319). Done since: #2569: Growable multiline XFA text reflows after edits, with undo/redo, save/reopen and print coverage; oversized fields still become read-only page text. Structured-data synchronization warnings retain accepted whitespace without relayout from stale data. Done since: #2572: Every repeated page-master button resolves its own field to the shared XFA click script, including print buttons. Done since: #2570: Dynamic XFA rich-text fields carry exData/maxLength into AcroForm MaxLen for reported and enforced Unicode character limits, retained after save/reopen. Missing: writing proposed tags and parent tree, field autotag, visual Tags/Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA | +| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).
Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Lossless image export applies per-component Decode, including Indexed mapping (#1201 partial). Existing 16-bit precision and color-management limits remain. #3120: Office export reads form XObjects 50 levels deep, as the renderer does, and up to 50 000 a page; pages past that are listed in `truncated_pages`. Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import. Done since: dotted-leader contents entries stay in separate complete paragraphs (#1757) | +| M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Content stream boundaries preserve comments, split operands and graphics state when image fragments merge. Done since: reject invalid image and soft-mask dimensions with checked sizing (#2178). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA | +| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds.
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded, text-only auto-tag proposal tool for one page. Marking a figure decorative rewrites only that stream (#1324). An XFA radio item whose value contains a space is written back unchanged (#1319). Missing: writing proposed tags and parent tree, field autotag, visual Tags/Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA | | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers
Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. CLI robustness scans visit each directory once through overlapping roots and symlink aliases. | | M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds.
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded text-only auto-tag proposal tool; text-only Apply writes marked content, paragraph tags and a parent tree, then opens the Tags panel for review. Missing: mixed-content and field autotag, heading/list/table/figure classification, Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA
Done since: CLI info, text and render use dynamic XFA layout pages, with synthetic count/text/pixel and copy-permission regressions (#1645).
Reading colours: pending page backgrounds follow the configured colour (#2059). | | M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds. Character encoding accepts Identity-H/V with Adobe CJK collections and predefined CJK CMaps independently (#3673); per-glyph validation and embedded-font extraction remain separate.
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded, text-only auto-tag proposal tool for one page. Marking a figure decorative rewrites only that stream (#1324). An XFA radio item whose value contains a space is written back unchanged (#1319). Missing: writing proposed tags and parent tree, field autotag, visual Tags/Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA | @@ -235,6 +250,9 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st |---|---|---| | 2026-10-20 | minor | M4: initial-view window and interface options resolve indirect boolean ViewerPreferences values; synthetic true/false and save/reopen regressions retain defaults for malformed entries. Estimates unchanged. | | 2026-10-21 | minor | M3, #3808: the Pages inspector has an independent 50–150% thumbnail size slider and buttons; selection and current-page following are retained, with headless ui.set coverage. Estimates unchanged. | +| 2026-10-20 | minor | M11 #3507: image merging preserves comments and state across content streams. Estimates unchanged. | +| 2026-10-17 | minor | M8, gap 6 #2662: a filled cover that hides half of any one word of a long line counts as a redaction-audit finding, applied or not; a sliver under half of every word stays unflagged. Estimates unchanged. | +| 2026-10-13 | minor | M10: dotted-leader contents entries retain titles and page numbers in separate paragraphs (#1757); estimates unchanged. | | 2026-10-20 | minor | M6: Prepare a form ▸ Calculation order: formulas and values, what is wrong and Fix order, Up/Down, Alt+↑/↓ and drag, links on the page. Estimates unchanged. | | 2026-10-20 | minor | M6: `form_calculation_order` reads, checks, fixes and sets the calculation order; an edited formula keeps its place in `/CO`. Estimates unchanged. | | 2026-10-20 | minor | M12 #3673: Character encoding accepts Identity-H/V with Adobe CJK collections, or a predefined non-Identity CMap; unknown names and foreign registries do not establish a mapping route. Synthetic checker and read-only automation regressions passed after failing before the fix. Per-glyph validation and embedded-font extraction remain separate; estimates unchanged. | @@ -269,6 +287,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | 2026-10-12 | minor | M1 #1338: a full rewrite keeps unknown trailer entries and objects reachable only from them. Estimates unchanged. | | 2026-10-12 | minor | M7 #1443: adding a word to a multi-line paragraph keeps its line breaks. Composite subset fonts whose `/W` leaves out default-width glyphs (Chrome, Skia) no longer measure those letters as missing, and paragraphs drawn turned on the page group into rows along their own baselines. Estimates unchanged. | | 2026-10-15 | minor | M14: PNG export (Export ▸ Image, `pdfcraft-cli render`, `page_render`) writes opaque pages as RGB at deflate level 3: the same file size, 3–5× faster to write. Estimates unchanged. | +| 2026-10-22 | minor | M14: JPEG and TIFF export (Export ▸ Image) refuse a raster that is not exactly width × height pixels, as PNG export does, instead of panicking in the encoder or dropping a partial pixel. 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. | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index a02c942eb..a2cf568a6 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -202,8 +202,8 @@ milestone = "M1" status = "shipped" commands = ["protect.unlock"] tools = ["doc_open"] -evidence = ["crates/engine/src/tests.rs::restricted_documents_refuse_changes_unless_opened_by_the_owner", "crates/engine/src/tests.rs::owner_password_of_older_revisions_opens_the_viewer_too", "crates/crypt/src/tests.rs::owner_authentication_recovers_the_user_password", "crates/engine/src/tests.rs::the_permissions_password_unlocks_an_open_document", "crates/ui-egui/tests/editing.rs::the_security_notice_unlocks_with_the_permissions_password", "crates/ui-egui/tests/editing.rs::remove_security_asks_for_the_permissions_password_first", "crates/ui-egui/tests/pickers.rs::the_permissions_password_can_be_typed_through_an_input_method"] -notes = "at open (the password prompt takes either password), or after opening a restricted file that needed no password (#995, #1077): Unlock… on the security notice and in Properties ▸ Security, and changing security asks for it first. Not yet: an automation tool to unlock an open document (reopen with doc_open and the password instead)" +evidence = ["crates/engine/src/tests.rs::restricted_documents_refuse_changes_unless_opened_by_the_owner", "crates/engine/src/tests.rs::owner_password_of_older_revisions_opens_the_viewer_too", "crates/engine/src/tests.rs::render_password_for_recovers_the_user_password_of_older_revisions", "apps/pdfcraft-cli/tests/owner_password.rs::info_accepts_the_owner_password", "apps/pdfcraft-cli/tests/owner_password.rs::text_accepts_the_owner_password", "crates/crypt/src/tests.rs::owner_authentication_recovers_the_user_password", "crates/engine/src/tests.rs::the_permissions_password_unlocks_an_open_document", "crates/ui-egui/tests/editing.rs::the_security_notice_unlocks_with_the_permissions_password", "crates/ui-egui/tests/editing.rs::remove_security_asks_for_the_permissions_password_first", "crates/ui-egui/tests/pickers.rs::the_permissions_password_can_be_typed_through_an_input_method"] +notes = "at open (the password prompt takes either password), or after opening a restricted file that needed no password (#995, #1077): Unlock… on the security notice and in Properties ▸ Security, and changing security asks for it first. The CLI's info, text, render and audit take either password too (#2670). Not yet: an automation tool to unlock an open document (reopen with doc_open and the password instead)" [[feature]] id = "core.permissions-enforced" @@ -286,8 +286,8 @@ milestone = "M1" status = "shipped" commands = ["file.save"] tools = ["doc_save"] -evidence = ["crates/ui-egui/tests/editing.rs::save_writes_an_incremental_update_and_clears_dirty", "crates/engine/src/tests.rs::save_is_incremental_stamps_mod_date_and_rebases", "crates/organize/src/tests.rs::successive_incremental_saves_stack_revisions", "crates/automation/src/lib.rs::staging_never_writes_through_a_file_planted_at_its_name", "crates/ui-egui/src/editing.rs::saving_never_writes_through_a_file_planted_at_the_staging_name"] -notes = "The atomic write stages to a new, randomly named file created with create_new, so a file or link planted at the staging path is never written through (desktop app, automation and CLI)." +evidence = ["crates/platform/src/staging.rs::atomic_saves_keep_finder_tags_and_binary_extended_attributes", "crates/platform/src/staging.rs::a_growing_resource_fork_refuses_publication_and_keeps_original", "crates/automation/tests/automation.rs::saving_a_pdf_keeps_the_destinations_extended_attributes", "crates/automation/tests/automation.rs::a_metadata_preservation_error_keeps_the_pdf_and_unsaved_edit", "crates/ui-egui/tests/editing.rs::save_writes_an_incremental_update_and_clears_dirty", "crates/engine/src/tests.rs::save_is_incremental_stamps_mod_date_and_rebases", "crates/organize/src/tests.rs::successive_incremental_saves_stack_revisions", "crates/automation/src/lib.rs::staging_never_writes_through_a_file_planted_at_its_name", "crates/ui-egui/src/editing.rs::saving_never_writes_through_a_file_planted_at_the_staging_name"] +notes = "The atomic write stages to a new, randomly named file created with create_new, so a file or link planted at the staging path is never written through (desktop app, automation and CLI). On macOS, accessible destination xattrs (including Finder tags) are copied before flush/rename; errors, metadata limits or a changed post-read size refuse the save without replacing the file. System-managed provenance is excluded. Permissions, ACLs, hard-link identity and creation dates are not preserved by this change (#2658)." [[feature]] id = "core.save-as-full-rewrite" @@ -1321,9 +1321,9 @@ tier = "P0" milestone = "M3" status = "shipped" commands = [] -tools = ["doc_info"] -evidence = ["crates/render/src/raster.rs::layer_override_hides_content", "crates/engine/src/tests.rs::layer_choices_survive_edits"] -notes = "" +tools = ["doc_info", "layer_set"] +evidence = ["crates/render/src/raster.rs::layer_override_hides_content", "crates/engine/src/tests.rs::layer_choices_survive_edits", "crates/automation/tests/automation.rs::layers_and_hidden_comments_change_what_page_render_draws"] +notes = "layer_set shows or hides a layer by name (every group with that name, as CAD exporters repeat names) or by position, and page_render draws with the document's layer visibility. Still missing: Reset to initial visibility (view.layers-reset), layer properties, and /OC on annotations (#2176)." [[feature]] id = "view.layers-reset" @@ -7000,8 +7000,8 @@ milestone = "M10" status = "partial" commands = ["export.docx"] tools = ["doc_export_office"] -evidence = ["crates/export/src/lib.rs::word_does_not_invent_borders_for_aligned_invoice_fields", "crates/export/src/lib.rs::word_overlap_does_not_consume_invoice_body_space", "crates/export/src/lib.rs::word_keeps_a_page_with_a_clear_image_and_big_title_in_flow", "crates/export/src/lib.rs::word_rotated_pages_keep_separate_page_sizes_and_unique_shapes", "crates/engine/src/tests.rs::office_export_positions_follow_the_displayed_crop_rotation_and_units", "crates/automation/tests/automation.rs::exporting_an_invoice_to_word_keeps_the_watermark_out_of_flow", "crates/export/src/lib.rs::docx_is_a_word_package", "crates/export/src/lib.rs::text_colour_survives_every_format", "crates/engine/src/tests.rs::exporting_office_files_keeps_text_colour", "crates/export/src/lib.rs::grid_text_becomes_a_real_table_in_every_format", "crates/export/src/lib.rs::a_link_inside_a_sentence_is_not_a_table_column", "crates/export/src/lib.rs::sentences_that_run_on_into_links_are_not_a_table", "crates/export/src/lib.rs::a_superscript_beside_a_number_keeps_its_table", "crates/export/src/lib.rs::word_opens_text_with_control_characters_and_long_pages", "crates/engine/src/tests.rs::exporting_office_files_keeps_images", "crates/automation/tests/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/compare.rs::export_to_word_html_and_rtf"] -notes = "Flowing Word document: paragraphs, Heading 1/2 from larger text, bold/italic, text colour (fill as sRGB; gray/CMYK converted naively, colour spaces via cs/sc read as black), borderless detected tables with spans/empty cells (a row whose blocks run on into each other, such as a link or superscript inside a sentence, or overlap, as a paragraph's lines do, is text and not a table row, #2806), per-page sections/sizes, and positioned editable text/artwork only for pages with rotated text or text overlapped by an image or watermark-sized text, other pages flowing with inline images (#773); synthetic invoice regression; the emitted package validates with Open XML SDK 2.20 (Office 2010 schema). Word/LibreOffice rendering of the positioned output remains unverified. Pictures on positioned pages are DrawingML anchors behind the text (a VML rectangle with an image showed no picture, #1936; not yet opened in Word); a Word file embeds at most 1000 pictures and 512 MiB of them. The earlier flowing output was checked with macOS textutil and opened in Microsoft Word (Windows). Characters XML forbids are dropped; the page size stays within Word's 22 in. Not yet: exact font matching, vector rules, arbitrary paint-order/transparency, colour per run within a paragraph, general exact layout fidelity." +evidence = ["crates/edit/src/tests.rs::reading_keeps_dotted_leader_entries_whole_and_in_order", "crates/engine/src/tests.rs::exporting_office_keeps_dotted_leader_entries_as_paragraphs", "crates/export/src/lib.rs::word_does_not_invent_borders_for_aligned_invoice_fields", "crates/export/src/lib.rs::word_overlap_does_not_consume_invoice_body_space", "crates/export/src/lib.rs::word_keeps_a_page_with_a_clear_image_and_big_title_in_flow", "crates/export/src/lib.rs::word_rotated_pages_keep_separate_page_sizes_and_unique_shapes", "crates/engine/src/tests.rs::office_export_positions_follow_the_displayed_crop_rotation_and_units", "crates/automation/tests/automation.rs::exporting_an_invoice_to_word_keeps_the_watermark_out_of_flow", "crates/export/src/lib.rs::docx_is_a_word_package", "crates/export/src/lib.rs::text_colour_survives_every_format", "crates/engine/src/tests.rs::exporting_office_files_keeps_text_colour", "crates/export/src/lib.rs::grid_text_becomes_a_real_table_in_every_format", "crates/export/src/lib.rs::a_link_inside_a_sentence_is_not_a_table_column", "crates/export/src/lib.rs::sentences_that_run_on_into_links_are_not_a_table", "crates/export/src/lib.rs::a_superscript_beside_a_number_keeps_its_table", "crates/export/src/lib.rs::word_opens_text_with_control_characters_and_long_pages", "crates/engine/src/tests.rs::exporting_office_files_keeps_images", "crates/automation/tests/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/compare.rs::export_to_word_html_and_rtf", "crates/export/src/lib.rs::docx_keeps_a_shared_page_background", "crates/edit/src/tests.rs::a_page_painted_first_with_a_full_page_fill_has_that_background", "crates/engine/src/tests.rs::exporting_to_word_keeps_the_page_background"] +notes = "Flowing Word document: paragraphs, Heading 1/2 from larger text, bold/italic, text colour (fill as sRGB; gray/CMYK converted naively, colour spaces via cs/sc read as black), borderless detected tables with spans/empty cells (a row whose blocks run on into each other, such as a link or superscript inside a sentence, or overlap, as a paragraph's lines do, is text and not a table row, #2806), per-page sections/sizes, the page colour (a full-page opaque fill painted first, such as Edit a PDF ▸ Background) as the document background when every page shares one non-white colour (Word has no per-page background, so differing colours are dropped; #2992; checked in the XML only, not yet opened in Word), and positioned editable text/artwork only for pages with rotated text or text overlapped by an image or watermark-sized text, other pages flowing with inline images (#773); synthetic invoice regression; dotted-leader contents entries keep the title, dots and page number together in separate paragraphs (#1757); the emitted package validates with Open XML SDK 2.20 (Office 2010 schema). Word/LibreOffice rendering of the positioned output remains unverified. Pictures on positioned pages are DrawingML anchors behind the text (a VML rectangle with an image showed no picture, #1936; not yet opened in Word); a Word file embeds at most 1000 pictures and 512 MiB of them. The earlier flowing output was checked with macOS textutil and opened in Microsoft Word (Windows). Characters XML forbids are dropped; the page size stays within Word's 22 in. Not yet: exact font matching, vector rules, arbitrary paint-order/transparency, colour per run within a paragraph, general exact layout fidelity." [[feature]] id = "create.export-rtf" @@ -7076,8 +7076,8 @@ milestone = "M10" status = "shipped" commands = ["export.image"] tools = ["doc_export_images"] -evidence = ["crates/engine/src/tests.rs::pages_export_as_png_jpeg_and_tiff", "crates/automation/tests/automation.rs::exporting_images_and_text_through_tools"] -notes = "JPEG with quality, TIFF; flattened onto white" +evidence = ["crates/engine/src/tests.rs::pages_export_as_png_jpeg_and_tiff", "crates/automation/tests/automation.rs::exporting_images_and_text_through_tools", "crates/engine/src/export.rs::rasters_of_the_wrong_length_are_rejected"] +notes = "JPEG with quality, TIFF; flattened onto white; a raster that is not exactly its width × height in pixels is refused, as for PNG" [[feature]] id = "create.export-jpeg2000" @@ -7284,7 +7284,7 @@ milestone = "M11" status = "partial" commands = ["optimize.advanced"] tools = ["doc_optimize"] -evidence = ["crates/ui-egui/tests/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy", "crates/automation/tests/automation.rs::optimizing_through_tools", "crates/ui-egui/src/optimize_ui.rs::the_progress_card_shows_the_stage_and_cancels", "crates/engine/src/tests.rs::an_optimize_job_reports_its_stages_and_can_be_cancelled", "crates/optimize/tests/optimize.rs::images_and_forms_nothing_draws_are_dropped", "crates/automation/tests/automation.rs::a_deleted_image_is_not_kept_by_reduce_or_optimize", "crates/ui-egui/tests/create.rs::the_pdf_optimizer_dialog_picks_a_preset", "crates/automation/tests/automation.rs::a_page_printed_in_strips_gets_small_through_tools", "crates/optimize/tests/optimize.rs::the_mobile_preset_makes_a_printed_page_small"] +evidence = ["crates/automation/tests/optimize_content_boundaries.rs::fragment_merge_preserves_a_comment_across_content_pieces", "crates/ui-egui/tests/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy", "crates/automation/tests/automation.rs::optimizing_through_tools", "crates/ui-egui/src/optimize_ui.rs::the_progress_card_shows_the_stage_and_cancels", "crates/engine/src/tests.rs::an_optimize_job_reports_its_stages_and_can_be_cancelled", "crates/optimize/tests/optimize.rs::images_and_forms_nothing_draws_are_dropped", "crates/automation/tests/automation.rs::a_deleted_image_is_not_kept_by_reduce_or_optimize", "crates/ui-egui/tests/create.rs::the_pdf_optimizer_dialog_picks_a_preset", "crates/automation/tests/automation.rs::a_page_printed_in_strips_gets_small_through_tools", "crates/optimize/tests/optimize.rs::the_mobile_preset_makes_a_printed_page_small"] notes = "Settings menu with the Standard and Mobile presets (Custom once an option changes; doc_optimize preset), Images (with Merge image fragments: a page picture drawn as one-pixel strips, as some PDF printers write it, is put back together so it can be resampled), Discard Objects, Discard User Data, Clean Up (also drops images and forms the pages list but never draw, such as deleted images); runs in the background with a progress bar (per image) and Cancel; no Fonts or Transparency panels, no saved custom presets, no space audit" [[feature]] @@ -7917,11 +7917,11 @@ title = "Remove PDF/A or PDF/X identification" area = "K" tier = "P2" milestone = "M11" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["standards.pdfa"] +tools = ["standards_remove_id"] +evidence = ["crates/organize/src/tests.rs::removing_the_pdfx_identification_keeps_the_output_intent_and_other_claims", "crates/organize/src/tests.rs::removing_the_pdfa_identification_removes_its_xmp_properties_in_either_form", "crates/automation/tests/automation.rs::pdfa_through_tools", "crates/ui-egui/tests/compare.rs::pdfa_dialog_verifies_and_converts"] +notes = "Standards ▸ PDF/A ▸ Remove PDF/A identification, and the standards_remove_id tool (pdfa or pdfx): removes the pdfaid: XMP properties, or GTS_PDFXVersion and GTS_PDFXConformance from the document information and the pdfxid: XMP properties. Output intents and other claims (PDF/UA) stay; a packet that can't be edited safely is left as it is. Not yet: PDF/X removal in the interface (there is no PDF/X dialog yet)" [[feature]] id = "optimize.pdfx5-reference-xobjects" diff --git a/vendor/lopdf/examples/merge.rs b/vendor/lopdf/examples/merge.rs index 151440c84..0fee85b51 100644 --- a/vendor/lopdf/examples/merge.rs +++ b/vendor/lopdf/examples/merge.rs @@ -92,7 +92,7 @@ fn main() { )); // Can set bookmark formatting and color per report bookmark added. - // Formating is 1 for italic 2 for bold 3 for bold and italic + // Formatting is 1 for italic 2 for bold 3 for bold and italic // Color is RGB 0.0..255.0 for (layer, mut doc) in documents { let color = [0.0, 0.0, 0.0]; diff --git a/xtask/src/demo_pdf/chrome.rs b/xtask/src/demo_pdf/chrome.rs index 7d4cbf91f..d1d098c2c 100644 --- a/xtask/src/demo_pdf/chrome.rs +++ b/xtask/src/demo_pdf/chrome.rs @@ -96,22 +96,18 @@ pub fn print_to_pdf(chrome: &Path, html: &Path, pdf: &Path, log: &Path) -> Resul let mut quiet_polls = 0; loop { if let Some(status) = child.try_wait()? { - if is_complete(pdf) { + // On Windows the launched `chrome.exe` can exit while its child processes are still + // writing the PDF, so give the file up to 60 s to appear, and only accept it once the + // size has been quiet and the file complete for 5 consecutive one-second polls. + if wait_for_settled_pdf(pdf, Duration::from_secs(60), Duration::from_millis(1000), 5) { return Ok(()); } bail!("Chrome exited with {status} without writing {}; see {}", pdf.display(), log.display()); } - let len = fs::metadata(pdf).map(|m| m.len()).ok(); - if len == last_len && len.is_some() && is_complete(pdf) { - quiet_polls += 1; - if quiet_polls >= 10 { - stop(&mut child); - return Ok(()); - } - } else { - quiet_polls = 0; + if poll_settled_pdf(pdf, &mut last_len, &mut quiet_polls, 10) { + stop(&mut child); + return Ok(()); } - last_len = len; if started.elapsed() > Duration::from_secs(120) { stop(&mut child); bail!("Chrome timed out printing {}; see {}", html.display(), log.display()); @@ -176,10 +172,58 @@ fn is_complete(pdf: &Path) -> bool { fs::read(pdf).map(|bytes| bytes.len() > 1024 && bytes[bytes.len() - 1024..].windows(5).any(|w| w == b"%%EOF")).unwrap_or(false) } +/// Check whether `pdf` is complete and unchanged since `last_len`, updating `last_len` and +/// `quiet_polls`. Returns `true` once `quiet_polls` reaches `required_quiet_polls`. +fn poll_settled_pdf(pdf: &Path, last_len: &mut Option, quiet_polls: &mut usize, required_quiet_polls: usize) -> bool { + let len = fs::metadata(pdf).map(|m| m.len()).ok(); + let settled = if len == *last_len && len.is_some() && is_complete(pdf) { + *quiet_polls += 1; + *quiet_polls >= required_quiet_polls.max(1) + } else { + *quiet_polls = 0; + false + }; + *last_len = len; + settled +} + +/// Poll `pdf` after the top-level Chrome process exits until the file exists, is complete, and +/// keeps the same byte length across `required_quiet_polls` consecutive polls of `poll_interval`, +/// or until `timeout` elapses. +fn wait_for_settled_pdf(pdf: &Path, timeout: Duration, poll_interval: Duration, required_quiet_polls: usize) -> bool { + let deadline = Instant::now() + timeout; + let mut last_len = None; + let mut quiet_polls = 0; + while Instant::now() < deadline { + if poll_settled_pdf(pdf, &mut last_len, &mut quiet_polls, required_quiet_polls) { + return true; + } + thread::sleep(poll_interval); + } + false +} + #[cfg(test)] mod tests { use super::*; + struct TestDir(PathBuf); + + impl TestDir { + fn new(tag: &str) -> Self { + let dir = env::temp_dir().join(format!("pdfcraft-chrome-{tag}-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + Self(dir) + } + } + + impl Drop for TestDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + #[test] fn args_disable_code_sign_clone_and_use_throwaway_profile() { let args = chrome_args(Path::new("/tmp/prof"), OsString::from("--print-to-pdf=out.pdf"), "file:///a.html"); @@ -196,4 +240,81 @@ mod tests { drop(profile); assert!(!dir.exists()); } + + #[test] + fn post_exit_wait_accepts_pdf_written_and_settled_after_launcher_exits() { + let dir = TestDir::new("exit-test"); + let pdf = dir.0.join("chrome.pdf"); + + let pdf_writer = pdf.clone(); + let writer = thread::spawn(move || { + thread::sleep(Duration::from_millis(20)); + // Partial write without `%%EOF` while child processes are still rendering. + let partial = vec![b'%'; 1500]; + fs::write(&pdf_writer, &partial).unwrap(); + + thread::sleep(Duration::from_millis(20)); + let mut complete = partial; + complete.extend_from_slice(&[b' '; 512]); + complete.extend_from_slice(b"\n%%EOF\n"); + fs::write(&pdf_writer, &complete).unwrap(); + complete.len() as u64 + }); + + assert!(wait_for_settled_pdf(&pdf, Duration::from_secs(5), Duration::from_millis(10), 5)); + let final_len = fs::metadata(&pdf).unwrap().len(); + let expected_len = writer.join().unwrap(); + assert_eq!(final_len, expected_len); + } + + #[test] + fn poll_settled_pdf_resets_quiet_count_when_incremental_eof_grows() { + let dir = TestDir::new("increment-test"); + let pdf = dir.0.join("chrome.pdf"); + + let mut last_len = None; + let mut quiet_polls = 0; + + // Missing file never settles. + assert!(!poll_settled_pdf(&pdf, &mut last_len, &mut quiet_polls, 5)); + assert_eq!(quiet_polls, 0); + + // First Skia increment already ends with `%%EOF`. + let mut first_increment = vec![b'%'; 1500]; + first_increment.extend_from_slice(b"\n%%EOF\n"); + fs::write(&pdf, &first_increment).unwrap(); + + // First observation records size; next 4 polls increment `quiet_polls` to 4 (< 5). + assert!(!poll_settled_pdf(&pdf, &mut last_len, &mut quiet_polls, 5)); + assert_eq!(quiet_polls, 0); + for expected_quiet in 1..=4 { + assert!(!poll_settled_pdf(&pdf, &mut last_len, &mut quiet_polls, 5)); + assert_eq!(quiet_polls, expected_quiet); + } + + // Second Skia increment grows the file before the 5th quiet poll: counter resets to 0. + let mut final_increment = first_increment; + final_increment.extend_from_slice(&[b' '; 1024]); + final_increment.extend_from_slice(b"\n%%EOF\n"); + fs::write(&pdf, &final_increment).unwrap(); + + assert!(!poll_settled_pdf(&pdf, &mut last_len, &mut quiet_polls, 5)); + assert_eq!(quiet_polls, 0); + + // Only after 5 consecutive unchanging polls at the new size does it report settled. + for expected_quiet in 1..=4 { + assert!(!poll_settled_pdf(&pdf, &mut last_len, &mut quiet_polls, 5)); + assert_eq!(quiet_polls, expected_quiet); + } + assert!(poll_settled_pdf(&pdf, &mut last_len, &mut quiet_polls, 5)); + assert_eq!(quiet_polls, 5); + } + + #[test] + fn post_exit_wait_times_out_when_pdf_is_never_written() { + let dir = TestDir::new("timeout-test"); + let pdf = dir.0.join("missing.pdf"); + + assert!(!wait_for_settled_pdf(&pdf, Duration::from_millis(50), Duration::from_millis(10), 5)); + } }