Merge PR #3475: fix(forms): draw fields the file leaves to the viewer, for display only (#3470)

This commit is contained in:
aiongg
2026-10-22 01:45:38 +00:00
6 changed files with 414 additions and 9 deletions
+20 -7
View File
@@ -317,6 +317,10 @@ struct Editor {
/// runs (content edits leave every annotation dictionary alone). Cleared on any refresh
/// that may change annotations; see [`AppearanceCache`].
appearances: AppearanceCache,
/// Display appearances drawn for form fields the file leaves to the viewer (#3470), reused
/// for each widget while the objects it was drawn from are unchanged; see
/// [`pdfcraft_forms::FieldAppearanceCache`].
field_appearances: pdfcraft_forms::FieldAppearanceCache,
/// The text blocks last computed for one page, reused while the document is unchanged.
text_blocks: TextBlocksCache,
}
@@ -883,23 +887,31 @@ fn is_placeholder_title(title: &str) -> bool {
address || t == "untitled"
}
/// The working file plus appearances for comments that have none. Signature artwork
/// is preserved; validation badges belong to the viewer and never enter these bytes,
/// which image/text exports and Print as Image also consume.
/// The working file plus appearances for comments that have none, and for form fields the file
/// leaves to the viewer (#3470). Signature artwork is preserved; validation badges belong to the
/// viewer and never enter these bytes, which image/text exports and Print as Image also consume.
fn display_bytes(editor: Option<&mut Editor>, bytes: &Arc<Vec<u8>>) -> Arc<Vec<u8>> {
let Some(e) = editor else { return bytes.clone() };
let Some(display) = pdfcraft_annot::with_missing_appearances_cached(&e.cos, &mut e.appearances) else {
let comments = pdfcraft_annot::with_missing_appearances_cached(&e.cos, &mut e.appearances);
let fields = pdfcraft_forms::with_field_appearances_cached(comments.as_ref().unwrap_or(&e.cos), &mut e.field_appearances);
let Some(display) = fields.or(comments) else {
return bytes.clone();
};
match write_incremental(&display, &SaveOptions::default()) {
Ok(written) => Arc::new(written),
Err(err) => {
e.cos.note_repair(format!("missing comment appearances could not be drawn ({err})"));
e.cos.note_repair(format!("missing comment or field appearances could not be drawn ({err})"));
bytes.clone()
}
}
}
/// `comments` (the display copy with comment appearances, if it needed any; else the document
/// itself) with its form fields drawn from their values where the file leaves that to the viewer.
fn with_field_appearances(comments: Option<pdfcraft_cos::Document>, doc: &pdfcraft_cos::Document) -> Option<pdfcraft_cos::Document> {
pdfcraft_forms::with_field_appearances(comments.as_ref().unwrap_or(doc)).or(comments)
}
/// The reader shows the same pages in `a` and `b`: count, displayed size and rotation.
fn same_pages(a: &[pdfcraft_render::PageInfo], b: &[pdfcraft_render::PageInfo]) -> bool {
a.len() == b.len()
@@ -907,14 +919,14 @@ fn same_pages(a: &[pdfcraft_render::PageInfo], b: &[pdfcraft_render::PageInfo])
}
/// The copy PDFCraft displays when opening `bytes`: with appearances drawn, in memory only, for
/// comments that have none. Render paths outside the app, such as the CLI, use this so their page
/// comments that have none and form fields the file leaves to the viewer. Render paths outside the app, such as the CLI, use this so their page
/// images agree with the app and independent viewers. It never changes the source document, and an
/// unparseable file, a write failure or a parser panic falls back to the original bytes.
pub fn display_bytes_for(bytes: Arc<Vec<u8>>, password: Option<&str>) -> Arc<Vec<u8>> {
guard(|| {
pdfcraft_cos::Document::open_with_password(bytes.clone(), password)
.ok()
.and_then(|cos| pdfcraft_annot::with_missing_appearances(&cos))
.and_then(|cos| with_field_appearances(pdfcraft_annot::with_missing_appearances(&cos), &cos))
.and_then(|display| write_incremental(&display, &SaveOptions::default()).ok())
.map(Arc::new)
})
@@ -3592,6 +3604,7 @@ impl Session {
redo: Vec::new(),
keys,
appearances: AppearanceCache::default(),
field_appearances: pdfcraft_forms::FieldAppearanceCache::default(),
text_blocks: TextBlocksCache::default(),
}),
None,
+65
View File
@@ -6147,3 +6147,68 @@ fn undo_never_makes_an_unsaved_document_clean() {
s.undo(new).unwrap();
assert!(!s.get(new).unwrap().dirty, "once saved, undo back to the saved state is clean");
}
/// #3470: a filled form whose writer left the appearances to the viewer (`/NeedAppearances`):
/// field A has an empty appearance, B none. The page as displayed (and printed) shows both
/// values, and a comment without an appearance beside them still gets its own; the file itself
/// is not changed, so saving writes it as it was.
#[test]
fn fields_left_to_the_viewer_are_displayed_from_their_values() {
let pdf = |need: bool| {
let flag = if need { "/NeedAppearances true " } else { "" };
format!(
"%PDF-1.7\n\
1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 5 0 R >> endobj\n\
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 400 200] /Annots [6 0 R 7 0 R 10 0 R] >> endobj\n\
10 0 obj << /Type /Annot /Subtype /Square /Rect [360 20 390 50] /C [1 0 0] /BS << /W 4 >> >> endobj\n\
5 0 obj << /Fields [6 0 R 7 0 R] {flag}/DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 8 0 R >> >> >> endobj\n\
6 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (a) /V (MMMMMMMM) /DA (/Helv 12 Tf 0 g) /Rect [50 120 350 140] /F 4 /P 3 0 R /AP << /N 9 0 R >> >> endobj\n\
7 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (b) /V (MMMMMMMM) /DA (/Helv 12 Tf 0 g) /Rect [50 60 350 80] /F 4 /P 3 0 R >> endobj\n\
8 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> endobj\n\
9 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 300 20] /Length 4 >> stream\nq\nQ\nendstream endobj\n\
trailer << /Root 1 0 R >>\n%%EOF"
)
.into_bytes()
};
let red = |s: &Session, id: DocId| {
let doc = s.get(id).unwrap();
let mut r = pdfcraft_render::PageRenderer::new(doc.display.clone(), doc.config.clone());
let page = r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() });
page.rgba.as_chunks::<4>().0.iter().filter(|p| p[0] > 200 && p[1] < 80 && p[2] < 80).count()
};
// Dark pixels inside a field's box (user-space rectangle, page drawn at scale 1).
let ink = |s: &Session, id: DocId, y0: u32, y1: u32| {
let doc = s.get(id).unwrap();
let mut r = pdfcraft_render::PageRenderer::new(doc.display.clone(), doc.config.clone());
let page = r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() });
let (px, _) = page.rgba.as_chunks::<4>();
(200 - y1..200 - y0)
.flat_map(|y| (55..345).map(move |x| (x, y)))
.filter(|&(x, y)| px.get((y * page.width + x) as usize).is_some_and(|p| p[0] < 128))
.count()
};
let mut s = Session::new();
let id = s.open("form.pdf", None, Arc::new(pdf(true)), None).unwrap();
let (a, b) = (ink(&s, id, 122, 138), ink(&s, id, 62, 78));
assert!(a > 100 && b > 100, "both values are shown: a {a}, b {b}");
assert!(red(&s, id) > 100, "the comment is drawn too");
// The file is saved as it was: B still has no appearance, A its empty one.
let saved = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap();
let fields = pdfcraft_forms::fields(&saved);
let widget = |name: &str| saved.get(fields.iter().find(|f| f.name == name).unwrap().widgets[0].obj);
assert!(widget("b").as_dict().is_some_and(|d| !d.contains(b"AP")), "B has no appearance");
let n = widget("a").as_dict().and_then(|d| d.get(b"AP").and_then(|ap| saved.resolve(ap).as_dict().and_then(|ap| ap.reference(b"N")))).unwrap();
let pdfcraft_cos::Object::Stream(n) = &*saved.get(n) else { panic!("A's appearance is a stream") };
assert_eq!(n.decoded().unwrap().trim_ascii_end(), b"q\nQ", "A keeps its empty appearance");
// Typing in one field redraws that field only, not the whole form, on each keystroke.
let builds = |s: &Session| s.get(id).unwrap().editor.as_ref().unwrap().field_appearances.builds();
let before = builds(&s);
s.apply(id, Edit::SetFieldValue { name: "a".into(), value: pdfcraft_forms::FieldValue::Text("Typed".into()) }).unwrap();
assert_eq!(builds(&s), before + 1);
// Without the flag, the empty appearance is the file's own, and only B is drawn.
let id = s.open("form.pdf", None, Arc::new(pdf(false)), None).unwrap();
let (a, b) = (ink(&s, id, 122, 138), ink(&s, id, 62, 78));
assert!(a == 0 && b > 100, "a {a}, b {b}");
}