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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* uk: the author's wording for the flatten setting

---------

Co-authored-by: samehelhawary <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Cody Vogt <[email protected]>
Co-authored-by: William Max & Co. <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-09 14:25:08 -04:00
1 parent f28cbbcdd9
commit 8e3782d5ca
24 files changed
+503 -22

No files matched your search

+110
View File
@@ -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};