diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 85926623..20866740 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -19,7 +19,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "844da6ed41f9baf939e1a99042dec5904447ca82fb5662c4361a9a809adb19f8" +sha256 = "5e6493d38917e6900f126d0a4fc9819b1b9a57e2d883207d03606d1c3e0c6d94" [[asset]] path = "crates/ui-egui/src/i18n/zh-hant.tsv" @@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "8fd69fd5e769259b99b059adb3a9d2fb90489bf561d62113ff8d25227495e122" +sha256 = "e735d7f40fa5d34627209287d82a4ce2651d7a04a31bc1bbae1b8be0ffbed972" [[asset]] path = "crates/ui-egui/src/i18n/de.tsv" @@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "8b380ba241bc25a05289717a3bfa4ea580be1c78b43c91d54c72de9c024b06eb" +sha256 = "97c6d0ef61a16419b8eb8807ad91a6544a5d7d9d3e20dfd35f33c39c105107a0" [[asset]] path = "crates/ui-egui/src/i18n/te.tsv" @@ -107,7 +107,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "ab089d2dab457a727158c3414dbe28b4edc3231cd30b5092feeeed3d8feca8a5" +sha256 = "fb9e2120dab378f35110897e7d0ebc53fce331cc918df393946a1f96ad2335e3" [[asset]] path = "crates/ui-egui/src/i18n/hu.tsv" @@ -118,7 +118,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "59ec12395bc45ed15a0de3b2d0878d2ea250bfd9e9372103d8869b142bcc675f" +sha256 = "fd4281afd9a33a0e73bab937b9d05cb92200967eb1847d8f43709ca9cdabf080" [[asset]] path = "crates/ui-egui/src/i18n/bg.tsv" @@ -129,7 +129,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "ca7c441f30dc116349f0aa523e8a6cfc8e019bf7ff73f377f0264f9dcda9f133" +sha256 = "4de7b3bae615f5b8bc29e56cd69d932735ee82ed5b7bbdb713c8b5c0c47cea84" [[asset]] path = "crates/ui-egui/src/i18n/ja.tsv" @@ -151,7 +151,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "bdb59f15d5659bd54fb4316b37f3e50f7c352640aeef1860be87700682d3bb48" +sha256 = "ca16d314028a95bd3b7eb73e9ea6c9d388a844f841e8c8811e340eb5a4161227" [[asset]] path = "assets/icons/accessibility.svg" diff --git a/ROADMAP.md b/ROADMAP.md index 698d314d..4c8e3dcf 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -135,6 +135,7 @@ M0 → M1 → M2 → M3 → M4 must happen in order. After M4, M5–M12 can run - **2026-10-09 (M14, #577):** The desktop GPU setup enables the selected adapter's supported 2D texture extent instead of egui-wgpu's fixed 8192-pixel extent. This removes the artificial surface ceiling behind a mixed-DPI Windows startup crash when opening Outlook PDF attachments. Synthetic regressions cover the observed 8600 × 3423 surface, downlevel adapters and preservation of other device limits. Hardware resolution limits still apply; milestone and overall effort estimates are unchanged. +- **2026-10-09 (M5, Fill & Sign flatten on save):** Preferences and `doc_save` can bake Fill & Sign text, marks and signatures into the page when saving. The option defaults off. Other comments and form fields stay, a second flatten does not reuse an XObject name already on the page, and a document that cannot be changed is left unwritten. Overall estimate unchanged (about 30–35%). - **2026-10-09 (M5.7, #434 partial):** Natural-size image stamps placed at a click or through `stamp_custom` remain upright on rotated pages, with a displayed-space-sized rectangle and a counterrotated standard appearance matrix. Move, resize, restyle and undo/redo preserve that orientation. Native and external Poppler save/reopen rendering cover all four quarter-turns; UI tests verify the displayed bounds. Explicit rectangle placement, image signatures, PDF-page stamps and image-file orientation metadata remain outside this repair. #434 stays open; milestone table and effort estimates remain unchanged. - **2026-10-09 (M1, raw-stream decoding limits):** COS checks unfiltered decoded data against the caller and per-stream limits before copying it, including empty filter arrays and streams whose Crypt filter was applied on load. Strict decoding uses the same raw-data guard. Tiny synthetic regressions cover exceeded, exact and zero-byte limits while retaining the filtered decoder behavior. Milestone and overall effort estimates unchanged (about 30–35%). @@ -142,6 +143,8 @@ M0 → M1 → M2 → M3 → M4 must happen in order. After M4, M5–M12 can run - **2026-10-09 (M4, page grid zoom):** The Organize page grid zooms from 50% to 300%: toolbar buttons with a percentage that resets, trackpad pinch or Ctrl/⌘ + wheel about the page under the pointer, and ⌘+ / ⌘− / ⌘0. Pages drawn larger than their thumbnails are rendered at the size drawn while in view, so text stays sharp in documents of any length. Not yet: a slider, remembering the size, a size control for the Pages panel. Overall estimate unchanged (about 30–35%). +- **2026-10-09 (M4, page grid zoom):** The Organize page grid zooms from 50% to 300%: toolbar buttons with a percentage that resets, trackpad pinch or Ctrl/⌘ + wheel about the page under the pointer, and ⌘+ / ⌘− / ⌘0. Pages drawn larger than their thumbnails are rendered at the size drawn while in view, so text stays sharp in documents of any length. Not yet: a slider, remembering the size, a size control for the Pages panel. Overall estimate unchanged (about 30–35%). + - **2026-10-08 (M5.7, signature resize proportions):** Image signature corner handles preserve the embedded image's original aspect ratio and anchor the opposite corner; edge midpoint handles stretch width or height independently. The live preview and committed edit share the constrained geometry, including page/view rotation and reopened PDFs whose rectangles were stretched previously. Synthetic UI tests cover all corners, edge resizing, undo and redo. Milestone and effort estimates unchanged. - **2026-10-08 (M5.7, live signature adjustment):** Embedded image signatures resize and move with every pointer frame. Movement hides the frame and handles, which return on release; Escape cancels without committing. A cached background preserves the page beneath the image, and only the completed gesture enters undo history. The read-only engine preview and `comment_image_preview` tool share bounded image decoding and render layers. Synthetic pixel tests cover live geometry, alpha, coloured page content, rotation, cancellation and encrypted save/reopen. Milestone and effort estimates unchanged. - **2026-10-08 (M10/M4, create from multiple files):** Create a PDF ▸ Multiple files is live. The picked PDFs, images and `.txt` files open as one unsaved document in the Organize page grid, where pages are removed and reordered and Save pages writes what is shown. The grid gained a "+" at every gap that inserts files right there, and on desktop files dropped on the grid go to the gap under the pointer; Insert from file now takes several files in order, converts images and text, and selects what it inserted. File-type detection moved from the shell into `pdfcraft-create` (`source_kind`), with one engine entry point (`Session::convert_to_pdf`) shared by Open, Insert, Create and the new `doc_create_multiple` tool (which can also write one PDF per file). Not yet: Office/HTML/Markdown inputs, PDF Portfolios, progress for big batches, dropping onto the grid in the web build. Overall estimate unchanged (about 30–35%). diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index 73bf42a4..f6d6add1 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -715,6 +715,59 @@ fn annot_dict(doc: &Document, r: ObjRef) -> Dict { doc.get(r).as_dict().cloned().unwrap_or_default() } +/// A Fill & Sign item: typed text, a check/cross/dot/line, or a typed, drawn or image signature. +/// +/// New items carry `/PCFillSign`. Older files are recognised from the dictionaries PdfCraft +/// already writes (`/IT /FreeTextTypeWriter`, stamp `/Name`, drawn ink whose subject is +/// "Signature"). +pub fn is_fill_sign(doc: &Document, d: &Dict) -> bool { + if matches!(d.get(b"PCFillSign").map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true))) { + return true; + } + let subtype = d.name(b"Subtype").unwrap_or_default(); + if subtype == b"FreeText" && d.name(b"IT") == Some(b"FreeTextTypeWriter") { + return true; + } + if subtype == b"Stamp" { + if matches!(d.name(b"Name"), Some(b"PCCheck" | b"PCCross" | b"PCDot" | b"PCLine" | b"PCTypedSignature")) { + return true; + } + if matches!(d.name(b"Name"), Some(b"PCCustomSignature" | b"PCCustomInitials")) + && matches!(d.get(b"PCPictureImage").map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true))) + { + return true; + } + } + subtype == b"Ink" && text_value(doc, d, b"Subj").as_deref() == Some("Signature") +} + +/// Whether any page has a Fill & Sign annotation that flatten would bake in (not hidden). +pub fn has_visible_fill_sign(doc: &Document) -> bool { + let Ok(pages) = page_refs(doc) else { return false }; + for page in pages { + for entry in annots(doc, page) { + let obj = doc.resolve(&entry); + let Some(d) = obj.as_dict() else { continue }; + let flags = d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0); + if flags & (FLAG_HIDDEN | FLAG_NO_VIEW) != 0 { + continue; + } + if is_fill_sign(doc, d) { + return true; + } + } + } + false +} + +fn fill_sign_shape(shape: &Shape) -> bool { + match shape { + Shape::Typewriter { .. } | Shape::Mark { .. } | Shape::Signature { .. } | Shape::TypedSignature { .. } => true, + Shape::CustomStamp { name, image: true, .. } => name == "Signature" || name == "Initials", + _ => false, + } +} + /// The embedded image of a Fill & Sign image signature or initials (0-based target). /// Other stamps have appearances that can't be represented by this image alone. pub fn signature_image(doc: &Document, page: usize, index: usize) -> Result, AnnotError> { @@ -747,6 +800,7 @@ const FLAG_PRINT: i64 = 4; const FLAG_NO_ZOOM: i64 = 8; const FLAG_NO_ROTATE: i64 = 16; const FLAG_HIDDEN: i64 = 2; +const FLAG_NO_VIEW: i64 = 32; const FLAG_LOCKED: i64 = 128; /// Size of a note icon (points, unscaled by zoom). @@ -1140,6 +1194,10 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R } _ => {} } + if fill_sign_shape(&new.shape) { + // Survives a later subject edit, so flatten-on-save still finds the mark. + d.set(b"PCFillSign".to_vec(), Object::Bool(true)); + } let r = doc.add(Object::Dict(d.clone())); set_appearance(doc, r)?; let mut list = annots(doc, page); diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 1444b313..0315f58c 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -645,6 +645,10 @@ impl Automation { let same_file = doc.path.as_deref().is_some_and(|p| Path::new(p) == target); // Saving to a new file is a full rewrite unless asked otherwise, like Save As. let full = a.opt_bool("full")?.unwrap_or(!same_file); + let flatten_fill_sign = a.opt_bool("flatten_fill_sign")?.unwrap_or(false); + if flatten_fill_sign { + self.apply(a, Edit::FlattenFillSign)?; + } let bytes = if full { self.session.save_full_bytes(id) } else { self.session.save_bytes(id) }.map_err(failed)?; write_atomic(&target, &bytes)?; let path = target.to_string_lossy().into_owned(); diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 18b7c67e..92f42e72 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -132,12 +132,17 @@ pub fn tools() -> Vec { t( "doc_save", "Save a document", - "Save to its own file (an incremental update, which keeps signatures valid) or to a new path (a full rewrite). The write is atomic.", + "Save to its own file (an incremental update, which keeps signatures valid) or to a new path (a full rewrite). The write is atomic. flatten_fill_sign bakes Fill & Sign text, marks and signatures into the page first and removes those annotations; other comments and form fields stay. Omit it, or pass false, to leave them editable.", ) .destructive() .cmd("file.save") .with(schema( - json!({ "doc": doc(), "path": { "type": "string", "description": "Save as this file. Omit to save in place." }, "full": { "type": "boolean", "description": "Force a full rewrite (or, with false, an incremental update)." } }), + json!({ + "doc": doc(), + "path": { "type": "string", "description": "Save as this file. Omit to save in place." }, + "full": { "type": "boolean", "description": "Force a full rewrite (or, with false, an incremental update)." }, + "flatten_fill_sign": { "type": "boolean", "description": "Bake Fill & Sign marks into the page before writing. Default false." } + }), &["doc"], )), t("doc_set_info", "Set document metadata", "Set a document information entry such as Title, Author, Subject or Keywords. Undoable.") diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index f5e3abbf..f42cbb6f 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -1614,6 +1614,28 @@ fn creating_images_with_dpi_through_tools() { } } +#[test] +fn saving_with_flatten_fill_sign_bakes_marks_and_leaves_other_comments() { + let dir = workdir("fill-sign-flatten"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "fill_sign_add", json!({ "doc": doc, "page": 1, "type": "text", "at": [40.0, 80.0], "text": "Hello" })); + ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "highlight", "find": "Page", "contents": "keep" })); + ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "text", "rect": [20.0, 120.0, 180.0, 142.0] })); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "kept.pdf" })); + let kept = ok(&mut a, "doc_open", json!({ "path": "kept.pdf" }))["doc"].as_u64().unwrap(); + let kept_comments = ok(&mut a, "comment_list", json!({ "doc": kept }))["comments"].as_array().unwrap().clone(); + assert!(kept_comments.iter().any(|c| c["contents"] == "Hello"), "without the flag the typewriter stays: {kept_comments:?}"); + + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "flat.pdf", "flatten_fill_sign": true })); + let flat = ok(&mut a, "doc_open", json!({ "path": "flat.pdf" }))["doc"].as_u64().unwrap(); + let comments = ok(&mut a, "comment_list", json!({ "doc": flat }))["comments"].as_array().unwrap().clone(); + assert!(comments.iter().all(|c| c["contents"] != "Hello"), "the typewriter was baked in: {comments:?}"); + assert!(comments.iter().any(|c| c["contents"] == "keep"), "the highlight stays: {comments:?}"); + let fields = ok(&mut a, "form_fields", json!({ "doc": flat }))["fields"].as_array().unwrap().clone(); + assert_eq!(fields.len(), 1, "the form field stays"); +} + #[test] fn flattening_through_tools() { let dir = workdir("flatten"); diff --git a/crates/edit/Cargo.toml b/crates/edit/Cargo.toml index 42733780..c929bfb4 100644 --- a/crates/edit/Cargo.toml +++ b/crates/edit/Cargo.toml @@ -9,6 +9,7 @@ license.workspace = true workspace = true [dependencies] +pdfcraft-annot = { workspace = true } pdfcraft-cos = { workspace = true } pdfcraft-content = { workspace = true } pdfcraft-fonts = { workspace = true } @@ -19,4 +20,3 @@ unicode-bidi = "0.3" [dev-dependencies] hayro-syntax = { workspace = true } -pdfcraft-annot = { workspace = true } diff --git a/crates/edit/src/flatten.rs b/crates/edit/src/flatten.rs index 704f12b5..dd05dbb7 100644 --- a/crates/edit/src/flatten.rs +++ b/crates/edit/src/flatten.rs @@ -51,9 +51,53 @@ fn placement(doc: &Document, form: &Dict, rect: [f64; 4]) -> Option<[f64; 6]> { Some([sx, 0.0, 0.0, sy, rect[0] - x0 * sx, rect[1] - y0 * sy]) } +#[derive(Clone, Copy)] +enum Which { + /// Every comment and/or every form field. + Comments { comments: bool, fields: bool }, + /// Fill & Sign text, marks and signatures only. + FillSign, +} + +fn selected(doc: &Document, d: &Dict, which: Which) -> bool { + let subtype = d.name(b"Subtype").unwrap_or_default(); + match which { + Which::Comments { comments, fields } => { + let widget = subtype == b"Widget"; + !matches!(subtype, b"Link" | b"Popup") && (!widget || fields) && (widget || comments) + } + Which::FillSign => !matches!(subtype, b"Link" | b"Popup" | b"Widget") && pdfcraft_annot::is_fill_sign(doc, d), + } +} + +/// An XObject name that is not already on the page, so a later flatten cannot replace `PCFl0`. +fn xobject_name(existing: &Dict, pending: &Dict, n: &mut u32) -> String { + loop { + let name = format!("PCFl{n}"); + if *n == u32::MAX { + return format!("PCFl{}", existing.len().saturating_add(pending.len())); + } + *n += 1; + if !existing.contains(name.as_bytes()) && !pending.contains(name.as_bytes()) { + return name; + } + } +} + /// Flatten comments and/or form fields on `pages`. Returns how many annotations were merged /// into page content. pub fn flatten(doc: &mut Document, pages: &[usize], comments: bool, fields: bool) -> Result { + flatten_which(doc, pages, Which::Comments { comments, fields }) +} + +/// Bake Fill & Sign text, marks and signatures into `pages` and remove those annotations. +/// Other comments, links and form fields stay. Returns how many appearances were drawn. +/// Does not modify the document when nothing matches. +pub fn flatten_fill_sign(doc: &mut Document, pages: &[usize]) -> Result { + flatten_which(doc, pages, Which::FillSign) +} + +fn flatten_which(doc: &mut Document, pages: &[usize], which: Which) -> Result { let all = page_list(doc); check(pages, all.len())?; let mut drawn = 0; @@ -64,6 +108,14 @@ pub fn flatten(doc: &mut Document, pages: &[usize], comments: bool, fields: bool let Some(list) = annots_obj.as_ref().map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()) else { continue }; let mut content = String::new(); let mut xobjects = Dict::new(); + let mut name_n = 0u32; + let existing_xo = page + .dict + .get(b"Resources") + .map(|r| doc.resolve(r)) + .and_then(|r| r.as_dict().cloned()) + .and_then(|r| r.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned())) + .unwrap_or_default(); let mut gone: HashSet = HashSet::new(); let mut gone_inline: HashSet = HashSet::new(); for (k, entry) in list.iter().enumerate() { @@ -71,7 +123,7 @@ pub fn flatten(doc: &mut Document, pages: &[usize], comments: bool, fields: bool let Some(d) = obj.as_dict() else { continue }; let subtype = d.name(b"Subtype").unwrap_or_default(); let widget = subtype == b"Widget"; - if matches!(subtype, b"Link" | b"Popup") || (widget && !fields) || (!widget && !comments) { + if !selected(doc, d, which) { continue; } let flags = d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0); @@ -82,7 +134,7 @@ pub fn flatten(doc: &mut Document, pages: &[usize], comments: bool, fields: bool if let (Some(ap), Some(rect)) = (appearance(doc, d), rect) { let form = doc.get(ap).as_dict().cloned().unwrap_or_default(); if let Some(m) = placement(doc, &form, rect) { - let name = format!("PCFl{drawn}"); + let name = xobject_name(&existing_xo, &xobjects, &mut name_n); content.push_str(&format!("q {} {} {} {} {} {} cm /{name} Do Q\n", n(m[0]), n(m[1]), n(m[2]), n(m[3]), n(m[4]), n(m[5]))); xobjects.set(name.into_bytes(), Object::Ref(ap)); drawn += 1; diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index 93df6829..dfdec9ed 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -612,7 +612,7 @@ pub fn marks_present(doc: &Document) -> Vec { } mod flatten; -pub use flatten::flatten; +pub use flatten::{flatten, flatten_fill_sign}; pub mod images; pub use images::{ImageChange, PageImage, change_image, page_images, rect_to_rect, turn_about_centre}; pub mod text; diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index f7756941..2ef3b48a 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -1268,3 +1268,66 @@ fn page_text_the_standard_fonts_cant_draw_is_refused() { update_content(&mut doc, 0, n, &Content::Text(arabic("ب"))).unwrap(); } } + +fn xobject(doc: &Document, page: usize, name: &[u8]) -> Option { + let p = &pdfcraft_model::pages(doc)[page]; + let res = doc.resolve(p.dict.get(b"Resources")?); + let xo = doc.resolve(res.as_dict()?.get(b"XObject")?); + xo.as_dict()?.get(name).and_then(Object::as_ref) +} + +fn annot_at(doc: &Document, page: usize, index: usize) -> pdfcraft_cos::ObjRef { + let p = &pdfcraft_model::pages(doc)[page]; + let list = doc.resolve(p.dict.get(b"Annots").unwrap()); + list.as_array().unwrap()[index].as_ref().unwrap() +} + +#[test] +fn fill_sign_flatten_bakes_marks_keeps_other_comments_and_does_not_reuse_pcfl0() { + use pdfcraft_annot::{FillMark, Markup, Meta, NewAnnotation, Shape, Style, add_annotation, summaries}; + let mut doc = fixture(); + assert_eq!(flatten_fill_sign(&mut doc, &[0]).unwrap(), 0); + assert!(!doc.is_modified(), "nothing to flatten leaves the file alone"); + + let meta = Meta { date: None, id: "x".into() }; + let add = |doc: &mut Document, shape: Shape, contents: &str| { + let style = Style::default_for(&shape); + add_annotation(doc, &NewAnnotation { page: 0, shape, style, contents: contents.into(), author: "a".into() }, &meta).unwrap() + }; + add(&mut doc, Shape::Rectangle { rect: [10.0, 10.0, 80.0, 40.0] }, "box"); + assert_eq!(flatten(&mut doc, &[0], true, false).unwrap(), 1); + let kept = xobject(&doc, 0, b"PCFl0").expect("the first flatten's XObject"); + + add(&mut doc, Shape::Typewriter { rect: [20.0, 500.0, 140.0, 520.0], font_size: 10.0 }, "Hello"); + doc.update_dict(annot_at(&doc, 0, 0), |d| { + d.remove(b"PCFillSign"); + }) + .unwrap(); + add(&mut doc, Shape::Mark { rect: [100.0, 100.0, 120.0, 120.0], mark: FillMark::Check }, ""); + add(&mut doc, Shape::Signature { strokes: vec![vec![[10.0, 50.0], [40.0, 80.0], [70.0, 50.0]]] }, ""); + doc.update_dict(annot_at(&doc, 0, 2), |d| d.set(b"Subj".to_vec(), pdfcraft_cos::PdfString::text("Pencil"))).unwrap(); + add(&mut doc, Shape::TypedSignature { rect: [20.0, 40.0, 90.0, 70.0], contours: vec![vec![[0.1, 0.2], [0.5, 0.9], [0.9, 0.2]]] }, ""); + add(&mut doc, Shape::TextMarkup { kind: Markup::Highlight, quads: vec![[20.0, 400.0, 80.0, 400.0, 20.0, 390.0, 80.0, 390.0]] }, "keep"); + add(&mut doc, Shape::TextBox { rect: [20.0, 300.0, 140.0, 340.0], font_size: 12.0 }, "Note"); + add(&mut doc, Shape::Ink { strokes: vec![vec![[200.0, 200.0], [220.0, 220.0], [240.0, 200.0]]] }, "pencil"); + let old = add(&mut doc, Shape::Signature { strokes: vec![vec![[300.0, 50.0], [330.0, 80.0], [360.0, 50.0]]] }, ""); + doc.update_dict(annot_at(&doc, 0, old), |d| { + d.remove(b"PCFillSign"); + }) + .unwrap(); + let hidden = add(&mut doc, Shape::Typewriter { rect: [20.0, 600.0, 140.0, 620.0], font_size: 10.0 }, "secret"); + doc.update_dict(annot_at(&doc, 0, hidden), |d| d.set(b"F".to_vec(), Object::Int(2))).unwrap(); + + let n = flatten_fill_sign(&mut doc, &[0]).unwrap(); + assert_eq!(n, 5, "text, check, both signatures and the typed signature; the hidden one stays"); + assert_eq!(xobject(&doc, 0, b"PCFl0"), Some(kept), "an existing PCFl0 is not replaced"); + let newest = streams(&doc, 0).last().unwrap().clone(); + assert!(newest.contains("/PCFl1 Do") && !newest.contains("/PCFl0 Do"), "{newest}"); + + let doc = reopen(&doc); + let mut left: Vec = summaries(&doc).iter().map(|s| s.contents.clone().unwrap_or_default()).collect(); + left.sort(); + assert_eq!(left, ["Note", "keep", "pencil", "secret"]); + assert!(summaries(&doc).iter().any(|s| s.intent.as_deref() == Some("FreeTextTypeWriter")), "the hidden typewriter stays"); + assert_eq!(summaries(&doc).iter().filter(|s| s.intent.as_deref() == Some("FreeTextTypeWriter")).count(), 1); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 9ee20873..fbd92be3 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1081,6 +1081,9 @@ pub enum Edit { comments: bool, fields: bool, }, + /// Bake Fill & Sign text, marks and signatures into the page. Other comments and fields stay. + /// A no-op (no undo step) when the document has none. + FlattenFillSign, /// Protect with passwords and permissions (written by the next save, which is a full rewrite). Protect(Protection), /// Remove password security (needs the owner password). @@ -1182,6 +1185,7 @@ impl Edit { Edit::Flatten { comments: true, fields: false } => "Flatten comments".into(), Edit::Flatten { comments: false, fields: true } => "Flatten form fields".into(), Edit::Flatten { .. } => "Flatten".into(), + Edit::FlattenFillSign => "Flatten Fill & Sign".into(), Edit::Protect(_) => "Protect with password".into(), Edit::RemoveProtection => "Remove security".into(), Edit::Batch { label, .. } => label.clone(), @@ -1323,7 +1327,8 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed | Edit::EditTextLine { .. } | Edit::EditTextBlock { .. } | Edit::EditPageImage { .. } - | Edit::Flatten { .. } => { + | Edit::Flatten { .. } + | Edit::FlattenFillSign => { if p.modify() { Ok(()) } else { @@ -1673,6 +1678,10 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> let n = pdfcraft_model::pages(doc).len(); pdfcraft_edit::flatten(doc, &(0..n).collect::>(), *comments, *fields)?; } + Edit::FlattenFillSign => { + let n = pdfcraft_model::pages(doc).len(); + pdfcraft_edit::flatten_fill_sign(doc, &(0..n).collect::>())?; + } Edit::Protect(p) => { p.validate()?; let seed = cx.entropy(); @@ -2272,6 +2281,10 @@ impl Session { if let Some(p) = editor.cos.permissions() { check_permission(&edit, &p)?; } + // Nothing to bake: leave undo history and the dirty flag alone. + if matches!(&edit, Edit::FlattenFillSign) && !pdfcraft_annot::has_visible_fill_sign(&editor.cos) { + return Ok(()); + } let mut next = editor.cos.clone(); if !js_off && uses_scripts(&edit) { cx.js = Some(js::JsRunner::new(&next, &name)); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 1e497399..d560effa 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1100,6 +1100,116 @@ fn flattening_keeps_the_look_and_drops_the_objects() { assert_eq!(s.get(id).unwrap().form.len(), 2); } +fn fill_item(shape: Shape, contents: &str) -> Edit { + let style = Style::default_for(&shape); + Edit::AddAnnotation(NewAnnotation { page: 0, shape, style, contents: contents.into(), author: "Ada".into() }) +} + +fn comment_text(s: &Session, id: DocId) -> Vec { + let mut t: Vec = s.get(id).unwrap().info.annotations.iter().map(|a| a.contents.clone().unwrap_or_default()).collect(); + t.sort(); + t +} + +#[test] +fn flattening_fill_and_sign_keeps_other_comments_fields_and_undoes() { + let (mut s, id) = session_with(1); + s.apply(id, fill_item(Shape::Typewriter { rect: [20.0, 200.0, 120.0, 220.0], font_size: 10.0 }, "Hello")).unwrap(); + s.apply(id, fill_item(Shape::Mark { rect: [140.0, 200.0, 160.0, 220.0], mark: FillMark::Check }, "")).unwrap(); + s.apply(id, fill_item(Shape::Signature { strokes: vec![vec![[10.0, 40.0], [40.0, 70.0], [70.0, 40.0]]] }, "")).unwrap(); + s.apply(id, fill_item(Shape::TypedSignature { rect: [90.0, 40.0, 150.0, 70.0], contours: vec![vec![[0.1, 0.2], [0.5, 0.9], [0.9, 0.2]]] }, "")) + .unwrap(); + let mut png = Vec::new(); + { + let mut enc = png::Encoder::new(&mut png, 8, 8); + enc.set_color(png::ColorType::Rgba); + let pixel = [40u8, 60, 80, 200]; + enc.write_header().unwrap().write_image_data(&pixel.repeat(64)).unwrap(); + } + let image = SignatureImage::from_bytes(&png).unwrap(); + let info = s.get(id).unwrap().info.pages[0].clone(); + s.apply(id, image.edit(0, &info, [20.0, 120.0], false, "Ada").unwrap()).unwrap(); + s.apply(id, fill_item(Shape::TextMarkup { kind: Markup::Highlight, quads: vec![[20.0, 280.0, 80.0, 280.0, 20.0, 270.0, 80.0, 270.0]] }, "keep")) + .unwrap(); + s.apply(id, fill_item(Shape::TextBox { rect: [20.0, 150.0, 120.0, 180.0], font_size: 12.0 }, "Note")).unwrap(); + s.apply(id, Edit::AddField { page: 0, rect: [20.0, 230.0, 120.0, 250.0], kind: NewField::Text { multiline: false }, name: Some("email".into()) }) + .unwrap(); + + s.apply(id, Edit::FlattenFillSign).unwrap(); + assert_eq!(s.get(id).unwrap().can_undo(), Some("Flatten Fill & Sign")); + assert_eq!(comment_text(&s, id), ["Note", "keep"]); + assert_eq!(s.get(id).unwrap().form.iter().map(|f| f.name.as_str()).collect::>(), ["email"]); + s.undo(id).unwrap(); + assert!(comment_text(&s, id).contains(&"Hello".to_string()), "undo restores the typewriter"); + + s.apply(id, Edit::FlattenFillSign).unwrap(); + let saved = s.save_bytes(id).unwrap(); + s.mark_saved(id, saved.clone(), None).unwrap(); + let mut again = Session::new(); + let id2 = again.open("saved.pdf", None, saved, None).unwrap(); + assert_eq!(comment_text(&again, id2), ["Note", "keep"]); + assert_eq!(again.get(id2).unwrap().form.iter().map(|f| f.name.as_str()).collect::>(), ["email"]); +} + +#[test] +fn flattening_fill_and_sign_does_nothing_when_there_are_none() { + let (mut s, id) = session_with(1); + s.apply(id, fill_item(Shape::TextMarkup { kind: Markup::Highlight, quads: vec![[20.0, 200.0, 80.0, 200.0, 20.0, 190.0, 80.0, 190.0]] }, "keep")) + .unwrap(); + let saved = s.save_bytes(id).unwrap(); + s.mark_saved(id, saved, None).unwrap(); + assert!(!s.get(id).unwrap().dirty); + s.apply(id, Edit::FlattenFillSign).unwrap(); + assert!(!s.get(id).unwrap().dirty, "nothing was baked in, so the document stays saved"); + assert_eq!(s.get(id).unwrap().can_undo(), Some("Add highlight"), "no undo step for a flatten that changed nothing"); + assert_eq!(comment_text(&s, id), ["keep"]); +} + +#[test] +fn flattening_fill_and_sign_is_refused_without_permission_to_change() { + let (mut s, id) = session_with(1); + s.apply(id, fill_item(Shape::Typewriter { rect: [20.0, 200.0, 120.0, 220.0], font_size: 10.0 }, "Hello")).unwrap(); + let p = Protection { printing: Printing::Low, changes: Changes::CommentFillSign, copy: false, ..protection(None, Some("boss")) }; + s.apply(id, Edit::Protect(p)).unwrap(); + let saved = s.save_bytes(id).unwrap(); + let mut other = Session::new(); + let id2 = other.open("locked.pdf", None, saved, None).unwrap(); + let before = other.get(id2).unwrap().bytes.clone(); + assert_eq!(other.apply(id2, Edit::FlattenFillSign), Err(EditError::NotPermitted("changes to the document"))); + assert_eq!(other.get(id2).unwrap().bytes, before); + assert!(!other.get(id2).unwrap().dirty); + assert!(comment_text(&other, id2).contains(&"Hello".to_string())); +} + +#[test] +fn flattening_fill_and_sign_on_a_signed_file_stays_incremental() { + 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 mut s = Session::new().with_clock(|| 1_800_000_000); + let id = s.open("fixture.pdf", None, Arc::new(fixture(1)), None).unwrap(); + let opts = SignOptions { page: 0, rect: Some([20.0, 20.0, 160.0, 50.0]), ..SignOptions::default() }; + let signed = s.sign(id, &digital_id, opts).unwrap(); + s.mark_signed(id, signed.clone(), None).unwrap(); + s.apply(id, fill_item(Shape::Typewriter { rect: [20.0, 200.0, 120.0, 220.0], font_size: 10.0 }, "Hello")).unwrap(); + s.apply(id, Edit::FlattenFillSign).unwrap(); + let saved = s.save_bytes(id).unwrap(); + assert!(saved.starts_with(signed.as_ref()), "the signature's bytes stay an incremental update"); + assert!(!comment_text(&s, id).contains(&"Hello".to_string())); +} + +#[test] +fn flattening_fill_and_sign_reopens_an_encrypted_file() { + let (mut s, id) = session_with(1); + s.apply(id, fill_item(Shape::Typewriter { rect: [20.0, 200.0, 120.0, 220.0], font_size: 10.0 }, "Hello")).unwrap(); + s.apply(id, Edit::Protect(protection(Some("secret"), None))).unwrap(); + s.apply(id, Edit::FlattenFillSign).unwrap(); + let saved = s.save_bytes(id).unwrap(); + let mut again = Session::new(); + assert!(again.open("p.pdf", None, saved.clone(), None).is_err()); + let id2 = again.open("p.pdf", None, saved, Some("secret")).unwrap(); + assert!(!comment_text(&again, id2).contains(&"Hello".to_string())); +} + #[test] fn pages_export_as_png_jpeg_and_tiff() { use crate::export::{Exporter, ImageFormat}; diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index c2dd20e6..ecce8dc7 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -282,14 +282,62 @@ impl PdfCraftApp { self.notify(crate::content_ui::undrawable_message(c)); return false; } + let typing = self + .views + .get(i) + .is_some_and(|v| v.pending_edit.is_some() || v.forms.focus.is_some() || v.fill_text.as_ref().is_some_and(|t| !t.text.trim().is_empty())); if self.active != Some(i) { - let typing = self.views.get(i).is_some_and(|v| v.pending_edit.is_some() || v.forms.focus.is_some()); if !typing { return true; } self.active = Some(i); } - self.commit_form_typing() + self.commit_form_typing() && self.commit_open_fill_text(id) + } + + /// The Fill & Sign type box commits on click-away. Save does that first, so the text is in + /// the file (and can be flattened) instead of being left on the screen. + fn commit_open_fill_text(&mut self, id: pdfcraft_engine::DocId) -> bool { + let Some(i) = self.views.iter().position(|v| v.id == id) else { return true }; + let Some(tb) = self.views[i].fill_text.clone() else { return true }; + let text = tb.text.trim().to_string(); + if text.is_empty() { + if let Some(view) = self.views.get_mut(i) { + view.fill_text = None; + } + return true; + } + let author = self.comment_prefs.author.clone(); + let edit = crate::fill_sign::typed(tb.page, tb.at, &text, &author); + if self.apply_edit(edit) { + if let Some(view) = self.views.get_mut(i) { + view.fill_text = None; + } + true + } else { + false + } + } + + /// When the preference is on, bake Fill & Sign marks before the bytes are written. + /// Returns false when the document is not allowed to change (nothing is written). + fn flatten_fill_sign_for_save(&mut self, id: pdfcraft_engine::DocId) -> bool { + if !self.flatten_fill_sign_on_save { + return true; + } + let before = self.session.get(id).map(|d| d.edit_generation()); + if let Err(e) = self.session.apply(id, Edit::FlattenFillSign) { + self.notify_error(e); + return false; + } + let after = self.session.get(id).map(|d| d.edit_generation()); + if before != after + && let Some(doc) = self.session.get(id) + && let Some(view) = self.views.iter_mut().find(|v| v.id == id) + { + view.document_changed(&doc.info); + } + true } /// Whether tab `index` has work that isn't saved: edits, or form typing not committed yet. @@ -300,6 +348,7 @@ impl PdfCraftApp { || v.pending_edit.is_some() || v.content.blocked().is_some() || v.forms.focus.as_ref().is_some_and(|f| crate::forms_ui::draft_edit(f, &doc.form).is_some()) + || v.fill_text.as_ref().is_some_and(|t| !t.text.trim().is_empty()) } /// Save the active document. Returns `true` if it was written. @@ -355,6 +404,9 @@ impl PdfCraftApp { #[cfg(target_arch = "wasm32")] { let _ = (target, path); + if !self.flatten_fill_sign_for_save(id) { + return false; + } let bytes = match self.session.save_bytes(id) { Ok(b) => b, Err(e) => { @@ -385,7 +437,7 @@ impl PdfCraftApp { fn save_doc_to(&mut self, id: pdfcraft_engine::DocId, dest: &str) -> bool { // Save As answers on a later frame: commit what was typed meanwhile, too (#166), even // if another tab is active by then. - if !self.commit_typing_in(id) { + if !self.commit_typing_in(id) || !self.flatten_fill_sign_for_save(id) { return false; } let Some(name) = self.session.get(id).map(|d| d.name.clone()) else { diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 11982108..518f34ca 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -125,6 +125,8 @@ Display theme Тема на показване Side panels Странични панели Enable Acrobat JavaScript Включи Acrobat JavaScript + Flatten Fill & Sign when saving Сплескай „Попълни и подпиши“ при запазване + Text, marks and signatures become part of the page. Other comments stay editable. Текстът, маркировките и подписите стават част от страницата. Другите коментари остават редактируеми. OK OK Cancel Отказ Close Затвори diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 01d601e2..0b6cb391 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -58,6 +58,8 @@ Display theme Design Side panels Seitenleisten Enable Acrobat JavaScript Acrobat-JavaScript aktivieren + Flatten Fill & Sign when saving Ausfüllen und unterschreiben beim Speichern reduzieren + Text, marks and signatures become part of the page. Other comments stay editable. Text, Markierungen und Unterschriften werden Teil der Seite. Andere Kommentare bleiben bearbeitbar. OK OK Join our Discord Unserem Discord beitreten Close Schließen diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 251618fa..a6b5c08d 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -58,6 +58,8 @@ Display theme Thème d'affichage Side panels Panneaux latéraux Enable Acrobat JavaScript Activer Acrobat JavaScript + Flatten Fill & Sign when saving Aplatir Remplir et signer à l'enregistrement + Text, marks and signatures become part of the page. Other comments stay editable. Le texte, les marques et les signatures deviennent partie de la page. Les autres commentaires restent modifiables. OK OK Join our Discord Rejoindre notre Discord Close Fermer diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index acb523c0..ef0a7306 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2088,3 +2088,8 @@ action wizard Start Indítás Larger pages Nagyobb oldalak Smaller pages Kisebb oldalak Reset page size Oldalméret visszaállítása + Larger pages Nagyobb oldalak + Smaller pages Kisebb oldalak + Reset page size Oldalméret visszaállítása + Flatten Fill & Sign when saving Kitöltés és aláírás beégetése mentéskor + Text, marks and signatures become part of the page. Other comments stay editable. A szöveg, a jelek és az aláírások az oldal részévé válnak. A többi megjegyzés szerkeszthető marad. diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index d9a0995f..c7888b4f 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -79,6 +79,8 @@ Display theme Тема оформления Side panels Боковые панели Enable Acrobat JavaScript Включить Acrobat JavaScript + Flatten Fill & Sign when saving Расплющить «Заполнить и подписать» при сохранении + Text, marks and signatures become part of the page. Other comments stay editable. Текст, пометки и подписи становятся частью страницы. Остальные комментарии можно по-прежнему править. OK OK Join our Discord Присоединяйтесь к нашему Discord Close Закрыть diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 084417c9..516562a5 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2110,3 +2110,8 @@ calendar date December грудня Larger pages Більші сторінки Smaller pages Менші сторінки Reset page size Скинути розмір сторінок + Larger pages Більші сторінки + Smaller pages Менші сторінки + Reset page size Скинути розмір сторінок + Flatten Fill & Sign when saving Звести «Заповнення й підпис» під час збереження + Text, marks and signatures become part of the page. Other comments stay editable. Текст, позначки й підписи стають частиною сторінки. Інші коментарі залишаються редагованими. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 19059258..f8b5233d 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -131,6 +131,8 @@ Display theme 显示主题 Side panels 侧边面板 Enable Acrobat JavaScript 启用 Acrobat JavaScript + Flatten Fill & Sign when saving 保存时拼合填写与签名 + Text, marks and signatures become part of the page. Other comments stay editable. 文字、标记和签名会成为页面内容。其他注释仍可编辑。 OK 确定 Cancel 取消 Close 关闭 diff --git a/crates/ui-egui/src/js_ui.rs b/crates/ui-egui/src/js_ui.rs index fb3059b1..f5015bed 100644 --- a/crates/ui-egui/src/js_ui.rs +++ b/crates/ui-egui/src/js_ui.rs @@ -307,6 +307,12 @@ pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tok .labelled_by(label.id); }); ui.add_space(8.0); + ui.label(egui::RichText::new(tl!("Fill & Sign")).font(theme::semibold(13.0))); + ui.checkbox(&mut app.flatten_fill_sign_on_save, tl!("Flatten Fill & Sign when saving")); + ui.label( + egui::RichText::new(tl!("Text, marks and signatures become part of the page. Other comments stay editable.")).small().color(t.text_muted), + ); + ui.add_space(8.0); ui.label(egui::RichText::new("JavaScript").font(theme::semibold(13.0))); egui::Frame::new().fill(t.hover).corner_radius(egui::CornerRadius::same(6)).inner_margin(egui::Margin::same(10)).show(ui, |ui| { ui.set_width(ui.available_width()); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 93c81a94..feb39e21 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -370,6 +370,8 @@ pub struct PdfCraftApp { pub theme_preference: ThemePreference, /// Interface language preference: `auto` (follow the system) or a code from [`i18n::LANGUAGES`]. pub language: String, + /// Preferences: bake Fill & Sign marks into the page when saving. Off, so a normal save stays editable. + pub flatten_fill_sign_on_save: bool, pub dialog: Option, /// How to ask for the latest release (the desktop app sets it; see `updates`). pub update_source: Option, @@ -620,6 +622,7 @@ impl PdfCraftApp { theme: ThemeKind::Light, theme_preference: ThemePreference::Light, language: i18n::AUTO.to_string(), + flatten_fill_sign_on_save: false, dialog: None, update_source: None, updates: updates::Updates::default(), @@ -1207,6 +1210,7 @@ impl PdfCraftApp { "default_zoom": self.view_defaults.zoom_name(), "highlight_fields": self.view_defaults.highlight_fields, "language": self.language, + "flatten_fill_sign": self.flatten_fill_sign_on_save, "author": self.comment_prefs.author, // Drawn signatures keep their original form (older settings read the same). "signature": match &self.signature { Some(fill_sign::SavedSig::Drawn(s)) => Some(s), _ => None }, @@ -1261,6 +1265,9 @@ impl PdfCraftApp { if let Some(language) = v["language"].as_str().and_then(i18n::normalize_pref) { self.language = language.to_string(); } + if let Some(on) = v["flatten_fill_sign"].as_bool() { + self.flatten_fill_sign_on_save = on; + } // An empty or missing name keeps the login-name default; settings are untrusted, so the // name is cut to a sane length. if let Some(author) = v["author"].as_str().map(str::trim).filter(|a| !a.is_empty()) { @@ -1506,6 +1513,13 @@ impl PdfCraftApp { }; } ("author", _) => self.comment_prefs.author = value.to_string(), + ("flatten-fill-sign", _) => { + self.flatten_fill_sign_on_save = match value { + "true" => true, + "false" => false, + _ => return Err("flatten-fill-sign must be true or false".into()), + }; + } ("comment", Some(v)) => { // `--comment 2:4` selects the 4th annotation of page 2 (1-based, as comment_list reports). let (p, i) = value.split_once(':').ok_or("comment: PAGE:INDEX")?; diff --git a/crates/ui-egui/tests/fill_sign.rs b/crates/ui-egui/tests/fill_sign.rs index 9ba0cc55..96df83af 100644 --- a/crates/ui-egui/tests/fill_sign.rs +++ b/crates/ui-egui/tests/fill_sign.rs @@ -878,6 +878,65 @@ fn image_import_cancel_clear_and_errors_preserve_saved_signatures() { std::fs::remove_file(path).unwrap(); } +#[test] +fn flatten_on_save_is_off_by_default_and_round_trips() { + let mut app = PdfCraftApp::new(); + assert!(!app.flatten_fill_sign_on_save); + app.set_option("flatten-fill-sign", "true").unwrap(); + assert!(app.set_option("flatten-fill-sign", "yes").is_err()); + let mut restored = PdfCraftApp::new(); + restored.restore(&app.persist()); + assert!(restored.flatten_fill_sign_on_save); + restored.set_option("flatten-fill-sign", "false").unwrap(); + let mut again = PdfCraftApp::new(); + again.restore(&restored.persist()); + assert!(!again.flatten_fill_sign_on_save); +} + +#[test] +fn saving_flattens_fill_and_sign_only_when_the_preference_is_on() { + use pdfcraft_engine::{Edit, FillMark, NewAnnotation, Shape, Style}; + use pdfcraft_ui_egui::SaveTarget; + use pdfcraft_ui_egui::fill_sign::TypeBox; + + let dir = std::env::temp_dir().join(format!("pdfcraft-fill-flatten-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let mark = Edit::AddAnnotation(NewAnnotation { + page: 0, + shape: Shape::Mark { rect: [40.0, 80.0, 56.0, 96.0], mark: FillMark::Check }, + style: Style::default_for(&Shape::Mark { rect: [0.0, 0.0, 12.0, 12.0], mark: FillMark::Check }), + contents: String::new(), + author: "Ada".into(), + }); + + let mut app = PdfCraftApp::new(); + app.open_bytes("form.pdf", None, FIXTURE.to_vec()).unwrap(); + let id = app.views[0].id; + app.session.apply(id, mark).unwrap(); + app.views[0].fill_text = Some(TypeBox { page: 0, at: [40.0, 200.0], text: "Typed".into(), focus: false }); + let kept = dir.join("kept.pdf"); + app.save_override = Some(kept.to_string_lossy().into_owned()); + assert!(app.save_active(SaveTarget::InPlace)); + let mut reopened = PdfCraftApp::new(); + reopened.open_bytes("kept.pdf", None, std::fs::read(&kept).unwrap()).unwrap(); + let texts: Vec = + reopened.session.get(reopened.views[0].id).unwrap().info.annotations.iter().map(|a| a.contents.clone().unwrap_or_default()).collect(); + assert!(texts.iter().any(|t| t == "Typed"), "an open type box is saved: {texts:?}"); + assert_eq!(reopened.session.get(reopened.views[0].id).unwrap().info.annotations.len(), 2, "the check mark stays too"); + + app.views[0].fill_text = Some(TypeBox { page: 0, at: [40.0, 160.0], text: "More".into(), focus: false }); + app.flatten_fill_sign_on_save = true; + let flat = dir.join("flat.pdf"); + app.save_override = Some(flat.to_string_lossy().into_owned()); + assert!(app.save_active(SaveTarget::InPlace)); + let mut flattened = PdfCraftApp::new(); + flattened.open_bytes("flat.pdf", None, std::fs::read(&flat).unwrap()).unwrap(); + let left = &flattened.session.get(flattened.views[0].id).unwrap().info.annotations; + assert!(left.is_empty(), "text and the check mark are page content: {left:?}"); + let _ = std::fs::remove_dir_all(&dir); +} + /// Issue #299: drawn and typed signatures and initials are upright as displayed on pages with /// `/Rotate` 90, 180 and 270, anchored at the displayed point, exactly as on an unturned page. #[test] diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index de7b83de..ab580ddb 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5235,11 +5235,11 @@ title = "Fill & Sign: flatten on save" area = "F" tier = "P0" milestone = "M5" -status = "partial" +status = "shipped" commands = ["comment.flatten"] -tools = ["doc_flatten"] -evidence = ["crates/edit/src/tests.rs::flattening_draws_appearances_into_the_page_and_removes_the_comments", "crates/engine/src/tests.rs::flattening_keeps_the_look_and_drops_the_objects", "crates/automation/tests/automation.rs::flattening_through_tools"] -notes = "Flatten comments flattens Fill & Sign items; no flatten-on-save option yet" +tools = ["doc_save", "doc_flatten"] +evidence = ["crates/edit/src/tests.rs::fill_sign_flatten_bakes_marks_keeps_other_comments_and_does_not_reuse_pcfl0", "crates/engine/src/tests.rs::flattening_fill_and_sign_keeps_other_comments_fields_and_undoes", "crates/engine/src/tests.rs::flattening_fill_and_sign_is_refused_without_permission_to_change", "crates/automation/tests/automation.rs::saving_with_flatten_fill_sign_bakes_marks_and_leaves_other_comments", "crates/ui-egui/tests/fill_sign.rs::saving_flattens_fill_and_sign_only_when_the_preference_is_on"] +notes = "Preferences ▸ Fill & Sign, and doc_save flatten_fill_sign, bake text, marks and signatures into the page. The option defaults off, so a normal save leaves them editable. Other comments and form fields stay. Flatten comments still flattens every comment." [[feature]] id = "form.autofill-profile" @@ -9675,7 +9675,7 @@ status = "partial" commands = ["app.preferences"] tools = ["js_enabled"] evidence = ["crates/ui-egui/tests/javascript.rs::document_scripts_and_preferences", "crates/ui-egui/tests/ui.rs::preferences_selects_default_workspace_for_next_open", "crates/ui-egui/tests/control.rs::control_default_workspace_and_session_override"] -notes = "Preferences dialog with Interface language, Documents and view (persistent default workspace with session override), Identity and JavaScript; other categories still live in their tools, and it isn't generated from a schema yet." +notes = "Preferences dialog with Interface language, Documents and view (persistent default workspace with session override), Identity, Fill & Sign (flatten on save, off by default) and JavaScript; other categories still live in their tools, and it isn't generated from a schema yet." [[feature]] id = "misc.prefs-general"