Update image signatures live during resize and movement (#355)

* M5.7: update image signatures live during resize and movement

* M5.7: preserve image signature proportions when resizing corners

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Devon Llewellyn
2026-10-08 17:17:05 -04:00
committed by GitHub
co-authored by Brandon Thomas
parent b5edb59d84
commit acb8e96aff
18 changed files with 730 additions and 42 deletions
+3 -1
View File
@@ -94,7 +94,7 @@ Hours are for a single agent (low–high). "Done" is the estimated fraction of t
| M2 | Model, render, text | 200–350 | 15% | 170–300 | hayro bootstrap renderer (vendored patches). Text extraction reaches word-F1 0.98 against pdftotext. Missing: model crate, fonts, DisplayList, renderer independent of hayro |
| M3 | Viewer app (native + web) | 80–150 | 88% | 10–19 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel. |
| M4 | Engine, history, save, organize | 100–180 | 95% | 9–17 | Done:<br>- command registry (menus, shortcuts and palette all use it);<br>- undo/redo; incremental, atomic and encrypted saves;<br>- autosave and crash recovery;<br>- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;<br>- CLI `edit/combine/extract/split`.<br>Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize. Missing: Replace pages, page transitions, recovery on the web |
| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 15–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export; flatten; Fill & Sign; agent tools. Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews and selection for resizing. Missing: custom stamps, replace-text proposals, summary layouts with the page |
| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 15–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export; flatten; Fill & Sign; agent tools. Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. Missing: custom stamps, replace-text proposals, summary layouts with the page |
| M6 | Forms + JavaScript | 160–320 | 45% | 95–185 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done. Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA |
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 17% | 210–420 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), image/PDF watermarks |
| M8 | Security + redaction | 100–180 | 66% | 35–65 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize. Missing: certificate security, redaction codes and pattern locales, DCT re-encoding |
@@ -125,6 +125,8 @@ M0 → M1 → M2 → M3 → M4 must happen in order. After M4, M5–M12 can run
## Log
- **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 (M5.7, signature placement):** Image signatures and initials preview at the pointer at their actual page size, including zoom and document/view rotation. A successful placement switches to Select with the new annotation selected; stamp resize handles use the existing engine edit, automation tool and undo history while preserving the appearance. Synthetic rendered UI and automation tests verify positioning, one placement per selection, resizing, undo/redo and save/reopen. Milestone and overall effort estimates unchanged.
- **2026-10-08 (M5.7, image signatures):** Fill & Sign imports local PNG/JPEG signatures and initials through the Image tab, with previews, local persistence and undoable placement. The shared engine validates bounded images and preserves transparency and orientation; `fill_sign_add` accepts an image path. Synthetic UI and automation tests cover cancellation, invalid input, settings, undo/redo and save/reopen rendering. Overall effort estimate unchanged (about 30–35%).
+1
View File
@@ -23,6 +23,7 @@ set_review_state(&mut doc, page, index, ReviewState::Accepted, "Ada", &meta)?;
move_annotation(&mut doc, page, index, dx, dy, &meta)?;
set_style(&mut doc, page, index, Some(rgb), Some(0.5), Some(2.0), &meta)?;
delete_annotation(&mut doc, page, index)?;
signature_image(&doc, page, index)?; // embedded image XObject of Fill & Sign signature/initials
```
A comment is addressed by `(page, index in /Annots)`, the same pair
+15
View File
@@ -662,6 +662,21 @@ fn annot_dict(doc: &Document, r: ObjRef) -> Dict {
doc.get(r).as_dict().cloned().unwrap_or_default()
}
/// 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> {
let p = page_ref(doc, page)?;
let list = annots(doc, p);
let entry = list.get(index).ok_or(AnnotError::NoSuchAnnotation { page, index })?;
let obj = doc.resolve(entry);
let Some(d) = obj.as_dict() else { return Ok(None) };
Ok((d.name(b"Subtype") == Some(b"Stamp")
&& matches!(d.name(b"Name"), Some(b"PCCustomSignature" | b"PCCustomInitials"))
&& matches!(d.get(b"PCPictureImage"), Some(Object::Bool(true))))
.then(|| d.reference(b"PCPicture"))
.flatten())
}
// ── building ────────────────────────────────────────────────────────────────────────────────
/// Annotation flags (§12.5.3).
+2
View File
@@ -23,6 +23,8 @@ mcp::McpServer::new(a).serve(stdin, stdout)? // newline-delimited JS
- Pages and positions are **1-based**. Rectangles are PDF points with the origin at the top-left of the displayed page.
- Unknown arguments are rejected, so typos fail loudly.
- Tools that change a document return its summary (`doc`, `pages`, `dirty`, `undo`, `redo`, …).
- `comment_image_preview` returns JSON and a PNG: `layer: "background"` (the default) gives the page without the selected image signature/initials; `layer: "image"` gives its embedded image with alpha. The metadata supplies its displayed rectangle, document rotation and annotation opacity. Each result can be saved with the CLI's `--out`. Cache the background and transform the image for live movement/resizing; `comment_edit` commits the final geometry in one undo step. Previewing never changes the document.
- For proportional image signature resizing, use the embedded image layer's width/height ratio rather than a previously stretched annotation rectangle. The GUI preserves this ratio at corners with the opposite corner anchored; edge midpoint handles change only their own axis. `comment_edit` accepts the resulting rectangle.
- `doc_close` refuses to drop unsaved changes unless `discard_changes: true`. `doc_save` writes atomically, incrementally in place, and in full for a new path.
- With a root set, every read and write path must resolve inside it (symlinks and `..` included). Relative paths resolve inside the root, and `..` is resolved by name before the check. Every path outside the root gets the same refusal (`<path> is outside the allowed directory <root>`), whether or not it exists, so a confined agent can't probe the rest of the disk; on Windows another network share or device path (`\\host\share`, `\\?\UNC\…`, `\\.\…`) is refused without being contacted. "Not found" and other filesystem errors are reported only for paths inside the root. Tools that write several files into a folder name them after the document or input file, with separators, colons and control characters replaced by `_`, so a name can't lead them out of the folder. The root itself must be a folder.
+29 -1
View File
@@ -7,7 +7,7 @@ use pdfcraft_engine::{Edit, Markup, NOTE_SIZE, NewAnnotation, NoteIcon, ReviewSt
use pdfcraft_render::{Annotation, PageInfo};
use serde_json::{Value, json};
use crate::{Args, Automation, Result, ToolError, failed};
use crate::{Args, Automation, Content, DEFAULT_DPI, MAX_DPI, Result, ToolError, encode_png, failed};
/// Author used when a tool call names none.
pub(crate) const DEFAULT_AUTHOR: &str = "PdfCraft";
@@ -86,6 +86,34 @@ impl Args<'_> {
}
impl Automation {
/// One of the read-only layers the GUI uses to drag/resize an embedded image signature.
pub(crate) fn comment_image_preview(&self, a: &Args) -> Result<Vec<Content>> {
let (page, index) = self.comment_target(a)?;
let dpi = a.opt_num("dpi")?.unwrap_or(DEFAULT_DPI);
if !(1.0..=MAX_DPI).contains(&dpi) {
return Err(ToolError::InvalidArgs(format!("dpi must be between 1 and {MAX_DPI}")));
}
let doc = self.doc(a)?;
let preview = doc.image_signature_preview(page, index).map_err(failed)?.ok_or_else(|| failed("choose an image signature or initials"))?;
let annotation = doc.info.annotations.iter().find(|c| c.page == page && c.index == index).ok_or_else(|| failed("no such comment"))?;
let info = doc.info.pages.get(page).ok_or_else(|| failed("no such page"))?;
let [w, h] = preview.image.size();
let layer = a.opt_str("layer")?.unwrap_or("background");
let image = match layer {
"image" => Content::Png { data: preview.image.bytes().as_ref().clone(), width: w as u32, height: h as u32 },
"background" => {
let out = preview.render_background((dpi / 72.0) as f32).map_err(failed)?;
Content::Png { data: encode_png(out.width, out.height, &out.rgba)?, width: out.width, height: out.height }
}
_ => return Err(ToolError::InvalidArgs("layer must be background or image".into())),
};
Ok(vec![
Content::Json(json!({ "page": page + 1, "index": index + 1, "rect": rect_to_view(info, annotation.rect), "rotation": info.rotation,
"layer": layer, "opacity": preview.opacity, "dpi": dpi })),
image,
])
}
/// Comments of a document, optionally only of one 0-based page.
fn comments(&self, a: &Args) -> Result<Vec<Annotation>> {
let doc = self.doc(a)?;
+1
View File
@@ -155,6 +155,7 @@ impl Automation {
self.apply(&a, edit)?
}
"page_render" => return self.page_render(&a).map(|c| vec![c]),
"comment_image_preview" => return self.comment_image_preview(&a),
"text_extract" => self.text_extract(&a)?,
"text_find" => self.text_find(&a)?,
"page_rotate" => {
+2
View File
@@ -629,6 +629,8 @@ pub fn tools() -> Vec<ToolDef> {
})),
&["doc"],
)),
t("comment_image_preview", "Preview image signature layers", "Return a PNG of the page without the selected image signature/initials (layer=background, default), or its embedded image with alpha (layer=image). Includes the displayed rectangle, document rotation and annotation opacity. Cache these layers for live placement/resizing; commit once with comment_edit. Does not change the document or undo history.")
.ro().with(schema(comment_ref(json!({ "layer": { "type": "string", "enum": ["background", "image"], "default": "background" }, "dpi": { "type": "number", "minimum": 1, "maximum": 600, "default": 96 } })), &["doc"])),
t("comment_delete", "Delete a comment", "Delete a comment with its pop-up and replies. Undoable.").destructive().with(schema(comment_ref(json!({})), &["doc"])),
t(
"doc_protect",
+63
View File
@@ -1432,6 +1432,69 @@ fn image_signatures_through_tools_preserve_transparency_and_survive_save() {
assert_eq!(ok(&mut a, "comment_list", json!({ "doc": doc }))["count"], 2, "errors leave the PDF intact");
}
#[test]
fn image_signature_preview_layers_are_read_only_and_survive_encrypted_save() {
let dir = workdir("signature-preview");
let mut png = Vec::new();
{
let mut encoder = png::Encoder::new(&mut png, 120, 40);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let rgba: Vec<u8> = (0..40)
.flat_map(|y| (0..120).flat_map(move |x| if (40..80).contains(&x) && (10..30).contains(&y) { [20, 40, 60, 128] } else { [0, 0, 0, 0] }))
.collect();
encoder.write_header().unwrap().write_image_data(&rgba).unwrap();
}
std::fs::write(dir.join("signature.png"), png).unwrap();
std::fs::write(dir.join("form.pdf"), fixture(1)).unwrap();
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap();
ok(&mut a, "page_rotate", json!({ "doc": doc, "pages": [1], "degrees": 90 }));
ok(&mut a, "fill_sign_add", json!({ "doc": doc, "page": 1, "type": "initials", "at": [20, 40], "path": "signature.png" }));
let background = a.call("page_render", &json!({ "doc": doc, "page": 1, "dpi": 72 })).unwrap();
let Content::Png { data: expected, .. } = &background[0] else { panic!() };
let expected = image::load_from_memory(expected).unwrap().to_rgba8();
ok(&mut a, "fill_sign_add", json!({ "doc": doc, "page": 1, "type": "signature", "at": [120, 100], "path": "signature.png" }));
ok(&mut a, "comment_edit", json!({ "doc": doc, "page": 1, "index": 2, "opacity": 0.5 }));
ok(&mut a, "doc_protect", json!({ "doc": doc, "open_password": "preview-test" }));
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "signed.pdf" }));
let reopened = ok(&mut a, "doc_open", json!({ "path": "signed.pdf", "password": "preview-test" }))["doc"].as_u64().unwrap();
for doc in [doc, reopened] {
let before = ok(&mut a, "doc_info", json!({ "doc": doc }));
let d = a.session().docs().iter().find(|d| d.id.0 == doc).unwrap();
let generation = d.edit_generation();
let bytes = d.bytes.clone();
let output = a.call("comment_image_preview", &json!({ "doc": doc, "page": 1, "index": 2, "dpi": 72 })).unwrap();
let [Content::Json(meta), Content::Png { data: background, .. }] = output.as_slice() else { panic!("labeled background layer") };
let image = a.call("comment_image_preview", &json!({ "doc": doc, "page": 1, "index": 2, "layer": "image" })).unwrap();
let [Content::Json(image_meta), Content::Png { data: signature, width, height }] = image.as_slice() else { panic!("labeled image layer") };
assert_eq!(meta["layer"], "background");
assert_eq!(image_meta["layer"], "image");
assert_eq!(meta["rotation"], 90);
assert_eq!(meta["opacity"], 0.5);
assert_eq!((*width, *height), (120, 40));
assert_eq!(image::load_from_memory(background).unwrap().to_rgba8(), expected, "page text and the other signature remain");
let signature = image::load_from_memory(signature).unwrap().to_rgba8();
assert_eq!(signature.get_pixel(5, 20).0[3], 0);
assert_eq!(signature.get_pixel(60, 20).0, [20, 40, 60, 128], "embedded alpha and colour survive reopen");
assert_eq!(ok(&mut a, "doc_info", json!({ "doc": doc })), before);
let d = a.session().docs().iter().find(|d| d.id.0 == doc).unwrap();
assert_eq!(d.edit_generation(), generation);
assert!(std::sync::Arc::ptr_eq(&bytes, &d.bytes));
assert!(d.image_signature_preview(0, 1).unwrap().unwrap().render_background(f32::NAN).is_err());
assert!(d.image_signature_preview(0, 999).is_err());
}
assert!(tools().iter().find(|t| t.name == "comment_image_preview").unwrap().read_only);
for dpi in [0, 601] {
assert!(matches!(
a.call("comment_image_preview", &json!({ "doc": reopened, "page": 1, "index": 2, "dpi": dpi })),
Err(ToolError::InvalidArgs(_))
));
}
ok(&mut a, "fill_sign_add", json!({ "doc": reopened, "page": 1, "type": "check", "at": [10, 10] }));
assert!(a.call("comment_image_preview", &json!({ "doc": reopened, "page": 1, "index": 3 })).is_err());
}
#[test]
fn creating_and_reducing_through_tools() {
let dir = workdir("create");
+1 -1
View File
@@ -23,7 +23,7 @@ pub mod ocr;
pub mod signature_image;
pub mod xfa;
pub use signature_image::SignatureImage;
pub use signature_image::{ImageSignaturePreview, SignatureImage};
pub use pdfcraft_organize::{BoxSpec, PageBox, SplitBy, split_ranges};
+96 -1
View File
@@ -5,7 +5,64 @@ use std::sync::Arc;
use image::{DynamicImage, ImageDecoder, ImageFormat, ImageReader, Limits};
use crate::{Edit, MarkFile};
use crate::{Document, Edit, MarkFile, guard};
/// Read-only layers for interactive movement: the page without this signature, and its image.
/// Preparing them never changes the working bytes, dirty flag or undo history.
pub struct ImageSignaturePreview {
pub image: SignatureImage,
pub opacity: f32,
page: usize,
bytes: Arc<Vec<u8>>,
config: pdfcraft_render::RenderConfig,
}
impl ImageSignaturePreview {
/// One background worker; the UI reuses its page texture throughout a gesture.
pub fn renderer(&self) -> pdfcraft_render::RenderPool {
pdfcraft_render::RenderPool::new(self.bytes.clone(), 1, self.config.clone())
}
/// Synchronous counterpart for automation, with the renderer's normal pixel limits.
pub fn render_background(&self, scale: f32) -> Result<pdfcraft_render::RenderedPage, String> {
if !scale.is_finite() || scale <= 0.0 {
return Err("preview scale must be finite and positive".into());
}
guard(|| {
let mut renderer = pdfcraft_render::PageRenderer::new(self.bytes.clone(), self.config.clone());
let out = renderer.render(pdfcraft_render::RenderRequest { page: self.page, scale, ..Default::default() });
if let Some(e) = &out.error {
return Err(e.clone());
}
Ok(out)
})?
}
}
impl Document {
/// Prepare the actual embedded signature, including alpha, and an independent background.
/// A cheap COS clone excludes only this annotation; other page content remains visible.
pub fn image_signature_preview(&self, page: usize, index: usize) -> Result<Option<ImageSignaturePreview>, String> {
guard(|| {
let editor = self.editor.as_ref().ok_or("the document can't be read")?;
let Some(picture) = pdfcraft_annot::signature_image(&editor.cos, page, index).map_err(|e| e.to_string())? else { return Ok(None) };
let obj = editor.cos.get(picture);
let pdfcraft_cos::Object::Stream(s) = &*obj else { return Err("the signature image is damaged".into()) };
let (w, h) = (s.dict.int(b"Width").unwrap_or(0), s.dict.int(b"Height").unwrap_or(0));
if w <= 0 || h <= 0 || w > 4096 || h > 4096 || w.saturating_mul(h) > MAX_PIXELS as i64 {
return Err(SignatureImageError::Size.to_string());
}
let (_, bytes) = pdfcraft_create::image_file(&editor.cos, picture)?;
let image = SignatureImage::from_bytes(&bytes).map_err(|e| e.to_string())?;
let opacity =
pdfcraft_annot::props(&editor.cos, page, index).map(|p| p.opacity).filter(|v| v.is_finite()).unwrap_or(1.0).clamp(0.0, 1.0) as f32;
let mut cos = editor.cos.clone();
pdfcraft_annot::delete_annotation(&mut cos, page, index).map_err(|e| e.to_string())?;
let bytes = Arc::new(pdfcraft_cos::write_incremental(&cos, &pdfcraft_cos::SaveOptions::default()).map_err(|e| e.to_string())?);
Ok(Some(ImageSignaturePreview { image, opacity, page, bytes, config: self.config.clone() }))
})?
}
}
/// Saved signatures live in settings, so both source and normalized PNG are bounded.
pub const MAX_SIGNATURE_IMAGE_BYTES: usize = 4 << 20;
@@ -183,4 +240,42 @@ mod tests {
let a = &session.get(id).unwrap().info.annotations[0];
assert_eq!(a.rect, [20.0, 99.75, 170.0, 100.25]);
}
#[test]
fn embedded_signature_preview_preserves_alpha_without_editing_the_document() {
let image = SignatureImage::from_bytes(&png(120, 40)).unwrap();
let mut session = crate::Session::new();
let blank = session.create_blank(300.0, 400.0, 1).unwrap();
let id = session.open_new("form.pdf", blank).unwrap();
session.apply(id, image.edit(0, [20.0, 100.0], false, "Ada").unwrap()).unwrap();
let doc = session.get(id).unwrap();
let bytes = doc.bytes.clone();
let generation = doc.edit_generation();
let preview = doc.image_signature_preview(0, 0).unwrap().unwrap();
assert_eq!(preview.image.rgba(), image.rgba());
let background = preview.render_background(1.0).unwrap();
assert!(background.rgba.chunks_exact(4).all(|p| p == [255, 255, 255, 255]));
assert!(Arc::ptr_eq(&bytes, &doc.bytes));
assert_eq!(doc.edit_generation(), generation);
assert_eq!(doc.can_undo(), Some("Add signature"));
assert!(doc.image_signature_preview(0, 1).is_err());
assert!(preview.render_background(0.0).is_err());
}
#[test]
fn oversized_embedded_signature_preview_is_rejected_before_decoding() {
let image = SignatureImage::from_bytes(&png(120, 40)).unwrap();
let mut session = crate::Session::new();
let blank = session.create_blank(300.0, 400.0, 1).unwrap();
let id = session.open_new("form.pdf", blank).unwrap();
session.apply(id, image.edit(0, [20.0, 100.0], false, "Ada").unwrap()).unwrap();
let doc = session.doc_mut(id).unwrap();
let cos = &mut doc.editor.as_mut().unwrap().cos;
let picture = pdfcraft_annot::signature_image(cos, 0, 0).unwrap().unwrap();
let pdfcraft_cos::Object::Stream(mut stream) = (*cos.get(picture)).clone() else { panic!() };
stream.dict.set(b"Width".to_vec(), pdfcraft_cos::Object::Int(i64::MAX));
cos.set(picture, pdfcraft_cos::Object::Stream(stream));
let error = doc.image_signature_preview(0, 0).err().unwrap();
assert!(error.contains("4 megapixels"), "{error}");
}
}
+27
View File
@@ -277,6 +277,7 @@ pub struct DocView {
pub fill_text: Option<crate::fill_sign::TypeBox>,
/// A queued Fill & Sign signature: select it after its edit succeeds, then leave placement.
pub(crate) fill_signature_page: Option<usize>,
pub(crate) signature_drag: crate::signature_drag::SignatureDrag,
/// A non-edit action requested by the organize toolbar, handled by the app.
pub pending_action: Option<ViewAction>,
}
@@ -384,6 +385,7 @@ impl DocView {
marquee_done: None,
fill_text: None,
fill_signature_page: None,
signature_drag: Default::default(),
}
}
@@ -866,6 +868,7 @@ impl DocView {
} else {
let tex = ctx.load_texture(format!("page-{:?}-{page}", self.id), img, TextureOptions::LINEAR);
self.pages.insert(page, PageTex { tag: r.request.tag, tex });
self.signature_drag.page_received(page);
self.waiting_since.remove(&page);
}
}
@@ -957,6 +960,26 @@ pub struct PageXform {
}
impl PageXform {
/// Draw an image in PDF user space, preserving orientation through both page rotations.
pub(crate) fn paint_user_image(
&self,
painter: &egui::Painter,
tex: egui::TextureId,
info: &DocInfo,
page: usize,
rect: [f64; 4],
color: Color32,
) {
let Some(p) = info.pages.get(page) else { return };
let mut mesh = egui::Mesh::with_texture(tex);
for (x, y, u, v) in [(rect[0], rect[3], 0.0, 0.0), (rect[2], rect[3], 1.0, 0.0), (rect[2], rect[1], 1.0, 1.0), (rect[0], rect[1], 0.0, 1.0)] {
let p = p.user_to_view(x as f32, y as f32);
mesh.vertices.push(egui::epaint::Vertex { pos: self.norm_to_screen(p[0] / self.pw, p[1] / self.ph), uv: pos2(u, v), color });
}
mesh.add_triangle(0, 1, 2);
mesh.add_triangle(0, 2, 3);
painter.add(egui::Shape::mesh(mesh));
}
pub fn norm_to_screen(&self, u: f32, v: f32) -> Pos2 {
let (a, b) = match self.rot {
90 => (1.0 - v, u),
@@ -1578,6 +1601,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
let on_link = tool == QuickTool::Link && can_modify && crate::link_ui::page_input(ui, &resp, &xf, i, info, &doc_links, view);
let consumed = on_edit_text || on_link || on_content || boxing || on_field || comments::page_input(ui, &resp, &pcx, view);
let preview_target = (tool == QuickTool::Select && !comments_hidden).then_some(view.comments.selected).flatten();
view.signature_drag.prepare(ui.ctx(), doc, preview_target, scale);
view.signature_drag.paint(painter, &pcx, &view.comments, view.pending_edit.as_ref());
// Text layer: find matches, selection, I-beam and drag-to-select.
let to_screen = |g: [f32; 4]| xf.view_rect(g);
if let Some(text) = view.texts.get(&i).cloned() {
+91 -15
View File
@@ -335,7 +335,7 @@ pub enum Gesture {
/// Moving a comment, from the press position on screen.
Move { page: usize, index: usize, from: Pos2 },
/// Resizing a comment by one of its handles: (dx, dy) ∈ {-1, 0, 1}² says which sides move.
Resize { page: usize, index: usize, handle: (i8, i8), from: Pos2 },
Resize { page: usize, index: usize, handle: (i8, i8), from: Pos2, aspect_ratio: Option<f32> },
}
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -371,6 +371,8 @@ pub struct CommentView {
/// The selected comment: (page, index in `/Annots`).
pub selected: Option<(usize, usize)>,
pub gesture: Option<Gesture>,
/// Escape ends egui's drag too; ignore its synthetic release until the mouse is up.
cancelled_drag: bool,
pub composer: Option<Composer>,
/// Reply being typed under the selected card.
pub reply: String,
@@ -474,9 +476,40 @@ impl PageCx<'_> {
self.comments().filter(|a| self.screen_rects(a).iter().any(|r| r.expand(3.0).contains(p))).last()
}
fn get(&self, index: usize) -> Option<&Annotation> {
pub(crate) fn get(&self, index: usize) -> Option<&Annotation> {
self.comments().find(|a| a.index == index)
}
pub(crate) fn rect_to_user(&self, r: Rect) -> [f64; 4] {
let (a, b) = (self.to_user(r.left_top()), self.to_user(r.right_bottom()));
[a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])]
}
/// The same geometry drives the live image, its handles, and the edit on release.
pub(crate) fn adjusted_rect(&self, a: &Annotation, gesture: Option<&Gesture>, pointer: Option<Pos2>, pending: Option<&Edit>) -> Rect {
let r = self.screen_rect(a);
if let Some(Edit::ResizeAnnotation { page, index, rect }) = pending
&& (*page, *index) == (self.page, a.index)
{
return self.xf.user_rect(self.info, self.page, rect.map(|v| v as f32));
}
if let Some(Edit::MoveAnnotation { page, index, dx, dy }) = pending
&& (*page, *index) == (self.page, a.index)
{
return self.xf.user_rect(
self.info,
self.page,
[a.rect[0] + *dx as f32, a.rect[1] + *dy as f32, a.rect[2] + *dx as f32, a.rect[3] + *dy as f32],
);
}
match (gesture, pointer) {
(Some(Gesture::Move { page, index, from }), Some(p)) if (*page, *index) == (self.page, a.index) => r.translate(p - *from),
(Some(Gesture::Resize { page, index, handle, from, aspect_ratio }), Some(p)) if (*page, *index) == (self.page, a.index) => {
resized(r, *handle, p - *from, *aspect_ratio)
}
_ => r,
}
}
}
fn is_markup(subtype: &str) -> bool {
@@ -505,7 +538,17 @@ fn handle_pos(r: Rect, (hx, hy): (i8, i8)) -> Pos2 {
pos2(x, y)
}
fn resized(r: Rect, (hx, hy): (i8, i8), d: egui::Vec2) -> Rect {
fn resized(r: Rect, (hx, hy): (i8, i8), d: egui::Vec2, aspect_ratio: Option<f32>) -> Rect {
if hx != 0
&& hy != 0
&& let Some(ratio) = aspect_ratio.filter(|v| v.is_finite() && *v > 0.0)
{
// As in content_ui, the larger requested dimension drives proportional corner resizing.
// Keep the opposite corner fixed and prevent crossing it from flipping the image.
let width = (r.width() + f32::from(hx) * d.x).max((r.height() + f32::from(hy) * d.y) * ratio).max(4.0).max(4.0 * ratio);
let anchor = handle_pos(r, (-hx, -hy));
return Rect::from_two_pos(anchor, anchor + vec2(f32::from(hx) * width, f32::from(hy) * width / ratio));
}
let mut r = r;
match hx {
-1 => r.min.x += d.x,
@@ -529,6 +572,14 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>,
let pressed_here = origin.is_some_and(|o| page_rect.contains(o));
let over_page = pointer.is_some_and(|p| page_rect.contains(p));
let cv = &mut view.comments;
if cv.gesture.is_some() && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) {
cv.gesture = None;
cv.cancelled_drag = true;
return true;
}
if cv.cancelled_drag {
return true;
}
if resp.secondary_clicked()
&& let Some(p) = pointer.filter(|p| page_rect.contains(*p))
{
@@ -757,7 +808,12 @@ fn select_input(
&& let Some(o) = origin
{
if let (Some(h), Some(a)) = (handle_at(o), selected) {
cv.gesture = Some(Gesture::Resize { page: cx.page, index: a.index, handle: h, from: o });
let aspect_ratio = view.signature_drag.aspect_ratio(cx.page, a.index).and_then(|ratio| {
let p = cx.info.pages.get(cx.page)?;
let rotation = u32::from(p.rotation) + u32::from(cx.xf.rot);
Some(if rotation % 180 == 0 { ratio } else { ratio.recip() })
});
cv.gesture = Some(Gesture::Resize { page: cx.page, index: a.index, handle: h, from: o, aspect_ratio });
consumed = true;
} else if let Some(a) = cx.hit(o)
&& !is_markup(&a.subtype)
@@ -785,12 +841,11 @@ fn select_input(
view.pending_edit = Some(Edit::MoveAnnotation { page, index, dx, dy });
}
}
Some(Gesture::Resize { page, index, handle, from }) if page == cx.page => {
Some(Gesture::Resize { page, index, handle, from, aspect_ratio }) if page == cx.page => {
cv.gesture = None;
if let Some(a) = cx.get(index) {
let r = resized(cx.screen_rect(a), handle, p - from);
let (u0, u1) = (cx.to_user(r.left_top()), cx.to_user(r.right_bottom()));
let rect = [u0[0].min(u1[0]), u0[1].min(u1[1]), u0[0].max(u1[0]), u0[1].max(u1[1])];
let r = resized(cx.screen_rect(a), handle, p - from, aspect_ratio);
let rect = cx.rect_to_user(r);
if r.width() >= 4.0 && r.height() >= 4.0 {
view.pending_edit = Some(Edit::ResizeAnnotation { page, index, rect });
}
@@ -878,14 +933,11 @@ pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, cx: &PageCx<'_>
}
return paint_gesture(painter, cx, view);
}
let mut r = cx.screen_rect(a).expand(2.0);
if let (Some(p), Some(g)) = (pointer, &cv.gesture) {
match g {
Gesture::Move { page, index: gi, from } if *page == cx.page && *gi == index => r = r.translate(p - *from),
Gesture::Resize { page, index: gi, handle, from } if *page == cx.page && *gi == index => r = resized(r, *handle, p - *from),
_ => {}
}
if view.signature_drag.contains(page, index) && matches!(cv.gesture, Some(Gesture::Move { page: p, index: i, .. }) if (p, i) == (page, index))
{
return paint_gesture(painter, cx, view);
}
let r = cx.adjusted_rect(a, cv.gesture.as_ref(), pointer, view.pending_edit.as_ref()).expand(2.0);
painter.rect_stroke(r, CornerRadius::ZERO, Stroke::new(1.0, SELECT_BLUE), egui::StrokeKind::Middle);
if cx.allowed && resizable(a) {
for h in HANDLES {
@@ -1129,6 +1181,9 @@ pub fn text_box_rect(at: [f64; 2], text: &str, size: f64) -> [f64; 4] {
/// Delete / Escape handling for comments (only while no text field has focus).
pub(crate) fn keys(ctx: &egui::Context, view: &mut DocView, tool: &mut QuickTool, allowed: bool) {
if !ctx.input(|i| i.pointer.any_down()) {
view.comments.cancelled_drag = false;
}
if ctx.egui_wants_keyboard_input() {
return;
}
@@ -1315,6 +1370,27 @@ pub fn status_badge(state: &str) -> Option<(&'static str, &'static str, Color32)
mod tests {
use super::*;
#[test]
fn proportional_corners_keep_the_opposite_anchor_and_edges_stretch_one_axis() {
let r = Rect::from_min_max(pos2(100.0, 100.0), pos2(196.0, 132.0));
for (hx, hy) in [(-1, -1), (1, -1), (-1, 1), (1, 1)] {
for (dx, dy) in [(24.0, 0.0), (0.0, 16.0), (-48.0, -20.0), (-200.0, -200.0)] {
let out = resized(r, (hx, hy), vec2(f32::from(hx) * dx, f32::from(hy) * dy), Some(3.0));
assert_eq!(handle_pos(out, (-hx, -hy)), handle_pos(r, (-hx, -hy)));
assert!((out.width() / out.height() - 3.0).abs() < 0.001);
assert!(out.width() >= 4.0 && out.height() >= 4.0);
}
}
for handle in [(0, -1), (1, 0), (0, 1), (-1, 0)] {
let out = resized(r, handle, vec2(24.0, 16.0), Some(3.0));
assert_eq!(out, resized(r, handle, vec2(24.0, 16.0), None));
assert_eq!(if handle.0 == 0 { out.width() } else { out.height() }, if handle.0 == 0 { r.width() } else { r.height() });
}
// Ordinary comment corners keep their existing independent width/height behavior.
let free = resized(r, (1, 1), vec2(24.0, 0.0), None);
assert_eq!(free.size(), vec2(120.0, 32.0));
}
#[test]
fn tools_round_trip_through_their_commands() {
for t in ALL {
+1
View File
@@ -37,6 +37,7 @@ impl PdfCraftApp {
let Some(doc) = self.session.get(id) else { return true };
let info = &doc.info;
let view = &mut self.views[i];
view.signature_drag.committed(&edit, doc.edit_generation());
match comment_page(&edit) {
// Comment edits change one page: keep every other raster.
Some(page) => view.page_changed(page),
+2 -18
View File
@@ -381,25 +381,9 @@ pub(crate) fn page_input(
};
if let Some(SavedSig::Image(image)) = saved {
if let Some(rect) = image.rect(at, tool == FillTool::Initials) {
// Map each corner, rather than the bounding box, so page and view rotations
// turn the preview exactly as they turn the placed stamp.
let tex = image_texture(ui, image, preview);
let mut mesh = egui::Mesh::with_texture(tex);
let corners = [(rect[0], rect[3], 0.0, 0.0), (rect[2], rect[3], 1.0, 0.0), (rect[2], rect[1], 1.0, 1.0), (rect[0], rect[1], 0.0, 1.0)];
if let Some(p) = info.pages.get(page) {
for (x, y, u, v) in corners {
let p = p.user_to_view(x as f32, y as f32);
mesh.vertices.push(egui::epaint::Vertex {
pos: xf.norm_to_screen(p[0] / xf.pw, p[1] / xf.ph),
uv: pos2(u, v),
color: Color32::WHITE,
});
}
mesh.add_triangle(0, 1, 2);
mesh.add_triangle(0, 2, 3);
ui.painter().add(egui::Shape::mesh(mesh));
ui.ctx().set_cursor_icon(egui::CursorIcon::None);
}
xf.paint_user_image(ui.painter(), tex, info, page, rect, Color32::WHITE);
ui.ctx().set_cursor_icon(egui::CursorIcon::None);
}
} else {
ui.ctx().set_cursor_icon(if tool == FillTool::Text { egui::CursorIcon::Text } else { egui::CursorIcon::Crosshair });
+1
View File
@@ -76,6 +76,7 @@ mod panels;
mod pickers;
pub mod prepare;
mod print_ui;
mod signature_drag;
pub use print_ui::{Handling as PrintHandling, PrintDraft, Which as PrintWhich};
mod redact_ui;
pub use redact_ui::{HiddenDraft, PagesDraft as RedactPagesDraft, RedactPrefs, SearchDraft as RedactSearchDraft};
+141
View File
@@ -0,0 +1,141 @@
//! Cached page/image layers: a signature follows each pointer frame without editing the PDF.
use egui::{ColorImage, TextureHandle, TextureOptions};
use pdfcraft_engine::{Document, Edit};
use pdfcraft_render::{RenderPool, RenderRequest};
use crate::QuickTool;
use crate::comments::{CommentView, Gesture, PageCx};
#[derive(Default)]
pub(crate) struct SignatureDrag {
key: Option<(u64, usize, usize)>,
aspect_ratio: Option<f32>,
layers: Option<Layers>,
}
struct Layers {
image: TextureHandle,
opacity: f32,
renderer: RenderPool,
background: Option<TextureHandle>,
tag: Option<u64>,
received_tag: Option<u64>,
/// Keep the final position visible until the normal page raster catches up.
settling: bool,
}
impl SignatureDrag {
pub fn prepare(&mut self, ctx: &egui::Context, doc: &Document, selected: Option<(usize, usize)>, scale: f32) {
let key = selected.map(|(p, i)| (doc.edit_generation(), p, i));
if self.key != key {
self.key = key;
self.aspect_ratio = None;
self.layers = None;
if let Some((_, page, index)) = key
&& doc.info.annotations.iter().any(|a| a.page == page && a.index == index && a.subtype == "Stamp" && !a.locked)
&& let Ok(Some(preview)) = doc.image_signature_preview(page, index)
{
let [width, height] = preview.image.size();
self.aspect_ratio = Some(width as f32 / height as f32);
self.layers = Some(Layers {
opacity: preview.opacity,
image: ctx.load_texture(
"signature-drag-image",
ColorImage::from_rgba_unmultiplied(preview.image.size(), preview.image.rgba()),
TextureOptions::LINEAR,
),
renderer: preview.renderer(),
background: None,
tag: None,
received_tag: None,
settling: false,
});
}
}
let (Some((_, page, _)), Some(layers)) = (self.key, self.layers.as_mut()) else { return };
// The image stays sharp at every zoom; bound the extra background raster to 2048².
let Some(p) = doc.info.pages.get(page) else { return };
let scale = scale.min(2048.0 / p.width.max(p.height).max(1.0));
let tag = (scale * 1000.0) as u64;
if layers.tag != Some(tag) {
layers.renderer.set_queue(vec![RenderRequest { page, scale, tag, ..Default::default() }]);
layers.tag = Some(tag);
}
if let Some(out) = layers.renderer.try_recv()
&& out.request.tag == tag
{
if out.error.is_none() {
layers.received_tag = Some(tag);
layers.background = Some(ctx.load_texture(
"signature-drag-background",
ColorImage::from_rgba_premultiplied([out.width as usize, out.height as usize], &out.rgba),
TextureOptions::LINEAR,
));
} else {
// Leave the ordinary page raster usable if an unsupported PDF can't preview.
self.layers = None;
return;
}
}
if layers.received_tag != Some(tag) {
ctx.request_repaint_after(std::time::Duration::from_millis(16));
}
}
pub fn contains(&self, page: usize, index: usize) -> bool {
self.key.is_some_and(|(_, p, i)| (p, i) == (page, index)) && self.layers.is_some()
}
/// Use the embedded image's proportions, even after an edge handle stretched its rectangle.
pub fn aspect_ratio(&self, page: usize, index: usize) -> Option<f32> {
self.key.filter(|(_, p, i)| (*p, *i) == (page, index)).and(self.aspect_ratio)
}
/// Movement/resizing only change this signature's geometry, so its background is reusable.
pub fn committed(&mut self, edit: &Edit, generation: u64) {
if let Edit::MoveAnnotation { page, index, .. } | Edit::ResizeAnnotation { page, index, .. } = edit
&& self.contains(*page, *index)
{
self.key = Some((generation, *page, *index));
if let Some(layers) = &mut self.layers {
layers.settling = true;
}
} else {
*self = Self::default();
}
}
pub fn page_received(&mut self, page: usize) {
if self.key.is_some_and(|(_, p, _)| p == page)
&& let Some(layers) = &mut self.layers
{
layers.settling = false;
}
}
pub fn paint(&self, painter: &egui::Painter, cx: &PageCx<'_>, cv: &CommentView, pending: Option<&Edit>) {
let (Some((_, page, index)), Some(layers)) = (self.key, &self.layers) else { return };
if page != cx.page || cx.tool != QuickTool::Select || cx.hidden {
return;
}
let dragging = matches!(cv.gesture, Some(Gesture::Move { page: p, index: i, .. } | Gesture::Resize { page: p, index: i, .. }) if (p, i) == (page, index));
let released = matches!(pending, Some(Edit::MoveAnnotation { page: p, index: i, .. } | Edit::ResizeAnnotation { page: p, index: i, .. }) if (*p, *i) == (page, index));
if !(dragging || released || layers.settling) {
return;
}
let (Some(background), Some(a)) = (&layers.background, cx.get(index)) else { return };
let rect = cx.adjusted_rect(a, cv.gesture.as_ref(), painter.ctx().input(|i| i.pointer.hover_pos()), pending);
let rect = cx.rect_to_user(rect);
let painter = painter.with_clip_rect(painter.clip_rect().intersect(cx.xf.rect));
cx.xf.paint_image(&painter, background.id(), 0.0, 0.0, 1.0, 1.0);
cx.xf.paint_user_image(
&painter,
layers.image.id(),
cx.info,
page,
rect,
egui::Color32::from_white_alpha((layers.opacity * 255.0).round() as u8),
);
}
}
+251 -2
View File
@@ -14,9 +14,14 @@ trailer << /Root 1 0 R >>
%%EOF";
fn harness() -> Harness<'static, PdfCraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
harness_bytes(FIXTURE)
}
fn harness_bytes(fixture: &[u8]) -> Harness<'static, PdfCraftApp> {
let fixture = fixture.to_vec();
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.open_bytes("form.pdf", None, FIXTURE.to_vec()).unwrap();
app.open_bytes("form.pdf", None, fixture.clone()).unwrap();
app.set_option("left", "closed").unwrap();
app.set_option("zoom", "150").unwrap();
app.set_option("author", "Ada").unwrap();
@@ -510,6 +515,250 @@ fn image_signature_placement_selects_resize_handles_and_requires_reselecting_to_
std::fs::remove_file(path).unwrap();
}
fn assert_rect(actual: [f32; 4], expected: [f32; 4]) {
assert!(actual.iter().zip(expected).all(|(a, b)| (*a - b).abs() < 0.001), "{actual:?} != {expected:?}");
}
fn pixel_at(h: &Harness<'static, PdfCraftApp>, pixels: &image::RgbaImage, x: f32, y: f32) -> [u8; 4] {
let p = at(h, x, y);
pixels.get_pixel(p.x.round() as u32, p.y.round() as u32).0
}
fn has_blue_near(pixels: &image::RgbaImage, p: Pos2) -> bool {
(-5..=5).any(|dy| {
(-5..=5).any(|dx| {
let v = pixels.get_pixel((p.x as i32 + dx) as u32, (p.y as i32 + dy) as u32).0;
i32::from(v[2]) > i32::from(v[0]) + 40 && i32::from(v[2]) > i32::from(v[1]) + 35
})
})
}
#[test]
fn image_signature_live_resize_and_move_preserve_background_and_hide_moving_handles() {
// Original synthetic page content; a white patch over the old image would fail this test.
const GREEN_PAGE: &[u8] = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /Contents 4 0 R >> endobj\n4 0 obj << /Length 36 >> stream\n0.8 1 0.7 rg 0 0 300 400 re f\nendstream\nendobj\ntrailer << /Root 1 0 R >>\n%%EOF";
let mut h = harness_bytes(GREEN_PAGE);
let path = signature_file("live");
let image = pdfcraft_engine::SignatureImage::read(std::fs::File::open(&path).unwrap()).unwrap();
h.state_mut().signature = Some(SavedSig::Image(image));
h.state_mut().execute("sign.fill.signature");
click(&mut h, 40.0, 250.0);
rendered_ink(&mut h, 90.0, 250.0);
let generation = h.state().session.get(h.state().views[0].id).unwrap().edit_generation();
let corner = at(&h, 136.0, 234.0);
h.drag_at(corner);
h.run_steps(1);
let end = at(&h, 184.0, 228.0);
h.hover_at(end);
// Wait only for the one-time background preparation. Later pointer frames are immediate.
rendered_ink(&mut h, 160.0, 242.0);
let end = at(&h, 220.0, 236.0);
h.hover_at(end);
h.run_steps(2);
let resized = h.render().unwrap();
assert!(pixel_at(&h, &resized, 200.0, 236.0)[..3].iter().all(|v| *v < 60), "ink resizes before release");
let assert_green = |v: [u8; 4]| {
assert!(v[1] > 230 && i32::from(v[1]) > i32::from(v[0]) + 10 && i32::from(v[1]) > i32::from(v[2]) + 10, "page content is preserved: {v:?}")
};
assert_green(pixel_at(&h, &resized, 90.0, 250.0));
assert!(has_blue_near(&resized, at(&h, 220.0, 266.0)), "resizing keeps its handles visible");
let doc = h.state().session.get(h.state().views[0].id).unwrap();
assert_eq!(doc.edit_generation(), generation, "pointer frames never edit the document");
assert_eq!(doc.can_undo(), Some("Add signature"));
assert_rect(doc.info.annotations[0].rect, [40.0, 234.0, 136.0, 266.0]);
if let Ok(dir) = std::env::var("PDFCRAFT_SHOTS") {
resized.save(format!("{dir}/image-signature-live-resize.png")).unwrap();
}
h.drop_at(end);
h.run_steps(3);
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Resize comment"));
let start = at(&h, 130.0, 236.0);
h.drag_at(start);
h.run_steps(1);
for (x, y) in [(140.0, 180.0), (150.0, 140.0)] {
h.hover_at(at(&h, x, y));
h.run_steps(2);
let moving = h.render().unwrap();
assert!(pixel_at(&h, &moving, x, y)[..3].iter().all(|v| *v < 60), "ink follows each pointer position");
assert_green(pixel_at(&h, &moving, 130.0, 236.0));
assert_green(pixel_at(&h, &moving, x - 85.0, y));
for (hx, hy) in [
(x - 90.0, y + 30.0),
(x, y + 30.0),
(x + 90.0, y + 30.0),
(x + 90.0, y),
(x + 90.0, y - 30.0),
(x, y - 30.0),
(x - 90.0, y - 30.0),
(x - 90.0, y),
] {
assert!(!has_blue_near(&moving, at(&h, hx, hy)), "moving hides the frame and all eight handles");
}
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Resize comment"));
if y == 140.0
&& let Ok(dir) = std::env::var("PDFCRAFT_SHOTS")
{
moving.save(format!("{dir}/image-signature-live-move.png")).unwrap();
}
}
let end = at(&h, 150.0, 140.0);
h.drop_at(end);
h.run_steps(2);
let released = h.render().unwrap();
assert!(has_blue_near(&released, at(&h, 240.0, 170.0)), "the frame returns immediately on release");
let doc = h.state().session.get(h.state().views[0].id).unwrap();
assert_eq!(doc.can_undo(), Some("Move comment"));
assert_eq!(doc.edit_generation(), generation + 2, "one edit for each completed gesture");
assert_rect(doc.info.annotations[0].rect, [60.0, 110.0, 240.0, 170.0]);
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)));
if let Ok(dir) = std::env::var("PDFCRAFT_SHOTS") {
released.save(format!("{dir}/image-signature-move-released.png")).unwrap();
}
h.state_mut().undo();
h.state_mut().undo();
h.run_steps(3);
assert_rect(h.state().session.get(h.state().views[0].id).unwrap().info.annotations[0].rect, [40.0, 234.0, 136.0, 266.0]);
rendered_ink(&mut h, 90.0, 250.0);
click(&mut h, 90.0, 250.0);
h.drag_at(at(&h, 90.0, 250.0));
h.run_steps(1);
h.hover_at(at(&h, 150.0, 180.0));
rendered_ink(&mut h, 150.0, 180.0);
assert_eq!(
h.state().session.get(h.state().views[0].id).unwrap().can_undo(),
Some("Add signature"),
"moving preview is still read-only before Escape"
);
h.key_press(egui::Key::Escape);
h.run_steps(2);
assert!(h.state().views[0].comments.gesture.is_none(), "Escape must cancel before releasing the mouse");
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Add signature"), "Escape must not commit");
h.drop_at(at(&h, 150.0, 180.0));
h.run_steps(2);
assert!(h.state().views[0].comments.gesture.is_none());
let doc = h.state().session.get(h.state().views[0].id).unwrap();
assert_eq!(doc.can_undo(), Some("Add signature"), "Escape cancels without an undo step");
assert_rect(doc.info.annotations[0].rect, [40.0, 234.0, 136.0, 266.0]);
let cancelled = h.render().unwrap();
assert_green(pixel_at(&h, &cancelled, 150.0, 180.0));
std::fs::remove_file(path).unwrap();
}
#[test]
fn embedded_image_signature_live_gestures_follow_document_and_view_rotation() {
let path = signature_file("live-rotations");
let image = pdfcraft_engine::SignatureImage::read(std::fs::File::open(&path).unwrap()).unwrap();
for (document_rotation, view_rotation) in [(90, 0), (0, 90), (90, 270)] {
let mut h = harness();
h.state_mut().apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: document_rotation });
while h.state().views[0].rotation != view_rotation {
h.state_mut().views[0].rotate_view(true);
}
h.state_mut().set_option("zoom", "100").unwrap();
h.state_mut().signature = Some(SavedSig::Image(image.clone()));
h.state_mut().execute("sign.fill.signature");
h.run_steps(3);
click(&mut h, 40.0, 250.0);
rendered_ink(&mut h, 90.0, 250.0);
// Future placements may use a different signature: drag the PDF's embedded image.
h.state_mut().signature = None;
h.drag_at(at(&h, 136.0, 234.0));
h.run_steps(1);
let end = at(&h, 184.0, 228.0);
h.hover_at(end);
rendered_ink(&mut h, 160.0, 242.0);
let pixels = h.render().unwrap();
assert!(pixel_at(&h, &pixels, 58.0, 254.0)[..3].iter().all(|v| *v < 60), "asymmetric ink rotates during resize");
assert!(pixel_at(&h, &pixels, 58.0, 230.0)[..3].iter().all(|v| *v > 240), "alpha isn't flipped");
h.drop_at(end);
h.run_steps(3);
h.drag_at(at(&h, 112.0, 242.0));
h.run_steps(1);
h.hover_at(at(&h, 132.0, 142.0));
h.run_steps(2);
let pixels = h.render().unwrap();
assert!(pixel_at(&h, &pixels, 132.0, 142.0)[..3].iter().all(|v| *v < 60), "image moves before release");
assert!(pixel_at(&h, &pixels, 160.0, 242.0)[..3].iter().all(|v| *v > 240), "old ink is removed during movement");
assert!(!has_blue_near(&pixels, at(&h, 204.0, 166.0)));
h.drop_at(at(&h, 132.0, 142.0));
h.run_steps(2);
assert!(has_blue_near(&h.render().unwrap(), at(&h, 204.0, 166.0)));
}
std::fs::remove_file(path).unwrap();
}
#[test]
fn image_signature_corners_restore_original_aspect_after_edge_resize_and_reopen() {
let path = signature_file("aspect");
let image = pdfcraft_engine::SignatureImage::read(std::fs::File::open(&path).unwrap()).unwrap();
let mut h = harness();
h.state_mut().signature = Some(SavedSig::Image(image));
h.state_mut().execute("sign.fill.signature");
click(&mut h, 70.0, 210.0);
// Side handles stretch just their own axis; pointer movement along the other axis is ignored.
for (from, to, expected) in
[([166.0, 210.0], [214.0, 216.0], [70.0, 194.0, 214.0, 226.0]), ([142.0, 194.0], [150.0, 170.0], [70.0, 170.0, 214.0, 226.0])]
{
h.drag_at(at(&h, from[0], from[1]));
h.run_steps(1);
let end = at(&h, to[0], to[1]);
h.hover_at(end);
h.run_steps(2);
h.drop_at(end);
h.run_steps(3);
assert_rect(h.state().session.get(h.state().views[0].id).unwrap().info.annotations[0].rect, expected);
}
let bytes = h.state().session.get(h.state().views[0].id).unwrap().bytes.clone();
// Reopened PDFs have no saved signature setting: each corner must use the embedded 3:1 image,
// rather than the stretched 144:56 annotation rectangle.
for (hx, hy) in [(-1.0, -1.0), (1.0, -1.0), (-1.0, 1.0), (1.0, 1.0)] {
let mut h = harness_bytes(&bytes);
assert!(h.state().signature.is_none());
h.state_mut().views[0].comments.selected = Some((0, 0));
h.run_steps(3);
rendered_ink(&mut h, 142.0, 198.0);
let generation = h.state().session.get(h.state().views[0].id).unwrap().edit_generation();
let (x, y) = (if hx < 0.0 { 70.0 } else { 214.0 }, if hy < 0.0 { 170.0 } else { 226.0 });
h.drag_at(at(&h, x, y));
h.run_steps(1);
let end = at(&h, x + hx * 10.0, y + hy * 10.0);
h.hover_at(end);
let expected = [
if hx < 0.0 { 16.0 } else { 70.0 },
if hy < 0.0 { 160.0 } else { 170.0 },
if hx < 0.0 { 214.0 } else { 268.0 },
if hy < 0.0 { 226.0 } else { 236.0 },
];
let ink_x = expected[0] + 198.0 * if hx < 0.0 { 0.15 } else { 0.85 };
let pixels = rendered_ink(&mut h, ink_x, (expected[1] + expected[3]) / 2.0);
assert!(has_blue_near(&pixels, at(&h, if hx < 0.0 { expected[0] } else { expected[2] }, if hy < 0.0 { expected[1] } else { expected[3] })));
let doc = h.state().session.get(h.state().views[0].id).unwrap();
assert_eq!(doc.edit_generation(), generation, "corner previews remain read-only");
assert_rect(doc.info.annotations[0].rect, [70.0, 170.0, 214.0, 226.0]);
if hx > 0.0
&& hy < 0.0
&& let Ok(dir) = std::env::var("PDFCRAFT_SHOTS")
{
pixels.save(format!("{dir}/image-signature-aspect-corner.png")).unwrap();
}
h.drop_at(end);
h.run_steps(3);
let doc = h.state().session.get(h.state().views[0].id).unwrap();
assert_rect(doc.info.annotations[0].rect, expected);
assert_eq!(doc.edit_generation(), generation + 1);
assert_eq!(doc.can_undo(), Some("Resize comment"));
assert!(((expected[2] - expected[0]) / (expected[3] - expected[1]) - 3.0).abs() < 0.001);
h.state_mut().undo();
h.run_steps(3);
assert_rect(h.state().session.get(h.state().views[0].id).unwrap().info.annotations[0].rect, [70.0, 170.0, 214.0, 226.0]);
h.state_mut().redo();
h.run_steps(3);
assert_rect(h.state().session.get(h.state().views[0].id).unwrap().info.annotations[0].rect, expected);
}
std::fs::remove_file(path).unwrap();
}
#[test]
fn image_import_cancel_clear_and_errors_preserve_saved_signatures() {
let path = signature_file("cancel");
+3 -3
View File
@@ -5213,9 +5213,9 @@ tier = "P0"
milestone = "M5"
status = "shipped"
commands = ["sign.fill.signature", "sign.fill.initials", "sign.fill.signature.change", "sign.fill.initials.change", "sign.fill.signature.remove", "sign.fill.initials.remove"]
tools = ["fill_sign_add", "comment_edit"]
evidence = ["crates/ui-egui/tests/fill_sign.rs::image_signature_preview_follows_pointer_at_page_size_with_zoom_and_rotation", "crates/ui-egui/tests/fill_sign.rs::image_signature_placement_selects_resize_handles_and_requires_reselecting_to_repeat", "crates/ui-egui/tests/fill_sign.rs::typed_signatures_and_initials", "crates/ui-egui/tests/fill_sign.rs::signing_draws_a_signature_once_and_places_it", "crates/ui-egui/tests/fill_sign.rs::changing_saved_signatures_and_initials_preserves_placed_marks", "crates/ui-egui/tests/fill_sign.rs::changing_drawn_signatures_can_be_cancelled_or_switched_to_type", "crates/ui-egui/tests/fill_sign.rs::saved_signature_cards_remove_and_add_without_changing_the_document", "crates/ui-egui/tests/fill_sign.rs::long_typed_names_fit_the_placed_signature_and_keep_every_outline", "crates/ui-egui/src/fill_sign.rs::long_signature_previews_have_clear_margins", "crates/ui-egui/tests/control.rs::saved_signature_can_be_changed_through_the_control_channel", "crates/fonts/src/script.rs::a_name_becomes_outlines", "crates/fonts/src/script.rs::long_names_keep_every_glyph_with_bounded_outline_work", "crates/automation/tests/automation.rs::fill_and_sign_through_tools", "crates/ui-egui/tests/fill_sign.rs::image_signatures_and_initials_can_be_imported_placed_and_remembered", "crates/ui-egui/tests/fill_sign.rs::image_import_cancel_clear_and_errors_preserve_saved_signatures", "crates/engine/src/signature_image.rs::image_signatures_preserve_alpha_and_fit_the_signature_box", "crates/engine/src/signature_image.rs::invalid_and_oversized_signature_images_are_rejected", "crates/engine/src/signature_image.rs::jpeg_signatures_are_normalized_to_png", "crates/engine/src/signature_image.rs::jpeg_signature_orientation_is_applied_before_preview_and_embedding", "crates/engine/src/signature_image.rs::very_wide_image_signatures_keep_the_requested_rectangle", "crates/automation/tests/automation.rs::image_signatures_through_tools_preserve_transparency_and_survive_save"]
notes = "signature and initials, typed (Dancing Script outlines as filled paths, no font embedded), drawn or imported from local PNG/JPEG images; saved in settings with preview cards and add/change/remove controls; changes leave placed annotations untouched; images preserve alpha, aspect ratio and EXIF orientation, fit within 150 pt by 32 pt (24 pt for initials), and are normalized without source paths or metadata; image previews follow the pointer at placement size with zoom and rotation; signatures/initials place once then select the new annotation; stamps resize through selection handles or comment_edit with their original appearance; native and web asynchronous pickers; fill_sign_add accepts path instead of text; image imports capped at 4 MiB, 4 megapixels and 4096 pixels per side"
tools = ["fill_sign_add", "comment_edit", "comment_image_preview"]
evidence = ["crates/ui-egui/tests/fill_sign.rs::image_signature_corners_restore_original_aspect_after_edge_resize_and_reopen", "crates/ui-egui/src/comments.rs::proportional_corners_keep_the_opposite_anchor_and_edges_stretch_one_axis", "crates/ui-egui/tests/fill_sign.rs::image_signature_live_resize_and_move_preserve_background_and_hide_moving_handles", "crates/ui-egui/tests/fill_sign.rs::embedded_image_signature_live_gestures_follow_document_and_view_rotation", "crates/automation/tests/automation.rs::image_signature_preview_layers_are_read_only_and_survive_encrypted_save", "crates/engine/src/signature_image.rs::embedded_signature_preview_preserves_alpha_without_editing_the_document", "crates/engine/src/signature_image.rs::oversized_embedded_signature_preview_is_rejected_before_decoding", "crates/ui-egui/tests/fill_sign.rs::image_signature_preview_follows_pointer_at_page_size_with_zoom_and_rotation", "crates/ui-egui/tests/fill_sign.rs::image_signature_placement_selects_resize_handles_and_requires_reselecting_to_repeat", "crates/ui-egui/tests/fill_sign.rs::typed_signatures_and_initials", "crates/ui-egui/tests/fill_sign.rs::signing_draws_a_signature_once_and_places_it", "crates/ui-egui/tests/fill_sign.rs::changing_saved_signatures_and_initials_preserves_placed_marks", "crates/ui-egui/tests/fill_sign.rs::changing_drawn_signatures_can_be_cancelled_or_switched_to_type", "crates/ui-egui/tests/fill_sign.rs::saved_signature_cards_remove_and_add_without_changing_the_document", "crates/ui-egui/tests/fill_sign.rs::long_typed_names_fit_the_placed_signature_and_keep_every_outline", "crates/ui-egui/src/fill_sign.rs::long_signature_previews_have_clear_margins", "crates/ui-egui/tests/control.rs::saved_signature_can_be_changed_through_the_control_channel", "crates/fonts/src/script.rs::a_name_becomes_outlines", "crates/fonts/src/script.rs::long_names_keep_every_glyph_with_bounded_outline_work", "crates/automation/tests/automation.rs::fill_and_sign_through_tools", "crates/ui-egui/tests/fill_sign.rs::image_signatures_and_initials_can_be_imported_placed_and_remembered", "crates/ui-egui/tests/fill_sign.rs::image_import_cancel_clear_and_errors_preserve_saved_signatures", "crates/engine/src/signature_image.rs::image_signatures_preserve_alpha_and_fit_the_signature_box", "crates/engine/src/signature_image.rs::invalid_and_oversized_signature_images_are_rejected", "crates/engine/src/signature_image.rs::jpeg_signatures_are_normalized_to_png", "crates/engine/src/signature_image.rs::jpeg_signature_orientation_is_applied_before_preview_and_embedding", "crates/engine/src/signature_image.rs::very_wide_image_signatures_keep_the_requested_rectangle", "crates/automation/tests/automation.rs::image_signatures_through_tools_preserve_transparency_and_survive_save"]
notes = "signature and initials, typed (Dancing Script outlines as filled paths, no font embedded), drawn or imported from local PNG/JPEG images; saved in settings with preview cards and add/change/remove controls; changes leave placed annotations untouched; images preserve alpha, aspect ratio and EXIF orientation, fit within 150 pt by 32 pt (24 pt for initials), and are normalized without source paths or metadata; image previews follow the pointer at placement size with zoom and rotation; signatures/initials place once then select the new annotation; embedded image signatures resize and move live, hiding the frame during movement and restoring it on release; corner handles preserve the original aspect ratio of the embedded image with the opposite corner anchored, while edge midpoint handles stretch width or height independently; cached background layers preserve page content and alpha, including reopened/encrypted documents; Escape cancels a gesture without committing; stamps resize through selection handles or comment_edit with their original appearance; native and web asynchronous pickers; fill_sign_add accepts path instead of text; image imports capped at 4 MiB, 4 megapixels and 4096 pixels per side"
[[feature]]
id = "form.fill-sign-flatten"