diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 47a325210..272604698 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -1297,7 +1297,9 @@ impl Automation { }) .flatten(); let header_version = bytes.strip_prefix(b"%PDF-").and_then(|b| b.get(..3)).and_then(valid_version); - let pdf_version = catalog_version.as_deref().or(header_version); + // Viewers read the later of the two (object streams raise the header past a lower catalog + // entry). Both are "d.d", so text order is version order. + let pdf_version = catalog_version.as_deref().into_iter().chain(header_version).max(); let o = &r.optimize; Ok(json!({ "path": path.to_string_lossy(), diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index d1f1b80db..7b44d87b7 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -5942,6 +5942,26 @@ fn optimizing_through_tools() { assert!(matches!(a.call("doc_optimize", &json!({ "doc": doc, "path": "x.pdf", "discard": ["everything"] })), Err(ToolError::InvalidArgs(_)))); } +/// #3227 review: retaining the version, a catalog `/Version` below the 1.5 an object stream +/// needs is not what viewers read: the header is raised, and the later of the two is reported. +#[test] +fn retained_pdf_version_reports_the_header_raised_for_object_streams() { + let dir = workdir("optimize-retain-version"); + let mut a = auto(&dir); + let blank = ok(&mut a, "doc_create", json!({ "from": "blank", "width": 300, "height": 400 }))["doc"].as_u64().unwrap(); + ok(&mut a, "doc_optimize", json!({ "doc": blank, "path": "v14.pdf", "compatibility": "1.4" })); + let mut cos = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("v14.pdf")).unwrap())).unwrap(); + let root = cos.root().unwrap(); + cos.update_dict(root, |c| c.set(b"Version".to_vec(), pdfcraft_cos::Object::name("1.4"))).unwrap(); + let bytes = pdfcraft_cos::write_full(&cos, &pdfcraft_cos::SaveOptions { object_streams: false, ..Default::default() }).unwrap(); + std::fs::write(dir.join("cat14.pdf"), &bytes).unwrap(); + let doc = ok(&mut a, "doc_open", json!({ "path": "cat14.pdf" }))["doc"].as_u64().unwrap(); + let r = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "out.pdf", "compatibility": "retain", "object_streams": true })); + let out = std::fs::read(dir.join("out.pdf")).unwrap(); + assert!(out.starts_with(b"%PDF-1.5\n") && out.windows(7).any(|w| w == b"/ObjStm"), "{r}"); + assert_eq!(r["pdf_version"].as_str(), Some("1.5"), "{r}"); +} + /// `data` as LZW with one 9-bit literal code per byte (clear-table first, EOD last): valid for /// fewer than 250 bytes, before the code width grows. fn lzw_literals(data: &[u8]) -> Vec { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ebb53d339..4fb114d1a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -4457,8 +4457,11 @@ impl Session { // An encrypted file needs the password it is protected with now (the owner // password when known, so saving never downgrades this session's rights). let keys = editor.keys.clone(); + // The saved file carries no Password-field values (#3943); the open document keeps them. + let typed = pdfcraft_forms::password_values(&editor.cos); editor.cos = pdfcraft_cos::Document::open_with_password(bytes.clone(), keys.reopen.as_deref()).map_err(|e| EditError::Reopen(e.to_string()))?; + pdfcraft_forms::restore_password_values(&mut editor.cos, &typed).map_err(|e| EditError::Reopen(e.to_string()))?; editor.saved = bytes; editor.saved_keys = keys; editor.unsaved_file = false; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9fe93a048..ce80c09f8 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2219,6 +2219,22 @@ fn password_field_values_are_kept_in_memory_but_written_to_no_file() { assert!(t.get(reopened).unwrap().form.iter().find(|f| f.name == "name").unwrap().value.iter().all(|v| v.is_empty())); } +/// #3943 review: recording a save rebases editing on the saved file, which carries no Password +/// value; the typed value stays in the open document all the same, and a later save still omits it. +#[test] +fn a_typed_password_value_survives_save_and_mark_saved() { + let fixture = String::from_utf8(form_fixture()).unwrap().replace("/T (name)", "/T (name) /Ff 8192"); + 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 saved = s.save_bytes(id).unwrap(); + s.mark_saved(id, saved, Some("saved.pdf".into())).unwrap(); + let value = |s: &Session| s.get(id).unwrap().form.iter().find(|f| f.name == "name").unwrap().value.clone(); + assert_eq!(value(&s), ["hunter2"], "the open document keeps the value after Save"); + assert!(!s.get(id).unwrap().dirty, "restoring it is not an unsaved change"); + assert!(!appears_in_pdf(&s.save_bytes(id).unwrap(), "hunter2"), "the next save still omits it"); +} + /// #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] diff --git a/crates/forms/src/appearance.rs b/crates/forms/src/appearance.rs index 15584ec77..3172fd7d6 100644 --- a/crates/forms/src/appearance.rs +++ b/crates/forms/src/appearance.rs @@ -130,8 +130,11 @@ impl TextBox { // 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 + // As the appearance draws it: the first baseline is 0.85 em below the padded top and + // a line is drawn while its descent (0.207 em) stays inside the border, so the + // padding below the last line is not needed: `inner_h` plus 2 pt. + let above = wrap_with(&shown, size, self.inner_w, width_of).len().saturating_sub(1) as f64; + above * size * 1.15 + 1.057 * size <= self.inner_h + 2.0 + 0.01 } 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(); diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index 17d67e473..75c3dbee0 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -1345,6 +1345,24 @@ pub fn has_password_values(doc: &Document) -> bool { !password_fields(doc).is_empty() } +/// The typed values of the text fields with the Password flag, as (name, text) pairs, for +/// [`restore_password_values`] after the document was rebased on a file written without them. +pub fn password_values(doc: &Document) -> Vec<(String, String)> { + password_fields(doc).into_iter().filter_map(|f| f.value.first().map(|v| (f.name.clone(), v.clone()))).collect() +} + +/// Put values from [`password_values`] back into the open document, in memory only. A field +/// that is gone or read-only keeps what it has. +pub fn restore_password_values(doc: &mut Document, values: &[(String, String)]) -> Result<(), FormError> { + for (name, v) in values { + match set_value(doc, name, &FieldValue::Text(v.clone())) { + Ok(()) | Err(FormError::NoSuchField(_) | FormError::ReadOnly(_)) => {} + Err(e) => return Err(e), + } + } + Ok(()) +} + /// `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]; diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index b36657293..9af1bc573 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -439,6 +439,38 @@ fn a_text_field_says_what_fits_it_as_its_appearance_draws_it() { assert!(tb("comb").fits(&"W".repeat(50))); } +/// #4339 review: a multi-line field is full when its appearance stops drawing lines (a line is +/// drawn while its descent stays inside the border), not when `lines x size x 1.15` outgrows the +/// padded height: a 16 pt field at 12 pt draws one line. +#[test] +fn a_short_multiline_field_takes_the_one_line_it_draws() { + let tx = |name: &str, extra: &str, h: i32| { + format!( + "<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /DA (/Helv 12 Tf 0 g) /Ff {}{extra} /Rect [50 {} 150 700] /P 3 0 R >>", + flags::MULTILINE, + 700 - 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 [6 0 R 7 0 R] >>".into(), + "<< /Fields [6 0 R 7 0 R] /DA (/Helv 12 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + tx("plain", "", 16), + tx("boxed", " /MK << /BC [0] >>", 16), + ]; + 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]) + }; + assert!(tb("plain").fits("one line") && !tb("plain").fits("one\ntwo")); + // A 1 pt border takes 1 pt from each side: the line's descent then falls outside it. + assert!(!tb("boxed").fits("one line")); +} + /// 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] diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index 7f463a48b..4db899c1c 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -704,7 +704,7 @@ pub(crate) fn menu_item(name: &str) -> Option { fn exec_menu_item(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let item = text(&arg(args, 0), ctx)?; if let Some(r) = menu_item(&item) { - host(ctx)?.borrow_mut().requests.push(r); + host(ctx)?.borrow_mut().request(r); } Ok(JsValue::undefined()) } diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index 06d699bcb..3d3c4ab52 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -254,6 +254,13 @@ fn kept_bytes(o: &Outcome) -> usize { o.console.iter().chain(&o.alerts).map(String::len).sum::() + o.requests.iter().map(req).sum::() } +#[test] +fn exec_menu_item_requests_are_capped_like_other_requests() { + let o = go("for (var i = 0; i < 12000; i++) app.execMenuItem('Print');", &Event::field("Mouse Up", "agree", "")); + assert_eq!(o.error, None); + assert_eq!(o.requests.len(), crate::xfa::MAX_EFFECTS); +} + #[test] fn console_alerts_and_requests_are_capped_in_count_and_bytes() { let o = go( diff --git a/crates/ui-egui/src/js_ui.rs b/crates/ui-egui/src/js_ui.rs index 0079423f6..49611293a 100644 --- a/crates/ui-egui/src/js_ui.rs +++ b/crates/ui-egui/src/js_ui.rs @@ -77,6 +77,9 @@ impl PdfCraftApp { }; // Show/Hide: a panel already showing closes. self.right = (self.right != Some(panel)).then_some(panel); + if self.right.is_some() { + self.pending_panel_reveals.push(crate::docking::Panel::Inspector); + } } Request::Focus(_) | Request::Beep | Request::Reset(_) => {} } diff --git a/crates/ui-egui/tests/it/control.rs b/crates/ui-egui/tests/it/control.rs index f64485293..1ffd9a443 100644 --- a/crates/ui-egui/tests/it/control.rs +++ b/crates/ui-egui/tests/it/control.rs @@ -1599,6 +1599,22 @@ fn explicit_panel_requests_reveal_inactive_group_members() { h.get_by_label("Header and footer"); } +/// #3580 review: a panel a script opens (`app.execMenuItem("ShowHideBookmarks")`) is shown even +/// when the inspector is a background tab of a custom docking layout. +#[test] +fn a_script_opened_panel_reveals_an_inactive_inspector() { + let (mut h, c) = harness(); + ok(&mut h, &c, "ui.set", json!({"key":"panel", "value":"pages"})); + ok(&mut h, &c, "ui.dock", json!({"operation":"move", "panel":"inspector", "target":"tools", "zone":"center"})); + ok(&mut h, &c, "ui.dock", json!({"operation":"activate", "panel":"tools"})); + h.run_steps(3); + assert!(h.query_by_label("This document has no bookmarks.").is_none()); + let id = h.state().active_ids().unwrap().1; + h.state_mut().run_button_script(id, "", "app.execMenuItem('ShowHideBookmarks')"); + h.run_steps(4); + h.get_by_label("This document has no bookmarks."); +} + #[test] fn home_and_combine_suppress_inspector_without_forgetting_layout_or_choice() { use pdfcraft_ui_egui::docking::Panel;