Flatten Fill & Sign marks when saving (#464)
* M5: flatten Fill & Sign marks when saving Saving can now bake typed text, marks and signatures into the page, from Preferences or doc_save. The option defaults off, so a normal save still leaves them editable, and other comments and form fields stay. * M4: zoom the Organize page grid The page grid draws its pages at 50% to 300%: toolbar buttons with a percentage that resets to 100%, trackpad pinch or Ctrl/⌘ + wheel about the page under the pointer, and ⌘+ / ⌘− / ⌘0 (which no longer change the hidden page view's zoom while the grid shows). Thumbnails are re-rendered sharper in a few steps as the grid grows, capped so one document's thumbnails stay within 256 MB. The page under the pointer (or the first one in view) stays where it is across a zoom. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * M4: zoomed grid pages are rendered at the size drawn A zoomed page grid stretched 132 pt thumbnails, and the stepped detail that was meant to help was capped by a memory budget, so a long document stayed blurry at any zoom. Pages drawn larger than their thumbnails now get a render at exactly the size drawn while they are in view; the thumbnail stands in until it arrives. Only pages in view keep one, so memory no longer depends on the page count, and thumbnails go back to their one fixed size. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * View: the middle button pans instead of drawing with markup tools egui's drag responses accept every pointer button, so on Windows and macOS a middle-button drag reached the page tools: with Draw (ink) selected it drew a stroke, and other tools cropped, selected or moved. Linux was unaffected because its middle-click auto-scroll already blocks page input. Outside Linux a middle-button drag that starts in the viewport (document or Organize Pages) now pans 1:1 while the button is held, with a Grabbing cursor, and page input is blocked from the press through the release. Linux keeps its latched auto-scroll. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * M3: a short page gone to from the Pages panel stays current The current page was whichever page showed most of itself. Going to a short page (a cheque) above a tall one scrolls it to the top, where the tall page below shows more, so the current page and the Pages panel's highlight moved to the tall page instead of the one clicked. The page navigated to now stays current while it is wholly on screen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * M4: page operations from the Pages panel: context menu and drag to reorder Right-clicking a thumbnail offers Extract pages, Cut, Copy and Paste after, on the selection or on the page clicked (which becomes the selection), as the organize grid does. Dragging thumbnails moves the selection, or the page grabbed, to the gap it is dropped on: a bar marks the gap, the page count follows the pointer, and the panel scrolls near its edges. The move is one undoable step; the moved pages stay selected and the current page follows its page. Documents that forbid assembly keep click-only thumbnails. Parity: view.thumbnail-context-ops is shipped, with what it still lacks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Pages panel: only the primary button drags thumbnails; a drag left over after the panel closed or the document changed is dropped * M5: translate Fill & Sign flatten labels into Ukrainian The coverage test failed because the new preference strings had no Ukrainian entries. * Fix main CI: move the rotated-field render test out of pdfcraft-forms (#591) #516 added pdfcraft-render as a dev-dependency of pdfcraft-forms for one rendering test. Both crates are layer 3, so `cargo xtask layers` (run in CI) rejects the sideways edge and main went red. The test now lives in crates/engine/tests/form_rotation.rs, where both crates are already dependencies, and forms drops the dev-dependency. The parity evidence points at the new location. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * uk: the author's wording for the flatten setting --------- Co-authored-by: samehelhawary <basio20044@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Cody Vogt <Cody.Vogt@caltechsurveys.com> Co-authored-by: William Max & Co. <info@williammax.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
parent
f28cbbcdd9
commit
8e3782d5ca
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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%).
|
||||
|
|
|
|||
|
|
@ -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<Option<ObjRef>, 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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -132,12 +132,17 @@ pub fn tools() -> Vec<ToolDef> {
|
|||
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.")
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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<usize, EditError> {
|
||||
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<usize, EditError> {
|
||||
flatten_which(doc, pages, Which::FillSign)
|
||||
}
|
||||
|
||||
fn flatten_which(doc: &mut Document, pages: &[usize], which: Which) -> Result<usize, EditError> {
|
||||
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<ObjRef> = HashSet::new();
|
||||
let mut gone_inline: HashSet<usize> = 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;
|
||||
|
|
|
|||
|
|
@ -612,7 +612,7 @@ pub fn marks_present(doc: &Document) -> Vec<MarkKind> {
|
|||
}
|
||||
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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<pdfcraft_cos::ObjRef> {
|
||||
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<String> = 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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::<Vec<_>>(), *comments, *fields)?;
|
||||
}
|
||||
Edit::FlattenFillSign => {
|
||||
let n = pdfcraft_model::pages(doc).len();
|
||||
pdfcraft_edit::flatten_fill_sign(doc, &(0..n).collect::<Vec<_>>())?;
|
||||
}
|
||||
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));
|
||||
|
|
|
|||
|
|
@ -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<String> {
|
||||
let mut t: Vec<String> = 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::<Vec<_>>(), ["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::<Vec<_>>(), ["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};
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 Затвори
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 8 and column 16.
|
|
|
@ -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
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 51.
|
|
|
@ -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
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 39.
|
|
|
@ -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.
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 7 and column 44.
|
|
|
@ -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 Закрыть
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
|
|
@ -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. Текст, позначки й підписи стають частиною сторінки. Інші коментарі залишаються редагованими.
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
|
|
@ -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 关闭
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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<Dialog>,
|
||||
/// How to ask for the latest release (the desktop app sets it; see `updates`).
|
||||
pub update_source: Option<updates::UpdateSource>,
|
||||
|
|
@ -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")?;
|
||||
|
|
|
|||
|
|
@ -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<String> =
|
||||
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]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue