mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-25 10:39:01 -06:00
This commit is contained in:
@@ -811,6 +811,27 @@ pub fn picture_rotation(doc: &Document, page: usize, index: usize) -> Result<i64
|
||||
Ok(doc.resolve(entry).as_dict().map_or(0, appearance::picture_turn))
|
||||
}
|
||||
|
||||
/// Leave page `page` of (a copy of) a document showing only its annotation at `index`, without
|
||||
/// the page's content or other annotations, so a render draws that annotation alone: to lay it
|
||||
/// over the page while it is dragged. `false`, with `doc` unchanged, when the annotation has no
|
||||
/// normal appearance (`/AP /N`) for that render to draw.
|
||||
pub fn isolate_annotation(doc: &mut Document, page: usize, index: usize) -> Result<bool, AnnotError> {
|
||||
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).
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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<Vec<u8>>,
|
||||
stamp: Arc<Vec<u8>>,
|
||||
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<RenderedPage, String> {
|
||||
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<RenderedPage, String> {
|
||||
self.render(&self.stamp, self.stamp_config(), scale)
|
||||
}
|
||||
|
||||
fn stamp_config(&self) -> RenderConfig {
|
||||
RenderConfig { transparent: true, ..self.config.clone() }
|
||||
}
|
||||
|
||||
fn render(&self, bytes: &Arc<Vec<u8>>, config: RenderConfig, 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(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<Option<StampPreview>, 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() }))
|
||||
})?
|
||||
}
|
||||
}
|
||||
@@ -2554,6 +2554,60 @@ fn comment_text(s: &Session, id: DocId) -> Vec<String> {
|
||||
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);
|
||||
|
||||
@@ -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<Output, (String, bool)> {
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -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<TextureHandle>,
|
||||
tag: Option<u64>,
|
||||
@@ -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<StampLayer>),
|
||||
}
|
||||
|
||||
struct StampLayer {
|
||||
renderer: RenderPool,
|
||||
/// The stamp's rectangle (user space) in the document it is rendered from.
|
||||
from: [f32; 4],
|
||||
tag: Option<u64>,
|
||||
/// 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 }))
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user