diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index b16d5fa94..964eec85d 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -811,6 +811,27 @@ pub fn picture_rotation(doc: &Document, page: usize, index: usize) -> Result Result { + let p = page_ref(doc, page)?; + let entry = annots(doc, p).get(index).cloned().ok_or(AnnotError::NoSuchAnnotation { page, index })?; + let has_normal = { + let obj = doc.resolve(&entry); + obj.as_dict().and_then(|d| d.get(b"AP")).is_some_and(|ap| doc.resolve(ap).as_dict().is_some_and(|ap| ap.contains(b"N"))) + }; + if !has_normal { + return Ok(false); + } + set_annots(doc, p, vec![entry])?; + doc.update_dict(p, |d| { + d.remove(b"Contents"); + })?; + Ok(true) +} + // ── building ──────────────────────────────────────────────────────────────────────────────── /// Annotation flags (§12.5.3). diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 2adbb3bae..24ea7bff5 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -34,6 +34,7 @@ mod scan_colours; mod scan_fields; pub mod signature_image; pub mod signature_scan; +mod stamp_preview; pub mod text_fonts; mod text_preview; pub mod word_count; @@ -41,6 +42,7 @@ pub mod xfa; pub use signature_image::{ImageSignaturePreview, SignatureImage}; pub use signature_scan::{PhotoRect, ScanError, SignaturePhoto, Turn}; +pub use stamp_preview::StampPreview; pub use text_preview::TextEditPreview; pub use pdfcraft_organize::{BoxSpec, FieldConflicts, PageBox, SplitBy, Transition, TransitionSpec, split_ranges}; diff --git a/crates/engine/src/stamp_preview.rs b/crates/engine/src/stamp_preview.rs new file mode 100644 index 000000000..6e5758a7c --- /dev/null +++ b/crates/engine/src/stamp_preview.rs @@ -0,0 +1,83 @@ +//! A stamp while it is moved or resized: the page without it, and the stamp alone. + +use std::sync::Arc; + +use pdfcraft_cos::{SaveOptions, write_incremental}; +use pdfcraft_render::{PageRenderer, RenderConfig, RenderPool, RenderRequest, RenderedPage}; + +use crate::{Document, guard}; + +/// Read-only layers for interactive movement and resizing: the page without the stamp, and the +/// stamp alone on a transparent page. Moving or resizing a stamp only changes its `/Rect`, which +/// its appearance is stretched to fit, so these two show the result before it is applied. +/// Preparing them never changes the working bytes, dirty flag or undo history. +pub struct StampPreview { + page: usize, + background: Arc>, + stamp: Arc>, + config: RenderConfig, +} + +impl StampPreview { + /// One worker for the page beneath the stamp. + pub fn background_renderer(&self) -> RenderPool { + RenderPool::new(self.background.clone(), 1, self.config.clone()) + } + + /// One worker for the stamp alone: its page has no content, no other annotations and no + /// background, so only the stamp's own pixels are drawn. + pub fn stamp_renderer(&self) -> RenderPool { + RenderPool::new(self.stamp.clone(), 1, self.stamp_config()) + } + + /// The page beneath the stamp, rendered synchronously with the renderer's normal pixel limits. + pub fn render_background(&self, scale: f32) -> Result { + self.render(&self.background, self.config.clone(), scale) + } + + /// The stamp alone on a transparent page, rendered like [`Self::render_background`]. + pub fn render_stamp(&self, scale: f32) -> Result { + self.render(&self.stamp, self.stamp_config(), scale) + } + + fn stamp_config(&self) -> RenderConfig { + RenderConfig { transparent: true, ..self.config.clone() } + } + + fn render(&self, bytes: &Arc>, config: RenderConfig, scale: f32) -> Result { + if !scale.is_finite() || scale <= 0.0 { + return Err("preview scale must be finite and positive".into()); + } + guard(|| { + let out = PageRenderer::new(bytes.clone(), config).render(RenderRequest { page: self.page, scale, ..Default::default() }); + if let Some(e) = &out.error { + return Err(e.clone()); + } + Ok(out) + })? + } +} + +impl Document { + /// The layers that show the stamp at `(page, index)` moved or resized as it is dragged. + /// `None` for an annotation that isn't a stamp, or one without an appearance to draw. + pub fn stamp_preview(&self, page: usize, index: usize) -> Result, String> { + guard(|| { + let editor = self.editor.as_ref().ok_or("the document can't be read")?; + let is_stamp = self.info.annotations.iter().any(|a| a.page == page && a.index == index && a.subtype == "Stamp"); + if !is_stamp { + return Ok(None); + } + let mut stamp = editor.cos.clone(); + if !pdfcraft_annot::isolate_annotation(&mut stamp, page, index).map_err(|e| e.to_string())? { + return Ok(None); + } + let mut background = editor.cos.clone(); + pdfcraft_annot::delete_annotation(&mut background, page, index).map_err(|e| e.to_string())?; + // The page beneath as it is displayed: comments without an appearance get theirs. + let background = pdfcraft_annot::with_missing_appearances(&background).unwrap_or(background); + let write = |cos: &pdfcraft_cos::Document| write_incremental(cos, &SaveOptions::default()).map(Arc::new).map_err(|e| e.to_string()); + Ok(Some(StampPreview { page, background: write(&background)?, stamp: write(&stamp)?, config: self.config.clone() })) + })? + } +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e36f4fe0e..3e59e6a37 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2554,6 +2554,60 @@ fn comment_text(s: &Session, id: DocId) -> Vec { t } +/// A dragged stamp's layers: the page without it (its text and other comments kept) and the +/// stamp alone on a transparent page. Preparing them changes nothing in the document. +#[test] +fn stamp_preview_layers_split_the_stamp_from_its_page() { + let (mut s, id) = session_with(1); + s.apply(id, fill_item(Shape::Mark { rect: [30.0, 60.0, 130.0, 66.0], mark: FillMark::Line }, "")).unwrap(); + s.apply(id, rect_comment(0, [140.0, 40.0, 190.0, 90.0])).unwrap(); + let doc = s.get(id).unwrap(); + let (generation, dirty) = (doc.edit_generation(), doc.dirty); + let preview = doc.stamp_preview(0, 0).unwrap().expect("the line is a stamp"); + let (stamp, background) = (preview.render_stamp(1.0).unwrap(), preview.render_background(1.0).unwrap()); + let px = |p: &pdfcraft_render::RenderedPage, x: u32, y: u32| -> [u8; 4] { + p.rgba[(((300 - y) * p.width + x) * 4) as usize..][..4].try_into().unwrap() + }; + let line = |x: u32, y: u32| (30..=130).contains(&x) && (59..=67).contains(&y); + let (mut drawn, mut stray) = (0, Vec::new()); + for y in 1..300 { + for x in 0..200 { + match (line(x, y), px(&stamp, x, y)) { + (true, [.., 255]) => drawn += 1, + (false, v) if v != [0, 0, 0, 0] => stray.push((x, y)), + _ => {} + } + } + } + assert!(drawn > 100, "the line is drawn opaque: {drawn} pixels"); + assert!(stray.is_empty(), "only the line is drawn on its layer: {:?}", &stray[..stray.len().min(8)]); + assert!((30..=130).all(|x| px(&background, x, 63) == [255, 255, 255, 255]), "the page beneath has no line"); + assert!((138..=142).any(|x| px(&background, x, 65) != [255, 255, 255, 255]), "other comments stay"); + assert!((20..120).any(|x| (150..170).any(|y| px(&background, x, y)[0] < 128)), "the page text stays"); + assert!(doc.stamp_preview(0, 1).unwrap().is_none(), "a rectangle isn't a stamp"); + assert!(doc.stamp_preview(0, 9).unwrap().is_none()); + let doc = s.get(id).unwrap(); + assert_eq!((doc.edit_generation(), doc.dirty, doc.can_undo()), (generation, dirty, Some("Add rectangle")), "the document is unchanged"); + + // Another app's comments without appearances: beneath a dragged stamp they are drawn as on + // screen, and such a stamp has nothing to lay over the page. + let bare = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >> endobj +4 0 obj << /Type /Annot /Subtype /Square /Rect [140 40 190 90] /C [1 0 0] >> endobj +5 0 obj << /Type /Annot /Subtype /Stamp /Rect [20 20 80 40] /Name /Draft >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + let mut s = Session::new(); + let id = s.open("bare.pdf", None, Arc::new(bare.to_vec()), None).unwrap(); + s.apply(id, fill_item(Shape::Mark { rect: [30.0, 60.0, 130.0, 66.0], mark: FillMark::Line }, "")).unwrap(); + let doc = s.get(id).unwrap(); + let background = doc.stamp_preview(0, 2).unwrap().expect("the line is a stamp").render_background(1.0).unwrap(); + assert!((138..=142).any(|x| px(&background, x, 65)[..3] == [255, 0, 0]), "the square without an appearance is drawn beneath"); + assert!(doc.stamp_preview(0, 1).unwrap().is_none(), "a stamp without an appearance"); +} + #[test] fn flattening_fill_and_sign_keeps_other_comments_fields_and_undoes() { let (mut s, id) = session_with(1); diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index d4e054cf7..24d728984 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -26,7 +26,7 @@ use hayro::hayro_interpret::hayro_cmap::CidFamily; use hayro::hayro_interpret::{InterpreterSettings, InterpreterWarning}; use hayro::hayro_syntax::Pdf; use hayro::vello_cpu::color::AlphaColor; -use hayro::vello_cpu::color::palette::css::WHITE; +use hayro::vello_cpu::color::palette::css::{TRANSPARENT, WHITE}; use hayro::{RenderCache, RenderSettings, render_into, render_size}; use crate::{Pixels, ReadingColors}; @@ -65,6 +65,9 @@ pub struct RenderConfig { /// View ▸ Reading colours: solid text and line-art paints are remapped for readability and /// the page clears to the setting's background. Display only; the document never changes. pub reading_colors: ReadingColors, + /// The page clears to transparent instead of white (or the reading background): for a page + /// holding only what is laid over another raster, such as a comment while it is dragged. + pub transparent: bool, } impl RenderConfig { @@ -300,10 +303,10 @@ fn render_page<'a>( pdf: &'a Pdf, cache: &RenderCache<'a>, settings: &InterpreterSettings, - reject_oversize: bool, - reading: ReadingColors, + config: &RenderConfig, req: RenderRequest, ) -> Result { + let reading = request_reading(config, &req); if req.kind == RequestKind::Text { return match catch_unwind(AssertUnwindSafe(|| crate::text::extract_page(pdf, req.page, settings))) { Ok(Some(t)) => Ok((0, 0, Pixels::default(), Some(Arc::new(t)))), @@ -324,6 +327,7 @@ fn render_page<'a>( Some(p) => (AlphaColor::from_rgb8(p.background[0], p.background[1], p.background[2]), Some(hayro::ColorReplacement::from(p))), None => (WHITE, None), }; + let bg_color = if config.transparent { TRANSPARENT } else { bg_color }; let rs = match req.tile { // Tiles are bounded by construction, so the page itself may be arbitrarily large. Some(t) => { @@ -342,7 +346,7 @@ fn render_page<'a>( } None => { let scale = effective_scale(w, h, req.scale); - if reject_oversize && (!(req.scale.is_finite() && req.scale > 0.0) || req.scale > scale) { + if config.reject_oversize && (!(req.scale.is_finite() && req.scale > 0.0) || req.scale > scale) { return Err(format!( "requested page {} raster scale {req_scale} exceeds renderer limits (maximum scale {scale})", req.page + 1, @@ -463,7 +467,7 @@ impl PageRenderer { let cache = RenderCache::new(); let warnings = Arc::new(Mutex::new(Vec::new())); let settings = warning_settings(&self.settings, warnings.clone()); - let r = render_page(pdf, &cache, &settings, self.config.reject_oversize, request_reading(&self.config, &req), req); + let r = render_page(pdf, &cache, &settings, &self.config, req); let warnings = take_warnings(&warnings); if matches!(r, Err((_, true))) { self.pdf = parse(&self.bytes, self.config.password.as_deref()); @@ -1193,7 +1197,7 @@ fn worker( panic!("injected page panic"); } match pdf { - Some(pdf) => render_page(pdf, cache, &settings, config.reject_oversize, request_reading(&config, &req), req), + Some(pdf) => render_page(pdf, cache, &settings, &config, req), None => Err(("the document could not be parsed".into(), false)), } })) @@ -6168,6 +6172,46 @@ trailer << /Root 1 0 R >> assert_eq!(pixel(&on, 150, 55), &[255, 255, 255, 255], "the image's white quadrant stays white"); } + /// A transparent page leaves what nothing paints see-through, so it can be laid over another + /// raster: with reading colours on, and in a tile, too. Painted pixels stay premultiplied. + #[test] + fn transparent_pages_paint_only_their_content() { + let content = "0 0 1 rg 10 10 30 20 re f /GS gs 0.5 g 60 10 30 20 re f"; + let pdf = format!( + "%PDF-1.4\n\ + 1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 50] /Contents 4 0 R /Resources << /ExtGState << /GS << /ca 0.5 >> >> >> >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF", + content.len() + ) + .into_bytes(); + let pixel = |page: &RenderedPage, x: u32, y: u32| page.rgba[((y * page.width + x) * 4) as usize..][..4].to_vec(); + for reading_colors in [ReadingColors::default(), ReadingColors::WHITE_ON_BLACK] { + let config = RenderConfig { transparent: true, reading_colors, ..Default::default() }; + let mut r = PageRenderer::new(Arc::new(pdf.clone()), config.clone()); + let page = r.render(RenderRequest { page: 0, scale: 2.0, ..Default::default() }); + assert!(page.error.is_none(), "{:?}", page.error); + assert_eq!(pixel(&page, 5, 5), [0, 0, 0, 0], "{reading_colors:?}: nothing painted, nothing shown"); + assert_eq!(pixel(&page, 50, 60)[3], 255, "{reading_colors:?}: the opaque rectangle covers"); + assert_eq!(pixel(&page, 150, 60), [64, 64, 64, 128], "{reading_colors:?}: half-transparent grey, premultiplied"); + let tile = r.render(RenderRequest { page: 0, scale: 2.0, tile: Some(Tile { x: 0, y: 40, w: 100, h: 60 }), ..Default::default() }); + assert_eq!((pixel(&tile, 5, 5), pixel(&tile, 50, 20)), (pixel(&page, 5, 45), pixel(&page, 50, 60)), "{reading_colors:?}"); + let pool = RenderPool::new(Arc::new(pdf.clone()), 1, config); + pool.set_queue(vec![RenderRequest { page: 0, scale: 2.0, ..Default::default() }]); + let pooled = loop { + if let Some(p) = pool.try_recv() { + break p; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + }; + assert_eq!(pooled.rgba, page.rgba, "{reading_colors:?}: the pool renders the same"); + } + let opaque = PageRenderer::new(Arc::new(pdf), RenderConfig::default()).render(RenderRequest { page: 0, scale: 2.0, ..Default::default() }); + assert_eq!(pixel(&opaque, 5, 5), [255, 255, 255, 255], "pages are still white by default"); + } + /// Reading colours replace *document* colours, never annotation colours (issue #1548): a /// highlight keeps its `/C`, so on a dark background the content under it shows through the /// Multiply blend in the highlight's colour instead of being darkened into invisibility. @@ -6504,9 +6548,9 @@ endstream endobj let pdf = parse(&Arc::new(pdf), None).expect("parses"); let cache = RenderCache::new(); let settings = InterpreterSettings::default(); - let preview = render_page(&pdf, &cache, &settings, false, ReadingColors::default(), req(true)).expect("preview renders"); - let fin = render_page(&pdf, &cache, &settings, false, ReadingColors::default(), req(false)).expect("final renders"); - let fresh = render_page(&pdf, &RenderCache::new(), &settings, false, ReadingColors::default(), req(false)).expect("final renders"); + let preview = render_page(&pdf, &cache, &settings, &RenderConfig::default(), req(true)).expect("preview renders"); + let fin = render_page(&pdf, &cache, &settings, &RenderConfig::default(), req(false)).expect("final renders"); + let fresh = render_page(&pdf, &RenderCache::new(), &settings, &RenderConfig::default(), req(false)).expect("final renders"); assert_eq!(rgb_at(&preview.2, w, 35, 35), [0, 0, 255]); assert_eq!(&fin.2[..], &fresh.2[..], "a final render after a preview matches one with a fresh cache"); } diff --git a/crates/ui-egui/src/signature_drag.rs b/crates/ui-egui/src/signature_drag.rs index 094aa6cbf..788f333e6 100644 --- a/crates/ui-egui/src/signature_drag.rs +++ b/crates/ui-egui/src/signature_drag.rs @@ -1,12 +1,16 @@ -//! Cached page/image layers: a signature follows each pointer frame without editing the PDF. +//! Cached page and stamp layers: a stamp (an image signature, a Fill & Sign mark, any other stamp) +//! follows each pointer frame, moved or resized, without editing the PDF. -use egui::{ColorImage, TextureHandle, TextureOptions}; +use egui::{ColorImage, TextureHandle, TextureOptions, pos2, vec2}; use pdfcraft_engine::{Document, Edit}; -use pdfcraft_render::{RenderPool, RenderRequest}; +use pdfcraft_render::{PageInfo, RenderPool, RenderRequest, Tile}; use crate::QuickTool; use crate::comments::{CommentView, Gesture, PageCx}; +/// The longest side, in pixels, a stamp's own layer is rendered at. +const MAX_STAMP_SIDE: f32 = 2048.0; + #[derive(Default)] pub(crate) struct SignatureDrag { key: Option<(u64, usize, usize)>, @@ -15,10 +19,7 @@ pub(crate) struct SignatureDrag { } struct Layers { - image: TextureHandle, - opacity: f32, - /// The page /Rotate the PDF draws the image turned back by. - turn: i64, + front: Front, renderer: RenderPool, background: Option, tag: Option, @@ -27,6 +28,29 @@ struct Layers { settling: bool, } +/// What is drawn over the page without the stamp. +enum Front { + /// An image signature's own image, sharp at every zoom. + Image { + texture: TextureHandle, + opacity: f32, + /// The page /Rotate the PDF draws the image turned back by. + turn: i64, + }, + /// Any other stamp, rendered alone. Moving or resizing a stamp only changes its `/Rect`, + /// which its appearance is stretched to fit, so its pixels are stretched with the frame. + Stamp(Box), +} + +struct StampLayer { + renderer: RenderPool, + /// The stamp's rectangle (user space) in the document it is rendered from. + from: [f32; 4], + tag: Option, + /// The rendered stamp: its tag, and the view-space box its pixels cover. + pixels: Option<(u64, TextureHandle, [f32; 4])>, +} + 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)); @@ -35,38 +59,35 @@ impl SignatureDrag { 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 Some(a) = doc.info.annotations.iter().find(|a| a.page == page && a.index == index && a.subtype == "Stamp" && !a.locked) { - let [width, height] = preview.image.size(); - let ratio = width as f32 / height as f32; - // As displayed, the image is turned by the page's rotation less the turn it is drawn back by. - let shown = doc.info.pages.get(page).map_or(0, |p| i64::from(p.rotation)) - preview.turn; - self.aspect_ratio = Some(if shown.rem_euclid(180) == 0 { ratio } else { ratio.recip() }); - self.layers = Some(Layers { - opacity: preview.opacity, - turn: preview.turn, - image: ctx.load_texture( + if let Ok(Some(preview)) = doc.image_signature_preview(page, index) { + let [width, height] = preview.image.size(); + let ratio = width as f32 / height as f32; + // As displayed, the image is turned by the page's rotation less the turn it is drawn back by. + let shown = doc.info.pages.get(page).map_or(0, |p| i64::from(p.rotation)) - preview.turn; + self.aspect_ratio = Some(if shown.rem_euclid(180) == 0 { ratio } else { ratio.recip() }); + let texture = 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 front = Front::Image { texture, opacity: preview.opacity, turn: preview.turn }; + self.layers = Some(Layers::new(front, preview.renderer())); + } else if let Ok(Some(preview)) = doc.stamp_preview(page, index) { + let stamp = StampLayer { renderer: preview.stamp_renderer(), from: a.rect, tag: None, pixels: None }; + self.layers = Some(Layers::new(Front::Stamp(Box::new(stamp)), preview.background_renderer())); + } } } let (Some((_, page, _)), Some(layers)) = (self.key, self.layers.as_mut()) else { return }; - // The image stays sharp at every zoom. The background matches the page view up to one - // whole-page texture; past that the view tiles and this preview stays at that cap. + // The background matches the page view up to one whole-page texture; past that the view + // tiles and this preview stays at that cap. let Some(p) = doc.info.pages.get(page) else { return }; - let scale = scale.min(crate::canvas::BASE_SIDE / p.width.max(p.height).max(1.0)); - let tag = (scale * 1000.0) as u64; + let background_scale = scale.min(crate::canvas::BASE_SIDE / p.width.max(p.height).max(1.0)); + let tag = (background_scale * 1000.0) as u64; if layers.tag != Some(tag) { - layers.renderer.set_queue(vec![RenderRequest { page, scale, tag, preview: true, ..Default::default() }]); + layers.renderer.set_queue(vec![RenderRequest { page, scale: background_scale, tag, preview: true, ..Default::default() }]); layers.tag = Some(tag); } if let Some(out) = layers.renderer.try_recv() @@ -85,7 +106,34 @@ impl SignatureDrag { return; } } - if layers.received_tag != Some(tag) { + let mut pending = layers.received_tag != Some(tag); + // The stamp itself is small: it stays sharp at every zoom. + if let Front::Stamp(stamp) = &mut layers.front { + let Some((scale, tile)) = stamp_tile(p, stamp.from, scale) else { + // Nothing to lay over the page: the frame alone follows the pointer, as before. + self.layers = None; + return; + }; + let tag = (scale * 1000.0) as u64; + if stamp.tag != Some(tag) { + stamp.renderer.set_queue(vec![RenderRequest { page, scale, tag, tile: Some(tile), ..Default::default() }]); + stamp.tag = Some(tag); + } + if let Some(out) = stamp.renderer.try_recv() + && out.request.tag == tag + { + let (Some(t), None) = (out.request.tile, &out.error) else { + self.layers = None; + return; + }; + let s = out.request.scale; + let area = [t.x as f32 / s, t.y as f32 / s, t.x.saturating_add(t.w) as f32 / s, t.y.saturating_add(t.h) as f32 / s]; + let image = crate::canvas::texture_image([out.width as usize, out.height as usize], out.rgba); + stamp.pixels = Some((tag, ctx.load_texture("signature-drag-stamp", image, TextureOptions::LINEAR), area)); + } + pending |= stamp.pixels.as_ref().is_none_or(|(received, ..)| *received != tag); + } + if pending { ctx.request_repaint_after(std::time::Duration::from_millis(16)); } } @@ -100,7 +148,7 @@ impl SignatureDrag { 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. + /// Movement/resizing only change this stamp's geometry, so its layers are 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) @@ -133,18 +181,66 @@ impl SignatureDrag { return; } let (Some(background), Some(a), Some(p)) = (&layers.background, cx.get(index), cx.info.pages.get(page)) else { return }; + if let Front::Stamp(stamp) = &layers.front + && stamp.pixels.is_none() + { + return; + } let (pointer, shift) = painter.ctx().input(|i| (i.pointer.hover_pos(), i.modifiers.shift)); let rect = cx.adjusted_rect(a, cv.gesture.as_ref(), pointer, shift, 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(), - p, - rect, - layers.turn, - egui::Color32::from_white_alpha((layers.opacity * 255.0).round() as u8), - ); + match &layers.front { + Front::Image { texture, opacity, turn } => cx.xf.paint_user_image( + &painter, + texture.id(), + p, + cx.rect_to_user(rect), + *turn, + egui::Color32::from_white_alpha((opacity * 255.0).round() as u8), + ), + Front::Stamp(stamp) => { + let Some((_, texture, area)) = &stamp.pixels else { return }; + // Where the stamp's pixels were rendered, stretched as its box is from `from` to `rect`. + let from = cx.xf.user_rect(cx.info, page, stamp.from); + if from.width() < 0.01 || from.height() < 0.01 { + return; + } + let stretch = vec2(rect.width() / from.width(), rect.height() / from.height()); + let mut mesh = egui::Mesh::with_texture(texture.id()); + for (u, v) in [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)] { + let (x, y) = (area[0] + u * (area[2] - area[0]), area[1] + v * (area[3] - area[1])); + let at = cx.xf.norm_to_screen(x / cx.xf.pw, y / cx.xf.ph); + let pos = rect.min + (at - from.min) * stretch; + mesh.vertices.push(egui::epaint::Vertex { pos, uv: pos2(u, v), color: egui::Color32::WHITE }); + } + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(0, 2, 3); + painter.add(egui::Shape::mesh(mesh)); + } + } } } + +impl Layers { + fn new(front: Front, renderer: RenderPool) -> Self { + Self { front, renderer, background: None, tag: None, received_tag: None, settling: false } + } +} + +/// The scale and tile of the page raster that hold a stamp at `rect` (user space), a pixel to +/// spare around it for its anti-aliased edges: at `scale`, or less to keep it within +/// [`MAX_STAMP_SIDE`]. `None` for a page or stamp that has no finite box. +fn stamp_tile(p: &PageInfo, rect: [f32; 4], scale: f32) -> Option<(f32, Tile)> { + let [a, b] = [p.user_to_view(rect[0], rect[1]), p.user_to_view(rect[2], rect[3])]; + let (x0, y0, x1, y1) = (a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])); + let scale = scale.min(MAX_STAMP_SIDE / (x1 - x0).max(y1 - y0).max(1.0)); + let (width, height) = ((p.width * scale).ceil(), (p.height * scale).ceil()); + if ![scale, x0, y0, x1, y1, width, height].iter().all(|v| v.is_finite()) || scale <= 0.0 { + return None; + } + // Float to integer casts saturate, and each edge stays on the page. + let (left, top) = (((x0 * scale).floor() - 1.0).max(0.0).min(width), ((y0 * scale).floor() - 1.0).max(0.0).min(height)); + let (right, bottom) = (((x1 * scale).ceil() + 1.0).min(width).max(left + 1.0), ((y1 * scale).ceil() + 1.0).min(height).max(top + 1.0)); + Some((scale, Tile { x: left as u32, y: top as u32, w: (right - left) as u32, h: (bottom - top) as u32 })) +} diff --git a/crates/ui-egui/tests/fill_sign.rs b/crates/ui-egui/tests/fill_sign.rs index c01b1b2b4..dd201271b 100644 --- a/crates/ui-egui/tests/fill_sign.rs +++ b/crates/ui-egui/tests/fill_sign.rs @@ -1265,6 +1265,74 @@ fn drawn_signatures_resize_from_their_corners() { assert_rect(rects(&h)[0], now); } +/// Whether black ink is drawn within 3 pixels of `p`. +fn ink_near(pixels: &image::RgbaImage, p: Pos2) -> bool { + (-3..=3).any(|dy| (-3..=3).any(|dx| pixels.get_pixel((p.x as i32 + dx) as u32, (p.y as i32 + dy) as u32).0[..3].iter().all(|v| *v < 60))) +} + +/// A Fill & Sign line, like every stamp, is stretched with its frame while a handle is dragged: +/// it grows and shrinks with the pointer instead of keeping its old length until the release. +#[test] +fn lines_grow_and_shrink_with_their_handles_before_release() { + let _gpu = gpu(); + for (document_rotation, view_rotation) in [(0, 0), (90, 0), (0, 90), (90, 270)] { + let rot = (document_rotation, view_rotation); + let mut h = harness(); + if document_rotation != 0 { + 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.run_steps(3); + assert!(h.state_mut().execute("sign.fill.line")); + click(&mut h, 100.0, 250.0); + assert_rect(rects(&h)[0], [82.0, 248.0, 118.0, 252.0]); + // In line mode a click on the line picks it up, with its handles. + click(&mut h, 100.0, 250.0); + assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "{rot:?}"); + let generation = h.state().session.get(h.state().views[0].id).unwrap().edit_generation(); + + // The handle at the line's end, dragged out to 160. + h.drag_at(at(&h, 118.0, 250.0)); + h.run_steps(1); + h.hover_at(at(&h, 160.0, 250.0)); + // Wait only for the one-time layers. Later pointer frames are immediate. + let mut grown = h.render().unwrap(); + for _ in 0..50 { + if ink_near(&grown, at(&h, 150.0, 250.0)) { + break; + } + h.run_steps(2); + std::thread::sleep(std::time::Duration::from_millis(10)); + grown = h.render().unwrap(); + } + assert!(ink_near(&grown, at(&h, 150.0, 250.0)), "{rot:?}: the line grows with the handle before release"); + assert!(ink_near(&grown, at(&h, 90.0, 250.0)), "{rot:?}: from where it starts"); + + // Back in, shorter than it was, still without letting go. + h.hover_at(at(&h, 100.0, 250.0)); + h.run_steps(2); + let shrunk = h.render().unwrap(); + assert!(ink_near(&shrunk, at(&h, 90.0, 250.0)), "{rot:?}: the line still starts where it did"); + for x in [110.0, 150.0] { + assert!(!ink_near(&shrunk, at(&h, x, 250.0)), "{rot:?}: the line shrinks with the handle before release (ink at {x})"); + } + let doc = h.state().session.get(h.state().views[0].id).unwrap(); + assert_eq!((doc.edit_generation(), doc.can_undo()), (generation, Some("Add line")), "{rot:?}: pointer frames never edit the document"); + if let Ok(dir) = std::env::var("PDFCRAFT_SHOTS") { + grown.save(format!("{dir}/line-live-grow-{document_rotation}-{view_rotation}.png")).unwrap(); + shrunk.save(format!("{dir}/line-live-shrink-{document_rotation}-{view_rotation}.png")).unwrap(); + } + h.drop_at(at(&h, 100.0, 250.0)); + h.run_steps(3); + let doc = h.state().session.get(h.state().views[0].id).unwrap(); + assert_eq!(doc.can_undo(), Some("Resize comment"), "{rot:?}"); + let [now] = rects(&h)[..] else { panic!("{rot:?}: still one line: {:?}", rects(&h)) }; + assert!(now.iter().zip([82.0, 248.0, 100.0, 252.0]).all(|(a, b)| (a - b).abs() < 0.01), "{rot:?}: {now:?}"); + } +} + /// A signature placed over a form field is picked up by the Select tool: the field under it /// doesn't take the press. Away from the signature, a click still fills the field. #[test]