mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-25 02:40:09 +00:00
M6: a saved Password field returns to its default value and is redrawn from it (#3926)
Review on #3943. A black-box test of Acrobat (synthetic form, Save As and Ctrl+S, every decoded object searched) showed how it saves a Password field: the typed value is nowhere in the file; a field with /DV is saved holding /DV, one without is left empty; the asterisks are drawn from the saved value (5 for a 5-letter default, not 13 for the typed password); /DV and /AA are kept. clear_password_values now does the same: it restores the (inherited) /DV as the value, or leaves the field empty, and redraws the widgets, so the appearance no longer shows how long the typed password was. A field already at its default no longer counts as holding a password, so such a form needs no cleaned copy. The parity note for form.fill-text says so and lists the two limits Acrobat shares (a script copying the password into an ordinary field, and an earlier revision kept by an incremental save). Mutants, each failing a test: no /DV restore, no redraw, the default ignored in password_fields, emptiness ignored (an empty string in an array). Dropping the empty-default filter was equivalent, so the filter is gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2060,7 +2060,9 @@ fn password_field_values_are_kept_in_memory_but_written_to_no_file() {
|
||||
|
||||
let saved = s.save_bytes(id).unwrap();
|
||||
assert!(!appears_in_pdf(&saved, "hunter2"), "Save");
|
||||
assert!(appears_in_pdf(&saved, "*******"), "the masked appearance is kept");
|
||||
// As Acrobat saves it (#3943 review): no default value, so an empty field whose appearance
|
||||
// no longer shows how long the typed password was.
|
||||
assert!(!appears_in_pdf(&saved, "*******"), "the typed length is not kept in the appearance");
|
||||
assert!(!appears_in_pdf(&s.save_full_bytes(id).unwrap(), "hunter2"), "a full rewrite");
|
||||
assert!(!appears_in_pdf(&s.reduced_bytes(id).unwrap().0, "hunter2"), "Reduce File Size");
|
||||
let snaps = s.autosave_snapshots();
|
||||
@@ -2070,7 +2072,7 @@ fn password_field_values_are_kept_in_memory_but_written_to_no_file() {
|
||||
// takes, and Split writes each part the same way.
|
||||
let extracted = s.extract(id, &[0]).unwrap();
|
||||
assert!(!appears_in_pdf(&extracted, "hunter2"), "Extract Pages");
|
||||
assert!(appears_in_pdf(&extracted, "*******"), "the extracted page keeps the masked appearance");
|
||||
assert!(!appears_in_pdf(&extracted, "*******"), "nor on the extracted page");
|
||||
for (_, _, part) in s.split_by_size(id, 1).unwrap() {
|
||||
assert!(!appears_in_pdf(&part, "hunter2"), "Split");
|
||||
}
|
||||
@@ -2095,7 +2097,7 @@ fn a_signed_file_carries_no_password_field_value() {
|
||||
assert!(appears_in_pdf(&s.get(id).unwrap().bytes, "hunter2"), "the working bytes hold it");
|
||||
let signed = s.sign(id, &digital_id, SignOptions { rect: None, ..SignOptions::default() }).unwrap();
|
||||
assert!(!appears_in_pdf(&signed, "hunter2"), "the signed file");
|
||||
assert!(appears_in_pdf(&signed, "*******"), "the masked appearance is signed in");
|
||||
assert!(!appears_in_pdf(&signed, "*******"), "nor the typed length");
|
||||
}
|
||||
|
||||
/// A value its field's font can't draw is kept, and the edit that drew it reports it in
|
||||
|
||||
+14
-8
@@ -1294,13 +1294,12 @@ pub(crate) fn ask_viewers_to_redraw(doc: &mut Document) -> Result<(), FormError>
|
||||
}
|
||||
|
||||
/// Text fields with the Password flag (inherited or their own) whose value (`/V`, inherited or
|
||||
/// their own) is not empty, or one of whose separate widgets carries a `/V`.
|
||||
/// their own) is not empty and not their default value (`/DV`, which the form's author wrote and
|
||||
/// a save restores), or one of whose separate widgets carries a `/V`.
|
||||
fn password_fields(doc: &Document) -> Vec<Field> {
|
||||
let on_widget = |f: &Field| f.widgets.iter().any(|w| w.obj != f.obj && doc.get(w.obj).as_dict().is_some_and(|d| d.get(b"V").is_some()));
|
||||
fields(doc)
|
||||
.into_iter()
|
||||
.filter(|f| f.kind == FieldKind::Text && f.has(flags::PASSWORD) && (f.value.iter().any(|v| !v.is_empty()) || on_widget(f)))
|
||||
.collect()
|
||||
let typed = |f: &Field| f.value.iter().any(|v| !v.is_empty()) && f.value.first() != f.default.first();
|
||||
fields(doc).into_iter().filter(|f| f.kind == FieldKind::Text && f.has(flags::PASSWORD) && (typed(f) || on_widget(f))).collect()
|
||||
}
|
||||
|
||||
/// Whether any text field with the Password flag holds a value in `doc`.
|
||||
@@ -1326,7 +1325,10 @@ fn field_chain(doc: &Document, r: ObjRef) -> Vec<ObjRef> {
|
||||
/// Remove the values of the text fields with the Password flag, for a copy of the document about
|
||||
/// to be written to a file (#3926). ISO 32000-2 §12.7.5.3, Table 231, Password flag, note: "it is
|
||||
/// imperative that PDF processors never store the value of the text field in the PDF file if
|
||||
/// this flag is set". The appearance keeps its masking characters.
|
||||
/// this flag is set". As Acrobat saves such a field (#3943 review, black-box test): a field with
|
||||
/// a default value (`/DV`, which the form's author wrote) gets it back as its value, one without
|
||||
/// is left empty, and its appearance is drawn again from that value, so the masking characters
|
||||
/// no longer show how long the typed password was. `/DV` and the field's scripts stay.
|
||||
///
|
||||
/// A value can sit on the field, on one of its widgets, or on an ancestor it inherits from
|
||||
/// (`/V` is inheritable). Each is removed; an ancestor's only when every field inheriting from it
|
||||
@@ -1366,9 +1368,13 @@ pub fn clear_password_values(doc: &mut Document) -> Result<usize, FormError> {
|
||||
shared_value_kept = true;
|
||||
}
|
||||
}
|
||||
if shared_value_kept {
|
||||
doc.update_dict(f.obj, |d| d.set(b"V".to_vec(), PdfString::text("")))?;
|
||||
let restored: Vec<String> = f.default.first().cloned().into_iter().collect();
|
||||
match restored.first() {
|
||||
Some(dv) => doc.update_dict(f.obj, |d| d.set(b"V".to_vec(), PdfString::text(dv)))?,
|
||||
None if shared_value_kept => doc.update_dict(f.obj, |d| d.set(b"V".to_vec(), PdfString::text("")))?,
|
||||
None => {}
|
||||
}
|
||||
redraw(doc, f, &restored, &mut NoScripts)?;
|
||||
}
|
||||
Ok(targets.len())
|
||||
}
|
||||
|
||||
@@ -3107,3 +3107,35 @@ fn inherited_and_widget_password_values_are_cleared() {
|
||||
assert!(!has_v(&doc, 7), "a widget's value is removed");
|
||||
assert!(has_v(&doc, 10), "a shared ancestor keeps its value");
|
||||
}
|
||||
|
||||
/// #3943 review: a cleared password field saves as Acrobat saves it (black-box test). One with a
|
||||
/// default value gets it back, and its appearance shows as many masking characters as the
|
||||
/// default has, not as the typed password had; one without is left empty and draws none. The
|
||||
/// default value and the scripts stay, and a field already at its default needs no clearing.
|
||||
#[test]
|
||||
fn a_cleared_password_field_returns_to_its_default_value() {
|
||||
let objs: Vec<String> = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), // 1
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), // 2
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R 8 0 R] >>".into(), // 3
|
||||
"<< /Fields [6 0 R 7 0 R 8 0 R] /DA (/Helv 12 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(), // 4
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), // 5
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (pw) /Ff 8192 /DV (DEF-1) /V (NEWPASS-77777) /AA << /K << /S /JavaScript /JS (1;) >> >> /Rect [50 700 250 720] /P 3 0 R >>".into(), // 6
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (pw3) /Ff 8192 /V (PW3-TYPED-9) /Rect [50 650 250 670] /P 3 0 R >>".into(), // 7
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (blank) /Ff 8192 /V [()] /Rect [50 600 250 620] /P 3 0 R >>".into(), // 8 holds nothing (an empty string in an array)
|
||||
];
|
||||
let mut doc = document(&objs);
|
||||
assert_eq!(clear_password_values(&mut doc).unwrap(), 2);
|
||||
let all = fields(&doc);
|
||||
let (pw, pw3) = (field(&all, "pw"), field(&all, "pw3"));
|
||||
assert_eq!(pw.value, ["DEF-1"], "back to its default");
|
||||
assert_eq!(pw.default, ["DEF-1"], "the default stays");
|
||||
assert!(pw.actions.scripts.keystroke.is_some(), "the scripts stay");
|
||||
assert!(pw3.value.iter().all(|v| v.is_empty()), "no default: empty");
|
||||
let shown = ap(&doc, &pw.widgets[0]);
|
||||
assert!(shown.contains("(*****)") && !shown.contains("NEWPASS") && !shown.contains("******"), "{shown}");
|
||||
let shown3 = ap(&doc, &pw3.widgets[0]);
|
||||
assert!(!shown3.contains('*') && !shown3.contains("PW3"), "{shown3}");
|
||||
assert!(!has_password_values(&doc), "a field at its default holds no typed password");
|
||||
assert_eq!(clear_password_values(&mut doc).unwrap(), 0);
|
||||
}
|
||||
|
||||
@@ -4440,8 +4440,8 @@ milestone = "M6"
|
||||
status = "shipped"
|
||||
commands = ["form.fields"]
|
||||
tools = ["form_fill", "form_fields"]
|
||||
evidence = ["crates/ui-egui/tests/it/forms.rs::typing_into_a_text_field_and_tabbing_on", "crates/ui-egui/tests/it/forms.rs::auto_size_fields_shrink_and_wrap_typed_text_to_fit_their_box", "crates/forms/src/tests.rs::text_fields_get_new_appearances", "crates/engine/src/tests.rs::filling_a_form_shows_saves_and_undoes", "crates/automation/tests/it/automation.rs::orphan_form_fields_through_tools", "crates/forms/src/tests.rs::fields_sharing_a_name_take_the_same_value", "crates/ui-egui/tests/it/forms.rs::hovering_a_field_shows_its_tooltip", "crates/forms/src/tests.rs::zero_max_len_is_unlimited_and_overrides_inherited_limit"]
|
||||
notes = "in-place editor; Enter or clicking away commits, Escape cancels; auto-size (0 Tf) fields shrink, and multiline ones wrap, the text being typed to fit the box, on screen as on the page (the screen measures in the interface font, so its size can be a little smaller than the page's Helvetica fit); fixed sizes are kept and clip, as in Acrobat; no format/keystroke JavaScript yet (M6.4); resting the pointer on any field shows its tooltip (/TU), as Acrobat does (#3854); imported MaxLen 0 permits text input and overrides inherited limits; positive limits remain enforced."
|
||||
evidence = ["crates/ui-egui/tests/it/forms.rs::typing_into_a_text_field_and_tabbing_on", "crates/ui-egui/tests/it/forms.rs::auto_size_fields_shrink_and_wrap_typed_text_to_fit_their_box", "crates/forms/src/tests.rs::text_fields_get_new_appearances", "crates/engine/src/tests.rs::filling_a_form_shows_saves_and_undoes", "crates/automation/tests/it/automation.rs::orphan_form_fields_through_tools", "crates/forms/src/tests.rs::fields_sharing_a_name_take_the_same_value", "crates/ui-egui/tests/it/forms.rs::hovering_a_field_shows_its_tooltip", "crates/forms/src/tests.rs::zero_max_len_is_unlimited_and_overrides_inherited_limit", "crates/engine/src/tests.rs::password_field_values_are_kept_in_memory_but_written_to_no_file", "crates/forms/src/tests.rs::a_cleared_password_field_returns_to_its_default_value"]
|
||||
notes = "in-place editor; Enter or clicking away commits, Escape cancels; auto-size (0 Tf) fields shrink, and multiline ones wrap, the text being typed to fit the box, on screen as on the page (the screen measures in the interface font, so its size can be a little smaller than the page's Helvetica fit); fixed sizes are kept and clip, as in Acrobat; no format/keystroke JavaScript yet (M6.4); resting the pointer on any field shows its tooltip (/TU), as Acrobat does (#3854); imported MaxLen 0 permits text input and overrides inherited limits; positive limits remain enforced. A text field with the Password flag is saved as Acrobat saves it (#3926, black-box test): the typed value is written to no file (Save, Save As, Reduce File Size, recovery, Extract/Split/Combine, signing); the field gets back its default value (/DV) or is left empty, and its asterisks are redrawn from that value. /DV and the field's scripts stay. Known limits, which Acrobat shares: a script that copies the password into an ordinary field saves it there (Acrobat recalculates a Calculate copy on save, PdfCraft does not), and an incremental save leaves a password stored by an earlier revision in that revision."
|
||||
|
||||
[[feature]]
|
||||
id = "form.fill-checkbox-radio"
|
||||
|
||||
Reference in New Issue
Block a user