diff --git a/crates/automation/src/forms.rs b/crates/automation/src/forms.rs index 26e7b9d06..763fd0efd 100644 --- a/crates/automation/src/forms.rs +++ b/crates/automation/src/forms.rs @@ -288,6 +288,23 @@ impl Automation { let edit = if edits.len() == 1 { edits.remove(0) } else { Edit::Batch { label: "Fill in form".into(), edits } }; let mut out = self.apply(a, edit)?; out["filled"] = json!(values.len()); + // A value the box cuts off at a fixed font size is saved whole but shows only in part: + // said, not silent (#4018). + let doc = self.doc(a)?; + let cut: Vec = values + .keys() + .filter_map(|name| doc.field_overflow(name).map(|(page, o)| (name, page, o))) + .map(|(name, page, o)| { + let page = page.map(|p| format!(" (page {})", p.saturating_add(1))).unwrap_or_default(); + json!(format!("form field {name:?}{page}: {o}, so the box shows only part of it (the whole value is saved)")) + }) + .collect(); + if !cut.is_empty() { + match out.get_mut("warnings").and_then(Value::as_array_mut) { + Some(w) => w.extend(cut), + None => out["warnings"] = Value::Array(cut), + } + } Ok(out) } diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 8f687263a..64d7f2ea8 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -364,7 +364,7 @@ pub fn tools() -> Vec { t( "form_add_field", "Prepare form: add a field", - "Add a form field on a page. type: text, date, checkbox, radio, combo, list, button, image (a picture placeholder; fill it with form_set_image), signature. rect in points from the top-left of the displayed page [x0, y0, x1, y1]. name defaults to Acrobat's next free name (Text1, Check Box1, Group1, Dropdown1, List Box1, Button1, Image1, Signature1, Date1). Radio buttons join the radio group named by group (a new group otherwise) with the export value export. Returns the field's name. Undoable.", + "Add a form field on a page. type: text, date, checkbox, radio, combo, list, button, image (a picture placeholder; fill it with form_set_image), signature. rect in points from the top-left of the displayed page [x0, y0, x1, y1]. name defaults to Acrobat's next free name (Text1, Check Box1, Group1, Dropdown1, List Box1, Button1, Image1, Signature1, Date1); a dotted name such as MASTER.first makes the field first under the parent field MASTER. Radio buttons join the radio group named by group (a new group otherwise) with the export value export. Returns the field's name. Undoable.", ) .with(schema( json!({ @@ -424,7 +424,7 @@ pub fn tools() -> Vec { t( "form_set_props", "Field properties", - "Change field properties: give field for one field, or fields for a list of unique field names (1–1000). A bulk change is atomic and one Undo step; an invalid or locked field leaves every field unchanged. Only the given properties change, and partial appearance changes preserve each field's other colours and font. name (rename), rect (position), rotation and caption require a single field. A rename also renames the field in the calculations that read it (calculate field lists and notation, and getField calls naming it in a string literal; names built at run time are not rewritten), in the same Undo step, and formulas_updated lists the fields whose calculation changed. caption changes one push-button normal label (widget: 1-based index, default 1); an empty string clears it. Other widgets and icon-only layouts are preserved. General and Options: tooltip, read_only, required, multiline, max_length (0 = no limit), options (combo/list items), font_size (0 = auto), format, validate, calculate (Acrobat's native AF rules). Options: align (left, center, right), default (Reset form's value; check boxes/radio buttons use their on state, \"\" for off), flags { scroll, rich_text, password, file_select, spell_check, comb (needs max_length), sort, editable, multi_select, commit_immediately }, check_style (check boxes/radio buttons: check, circle, cross, diamond, square, star). rotation is 0, 90, 180 or 270 degrees counterclockwise and swaps width/height when the axis changes. Undoable.", + "Change field properties: give field for one field, or fields for a list of unique field names (1–1000). A bulk change is atomic and one Undo step; an invalid or locked field leaves every field unchanged. Only the given properties change, and partial appearance changes preserve each field's other colours and font. name (rename: a dotted name such as MASTER.first is the full name and moves the field under those parent fields; a plain name renames it within its parent), rect (position), rotation and caption require a single field. A rename also renames the field in the calculations that read it (calculate field lists and notation, and getField calls naming it in a string literal; names built at run time are not rewritten), in the same Undo step, and formulas_updated lists the fields whose calculation changed. caption changes one push-button normal label (widget: 1-based index, default 1); an empty string clears it. Other widgets and icon-only layouts are preserved. General and Options: tooltip, read_only, required, multiline, max_length (0 = no limit), options (combo/list items), font_size (0 = auto), format, validate, calculate (Acrobat's native AF rules). Options: align (left, center, right), default (Reset form's value; check boxes/radio buttons use their on state, \"\" for off), flags { scroll, rich_text, password, file_select, spell_check, comb (needs max_length), sort, editable, multi_select, commit_immediately }, check_style (check boxes/radio buttons: check, circle, cross, diamond, square, star). rotation is 0, 90, 180 or 270 degrees counterclockwise and swaps width/height when the axis changes. Undoable.", ) .with(schema( json!({ diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index a75ef990a..f2b135d9d 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -2331,6 +2331,51 @@ fn forms_through_tools() { assert_ne!(v, "Filled by an agent"); } +/// A PDF of the given objects (numbered from 1). +fn pdf_of(objs: &[&str]) -> Vec { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +/// #4018: `form_fill` warns about a value its box cuts off at the field's fixed font size (the +/// value is saved whole), and not about one that fits. +#[test] +fn form_fill_warns_when_a_value_does_not_fit() { + let objs = [ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 300] >>", + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R] >>", + "<< /Fields [5 0 R 6 0 R] /DA (/Helv 12 Tf 0 g) /DR << /Font << /Helv 7 0 R >> >> >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (narrow) /Rect [10 250 150 268] /P 3 0 R >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (wide) /Rect [10 200 310 218] /P 3 0 R >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", + ]; + let dir = workdir("form-overflow"); + std::fs::write(dir.join("form.pdf"), pdf_of(&objs)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap(); + let long = "Doe, Jane (represented by John Doe)"; + let r = ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "narrow": long, "wide": long } })); + let warnings: Vec<&str> = r["warnings"].as_array().map(|w| w.iter().filter_map(Value::as_str).collect()).unwrap_or_default(); + assert_eq!(warnings.len(), 1, "{r}"); + assert!(warnings[0].starts_with("form field \"narrow\" (page 1): the value does not fit"), "{}", warnings[0]); + let after = ok(&mut a, "form_fields", json!({ "doc": doc })); + assert_eq!(after["fields"][0]["value"], long, "saved whole"); + let r = ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "narrow": "Ada" } })); + assert!(r.get("warnings").is_none(), "{r}"); +} + /// A form whose fields are only page widgets (the `/Fields` list is empty) still lists, fills and /// resets through the tools, and the leniency shows up as a repair note. #[test] @@ -2346,19 +2391,7 @@ fn orphan_form_fields_through_tools() { "<< /Type /Annot /Subtype /Widget /FT /Tx /T (beta) /Rect [10 150 90 170] /P 3 0 R >>", // 6 "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", // 7 ]; - let mut out = b"%PDF-1.7\n".to_vec(); - let mut offsets = Vec::new(); - for (i, o) in objs.iter().enumerate() { - offsets.push(out.len()); - out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); - } - let xref = out.len(); - out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); - for o in offsets { - out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); - } - out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); - out + pdf_of(&objs) } let dir = workdir("orphan-forms"); std::fs::write(dir.join("orphan.pdf"), orphan_form()).unwrap(); diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index a59743dbb..aa43d3dba 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -200,6 +200,11 @@ impl crate::Document { self.editor.as_ref().and_then(|e| pdfcraft_forms::field_actions(&e.cos, name).ok()).unwrap_or_default() } + /// Field Properties ▸ Actions: how many actions are chained after each trigger's first one. + pub fn field_action_chains(&self, name: &str) -> Vec<(pdfcraft_forms::Trigger, usize)> { + self.editor.as_ref().and_then(|e| pdfcraft_forms::field_action_chains(&e.cos, name).ok()).unwrap_or_default() + } + /// Document JavaScripts (name, source), in name order. pub fn document_scripts(&self) -> Vec<(String, String)> { self.editor.as_ref().map(|e| document_scripts(&e.cos)).unwrap_or_default() diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 0a9db62cb..c92560120 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -791,6 +791,17 @@ impl Document { self.editable() && self.permissions().is_none_or(|p| p.modify()) } + /// How widget `widget` of text field `name` lays its value out, for asking whether a typed + /// value fits it ([`form_appearance::TextBox::fits`]): a field that doesn't scroll takes no + /// more than fits. `None` without such a field or widget, or when the document can't be + /// edited. + pub fn field_text_box(&self, name: &str, widget: usize) -> Option { + let cos = &self.editor.as_ref()?.cos; + let f = self.form.iter().find(|f| f.name == name)?; + let w = f.widgets.get(widget)?; + Some(form_appearance::text_box(cos, f, w)) + } + /// Filling in form fields is allowed (Table 22, bits 6 and 9). pub fn allows_form_filling(&self) -> bool { self.editable() && self.permissions().is_none_or(|p| p.fill_forms()) @@ -851,6 +862,14 @@ impl Document { matches!(f.kind, FormFieldKind::CheckBox | FormFieldKind::Radio).then(|| pdfcraft_forms::check_style(&e.cos, f)) } + /// Whether text field `name`'s value is cut off by its box at the field's font size (#4018): + /// the page (0-based) of the first widget that cuts it, and how. + pub fn field_overflow(&self, name: &str) -> Option<(Option, pdfcraft_forms::appearance::Overflow)> { + let e = self.editor.as_ref()?; + let f = self.form.iter().find(|f| f.name == name)?; + f.widgets.iter().find_map(|w| pdfcraft_forms::appearance::value_overflow(&e.cos, f, w).map(|o| (w.page, o))) + } + /// Remove Hidden Information: what each category would remove. pub fn hidden_info(&self) -> Vec<(Hidden, usize)> { self.editor.as_ref().map(|e| pdfcraft_redact::sanitize::scan(&e.cos)).unwrap_or_default() @@ -2989,6 +3008,19 @@ fn renderer_password(bytes: &[u8]) -> String { } } +/// The document as it may be written to a file (#3926): a copy without the values of text fields +/// with the Password flag, or `cos` itself when it holds none. ISO 32000-2 §12.7.5.3, Table 231 +/// (a note to the Password flag): "it is imperative that PDF processors never store the value of +/// the text field in the PDF file if this flag is set". The open document keeps them in memory. +fn writable(cos: &pdfcraft_cos::Document) -> Result, EditError> { + if !pdfcraft_forms::has_password_values(cos) { + return Ok(std::borrow::Cow::Borrowed(cos)); + } + let mut copy = cos.clone(); + pdfcraft_forms::clear_password_values(&mut copy).map_err(|e| EditError::Write(e.to_string()))?; + Ok(std::borrow::Cow::Owned(copy)) +} + /// The last-resort guard (AGENTS.md §4): run `f`, turning a panic that escapes it into an error /// message, so one bad file or edit can't take the app and its other documents down. It is a /// safety net for bugs, not a substitute for returning errors. @@ -4366,12 +4398,13 @@ impl Session { // file so that revision stays in the chain. A full rewrite (redaction, new encryption, // a reconstructed file) still writes the current graph on its own. let rewrite = editor.cos.full_save_required() || editor.cos.encryption_changed() || editor.cos.revisions().is_empty(); + let cos = writable(&editor.cos)?; guard(|| { if on_saved || rewrite { - write_incremental(&editor.cos, &opts) + write_incremental(&cos, &opts) } else { let mut base = pdfcraft_cos::Document::open_with_password(editor.saved.clone(), editor.saved_keys.reopen.as_deref())?; - base.overlay_changes_from(&editor.cos)?; + base.overlay_changes_from(&cos)?; write_incremental(&base, &opts) } }) @@ -4388,7 +4421,8 @@ impl Session { } let editor = doc.editor.as_ref().ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?; let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), ..SaveOptions::default() }; - guard(|| write_full(&editor.cos, &opts)).map_err(EditError::Write)?.map(Arc::new).map_err(|e| EditError::Write(e.to_string())) + let cos = writable(&editor.cos)?; + guard(|| write_full(&cos, &opts)).map_err(EditError::Write)?.map(Arc::new).map_err(|e| EditError::Write(e.to_string())) } /// Check that `bytes`, from [`Self::save_bytes`] or [`Self::save_full_bytes`], reopen as this @@ -4996,9 +5030,12 @@ impl Session { } /// [`Self::write_new`] without the check, for bytes nobody writes: measuring a part's size. + /// Like a save, a new file never carries a typed Password-field value (#3926): Extract and + /// Split copy the fields of the pages they take. fn write_unchecked(&self, doc: &pdfcraft_cos::Document) -> Result>, EditError> { let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), ..SaveOptions::default() }; - write_full(doc, &opts).map(Arc::new).map_err(|e| EditError::Write(e.to_string())) + let doc = writable(doc)?; + write_full(&doc, &opts).map(Arc::new).map_err(|e| EditError::Write(e.to_string())) } /// Whether a changed document still needs a recovery snapshot. This does not consume it. @@ -5012,12 +5049,23 @@ impl Session { let mut out = Vec::new(); for d in &mut self.docs { if d.dirty && d.generation != d.snapshot_generation { + // The working bytes hold typed Password-field values (#3926): a recovery file is + // written from a copy without them, and none is written if that copy fails. + let bytes = match d.editor.as_ref().filter(|e| pdfcraft_forms::has_password_values(&e.cos)) { + Some(e) => match writable(&e.cos) + .and_then(|c| write_incremental(&c, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))) + { + Ok(b) => Arc::new(b), + Err(_) => continue, + }, + None => d.bytes.clone(), + }; d.snapshot_generation = d.generation; out.push(RecoverySnapshot { doc: d.id, name: d.name.clone(), path: d.path.clone(), - bytes: d.bytes.clone(), + bytes, encrypted: d.info.encrypted || d.editor.as_ref().is_some_and(|e| e.cos.security().is_some()), }); } @@ -5171,7 +5219,9 @@ impl Session { if opts.date.is_empty() { opts.date = self.signing_date(); } - Ok(Arc::new(pdfcraft_sign::sign(&editor.cos, id, &opts)?)) + // A typed Password-field value is not signed into the file (#3926), as it isn't saved. + let cos = writable(&editor.cos)?; + Ok(Arc::new(pdfcraft_sign::sign(&cos, id, &opts)?)) } /// [`Session::sign`], embedding an RFC 3161 signature timestamp (PAdES B-T) produced by @@ -5188,7 +5238,8 @@ impl Session { if opts.date.is_empty() { opts.date = self.signing_date(); } - let signed = Arc::new(pdfcraft_sign::sign_with_timestamp(&editor.cos, id, &opts, tsa)?); + let cos = writable(&editor.cos)?; + let signed = Arc::new(pdfcraft_sign::sign_with_timestamp(&cos, id, &opts, tsa)?); // Checked like a save before anyone writes it (#2166). self.check_save(doc, &signed)?; Ok(signed) @@ -5200,7 +5251,8 @@ impl Session { let d = self.get(doc).ok_or(EditError::NoDocument)?; let editor = d.editor.as_ref().ok_or_else(|| EditError::ReadOnly(d.read_only_reason.clone().unwrap_or_default()))?; let date = if date.is_empty() { self.signing_date() } else { date }; - let stamped = Arc::new(pdfcraft_sign::timestamp_document(&editor.cos, tsa, &date)?); + let cos = writable(&editor.cos)?; + let stamped = Arc::new(pdfcraft_sign::timestamp_document(&cos, tsa, &date)?); self.check_save(doc, &stamped)?; Ok(stamped) } @@ -5210,7 +5262,8 @@ impl Session { pub fn embed_ltv(&self, doc: DocId, evidence: &pdfcraft_sign::dss::Evidence) -> Result>, EditError> { let d = self.get(doc).ok_or(EditError::NoDocument)?; let editor = d.editor.as_ref().ok_or_else(|| EditError::ReadOnly(d.read_only_reason.clone().unwrap_or_default()))?; - let embedded = Arc::new(pdfcraft_sign::dss::embed(&editor.cos, evidence)?); + let cos = writable(&editor.cos)?; + let embedded = Arc::new(pdfcraft_sign::dss::embed(&cos, evidence)?); self.check_save(doc, &embedded)?; Ok(embedded) } diff --git a/crates/engine/src/optimizer.rs b/crates/engine/src/optimizer.rs index ee8c957f9..444c428ca 100644 --- a/crates/engine/src/optimizer.rs +++ b/crates/engine/src/optimizer.rs @@ -83,6 +83,8 @@ impl OptimizeJob { fn work(self, progress: &mut dyn FnMut(OptimizeStage) -> bool) -> Result { let OptimizeJob { mut cos, settings, discard, opts } = self; + // Like every file written, without the values of Password-flag fields (#3926). + pdfcraft_forms::clear_password_values(&mut cos).map_err(|e| EditError::Write(e.to_string()))?; let mut step = |stage| if progress(stage) { Ok(()) } else { Err(EditError::Cancelled) }; step(OptimizeStage::Discarding)?; let discarded = if discard.is_empty() { Vec::new() } else { pdfcraft_redact::sanitize::remove_hidden(&mut cos, &discard)? }; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 4d3145065..c61394ec2 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2120,6 +2120,81 @@ fn form_fixture() -> Vec { out } +/// Whether `needle` appears in `bytes`, or in any stream of the PDF they hold once decoded (so +/// compressed object streams are searched too), as literal or hex string bytes. +fn appears_in_pdf(bytes: &Arc>, needle: &str) -> bool { + let hex: String = needle.bytes().map(|b| format!("{b:02x}")).collect(); + let forms = [needle.as_bytes().to_vec(), hex.clone().into_bytes(), hex.to_uppercase().into_bytes()]; + let found = |hay: &[u8]| forms.iter().any(|f| hay.windows(f.len()).any(|w| w == f.as_slice())); + if found(bytes) { + return true; + } + let doc = pdfcraft_cos::Document::open(bytes.clone()).unwrap(); + doc.object_numbers().into_iter().any(|num| match &*doc.get(pdfcraft_cos::ObjRef { num, generation: 0 }) { + pdfcraft_cos::Object::Stream(st) => st.decoded().is_ok_and(|d| found(&d)), + _ => false, + }) +} + +/// #3926: a value typed into a text field with the Password flag stays in the open document but +/// is written to no file: Save, a full rewrite, Reduce File Size, a recovery snapshot, or a new +/// file made from its pages (Extract, Split, Combine). ISO +/// 32000-2 §12.7.5.3, Table 231, note to the Password flag: PDF processors should never store +/// the value in the PDF file. +#[test] +fn password_field_values_are_kept_in_memory_but_written_to_no_file() { + let fixture = String::from_utf8(form_fixture()).unwrap().replace("/T (name)", "/T (name) /Ff 8192"); + // Offsets shift by the inserted text; the parser rebuilds the cross-reference table. + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("form.pdf", None, Arc::new(fixture.into_bytes()), None).unwrap(); + s.apply(id, Edit::SetFieldValue { name: "name".into(), value: FieldValue::Text("hunter2".into()) }).unwrap(); + let field = s.get(id).unwrap().form.iter().find(|f| f.name == "name").cloned().unwrap(); + assert!(field.has(pdfcraft_forms::flags::PASSWORD)); + assert_eq!(field.value, vec!["hunter2".to_string()], "the open document keeps the value"); + assert!(appears_in_pdf(&s.get(id).unwrap().bytes, "hunter2"), "the working bytes do hold it (the test can see it)"); + + let saved = s.save_bytes(id).unwrap(); + assert!(!appears_in_pdf(&saved, "hunter2"), "Save"); + // 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(); + assert_eq!(snaps.len(), 1); + assert!(!appears_in_pdf(&snaps[0].bytes, "hunter2"), "the recovery snapshot"); + // New files built from its pages too (#3943 review): Extract copies the fields of the pages it + // 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, "*******"), "nor on the extracted page"); + for (_, _, part) in s.split_by_size(id, 1).unwrap() { + assert!(!appears_in_pdf(&part, "hunter2"), "Split"); + } + assert!(!appears_in_pdf(&s.combine(&[("form.pdf".into(), s.get(id).unwrap().bytes.clone())]).unwrap(), "hunter2"), "Combine"); + // Reopened, the field is empty, as in any reader. + let mut t = Session::new(); + let reopened = t.open("saved.pdf", None, saved, None).unwrap(); + assert!(t.get(reopened).unwrap().form.iter().find(|f| f.name == "name").unwrap().value.iter().all(|v| v.is_empty())); +} + +/// #3926 / #3943 review: signing writes the signed file from the same copy a save does, so a +/// typed Password-field value is not signed into the file either. +#[test] +fn a_signed_file_carries_no_password_field_value() { + let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap(); + let digital_id = sign::pkcs12::open(&p12, "test").unwrap(); + let fixture = String::from_utf8(form_fixture()).unwrap().replace("/T (name)", "/T (name) /Ff 8192"); + // After the test certificate's start date (2026-10-02). + let mut s = Session::new().with_clock(|| 1_800_000_000); + let id = s.open("form.pdf", None, Arc::new(fixture.into_bytes()), None).unwrap(); + s.apply(id, Edit::SetFieldValue { name: "name".into(), value: FieldValue::Text("hunter2".into()) }).unwrap(); + 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, "*******"), "nor the typed length"); +} + /// A value its field's font can't draw is kept, and the edit that drew it reports it in /// `edit_notices` (the interface shows it, tools return it): not a later unrelated edit, not a /// failed one, and not a value that can be drawn. The form (an inline AcroForm here) asks other @@ -4812,6 +4887,25 @@ fn measurement_edits_are_transactional_and_honour_annotation_permissions() { ); } +/// A laid-out XFA field that doesn't scroll (`hScrollPolicy="off"`) opens as a text field with +/// DoNotScroll, and its text box says what fits it: what the editor takes no more than. +#[test] +fn an_xfa_field_that_doesnt_scroll_opens_with_do_not_scroll_and_a_text_box() { + let xdp = r##" +"##; + let mut s = Session::new(); + let id = s.open("form.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(xdp)), None).expect("opens"); + let doc = s.get(id).unwrap(); + let field = |end: &str| doc.form.iter().find(|f| f.name.ends_with(end)).unwrap_or_else(|| panic!("no field {end}")); + assert!(field("code").has(field_flags::DO_NOT_SCROLL) && !field("note").has(field_flags::DO_NOT_SCROLL)); + let tb = doc.field_text_box(&field("code").name, 0).expect("a text box"); + assert!(tb.fits("A1B2") && !tb.fits(&"W".repeat(40)), "a 1-inch field at 10 pt"); + assert!(doc.field_text_box("no such field", 0).is_none() && doc.field_text_box(&field("code").name, 5).is_none()); +} + #[test] fn dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally() { let original = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::template(3))); @@ -7589,3 +7683,59 @@ fn a_page_truncated_for_forms_and_pictures_is_listed_once() { assert!(pages[0].images.is_empty(), "the picture is past the budget"); assert_eq!(truncated, [0]); } + +/// ISO 32000-2 Table 192, 12.7.5.2.3 and Table 228 (Comb): a check box, a push button, a comb +/// field the file leaves to the viewer. The page as displayed and printed as an image shows the +/// check box's caption symbol in its text colour, the button's grey face and the comb's cells in +/// the border colour, and saving writes the file as it was. +#[test] +fn buttons_and_combs_left_to_the_viewer_are_displayed() { + let pdf = b"%PDF-1.7\n\ + 1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 4 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 8 0 R] >> endobj\n\ + 4 0 obj << /Fields [6 0 R 7 0 R 8 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> endobj\n\ + 5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> endobj\n\ + 6 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (k) /V /Yes /AS /Yes /DA (/ZaDb 0 Tf 1 0 0 rg) /MK << /CA (8) /BC [0 0 0] >> /Rect [20 150 60 190] /F 4 /P 3 0 R >> endobj\n\ + 7 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (o) /DA (/Helv 12 Tf 0 g) /MK << /CA (Press) /BG [0.8 0.8 0.8] /BC [0 0 0] >> /BS << /W 2 /S /B >> /Rect [100 150 220 190] /F 4 /P 3 0 R >> endobj\n\ + 8 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /Ff 16777216 /MaxLen 4 /T (d) /V (AB) /DA (/Helv 12 Tf 0 g) /MK << /BC [0 0 1] >> /Rect [20 80 220 110] /F 4 /P 3 0 R >> endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF" + .to_vec(); + let render = |bytes: Arc>| { + let mut r = pdfcraft_render::PageRenderer::new(bytes, pdfcraft_render::RenderConfig::default()); + r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }) + }; + // Pixels of the page (drawn at scale 1, 400 x 200) in user-space box x0..x1, y0..y1 that `keep`. + let count = |page: &pdfcraft_render::RenderedPage, (x0, y0, x1, y1): (u32, u32, u32, u32), keep: &dyn Fn(&[u8; 4]) -> bool| { + let (px, _) = page.rgba.as_chunks::<4>(); + (200 - y1..200 - y0) + .flat_map(|y| (x0..x1).map(move |x| (x, y))) + .filter(|&(x, y)| px.get((y * page.width + x) as usize).is_some_and(keep)) + .count() + }; + let red = |p: &[u8; 4]| p[0] > 200 && p[1] < 80 && p[2] < 80; + let grey = |p: &[u8; 4]| (180..=230).contains(&p[0]) && p[0] == p[1] && p[1] == p[2]; + // Bluish: a 1 pt line on a pixel edge is half blue on each side. + let blue = |p: &[u8; 4]| p[2] > 200 && p[0] < 180 && p[1] < 180 && p[0] + 40 < p[2]; + let mut s = Session::new(); + let id = s.open("form.pdf", None, Arc::new(pdf), None).unwrap(); + let shown = render(s.get(id).unwrap().display.clone()); + assert!(count(&shown, (24, 154, 56, 186), &red) > 60, "the check box's red cross"); + assert!(count(&shown, (104, 154, 216, 186), &grey) > 1000, "the push button's grey face"); + // The comb's three inner lines at x = 70, 120 and 170, between its cells. + for x in [70, 120, 170] { + assert!(count(&shown, (x - 1, 84, x + 1, 106), &blue) >= 20, "a cell line at {x}"); + } + assert_eq!(count(&shown, (95, 84, 97, 106), &blue), 0, "no line inside a cell"); + // Print as Image: the page as a picture, at 72 dpi so it is the size of the page here. + let raster = pdfcraft_print::Raster { dpi: 72.0, grayscale: false }; + let pictures = crate::print_raster::rasterize(s.get(id).unwrap(), &[0], &raster, pdfcraft_print::Content::Document).unwrap(); + let printed = render(Arc::new(pictures)); + assert!(count(&printed, (24, 154, 56, 186), &red) > 60, "the check box prints"); + assert!(count(&printed, (104, 154, 216, 186), &grey) > 1000, "and the push button"); + // The file is saved as it was: no widget has an appearance. + let saved = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap(); + for f in pdfcraft_forms::fields(&saved) { + assert!(saved.get(f.widgets[0].obj).as_dict().is_some_and(|d| !d.contains(b"AP")), "{} has no appearance", f.name); + } +} diff --git a/crates/forms/README.md b/crates/forms/README.md index ac48d6c58..4aec223ee 100644 --- a/crates/forms/README.md +++ b/crates/forms/README.md @@ -39,8 +39,9 @@ let cells: Vec = grid.cells_for(names)?; // "Table1_1", "Table - Inheritable attributes (`/FT`, `/Ff`, `/V`, `/DV`, `/DA`, `/Q`, `/MaxLen`) are resolved down the field tree; the form's `/DA` and `/Q` are the last fallback. -- Text and choice fields get new appearance streams (`/Tx BMC … EMC`): auto-size (`0 Tf`), comb, - multiline wrapping, password masking, quadding, `/MK` background and border, list-box selection. +- Text and choice fields get new appearance streams (`/Tx BMC … EMC`): auto-size (`0 Tf`), comb + (cells parted in the border colour), multiline wrapping, password masking, quadding, `/MK` + background and border, list-box selection. The `/DA` font is used when `/DR` has it as a simple font or a composite font with a predefined Unicode CMap; otherwise Helvetica (WinAnsi). A value or button caption the font can't hold (Chinese in a Helvetica field, a character beyond U+FFFF in a UCS-2 CMap) is kept, as Acrobat @@ -55,7 +56,9 @@ let cells: Vec = grid.cells_for(names)?; // "Table1_1", "Table drawn from their values, as typing them would draw them; the engine shows and prints that copy and saves the file as it was (#3470). `with_field_appearances_cached` keeps each widget's drawing while the objects it was drawn from are the same (`FieldAppearanceCache`), so typing in - one field redraws that field, not the form. + one field redraws that field, not the form. Check boxes and radio buttons with no appearance for + their state are drawn from `/MK` (border, background, and when on the `/MK /CA` ZapfDingbats + symbol in the `/DA` colour), and push buttons with none from `/MK`, `/BS` and their caption. - Check boxes and radio buttons switch `/V` and each widget's `/AS`. Widgets with no appearances get PdfCraft's own (a drawn check mark or dot; no symbol font needed). - Imported `/MaxLen 0` is treated as unlimited (also clearing an inherited limit), while the diff --git a/crates/forms/src/actions.rs b/crates/forms/src/actions.rs index cdc89e563..f58bd1078 100644 --- a/crates/forms/src/actions.rs +++ b/crates/forms/src/actions.rs @@ -159,6 +159,60 @@ pub fn field_actions(doc: &Document, name: &str) -> Result usize { + let mut seen = std::collections::HashSet::new(); + if let Object::Ref(r) = action { + seen.insert(*r); + } + let mut stack: Vec = doc.resolve(action).as_dict().and_then(|d| d.get(b"Next").cloned()).into_iter().collect(); + let mut count = 0; + while let Some(o) = stack.pop() { + if count >= MAX_CHAIN { + break; + } + if let Object::Ref(r) = &o + && !seen.insert(*r) + { + continue; + } + match &*doc.resolve(&o) { + Object::Array(items) => stack.extend(items.iter().cloned()), + Object::Dict(d) => { + count += 1; + stack.extend(d.get(b"Next").cloned()); + } + _ => {} + } + } + count +} + +/// For each trigger of field `name` (its first widget) whose action has others chained after it, +/// how many there are. +pub fn field_action_chains(doc: &Document, name: &str) -> Result, FormError> { + let all = fields(doc); + let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?; + let Some(w) = f.widgets.first() else { return Ok(Vec::new()) }; + let wd = doc.get(w.obj).as_dict().cloned().unwrap_or_default(); + let aa = wd.get(b"AA").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()).unwrap_or_default(); + Ok(Trigger::ALL + .into_iter() + .filter_map(|t| { + let o = match t.key() { + None => wd.get(b"A").cloned().or_else(|| aa.get(b"U").cloned()), + Some(k) => aa.get(k).cloned(), + }?; + let n = chained(doc, &o); + (n > 0).then_some((t, n)) + }) + .collect()) +} + fn write(doc: &mut Document, a: &FieldAction, pages: &[pdfcraft_cos::ObjRef]) -> Result, FormError> { let mut d = Dict::new(); d.set(b"Type".to_vec(), Object::name("Action")); @@ -207,11 +261,13 @@ fn write(doc: &mut Document, a: &FieldAction, pages: &[pdfcraft_cos::ObjRef]) -> } /// Replace field `name`'s actions (on every widget) with `actions`; triggers not listed lose -/// theirs. `Other` actions are left as they are. +/// theirs. `Other` actions, and actions that read the same as before, are left as they are (with +/// their chain and the keys this module doesn't read); a replaced action keeps its chain. pub fn set_field_actions(doc: &mut Document, name: &str, actions: &[(Trigger, FieldAction)]) -> Result<(), FormError> { let all = fields(doc); let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?.clone(); let pages = crate::page_refs(doc); + let mut scripts = crate::script_budget(); for w in &f.widgets { let wd = doc.get(w.obj).as_dict().cloned().unwrap_or_default(); let mut aa = wd.get(b"AA").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()).unwrap_or_default(); @@ -221,10 +277,23 @@ pub fn set_field_actions(doc: &mut Document, name: &str, actions: &[(Trigger, Fi if matches!(new, Some(FieldAction::Other(_))) { continue; } + let old = match t.key() { + None => wd.get(b"A").cloned().or_else(|| aa.get(b"U").cloned()), + Some(k) => aa.get(k).cloned(), + }; + let old_action = old.as_ref().and_then(|o| read(doc, o, &pages, &mut scripts)); + if new.is_some() && new == old_action.as_ref() { + continue; + } + // The list shows a trigger's first action: replacing it keeps the ones chained after it. + let next = new.and(old).and_then(|o| doc.resolve(&o).as_dict().and_then(|d| d.get(b"Next").cloned())); let obj = match new { Some(a) => write(doc, a, &pages)?, None => None, }; + if let (Some(Object::Ref(r)), Some(next)) = (&obj, next) { + doc.update_dict(*r, |d| d.set(b"Next".to_vec(), next))?; + } match t.key() { None => { a_entry = obj; diff --git a/crates/forms/src/appearance.rs b/crates/forms/src/appearance.rs index 3b6f6f20d..875b8c8e1 100644 --- a/crates/forms/src/appearance.rs +++ b/crates/forms/src/appearance.rs @@ -72,6 +72,128 @@ pub fn auto_text_size_by(width: f64, height: f64, multiline: bool, line_width: i if tw.is_finite() && tw > width { (size * width / tw).max(4.0) } else { size } } +/// How a text field's widget lays its value out in [`field_appearance_drawn`], for asking +/// whether a value fits it ([`TextBox::fits`]): what a field that doesn't scroll (`DoNotScroll`, +/// ISO 32000-2 Table 228) still takes. Built from the same pieces the appearance uses: the `/DA` +/// font from the form's `/DR` (Helvetica where that font can't show the text), the room inside +/// the border and the 2 pt padding, the `/DA` size (or the automatic size), and wrapping for a +/// multi-line field; the two must change together. +#[derive(Clone, Debug, PartialEq)] +pub struct TextBox { + /// The `/DA` font from `/DR`, when it is one the appearance can draw with. + font: Option, + /// Width and height inside the border and padding. + inner_w: f64, + inner_h: f64, + /// The `/DA` size; `None` for an automatic (`0 Tf`) size, which shrinks to fit. + size: Option, + multiline: bool, + password: bool, + /// A comb field: its cells, not its width, bound it (`/MaxLen`). + comb: bool, +} + +/// The [`TextBox`] widget `w` of text field `f` lays its value out in. +pub fn text_box(doc: &Document, f: &Field, w: &Widget) -> TextBox { + let wobj = doc.get(w.obj); + let wd = wobj.as_dict().cloned().unwrap_or_default(); + let (page_w, page_h) = ((w.rect[2] - w.rect[0]).max(1.0), (w.rect[3] - w.rect[1]).max(1.0)); + let place = placement(mk_rotation(doc, &wd), page_w, page_h); + let da = parse_da(wd.get(b"DA").and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text())).as_deref().unwrap_or(&f.da)); + let pad = 2.0 + border_width(doc, &wd); + let multiline = f.has(flags::MULTILINE); + TextBox { + font: dr_font(doc, &da.font).map(|(_, enc)| enc), + inner_w: (place.layout_w - 2.0 * pad).max(1.0), + inner_h: place.layout_h - 2.0 * pad, + size: (da.size > 0.0 && da.size.is_finite()).then_some(da.size), + multiline, + password: f.has(flags::PASSWORD), + comb: f.has(flags::COMB) && !multiline && !f.has(flags::PASSWORD) && f.max_len.is_some_and(|m| m > 0), + } +} + +impl TextBox { + /// Whether `text` fits: on one line within the width, or, multi-line, wrapped within the + /// height, as the appearance draws it. An automatic size shrinks before text stops fitting, + /// so such a field is full only when the text no longer fits at its smallest size (4 pt). A + /// comb field always fits (its `/MaxLen` bounds it). + pub fn fits(&self, text: &str) -> bool { + if self.comb || text.is_empty() { + return true; + } + let shown: String = if self.password { "*".repeat(text.chars().count()) } else { text.to_string() }; + // The font the appearance would choose for this text. + let can_show = |enc: &FontText| if self.multiline { shown.split(['\n', '\r']).all(|l| enc.can_encode(l)) } else { enc.can_encode(&shown) }; + let font = self.font.as_ref().filter(|enc| can_show(enc)).unwrap_or(&FontText::WinAnsi); + let width_of = |t: &str, size: f64| font.width(t, size); + // The smallest an automatic size goes ([`auto_text_size_by`]). + let size = self.size.unwrap_or(4.0); + if self.multiline { + let lines = wrap_with(&shown, size, self.inner_w, width_of).len() as f64; + lines * size * 1.15 <= self.inner_h + 1e-6 + } else { + // A line break in a single-line value is drawn as a space. + let line: String = shown.chars().map(|c| if matches!(c, '\n' | '\r') { ' ' } else { c }).collect(); + width_of(&line, size) <= self.inner_w + 1e-6 + } + } + + /// An edit from `before` to `after` as a field that doesn't scroll takes it: all of it when + /// `after` fits, and otherwise as much of the text it inserted (typed or pasted) as still + /// fits, keeping what it removed removed. Returns the text and where the caret goes (in + /// characters, after what was kept of the insertion). + pub fn fitted_edit(&self, before: &str, after: &str) -> (String, usize) { + let (b, a): (Vec, Vec) = (before.chars().collect(), after.chars().collect()); + // The edit replaced b[p..b.len() - q] with a[p..a.len() - q]. + let p = b.iter().zip(&a).take_while(|(x, y)| x == y).count(); + let most = b.len().min(a.len()).saturating_sub(p); + let q = b.iter().rev().zip(a.iter().rev()).take(most).take_while(|(x, y)| x == y).count(); + // q <= a.len() - p, so these are in range; `get` keeps it so if that ever changes. + let end = a.len().saturating_sub(q); + let (prefix, inserted, suffix) = (a.get(..p).unwrap_or_default(), a.get(p..end).unwrap_or_default(), a.get(end..).unwrap_or_default()); + let with = |k: usize| prefix.iter().chain(inserted.get(..k).unwrap_or_default()).chain(suffix).collect::(); + if self.fits(after) { + return (after.to_string(), p + inserted.len()); + } + // The longest of the insertion's first `from..=to` characters that fits, halving: in one + // font a longer text is never narrower. `None` when even `from` doesn't fit. + let longest = |from: usize, to: usize| -> Option { + if from > to || !self.fits(&with(from)) { + return None; + } + let (mut lo, mut hi) = (from, to); + while lo < hi { + let mid = lo + (hi - lo).div_ceil(2); + if self.fits(&with(mid)) { lo = mid } else { hi = mid - 1 } + } + Some(lo) + }; + // The font can change once, from the form's own to Helvetica, at the first inserted + // character the form's font can't show: each side of that point is searched on its own. + let n = inserted.len(); + let kept = match self.font_switch(prefix, inserted, suffix) { + Some(t) => longest(0, t.saturating_sub(1)).into_iter().chain(longest(t, n)).max(), + None => longest(0, n), + } + .unwrap_or(0); + (with(kept), p + kept) + } + + /// Where an insertion changes the font [`Self::fits`] measures in: how much of `inserted` + /// (between `prefix` and `suffix`) first includes a character the form's own font can't show, + /// when everything around it can be shown in that font. `None` when the font never changes. + fn font_switch(&self, prefix: &[char], inserted: &[char], suffix: &[char]) -> Option { + let enc = self.font.as_ref().filter(|_| !self.password)?; + // As `fits` decides: a multi-line value is checked line by line, its breaks aside. + let shows = |c: &char| (self.multiline && matches!(c, '\n' | '\r')) || enc.can_encode(&c.to_string()); + if !prefix.iter().chain(suffix).all(shows) { + return None; + } + inserted.iter().position(|c| !shows(c)).map(|i| i + 1) + } +} + /// `/MK /R` as a quadrant, or 0 when it is missing or not a multiple of 90. pub(crate) fn mk_rotation(doc: &Document, wd: &Dict) -> i64 { let Some(raw) = wd.get(b"MK") else { return 0 }; @@ -351,7 +473,7 @@ fn frame(doc: &Document, wd: &Dict, w: f64, h: f64) -> (String, f64) { fn border_width(doc: &Document, wd: &Dict) -> f64 { let mk = wd.get(b"MK").map(|m| doc.resolve(m)).and_then(|m| m.as_dict().cloned()); let bs = wd.get(b"BS").map(|b| doc.resolve(b)).and_then(|b| b.as_dict().cloned()); - let bw = bs.as_ref().and_then(|b| b.get(b"W")).and_then(|x| doc.resolve(x).as_f64()).unwrap_or(1.0).max(0.0); + let bw = line_width(doc, wd); if color_array(doc, mk.as_ref(), b"BC").is_none() || bw <= 0.0 { return 0.0; } @@ -359,6 +481,37 @@ fn border_width(doc: &Document, wd: &Dict) -> f64 { if style == b"B" || style == b"I" { 2.0 * bw } else { bw } } +/// The widget's border line width, `/BS /W` (default 1, Table 168), as [`frame`] strokes it. +fn line_width(doc: &Document, wd: &Dict) -> f64 { + let bs = wd.get(b"BS").map(|b| doc.resolve(b)).and_then(|b| b.as_dict().cloned()); + // `f64::max` turns NaN into 0, as it does a negative width. + bs.as_ref().and_then(|b| b.get(b"W")).and_then(|x| doc.resolve(x).as_f64()).unwrap_or(1.0).max(0.0) +} + +/// Comb fields stop drawing their cells past this many; they would be a solid block anyway. +const MAX_COMB_DIVIDERS: usize = 500; + +/// The lines between the `cells` positions of a comb field `w` × `h` (ISO 32000-2 Table 228, +/// Comb: the field is divided into `MaxLen` equally spaced positions), in the border's colour and +/// width, inside the border. None without a border, or past [`MAX_COMB_DIVIDERS`] cells. +fn comb_dividers(doc: &Document, wd: &Dict, w: f64, h: f64, cells: usize) -> String { + let mk = wd.get(b"MK").map(|m| doc.resolve(m)).and_then(|m| m.as_dict().cloned()); + let inset = border_width(doc, wd); + let lw = line_width(doc, wd); + let Some(bc) = color_array(doc, mk.as_ref(), b"BC") else { return String::new() }; + if !(inset > 0.0 && inset.is_finite() && lw > 0.0 && lw.is_finite()) || !(2..=MAX_COMB_DIVIDERS).contains(&cells) { + return String::new(); + } + let cell = w / cells as f64; + let mut c = format!("{}\n{} w\n", stroke_op(&bc), n(lw)); + for i in 1..cells { + let x = cell * i as f64; + c.push_str(&format!("{} {} m {} {} l\n", n(x), n(inset), n(x), n(h - inset))); + } + c.push_str("S\n"); + c +} + /// Where a list box draws its options: one row per option from the top, starting with option /// `top` (`/TI`, §12.7.4.4). The appearance PdfCraft writes and the viewer's in-place list share /// it, so a click picks the option drawn under the pointer (#1524). @@ -449,6 +602,45 @@ pub fn field_appearance_as(doc: &Document, f: &Field, w: &Widget, values: &[Stri /// hold it: the form's font is a simple (WinAnsi) font, Helvetica stands in for one PdfCraft can't /// write with (an embedded subset, `Identity-H`), or a UCS-2 CMap meets a character beyond U+FFFF. pub fn field_appearance_drawn(doc: &Document, f: &Field, w: &Widget, values: &[String], format: bool) -> (Stream, Option) { + let (stream, unencodable, _) = field_appearance_fit(doc, f, w, values, format); + (stream, unencodable) +} + +/// A text value that its box cuts off at the field's font size (#4018). +#[derive(Clone, Debug, PartialEq)] +pub enum Overflow { + /// One line `width` points wide, with `room` for it, at `size` points. + Wide { width: f64, room: f64, size: f64 }, + /// `lines` wrapped lines, of which the box shows `room`, at `size` points. + Tall { lines: usize, room: usize, size: f64 }, + /// One line `height` points high (ascent to descent), with `room` for it, at `size` points. + High { height: f64, room: f64, size: f64 }, +} + +impl std::fmt::Display for Overflow { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Wide { width, room, size } => { + write!(f, "the value does not fit: {} pt wide at {} pt, room for {} pt", fmt(*width), fmt(*size), fmt(*room)) + } + Self::Tall { lines, room, size } => write!(f, "the value does not fit: {lines} lines at {} pt, room for {room}", fmt(*size)), + Self::High { height, room, size } => { + write!(f, "the value does not fit: {} pt high at {} pt, room for {} pt", fmt(*height), fmt(*size), fmt(*room)) + } + } + } +} + +/// Whether the text field's value, drawn in widget `w`, is cut off by its box. +pub fn value_overflow(doc: &Document, f: &Field, w: &Widget) -> Option { + if f.kind != FieldKind::Text { + return None; + } + field_appearance_fit(doc, f, w, &f.value, true).2 +} + +/// [`field_appearance_drawn`], and whether the text drawn is cut off by the box. +fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String], format: bool) -> (Stream, Option, Option) { // The Format event: what is shown, not what is stored. let formatted: Vec; let values = if format @@ -513,6 +705,7 @@ pub fn field_appearance_drawn(doc: &Document, f: &Field, w: &Widget, values: &[S } }; let mut size = da.size; + let mut overflow = None; match f.kind { FieldKind::List => { // Every option, one per line from the top (from `/TI`); selected ones highlighted. @@ -539,13 +732,25 @@ pub fn field_appearance_drawn(doc: &Document, f: &Field, w: &Widget, values: &[S size = auto_text_size(&text, inner_w, height - 2.0 * pad, true, width_of); } let mut y = height - pad - size * 0.85; - for line in wrap_with(&text, size, inner_w, width_of) { + let lines = wrap_with(&text, size, inner_w, width_of); + // The lines whose descent stays inside the border; the rest are cut off. + let mut room = 0; + for line in &lines { if y < -size { break; } - show(&mut body, x_for(&line, size), y, &line); + if y - 0.207 * size >= bw - 0.01 { + room += 1; + } + show(&mut body, x_for(line, size), y, line); y -= size * 1.15; } + if room < lines.len() { + overflow = Some(Overflow::Tall { lines: lines.len(), room, size }); + } else if let Some(tw) = lines.iter().map(|l| width_of(l, size)).filter(|tw| *tw > inner_w + 0.01).reduce(f64::max) { + // A character wider than the box is a line of its own, still cut at the sides. + overflow = Some(Overflow::Wide { width: tw, room: inner_w, size }); + } } else { if size == 0.0 { // Comb cells hold one character each, so only the height limits them. @@ -557,14 +762,24 @@ pub fn field_appearance_drawn(doc: &Document, f: &Field, w: &Widget, values: &[S } // Centre Helvetica's ascent (0.718) and descent (0.207) vertically. let y = (height - 0.925 * size) / 2.0 + 0.207 * size; + // The clip box is inside the border; a line taller than it is cut at the top and bottom. + let room_h = height - 2.0 * bw; + if 0.925 * size > room_h + 0.01 { + overflow = Some(Overflow::High { height: 0.925 * size, room: room_h, size }); + } if comb { let cells = f.max_len.unwrap_or(1).max(1); let cell = width / cells as f64; + c.push_str(&comb_dividers(doc, &wd, width, height, cells)); for (i, ch) in text.chars().take(cells).enumerate() { let s = ch.to_string(); show(&mut body, cell * i as f64 + (cell - width_of(&s, size)) / 2.0, y, &s); } } else { + let tw = width_of(&text.replace(['\n', '\r'], " "), size); + if overflow.is_none() && tw > inner_w + 0.01 { + overflow = Some(Overflow::Wide { width: tw, room: inner_w, size }); + } show(&mut body, x_for(&text, size), y, &text); } } @@ -595,13 +810,17 @@ pub fn field_appearance_drawn(doc: &Document, f: &Field, w: &Widget, values: &[S d.set(b"Subtype".to_vec(), Object::name("Form")); set_form_box(&mut d, place); d.set(b"Resources".to_vec(), Object::Dict(res)); - (Stream::flate(d, &content), unencodable) + // Nothing drawn is nothing cut, however small the box. + let overflow = overflow.filter(|_| !text.is_empty()); + (Stream::flate(d, &content), unencodable, overflow) } /// On/Off appearances for a check box or radio button that has none (a check mark or a dot, /// drawn as paths, so no symbol font is needed). +/// The mark is drawn in the colour of the widget's `/DA`, else of `f_da` (the field's): the +/// Appearance tab's Text Color (#4261). /// The streams are added to `doc` as indirect objects (streams can't be direct objects). -pub fn check_box_states(doc: &mut Document, w: &Widget, kind: FieldKind, on_name: &str) -> Dict { +pub fn check_box_states(doc: &mut Document, w: &Widget, kind: FieldKind, on_name: &str, f_da: &str) -> Dict { let wobj = doc.get(w.obj); let wd = wobj.as_dict().cloned().unwrap_or_default(); let (page_w, page_h) = ((w.rect[2] - w.rect[0]).max(1.0), (w.rect[3] - w.rect[1]).max(1.0)); @@ -609,6 +828,7 @@ pub fn check_box_states(doc: &mut Document, w: &Widget, kind: FieldKind, on_name let (width, height) = (place.layout_w, place.layout_h); let (frame_c, _) = frame(doc, &wd, width, height); let style = crate::author::CheckStyle::of_widget(doc, &wd, kind); + let da = parse_da(wd.get(b"DA").and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text())).as_deref().unwrap_or(f_da)); let mut form = |content: String| -> Object { let mut d = Dict::new(); d.set(b"Type".to_vec(), Object::name("XObject")); @@ -617,7 +837,7 @@ pub fn check_box_states(doc: &mut Document, w: &Widget, kind: FieldKind, on_name Object::Ref(doc.add(Object::Stream(Stream::flate(d, content.as_bytes())))) }; let s = width.min(height); - let mark = check_mark(style, width / 2.0, height / 2.0, s); + let mark = check_mark(style, width / 2.0, height / 2.0, s, &da.color); let mut nd = Dict::new(); nd.set(crate::name_bytes(on_name), form(format!("{frame_c}{mark}"))); nd.set(b"Off".to_vec(), form(frame_c)); @@ -627,12 +847,13 @@ pub fn check_box_states(doc: &mut Document, w: &Widget, kind: FieldKind, on_name } /// The on-state mark for `style` (#94), centred on (`cx`, `cy`) in a box whose smaller side is -/// `s`, drawn in black. -fn check_mark(style: crate::author::CheckStyle, cx: f64, cy: f64, s: f64) -> String { +/// `s`, drawn in the fill colour operation `color` (`0 g`, `0 0 1 rg`). +fn check_mark(style: crate::author::CheckStyle, cx: f64, cy: f64, s: f64, color: &str) -> String { use crate::author::CheckStyle; + let (fill, stroke) = (color, stroke_op(color)); // A filled polygon through `pts`. let polygon = |pts: &[(f64, f64)]| { - let mut c = String::from("0 g\n"); + let mut c = format!("{fill}\n"); for (i, (x, y)) in pts.iter().enumerate() { c.push_str(&format!("{} {} {} ", n(*x), n(*y), if i == 0 { "m" } else { "l" })); } @@ -642,7 +863,7 @@ fn check_mark(style: crate::author::CheckStyle, cx: f64, cy: f64, s: f64) -> Str let line = n((s * 0.1).max(1.0)); match style { CheckStyle::Check => format!( - "0 G\n{line} w 1 J 1 j\n{} {} m {} {} l {} {} l S\n", + "{stroke}\n{line} w 1 J 1 j\n{} {} m {} {} l {} {} l S\n", n(cx - s * 0.28), n(cy), n(cx - s * 0.08), @@ -653,7 +874,7 @@ fn check_mark(style: crate::author::CheckStyle, cx: f64, cy: f64, s: f64) -> Str CheckStyle::Cross => { let d = s * 0.25; format!( - "0 G\n{line} w 1 J\n{} {} m {} {} l S\n{} {} m {} {} l S\n", + "{stroke}\n{line} w 1 J\n{} {} m {} {} l S\n{} {} m {} {} l S\n", n(cx - d), n(cy - d), n(cx + d), @@ -666,7 +887,7 @@ fn check_mark(style: crate::author::CheckStyle, cx: f64, cy: f64, s: f64) -> Str } CheckStyle::Square => { let d = s * 0.22; - format!("0 g\n{} {} {} {} re f\n", n(cx - d), n(cy - d), n(2.0 * d), n(2.0 * d)) + format!("{fill}\n{} {} {} {} re f\n", n(cx - d), n(cy - d), n(2.0 * d), n(2.0 * d)) } CheckStyle::Diamond => { let d = s * 0.3; @@ -688,7 +909,7 @@ fn check_mark(style: crate::author::CheckStyle, cx: f64, cy: f64, s: f64) -> Str let r = s * 0.25; let k = 0.5523 * r; format!( - "0 g\n{} {} m {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c f\n", + "{fill}\n{} {} m {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c f\n", n(cx + r), n(cy), n(cx + r), @@ -720,6 +941,94 @@ fn check_mark(style: crate::author::CheckStyle, cx: f64, cy: f64, s: f64) -> Str } } +/// How a check box or radio button widget `w` of field `f` looks in state on (`on`) or Off when +/// the file has no appearance for that state: its `/MK` background and border ([`frame`]), and +/// when on, its caption `/MK /CA` (ISO 32000-2 Table 192: the normal caption, which check boxes +/// and radio buttons may use too) as a ZapfDingbats character, as in the check box example of +/// 12.7.5.2.3: `4` (a check mark) by default, `l` (a dot) for a radio button. The character is +/// drawn in the `/DA` colour, at the `/DA` size or, for an automatic size (`0 Tf`), filling most +/// of the box. ZapfDingbats is one of the standard 14 fonts, so no font program is needed. +pub(crate) fn button_state_shown(doc: &Document, f: &Field, w: &Widget, on: bool) -> Stream { + let wobj = doc.get(w.obj); + let wd = wobj.as_dict().cloned().unwrap_or_default(); + let (page_w, page_h) = ((w.rect[2] - w.rect[0]).max(1.0), (w.rect[3] - w.rect[1]).max(1.0)); + let place = placement(mk_rotation(doc, &wd), page_w, page_h); + let (width, height) = (place.layout_w, place.layout_h); + let (frame_c, bw) = frame(doc, &wd, width, height); + let mut content = frame_c.into_bytes(); + let mut res = Dict::new(); + if on { + let mk = wd.get(b"MK").map(|m| doc.resolve(m)).and_then(|m| m.as_dict().cloned()); + let caption = mk.as_ref().and_then(|mk| mk.get(b"CA")).and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text())); + let dingbats = pdfcraft_fonts::Std14::ZapfDingbats; + // The caption's first character as a ZapfDingbats code, when the font has a glyph there. + let code = caption + .and_then(|s| s.chars().next()) + .and_then(|ch| u8::try_from(u32::from(ch)).ok()) + .filter(|&code| dingbats.width(code) > 0.0) + .unwrap_or(if f.kind == FieldKind::Radio { b'l' } else { b'4' }); + let da = parse_da(wd.get(b"DA").and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text())).as_deref().unwrap_or(&f.da)); + // The glyph's advance and (about) its height, in ems. + let (advance, tall) = (dingbats.width(code) / 1000.0, 0.75); + let room = |side: f64| (side - 2.0 * bw - 2.0).max(1.0); + // An automatic size leaves a margin of a tenth of the room on each side. + let size = if da.size > 0.0 { da.size } else { (0.8 * room(width) / advance.max(0.1)).min(0.8 * room(height) / tall).clamp(1.0, 300.0) }; + let (x, y) = ((width - advance * size) / 2.0, (height - tall * size) / 2.0 + 0.05 * size); + content.extend(format!("q\nBT\n/ZaDb {} Tf\n{}\n{} {} Td\n", n(size), da.color, n(x), n(y)).bytes()); + content.extend(literal(&[code])); + content.extend_from_slice(b" Tj\nET\nQ\n"); + let mut font = Dict::new(); + font.set(b"Type".to_vec(), Object::name("Font")); + font.set(b"Subtype".to_vec(), Object::name("Type1")); + font.set(b"BaseFont".to_vec(), Object::name("ZapfDingbats")); + let mut fonts = Dict::new(); + fonts.set(b"ZaDb".to_vec(), Object::Dict(font)); + res.set(b"Font".to_vec(), Object::Dict(fonts)); + } + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Form")); + set_form_box(&mut d, place); + d.set(b"Resources".to_vec(), Object::Dict(res)); + Stream::flate(d, &content) +} + +/// How a push button widget `w` of field `f` with no normal appearance looks: its `/MK` +/// background and border, in the `/BS` width and style ([`frame`]: Table 168, `B` beveled, `I` +/// inset), with its caption `/MK /CA` (Table 192) centred in the `/DA` font, size and colour. +/// A button with an icon (`/MK /I`) is drawn as PdfCraft draws its own buttons, icon included. +pub(crate) fn push_button_shown(doc: &Document, f: &Field, w: &Widget) -> Stream { + let wobj = doc.get(w.obj); + let wd = wobj.as_dict().cloned().unwrap_or_default(); + let mk = wd.get(b"MK").map(|m| doc.resolve(m)).and_then(|m| m.as_dict().cloned()).unwrap_or_default(); + if mk.contains(b"I") { + return crate::author::button_appearance(doc, w, &f.da).0; + } + let (page_w, page_h) = ((w.rect[2] - w.rect[0]).max(1.0), (w.rect[3] - w.rect[1]).max(1.0)); + let place = placement(mk_rotation(doc, &wd), page_w, page_h); + let (width, height) = (place.layout_w, place.layout_h); + let (frame_c, _) = frame(doc, &wd, width, height); + let mut content = frame_c.into_bytes(); + let caption = mk.get(b"CA").and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text())).unwrap_or_default(); + let mut fonts = Dict::new(); + if !caption.is_empty() && mk.get(b"TP").and_then(|t| doc.resolve(t).as_int()) != Some(1) { + let da = wd.get(b"DA").and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text())); + // The automatic size PdfCraft's own buttons use. + let auto = ((height - 4.0) * 0.6).clamp(4.0, 14.0); + let (ops, name, font, _) = button_caption(doc, da.as_deref().unwrap_or(&f.da), &caption, width, height, auto); + content.extend(ops); + fonts.set(name.into_bytes(), font); + } + let mut res = Dict::new(); + res.set(b"Font".to_vec(), Object::Dict(fonts)); + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Form")); + set_form_box(&mut d, place); + d.set(b"Resources".to_vec(), Object::Dict(res)); + Stream::flate(d, &content) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index 95cd9d9a8..873ef3d71 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -134,10 +134,9 @@ pub(crate) fn ensure_form(doc: &mut Document) -> Result { if !af.contains(b"DA") { af.set(b"DA".to_vec(), PdfString::literal(b"/Helv 0 Tf 0 g".to_vec())); } - let mut dr = af.get(b"DR").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); - let mut fonts = dr.get(b"Font").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); + let (dr, mut fonts) = form_fonts(doc, &af); + let mut added = inline_fonts_as_objects(doc, &mut fonts); // Acrobat's resource names for the standard fonts its Appearance tab offers. - let mut added = false; for (name, base) in [("Helv", "Helvetica"), ("TiRo", "Times-Roman"), ("Cour", "Courier")] { if fonts.contains(name.as_bytes()) { continue; @@ -147,17 +146,105 @@ pub(crate) fn ensure_form(doc: &mut Document) -> Result { f.set(b"Subtype".to_vec(), Object::name("Type1")); f.set(b"BaseFont".to_vec(), Object::name(base)); f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); - fonts.set(name.as_bytes().to_vec(), Object::Dict(f)); + let r = doc.add(Object::Dict(f)); + fonts.set(name.as_bytes().to_vec(), Object::Ref(r)); added = true; } if added { - dr.set(b"Font".to_vec(), Object::Dict(fonts)); - af.set(b"DR".to_vec(), Object::Dict(dr)); + store_form_fonts(doc, &mut af, dr, fonts); } doc.set(r, Object::Dict(af)); Ok(r) } +/// The form dictionary's `/DR` and its `/Font` dictionary, resolved (empty where missing). +fn form_fonts(doc: &Document, af: &Dict) -> (Dict, Dict) { + let dr = af.get(b"DR").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); + let fonts = dr.get(b"Font").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); + (dr, fonts) +} + +/// Each /DR font its own object: Acrobat, redrawing a text field after it is typed in, shows +/// nothing when the font is a dictionary written inline in /DR (#3441). Fonts written inline +/// (by an earlier save, or another writer) become objects saying the same; a font that is an +/// object already, or an entry that isn't a dictionary, is left alone. Whether any changed. +fn inline_fonts_as_objects(doc: &mut Document, fonts: &mut Dict) -> bool { + let inline: Vec<(Vec, Dict)> = fonts.iter().filter_map(|(k, v)| Some((k.clone(), v.as_dict()?.clone()))).collect(); + let changed = !inline.is_empty(); + for (name, font) in inline { + let r = doc.add(Object::Dict(font)); + fonts.set(name, Object::Ref(r)); + } + changed +} + +/// Write `fonts` back as the `/DR /Font` of the form dictionary `af`. An indirect `/DR` or +/// `/Font` stays the object it is. +fn store_form_fonts(doc: &mut Document, af: &mut Dict, mut dr: Dict, fonts: Dict) { + match dr.get(b"Font").and_then(Object::as_ref) { + Some(fr) => doc.set(fr, Object::Dict(fonts)), + None => dr.set(b"Font".to_vec(), Object::Dict(fonts)), + } + match af.get(b"DR").and_then(Object::as_ref) { + Some(dr_ref) => doc.set(dr_ref, Object::Dict(dr)), + None => af.set(b"DR".to_vec(), Object::Dict(dr)), + } +} + +/// Filling a field: the form's inline `/DR` fonts become objects (see +/// [`inline_fonts_as_objects`]), so a form an earlier version of PdfCraft made works in Acrobat +/// once it has been filled here too (#3441). Only the font storage changes; nothing is added. +/// Not in a form a signature covers: there the file stays as the signer saw it, apart from the +/// value itself. +pub(crate) fn form_fonts_as_objects(doc: &mut Document, all: &[crate::Field]) -> Result<(), FormError> { + let signed = all.iter().any(|f| f.kind == crate::FieldKind::Signature && holds_value(doc, f.obj)); + if signed { + return Ok(()); + } + let Some(root) = doc.root() else { return Ok(()) }; + let Some(entry) = doc.get(root).as_dict().and_then(|c| c.get(b"AcroForm").cloned()) else { return Ok(()) }; + match entry { + Object::Ref(r) => { + let Some(mut af) = doc.get(r).as_dict().cloned() else { return Ok(()) }; + let (dr, mut fonts) = form_fonts(doc, &af); + if inline_fonts_as_objects(doc, &mut fonts) { + store_form_fonts(doc, &mut af, dr, fonts); + doc.set(r, Object::Dict(af)); + } + } + // A form dictionary written directly in the catalog (the catalog's /AcroForm need not be + // an indirect reference) is written back there. + Object::Dict(mut af) => { + let (dr, mut fonts) = form_fonts(doc, &af); + if inline_fonts_as_objects(doc, &mut fonts) { + store_form_fonts(doc, &mut af, dr, fonts); + doc.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Dict(af)))?; + } + } + _ => {} + } + Ok(()) +} + +/// Whether the field `r`, or the nearest ancestor that has one, holds a `/V` (the entry is +/// inheritable, ISO 32000-2 §12.7.4). The `/Parent` chain is followed at most 64 deep, as deep +/// as the field walk goes, so a cyclic chain ends. +fn holds_value(doc: &Document, r: ObjRef) -> bool { + let mut at = r; + for _ in 0..=64 { + let obj = doc.get(at); + let Some(d) = obj.as_dict() else { return false }; + if d.contains(b"V") { + return true; + } + match d.reference(b"Parent") { + Some(p) => at = p, + None => return false, + } + } + false +} + /// The Appearance tab: borders, colours and text. #[derive(Clone, Copy, Debug, PartialEq)] pub struct Look { @@ -486,14 +573,228 @@ fn add_to_fields(doc: &mut Document, af: ObjRef, field: ObjRef) -> Result<(), Fo Ok(()) } -/// The first free "N" name. -fn free_name(existing: &[Field], base: &str) -> String { +/// The most parts a dotted field name may have ("a.b.c" has three). +const MAX_NAME_PARTS: usize = 32; + +/// The field attributes a field inherits from its parents (ISO 32000-2 §12.7.4.1). +const INHERITED_KEYS: [&[u8]; 7] = [b"FT", b"Ff", b"V", b"DV", b"DA", b"Q", b"MaxLen"]; + +/// The parts of a full field name: `a.b` is the field `b` under the parent field `a`, since a +/// field's own (partial) name can't contain a period (ISO 32000-2 §12.7.4.2) (#2696). +fn name_parts(name: &str) -> Result, FormError> { + let parts: Vec<&str> = name.split('.').take(MAX_NAME_PARTS.saturating_add(1)).collect(); + if parts.len() > MAX_NAME_PARTS { + return invalid(format!("field names can have at most {MAX_NAME_PARTS} parts")); + } + if parts.iter().any(|p| p.trim().is_empty()) { + return invalid("field names can't be empty, or have an empty part between periods"); + } + Ok(parts) +} + +/// Why the full name `name` can't be used beside the field named `other`, if it can't: the +/// same name, or one inside the other (a field that has a value can't also hold fields). +fn clash(name: &str, other: &str) -> Option { + if name == other { + return Some(format!("a field named {name:?} already exists")); + } + let inside = |a: &str, b: &str| a.strip_prefix(b).is_some_and(|rest| rest.starts_with('.')); + (inside(name, other) || inside(other, name)).then(|| format!("{name:?} can't be used beside the field {other:?}")) +} + +/// The array `key` of `holder` (`/Fields` of the form, `/Kids` of a field). +fn list_of(doc: &Document, holder: ObjRef, key: &[u8]) -> Vec { + let d = doc.get(holder).as_dict().cloned().unwrap_or_default(); + d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default() +} + +/// Add `field` to `parent`'s `/Kids`, or to the form's `/Fields` at the top. +fn link(doc: &mut Document, af: ObjRef, parent: Option, field: ObjRef) -> Result<(), FormError> { + let Some(p) = parent else { return add_to_fields(doc, af, field) }; + let mut kids = list_of(doc, p, b"Kids"); + kids.push(Object::Ref(field)); + doc.update_dict(p, |d| d.set(b"Kids".to_vec(), Object::Array(kids)))?; + Ok(()) +} + +/// The node named `name` (its own `/T`) directly under `parent`, or at the top of the form. +fn child_named(doc: &Document, af: ObjRef, parent: Option, name: &str) -> Option { + let list = match parent { + Some(p) => list_of(doc, p, b"Kids"), + None => list_of(doc, af, b"Fields"), + }; + list.iter() + .filter_map(|o| o.as_ref()) + .find(|r| doc.get(*r).as_dict().and_then(|d| d.get(b"T").cloned()).and_then(|t| crate::text_of(&doc.resolve(&t))).as_deref() == Some(name)) +} + +/// The values a parent field passes to the fields under it that can't be turned off by giving +/// the field a neutral one of its own, as `/Ff` and `/Q` can. +const PASSED_VALUES: [&[u8]; 3] = [b"V", b"DV", b"MaxLen"]; + +/// Whether `field` may go in the field tree under the full name `parts`, changing nothing. The +/// field list holds only terminal fields, so a parent left empty by a move isn't in it: a node +/// of the same name there is found here. +/// `inherited` is what the field will hold as its own before it moves (see [`inherited`]). +fn check_place(doc: &Document, af: ObjRef, field: ObjRef, parts: &[&str], inherited: &Dict) -> Result<(), FormError> { + let Some((own, parents)) = parts.split_last() else { return invalid("field names can't be empty") }; + let mut owned = doc.get(field).as_dict().cloned().unwrap_or_default(); + for (k, v) in inherited.iter() { + owned.set(k.to_vec(), v.clone()); + } + let mut at: Option = None; + let mut whole_path = true; + for part in parents { + let Some(r) = child_named(doc, af, at, part) else { + whole_path = false; + break; + }; + // A value, reset value or length limit held by a parent would pass to this field. + let held = doc.get(r).as_dict().is_some_and(|d| PASSED_VALUES.iter().any(|k| d.contains(k) && !owned.contains(k))); + if held { + return invalid(format!("{part:?} holds a value for the fields under it, so {:?} can't be put there", parts.join("."))); + } + at = Some(r); + } + // A field being moved is still in the tree here, but under another full name (a move to its + // own name changes nothing and doesn't get here), so a node found is another. + if whole_path && child_named(doc, af, at, own).is_some() { + return invalid(format!("a field named {:?} already exists", parts.join("."))); + } + Ok(()) +} + +/// Put `field` in the field tree under the full name `parts`: its own `/T` is the last part, +/// and the parts before it name parent fields, reused where the tree has them and made where +/// not (Acrobat makes `a.b` a field `b` under a field `a`). +fn attach(doc: &mut Document, af: ObjRef, field: ObjRef, parts: &[&str]) -> Result<(), FormError> { + let Some((own, parents)) = parts.split_last() else { return invalid("field names can't be empty") }; + check_place(doc, af, field, parts, &Dict::new())?; + let mut parent: Option = None; + let mut inherits = false; + for part in parents { + let next = match child_named(doc, af, parent, part) { + Some(r) => { + inherits |= doc.get(r).as_dict().is_some_and(|d| d.contains(b"Ff") || d.contains(b"Q")); + r + } + None => { + let mut d = Dict::new(); + d.set(b"T".to_vec(), PdfString::text(part)); + d.set(b"Kids".to_vec(), Object::Array(Vec::new())); + if let Some(p) = parent { + d.set(b"Parent".to_vec(), Object::Ref(p)); + } + let r = doc.add(Object::Dict(d)); + link(doc, af, parent, r)?; + r + } + }; + parent = Some(next); + } + doc.update_dict(field, |d| { + d.set(b"T".to_vec(), PdfString::text(own)); + match parent { + Some(p) => d.set(b"Parent".to_vec(), Object::Ref(p)), + None => { + d.remove(b"Parent"); + } + } + // An existing parent's flags and alignment would otherwise pass to this field. + if inherits { + for k in [&b"Ff"[..], b"Q"] { + if !d.contains(k) { + d.set(k.to_vec(), Object::Int(0)); + } + } + } + })?; + link(doc, af, parent, field) +} + +/// What `field` inherits from its parents and doesn't hold itself, changing nothing. +fn inherited(doc: &Document, field: ObjRef) -> Dict { + let own = doc.get(field).as_dict().cloned().unwrap_or_default(); + let mut add = Dict::new(); + let mut seen = std::collections::HashSet::new(); + let mut parent = own.reference(b"Parent"); + while let Some(p) = parent { + if seen.len() > 64 || !seen.insert(p) { + break; + } + let Some(d) = doc.get(p).as_dict().cloned() else { break }; + for k in INHERITED_KEYS { + if let Some(v) = d.get(k).filter(|_| !own.contains(k) && !add.contains(k)) { + add.set(k.to_vec(), v.clone()); + } + } + parent = d.reference(b"Parent"); + } + add +} + +/// Copy onto `field` what it inherits (`add`, from [`inherited`]), so it keeps it when it moves. +fn own_inherited(doc: &mut Document, field: ObjRef, add: &Dict) -> Result<(), FormError> { + if add.iter().next().is_some() { + doc.update_dict(field, |d| { + for (k, v) in add.iter() { + d.set(k.to_vec(), v.clone()); + } + })?; + } + Ok(()) +} + +/// Take `field` out of the field tree: from its parent's `/Kids`, or from `/Fields`. +fn detach(doc: &mut Document, field: ObjRef) -> Result<(), FormError> { + let parent = doc.get(field).as_dict().and_then(|d| d.reference(b"Parent")); + let remove_from = |doc: &mut Document, holder: ObjRef, key: &[u8]| -> Result<(), FormError> { + let mut d = doc.get(holder).as_dict().cloned().unwrap_or_default(); + let list = d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); + let kept: Vec = list.into_iter().filter(|o| o.as_ref() != Some(field)).collect(); + d.set(key.to_vec(), Object::Array(kept)); + doc.set(holder, Object::Dict(d)); + Ok(()) + }; + match parent { + Some(p) => remove_from(doc, p, b"Kids")?, + None => { + let root = doc.root().ok_or(FormError::NoForm)?; + match doc.get(root).as_dict().and_then(|d| d.get(b"AcroForm").cloned()) { + Some(Object::Ref(af)) => remove_from(doc, af, b"Fields")?, + // A form dictionary held directly in the catalog. + Some(Object::Dict(mut af)) => { + let list = af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); + let kept: Vec = list.into_iter().filter(|o| o.as_ref() != Some(field)).collect(); + af.set(b"Fields".to_vec(), Object::Array(kept)); + doc.update_dict(root, |d| d.set(b"AcroForm".to_vec(), Object::Dict(af)))?; + } + _ => {} + } + } + } + Ok(()) +} + +/// The first free "N" name: not a field's, and not held by a node at the top of the field +/// tree (`held`), such as a parent field left empty by a move, which isn't in `existing`. +fn free_name(existing: &[Field], held: &[String], base: &str) -> String { (1..=u64::MAX) .map(|n| format!("{base}{n}")) - .find(|c| !existing.iter().any(|f| f.name == *c || f.name.starts_with(&format!("{c}.")))) + .find(|c| !held.contains(c) && !existing.iter().any(|f| f.name == *c || f.name.starts_with(&format!("{c}.")))) .unwrap_or_else(|| base.to_string()) } +/// The own names (`/T`) of the nodes in the form's `/Fields`, changing nothing. +fn top_names(doc: &Document) -> Vec { + let Some(root) = doc.root() else { return Vec::new() }; + let Some(af) = doc.get(root).as_dict().and_then(|d| d.get(b"AcroForm").map(|o| doc.resolve(o))).and_then(|o| o.as_dict().cloned()) else { + return Vec::new(); + }; + let list = af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); + list.iter().filter_map(|o| doc.resolve(o).as_dict().and_then(|d| d.get(b"T").cloned())).filter_map(|t| crate::text_of(&doc.resolve(&t))).collect() +} + fn widget_dict(page: ObjRef, rect: [f64; 4]) -> Dict { let mut d = Dict::new(); d.set(b"Type".to_vec(), Object::name("Annot")); @@ -527,20 +828,18 @@ pub fn add_field(doc: &mut Document, page: usize, rect: [f64; 4], kind: &NewFiel _ => name, }; let name = match name.map(str::trim) { - Some(n) if !n.is_empty() => { - if n.contains('.') { - return invalid("field names can't contain a period"); - } - n.to_string() - } - _ => free_name(&existing, kind.base_name()), + Some(n) if !n.is_empty() => n.to_string(), + _ => free_name(&existing, &top_names(doc), kind.base_name()), }; + let parts = name_parts(&name)?; let radio_group = match kind { NewField::Radio { group, .. } => existing.iter().find(|f| f.kind == FieldKind::Radio && Some(&f.name) == group.as_ref()).cloned(), _ => None, }; - if radio_group.is_none() && existing.iter().any(|f| f.name == name) { - return invalid(format!("a field named {name:?} already exists")); + if radio_group.is_none() + && let Some(why) = existing.iter().find_map(|f| clash(&name, &f.name)) + { + return invalid(why); } let af = ensure_form(doc)?; let mut w = widget_dict(page_ref, rect); @@ -637,12 +936,11 @@ pub fn add_field(doc: &mut Document, page: usize, rect: [f64; 4], kind: &NewFiel let mut g = Dict::new(); g.set(b"FT".to_vec(), Object::name("Btn")); g.set(b"Ff".to_vec(), Object::Int((flags::RADIO | flags::NO_TOGGLE_TO_OFF) as i64)); - g.set(b"T".to_vec(), PdfString::text(&field_name)); g.set(b"V".to_vec(), Object::name("Off")); g.set(b"DA".to_vec(), PdfString::literal(b"/ZaDb 0 Tf 0 g".to_vec())); g.set(b"Kids".to_vec(), Object::Array(Vec::new())); let g = doc.add(Object::Dict(g)); - add_to_fields(doc, af, g)?; + attach(doc, af, g, &parts)?; g } }; @@ -666,13 +964,10 @@ pub fn add_field(doc: &mut Document, page: usize, rect: [f64; 4], kind: &NewFiel caption: None, list: None, }; - let ap = appearance::check_box_states(doc, &w, FieldKind::Radio, export); + let ap = appearance::check_box_states(doc, &w, FieldKind::Radio, export, "/ZaDb 0 Tf 0 g"); doc.update_dict(widget, |d| d.set(b"AP".to_vec(), Object::Dict(ap)))?; } - _ => { - doc.update_dict(widget, |d| d.set(b"T".to_vec(), PdfString::text(&name)))?; - add_to_fields(doc, af, widget)?; - } + _ => attach(doc, af, widget, &parts)?, } add_to_page(doc, page_ref, widget)?; redraw_field(doc, &field_name)?; @@ -709,7 +1004,7 @@ fn redraw_widgets(doc: &mut Document, name: &str, only: Option) -> Result } FieldKind::CheckBox | FieldKind::Radio => { let on = w.on_state.clone().unwrap_or_else(|| "Yes".into()); - appearance::check_box_states(doc, w, f.kind, &on) + appearance::check_box_states(doc, w, f.kind, &on, &f.da) } FieldKind::PushButton => { let (s, m) = button_appearance(doc, w, &f.da); @@ -775,7 +1070,7 @@ fn form_stream(place: crate::appearance::Placement, content: Vec, resources: /// color its `/DA` asks for (the widget's, else `field_da`, the field's own or inherited one). /// Also returns the first caption character the font can't draw (drawn as `?`), for the caller to /// mark and report. -fn button_appearance(doc: &Document, w: &Widget, field_da: &str) -> (Stream, Option) { +pub(crate) fn button_appearance(doc: &Document, w: &Widget, field_da: &str) -> (Stream, Option) { let (mut c, place) = frame_only(doc, w); let (width, height) = (place.layout_w, place.layout_h); let wobj = doc.get(w.obj); @@ -919,18 +1214,52 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result own, + _ => n, + }; + if n.contains('.') { + // A full name: the field moves under the parent fields it names (#2696). + let parts = name_parts(n)?; + if let Some(why) = all.iter().filter(|x| x.obj != f.obj).find_map(|x| clash(n, &x.name)) { + return invalid(why); + } + let af = ensure_form(doc)?; + // Checked before anything changes, so a refused move leaves the field as it was. + let add = inherited(doc, f.obj); + check_place(doc, af, f.obj, &parts, &add)?; + // From here a failure (a write that `check_place` couldn't foresee) would leave the + // field half moved in `doc`. The engine runs every edit on a copy of the document and + // keeps it only when the edit succeeds (`Session::apply`), so the open document never + // sees that. + own_inherited(doc, f.obj, &add)?; + detach(doc, f.obj)?; + attach(doc, af, f.obj, &parts)?; + new_name = n.to_string(); + } else { + if n.is_empty() { + return invalid("field names can't be empty"); + } + // Rename the terminal part (the field's own /T). The parents' part stays, so the name + // that must be free is the full one: `MASTER.a` renamed to `b` takes `MASTER.b` (ISO + // 32000-2 §12.7.4.2). The field itself is no clash when it keeps its own name. + let prefix = f.name.rsplit_once('.').map(|(p, _)| format!("{p}.")).unwrap_or_default(); + new_name = format!("{prefix}{n}"); + if let Some(why) = all.iter().filter(|x| x.obj != f.obj).find_map(|x| clash(&new_name, &x.name)) { + return invalid(why); + } + // A node of that name beside it that isn't a terminal field, such as a parent + // field left empty by a move. + let af = ensure_form(doc)?; + let parent = doc.get(f.obj).as_dict().and_then(|d| d.reference(b"Parent")); + if child_named(doc, af, parent, n).is_some_and(|r| r != f.obj) { + return invalid(format!("a field named {new_name:?} already exists")); + } + doc.update_dict(f.obj, |d| d.set(b"T".to_vec(), PdfString::text(n)))?; } - // Rename the terminal part (the field's own /T). The parents' part stays, so the name that - // must be free is the full one: `MASTER.a` renamed to `b` takes `MASTER.b` (ISO 32000-2 - // §12.7.4.2). The field itself is no clash when it keeps its own name. - let prefix = f.name.rsplit_once('.').map(|(p, _)| format!("{p}.")).unwrap_or_default(); - new_name = format!("{prefix}{n}"); - if all.iter().any(|x| x.obj != f.obj && x.name == new_name) { - return invalid(format!("a field named {new_name:?} already exists")); - } - doc.update_dict(f.obj, |d| d.set(b"T".to_vec(), PdfString::text(n)))?; // Calculations read the field by its new name, unless another field keeps the old one. if all.iter().filter(|x| x.name == f.name).count() == 1 { rename_in_calculations(doc, &all, &f.name, &new_name)?; @@ -1262,33 +1591,7 @@ fn delete_found_field(doc: &mut Document, f: Field) -> Result<(), FormError> { } } } - // Out of the field tree: from its parent's /Kids, or from /Fields. - let parent = doc.get(f.obj).as_dict().and_then(|d| d.reference(b"Parent")); - let remove_from = |doc: &mut Document, holder: ObjRef, key: &[u8]| -> Result<(), FormError> { - let mut d = doc.get(holder).as_dict().cloned().unwrap_or_default(); - let list = d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); - let kept: Vec = list.into_iter().filter(|o| o.as_ref() != Some(f.obj)).collect(); - d.set(key.to_vec(), Object::Array(kept)); - doc.set(holder, Object::Dict(d)); - Ok(()) - }; - match parent { - Some(p) => remove_from(doc, p, b"Kids")?, - None => { - let root = doc.root().ok_or(FormError::NoForm)?; - match doc.get(root).as_dict().and_then(|d| d.get(b"AcroForm").cloned()) { - Some(Object::Ref(af)) => remove_from(doc, af, b"Fields")?, - // A form dictionary held directly in the catalog. - Some(Object::Dict(mut af)) => { - let list = af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); - let kept: Vec = list.into_iter().filter(|o| o.as_ref() != Some(f.obj)).collect(); - af.set(b"Fields".to_vec(), Object::Array(kept)); - doc.update_dict(root, |d| d.set(b"AcroForm".to_vec(), Object::Dict(af)))?; - } - _ => {} - } - } - } + detach(doc, f.obj)?; // Out of the calculation order too: a `/CO` entry would keep the field, its value and its // widgets reachable for a full save. drop_from_calculation_order(doc, |r| r == f.obj || widgets.contains(&r)) diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index d120c3cd3..777caa599 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -24,7 +24,7 @@ mod calc_order; pub mod detect; mod scripting; pub mod table; -pub use actions::{FieldAction, Trigger, field_actions, set_field_actions}; +pub use actions::{FieldAction, Trigger, field_action_chains, field_actions, set_field_actions}; pub use author::{ BorderStyle, CheckStyle, FieldFont, FieldProps, Look, LookPatch, MIN_FIELD_SIDE, NewField, add_field, check_style, delete_field, delete_field_object, duplicate_field, look, redraw_field, set_button_icon, set_props, @@ -932,6 +932,7 @@ pub fn set_value_with(doc: &mut Document, name: &str, value: &FieldValue, script return Err(FormError::ReadOnly(name.into())); } write_value(doc, f, value, scripts)?; + author::form_fonts_as_objects(doc, &all)?; // Fields with the same fully qualified name are one field (§12.7.3.1). Writers that repeat a // name as separate field dictionaries, instead of widgets under one parent, get the value // the first one took after its scripts, without running them again. @@ -1191,7 +1192,7 @@ fn set_states(doc: &mut Document, f: &Field, on: Option<&str>) -> Result<(), For let has_ap = doc.get(w.obj).as_dict().and_then(|d| d.get(b"AP").cloned()).is_some(); if !has_ap { let on_name = w.on_state.clone().unwrap_or_else(|| on.unwrap_or("Yes").to_string()); - let ap = appearance::check_box_states(doc, w, f.kind, &on_name); + let ap = appearance::check_box_states(doc, w, f.kind, &on_name, &f.da); doc.update_dict(w.obj, |d| d.set(b"AP".to_vec(), Object::Dict(ap)))?; } doc.update_dict(w.obj, |d| d.set(b"AS".to_vec(), name_obj(state)))?; @@ -1293,16 +1294,140 @@ pub(crate) fn ask_viewers_to_redraw(doc: &mut Document) -> Result<(), FormError> Ok(()) } +/// Text fields with the Password flag (inherited or their own) whose value (`/V`, inherited or +/// 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 { + if !has_password_flag(doc) { + return Vec::new(); + } + 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())); + 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 field dictionary sets the Password flag (#3943 review). A field that inherits the +/// flag has an ancestor that sets it, so that ancestor is found instead; a kid clearing the flag +/// again only makes the answer cautious. It reads only `/Ff`, `/Kids` and `/Parent`, so a save +/// or recovery snapshot of a form without such a field doesn't build every [`Field`]. Like +/// [`fields`], it looks at the form's field tree and at the widgets on the pages, which may be +/// missing from `/Fields`. Each dictionary is visited once and at most 32 levels deep. +fn has_password_flag(doc: &Document) -> bool { + let sets_it = |r: ObjRef| doc.get(r).as_dict().and_then(|d| field_flags(doc, d)).is_some_and(|ff| ff & flags::PASSWORD != 0); + let top = acroform(doc).and_then(|af| af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned())).unwrap_or_default(); + let mut stack: Vec<(Object, usize)> = top.into_iter().map(|o| (o, 0)).collect(); + let mut seen = std::collections::HashSet::new(); + while let Some((o, depth)) = stack.pop() { + let Object::Ref(r) = o else { continue }; + if depth > 32 || !seen.insert(r) { + continue; + } + if sets_it(r) { + return true; + } + let kids = doc.get(r).as_dict().and_then(|d| d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned())).unwrap_or_default(); + stack.extend(kids.into_iter().map(|k| (k, depth + 1))); + } + // Widgets on the pages, and the fields above them. + page_refs(doc).into_iter().any(|p| { + let annots = + doc.get(p).as_dict().and_then(|d| d.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned())).unwrap_or_default(); + annots.iter().filter_map(Object::as_ref).filter(|r| !seen.contains(r)).any(|r| field_chain(doc, r).into_iter().any(sets_it)) + }) +} + +/// Whether any text field with the Password flag holds a value in `doc`. +pub fn has_password_values(doc: &Document) -> bool { + !password_fields(doc).is_empty() +} + +/// `r` and its field ancestors (`/Parent`), nearest first, at most 32 deep and without repeats. +fn field_chain(doc: &Document, r: ObjRef) -> Vec { + let mut chain = vec![r]; + let mut cur = r; + while chain.len() < 32 { + let Some(Object::Ref(parent)) = doc.get(cur).as_dict().and_then(|d| d.get(b"Parent").cloned()) else { break }; + if chain.contains(&parent) { + break; + } + chain.push(parent); + cur = parent; + } + chain +} + +/// 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". 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 +/// is such a password field. Where an ancestor's value is shared with other fields and stays, the +/// password field gets an empty value of its own so it no longer inherits it. Returns how many +/// fields were cleared. +pub fn clear_password_values(doc: &mut Document) -> Result { + let targets = password_fields(doc); + if targets.is_empty() { + return Ok(0); + } + let is_password = |f: &Field| f.kind == FieldKind::Text && f.has(flags::PASSWORD); + // Each field's ancestors, found once: only values change below, never `/Parent` (#3943 + // review: walking them again for every ancestor of every target was quadratic). + let chains: Vec<(bool, Vec)> = fields(doc).iter().map(|g| (is_password(g), field_chain(doc, g.obj))).collect(); + for f in &targets { + let chain = field_chain(doc, f.obj); + for w in &f.widgets { + if w.obj != f.obj { + doc.update_dict(w.obj, |d| { + d.remove(b"V"); + })?; + } + } + doc.update_dict(f.obj, |d| { + d.remove(b"V"); + })?; + let mut shared_value_kept = false; + for a in chain.iter().skip(1) { + if doc.get(*a).as_dict().is_none_or(|d| d.get(b"V").is_none()) { + continue; + } + let exclusive = chains.iter().filter(|(_, chain)| chain.contains(a)).all(|(password, _)| *password); + if exclusive { + doc.update_dict(*a, |d| { + d.remove(b"V"); + })?; + } else { + shared_value_kept = true; + } + } + let restored: Vec = 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()) +} + /// Whether the form asks viewers to draw its fields' appearances themselves (`/NeedAppearances`). pub fn need_appearances(doc: &Document) -> bool { acroform(doc).is_some_and(|af| matches!(af.get(b"NeedAppearances").map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true)))) } -/// A copy of `doc` in which text and choice fields are drawn from their values, for displaying -/// the document (`None` when no widget needs it): every such widget when the form sets -/// `/NeedAppearances` (ISO 32000-2 Table 224: the writer left the appearances to the viewer), -/// and otherwise each widget without a normal appearance (`/AP /N`). Drawn as typing the value -/// would draw it (#3470). `doc` is not changed: saving writes the file as it was. +/// A copy of `doc` in which form fields are drawn the way the file leaves to the viewer, for +/// displaying the document (`None` when no widget needs it). Text and choice fields are drawn from +/// their values: every one when the form sets `/NeedAppearances` (ISO 32000-2 Table 224: the +/// writer left the appearances to the viewer), and otherwise each widget without a normal +/// appearance (`/AP /N`), as typing the value would draw it (#3470). Check boxes and radio buttons +/// with no normal appearance for their state (`/AS`) are drawn from their `/MK` background, border +/// and caption, and push buttons with none from their `/MK` and `/BS` (Table 192). `doc` is not +/// changed: saving writes the file as it was. pub fn with_field_appearances(doc: &Document) -> Option { with_field_appearances_cached(doc, &mut FieldAppearanceCache::default()) } @@ -1312,18 +1437,45 @@ pub fn with_field_appearances(doc: &Document) -> Option { pub fn with_field_appearances_cached(doc: &Document, cache: &mut FieldAppearanceCache) -> Option { let all = need_appearances(doc); let mut copy: Option = None; - for f in fields(doc).iter().filter(|f| matches!(f.kind, FieldKind::Text | FieldKind::Combo | FieldKind::List)) { + for f in fields(doc) { for w in &f.widgets { let widget = doc.get(w.obj); - let drawn = widget.as_dict().and_then(|d| d.get(b"AP")).is_some_and(|ap| doc.resolve(ap).as_dict().is_some_and(|ap| ap.contains(b"N"))); - if drawn && !all { + let normal = widget + .as_dict() + .and_then(|d| d.get(b"AP")) + .and_then(|ap| doc.resolve(ap).as_dict().and_then(|ap| ap.get(b"N").map(|n| doc.resolve(n)))); + // A check box or radio button shows the appearance of its state (12.7.5.2.3): `/AS`, + // else a check box's value, else Off. An Off state may go without one (it shows + // nothing); an on state needs one. + let state = match f.kind { + FieldKind::CheckBox | FieldKind::Radio => { + let value = f.value.first().filter(|v| f.kind == FieldKind::CheckBox && !v.is_empty() && v.as_str() != "Off"); + Some(w.state.clone().or_else(|| value.cloned()).unwrap_or_else(|| "Off".into())) + } + _ => None, + }; + let missing = match f.kind { + FieldKind::Text | FieldKind::Combo | FieldKind::List => normal.is_none() || all, + FieldKind::PushButton => normal.is_none(), + FieldKind::CheckBox | FieldKind::Radio => match (normal.as_deref(), state.as_deref()) { + (None, _) => true, + (Some(Object::Dict(states)), Some(s)) => s != "Off" && !states.contains(&name_bytes(s)), + _ => false, + }, + FieldKind::Signature => false, + }; + if !missing { continue; } let inputs = appearance_inputs(doc, w); let stream = match cache.get(w.obj, &inputs) { Some(stream) => stream, None => { - let (stream, _) = appearance::field_appearance_drawn(doc, f, w, &f.value, true); + let stream = match f.kind { + FieldKind::CheckBox | FieldKind::Radio => appearance::button_state_shown(doc, &f, w, state.as_deref() != Some("Off")), + FieldKind::PushButton => appearance::push_button_shown(doc, &f, w), + _ => appearance::field_appearance_drawn(doc, &f, w, &f.value, true).0, + }; cache.insert(w.obj, inputs, &stream); stream } @@ -1331,10 +1483,25 @@ pub fn with_field_appearances_cached(doc: &Document, cache: &mut FieldAppearance let target = copy.get_or_insert_with(|| doc.clone()); let ap = target.add(Object::Stream(stream)); let mut fresh = Dict::new(); - fresh.set(b"N".to_vec(), Object::Ref(ap)); + match &state { + // The drawn look under its state's name, and that state as the widget's own. + Some(s) => { + let mut states = Dict::new(); + states.set(name_bytes(s), Object::Ref(ap)); + fresh.set(b"N".to_vec(), Object::Dict(states)); + } + None => fresh.set(b"N".to_vec(), Object::Ref(ap)), + } let apd = merged_appearance(target, w.obj, &fresh); // Fails only when the widget isn't a dictionary; that widget then keeps its own look. - target.update_dict(w.obj, |d| d.set(b"AP".to_vec(), Object::Dict(apd))).ok(); + target + .update_dict(w.obj, |d| { + d.set(b"AP".to_vec(), Object::Dict(apd)); + if let Some(s) = &state { + d.set(b"AS".to_vec(), Object::Name(name_bytes(s))); + } + }) + .ok(); } } copy diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index ea683bb67..0c2daeb6d 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -387,6 +387,119 @@ fn opt_export_values_select_button_states() { assert!(err.to_string().contains("options: Post, Pick-up"), "{err}"); } +/// What fits a text field is measured as its appearance draws it: in the field's own font (its +/// `/DR` font, Helvetica where that can't show the text), inside the border and padding, at its +/// size, wrapped for multi-line, at the smallest automatic size, as asterisks for a password, +/// and without a limit for a comb field. +#[test] +fn a_text_field_says_what_fits_it_as_its_appearance_draws_it() { + // Each widget is 100 x 20 pt (multi-line: 100 x 40 pt) with no border: 96 pt inside the 2 pt + // padding (36 pt high for the multi-line one). + let tx = |name: &str, da: &str, ff: u32, extra: &str, top: i32, h: i32| { + format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /DA ({da}) /Ff {ff}{extra} /Rect [50 {} 150 {top}] /P 3 0 R >>", top - h) + }; + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R] >>".into(), + "<< /Fields [7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R /Cour 6 0 R >> >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Courier /Encoding /WinAnsiEncoding >>".into(), + tx("one", "/Helv 12 Tf 0 g", 0, "", 780, 20), + tx("auto", "/Helv 0 Tf 0 g", 0, "", 750, 20), + tx("many", "/Helv 12 Tf 0 g", flags::MULTILINE, "", 720, 40), + tx("cour", "/Cour 12 Tf 0 g", 0, "", 670, 20), + tx("pin", "/Helv 12 Tf 0 g", flags::PASSWORD, "", 640, 20), + tx("comb", "/Helv 12 Tf 0 g", flags::COMB, " /MaxLen 5", 610, 20), + ]; + let doc = document(&objs); + let all = fields(&doc); + let tb = |name: &str| { + let f = field(&all, name); + appearance::text_box(&doc, f, &f.widgets[0]) + }; + let helv = |s: &str, size: f64| pdfcraft_fonts::helvetica_width(s, size); + // Single line at 12 pt: as wide as the 96 pt inside, and no wider. + let fits_12: String = std::iter::repeat_n('W', (96.0 / helv("W", 12.0)) as usize).collect(); + assert!(tb("one").fits(&fits_12) && !tb("one").fits(&format!("{fits_12}W")), "{}", fits_12.len()); + // An automatic size shrinks first: full only when 4 pt no longer fits. + let fits_4: String = std::iter::repeat_n('W', (96.0 / helv("W", 4.0)) as usize).collect(); + assert!(!tb("one").fits(&fits_4) && tb("auto").fits(&fits_4) && !tb("auto").fits(&format!("{fits_4}W"))); + // Multi-line: lines of 12 x 1.15 pt each in 36 pt, so two lines fit and three don't. + assert!(tb("many").fits("one\ntwo") && !tb("many").fits("one\ntwo\nthree")); + let two_lines_of_words = "word ".repeat(((96.0 / helv("word ", 12.0)) as usize) * 2); + assert!(tb("many").fits(two_lines_of_words.trim_end()) && !tb("many").fits(&format!("{two_lines_of_words}word"))); + // The field's own font: narrow i's that Helvetica fits are too wide in Courier (600/1000 em). + let i_s: String = std::iter::repeat_n('i', 14).collect(); + assert!(tb("one").fits(&i_s) && !tb("cour").fits(&i_s), "Courier is 0.6 em a character: {}", 14.0 * 0.6 * 12.0); + // A password is drawn, so measured, as asterisks. + let thirty_i: String = std::iter::repeat_n('i', 30).collect(); + assert!(tb("one").fits(&thirty_i) && !tb("pin").fits(&thirty_i), "{} pt of asterisks", helv(&"*".repeat(30), 12.0)); + // A comb field's cells bound it, not its width. + assert!(tb("comb").fits(&"W".repeat(50))); +} + +/// An edit to a field that doesn't scroll keeps as much of what it inserted as fits, wherever the +/// caret was, and always keeps a deletion. +#[test] +fn an_edit_to_a_full_field_keeps_what_fits() { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>".into(), + "<< /Fields [5 0 R] /DA (/Helv 12 Tf 0 g) >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (one) /DA (/Helv 12 Tf 0 g) /Rect [50 760 150 780] /P 3 0 R >>".into(), + ]; + let doc = document(&objs); + let all = fields(&doc); + let f = field(&all, "one"); + let tb = appearance::text_box(&doc, f, &f.widgets[0]); + // Typing in the middle of "Short": the X's go in until the field is full, the end stays. + let typed = format!("Sh{}ort", "X".repeat(40)); + let (kept, caret) = tb.fitted_edit("Short", &typed); + let xs = kept.chars().filter(|c| *c == 'X').count(); + assert!(kept.starts_with("ShX") && kept.ends_with("Xort") && xs < 40, "{kept}"); + assert!(tb.fits(&kept) && !tb.fits(&format!("Sh{}ort", "X".repeat(xs + 1))), "as many as fit: {xs}"); + assert_eq!(caret, 2 + xs, "the caret after the last X kept"); + // What fits is kept whole. + assert_eq!(tb.fitted_edit("Short", "Shorter"), ("Shorter".to_string(), 7)); + // A deletion is kept even from text that already overflows (set by a script, say). + let long = "W".repeat(20); + assert!(!tb.fits(&long)); + assert_eq!(tb.fitted_edit(&long, &"W".repeat(19)), ("W".repeat(19), 19)); + // Replacing a selection with too much keeps the removal and what fits of the replacement. + let (kept, _) = tb.fitted_edit("Short", &format!("S{}t", "W".repeat(30))); + assert!(kept.starts_with("SW") && kept.ends_with('t') && tb.fits(&kept), "{kept}"); +} + +/// When the field's own font can't show a character of a paste, the text from there on is drawn +/// in Helvetica, which can be narrower: the longest part that fits may come after that point, and +/// is still the one kept. +#[test] +fn an_edit_keeps_the_longest_part_that_fits_across_a_change_of_font() { + // A subset font holding only A, 1 em wide: 12 pt an A at 12 pt, against Helvetica's 8 pt. + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R] >>".into(), + "<< /Fields [6 0 R] /DA (/Wide 12 Tf 0 g) /DR << /Font << /Wide 5 0 R >> >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /ABCDEF+Wide /FirstChar 65 /LastChar 65 /Widths [1000] /Encoding /WinAnsiEncoding >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (one) /DA (/Wide 12 Tf 0 g) /Rect [50 760 150 780] /P 3 0 R >>".into(), + ]; + let doc = document(&objs); + let all = fields(&doc); + let f = field(&all, "one"); + let tb = appearance::text_box(&doc, f, &f.widgets[0]); + // 96 pt inside: 8 A's in the subset font, or 11 characters (8.004 pt each) once a B brings + // Helvetica in. + assert!(tb.fits(&"A".repeat(8)) && !tb.fits(&"A".repeat(9))); + let pasted = format!("{}B{}", "A".repeat(10), "A".repeat(30)); + let (kept, caret) = tb.fitted_edit("", &pasted); + assert_eq!(kept, format!("{}B", "A".repeat(10)), "the 11 characters that fit in Helvetica, not the 8 before the B"); + assert!(!tb.fits(&format!("{}BA", "A".repeat(10))), "and no more"); + assert_eq!(caret, 11); +} + /// Issue #1323: a text field without a format keeps a whitespace-only value as typed; only an /// empty value clears it. #[test] @@ -1345,6 +1458,32 @@ fn renaming_a_field_renames_it_in_the_calculations_that_read_it() { assert_eq!((field(&all, "sum").value.clone(), field(&all, "product").value.clone()), (vec!["12".to_string()], vec!["20".to_string()])); } +/// A dotted name moves the field under new parent fields (#2696), and the calculations that read +/// it follow it to its full new name, as they follow a plain rename. +#[test] +fn a_dotted_rename_moves_the_field_and_its_calculations_follow() { + use crate::af::{CalcOp, Calculate}; + let mut doc = one_page(); + let text = NewField::Text { multiline: false }; + for (i, name) in ["a", "b", "sum", "script"].into_iter().enumerate() { + let y = 700.0 - 40.0 * i as f64; + add_field(&mut doc, 0, [50.0, y, 250.0, y + 20.0], &text, Some(name)).unwrap(); + } + let calculate = + FieldProps { calculate: Some(Calculate::Simple { op: CalcOp::Sum, fields: vec!["a".into(), "b".into()] }), ..FieldProps::default() }; + set_props(&mut doc, "sum", &calculate).unwrap(); + set_field_script(&mut doc, "script", "calculate", Some("event.value = getField(\"a\").value;")).unwrap(); + set_props(&mut doc, "a", &FieldProps { name: Some("Group.a".into()), ..FieldProps::default() }).unwrap(); + let mut doc = reopen(&doc); + let all = fields(&doc); + assert!(all.iter().any(|f| f.name == "Group.a") && all.iter().all(|f| f.name != "a")); + assert_eq!(field(&all, "sum").actions.calculate, Calculate::Simple { op: CalcOp::Sum, fields: vec!["Group.a".into(), "b".into()] }); + assert_eq!(field(&all, "script").actions.scripts.calculate.as_deref(), Some("event.value = getField(\"Group.a\").value;")); + set_value(&mut doc, "Group.a", &FieldValue::Text("10".into())).unwrap(); + set_value(&mut doc, "b", &FieldValue::Text("2".into())).unwrap(); + assert_eq!(field(&fields(&doc), "sum").value, ["12"]); +} + /// The rename reaches a script stored as a stream, an indirect action and the only widget's /// actions; it leaves a script that isn't UTF-8 as it is, and a name another field still has. #[test] @@ -1726,6 +1865,49 @@ fn check_box_styles_change_the_mark() { ); } +#[test] +fn check_mark_takes_the_fields_text_colour() { + // #4261: Appearance ▸ Text Color is written to /DA; the mark of a check box or radio button + // is drawn in it (Acrobat does the same), black when the /DA has no colour. + let mut doc = one_page(); + let check = add_field(&mut doc, 0, [50.0, 700.0, 70.0, 720.0], &NewField::CheckBox, None).unwrap(); + let radio = add_field(&mut doc, 0, [50.0, 600.0, 70.0, 620.0], &NewField::Radio { group: None, export: "A".into() }, None).unwrap(); + let on_ap = |doc: &Document, name: &str| { + let all = fields(doc); + let w = &field(&all, name).widgets[0]; + let on = w.on_state.clone().unwrap(); + let n = doc + .get(w.obj) + .as_dict() + .unwrap() + .get(b"AP") + .unwrap() + .as_dict() + .unwrap() + .get(b"N") + .unwrap() + .as_dict() + .unwrap() + .reference(on.as_bytes()) + .unwrap(); + let Object::Stream(s) = &*doc.get(n) else { panic!() }; + String::from_utf8_lossy(&s.decoded().unwrap()).into_owned() + }; + assert!(on_ap(&doc, &check).contains("0 G"), "black by default"); + let blue = FieldProps { appearance: Some(LookPatch { text: Some([0.0, 0.0, 1.0]), ..Default::default() }), ..Default::default() }; + for name in [&check, &radio] { + set_props(&mut doc, name, &blue).unwrap(); + let ap = on_ap(&reopen(&doc), name); + // The check strokes, the radio's default circle fills. + let op = if name == &check { "0 0 1 RG" } else { "0 0 1 rg" }; + assert!(ap.contains(op), "{name}: the mark is blue: {ap}"); + assert!(!ap.contains("0 G\n") && !ap.contains("0 g\n"), "{name}: no black left: {ap}"); + } + // A style change redraws too, and keeps the colour. + set_props(&mut doc, &check, &FieldProps { check_style: Some(CheckStyle::Square), ..FieldProps::default() }).unwrap(); + assert!(on_ap(&doc, &check).contains("0 0 1 rg"), "a square is blue as well"); +} + #[test] fn ordering_tabs_manually() { let mut doc = one_page(); @@ -1907,6 +2089,80 @@ fn field_actions_round_trip_on_every_trigger() { assert_eq!(fields(&doc).iter().find(|f| f.name == "go").unwrap().button, Some(af::ButtonAction::Named("Print".into()))); } +#[test] +fn editing_one_script_keeps_the_other_actions_and_the_chain() { + let mut doc = fixture(); + let go = field(&fields(&doc), "go").obj; + let action = |doc: &mut Document, entries: Vec<(&[u8], Object)>| { + let mut a = Dict::new(); + for (k, v) in entries { + a.set(k.to_vec(), v); + } + doc.add(Object::Dict(a)) + }; + let next = action(&mut doc, vec![(b"S", Object::name("URI")), (b"URI", Object::String(PdfString::literal(b"https://example.org".to_vec())))]); + let up = action( + &mut doc, + vec![(b"S", Object::name("JavaScript")), (b"JS", Object::String(PdfString::text("app.alert(1);"))), (b"Next", Object::Ref(next))], + ); + let blur = action( + &mut doc, + vec![(b"S", Object::name("JavaScript")), (b"JS", Object::String(PdfString::text("this.dirty = true;"))), (b"Foo", Object::Int(7))], + ); + let mut aa = Dict::new(); + aa.set(b"Bl".to_vec(), Object::Ref(blur)); + doc.update_dict(go, |d| { + d.set(b"A".to_vec(), Object::Ref(up)); + d.set(b"AA".to_vec(), Object::Dict(aa)); + }) + .unwrap(); + let mut acts = field_actions(&doc, "go").unwrap(); + assert_eq!(acts[0], (Trigger::MouseUp, FieldAction::JavaScript("app.alert(1);".into()))); + acts[0].1 = FieldAction::JavaScript("app.alert(2);".into()); + set_field_actions(&mut doc, "go", &acts).unwrap(); + let w = doc.get(go).as_dict().cloned().unwrap(); + // The action that did not change is the same object, with its keys this crate doesn't read. + let aa = w.get(b"AA").unwrap().as_dict().unwrap().clone(); + assert_eq!(aa.get(b"Bl"), Some(&Object::Ref(blur))); + // The edited script keeps the actions chained after it. + let a = doc.resolve(w.get(b"A").unwrap()).as_dict().cloned().unwrap(); + assert_eq!(a.get(b"Next"), Some(&Object::Ref(next))); + assert_eq!(field_actions(&doc, "go").unwrap()[0], (Trigger::MouseUp, FieldAction::JavaScript("app.alert(2);".into()))); +} + +#[test] +fn replacing_a_chained_action_with_another_type_keeps_the_chain() { + let mut doc = fixture(); + let go = field(&fields(&doc), "go").obj; + let s = |t: &str| Object::String(PdfString::text(t)); + let mut named = Dict::new(); + named.set(b"S".to_vec(), Object::name("Named")); + named.set(b"N".to_vec(), Object::name("NextPage")); + let named = doc.add(Object::Dict(named)); + // A chain as an array: a link, then a menu item that points back at the link (a loop). + let mut uri = Dict::new(); + uri.set(b"S".to_vec(), Object::name("URI")); + uri.set(b"URI".to_vec(), s("https://example.org")); + let uri = doc.add(Object::Dict(uri)); + doc.update_dict(named, |d| d.set(b"Next".to_vec(), Object::Ref(uri))).unwrap(); + let chain = Object::Array(vec![Object::Ref(uri), Object::Ref(named)]); + let mut up = Dict::new(); + up.set(b"S".to_vec(), Object::name("JavaScript")); + up.set(b"JS".to_vec(), s("app.alert(1);")); + up.set(b"Next".to_vec(), chain.clone()); + doc.update_dict(go, |d| d.set(b"A".to_vec(), Object::Dict(up))).unwrap(); + assert_eq!(field_action_chains(&doc, "go").unwrap(), [(Trigger::MouseUp, 2)]); + set_field_actions(&mut doc, "go", &[(Trigger::MouseUp, FieldAction::Uri("https://example.com".into()))]).unwrap(); + let w = doc.get(go).as_dict().cloned().unwrap(); + let a = doc.resolve(w.get(b"A").unwrap()).as_dict().cloned().unwrap(); + assert_eq!(a.get(b"Next"), Some(&chain)); + assert_eq!(field_actions(&doc, "go").unwrap(), [(Trigger::MouseUp, FieldAction::Uri("https://example.com".into()))]); + assert_eq!(field_action_chains(&doc, "go").unwrap(), [(Trigger::MouseUp, 2)]); + // Deleting the trigger's action deletes its chain with it. + set_field_actions(&mut doc, "go", &[]).unwrap(); + assert_eq!(field_action_chains(&doc, "go").unwrap(), []); +} + #[test] fn push_buttons_read_hide_actions() { let mut doc = fixture(); @@ -3097,3 +3353,840 @@ fn a_skipped_field_leaves_its_widget_to_the_next() { let widgets: Vec<(&str, usize)> = all.iter().map(|f| (f.name.as_str(), f.widgets.len())).collect(); assert_eq!(widgets, [("b", 1)]); } + +/// #2696: a dotted name is a full name. `MASTER.first` is a field `first` under a parent field +/// `MASTER` (a partial name can't contain a period, ISO 32000-2 §12.7.4.2); a second `MASTER.` +/// name joins that parent; renaming to a dotted name moves the field, keeping what it inherited; +/// and a name that would make one field both hold a value and hold fields is refused. +#[test] +fn dotted_field_names_are_made_with_parent_fields() { + let mut doc = fixture(); + let top = |doc: &Document| { + let root = doc.root().unwrap(); + let af = doc.get(root).as_dict().and_then(|d| d.reference(b"AcroForm")).unwrap(); + doc.get(af).as_dict().and_then(|d| d.get(b"Fields").and_then(|o| o.as_array().map(|a| a.len()))).unwrap() + }; + let own_name = |doc: &Document, f: &Field| doc.get(f.obj).as_dict().and_then(|d| d.get(b"T").and_then(text_of)); + let parent_of = |doc: &Document, f: &Field| doc.get(f.obj).as_dict().and_then(|d| d.reference(b"Parent")).unwrap(); + let text = NewField::Text { multiline: false }; + let before = top(&doc); + assert_eq!(add_field(&mut doc, 0, [50.0, 300.0, 200.0, 320.0], &text, Some("MASTER.account_firstname")).unwrap(), "MASTER.account_firstname"); + add_field(&mut doc, 0, [50.0, 260.0, 200.0, 280.0], &text, Some("MASTER.street1")).unwrap(); + assert_eq!(top(&doc), before + 1, "one parent field at the top"); + let saved = reopen(&doc); + let all = fields(&saved); + let (a, b) = (field(&all, "MASTER.account_firstname"), field(&all, "MASTER.street1")); + assert_eq!(own_name(&saved, b).as_deref(), Some("street1"), "the field's own name has no period"); + let master = parent_of(&saved, a); + assert_eq!(parent_of(&saved, b), master, "both under one MASTER"); + assert_eq!(saved.get(master).as_dict().and_then(|d| d.get(b"T").and_then(text_of)).as_deref(), Some("MASTER")); + + // Renaming to a dotted name moves the field: zip joins the existing address parent. + assert_eq!(set_props(&mut doc, "zip", &FieldProps { name: Some("address.zip".into()), ..FieldProps::default() }).unwrap(), "address.zip"); + assert_eq!(top(&doc), before, "zip left the top"); + let all = fields(&doc); + let (zip, city) = (field(&all, "address.zip"), field(&all, "address.city")); + assert_eq!(parent_of(&doc, zip), parent_of(&doc, city)); + assert_eq!(zip.max_len, Some(5), "its own settings move with it"); + + // What a moved field inherited stays; what a new field would inherit from a parent it + // joins doesn't apply to it. + let address = parent_of(&doc, city); + doc.update_dict(address, |d| { + d.set(b"Ff".to_vec(), Object::Int(flags::READ_ONLY as i64)); + d.set(b"V".to_vec(), Object::String(PdfString::literal(b"Springfield".to_vec()))); + }) + .unwrap(); + let city = field(&fields(&doc), "address.city").clone(); + assert!(city.read_only() && city.value == ["Springfield"]); + set_props(&mut doc, "address.city", &FieldProps { name: Some("billing.city".into()), ..FieldProps::default() }).unwrap(); + let moved = field(&fields(&doc), "billing.city").clone(); + assert!(moved.read_only() && moved.value == ["Springfield"], "the moved field keeps what it inherited"); + // A parent's value, reset value or length limit would pass to a new field under it (review + // on #2957), and a field can't turn them off: such a parent takes no new field. + let street = |doc: &mut Document| add_field(doc, 0, [300.0, 300.0, 450.0, 320.0], &text, Some("address.street")); + for (key, value) in [ + (&b"V"[..], Object::String(PdfString::literal(b"x".to_vec()))), + (b"DV", Object::String(PdfString::literal(b"x".to_vec()))), + (b"MaxLen", Object::Int(3)), + ] { + doc.update_dict(address, |d| { + for k in [&b"V"[..], b"DV", b"MaxLen"] { + d.remove(k); + } + d.set(key.to_vec(), value); + }) + .unwrap(); + let mut copy = doc.clone(); + assert!(street(&mut copy).is_err(), "address holds /{}", String::from_utf8_lossy(key)); + } + doc.update_dict(address, |d| { + d.remove(b"MaxLen"); + }) + .unwrap(); + street(&mut doc).unwrap(); + let new = field(&fields(&doc), "address.street").clone(); + assert!(!new.read_only(), "a new field doesn't take its parent's flags"); + assert!(new.value.iter().all(|v| v.is_empty()) && new.max_len.is_none(), "nor a value or length limit: {:?} {:?}", new.value, new.max_len); + + // A parent left empty by a move isn't a terminal field, but its name is still taken. + add_field(&mut doc, 0, [300.0, 340.0, 450.0, 360.0], &text, Some("box.only")).unwrap(); + set_props(&mut doc, "box.only", &FieldProps { name: Some("elsewhere.only".into()), ..FieldProps::default() }).unwrap(); + assert!(fields(&doc).iter().all(|f| !f.name.starts_with("box")), "box holds no field now"); + let mut copy = doc.clone(); + assert!(add_field(&mut copy, 0, [300.0, 380.0, 450.0, 400.0], &text, Some("box")).is_err(), "a new field named box"); + let mut copy = doc.clone(); + assert!(set_props(&mut copy, "name", &FieldProps { name: Some("box".into()), ..FieldProps::default() }).is_err(), "a rename to box"); + // Under it, the name is free: the empty parent takes a field again. + add_field(&mut doc, 0, [300.0, 380.0, 450.0, 400.0], &text, Some("box.again")).unwrap(); + // Only a whole path that exists can be taken: under box.sub, made new, "again" is free. + add_field(&mut doc, 0, [300.0, 420.0, 450.0, 440.0], &text, Some("box.sub.again")).unwrap(); + // A field holding its own value may join a parent holding one; and the parents a new path + // makes are new, even where an existing field has the name of a later part. + doc.update_dict(address, |d| d.set(b"V".to_vec(), Object::String(PdfString::literal(b"x".to_vec())))).unwrap(); + set_props(&mut doc, "billing.city", &FieldProps { name: Some("address.city".into()), ..FieldProps::default() }).unwrap(); + assert_eq!(field(&fields(&doc), "address.city").value, ["Springfield"]); + add_field(&mut doc, 0, [300.0, 460.0, 450.0, 480.0], &text, Some("nope.address.x")).unwrap(); + // Renaming the last part: to its own name is no change; to an empty parent's name beside it + // is taken. + assert_eq!(set_props(&mut doc, "address.city", &FieldProps { name: Some("city".into()), ..FieldProps::default() }).unwrap(), "address.city"); + doc.update_dict(address, |d| { + d.remove(b"V"); + }) + .unwrap(); + add_field(&mut doc, 0, [300.0, 500.0, 450.0, 520.0], &text, Some("address.grp.x")).unwrap(); + set_props(&mut doc, "address.grp.x", &FieldProps { name: Some("elsewhere.x".into()), ..FieldProps::default() }).unwrap(); + assert!( + set_props(&mut doc.clone(), "address.city", &FieldProps { name: Some("grp".into()), ..FieldProps::default() }).is_err(), + "address.grp is taken" + ); + // A move the destination refuses leaves the field where it was (review on #2957): it was + // taken out of the tree before the destination was checked, and stayed out. + doc.update_dict(address, |d| d.set(b"MaxLen".to_vec(), Object::Int(3))).unwrap(); + let at_top = top(&doc); + assert!(set_props(&mut doc, "name", &FieldProps { name: Some("address.name".into()), ..FieldProps::default() }).is_err()); + assert_eq!(top(&doc), at_top, "name is still in the form's /Fields"); + // Nor is what it inherits copied onto it first (review on #2957): src.f inherits /V from src. + add_field(&mut doc, 0, [300.0, 540.0, 450.0, 560.0], &text, Some("src.f")).unwrap(); + let f = field(&fields(&doc), "src.f").obj; + let src = doc.get(f).as_dict().and_then(|d| d.reference(b"Parent")).unwrap(); + doc.update_dict(src, |d| d.set(b"V".to_vec(), Object::String(PdfString::literal(b"inh".to_vec())))).unwrap(); + assert!(set_props(&mut doc, "src.f", &FieldProps { name: Some("address.f".into()), ..FieldProps::default() }).is_err()); + assert!(!doc.get(f).as_dict().unwrap().contains(b"V"), "src.f still only inherits its value"); + // What it inherits counts as its own where it goes: with /V on address and no /MaxLen, + // src.f moves, keeping its value. + doc.update_dict(address, |d| { + d.remove(b"MaxLen"); + d.set(b"V".to_vec(), Object::String(PdfString::literal(b"x".to_vec()))); + }) + .unwrap(); + set_props(&mut doc, "src.f", &FieldProps { name: Some("address.f".into()), ..FieldProps::default() }).unwrap(); + assert_eq!(field(&fields(&doc), "address.f").value, ["inh"]); + doc.update_dict(address, |d| { + d.remove(b"V"); + }) + .unwrap(); + doc.update_dict(address, |d| { + d.remove(b"MaxLen"); + }) + .unwrap(); + + for bad in ["MASTER", "MASTER.street1.x", "MASTER..x", ".x", "x.", "MASTER. "] { + assert!(add_field(&mut doc, 0, [300.0, 200.0, 450.0, 220.0], &text, Some(bad)).is_err(), "{bad:?}"); + assert!(set_props(&mut doc, "name", &FieldProps { name: Some(bad.into()), ..FieldProps::default() }).is_err(), "{bad:?}"); + } + let deep = vec!["p"; 33].join("."); + assert!(add_field(&mut doc, 0, [300.0, 200.0, 450.0, 220.0], &text, Some(&deep)).is_err(), "too deep"); + assert!(set_props(&mut doc, "name", &FieldProps { name: Some("MASTER.street1".into()), ..FieldProps::default() }).is_err(), "taken"); +} + +/// Review on #2957: a parent left empty by a move keeps its name from the next default name +/// (`Text1` after `Text1.x` moved away); and a full name that keeps the parents is a plain +/// rename, so the field stays in its place among its parent's /Kids and keeps inheriting. +#[test] +fn empty_parents_and_same_parent_renames() { + let mut doc = fixture(); + let text = NewField::Text { multiline: false }; + let auto = |doc: &mut Document, y: f64| add_field(doc, 0, [50.0, y, 200.0, y + 20.0], &text, None); + let mut copy = doc.clone(); + let first = auto(&mut copy, 100.0).unwrap(); + add_field(&mut doc, 0, [50.0, 140.0, 200.0, 160.0], &text, Some(&format!("{first}.x"))).unwrap(); + set_props(&mut doc, &format!("{first}.x"), &FieldProps { name: Some("other.x".into()), ..FieldProps::default() }).unwrap(); + assert!(fields(&doc).iter().all(|f| !f.name.starts_with(&first)), "{first} is an empty parent now"); + let next = auto(&mut doc, 100.0).expect("a default name the empty parent doesn't hold"); + assert_ne!(next, first); + + let all = fields(&doc); + let city = field(&all, "address.city").obj; + let address = doc.get(city).as_dict().and_then(|d| d.reference(b"Parent")).unwrap(); + doc.update_dict(address, |d| d.set(b"V".to_vec(), Object::String(PdfString::literal(b"Springfield".to_vec())))).unwrap(); + let kids = |doc: &Document| doc.get(address).as_dict().and_then(|d| d.get(b"Kids").and_then(|o| o.as_array().cloned())).unwrap(); + let before = kids(&doc); + assert_eq!( + set_props(&mut doc, "address.city", &FieldProps { name: Some("address.town".into()), ..FieldProps::default() }).unwrap(), + "address.town" + ); + assert_eq!(kids(&doc), before, "same place among the kids"); + assert!(!doc.get(city).as_dict().unwrap().contains(b"V"), "still inherits its value rather than owning a copy"); + assert_eq!(field(&fields(&doc), "address.town").value, ["Springfield"]); +} + +/// Review on #2957: spaces in a typed name. The ends of the whole name are dropped, as Acrobat +/// does when a field is renamed; a space beside a period stays in the part it belongs to (`a. b` +/// is `b` with a leading space under `a`), as Acrobat keeps it and as ISO 32000-2 §12.7.4.2 +/// allows: only a period is barred from a partial name. +#[test] +fn a_typed_name_loses_its_ends_but_keeps_spaces_beside_periods() { + let mut doc = fixture(); + let own_name = |doc: &Document, full: &str| { + let all = fields(doc); + doc.get(field(&all, full).obj).as_dict().and_then(|d| d.get(b"T").and_then(text_of)) + }; + let text = NewField::Text { multiline: false }; + assert_eq!(add_field(&mut doc, 0, [50.0, 300.0, 200.0, 320.0], &text, Some(" plain ")).unwrap(), "plain"); + assert_eq!(add_field(&mut doc, 0, [50.0, 260.0, 200.0, 280.0], &text, Some("a. b")).unwrap(), "a. b"); + assert_eq!(add_field(&mut doc, 0, [50.0, 220.0, 200.0, 240.0], &text, Some("c .d")).unwrap(), "c .d"); + assert_eq!(set_props(&mut doc, "zip", &FieldProps { name: Some(" e. f ".into()), ..FieldProps::default() }).unwrap(), "e. f"); + let saved = reopen(&doc); + assert_eq!(own_name(&saved, "plain").as_deref(), Some("plain")); + assert_eq!(own_name(&saved, "a. b").as_deref(), Some(" b")); + assert_eq!(own_name(&saved, "c .d").as_deref(), Some("d")); + assert_eq!(own_name(&saved, "e. f").as_deref(), Some(" f")); + let parent = |full: &str| { + let all = fields(&saved); + let p = saved.get(field(&all, full).obj).as_dict().and_then(|d| d.reference(b"Parent")).unwrap(); + saved.get(p).as_dict().and_then(|d| d.get(b"T").and_then(text_of)) + }; + assert_eq!(parent("c .d").as_deref(), Some("c "), "the space before the period is the parent's"); + assert_eq!(parent("e. f").as_deref(), Some("e")); + // A part that is only spaces is still empty. + assert!(add_field(&mut doc, 0, [300.0, 300.0, 450.0, 320.0], &text, Some("g. .h")).is_err()); +} + +/// The form's `/DR /Font` entries, resolved through an indirect `/DR` or `/Font`. +fn dr_fonts(doc: &Document) -> Dict { + let af = acroform(doc).unwrap(); + let dr = doc.resolve(af.get(b"DR").unwrap()); + doc.resolve(dr.as_dict().unwrap().get(b"Font").unwrap()).as_dict().cloned().unwrap() +} + +/// #3441: Acrobat shows nothing in a text field it redraws after typing when the field's `/DR` +/// font is a dictionary written inline. A form PdfCraft makes holds each font as its own object. +#[test] +fn a_new_form_holds_its_fonts_as_objects() { + let mut doc = document(&[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + ]); + add_field(&mut doc, 0, [40.0, 600.0, 300.0, 630.0], &NewField::Text { multiline: false }, Some("name")).unwrap(); + let doc = reopen(&doc); + let fonts = dr_fonts(&doc); + for (name, base) in [("Helv", "Helvetica"), ("TiRo", "Times-Roman"), ("Cour", "Courier")] { + let r = fonts.reference(name.as_bytes()).unwrap_or_else(|| panic!("{name} is an object: {fonts:?}")); + assert_eq!(doc.get(r).as_dict().and_then(|f| f.name(b"BaseFont")), Some(base.as_bytes())); + } +} + +/// #3441: a form saved earlier with inline `/DR` fonts gets them as objects once a field is +/// added, saying the same; an indirect `/Font` dictionary stays the object it was. +#[test] +fn inline_form_fonts_become_objects_and_an_indirect_font_dictionary_stays_one() { + for indirect in [false, true] { + let font = "/Helv << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> /Mine << /Type /Font /Subtype /Type1 /BaseFont /Symbol >>"; + let (fonts_entry, extra) = + if indirect { ("6 0 R".to_string(), vec![format!("<< {font} >>")]) } else { (format!("<< {font} >>"), Vec::new()) }; + let mut objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + format!("<< /Fields [] /DA (/Helv 0 Tf 0 g) /DR << /Font {fonts_entry} >> >>"), + "null".into(), + ]; + objs.extend(extra); + let mut doc = document(&objs); + add_field(&mut doc, 0, [40.0, 600.0, 300.0, 630.0], &NewField::Text { multiline: false }, Some("name")).unwrap(); + let doc = reopen(&doc); + let fonts = dr_fonts(&doc); + for (name, base) in [("Helv", "Helvetica"), ("Mine", "Symbol"), ("TiRo", "Times-Roman")] { + let r = fonts.reference(name.as_bytes()).unwrap_or_else(|| panic!("{name} is an object (indirect {indirect}): {fonts:?}")); + assert_eq!(doc.get(r).as_dict().and_then(|f| f.name(b"BaseFont")), Some(base.as_bytes()), "{name} says the same"); + } + let dr = doc.resolve(acroform(&doc).unwrap().get(b"DR").unwrap()); + assert_eq!(dr.as_dict().unwrap().reference(b"Font").is_some(), indirect, "the /Font dictionary keeps its form"); + } +} + +/// #3441: an indirect `/DR` stays the object it is, and a form whose three standard fonts are +/// all already there (inline) still gets them as objects, though none is added. +#[test] +fn an_indirect_resource_dictionary_and_a_complete_inline_set_are_handled() { + let inline = "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>"; + let all = format!( + "/Helv {inline} /TiRo << /Type /Font /Subtype /Type1 /BaseFont /Times-Roman >> /Cour << /Type /Font /Subtype /Type1 /BaseFont /Courier >>" + ); + for indirect_dr in [false, true] { + let dr = if indirect_dr { "5 0 R".to_string() } else { format!("<< /Font << {all} >> >>") }; + let doc = document(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + format!("<< /Fields [] /DA (/Helv 0 Tf 0 g) /DR {dr} >>"), + format!("<< /Font << {all} >> >>"), + ]); + let mut doc = doc; + add_field(&mut doc, 0, [40.0, 600.0, 300.0, 630.0], &NewField::Text { multiline: false }, Some("name")).unwrap(); + let doc = reopen(&doc); + let fonts = dr_fonts(&doc); + for name in ["Helv", "TiRo", "Cour"] { + assert!(fonts.reference(name.as_bytes()).is_some(), "{name} is an object (indirect /DR {indirect_dr}): {fonts:?}"); + } + assert_eq!(acroform(&doc).unwrap().reference(b"DR").is_some(), indirect_dr, "/DR keeps its form"); + } +} + +/// #3441: fonts the form already has are its own, not replaced by the standard ones: an inline +/// `/Helv` that is Arial becomes an object that is still Arial, a `/TiRo` object stays that +/// object, and a form needing no change leaves its `/DR` object unwritten. The fonts PdfCraft +/// adds use WinAnsi, as Acrobat's do. +#[test] +fn existing_form_fonts_are_kept_and_an_unchanged_resource_dictionary_is_not_rewritten() { + let mut doc = document(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + "<< /Fields [] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv << /Type /Font /Subtype /Type1 /BaseFont /Arial >> /TiRo 5 0 R >> >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Times-Bold >>".into(), + ]); + add_field(&mut doc, 0, [40.0, 600.0, 300.0, 630.0], &NewField::Text { multiline: false }, Some("name")).unwrap(); + let doc = reopen(&doc); + let fonts = dr_fonts(&doc); + let font = |name: &str| doc.get(fonts.reference(name.as_bytes()).unwrap()).as_dict().cloned().unwrap(); + assert_eq!(font("Helv").name(b"BaseFont"), Some(&b"Arial"[..]), "the form's own Helv"); + assert_eq!(fonts.reference(b"TiRo"), Some(pdfcraft_cos::ObjRef::new(5, 0)), "the same object"); + assert_eq!(font("Cour").name(b"Encoding"), Some(&b"WinAnsiEncoding"[..])); + + // Every font an object already, /DR an object: adding a field leaves /DR as it was. + let mut doc = document(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + "<< /Fields [] /DA (/Helv 0 Tf 0 g) /DR 5 0 R >>".into(), + "<< /Font << /Helv 6 0 R /TiRo 6 0 R /Cour 6 0 R >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + ]); + add_field(&mut doc, 0, [40.0, 600.0, 300.0, 630.0], &NewField::Text { multiline: false }, Some("name")).unwrap(); + assert!(!doc.modified_objects().contains(&5), "/DR unchanged: {:?}", doc.modified_objects()); +} + +/// #3441, exhaustively: every way a form's `/DR` may hold its fonts. `/DR` missing, inline or an +/// object; its `/Font` missing, inline, an object or not a dictionary; `/Helv` missing, inline, +/// an object, a reference to nothing, `null` or a number; and a font of the form's own, inline or +/// an object. After a field is added, whatever the shape: +/// - no font is a dictionary written inline; +/// - a font that was an object is the same object, and one that was inline says the same as an +/// object; +/// - a `/DR` or `/Font` that was an object (and a dictionary) still is one; +/// - an entry that is not a dictionary (`null`, a number, a reference to nothing) is left alone; +/// - Helv, TiRo and Cour are there. +#[test] +fn every_shape_of_form_fonts_ends_as_objects() { + let font = |base: &str| format!("<< /Type /Font /Subtype /Type1 /BaseFont /{base} >>"); + let mut cases = 0; + for dr_shape in ["missing", "inline", "object"] { + for font_shape in ["missing", "inline", "object", "number"] { + for helv in ["missing", "inline", "object", "dangling", "null", "number"] { + for mine in ["missing", "inline", "object"] { + if dr_shape == "missing" && (font_shape != "missing" || helv != "missing" || mine != "missing") { + continue; + } + if matches!(font_shape, "missing" | "number") && (helv != "missing" || mine != "missing") { + continue; + } + cases += 1; + let what = format!("/DR {dr_shape}, /Font {font_shape}, /Helv {helv}, /Mine {mine}"); + let mut entries = String::new(); + match helv { + "inline" => entries += &format!("/Helv {} ", font("Arial")), + "object" => entries += "/Helv 7 0 R ", + "dangling" => entries += "/Helv 99 0 R ", + "null" => entries += "/Helv null ", + "number" => entries += "/Helv 3 ", + _ => {} + } + match mine { + "inline" => entries += &format!("/Mine {} ", font("Symbol")), + "object" => entries += "/Mine 8 0 R ", + _ => {} + } + let fonts_value = match font_shape { + "inline" => format!("<< {entries}>>"), + "object" => "6 0 R".into(), + "number" => "7".into(), + _ => String::new(), + }; + let dr_dict = if fonts_value.is_empty() { "<< >>".to_string() } else { format!("<< /Font {fonts_value} >>") }; + let dr_value = match dr_shape { + "inline" => dr_dict.clone(), + "object" => "5 0 R".into(), + _ => String::new(), + }; + let af = if dr_value.is_empty() { "<< /Fields [] >>".to_string() } else { format!("<< /Fields [] /DR {dr_value} >>") }; + let mut doc = document(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + af, + if dr_shape == "object" { dr_dict } else { "null".into() }, + if font_shape == "object" { format!("<< {entries}>>") } else { "null".into() }, + font("Helvetica-Oblique"), + font("ZapfDingbats"), + ]); + add_field(&mut doc, 0, [40.0, 600.0, 300.0, 630.0], &NewField::Text { multiline: false }, Some("name")) + .unwrap_or_else(|e| panic!("{what}: {e}")); + let doc = reopen(&doc); + let af = acroform(&doc).unwrap(); + let dr = doc.resolve(af.get(b"DR").unwrap_or_else(|| panic!("{what}: no /DR"))); + let fonts = dr_fonts(&doc); + for (name, value) in fonts.iter() { + assert!(value.as_dict().is_none(), "{what}: {} is inline", String::from_utf8_lossy(name)); + } + for name in ["Helv", "TiRo", "Cour"] { + assert!(fonts.contains(name.as_bytes()), "{what}: no {name}"); + } + let base = + |name: &[u8]| fonts.reference(name).and_then(|r| doc.get(r).as_dict().and_then(|f| f.name(b"BaseFont").map(<[u8]>::to_vec))); + match helv { + "inline" => assert_eq!(base(b"Helv").as_deref(), Some(&b"Arial"[..]), "{what}"), + "object" => assert_eq!(fonts.reference(b"Helv"), Some(pdfcraft_cos::ObjRef::new(7, 0)), "{what}"), + "dangling" => assert_eq!(fonts.reference(b"Helv"), Some(pdfcraft_cos::ObjRef::new(99, 0)), "{what}"), + "null" => assert_eq!(fonts.get(b"Helv"), Some(&Object::Null), "{what}"), + "number" => assert_eq!(fonts.get(b"Helv"), Some(&Object::Int(3)), "{what}"), + _ => assert_eq!(base(b"Helv").as_deref(), Some(&b"Helvetica"[..]), "{what}"), + } + match mine { + "inline" => assert_eq!(base(b"Mine").as_deref(), Some(&b"Symbol"[..]), "{what}"), + "object" => assert_eq!(fonts.reference(b"Mine"), Some(pdfcraft_cos::ObjRef::new(8, 0)), "{what}"), + _ => assert!(!fonts.contains(b"Mine"), "{what}"), + } + if dr_shape == "object" { + assert_eq!(af.reference(b"DR"), Some(pdfcraft_cos::ObjRef::new(5, 0)), "{what}: /DR stays its object"); + } + if font_shape == "object" { + assert_eq!(dr.as_dict().unwrap().reference(b"Font"), Some(pdfcraft_cos::ObjRef::new(6, 0)), "{what}: /Font stays its object"); + } + } + } + } + } + assert_eq!(cases, 1 + 2 * (2 + 2 * 6 * 3), "every shape was tried"); +} + +/// #3441 (review follow-up): filling a field converts the form's inline `/DR` fonts, so a form +/// an earlier PdfCraft made works in Acrobat once it has been filled here. Nothing is added. A +/// form a signature covers keeps `/DR` as the signer saw it, and a form whose fonts are objects +/// already leaves its `/DR` object unwritten. +#[test] +fn filling_a_field_makes_inline_form_fonts_objects_unless_the_form_is_signed() { + let inline = "/Helv << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>"; + let form = |signed: bool, dr: &str| { + let sig = if signed { "<< /Type /Annot /Subtype /Widget /FT /Sig /T (sig) /V 7 0 R /Rect [0 0 0 0] /P 3 0 R >>" } else { "null" }; + let fields = if signed { "[5 0 R 6 0 R]" } else { "[5 0 R]" }; + document(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>".into(), + format!("<< /Fields {fields} /DA (/Helv 0 Tf 0 g) /DR {dr} >>"), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /DA (/Helv 12 Tf 0 g) /Rect [50 700 300 720] /P 3 0 R >>".into(), + sig.into(), + "<< /Type /Sig /Filter /Adobe.PPKLite /Contents <00> >>".into(), + "<< /Font << /Helv 9 0 R >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + ]) + }; + let mut doc = form(false, &format!("<< /Font << {inline} >> >>")); + set_value(&mut doc, "name", &FieldValue::Text("Ada".into())).unwrap(); + let doc = reopen(&doc); + let fonts = dr_fonts(&doc); + assert!(fonts.reference(b"Helv").is_some(), "Helv became an object: {fonts:?}"); + assert_eq!(fonts.len(), 1, "nothing added: {fonts:?}"); + assert_eq!(field(&fields(&doc), "name").value, ["Ada"]); + + let mut doc = form(true, &format!("<< /Font << {inline} >> >>")); + set_value(&mut doc, "name", &FieldValue::Text("Ada".into())).unwrap(); + let doc = reopen(&doc); + assert!(dr_fonts(&doc).get(b"Helv").is_some_and(|h| h.as_dict().is_some()), "a signed form keeps /DR as it was"); + + // An empty signature field signs nothing: the form is converted as an unsigned one. + let mut doc = form(true, &format!("<< /Font << {inline} >> >>")); + doc.update_dict(pdfcraft_cos::ObjRef::new(6, 0), |d| { + d.remove(b"V"); + }) + .unwrap(); + set_value(&mut doc, "name", &FieldValue::Text("Ada".into())).unwrap(); + assert!(dr_fonts(&reopen(&doc)).reference(b"Helv").is_some(), "an unsigned signature field doesn't hold /DR back"); + + let mut doc = form(false, "8 0 R"); + set_value(&mut doc, "name", &FieldValue::Text("Ada".into())).unwrap(); + assert!(!doc.modified_objects().contains(&8), "/DR unchanged: {:?}", doc.modified_objects()); +} + +/// #3441 (review follow-up): a signature value held by a parent field signs its kid too (`/V` is +/// inheritable, ISO 32000-2 Table 226), so the form still counts as signed and keeps its `/DR`. +#[test] +fn a_signature_value_on_a_parent_field_keeps_the_form_fonts_as_signed() { + let mut doc = document(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R 8 0 R] >>".into(), + "<< /Fields [5 0 R 6 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> >> >> >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /DA (/Helv 12 Tf 0 g) /Rect [50 700 300 720] /P 3 0 R >>".into(), + "<< /FT /Sig /T (sig) /V 7 0 R /Kids [8 0 R] >>".into(), + "<< /Type /Sig /Filter /Adobe.PPKLite /Contents <00> >>".into(), + "<< /Type /Annot /Subtype /Widget /T (s1) /Parent 6 0 R /Rect [0 0 0 0] /P 3 0 R >>".into(), + ]); + let kid = pdfcraft_cos::ObjRef::new(8, 0); + assert!(fields(&doc).iter().any(|f| f.obj == kid && f.kind == FieldKind::Signature), "the kid is the signature field"); + set_value(&mut doc, "name", &FieldValue::Text("Ada".into())).unwrap(); + let doc = reopen(&doc); + assert!(dr_fonts(&doc).get(b"Helv").is_some_and(|h| h.as_dict().is_some()), "an inherited signature value keeps /DR as it was"); + assert_eq!(field(&fields(&doc), "name").value, ["Ada"]); +} + +/// #3441 (review follow-up): a catalog that holds `/AcroForm` as a direct dictionary gets its +/// inline `/DR` fonts converted too when a field is filled. +#[test] +fn filling_a_field_converts_inline_fonts_of_a_direct_form_dictionary() { + let mut doc = document(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> >> >> >> >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /DA (/Helv 12 Tf 0 g) /Rect [50 700 300 720] /P 3 0 R >>".into(), + ]); + set_value(&mut doc, "name", &FieldValue::Text("Ada".into())).unwrap(); + let doc = reopen(&doc); + let fonts = dr_fonts(&doc); + assert!(fonts.reference(b"Helv").is_some(), "Helv became an object: {fonts:?}"); + assert_eq!(fonts.len(), 1, "nothing added: {fonts:?}"); + assert_eq!(field(&fields(&doc), "name").value, ["Ada"]); +} + +/// #3926 / #3943 review: a password value can be inherited from an ancestor field or sit on a +/// separate widget. Clearing removes both; an ancestor's value shared with a non-password field +/// stays for that field, and the password field gets an empty value of its own instead. +#[test] +fn inherited_and_widget_password_values_are_cleared() { + let objs: Vec = 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 9 0 R 11 0 R] >>".into(), // 3 + "<< /Fields [5 0 R 8 0 R 10 0 R] /DA (/Helv 0 Tf 0 g) >>".into(), // 4 + "<< /T (acct) /FT /Tx /Ff 8192 /V (secret1) /Kids [6 0 R] >>".into(), // 5 flag and value inherited + "<< /Type /Annot /Subtype /Widget /T (pin) /Parent 5 0 R /Rect [50 700 150 720] /P 3 0 R >>".into(), // 6 + "<< /Type /Annot /Subtype /Widget /Parent 8 0 R /V (secret3) /Rect [50 650 150 670] /P 3 0 R >>".into(), // 7 value on the widget + "<< /FT /Tx /T (code) /Ff 8192 /Kids [7 0 R] >>".into(), // 8 + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (pw) /Ff 8192 /Parent 10 0 R /Rect [50 600 150 620] /P 3 0 R >>".into(), // 9 + "<< /T (grp) /V (shared2) /Kids [9 0 R 11 0 R] >>".into(), // 10 shared with a plain field + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (plain) /Parent 10 0 R /Rect [50 550 150 570] /P 3 0 R >>".into(), // 11 + ]; + let mut doc = document(&objs); + assert!(has_password_values(&doc)); + let value = |doc: &Document, n: &str| fields(doc).into_iter().find(|f| f.name == n).map(|f| f.value).unwrap_or_default(); + assert_eq!(value(&doc, "acct.pin"), ["secret1"], "inherited before"); + assert_eq!(clear_password_values(&mut doc).unwrap(), 3); + assert!(!has_password_values(&doc)); + for n in ["acct.pin", "code", "grp.pw"] { + assert!(value(&doc, n).iter().all(|v| v.is_empty()), "{n} cleared"); + } + assert_eq!(value(&doc, "grp.plain"), ["shared2"], "the shared value stays for the plain field"); + let has_v = |doc: &Document, n: u32| doc.get(pdfcraft_cos::ObjRef { num: n, generation: 0 }).as_dict().is_some_and(|d| d.get(b"V").is_some()); + assert!(!has_v(&doc, 5), "an ancestor only password fields inherit from loses its value"); + 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 = 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); +} + +/// #3943 review: the quick check that skips forms without a Password field still sees one whose +/// widget is only on the page, not listed in `/Fields`; one that inherits the flag from a parent +/// that is not listed either; and one deeper in the field tree whose widget is on no page. Each +/// is alone in its own document, so each path of the check is needed. +#[test] +fn a_password_field_missing_from_the_field_list_is_still_cleared() { + let form = |fields: &str, annots: &str, rest: &[&str]| { + let mut objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + format!("<< /Type /Page /Parent 2 0 R /Annots [{annots}] >>"), + format!("<< /Fields [{fields}] /DA (/Helv 0 Tf 0 g) >>"), + ]; + objs.extend(rest.iter().map(|o| o.to_string())); + document(&objs) + }; + let page_only = + form("", "5 0 R", &["<< /Type /Annot /Subtype /Widget /FT /Tx /T (pin) /Ff 8192 /V (secret5) /Rect [50 700 150 720] /P 3 0 R >>"]); + let inherited = form( + "", + "5 0 R", + &[ + "<< /Type /Annot /Subtype /Widget /T (kid) /Parent 6 0 R /Rect [50 650 150 670] /P 3 0 R >>", + "<< /FT /Tx /T (acct) /Ff 8192 /V (secret6) /Kids [5 0 R] >>", + ], + ); + let off_page = form( + "5 0 R", + "", + &[ + "<< /T (grp) /Kids [6 0 R] >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (hidden) /Ff 8192 /V (secret7) /Parent 5 0 R /Rect [0 0 0 0] >>", + ], + ); + for (what, mut doc) in [("page only", page_only), ("inherited", inherited), ("on no page", off_page)] { + assert!(has_password_values(&doc), "{what}"); + assert_eq!(clear_password_values(&mut doc).unwrap(), 1, "{what}"); + assert!(fields(&doc).iter().all(|f| f.value.iter().all(|v| v.is_empty())), "{what}: {:?}", fields(&doc)); + } +} + +/// #4018: a value that doesn't fit its box at the field's font size is reported (it is drawn cut +/// at the box). An auto-sized field shrinks to fit, and a short or empty value fits. +#[test] +fn a_value_wider_or_taller_than_its_box_is_reported() { + let long = "Doe, Jane (represented by John Doe)"; + let field = |name: &str, rect: &str, extra: &str, value: &str| { + format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /V ({value}) /Rect [{rect}] /P 3 0 R {extra} >>") + }; + let doc = document(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R 8 0 R 9 0 R 10 0 R] >>".into(), + "<< /Fields [6 0 R 7 0 R 8 0 R 9 0 R 10 0 R] /DA (/Helv 12 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + field("single", "50 700 190 718", "", long), + field("multi", "50 650 190 668", "/Ff 4096", long), + field("auto", "50 600 190 618", "/DA (/Helv 0 Tf 0 g)", long), + field("wide", "50 550 350 568", "", long), + field("short", "50 500 190 518", "/Ff 4096", "Ada"), + ]); + let overflow = |name: &str| { + let f = fields(&doc).into_iter().find(|f| f.name == name).unwrap(); + appearance::value_overflow(&doc, &f, &f.widgets[0]) + }; + match overflow("single") { + Some(appearance::Overflow::Wide { width, room, size }) => { + assert_eq!(size, 12.0); + assert!((width - 201.4).abs() < 1.0 && room < 140.0, "{width} in {room}"); + } + other => panic!("single: {other:?}"), + } + assert_eq!(overflow("multi"), Some(appearance::Overflow::Tall { lines: 2, room: 1, size: 12.0 })); + assert_eq!(overflow("auto"), None, "auto size shrinks to fit"); + assert_eq!(overflow("wide"), None); + assert_eq!(overflow("short"), None); +} + +/// #4018: a single line taller than its box is cut at the top and bottom, and a character wider +/// than a multiline box is a line of its own that is still cut at the sides; both are reported. +/// An empty value draws nothing, so nothing is cut however small the box. +#[test] +fn a_line_taller_than_its_box_or_a_character_wider_than_it_is_reported() { + let field = |name: &str, rect: &str, extra: &str, value: &str| { + format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /V ({value}) /Rect [{rect}] /P 3 0 R {extra} >>") + }; + let doc = document(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R] >>".into(), + "<< /Fields [6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R] /DA (/Helv 12 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + field("low", "50 700 190 708", "", "Ada"), + field("comb", "50 650 190 658", "/Ff 16777216 /MaxLen 5", "Ada"), + field("narrow", "50 500 56 560", "/Ff 4096", "WW"), + field("roomy", "50 400 190 418", "", "Ada"), + field("empty", "50 350 190 358", "", ""), + field("empty-multi", "50 300 190 304", "/Ff 4096", ""), + ]); + let overflow = |name: &str| { + let f = fields(&doc).into_iter().find(|f| f.name == name).unwrap(); + appearance::value_overflow(&doc, &f, &f.widgets[0]) + }; + for name in ["low", "comb"] { + match overflow(name) { + Some(o @ appearance::Overflow::High { height, room, size }) => { + assert_eq!(size, 12.0); + assert!((height - 11.1).abs() < 0.01 && room <= 8.0, "{name}: {height} in {room}"); + assert!(o.to_string().starts_with("the value does not fit: 11.1 pt high at 12 pt"), "{o}"); + } + other => panic!("{name}: {other:?}"), + } + } + match overflow("narrow") { + Some(appearance::Overflow::Wide { width, room, size }) => { + assert_eq!(size, 12.0); + assert!(width > room && (width - 11.32).abs() < 0.1, "{width} in {room}"); + } + other => panic!("narrow: {other:?}"), + } + assert_eq!(overflow("roomy"), None); + assert_eq!(overflow("empty"), None); + assert_eq!(overflow("empty-multi"), None); +} + +/// Check boxes, radio buttons and push buttons whose file gives them no appearance, a comb field, +/// a combo box and a list box, for the display copy. +fn left_buttons() -> Document { + let widget = |n: &str, rest: &str| format!("<< /Type /Annot /Subtype /Widget /T ({n}) /F 4 /P 3 0 R {rest} >>"); + document(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R 15 0 R 16 0 R 17 0 R 18 0 R] >>".into(), + "<< /Fields [6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R 15 0 R 16 0 R 17 0 R 18 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + widget("i", "/FT /Btn /V /Yes /AS /Yes /DA (/ZaDb 0 Tf 0 g) /MK << /CA (4) /BC [0 0 0] >> /Rect [50 700 74 724]"), // 6 + widget("j", "/FT /Btn /V /Yes /AS /Yes /DA (/ZaDb 0 Tf 0 0 1 rg) /MK << /CA (l) /BC [0 0 0] >> /Rect [100 700 124 724]"), + widget("k", "/FT /Btn /V /Yes /AS /Yes /DA (/ZaDb 0 Tf 1 0 0 rg) /MK << /CA (8) /BC [0 0 0] >> /Rect [150 700 174 724]"), + widget("l", "/FT /Btn /V /Yes /AS /Yes /DA (/ZaDb 9 Tf 0 g) /MK << /CA (H) /BC [0 0 0] /BG [1 1 0] >> /Rect [200 700 224 724]"), + widget("off", "/FT /Btn /V /Off /AS /Off /DA (/ZaDb 0 Tf 0 g) /MK << /CA (4) /BC [0 0 0] >> /Rect [250 700 274 724]"), // 10 + widget("radio", "/FT /Btn /Ff 32768 /V /A /AS /A /DA (/ZaDb 0 Tf 0 g) /MK << /BC [0 0 0] >> /Rect [300 700 324 724]"), + widget( + "o", + "/FT /Btn /Ff 65536 /DA (/Helv 12 Tf 0 g) /MK << /CA (Press me) /BG [0.8 0.8 0.8] /BC [0 0 0] >> /BS << /W 2 /S /B >> /Rect [50 600 170 624]", + ), + widget("own", "/FT /Btn /V /Yes /AS /Yes /MK << /CA (4) /BC [0 0 0] >> /Rect [350 700 374 724] /AP << /N << /Yes 19 0 R /Off 19 0 R >> >>"), + widget("half", "/FT /Btn /V /Yes /AS /Yes /DA (/ZaDb 0 Tf 0 g) /MK << /CA (8) >> /Rect [400 700 424 724] /AP << /N << /Off 19 0 R >> >>"), + widget("bad", "/FT /Btn /V /Yes /AS /Yes /DA (/ZaDb nan Tf 1 x rg) /MK << /CA 42 /BC [(no) /Name] /BG 7 >> /BS << /W -3 >> /Rect [450 700 474 724]"), // 15 + widget("d", "/FT /Tx /Ff 16777216 /MaxLen 6 /V (ABC123) /DA (/Helv 12 Tf 0 g) /MK << /BC [0 0 1] >> /Rect [50 500 170 524]"), + widget("m", "/FT /Ch /Ff 131072 /Opt [(Apple) (Banana)] /V (Banana) /Q 2 /DA (/Helv 12 Tf 0 g) /MK << /BC [0 0 0] >> /Rect [50 400 170 424]"), + widget("n", "/FT /Ch /Opt [(One) (Two)] /V (Two) /DA (/Helv 11 Tf 0 g) /MK << /BC [0 0 0] >> /Rect [200 400 320 460]"), + "<< /Length 4 >>\nstream\nq\nQ\nendstream".into(), // 19 + ]) +} + +/// The appearance stream of widget `w` in `state` (a check box's `/AP /N /`). +fn state_ap(doc: &Document, w: &Widget, state: &str) -> String { + let wd = doc.get(w.obj).as_dict().cloned().unwrap(); + let ap = doc.resolve(wd.get(b"AP").unwrap()).as_dict().cloned().unwrap(); + let n = doc.resolve(ap.get(b"N").unwrap()).as_dict().cloned().unwrap(); + let r = n.reference(state.as_bytes()).expect("the state's appearance"); + let Object::Stream(s) = &*doc.get(r) else { panic!("a stream") }; + String::from_utf8_lossy(&s.decoded().unwrap()).into_owned() +} + +/// The decoded appearance PdfCraft would save for text or choice field `name` of `doc`. +fn saved_look(doc: &Document, name: &str) -> String { + let all = fields(doc); + let f = field(&all, name); + String::from_utf8_lossy(&appearance::field_appearance(doc, f, &f.widgets[0], &f.value).decoded().unwrap()).into_owned() +} + +/// ISO 32000-2 Table 192 and 12.7.5.2.3: a check box with no appearance shows its `/MK /BC` +/// border and, when on, its caption `/MK /CA` as a ZapfDingbats character in the `/DA` colour, +/// sized to the box when the `/DA` size is 0. +#[test] +fn check_boxes_without_an_appearance_show_their_caption_symbol() { + let doc = left_buttons(); + let copy = with_field_appearances(&doc).expect("buttons to draw"); + let all = fields(©); + for (name, symbol, colour) in [("i", "(4) Tj", "0 g"), ("j", "(l) Tj", "0 0 1 rg"), ("k", "(8) Tj", "1 0 0 rg"), ("l", "(H) Tj", "0 g")] { + let shown = state_ap(©, &field(&all, name).widgets[0], "Yes"); + assert!(shown.contains(symbol), "{name}: {shown}"); + assert!(shown.contains(&format!("{colour}\n")), "{name} in its colour: {shown}"); + assert!(shown.contains("0 0 0 RG\n1 w\n0.5 0.5 23 23 re S"), "{name} has its border: {shown}"); + } + // A /DA size is kept; the automatic one fills the box inside the border. + let l = state_ap(©, &field(&all, "l").widgets[0], "Yes"); + assert!(l.contains("/ZaDb 9 Tf") && l.contains("1 1 0 rg\n0 0 24 24 re f"), "{l}"); + let i = state_ap(©, &field(&all, "i").widgets[0], "Yes"); + let size: f64 = i.split("/ZaDb ").nth(1).and_then(|s| s.split(' ').next()).and_then(|s| s.parse().ok()).unwrap(); + assert!((18.0..=26.0).contains(&size), "{size}"); + let w = copy.get(field(&all, "i").widgets[0].obj); + let ap = copy.resolve(w.as_dict().unwrap().get(b"AP").unwrap()); + let n = copy.resolve(ap.as_dict().unwrap().get(b"N").unwrap()); + let Object::Stream(s) = &*copy.get(n.as_dict().unwrap().reference(b"Yes").unwrap()) else { panic!() }; + let font = s.dict.get(b"Resources").and_then(|r| r.as_dict()).and_then(|r| r.get(b"Font")).and_then(|f| f.as_dict()).and_then(|f| f.get(b"ZaDb")); + assert_eq!(font.and_then(|f| f.as_dict()).and_then(|f| f.name(b"BaseFont")), Some(&b"ZapfDingbats"[..]), "a standard-14 font, no program"); +} + +/// An Off check box shows only its border, a radio button without a caption a dot (`l`), a box +/// whose file has only an Off look gets an on look, and one with its own look keeps it. +#[test] +fn button_states_without_an_appearance() { + let doc = left_buttons(); + let copy = with_field_appearances(&doc).expect("buttons to draw"); + let all = fields(©); + let off = state_ap(©, &field(&all, "off").widgets[0], "Off"); + assert!(off.contains("re S") && !off.contains("Tj"), "{off}"); + assert!(state_ap(©, &field(&all, "radio").widgets[0], "A").contains("(l) Tj")); + assert!(state_ap(©, &field(&all, "half").widgets[0], "Yes").contains("(8) Tj")); + let own = |d: &Document| d.get(field(&fields(d), "own").widgets[0].obj).as_dict().and_then(|w| w.get(b"AP").cloned()); + assert_eq!(own(©), own(&doc), "a check box with its own look keeps it"); + // Nonsense in /MK, /DA and /BS: the default symbol, no border, and no panic. + let bad = state_ap(©, &field(&all, "bad").widgets[0], "Yes"); + assert!(bad.contains("(4) Tj") && !bad.contains("re S"), "{bad}"); + // The document itself is untouched. + assert!(doc.get(field(&fields(&doc), "i").widgets[0].obj).as_dict().is_some_and(|w| !w.contains(b"AP"))); +} + +/// Table 192 and Table 168: a push button with no appearance shows its `/MK /BG` background, its +/// beveled `/MK /BC` border in the `/BS` width, and its caption `/MK /CA` centred. +#[test] +fn push_buttons_without_an_appearance_show_their_caption_and_frame() { + let copy = with_field_appearances(&left_buttons()).expect("buttons to draw"); + let shown = ap(©, &field(&fields(©), "o").widgets[0]); + for part in ["0.8 0.8 0.8 rg\n0 0 120 24 re f", "0 0 0 RG\n2 w\n1 1 118 22 re S", "1 g\n", "0.5 g\n", "(Press me) Tj", "/Helv 12.00 Tf"] { + assert!(shown.contains(part), "{part}: {shown}"); + } +} + +/// ISO 32000-2 Table 228, Comb: the field is divided into MaxLen equally spaced positions, and a +/// bordered comb field shows them: MaxLen - 1 lines in the border colour, in its saved look too. +#[test] +fn comb_fields_show_their_cells() { + let mut doc = left_buttons(); + let shown = saved_look(&doc, "d"); + let lines: Vec<&str> = shown.lines().filter(|l| l.ends_with(" l") && l.contains(" 1 m ")).collect(); + assert_eq!(lines, ["20 1 m 20 23 l", "40 1 m 40 23 l", "60 1 m 60 23 l", "80 1 m 80 23 l", "100 1 m 100 23 l"], "{shown}"); + assert!(shown.contains("0 0 1 RG\n1 w\n20 1 m"), "in the border colour: {shown}"); + // Past the cap no lines, and a huge MaxLen is no trouble; without a border, none either. + let r = field(&fields(&doc), "d").widgets[0].obj; + doc.update_dict(r, |w| w.set(b"MaxLen".to_vec(), Object::Int(1 << 40))).unwrap(); + let huge = saved_look(&doc, "d"); + assert!(!huge.contains(" 1 m ") && huge.contains("(A) Tj"), "{huge}"); + doc.update_dict(r, |w| { + w.set(b"MaxLen".to_vec(), Object::Int(6)); + w.remove(b"MK"); + }) + .unwrap(); + let plain = saved_look(&doc, "d"); + assert!(!plain.contains(" m ") && plain.contains("(A) Tj"), "{plain}"); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 1af2a6a4f..5e52d1d98 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -4204,7 +4204,8 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { if let Some(e) = crate::edit_text_ui::overlay(ui.ctx(), view, info) { view.pending_edit = Some(e); } - if let Some(e) = crate::forms_ui::overlay(ui.ctx(), view, info, &form, today) { + let text_box = crate::forms_ui::text_box(doc, view, &form); + if let Some(e) = crate::forms_ui::overlay(ui.ctx(), view, info, &form, text_box.as_ref(), today) { view.pending_edit = Some(e); } if let Some(e) = crate::fill_sign::type_box(ui.ctx(), view, info, &author) { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 0337a8cbf..6f6bf41cf 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1360,8 +1360,10 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { && let Some(i) = app.active { // Keep the (possibly renamed) field selected. - let prefix = d.field.rsplit_once('.').map(|(p, _)| format!("{p}.")).unwrap_or_default(); - let name = format!("{prefix}{}", d.name.trim()); + // A dotted name is the full name; a plain one renames the field within its parent. + let typed = d.name.trim(); + let prefix = d.field.rsplit_once('.').filter(|_| !typed.contains('.')).map(|(p, _)| format!("{p}.")).unwrap_or_default(); + let name = format!("{prefix}{typed}"); if let (Some(before), Some(after)) = (before, form(app)) { let n = pdfcraft_engine::form_calculations_changed(&before, &after).iter().filter(|f| **f != name).count(); if n > 0 { diff --git a/crates/ui-egui/src/forms_ui.rs b/crates/ui-egui/src/forms_ui.rs index 76e092672..5c3b91a39 100644 --- a/crates/ui-egui/src/forms_ui.rs +++ b/crates/ui-egui/src/forms_ui.rs @@ -290,8 +290,8 @@ pub(crate) fn commit(view: &mut DocView, form: &[FormField]) { const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; -/// The date picker under a focused date field. Returns the picked date, formatted with the -/// field's own format. +/// The date picker under a focused date field: a month of days, and a Today button under them. +/// Returns the picked date, formatted with the field's own format. fn calendar(ctx: &egui::Context, view: &mut DocView, field: egui::Rect, fmt: &str, today: (i64, u32, u32)) -> Option { use pdfcraft_engine::form_scripts::{DateTime, format_date, parse_date}; let fx = view.forms.focus.as_mut()?; @@ -345,7 +345,7 @@ fn calendar(ctx: &egui::Context, view: &mut DocView, field: egui::Rect, fmt: &st (Some(a), Some(b)) => (b - a).num_days() as usize, _ => 31, }; - egui::Grid::new(("date-grid", view.id.0)).spacing([4.0, 2.0]).show(ui, |ui| { + let grid = egui::Grid::new(("date-grid", view.id.0)).spacing([4.0, 2.0]).show(ui, |ui| { for d in ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"] { ui.label(egui::RichText::new(tl_ctx!("calendar weekday", d)).small()); } @@ -381,6 +381,24 @@ fn calendar(ctx: &egui::Context, view: &mut DocView, field: egui::Rect, fmt: &st } } }); + // Today, whichever month the calendar shows: picked as a click on its day would + // pick it. A year the field's date can't hold offers no button. + if let Ok(ty) = i32::try_from(today.0) { + ui.add_space(4.0); + // As wide as the grid, so the button sits centred under it without widening the popup. + ui.allocate_ui_with_layout(vec2(grid.response.rect.width(), 0.0), egui::Layout::top_down(egui::Align::Center), |ui| { + let mut button = ui.small_button(tl!("Today")); + if let Some(month) = today.1.checked_sub(1).and_then(|i| MONTHS.get(i as usize)) { + button = button.on_hover_text(crate::i18n::fmt( + tl!("{month} {day}, {y}"), + &[("month", tl_ctx!("calendar date", month)), ("day", &today.2.to_string()), ("y", &ty.to_string())], + )); + } + if button.clicked() { + picked = Some(format_date(DateTime { y: ty, m: today.1, d: today.2, hh: 0, mm: 0, ss: 0 }, fmt)); + } + }); + } }); }); if let Some(fx) = view.forms.focus.as_mut() { @@ -516,8 +534,26 @@ fn paint_unbaked_widget(painter: &egui::Painter, xf: &PageXform, f: &FormField, } } +/// How the focused text field lays text out, when it is one that doesn't scroll (`DoNotScroll`): +/// its editor then takes no more than fits ([`overlay`]). +pub(crate) fn text_box(doc: &pdfcraft_engine::Document, view: &DocView, form: &[FormField]) -> Option { + let fx = view.forms.focus.as_ref()?; + let f = form.iter().find(|f| f.name == fx.name)?; + let no_scroll = f.kind == FormFieldKind::Text && f.has(field_flags::DO_NOT_SCROLL); + no_scroll.then(|| doc.field_text_box(&fx.name, fx.widget)).flatten() +} + /// The in-place editor or option list for the focused field. Returns an edit to apply. -pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, form: &[FormField], today: (i64, u32, u32)) -> Option { +/// `text_box`, for a text field that doesn't scroll, is how its widget lays text out: the editor +/// then takes no more than fits. +pub(crate) fn overlay( + ctx: &egui::Context, + view: &mut DocView, + info: &DocInfo, + form: &[FormField], + text_box: Option<&form_appearance::TextBox>, + today: (i64, u32, u32), +) -> Option { let pressed = view.forms.pressed; if !ctx.input(|i| i.pointer.primary_down()) { view.forms.pressed = false; @@ -566,6 +602,8 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, f let font = (field_text_size(ui.painter(), f, w, &fx.text) * zoom).clamp(3.0, 64.0); // The field is pale yellow in every theme, so the caret must not follow the theme (pale in dark mode) (#655). ui.visuals_mut().text_cursor.stroke.color = crate::edit_text_ui::caret_color_on(FIELD_FILL); + // A field that doesn't scroll takes no more than fits (ISO 32000-2 Table 228). + let before = text_box.map(|_| fx.text.clone()); let mut te = if multiline { egui::TextEdit::multiline(&mut fx.text) } else { egui::TextEdit::singleline(&mut fx.text) }; te = te // Wraps (inside the 3 px side margins) at the width the size was fitted to: @@ -589,6 +627,19 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, f laid_out = Some((out.galley, out.galley_pos)); out.response.response }); + if let (Some(tb), Some(before)) = (text_box, before) + && fx.text != before + { + let (kept, caret) = tb.fitted_edit(&before, &fx.text); + if kept != fx.text { + fx.text = kept; + if let Some(mut st) = egui::TextEdit::load_state(ui.ctx(), r.id) { + st.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(caret)))); + st.store(ui.ctx(), r.id); + } + ui.ctx().request_repaint(); + } + } if let Some(press) = fx.press.take() && let Some((galley, pos)) = laid_out && let Some(mut st) = egui::TextEdit::load_state(ui.ctx(), r.id) diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 765bbba55..fa508aa77 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -2791,3 +2791,6 @@ certificate Version الإصدار field names can't contain a period: {name} لا يمكن أن تحتوي أسماء الحقول على نقطة: {name} two cells would both be named {name} ستحمل خليتان الاسم نفسه {name} a field named {name} already exists يوجد حقل باسم {name} بالفعل + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today اليوم diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 9bb377a93..02f83a31a 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2478,3 +2478,6 @@ animation effects Off Изключени field names can't contain a period: {name} имената на полетата не може да съдържат точка: {name} two cells would both be named {name} две клетки биха имали едно и също име {name} a field named {name} already exists вече съществува поле с име {name} + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today Днес diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index dd2f497b8..80d70568f 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -2774,3 +2774,6 @@ theme Appearance Aspecte field names can't contain a period: {name} els noms dels camps no poden contenir cap punt: {name} two cells would both be named {name} dues cel·les es dirien {name} a field named {name} already exists ja existeix un camp anomenat {name} + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today Avui diff --git a/crates/ui-egui/src/i18n/ckb.tsv b/crates/ui-egui/src/i18n/ckb.tsv index 804e7a97a..6e35d31bb 100644 --- a/crates/ui-egui/src/i18n/ckb.tsv +++ b/crates/ui-egui/src/i18n/ckb.tsv @@ -2713,3 +2713,6 @@ certificate Version وەشان Make properties default کردنی تایبەتمەندییەکان بە بنەڕەتی Pages selected پەڕە هەڵبژێردراوەکان Page properties تایبەتمەندییەکانی پەڕە + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today ئەمڕۆ diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 66cac29aa..2511ccfe4 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2822,3 +2822,6 @@ certificate Version Verze field names can't contain a period: {name} názvy polí nesmějí obsahovat tečku: {name} two cells would both be named {name} dvě buňky by se jmenovaly {name} a field named {name} already exists pole s názvem {name} už existuje + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today Dnes diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 76366d452..6c2ed2a5c 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2516,3 +2516,6 @@ animation effects Off Aus field names can't contain a period: {name} Feldnamen dürfen keinen Punkt enthalten: {name} two cells would both be named {name} zwei Zellen hätten beide den Namen {name} a field named {name} already exists ein Feld namens {name} ist bereits vorhanden + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today Heute diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 18464664a..e749b907d 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2521,3 +2521,6 @@ animation effects Off Ανενεργά field names can't contain a period: {name} τα ονόματα πεδίων δεν μπορούν να περιέχουν τελεία: {name} two cells would both be named {name} δύο κελιά θα είχαν το ίδιο όνομα {name} a field named {name} already exists υπάρχει ήδη πεδίο με όνομα {name} + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today Σήμερα diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 73b0ffbf1..567ac72e2 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2838,3 +2838,6 @@ theme Appearance Aspecto field names can't contain a period: {name} los nombres de campo no pueden contener un punto: {name} two cells would both be named {name} dos celdas se llamarían {name} a field named {name} already exists ya existe un campo llamado {name} + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today Hoy diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index efcf3e3c6..d8e550f7d 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -2783,3 +2783,6 @@ theme Appearance Teema field names can't contain a period: {name} kenttien nimissä ei voi olla pistettä: {name} two cells would both be named {name} kahdella solulla olisi sama nimi {name} a field named {name} already exists kenttä nimeltä {name} on jo olemassa + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today Tänään diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index ec6e59e56..70e1c3ea0 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2781,3 +2781,6 @@ certificate Version Version field names can't contain a period: {name} les noms de champ ne peuvent pas contenir de point : {name} two cells would both be named {name} deux cellules porteraient le nom {name} a field named {name} already exists un champ nommé {name} existe déjà + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today Aujourd'hui diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 7be5ec688..ef017fbbf 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -2750,3 +2750,6 @@ theme Appearance स्वरूप field names can't contain a period: {name} फ़ील्ड के नामों में पूर्ण विराम (.) नहीं हो सकता: {name} two cells would both be named {name} दो सेल का नाम {name} हो जाएगा a field named {name} already exists {name} नाम का फ़ील्ड पहले से मौजूद है + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today आज diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index f1723629c..4a7c165f4 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2514,3 +2514,6 @@ animation effects Off Ki field names can't contain a period: {name} a mezőnevek nem tartalmazhatnak pontot: {name} two cells would both be named {name} két cella neve is {name} lenne a field named {name} already exists már van {name} nevű mező + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today Ma diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 3585b344e..53355ef79 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2751,3 +2751,6 @@ theme Appearance Tampilan field names can't contain a period: {name} nama kolom tidak boleh berisi titik: {name} two cells would both be named {name} dua sel akan sama-sama bernama {name} a field named {name} already exists kolom bernama {name} sudah ada + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today Hari ini diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 0dc6bb087..491b56a03 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2747,3 +2747,6 @@ certificate SHA-256 digest Impronta SHA-256 field names can't contain a period: {name} i nomi dei campi non possono contenere un punto: {name} two cells would both be named {name} due celle si chiamerebbero entrambe {name} a field named {name} already exists esiste già un campo chiamato {name} + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today Oggi diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 963e8d364..14d7aafcc 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2515,3 +2515,6 @@ animation effects Off オフ field names can't contain a period: {name} フィールド名にピリオドは使えません: {name} two cells would both be named {name} 2 つのセルが同じ名前 {name} になります a field named {name} already exists {name} という名前のフィールドは既にあります + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today 今日 diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index 311626f34..2f9f3f526 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -2513,3 +2513,6 @@ animation effects Off Өшірулі field names can't contain a period: {name} өріс атауларында нүкте болмауы керек: {name} two cells would both be named {name} екі ұяшықтың да атауы {name} болар еді a field named {name} already exists {name} атты өріс бар + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today Бүгін diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 48ef31e19..ac6b5ba8b 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2750,3 +2750,6 @@ theme Appearance 모양 field names can't contain a period: {name} 필드 이름에는 마침표를 쓸 수 없습니다: {name} two cells would both be named {name} 두 셀의 이름이 모두 {name}이(가) 됩니다 a field named {name} already exists {name}(이)라는 필드가 이미 있습니다 + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today 오늘 diff --git a/crates/ui-egui/src/i18n/lo.tsv b/crates/ui-egui/src/i18n/lo.tsv index a8a9f6f64..975aff65b 100644 --- a/crates/ui-egui/src/i18n/lo.tsv +++ b/crates/ui-egui/src/i18n/lo.tsv @@ -2429,3 +2429,6 @@ animation effects Off ປິດ Make properties default ກຳນົດຄຸນສົມບັດເປັນຄ່າເລີ່ມຕົ້ນ Pages selected ໜ້າທີ່ເລືອກ Page properties ຄຸນສົມບັດໜ້າ + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today ມື້ນີ້ diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index e2476f196..5be485d1f 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -2776,3 +2776,6 @@ theme Appearance Išvaizda a table has 1 to {max} columns lentelėje gali būti nuo 1 iki {max} stulpelių the first row number can be at most {max} pirmosios eilutės numeris gali būti ne didesnis nei {max} the area, the spacing and the column widths must be numbers, the spacing 0 or more and each width more than 0 sritis, tarpai ir stulpelių pločiai turi būti skaičiai, tarpai – 0 arba daugiau, o kiekvienas plotis – daugiau nei 0 + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today Šiandien diff --git a/crates/ui-egui/src/i18n/nb.tsv b/crates/ui-egui/src/i18n/nb.tsv index 27c1383ec..b49187b2a 100644 --- a/crates/ui-egui/src/i18n/nb.tsv +++ b/crates/ui-egui/src/i18n/nb.tsv @@ -2694,3 +2694,6 @@ key Space Mellomrom # Page gaps (AI-assisted, not separately native-reviewed) Show gaps between pages Vis mellomrom mellom sidene Hide gaps to join page edges. Margins inside the PDF remain unchanged. Skjul mellomrommene slik at sidekantene møtes. Margene inne i PDF-en endres ikke. + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today I dag diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 6836f3e0a..998cfb871 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2611,3 +2611,6 @@ animation effects Off Wyłączone field names can't contain a period: {name} nazwy pól nie mogą zawierać kropki: {name} two cells would both be named {name} dwie komórki miałyby nazwę {name} a field named {name} already exists pole o nazwie {name} już istnieje + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today Dzisiaj diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index dcbebae95..25f009620 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2800,3 +2800,6 @@ theme Appearance Aparência a table has 1 to {max} columns uma tabela tem de 1 a {max} colunas the first row number can be at most {max} o número da primeira linha pode ser no máximo {max} the area, the spacing and the column widths must be numbers, the spacing 0 or more and each width more than 0 a área, o espaçamento e as larguras das colunas devem ser números, o espaçamento 0 ou mais e cada largura maior que 0 + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today Hoje diff --git a/crates/ui-egui/src/i18n/ro.tsv b/crates/ui-egui/src/i18n/ro.tsv index 2bd680f91..5a276ec75 100644 --- a/crates/ui-egui/src/i18n/ro.tsv +++ b/crates/ui-egui/src/i18n/ro.tsv @@ -2774,3 +2774,6 @@ theme Appearance Aspect field names can't contain a period: {name} numele câmpurilor nu pot conține punct: {name} two cells would both be named {name} două celule s-ar numi amândouă {name} a field named {name} already exists există deja un câmp numit {name} + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today Astăzi diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 0dc40fdba..5a8d76946 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2428,3 +2428,6 @@ animation effects Off Выкл field names can't contain a period: {name} имена полей не могут содержать точку: {name} two cells would both be named {name} две ячейки получили бы одно имя {name} a field named {name} already exists поле с именем {name} уже существует + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today Сегодня diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index f5e374ae7..31e6ac632 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -2520,3 +2520,6 @@ animation effects Off Izklopljeni field names can't contain a period: {name} imena polj ne smejo vsebovati pike: {name} two cells would both be named {name} dve celici bi imeli enako ime {name} a field named {name} already exists polje z imenom {name} že obstaja + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today Danes diff --git a/crates/ui-egui/src/i18n/sq.tsv b/crates/ui-egui/src/i18n/sq.tsv index c93efee5a..22de86f84 100644 --- a/crates/ui-egui/src/i18n/sq.tsv +++ b/crates/ui-egui/src/i18n/sq.tsv @@ -2500,3 +2500,6 @@ animation effects Off Joaktiv field names can't contain a period: {name} emrat e fushave nuk mund të përmbajnë pikë: {name} two cells would both be named {name} dy qeliza do të kishin të dyja emrin {name} a field named {name} already exists ekziston tashmë një fushë me emrin {name} + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today Sot diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index fa3f6cec4..65da5a2c0 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -2424,3 +2424,6 @@ animation effects Off ఆఫ్ field names can't contain a period: {name} ఫీల్డ్ పేర్లలో చుక్క ఉండకూడదు: {name} two cells would both be named {name} రెండు గడులకూ {name} అనే పేరు వస్తుంది a field named {name} already exists {name} అనే ఫీల్డ్ ఇప్పటికే ఉంది + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today ఈరోజు diff --git a/crates/ui-egui/src/i18n/th.tsv b/crates/ui-egui/src/i18n/th.tsv index e56770757..7efff8485 100644 --- a/crates/ui-egui/src/i18n/th.tsv +++ b/crates/ui-egui/src/i18n/th.tsv @@ -2681,3 +2681,6 @@ key Space Space Page properties คุณสมบัติหน้า The password must have at least 8 characters. รหัสผ่านต้องมีอย่างน้อย 8 ตัวอักษร theme Appearance ลักษณะที่ปรากฏ + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today วันนี้ diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index c292b8863..1dce9cf4c 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2511,3 +2511,6 @@ animation effects Off Kapalı field names can't contain a period: {name} alan adları nokta içeremez: {name} two cells would both be named {name} iki hücrenin de adı {name} olurdu a field named {name} already exists {name} adlı bir alan zaten var + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today Bugün diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index cc8f45765..706a5d6c2 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2537,3 +2537,6 @@ animation effects Off Вимкнено field names can't contain a period: {name} імена полів не можуть містити крапку: {name} two cells would both be named {name} дві клітинки мали б однакове ім'я {name} a field named {name} already exists поле з іменем {name} уже існує + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today Сьогодні diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 87abe5129..d2ac75ebb 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2780,3 +2780,6 @@ key Space Space a table has 1 to {max} columns một bảng có từ 1 đến {max} cột the first row number can be at most {max} số hàng đầu tiên tối đa là {max} the area, the spacing and the column widths must be numbers, the spacing 0 or more and each width more than 0 vùng, khoảng cách và độ rộng cột phải là số, khoảng cách từ 0 trở lên và mỗi độ rộng lớn hơn 0 + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today Hôm nay diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 594f68df8..975fce279 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2479,3 +2479,6 @@ animation effects Off 关 field names can't contain a period: {name} 字段名称不能包含句点:{name} two cells would both be named {name} 两个单元格的名称都会是 {name} a field named {name} already exists 已存在名为 {name} 的字段 + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today 今天 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index cb578ba26..78bcd62ca 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2465,3 +2465,6 @@ animation effects Off 關 field names can't contain a period: {name} 欄位名稱不能包含句點:{name} two cells would both be named {name} 兩個儲存格的名稱都會是 {name} a field named {name} already exists 已有名為 {name} 的欄位 + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today 今天 diff --git a/crates/ui-egui/src/prepare.rs b/crates/ui-egui/src/prepare.rs index f79ebf17f..f3913401a 100644 --- a/crates/ui-egui/src/prepare.rs +++ b/crates/ui-egui/src/prepare.rs @@ -560,7 +560,8 @@ pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, xf: &PageXform, egui::StrokeKind::Inside, ); // The name tag: white text on black, clipped to the field. - let label = f.name.rsplit('.').next().unwrap_or(&f.name); + // The full name, as Acrobat shows it: MASTER.first, not first (#2696). + let label = f.name.as_str(); let font = theme::regular((9.0 * zoom).clamp(7.0, 13.0)); let galley = painter.layout_no_wrap(label.to_string(), font, Color32::WHITE); let size = galley.size() + vec2(6.0, 2.0); @@ -740,6 +741,7 @@ impl crate::PdfCraftApp { self.session.get(id).map(|doc| doc.form.iter().filter(|x| x.name != name).map(|x| x.name.clone()).collect()).unwrap_or_default(); d.others = others.clone(); d.actions = self.session.get(id).map(|doc| doc.field_actions(name)).unwrap_or_default(); + d.chained = self.session.get(id).map(|doc| doc.field_action_chains(name)).unwrap_or_default(); if let Some(o) = d.original.as_mut() { o.others = others; o.actions = d.actions.clone(); @@ -852,6 +854,11 @@ pub struct FieldDraft { /// Actions tab: each trigger's action, and the one being added. pub actions: Vec<(pdfcraft_engine::FieldTrigger, pdfcraft_engine::FieldAction)>, pub new_action: ActionDraft, + /// Actions tab: the trigger whose script `new_action` is editing (Edit), if any. + pub editing: Option, + /// Actions tab: how many actions the document chains after each trigger's first one. They are + /// kept when that first action is replaced. + pub chained: Vec<(pdfcraft_engine::FieldTrigger, usize)>, original: Box>, } @@ -887,7 +894,8 @@ impl FieldDraft { kind: f.kind, widget, tab: FieldTab::General, - name: f.name.rsplit('.').next().unwrap_or(&f.name).to_string(), + // The full name: a dotted name moves the field under those parent fields (#2696). + name: f.name.clone(), tooltip: f.tooltip.clone().unwrap_or_default(), caption: f.widgets.get(widget).and_then(|w| w.caption.clone()).unwrap_or_default(), read_only: f.read_only(), @@ -913,6 +921,8 @@ impl FieldDraft { others: Vec::new(), actions: Vec::new(), new_action: ActionDraft::default(), + editing: None, + chained: Vec::new(), original: Box::new(None), }; d.original = Box::new(Some(d.clone())); @@ -1533,21 +1543,27 @@ fn validate_tab(ui: &mut egui::Ui, d: &mut FieldDraft) { fn actions_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Tokens) { use pdfcraft_engine::FieldTrigger as T; - ui.label(egui::RichText::new(tl!("Add an Action")).font(theme::semibold(13.0))); + let editing = d.editing.is_some(); + ui.label(egui::RichText::new(if editing { tl!("Edit Action") } else { tl!("Add an Action") }).font(theme::semibold(13.0))); let a = &mut d.new_action; egui::Grid::new("field-actions-add").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { ui.label(tl!("Select Trigger:")); - egui::ComboBox::from_id_salt("action-trigger").selected_text(tl!(a.trigger.label())).width(220.0).show_ui(ui, |ui| { - for tr in T::ALL { - ui.selectable_value(&mut a.trigger, tr, tl!(tr.label())); - } + // Editing changes the script of one trigger's action: its trigger and kind stay. + ui.add_enabled_ui(!editing, |ui| { + egui::ComboBox::from_id_salt("action-trigger").selected_text(tl!(a.trigger.label())).width(220.0).show_ui(ui, |ui| { + for tr in T::ALL { + ui.selectable_value(&mut a.trigger, tr, tl!(tr.label())); + } + }); }); ui.end_row(); ui.label(tl!("Select Action:")); - egui::ComboBox::from_id_salt("action-kind").selected_text(tl!(ACTION_KINDS[a.kind])).width(220.0).show_ui(ui, |ui| { - for (i, k) in ACTION_KINDS.iter().enumerate() { - ui.selectable_value(&mut a.kind, i, tl!(k)); - } + ui.add_enabled_ui(!editing, |ui| { + egui::ComboBox::from_id_salt("action-kind").selected_text(tl!(ACTION_KINDS[a.kind])).width(220.0).show_ui(ui, |ui| { + for (i, k) in ACTION_KINDS.iter().enumerate() { + ui.selectable_value(&mut a.kind, i, tl!(k)); + } + }); }); ui.end_row(); let hint = match a.kind { @@ -1568,7 +1584,12 @@ fn actions_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Tokens) ui.end_row(); }); let mut error = None; - if ui.button(tl!("Add")).clicked() { + let (mut commit, mut discard) = (false, false); + ui.horizontal(|ui| { + commit = ui.button(if editing { tl!("Update") } else { tl!("Add") }).clicked(); + discard = editing && ui.button(tl!("Discard")).clicked(); + }); + if commit { match d.new_action.action() { Ok(act) => { let tr = d.new_action.trigger; @@ -1576,10 +1597,15 @@ fn actions_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Tokens) d.actions.push((tr, act)); d.actions.sort_by_key(|(x, _)| T::ALL.iter().position(|y| y == x)); d.new_action.text.clear(); + d.editing = None; } Err(e) => error = Some(e), } } + if discard { + d.new_action = ActionDraft::default(); + d.editing = None; + } if let Some(e) = error { ui.label(egui::RichText::new(tl!(&e)).small().color(t.text_muted)); } @@ -1590,19 +1616,40 @@ fn actions_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Tokens) if d.actions.is_empty() { ui.label(egui::RichText::new(tl!("No actions")).color(t.text_muted)); } - let mut remove = None; + let (mut remove, mut edit) = (None, None); for (i, (tr, act)) in d.actions.iter().enumerate() { ui.horizontal(|ui| { ui.label(egui::RichText::new(tl!(tr.label())).strong()); - ui.label(act.describe()); + let more = d.chained.iter().find(|(x, _)| x == tr).map_or(0, |(_, n)| *n); + if more == 0 { + ui.label(act.describe()); + } else { + let then = crate::i18n::trn(crate::i18n::current(), more as u64, "then {n} more action", "then {n} more actions"); + ui.label(format!("{}, {then}", act.describe())); + } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.push_id(i, |ui| ui.button(tl!("Delete"))).inner.clicked() { - remove = Some(i); - } + ui.push_id(i, |ui| { + if ui.button(tl!("Delete")).clicked() { + remove = Some(i); + } + if let pdfcraft_engine::FieldAction::JavaScript(js) = act + && ui.add_enabled(d.editing.is_none(), egui::Button::new(tl!("Edit"))).clicked() + { + edit = Some((*tr, js.clone())); + } + }); }); }); } + if let Some((trigger, text)) = edit { + d.new_action = ActionDraft { trigger, kind: 0, text }; + d.editing = Some(trigger); + } if let Some(i) = remove { + if d.actions.get(i).map(|(x, _)| Some(*x)) == Some(d.editing) { + d.new_action = ActionDraft::default(); + d.editing = None; + } d.actions.remove(i); } }); diff --git a/crates/ui-egui/tests/it/forms.rs b/crates/ui-egui/tests/it/forms.rs index 8e1a3b760..398bc1aeb 100644 --- a/crates/ui-egui/tests/it/forms.rs +++ b/crates/ui-egui/tests/it/forms.rs @@ -105,6 +105,75 @@ fn a_blank_tooltip_shows_nothing() { assert!(egui::Tooltip::seconds_since_last_tooltip(&h.ctx).is_infinite(), "no tooltip was shown"); } +/// A text field that doesn't scroll (`DoNotScroll`) takes no more than it shows, measured as its +/// appearance draws it: a long paste keeps what fits, typing into a full field adds nothing, and +/// a field without the flag takes it all. +#[test] +fn a_field_that_doesnt_scroll_stops_taking_text_when_full() { + let mut h = harness(); + set_city( + &mut h, + pdfcraft_engine::FieldProps { flags: vec![(pdfcraft_engine::field_flags::DO_NOT_SCROLL, true)], font_size: Some(12.0), ..Default::default() }, + ); + let text_box = |h: &Harness<'static, PdfCraftApp>| { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().field_text_box("city", 0).expect("the city field's text box") + }; + let typing = |h: &Harness<'static, PdfCraftApp>| h.state().views[0].forms.focus.as_ref().map(|f| f.text.clone()).unwrap_or_default(); + click_field(&mut h, "city", 0); + h.event(egui::Event::Text("W".repeat(80))); + h.run_steps(2); + let kept = typing(&h); + let tb = text_box(&h); + assert!(!kept.is_empty() && kept.len() < 80, "{} of 80 kept", kept.len()); + assert!(tb.fits(&kept) && !tb.fits(&format!("{kept}W")), "as much as fits: {}", kept.len()); + h.event(egui::Event::Text("WWW".into())); + h.run_steps(2); + assert_eq!(typing(&h), kept, "a full field takes no more"); + h.key_press(egui::Key::Tab); + h.run_steps(4); + assert_eq!(value(&h, "city"), [kept]); + // A field without the flag takes the whole paste. + click_field(&mut h, "name", 0); + h.event(egui::Event::Text("W".repeat(80))); + h.run_steps(2); + h.key_press(egui::Key::Tab); + h.run_steps(4); + assert_eq!(value(&h, "name"), ["W".repeat(80)]); +} + +/// A multi-line field that doesn't scroll, with an automatic size, takes lines until even the +/// smallest size it shrinks to can't show them all. +#[test] +fn a_multi_line_auto_sized_field_that_doesnt_scroll_stops_when_full() { + let mut h = harness(); + set_city( + &mut h, + pdfcraft_engine::FieldProps { + flags: vec![(pdfcraft_engine::field_flags::DO_NOT_SCROLL, true)], + multiline: Some(true), + font_size: Some(0.0), + ..Default::default() + }, + ); + let da = { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == "city").unwrap().da.clone() + }; + assert!(da.contains(" 0 Tf"), "an automatic size: {da}"); + click_field(&mut h, "city", 0); + h.event(egui::Event::Text("line\n".repeat(60))); + h.run_steps(2); + let kept = h.state().views[0].forms.focus.as_ref().map(|f| f.text.clone()).unwrap_or_default(); + let tb = { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().field_text_box("city", 0).expect("the city field's text box") + }; + let lines = kept.lines().count(); + assert!(lines > 1 && lines < 60, "{lines} of 60 lines kept"); + assert!(tb.fits(&kept) && !tb.fits(&format!("{kept}\nline")), "as many lines as fit at 4 pt: {lines}"); +} + #[test] fn typing_into_a_text_field_and_tabbing_on() { let mut h = harness(); @@ -451,6 +520,53 @@ fn an_undelimited_date_field_takes_a_typed_date_and_its_calendar_opens_on_it() { h.get_by_label("March 2025"); } +/// #4274: the calendar had no way to pick today; from another month the only way back was the +/// arrows. Today is picked in each field's own format, whichever month the calendar shows, and +/// is one edit as a clicked day is. +#[test] +fn the_calendar_picks_today_in_the_fields_format_from_any_month() { + use egui_kittest::kittest::Queryable; + let mut h = harness(); + // 2023-11-14 on the injected clock. + let s = h.state_mut(); + s.session = std::mem::take(&mut s.session).with_clock(|| 1_700_000_000); + assert_eq!(h.state().session.today(), (2023, 11, 14)); + for (name, rect) in [("due", [50.0, 40.0, 200.0, 60.0]), ("start", [50.0, 80.0, 200.0, 100.0])] { + h.state_mut().apply_edit(pdfcraft_engine::Edit::AddField { page: 0, rect, kind: pdfcraft_engine::NewField::Date, name: Some(name.into()) }); + } + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::SetFieldProps { + name: "start".into(), + props: Box::new(pdfcraft_engine::FieldProps { + format: Some(pdfcraft_engine::form_scripts::Format::Date("yyyy-mm-dd".into())), + ..Default::default() + }), + })); + h.run_steps(4); + // Two months on, and two months back: Today still picks the 14th of November. + for (name, arrow, shown, want) in [("due", "›", "January 2024", "11/14/2023"), ("start", "‹", "September 2023", "2023-11-14")] { + click_field(&mut h, name, 0); + h.get_by_label("November 2023"); + for _ in 0..2 { + h.get_by_label(arrow).click(); + h.run_steps(2); + } + h.get_by_label(shown); + if let Ok(dir) = std::env::var("PDFCRAFT_SHOTS") { + h.render().unwrap().save(format!("{dir}/calendar-today-{name}.png")).unwrap(); + } + h.get_by_label("Today").click(); + h.run_steps(4); + assert_eq!(value(&h, name), [want], "today in {name}'s format"); + assert!(h.state().views[0].forms.focus.is_none(), "picking closes the field, as a clicked day does"); + assert!(h.query_by_label("Today").is_none(), "and its calendar"); + } + // One edit each, as a clicked day is. + h.state_mut().undo(); + h.run_steps(2); + assert_eq!(value(&h, "start"), Vec::::new()); + assert_eq!(value(&h, "due"), ["11/14/2023"]); +} + #[test] fn lithuanian_calendar_localizes_months_and_weekdays_without_changing_field_values() { use egui_kittest::kittest::Queryable; diff --git a/crates/ui-egui/tests/it/prepare.rs b/crates/ui-egui/tests/it/prepare.rs index 106d2d539..13aa5b981 100644 --- a/crates/ui-egui/tests/it/prepare.rs +++ b/crates/ui-egui/tests/it/prepare.rs @@ -9,11 +9,15 @@ use pdfcraft_engine::field_table::TableColumn; use pdfcraft_ui_egui::{Dialog, PdfCraftApp, QuickTool}; fn harness() -> Harness<'static, PdfCraftApp> { - let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|cc| { + harness_with("form.pdf", include_bytes!("../data/form.pdf").to_vec()) +} + +fn harness_with(name: &'static str, bytes: Vec) -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |cc| { cc.egui_ctx.options_mut(|options| options.warn_on_id_clash = true); let mut app = PdfCraftApp::new(); app.set_option("language", "en").unwrap(); - app.open_bytes("form.pdf", None, include_bytes!("../data/form.pdf").to_vec()).unwrap(); + app.open_bytes(name, None, bytes).unwrap(); app.set_option("zoom", "150").unwrap(); app }); @@ -712,6 +716,134 @@ fn actions_tab_adds_and_removes_actions() { ); } +#[test] +fn actions_tab_edits_the_script_of_an_existing_action() { + use pdfcraft_engine::{FieldAction, FieldTrigger}; + let mut h = harness(); + h.state_mut().execute("form.prepare"); + h.run_steps(2); + let script = "if (event.value > 10) {\n app.alert('Too many');\n}"; + let actions = |h: &Harness<'static, PdfCraftApp>| { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().field_actions("city") + }; + let open = |h: &mut Harness<'static, PdfCraftApp>| { + h.state_mut().open_field_props("city", 0); + h.run_steps(2); + h.get_by_label("Actions").click(); + h.run_steps(2); + }; + // A script on Mouse Up and a link on Blur, saved with the field. + open(&mut h); + for (trigger, kind, text) in [(FieldTrigger::MouseUp, 0, script), (FieldTrigger::OnBlur, 1, "https://example.org")] { + let d = h.state_mut().field_props.as_mut().unwrap(); + d.new_action = pdfcraft_ui_egui::prepare::ActionDraft { trigger, kind, text: text.into() }; + h.run_steps(3); + h.get_by_label("Add").click(); + h.run_steps(2); + } + h.get_by_label("OK").click(); + h.run_steps(3); + let saved = actions(&h); + // Edit shows the script exactly as stored; Discard leaves everything as it was. + open(&mut h); + h.get_all_by_label("Edit").last().unwrap().click(); + h.run_steps(2); + h.get_by_label("Edit Action"); + assert_eq!(h.state().field_props.as_ref().unwrap().new_action.text, script); + h.state_mut().field_props.as_mut().unwrap().new_action.text = "app.alert('changed');".into(); + h.run_steps(2); + h.get_by_label("Discard").click(); + h.run_steps(2); + h.get_by_label("Add an Action"); + h.get_by_label("OK").click(); + h.run_steps(3); + assert_eq!(actions(&h), saved); + // Update replaces that script only: same trigger, the link untouched. + open(&mut h); + h.get_all_by_label("Edit").last().unwrap().click(); + h.run_steps(2); + h.state_mut().field_props.as_mut().unwrap().new_action.text = "app.alert('changed');".into(); + h.run_steps(2); + h.get_by_label("Update").click(); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + assert_eq!( + actions(&h), + [ + (FieldTrigger::MouseUp, FieldAction::JavaScript("app.alert('changed');".into())), + (FieldTrigger::OnBlur, FieldAction::Uri("https://example.org".into())) + ] + ); +} + +/// One page with a push button "go": Mouse Up runs a script with a link chained after it, On Blur +/// runs another script. +fn chained_action_fixture() -> Vec { + let objs = [ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] /DA (/Helv 0 Tf 0 g) >> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + "<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (go) /Rect [20 300 100 320] /P 3 0 R /A 5 0 R /AA << /Bl << /S /JavaScript /JS (this.dirty = true;) >> >> >>", + "<< /S /JavaScript /JS (app.alert\\(1\\);) /Next 6 0 R >>", + "<< /S /URI /URI (https://example.org) >>", + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +#[test] +fn actions_tab_shows_a_chain_and_keeps_it_when_the_first_action_is_replaced() { + use egui_kittest::kittest::NodeT; + use pdfcraft_engine::FieldTrigger; + let mut h = harness_with("chain.pdf", chained_action_fixture()); + h.state_mut().execute("form.prepare"); + h.run_steps(2); + let open = |h: &mut Harness<'static, PdfCraftApp>| { + h.state_mut().open_field_props("go", 0); + h.run_steps(2); + h.get_by_label("Actions").click(); + h.run_steps(2); + }; + open(&mut h); + h.get_by_label("Run a JavaScript, then 1 more action"); + // The first Edit is the menu bar's; the rows' follow (Mouse Up, On Blur). + assert_eq!(h.get_all_by_label("Edit").count(), 3); + h.get_all_by_label("Edit").nth(1).unwrap().click(); + h.run_steps(2); + assert_eq!(h.state().field_props.as_ref().unwrap().editing, Some(FieldTrigger::MouseUp)); + // While one script is open, no other Edit can overwrite it. + assert!(h.get_all_by_label("Edit").skip(1).all(|n| n.accesskit_node().is_disabled())); + h.get_by_label("Discard").click(); + h.run_steps(2); + assert!(h.get_all_by_label("Edit").skip(1).all(|n| !n.accesskit_node().is_disabled())); + // Replace the Mouse Up script with a link: the chained link stays after it. + { + let d = h.state_mut().field_props.as_mut().unwrap(); + d.actions.retain(|(t, _)| *t != FieldTrigger::MouseUp); + d.new_action = pdfcraft_ui_egui::prepare::ActionDraft { trigger: FieldTrigger::MouseUp, kind: 1, text: "https://example.com".into() }; + } + h.run_steps(3); + h.get_by_label("Add").click(); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + open(&mut h); + h.get_by_label("Open a web link: https://example.com, then 1 more action"); +} + /// Text field table: pick the tool and drag out an area in the empty lower part of page 1, 200 by /// 120 points from (40, 240) as displayed. The table's window opens for it. fn draw_table(h: &mut Harness<'static, PdfCraftApp>) { diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs index 23342168e..8efee1029 100644 --- a/crates/xfa/src/layout.rs +++ b/crates/xfa/src/layout.rs @@ -126,6 +126,8 @@ pub struct Widget { pub action: Option, /// Check boxes: the on and off values of the data; radio buttons: the on value. pub items: Vec, + /// A text field that doesn't scroll: it takes no more text than it shows (`DoNotScroll`). + pub no_scroll: bool, } #[derive(Clone, Debug, Default, PartialEq)] @@ -1804,6 +1806,7 @@ impl Layouter<'_> { default: template_default, action, items, + no_scroll: f.no_scroll, }))) } } diff --git a/crates/xfa/src/model.rs b/crates/xfa/src/model.rs index b60180b7f..793950bfb 100644 --- a/crates/xfa/src/model.rs +++ b/crates/xfa/src/model.rs @@ -486,6 +486,9 @@ pub struct Field { pub check_size: Option, pub check_round: bool, pub multiline: bool, + /// The field doesn't scroll in the direction its text runs out (`hScrollPolicy="off"` for one + /// line, `vScrollPolicy="off"` for several): it takes no more text than it shows. + pub no_scroll: bool, pub caption: Option, pub value: Value, /// Check buttons: the on value then the off value; choice lists: the items as shown. diff --git a/crates/xfa/src/parse.rs b/crates/xfa/src/parse.rs index fc9ad1a24..500465168 100644 --- a/crates/xfa/src/parse.rs +++ b/crates/xfa/src/parse.rs @@ -478,6 +478,7 @@ fn parse_field(n: XmlNode, warnings: &mut Vec) -> Field { } _ => ChoiceList::default(), }; + let multiline = ui_el.is_some_and(|e| e.attribute("multiLine").is_some_and(|m| m == "1" || m == "true")); let validate_el = child(n, "validate"); let validate_message = validate_el .and_then(|v| child(v, "message")) @@ -492,7 +493,8 @@ fn parse_field(n: XmlNode, warnings: &mut Vec) -> Field { ui_margin: parse_margin(ui_el.and_then(|e| child(e, "margin"))), check_size: ui_el.and_then(|e| attr_measure(e, "size")).filter(|s| *s > 0.0), check_round: ui_el.is_some_and(|e| e.attribute("shape") == Some("round")), - multiline: ui_el.is_some_and(|e| e.attribute("multiLine").is_some_and(|m| m == "1" || m == "true")), + multiline, + no_scroll: ui_el.is_some_and(|e| e.attribute(if multiline { "vScrollPolicy" } else { "hScrollPolicy" }) == Some("off")), caption: parse_caption(child(n, "caption"), warnings), value: parse_value(value_el, warnings), items, diff --git a/crates/xfa/src/pdf.rs b/crates/xfa/src/pdf.rs index 8e0c47d88..fa1f1db35 100644 --- a/crates/xfa/src/pdf.rs +++ b/crates/xfa/src/pdf.rs @@ -244,6 +244,8 @@ impl Emitter<'_> { format!("/{} {} Tf {} rg", face.resource_name(), n(face.size), rgb(face.color)).into_bytes() } + /// Write laid-out widget `w` on `page` as an AcroForm widget annotation (and its field), with + /// its border, background and kind, and return the annotation's object. fn widget(&mut self, w: &Widget, page: &Page, page_ref: ObjRef) -> Result, XfaError> { let r = w.rect; let (x0, y0, x1, y1) = (r.x, page.height - r.bottom(), r.right(), page.height - r.y); @@ -285,6 +287,10 @@ impl Emitter<'_> { if w.kind == WidgetKind::Password { ff |= 1 << 13; } + // DoNotScroll (ISO 32000-2 Table 228): the field takes no more than it shows. + if w.no_scroll { + ff |= 1 << 23; + } if let Some(m) = w.max_chars { d.set(b"MaxLen".to_vec(), Object::Int(m.min(100_000) as i64)); } @@ -769,6 +775,13 @@ pub fn write_form(doc: &mut Document, form: &Form) -> Result z.set(b"BaseFont".to_vec(), Object::name("ZapfDingbats")); dr_fonts.set(b"ZaDb".to_vec(), Object::Dict(z)); } + // Each font its own object: Acrobat shows nothing in a text field it redraws after typing + // when the field's /DR font is a dictionary written inline (#3441). + let inline: Vec<(Vec, Dict)> = dr_fonts.iter().filter_map(|(k, v)| Some((k.clone(), v.as_dict()?.clone()))).collect(); + for (name, font) in inline { + let r = doc.add(Object::Dict(font)); + dr_fonts.set(name, Object::Ref(r)); + } dr.set(b"Font".to_vec(), Object::Dict(dr_fonts)); acro.set(b"DR".to_vec(), Object::Dict(dr)); if !acro.contains(b"DA") { diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 411529b17..6231cbf78 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use pdfcraft_cos::{Document, Object, SaveOptions, write_incremental}; +use pdfcraft_cos::{Dict, Document, Object, SaveOptions, write_incremental}; use super::*; use crate::data::{ @@ -324,6 +324,12 @@ fn rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields() { let dr = doc.resolve(acro.get(b"DR").unwrap()); let fonts = doc.resolve(dr.as_dict().unwrap().get(b"Font").unwrap()); assert!(fonts.as_dict().unwrap().contains(b"Cour") && fonts.as_dict().unwrap().contains(b"ZaDb")); + // Each one its own object: Acrobat shows nothing in a text field it redraws after typing + // when the /DR font is written inline (#3441). + for (name, font) in fonts.as_dict().unwrap().iter() { + let r = font.as_ref().unwrap_or_else(|| panic!("{} is an object", String::from_utf8_lossy(name))); + assert_eq!(doc.get(r).as_dict().and_then(|f| f.name(b"Type")), Some(&b"Font"[..])); + } // A field carries its SOM path for data round trips. let first = doc.resolve(&doc.resolve(acro.get(b"Fields").unwrap()).as_array().unwrap()[0]); assert!(first.as_dict().unwrap().contains(SOM_KEY)); @@ -350,6 +356,45 @@ fn form_text_the_standard_fonts_cannot_draw_is_reported() { assert!(!render_into(&mut doc).unwrap().warnings.iter().any(|w| w.contains("standard fonts"))); } +/// A field whose ui doesn't scroll in the direction its text runs out (`hScrollPolicy="off"` for +/// one line, `vScrollPolicy="off"` for several) is written with DoNotScroll (`/Ff` bit 24), so it +/// takes no more text than it shows; the other direction's policy, or the default, doesn't. +#[test] +fn fields_that_dont_scroll_are_written_with_do_not_scroll() { + let field = |name: &str, ui: &str| format!(r#"{ui}"#); + let xdp = format!( + r##" +"##, + field("line", r#""#), + field("lines", r#""#), + field("number", r#""#), + field("when", r#""#), + field("linesAcross", r#""#), + field("lineDown", r#""#), + field("free", r#""#), + ); + let mut doc = Document::open(Arc::new(shell(&xdp))).unwrap(); + render_into(&mut doc).unwrap(); + let catalog = doc.get(doc.root().unwrap()); + let acro = doc.resolve(catalog.as_dict().unwrap().get(b"AcroForm").unwrap()); + let fields = doc.resolve(acro.as_dict().unwrap().get(b"Fields").unwrap()).as_array().unwrap().clone(); + let mut no_scroll = std::collections::BTreeMap::new(); + for r in fields.iter().filter_map(Object::as_ref) { + let f = doc.get(r); + let d = f.as_dict().unwrap(); + let name = d.get(b"T").and_then(Object::as_string).map(|t| t.to_text()).unwrap_or_default(); + no_scroll.insert(name, d.int(b"Ff").unwrap_or(0) & (1 << 23) != 0); + } + let expected: std::collections::BTreeMap = + [("line", true), ("lines", true), ("number", true), ("when", true), ("linesAcross", false), ("lineDown", false), ("free", false)] + .into_iter() + .map(|(n, v)| (n.to_string(), v)) + .collect(); + assert_eq!(no_scroll, expected); +} + #[test] fn documents_without_xfa_are_not_touched() { let mut doc = Document::new_empty(); @@ -2832,3 +2877,39 @@ fn long_names_repeated_by_an_index_are_capped() { let data = build_data(&doc, &fields); assert!(data.len() < 2_000_000, "{} bytes of data built", data.len()); } + +/// #3441: a font the form already holds as an object stays that object when the XFA layout +/// writes its fields' fonts into `/DR`. +#[test] +fn rendering_keeps_the_forms_own_font_objects() { + let mut doc = Document::open(Arc::new(shell(&template(2)))).unwrap(); + let mut font = Dict::new(); + font.set(b"Type".to_vec(), Object::name("Font")); + font.set(b"Subtype".to_vec(), Object::name("Type1")); + font.set(b"BaseFont".to_vec(), Object::name("Symbol")); + let mine = doc.add(Object::Dict(font)); + let mut fonts = Dict::new(); + fonts.set(b"Mine".to_vec(), Object::Ref(mine)); + let mut dr = Dict::new(); + dr.set(b"Font".to_vec(), Object::Dict(fonts)); + let root = doc.root().unwrap(); + match doc.get(root).as_dict().unwrap().get(b"AcroForm").cloned().unwrap() { + Object::Ref(af) => doc.update_dict(af, |d| d.set(b"DR".to_vec(), Object::Dict(dr))).unwrap(), + _ => doc + .update_dict(root, |c| { + if let Some(Object::Dict(af)) = c.get(b"AcroForm").cloned() { + let mut af = af; + af.set(b"DR".to_vec(), Object::Dict(dr)); + c.set(b"AcroForm".to_vec(), Object::Dict(af)); + } + }) + .unwrap(), + } + render_into(&mut doc).unwrap(); + let catalog = doc.get(root); + let acro = doc.resolve(catalog.as_dict().unwrap().get(b"AcroForm").unwrap()); + let dr = doc.resolve(acro.as_dict().unwrap().get(b"DR").unwrap()); + let fonts = doc.resolve(dr.as_dict().unwrap().get(b"Font").unwrap()); + assert_eq!(fonts.as_dict().unwrap().reference(b"Mine"), Some(mine), "the form's own font object"); + assert!(fonts.as_dict().unwrap().reference(b"Helv").is_some(), "and the layout's fonts as objects"); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 1c2f060db..37e0ff6d3 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", "crates/engine/src/tests.rs::cli_display_adds_missing_appearances_without_changing_the_source", "crates/engine/src/tests.rs::fields_left_to_the_viewer_are_displayed_from_their_values", "crates/forms/src/tests.rs::need_appearances_draws_every_text_and_choice_field_in_the_display_copy", "crates/forms/src/tests.rs::the_field_appearance_cache_sees_every_input"] -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; text and choice fields are drawn from their values when the form sets /NeedAppearances (every one) or a widget has no /N (#3470), also display-only and cached per widget (a field edit on a 1000-field /NeedAppearances form takes ~13 ms against ~7 ms on a form that needs none) (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", "crates/engine/src/tests.rs::fields_left_to_the_viewer_are_displayed_from_their_values", "crates/forms/src/tests.rs::need_appearances_draws_every_text_and_choice_field_in_the_display_copy", "crates/forms/src/tests.rs::the_field_appearance_cache_sees_every_input", "crates/engine/src/tests.rs::buttons_and_combs_left_to_the_viewer_are_displayed", "crates/forms/src/tests.rs::check_boxes_without_an_appearance_show_their_caption_symbol", "crates/forms/src/tests.rs::push_buttons_without_an_appearance_show_their_caption_and_frame"] +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; text and choice fields are drawn from their values when the form sets /NeedAppearances (every one) or a widget has no /N (#3470), also display-only and cached per widget (a field edit on a 1000-field /NeedAppearances form takes ~13 ms against ~7 ms on a form that needs none) (a read-only document still gets the renderer's Highlight fallback only); check boxes and radio buttons with no appearance for their /AS are drawn display-only from /MK (background, border, and when on the /MK /CA ZapfDingbats symbol in the /DA colour, a check or a dot by default), and push buttons with none from /MK and /BS with their caption; Link borders without /AP and printing are not covered yet" [[feature]] id = "view.render-error-isolation" @@ -4439,8 +4439,8 @@ milestone = "M2" status = "shipped" commands = [] tools = [] -evidence = ["crates/render/src/raster.rs::widget_appearance_states", "crates/engine/src/tests.rs::validated_signatures_without_layers_keep_their_appearance", "crates/engine/src/tests.rs::real_layered_signatures_keep_their_original_appearance", "crates/engine/src/tests.rs::real_layered_signatures_round_trip_behind_a_user_password", "crates/engine/src/tests.rs::a_metadata_edit_keeps_the_original_signature_appearance", "crates/engine/src/tests.rs::saving_after_an_edit_keeps_the_baked_appearance", "crates/engine/src/tests.rs::chamfered_signature_borders_survive_trust_exports_printing_and_save", "crates/engine/src/tests.rs::mixed_signature_caption_details_survive_trust_exports_printing_and_save"] -notes = "widgets retain their original artwork. Signature validation is a separate viewer page overlay; it is never written into document streams or included in image/text exports, vector printing or Print as Image. Saves retain signed appearance bytes. Generic in-place appearance rewriting is deferred: no supported producer template is positively identified, and caption text plus drawing uniqueness does not establish an icon role. Baked Not Verified captions remain unchanged; issue #79 is not fully resolved." +evidence = ["crates/render/src/raster.rs::widget_appearance_states", "crates/engine/src/tests.rs::validated_signatures_without_layers_keep_their_appearance", "crates/engine/src/tests.rs::real_layered_signatures_keep_their_original_appearance", "crates/engine/src/tests.rs::real_layered_signatures_round_trip_behind_a_user_password", "crates/engine/src/tests.rs::a_metadata_edit_keeps_the_original_signature_appearance", "crates/engine/src/tests.rs::saving_after_an_edit_keeps_the_baked_appearance", "crates/engine/src/tests.rs::chamfered_signature_borders_survive_trust_exports_printing_and_save", "crates/engine/src/tests.rs::mixed_signature_caption_details_survive_trust_exports_printing_and_save", "crates/engine/src/tests.rs::buttons_and_combs_left_to_the_viewer_are_displayed", "crates/forms/src/tests.rs::button_states_without_an_appearance"] +notes = "widgets retain their original artwork. Signature validation is a separate viewer page overlay; it is never written into document streams or included in image/text exports, vector printing or Print as Image. Saves retain signed appearance bytes. Generic in-place appearance rewriting is deferred: no supported producer template is positively identified, and caption text plus drawing uniqueness does not establish an icon role. Baked Not Verified captions remain unchanged; issue #79 is not fully resolved. Check boxes and radio buttons with no appearance for their state are shown (display-only) with their /MK border and background and, when on, the /MK /CA ZapfDingbats symbol in the /DA colour (a check or a dot by default); push buttons with none show their /MK frame and caption; files that carry their own appearances are not redrawn." [[feature]] id = "form.fill-text" @@ -4451,8 +4451,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", "crates/forms/src/tests.rs::a_text_field_says_what_fits_it_as_its_appearance_draws_it", "crates/forms/src/tests.rs::an_edit_to_a_full_field_keeps_what_fits", "crates/ui-egui/tests/it/forms.rs::a_field_that_doesnt_scroll_stops_taking_text_when_full", "crates/ui-egui/tests/it/forms.rs::a_multi_line_auto_sized_field_that_doesnt_scroll_stops_when_full", "crates/engine/src/tests.rs::an_xfa_field_that_doesnt_scroll_opens_with_do_not_scroll_and_a_text_box", "crates/xfa/src/tests.rs::fields_that_dont_scroll_are_written_with_do_not_scroll", "crates/forms/src/tests.rs::comb_fields_show_their_cells"] +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; a field that doesn't scroll (DoNotScroll, ISO 32000-2 Table 228, and XFA's hScrollPolicy/vScrollPolicy=\"off\", written as it) takes no more text than its appearance shows, measured in the field's own font, padding and size (the smallest automatic size, 4 pt, for 0 Tf): typing into a full field adds nothing, and a paste or a replaced selection keeps what fits (form_fill isn't interactive and still takes any value); 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. A comb field with a border colour shows its MaxLen cells as lines in that colour, in the appearances PdfCraft saves and in display-only ones (up to 500 cells)." [[feature]] id = "form.fill-checkbox-radio" @@ -4655,8 +4655,8 @@ milestone = "M6" status = "shipped" commands = [] tools = ["form_fill"] -evidence = ["crates/ui-egui/tests/it/forms.rs::date_fields_offer_a_calendar", "crates/ui-egui/tests/it/forms.rs::the_calendar_follows_a_complete_date_typed_while_it_is_open"] -notes = "a month calendar under date fields; the pick is written in the field's own date format; it opens on the field's date and moves to a whole date typed while it is open, staying on the month its arrows turned to otherwise" +evidence = ["crates/ui-egui/tests/it/forms.rs::date_fields_offer_a_calendar", "crates/ui-egui/tests/it/forms.rs::the_calendar_follows_a_complete_date_typed_while_it_is_open", "crates/ui-egui/tests/it/forms.rs::the_calendar_picks_today_in_the_fields_format_from_any_month"] +notes = "a month calendar under date fields; the pick is written in the field's own date format; it opens on the field's date and moves to a whole date typed while it is open, staying on the month its arrows turned to otherwise; a Today button under the days picks today from whichever month it shows, as a click on its day would (#4274)" [[feature]] id = "form.auto-complete" @@ -4967,8 +4967,8 @@ milestone = "M6" status = "shipped" commands = ["form.prepare"] tools = ["form_actions", "form_set_actions"] -evidence = ["crates/forms/src/tests.rs::field_actions_round_trip_on_every_trigger", "crates/automation/tests/it/automation.rs::field_actions_through_tools", "crates/ui-egui/tests/it/prepare.rs::actions_tab_adds_and_removes_actions"] -notes = "Six triggers; Run a JavaScript, Open a web link, Reset a form, Execute a menu item, Go to a page view, Show/hide a field, Submit a form. Other action types are kept." +evidence = ["crates/forms/src/tests.rs::field_actions_round_trip_on_every_trigger", "crates/automation/tests/it/automation.rs::field_actions_through_tools", "crates/ui-egui/tests/it/prepare.rs::actions_tab_adds_and_removes_actions", "crates/ui-egui/tests/it/prepare.rs::actions_tab_edits_the_script_of_an_existing_action", "crates/forms/src/tests.rs::editing_one_script_keeps_the_other_actions_and_the_chain", "crates/forms/src/tests.rs::replacing_a_chained_action_with_another_type_keeps_the_chain", "crates/ui-egui/tests/it/prepare.rs::actions_tab_shows_a_chain_and_keeps_it_when_the_first_action_is_replaced"] +notes = "Six triggers; Run a JavaScript, Open a web link, Reset a form, Execute a menu item, Go to a page view, Show/hide a field, Submit a form. Other action types are kept. Edit opens the script of an existing Run a JavaScript action; unchanged actions are kept as they are, and replacing a trigger's first action keeps the actions chained after it, which the row counts. Edit for the other action types, and editing the chained actions, are missing." [[feature]] id = "form.props-format" @@ -5080,8 +5080,8 @@ milestone = "M6" status = "shipped" commands = ["form.prepare"] tools = ["form_set_props"] -evidence = ["crates/forms/src/tests.rs::locked_fields_only_take_unlocking", "crates/ui-egui/tests/it/prepare.rs::preview_fills_the_form_and_locked_fields_keep_their_properties", "crates/forms/src/tests.rs::renaming_a_kid_is_checked_against_the_full_name_it_takes", "crates/forms/src/tests.rs::renaming_a_top_level_field_is_still_checked_against_top_level_names"] -notes = "rename in Field Properties ▸ General; Locked sets the widgets' annotation Locked flag (/F bit 8) and greys out the properties until unlocked; values can still be filled in" +evidence = ["crates/forms/src/tests.rs::locked_fields_only_take_unlocking", "crates/ui-egui/tests/it/prepare.rs::preview_fills_the_form_and_locked_fields_keep_their_properties", "crates/forms/src/tests.rs::renaming_a_kid_is_checked_against_the_full_name_it_takes", "crates/forms/src/tests.rs::renaming_a_top_level_field_is_still_checked_against_top_level_names", "crates/forms/src/tests.rs::dotted_field_names_are_made_with_parent_fields"] +notes = "rename in Field Properties ▸ General, which shows the full name; a dotted name (MASTER.first) is a full name and makes or joins parent fields, as Acrobat does, moving a renamed field with what it inherited (#2696); Prepare a form's table tool still refuses a period in the cell names it makes; a plain name renames the field within its parent, so the GUI cannot yet move a nested field to the top level; Locked sets the widgets' annotation Locked flag (/F bit 8) and greys out the properties until unlocked; values can still be filled in" [[feature]] id = "form.tab-order"