Comments without an appearance stream are drawn (display only, never saved) (#371)

* M5: comments without an appearance stream are drawn (display only, never saved)

#260 (page 7): a FreeText box with /DA and an orange /C background, a
Text note and an Ink stroke, all without /AP, were not drawn; Acrobat,
MuPDF, PDFium and Poppler draw them. The renderer only draws /AP (plus
the Highlight fallback of #274), and pdfcraft-annot, which already
draws all of these for PdfCraft's own comments, can't be used from the
renderer (both are L3).

pdfcraft_annot::with_missing_appearances returns a copy of the document
in which every annotation without /AP /N that appearance::build can
draw (FreeText, Ink, Text, FileAttachment, stamps, shapes, lines,
markup, …) has the appearance build draws from its dictionary; links,
form fields and pop-ups are left alone, as is anything build declines.
The engine keeps that copy, written as an in-memory incremental update,
in a new Document::display: the page view's render pool, page_render
and page images (export_source) read it. Document::bytes, what Save,
signatures and the inspector use, is unchanged. display is rebuilt
wherever the renderer was (open, refresh, scoped refresh) and is bytes
itself when nothing is missing or the document can't be edited.

Tests: annot tests::comments_without_appearances_get_one_for_display_only
(FreeText, Ink and Text get /AP /N in the copy, an existing /AP is kept,
Link, Widget and an unknown subtype aren't touched, the original is
untouched, none means no copy) and engine
tests::comments_without_appearances_are_drawn_but_not_saved (the page
view draws the FreeText background and the Ink stroke, before and after
an edit; the working bytes stay the opened file; Save writes no /AP).
On main the page view shows white where the FreeText box is. Parity:
view.render-annotation-appearances cites the test and notes what's left
(Link borders without /AP, printing).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Test the Ink stroke on its apex row: Ink is now a curve through its points (#374)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-08 19:43:58 -04:00
1 parent 3b8a274f52
commit 07b95409bf
7 files changed
+175 -13

No files matched your search

+66
View File
@@ -2042,3 +2042,69 @@ fn runaway_xfa_calculations_at_open_end_quickly_with_a_report() {
assert!(started.elapsed() < std::time::Duration::from_secs(2));
assert!(s.take_js_output(id).errors.is_empty());
}
/// The page view's raster of `page` at 1 px/pt (the document's own render pool).
fn shown(s: &Session, id: DocId, page: usize) -> pdfcraft_render::RenderedPage {
let doc = s.get(id).unwrap();
doc.renderer.set_queue(vec![pdfcraft_render::RenderRequest { page, scale: 1.0, ..Default::default() }]);
let start = std::time::Instant::now();
loop {
if let Some(p) = doc.renderer.try_recv() {
assert!(p.error.is_none(), "{:?}", p.error);
return p;
}
assert!(start.elapsed() < std::time::Duration::from_secs(20), "no render");
std::thread::sleep(std::time::Duration::from_millis(5));
}
}
/// #260 (page 7): comments without an appearance stream, here a FreeText box (`/C` background,
/// blue `/DA` text) and an Ink stroke, weren't drawn. The page view now draws them from
/// appearances made for display only; the working file and what Save writes stay as they were.
#[test]
fn comments_without_appearances_are_drawn_but_not_saved() {
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 5 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] >>",
b"<< /Type /Annot /Subtype /Ink /Rect [20 20 180 120] /InkList [[30 30 100 110 170 30]] /Border [0 0 4] /C [0 .6 0] >>",
];
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());
let original = Arc::new(bytes);
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("comments.pdf", None, original.clone(), None).expect("opens");
let check = |s: &Session| {
let p = shown(s, id, 0);
let px = |x: u32, y: u32| p.rgba[((y * p.width + x) * 4) as usize..][..4].to_vec();
// y down: the FreeText box spans rows 40..100, the Ink apex is at (100, 190).
assert_eq!(px(100, 90), vec![217, 120, 5, 255], "the FreeText background");
// On the apex row: Ink is drawn as a curve through its points (#374), level at the apex.
assert_eq!(px(100, 190), vec![0, 153, 0, 255], "the Ink stroke");
};
check(&s);
let doc = s.get(id).unwrap();
assert!(Arc::ptr_eq(&doc.bytes, &original), "the working file is the file as opened");
assert!(!Arc::ptr_eq(&doc.display, &doc.bytes) && Arc::ptr_eq(&doc.export_source().bytes, &doc.display), "page images show it too");
// After an edit (a scoped refresh) they are still drawn, and Save writes no appearances.
s.apply(id, rect_comment(0, [150.0, 270.0, 190.0, 290.0])).unwrap();
check(&s);
let saved = s.save_bytes(id).unwrap();
let reopened = pdfcraft_cos::Document::open(saved).unwrap();
for r in [pdfcraft_cos::ObjRef::new(4, 0), pdfcraft_cos::ObjRef::new(5, 0)] {
assert!(reopened.get(r).as_dict().is_some_and(|d| !d.contains(b"AP")), "{r:?} is saved without /AP");
}
}