mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-27 03:27:21 -06:00
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user