From ad6728bf6c64806b8cef4d93326ea307382de803 Mon Sep 17 00:00:00 2001 From: Earl Co Date: Thu, 15 Oct 2026 08:58:04 +0200 Subject: [PATCH] fix(cli): render comments the app renders when /AP is missing (#2371) (#2382) --- apps/pdfcraft-cli/src/main.rs | 5 +++- crates/engine/src/lib.rs | 17 +++++++++++++ crates/engine/src/tests.rs | 45 +++++++++++++++++++++++++++++++++++ parity/acrobat-features.toml | 4 ++-- 4 files changed, 68 insertions(+), 3 deletions(-) diff --git a/apps/pdfcraft-cli/src/main.rs b/apps/pdfcraft-cli/src/main.rs index 3ec005344f..e749eb54d9 100644 --- a/apps/pdfcraft-cli/src/main.rs +++ b/apps/pdfcraft-cli/src/main.rs @@ -577,8 +577,11 @@ fn render(args: &[String]) -> Result<(), CliError> { Some("pam") => None, _ => return Err(format!("render: --out {out}: use a .png, .jpg, .tif or .pam name").into()), }; + // The app renders a display copy that draws comments missing their /AP streams; the CLI + // must agree with it (and with independent viewers), while keeping the file it reads intact. + let bytes = read(path)?; let mut r = PageRenderer::new( - read(path)?, + pdfcraft_engine::display_bytes_for(bytes.clone(), flag(args, "--password")), RenderConfig { password: flag(args, "--password").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 }); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 559213789d..cfbec3b7ff 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -799,6 +799,23 @@ fn display_bytes(editor: Option<&mut Editor>, bytes: &Arc>) -> Arc>, password: Option<&str>) -> Arc> { + guard(|| { + pdfcraft_cos::Document::open_with_password(bytes.clone(), password) + .ok() + .and_then(|cos| pdfcraft_annot::with_missing_appearances(&cos)) + .and_then(|display| write_incremental(&display, &SaveOptions::default()).ok()) + .map(Arc::new) + }) + .ok() + .flatten() + .unwrap_or(bytes) +} + /// 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 diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 4847800f03..60a0aa0f8f 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4345,6 +4345,51 @@ fn comments_without_appearances_are_drawn_but_not_saved() { } } +/// A page with a FreeText comment that has no `/AP` stream: the smallest case behind #2371. +fn free_text_without_appearance() -> Arc> { + let objs: Vec<&[u8]> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>", + b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + b"<< /Type /Annot /Subtype /FreeText /Rect [20 200 180 260] /Contents (Box) /DA (/Helv 10 Tf 0 0 1 rg) /Border [0 0 1] /C [.85 .47 .02] >>", + ]; + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + bytes.extend_from_slice(o); + bytes.extend_from_slice(b"\nendobj\n"); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + Arc::new(bytes) +} + +/// #2371: a comment without an appearance stream is displayed by the app, so the CLI's page image +/// must show it too. The helper's copy is for rendering only; documents that need no copy, and +/// files it can't display, keep exactly the original source bytes. +#[test] +fn cli_display_adds_missing_appearances_without_changing_the_source() { + let original = free_text_without_appearance(); + let display = display_bytes_for(original.clone(), None); + assert_ne!(display.as_slice(), original.as_slice(), "the display copy draws the missing comment appearance"); + + let doc = pdfcraft_cos::Document::open(display).unwrap(); + let object = doc.get(pdfcraft_cos::ObjRef::new(4, 0)); + let annot = object.as_dict().unwrap(); + assert!(annot.contains(b"AP"), "the display copy has an appearance stream"); + + let plain = Arc::new(fixture(1)); + assert!(Arc::ptr_eq(&display_bytes_for(plain.clone(), None), &plain), "a complete document needs no copy"); + let damaged = Arc::new(b"not a PDF".to_vec()); + assert!(Arc::ptr_eq(&display_bytes_for(damaged.clone(), None), &damaged), "an unreadable file falls back to its source"); +} + /// Titles a browser writes for a blank page or a pop-up name no document; real titles stay. #[test] fn placeholder_titles_are_recognised() { diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index e8b7f64e82..9400753bc1 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -806,8 +806,8 @@ milestone = "M2" status = "shipped" commands = [] tools = [] -evidence = ["crates/render/src/raster.rs::widget_appearance_states", "crates/render/src/raster.rs::highlights_without_an_appearance_are_drawn", "crates/engine/src/tests.rs::comments_without_appearances_are_drawn_but_not_saved"] -notes = "comments without /AP (FreeText, Ink, notes, stamps, shapes, markup) are drawn from their dictionaries in the page view, page_render and page images, never saved (a read-only document still gets the renderer's Highlight fallback only); Link borders without /AP and printing are not covered yet" +evidence = ["crates/render/src/raster.rs::widget_appearance_states", "crates/render/src/raster.rs::highlights_without_an_appearance_are_drawn", "crates/engine/src/tests.rs::comments_without_appearances_are_drawn_but_not_saved", "crates/engine/src/tests.rs::cli_display_adds_missing_appearances_without_changing_the_source"] +notes = "comments without /AP (FreeText, Ink, notes, stamps, shapes, markup) are drawn from their dictionaries in the page view, page_render and page images, including the CLI render command, never saved (a read-only document still gets the renderer's Highlight fallback only); Link borders without /AP and printing are not covered yet" [[feature]] id = "view.render-error-isolation"