A moved comment is drawn where it goes while it is dragged

Moving a rectangle, an oval or any other comment showed only its blue
frame going, the shape staying where it was until release. The comment is
now rendered alone once as the drag starts (Document::comment_layer: a
read-only copy with only that comment, no page content) and laid over the
page where it goes; the page beneath keeps showing it where it was until
the move is applied, as a CAD program's move does. Rendering the page
without it would cost a whole page's render at every drag on a heavy plan.
Stamps and Fill & Sign marks keep their own layers. annot: isolate_annotations
for several comments of a page, so a group can be drawn together.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Oliver Hagmann
2026-10-29 05:50:58 +01:00
co-authored by Claude Opus 5.5
parent 3109bb2359
commit 67b5b2dcf6
10 changed files with 330 additions and 49 deletions
+1 -1
View File
@@ -66,7 +66,7 @@ pub use links::{
};
pub use signature_image::{ImageSignaturePreview, SignatureImage};
pub use signature_scan::{PhotoRect, ScanError, SignaturePhoto, Turn};
pub use stamp_preview::StampPreview;
pub use stamp_preview::{CommentLayer, StampPreview};
pub use text_preview::{TextEditPreview, UnmarkedPages};
pub use pdfcraft_organize::{BoxSpec, FieldConflicts, PageBox, SplitBy, Transition, TransitionSpec, mime_for_name, split_ranges};
+58 -1
View File
@@ -1,4 +1,5 @@
//! A stamp while it is moved or resized: the page without it, and the stamp alone.
//! A stamp while it is moved or resized: the page without it, and the stamp alone. Any other
//! comment while it is moved: the comment alone.
use std::sync::Arc;
@@ -81,3 +82,59 @@ impl Document {
})?
}
}
/// Comments alone on a transparent page, for drawing them where they go while they are moved.
/// The page beneath keeps showing them where they were until the move is applied: rendering the page
/// without it would cost a whole page's render at every drag on a heavy plan. Read-only, as
/// [`StampPreview`] is.
pub struct CommentLayer {
page: usize,
bytes: Arc<Vec<u8>>,
config: RenderConfig,
}
impl CommentLayer {
/// One worker for the comments alone: their page has no content and no other annotations.
pub fn renderer(&self) -> RenderPool {
RenderPool::new(self.bytes.clone(), 1, self.transparent())
}
/// The comments alone, rendered synchronously with the renderer's normal pixel limits.
pub fn render(&self, scale: f32) -> Result<RenderedPage, String> {
if !scale.is_finite() || scale <= 0.0 {
return Err("preview scale must be finite and positive".into());
}
guard(|| {
let out =
PageRenderer::new(self.bytes.clone(), self.transparent()).render(RenderRequest { page: self.page, scale, ..Default::default() });
if let Some(e) = &out.error {
return Err(e.clone());
}
Ok(out)
})?
}
fn transparent(&self) -> RenderConfig {
RenderConfig { transparent: true, ..self.config.clone() }
}
}
impl Document {
/// The comments at `indices` on `page` alone (the ones moved together), drawn as on screen:
/// one without an appearance of its own gets the one the page view draws. `None` when one
/// of them doesn't exist or none has anything to draw.
pub fn comment_layer(&self, page: usize, indices: &[usize]) -> Result<Option<CommentLayer>, String> {
guard(|| {
let editor = self.editor.as_ref().ok_or("the document can't be read")?;
if indices.is_empty() || !indices.iter().all(|&i| self.info.annotations.iter().any(|a| a.page == page && a.index == i)) {
return Ok(None);
}
let mut alone = pdfcraft_annot::with_missing_appearances(&editor.cos).unwrap_or_else(|| editor.cos.clone());
if !pdfcraft_annot::isolate_annotations(&mut alone, page, indices).map_err(|e| e.to_string())? {
return Ok(None);
}
let bytes = write_incremental(&alone, &SaveOptions::default()).map(Arc::new).map_err(|e| e.to_string())?;
Ok(Some(CommentLayer { page, bytes, config: self.config.clone() }))
})?
}
}
+38
View File
@@ -3485,6 +3485,44 @@ trailer << /Root 1 0 R >>
assert!(doc.stamp_preview(0, 1).unwrap().is_none(), "a stamp without an appearance");
}
/// A moved comment's layer: the comment alone on a transparent page (no page text, no other
/// comment), drawn as on screen, another app's comment without an appearance too. Preparing it
/// changes nothing in the document.
#[test]
fn comment_layer_draws_the_comment_alone() {
let (mut s, id) = session_with(1);
s.apply(id, rect_comment(0, [140.0, 40.0, 190.0, 90.0])).unwrap();
s.apply(id, rect_comment(0, [20.0, 20.0, 60.0, 60.0])).unwrap();
let doc = s.get(id).unwrap();
let (generation, dirty) = (doc.edit_generation(), doc.dirty);
let layer = doc.comment_layer(0, &[0]).unwrap().expect("a rectangle").render(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()
};
assert_eq!(px(&layer, 165, 65), [255, 0, 0, 255], "the rectangle is drawn");
let near = |x: u32, y: u32| (137..=193).contains(&x) && (37..=93).contains(&y);
let stray: Vec<(u32, u32)> =
(1..300).flat_map(|y| (0..200).map(move |x| (x, y))).filter(|&(x, y)| !near(x, y) && px(&layer, x, y) != [0, 0, 0, 0]).collect();
assert!(stray.is_empty(), "only the rectangle is on its layer: {:?}", &stray[..stray.len().min(8)]);
assert!(doc.comment_layer(0, &[9]).unwrap().is_none(), "no such comment");
let both = doc.comment_layer(0, &[0, 1]).unwrap().expect("both").render(1.0).unwrap();
assert_eq!((px(&both, 165, 65), px(&both, 40, 40)), ([255, 0, 0, 255], [255, 0, 0, 255]), "moved together, drawn together");
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");
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] >> endobj
4 0 obj << /Type /Annot /Subtype /Square /Rect [140 40 190 90] /C [1 0 0] >> 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();
let layer = s.get(id).unwrap().comment_layer(0, &[0]).unwrap().expect("drawn as on screen").render(1.0).unwrap();
assert!((138..=142).any(|x| px(&layer, x, 65)[..3] == [255, 0, 0]), "the square without an appearance is drawn");
}
#[test]
fn flattening_fill_and_sign_keeps_other_comments_fields_and_undoes() {
let (mut s, id) = session_with(1);