From 4b8f71f209dbd1dc5b0fb1df6fb5a7a8c04b27cc Mon Sep 17 00:00:00 2001 From: Jiekang Tian Date: Sat, 14 Nov 2026 14:50:00 +0800 Subject: [PATCH] fix(app): bound image preview demand and import preparation --- crates/app/src/ui/canvas/view_fit.rs | 6 +- .../app/src/ui/file_dialogs/image_import.rs | 348 +--------- .../src/ui/file_dialogs/image_import/jobs.rs | 48 +- .../ui/file_dialogs/image_import/prepare.rs | 388 +++++++++++ .../src/ui/file_dialogs/image_import/proxy.rs | 612 ++++++++++++++---- .../src/ui/file_dialogs/image_import_tests.rs | 117 +++- crates/app/src/ui/mod.rs | 4 + .../app/src/ui/primary_sidebar/thumbnails.rs | 4 + docs/src/content/docs/guides/images.md | 13 +- docs/src/content/docs/zh-cn/guides/images.md | 9 +- 10 files changed, 1063 insertions(+), 486 deletions(-) create mode 100644 crates/app/src/ui/file_dialogs/image_import/prepare.rs diff --git a/crates/app/src/ui/canvas/view_fit.rs b/crates/app/src/ui/canvas/view_fit.rs index bd204ca0..36355a4a 100644 --- a/crates/app/src/ui/canvas/view_fit.rs +++ b/crates/app/src/ui/canvas/view_fit.rs @@ -26,10 +26,14 @@ pub(crate) fn store_navigation_rect(ctx: &egui::Context, rect: EguiRect) { }); } -fn navigation_rect(ctx: &egui::Context) -> Option { +pub(crate) fn navigation_rect(ctx: &egui::Context) -> Option { ctx.data(|data| data.get_temp::(egui::Id::new(NAVIGATION_RECT_ID))) } +pub(crate) fn clear_navigation_rect(ctx: &egui::Context) { + ctx.data_mut(|data| data.remove::(egui::Id::new(NAVIGATION_RECT_ID))); +} + /// The pointer position, unless a floating task card sits under it — over a /// card the pointer does not address the plot below (mirrors /// `task_card::pointer_allows_canvas` for gesture dispatch). diff --git a/crates/app/src/ui/file_dialogs/image_import.rs b/crates/app/src/ui/file_dialogs/image_import.rs index ffbabc0a..227ca9d6 100644 --- a/crates/app/src/ui/file_dialogs/image_import.rs +++ b/crates/app/src/ui/file_dialogs/image_import.rs @@ -19,6 +19,10 @@ mod geometry; use geometry::{image_frame, physical_image_size_mm}; #[cfg(test)] use geometry::{initial_image_size, natural_vector_size}; +mod prepare; +#[cfg(test)] +use prepare::prepare_bytes; +use prepare::worker; mod jobs; pub(crate) use jobs::poll; mod new_pages; @@ -29,6 +33,7 @@ pub(crate) use picker::{ }; mod proxy; use proxy::{insert_proxy, rebuild_preview}; +pub(crate) use proxy::{poll_rebuilds, request_thumbnail}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum ImportImageState { @@ -82,6 +87,19 @@ struct Candidate { preview: plotium_io::image::ProxyImage, } +impl Candidate { + fn display_name(&self) -> &str { + if self.page_index > 0 { + &self.basename + } else { + Path::new(&self.basename) + .file_stem() + .and_then(|name| name.to_str()) + .unwrap_or("Image") + } + } +} + #[derive(Clone, Copy)] struct ImportOptions { allow_first_frame: bool, @@ -100,16 +118,19 @@ struct Failure { enum WorkerEvent { State(ImportImageState), - Finished(Vec>), + Candidate(Result), + Finished, } struct ImportJob { + project_context: plotium_core::state::ProjectContextId, request: ImportImageRequest, operation: OperationId, state: ImportImageState, receiver: mpsc::Receiver, cancelled: Arc, consent: mpsc::Sender, + staged: Vec>, } #[derive(Default)] @@ -121,14 +142,14 @@ thread_local! { static MANAGER: RefCell = RefCell::new(ImageImportManager::default()); } -pub(crate) fn enqueue(app: &mut PlotiumApp, request: ImportImageRequest) { +pub(crate) fn enqueue(app: &mut PlotiumApp, mut request: ImportImageRequest) { let operation = app.session.begin_operation(); - let (sender, receiver) = mpsc::channel(); + let (sender, receiver) = mpsc::sync_channel(2); let (consent, worker_consent) = mpsc::channel(); let cancelled = Arc::new(AtomicBool::new(false)); let worker_cancelled = Arc::clone(&cancelled); - let paths = request.paths.clone(); - let payloads = request.payloads.clone(); + let paths = std::mem::take(&mut request.paths); + let payloads = std::mem::take(&mut request.payloads); let options = ImportOptions { allow_first_frame: request.allow_first_frame, strip_metadata: request.strip_metadata, @@ -146,12 +167,14 @@ pub(crate) fn enqueue(app: &mut PlotiumApp, request: ImportImageRequest) { }); MANAGER.with(|manager| { manager.borrow_mut().jobs.push(ImportJob { + project_context: app.session.project_context, request, operation, state: ImportImageState::Queued, receiver, cancelled, consent, + staged: Vec::new(), }); }); app.session.status = "Image import queued.".to_owned(); @@ -172,316 +195,7 @@ pub(crate) fn cancel_all(app: &mut PlotiumApp) { app.session.status = "Cancelling image import…".to_owned(); } -fn worker( - paths: Vec, - payloads: Vec<(String, Vec)>, - cancelled: Arc, - sender: mpsc::Sender, - consent: mpsc::Receiver, - options: ImportOptions, -) { - let _ = sender.send(WorkerEvent::State(ImportImageState::Probing)); - let mut results = Vec::with_capacity(paths.len()); - for path in paths { - if cancelled.load(Ordering::Relaxed) { - let _ = sender.send(WorkerEvent::State(ImportImageState::Cancelled)); - return; - } - match prepare_path(&path, &sender, &consent, &cancelled, options) { - Ok(candidates) => results.extend(candidates.into_iter().map(Ok)), - Err(error) => results.push(Err(error)), - } - if cancelled.load(Ordering::Relaxed) { - let _ = sender.send(WorkerEvent::State(ImportImageState::Cancelled)); - return; - } - } - for (name, bytes) in payloads { - if cancelled.load(Ordering::Relaxed) { - let _ = sender.send(WorkerEvent::State(ImportImageState::Cancelled)); - return; - } - match prepare_bytes(name, bytes, &sender, &consent, &cancelled, options) { - Ok(candidates) => results.extend(candidates.into_iter().map(Ok)), - Err(error) => results.push(Err(error)), - } - if cancelled.load(Ordering::Relaxed) { - let _ = sender.send(WorkerEvent::State(ImportImageState::Cancelled)); - return; - } - } - let _ = sender.send(WorkerEvent::State(ImportImageState::ReadyToCommit)); - let _ = sender.send(WorkerEvent::Finished(results)); -} - -fn prepare_path( - path: &Path, - sender: &mpsc::Sender, - consent: &mpsc::Receiver, - cancelled: &AtomicBool, - options: ImportOptions, -) -> Result, Failure> { - let basename = path - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or("") - .to_owned(); - let bytes = std::fs::read(path).map_err(|error| { - failure( - &basename, - "unknown", - "read", - error.to_string(), - "Check file permissions and retry.", - ) - })?; - prepare_bytes(basename, bytes, sender, consent, cancelled, options) -} - -fn prepare_bytes( - basename: String, - bytes: Vec, - sender: &mpsc::Sender, - consent: &mpsc::Receiver, - cancelled: &AtomicBool, - options: ImportOptions, -) -> Result, Failure> { - let detected = plotium_io::image::sniff(&bytes).name().to_owned(); - if plotium_io::image::sniff(&bytes) == plotium_io::image::RasterFormat::Svg { - return prepare_svg(basename, &bytes, &detected, options).map(|candidate| vec![candidate]); - } - let source_dpi = plotium_io::image::metadata_dpi(&bytes); - let probe = plotium_io::image::probe(&bytes).map_err(|error| { - failure( - &basename, - &detected, - "probe", - error.to_string(), - "Choose a supported PNG, JPEG, TIFF, WebP, BMP, or SVG file.", - ) - })?; - if options.import_all_tiff_pages && probe.format != plotium_io::image::RasterFormat::Tiff { - return Err(failure( - &basename, - &detected, - "probe_pages", - "the all-pages command accepts TIFF sources only".to_owned(), - "Choose Add Images… for PNG, JPEG, WebP, BMP, or SVG sources.", - )); - } - if probe.animated && !options.allow_first_frame { - return Err(failure( - &basename, - &detected, - "probe", - "animated images are not supported by this import path".to_owned(), - "Export the intended frame as a static PNG, JPEG, TIFF, WebP, or BMP and retry.", - )); - } - if probe.class == plotium_io::image::ResourceClass::Rejected { - return Err(failure( - &basename, - &detected, - "limits", - format!( - "{} × {} pixels and {} decoded bytes exceed the hard limit", - probe.width, probe.height, probe.decoded_bytes - ), - "Reduce the image dimensions before importing.", - )); - } - let bytes = if options.strip_metadata { - plotium_io::image::strip_metadata_to_png(&bytes, options.allow_first_frame).map_err( - |error| { - failure( - &basename, - &detected, - "strip_metadata", - error.to_string(), - "Use the normal import path for a large image, or reduce its dimensions first.", - ) - }, - )? - } else { - bytes - }; - let mut notices = Vec::new(); - if options.strip_metadata { - notices.push( - "Metadata was removed explicitly and the embedded pixels were re-encoded as PNG.", - ); - } - if probe.animated { - notices.push( - "Only the first animation frame is displayed; original animated bytes remain embedded.", - ); - } - if probe.high_precision { - notices.push( - "High-precision source is displayed as RGBA8 sRGB; original bytes remain embedded.", - ); - } - if probe.has_icc || probe.has_exif { - notices.push("ICC/EXIF metadata is preserved in the embedded original bytes."); - } - if probe.pages > 1 && !options.import_all_tiff_pages { - notices.push("Multi-page TIFF detected; only page one is imported by this command. Use Add All TIFF Pages… to create one image per page."); - } - let preview = if probe.class == plotium_io::image::ResourceClass::ProxyRequired { - let _ = sender.send(WorkerEvent::State( - ImportImageState::AwaitingLargeImageConsent, - )); - match consent.recv() { - Ok(true) => {} - Ok(false) | Err(_) => { - cancelled.store(true, Ordering::Relaxed); - return Err(failure( - &basename, - &detected, - "consent", - "large-image import was cancelled".to_owned(), - "Choose the image again if you want to import it.", - )); - } - } - let _ = sender.send(WorkerEvent::State(ImportImageState::DecodingProxy)); - let proxy = plotium_io::image::decode_proxy_rgba8(&bytes, 2048).map_err(|error| { - failure( - &basename, - &detected, - "proxy_decode", - error.to_string(), - "Reduce the image dimensions below the 500 megapixel hard limit and retry.", - ) - })?; - notices.push("Large image embedded; a bounded proxy is used for interactive preview."); - proxy - } else { - rebuild_preview(&bytes, 0).map_err(|error| { - failure( - &basename, - &detected, - "decode", - error.to_string(), - "Retry or re-encode the image in a supported format.", - ) - })? - }; - let warning = (!notices.is_empty()).then(|| notices.join(" ")); - let sha256 = plotium_io::image::sha256(&bytes); - let bytes = Arc::new(bytes); - let format = if options.strip_metadata { - "png".to_owned() - } else { - probe.format.name().to_ascii_lowercase() - }; - let mut candidates = vec![Candidate { - basename, - bytes, - sha256, - format, - pixel_size: [probe.width, probe.height], - auto_page_size_mm: physical_image_size_mm([probe.width, probe.height], source_dpi), - page_index: 0, - warning, - preview, - }]; - if options.import_all_tiff_pages && probe.format == plotium_io::image::RasterFormat::Tiff { - for page_index in 1..probe.pages { - let decoded = plotium_io::image::decode_rgba8_page( - candidates[0].bytes.as_slice(), - page_index, - false, - ) - .map_err(|error| { - failure( - &candidates[0].basename, - "TIFF", - "decode_page", - error.to_string(), - "Re-encode the failing TIFF page as a standalone PNG and retry.", - ) - })?; - candidates.push(Candidate { - basename: format!( - "{} — page {}", - Path::new(&candidates[0].basename) - .file_stem() - .and_then(|name| name.to_str()) - .unwrap_or("Image"), - page_index + 1 - ), - bytes: Arc::clone(&candidates[0].bytes), - sha256, - format: "tiff".to_owned(), - pixel_size: [decoded.probe.width, decoded.probe.height], - auto_page_size_mm: physical_image_size_mm( - [decoded.probe.width, decoded.probe.height], - source_dpi, - ), - page_index, - warning: decoded.probe.high_precision.then(|| { - "High-precision TIFF page is displayed as RGBA8 sRGB; original bytes remain embedded." - .to_owned() - }), - preview: rebuild_preview(candidates[0].bytes.as_slice(), page_index).map_err( - |error| { - failure( - &candidates[0].basename, - "TIFF", - "preview_page", - error.to_string(), - "Re-encode the failing TIFF page as a standalone PNG and retry.", - ) - }, - )?, - }); - } - } - Ok(candidates) -} - -/// Embeds the normalized form, which already carries no source metadata. -fn prepare_svg( - basename: String, - source: &[u8], - detected: &str, - options: ImportOptions, -) -> Result { - if options.import_all_tiff_pages { - return Err(failure( - &basename, - detected, - "probe_pages", - "the all-pages command accepts TIFF sources only".to_owned(), - "Choose Add Images… for PNG, JPEG, WebP, BMP, or SVG sources.", - )); - } - let remedy = "Re-export the SVG from its source application and retry."; - let bytes = plotium_core::export::normalize_svg_asset(source) - .map_err(|error| failure(&basename, detected, "normalize", error.to_string(), remedy))?; - let probe = plotium_io::image::probe(&bytes) - .map_err(|error| failure(&basename, detected, "probe", error.to_string(), remedy))?; - let preview = rebuild_preview(&bytes, 0) - .map_err(|error| failure(&basename, detected, "decode", error.to_string(), remedy))?; - Ok(Candidate { - basename, - sha256: plotium_io::image::sha256(&bytes), - bytes: Arc::new(bytes), - format: probe.format.name().to_ascii_lowercase(), - pixel_size: [probe.width, probe.height], - auto_page_size_mm: physical_image_size_mm([probe.width, probe.height], None), - page_index: 0, - warning: None, - preview, - }) -} - fn commit(app: &mut PlotiumApp, job: &mut ImportJob, results: Vec>) { - if job.cancelled.load(Ordering::Relaxed) { - job.state = ImportImageState::Cancelled; - return; - } if job.request.target == ImportImageTarget::NewPages { new_pages::commit(app, job, results); return; @@ -598,11 +312,7 @@ fn commit(app: &mut PlotiumApp, job: &mut ImportJob, results: Vec event, Err(mpsc::TryRecvError::Empty) => break, Err(mpsc::TryRecvError::Disconnected) => { - job.state = ImportImageState::Failed; - record_failures( - app, - job.operation, - 0, - vec![failure( - "", - "unknown", - "worker", - "the image import worker stopped before returning a result" - .to_owned(), - "Retry the import; if it still fails, review the diagnostic history.", - )], - ); + let mut staged = std::mem::take(&mut job.staged); + staged.push(Err(failure( + "", + "unknown", + "worker", + "the image import worker stopped before returning a result".to_owned(), + "Retry the import; if it still fails, review the diagnostic history.", + ))); + // Report partial success and the worker failure together, so + // the diagnostic does not incorrectly claim zero imports. + commit(app, job, staged); completed.push(index); break; } @@ -34,11 +36,23 @@ pub(crate) fn poll(app: &mut PlotiumApp, ctx: &egui::Context) { job.state = state; app.session.status = format!("Image import: {state:?}."); if state == ImportImageState::Cancelled { + let staged = std::mem::take(&mut job.staged); + if !staged.is_empty() { commit(app, job, staged); } + if job.state == ImportImageState::Committed { + app.session.status = "Image import cancelled; prepared images were imported and can be undone together.".to_owned(); + } completed.push(index); break; } } - WorkerEvent::Finished(results) => { + WorkerEvent::Candidate(mut result) => { + // Keep originals and metadata until atomic commit, never decoded + // previews for the entire batch. Visible previews are rebuilt on demand. + if let Ok(candidate) = &mut result { candidate.preview.rgba8 = Vec::new(); } + job.staged.push(result); + } + WorkerEvent::Finished => { + let results = std::mem::take(&mut job.staged); job.state = ImportImageState::ReadyToCommit; commit(app, job, results); completed.push(index); diff --git a/crates/app/src/ui/file_dialogs/image_import/prepare.rs b/crates/app/src/ui/file_dialogs/image_import/prepare.rs new file mode 100644 index 00000000..bf9964be --- /dev/null +++ b/crates/app/src/ui/file_dialogs/image_import/prepare.rs @@ -0,0 +1,388 @@ +use super::*; + +pub(super) fn worker( + paths: Vec, + payloads: Vec<(String, Vec)>, + cancelled: Arc, + sender: mpsc::SyncSender, + consent: mpsc::Receiver, + options: ImportOptions, +) { + if sender + .send(WorkerEvent::State(ImportImageState::Probing)) + .is_err() + { + return; + } + + for path in paths { + if cancelled.load(Ordering::Relaxed) { + if sender + .send(WorkerEvent::State(ImportImageState::Cancelled)) + .is_err() + { + log::debug!("Image import receiver closed after cancellation"); + } + return; + } + match prepare_path(&path, &sender, &consent, &cancelled, options) { + Ok(()) => {} + Err(error) => { + if sender.send(WorkerEvent::Candidate(Err(error))).is_err() { + return; + } + } + } + if cancelled.load(Ordering::Relaxed) { + if sender + .send(WorkerEvent::State(ImportImageState::Cancelled)) + .is_err() + { + log::debug!("Image import receiver closed after cancellation"); + } + return; + } + } + for (name, bytes) in payloads { + if cancelled.load(Ordering::Relaxed) { + if sender + .send(WorkerEvent::State(ImportImageState::Cancelled)) + .is_err() + { + log::debug!("Image import receiver closed after cancellation"); + } + return; + } + match prepare_bytes(name, bytes, &sender, &consent, &cancelled, options) { + Ok(()) => {} + Err(error) => { + if sender.send(WorkerEvent::Candidate(Err(error))).is_err() { + return; + } + } + } + if cancelled.load(Ordering::Relaxed) { + if sender + .send(WorkerEvent::State(ImportImageState::Cancelled)) + .is_err() + { + log::debug!("Image import receiver closed after cancellation"); + } + return; + } + } + if sender + .send(WorkerEvent::State(ImportImageState::ReadyToCommit)) + .is_err() + { + return; + } + if sender.send(WorkerEvent::Finished).is_err() { + log::debug!("Image import receiver closed before completion"); + } +} + +fn send_candidate( + sender: &mpsc::SyncSender, + candidate: Candidate, + cancelled: &AtomicBool, +) { + if sender.send(WorkerEvent::Candidate(Ok(candidate))).is_err() { + cancelled.store(true, Ordering::Relaxed); + } +} + +fn prepare_path( + path: &Path, + sender: &mpsc::SyncSender, + consent: &mpsc::Receiver, + cancelled: &AtomicBool, + options: ImportOptions, +) -> Result<(), Failure> { + let basename = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("") + .to_owned(); + let bytes = std::fs::read(path).map_err(|error| { + failure( + &basename, + "unknown", + "read", + error.to_string(), + "Check file permissions and retry.", + ) + })?; + prepare_bytes(basename, bytes, sender, consent, cancelled, options) +} + +pub(super) fn prepare_bytes( + basename: String, + bytes: Vec, + sender: &mpsc::SyncSender, + consent: &mpsc::Receiver, + cancelled: &AtomicBool, + options: ImportOptions, +) -> Result<(), Failure> { + let detected = plotium_io::image::sniff(&bytes).name().to_owned(); + if plotium_io::image::sniff(&bytes) == plotium_io::image::RasterFormat::Svg { + let candidate = prepare_svg(basename, &bytes, &detected, options)?; + send_candidate(sender, candidate, cancelled); + return Ok(()); + } + let source_dpi = plotium_io::image::metadata_dpi(&bytes); + let probe = plotium_io::image::probe(&bytes).map_err(|error| { + failure( + &basename, + &detected, + "probe", + error.to_string(), + "Choose a supported PNG, JPEG, TIFF, WebP, BMP, or SVG file.", + ) + })?; + if options.import_all_tiff_pages && probe.format != plotium_io::image::RasterFormat::Tiff { + return Err(failure( + &basename, + &detected, + "probe_pages", + "the all-pages command accepts TIFF sources only".to_owned(), + "Choose Add Images… for PNG, JPEG, WebP, BMP, or SVG sources.", + )); + } + if probe.animated && !options.allow_first_frame { + return Err(failure( + &basename, + &detected, + "probe", + "animated images are not supported by this import path".to_owned(), + "Export the intended frame as a static PNG, JPEG, TIFF, WebP, or BMP and retry.", + )); + } + if probe.class == plotium_io::image::ResourceClass::Rejected { + return Err(failure( + &basename, + &detected, + "limits", + format!( + "{} × {} pixels and {} decoded bytes exceed the hard limit", + probe.width, probe.height, probe.decoded_bytes + ), + "Reduce the image dimensions before importing.", + )); + } + let bytes = if options.strip_metadata { + plotium_io::image::strip_metadata_to_png(&bytes, options.allow_first_frame).map_err( + |error| { + failure( + &basename, + &detected, + "strip_metadata", + error.to_string(), + "Use the normal import path for a large image, or reduce its dimensions first.", + ) + }, + )? + } else { + bytes + }; + let mut notices = Vec::new(); + if options.strip_metadata { + notices.push( + "Metadata was removed explicitly and the embedded pixels were re-encoded as PNG.", + ); + } + if probe.animated { + notices.push( + "Only the first animation frame is displayed; original animated bytes remain embedded.", + ); + } + if probe.high_precision { + notices.push( + "High-precision source is displayed as RGBA8 sRGB; original bytes remain embedded.", + ); + } + if probe.has_icc || probe.has_exif { + notices.push("ICC/EXIF metadata is preserved in the embedded original bytes."); + } + if probe.pages > 1 && !options.import_all_tiff_pages { + notices.push("Multi-page TIFF detected; only page one is imported by this command. Use Add All TIFF Pages… to create one image per page."); + } + let preview = if probe.class == plotium_io::image::ResourceClass::ProxyRequired { + if sender + .send(WorkerEvent::State( + ImportImageState::AwaitingLargeImageConsent, + )) + .is_err() + { + cancelled.store(true, Ordering::Relaxed); + return Ok(()); + } + match consent.recv() { + Ok(true) => {} + Ok(false) | Err(_) => { + cancelled.store(true, Ordering::Relaxed); + return Err(failure( + &basename, + &detected, + "consent", + "large-image import was cancelled".to_owned(), + "Choose the image again if you want to import it.", + )); + } + } + if sender + .send(WorkerEvent::State(ImportImageState::DecodingProxy)) + .is_err() + { + cancelled.store(true, Ordering::Relaxed); + return Ok(()); + } + let proxy = plotium_io::image::decode_proxy_rgba8(&bytes, 256).map_err(|error| { + failure( + &basename, + &detected, + "proxy_decode", + error.to_string(), + "Reduce the image dimensions below the 500 megapixel hard limit and retry.", + ) + })?; + notices.push("Large image embedded; a bounded proxy is used for interactive preview."); + proxy + } else { + rebuild_preview(&bytes, 0).map_err(|error| { + failure( + &basename, + &detected, + "decode", + error.to_string(), + "Retry or re-encode the image in a supported format.", + ) + })? + }; + let warning = (!notices.is_empty()).then(|| notices.join(" ")); + let sha256 = plotium_io::image::sha256(&bytes); + let bytes = Arc::new(bytes); + let format = if options.strip_metadata { + "png".to_owned() + } else { + probe.format.name().to_ascii_lowercase() + }; + let first = Candidate { + basename: basename.clone(), + bytes: Arc::clone(&bytes), + sha256, + format, + pixel_size: [probe.width, probe.height], + auto_page_size_mm: physical_image_size_mm([probe.width, probe.height], source_dpi), + page_index: 0, + warning, + preview, + }; + send_candidate(sender, first, cancelled); + if options.import_all_tiff_pages && probe.format == plotium_io::image::RasterFormat::Tiff { + for page_index in 1..probe.pages { + if cancelled.load(Ordering::Relaxed) { + break; + } + let decoded = plotium_io::image::decode_rgba8_page(bytes.as_slice(), page_index, false) + .map_err(|error| { + failure( + &basename, + "TIFF", + "decode_page", + error.to_string(), + "Re-encode the failing TIFF page as a standalone PNG and retry.", + ) + })?; + let pixels = image::RgbaImage::from_raw( + decoded.probe.width, + decoded.probe.height, + decoded.rgba8, + ) + .ok_or_else(|| { + failure( + &basename, + "TIFF", + "preview_page", + "The decoded TIFF page has inconsistent dimensions.".to_owned(), + "Re-encode the failing TIFF page as a standalone PNG and retry.", + ) + })?; + // The page was already decoded to validate it and obtain its dimensions. + // Reuse those pixels rather than opening and decoding the page again. + let preview = if pixels.width() <= 256 && pixels.height() <= 256 { + pixels + } else { + image::DynamicImage::ImageRgba8(pixels) + .thumbnail(256, 256) + .into_rgba8() + }; + let preview = plotium_io::image::ProxyImage { + pixel_size: [preview.width(), preview.height()], + rgba8: preview.into_raw(), + }; + let candidate = Candidate { + basename: format!( + "{} — page {}", + Path::new(&basename) + .file_stem() + .and_then(|name| name.to_str()) + .unwrap_or("Image"), + page_index + 1 + ), + bytes: Arc::clone(&bytes), + sha256, + format: "tiff".to_owned(), + pixel_size: [decoded.probe.width, decoded.probe.height], + auto_page_size_mm: physical_image_size_mm( + [decoded.probe.width, decoded.probe.height], + source_dpi, + ), + page_index, + warning: decoded.probe.high_precision.then(|| { + "High-precision TIFF page is displayed as RGBA8 sRGB; original bytes remain embedded." + .to_owned() + }), + preview, + }; + send_candidate(sender, candidate, cancelled); + } + } + Ok(()) +} + +/// Embeds the normalized form, which already carries no source metadata. +fn prepare_svg( + basename: String, + source: &[u8], + detected: &str, + options: ImportOptions, +) -> Result { + if options.import_all_tiff_pages { + return Err(failure( + &basename, + detected, + "probe_pages", + "the all-pages command accepts TIFF sources only".to_owned(), + "Choose Add Images… for PNG, JPEG, WebP, BMP, or SVG sources.", + )); + } + let remedy = "Re-export the SVG from its source application and retry."; + let bytes = plotium_core::export::normalize_svg_asset(source) + .map_err(|error| failure(&basename, detected, "normalize", error.to_string(), remedy))?; + let probe = plotium_io::image::probe(&bytes) + .map_err(|error| failure(&basename, detected, "probe", error.to_string(), remedy))?; + let preview = rebuild_preview(&bytes, 0) + .map_err(|error| failure(&basename, detected, "decode", error.to_string(), remedy))?; + Ok(Candidate { + basename, + sha256: plotium_io::image::sha256(&bytes), + bytes: Arc::new(bytes), + format: probe.format.name().to_ascii_lowercase(), + pixel_size: [probe.width, probe.height], + auto_page_size_mm: physical_image_size_mm([probe.width, probe.height], None), + page_index: 0, + warning: None, + preview, + }) +} diff --git a/crates/app/src/ui/file_dialogs/image_import/proxy.rs b/crates/app/src/ui/file_dialogs/image_import/proxy.rs index 03bc5054..064d4762 100644 --- a/crates/app/src/ui/file_dialogs/image_import/proxy.rs +++ b/crates/app/src/ui/file_dialogs/image_import/proxy.rs @@ -8,12 +8,17 @@ struct ProxyRebuild { hash: [u8; 32], page_index: u32, format: String, + edge: u32, receiver: mpsc::Receiver>, } #[derive(Default)] struct ProxyRebuilds { active: Option, + #[cfg(test)] + started: usize, + context: Option, + completed: std::collections::BTreeMap<([u8; 32], u32), u32>, failed: std::collections::BTreeSet<([u8; 32], u32)>, } @@ -21,18 +26,33 @@ thread_local! { static REBUILDS: RefCell = RefCell::new(ProxyRebuilds::default()); } -pub(super) fn poll_rebuilds(app: &mut PlotiumApp, ctx: &egui::Context) { +type ThumbnailDemand = std::collections::BTreeSet; + +pub(crate) fn request_thumbnail(ctx: &egui::Context, canvas: CanvasId) { + ctx.data_mut(|data| { + data.get_temp_mut_or_default::(egui::Id::new("image_thumbnail_demand")) + .insert(canvas); + }); +} + +pub(crate) fn poll_rebuilds(app: &mut PlotiumApp, ctx: &egui::Context) { REBUILDS.with(|rebuilds| { let mut rebuilds = rebuilds.borrow_mut(); + if rebuilds.context != Some(app.session.project_context) { + *rebuilds = ProxyRebuilds { + context: Some(app.session.project_context), + ..Default::default() + }; + } + let mut ready = None; if let Some(active) = &rebuilds.active { match active.receiver.try_recv() { Ok(Ok(proxy)) => { - let hash = active.hash; - let page_index = active.page_index; - insert_proxy(app, hash, page_index, proxy); + ready = Some(((active.hash, active.page_index), active.edge, proxy)); rebuilds.active = None; } Ok(Err(reason)) => { + ctx.request_repaint(); let hash = active.hash; let page_index = active.page_index; let format = active.format.clone(); @@ -54,9 +74,23 @@ pub(super) fn poll_rebuilds(app: &mut PlotiumApp, ctx: &egui::Context) { } Err(mpsc::TryRecvError::Empty) => { ctx.request_repaint_after(std::time::Duration::from_millis(30)); - return; } Err(mpsc::TryRecvError::Disconnected) => { + ctx.request_repaint(); + let format = active.format.clone(); + let operation = app.session.begin_operation(); + record_failures( + app, + operation, + 0, + vec![failure( + "Embedded image", + &format, + "proxy_rebuild", + "The preview worker stopped unexpectedly.".to_owned(), + "Reopen the project to retry the preview.", + )], + ); let hash = active.hash; let page_index = active.page_index; rebuilds.failed.insert((hash, page_index)); @@ -64,117 +98,181 @@ pub(super) fn poll_rebuilds(app: &mut PlotiumApp, ctx: &egui::Context) { } } } - let view = BoardView { + let viewport = crate::ui::canvas::navigation_rect(ctx); + let view = viewport.map(|rect| BoardView { center: app.session.board.world_center, - half_extent: (ctx.content_rect().size() * 0.5 / app.session.board.zoom).into(), + half_extent: (rect.size() * 0.5 / app.session.board.zoom).into(), pixels_per_point: app.session.board.zoom * ctx.pixels_per_point(), - }; - let existing: std::collections::BTreeMap<_, _> = app - .session - .ui - .raster_proxies - .iter() - .map(|proxy| ((proxy.hash, proxy.page_index), proxy.pixel_size)) - .collect(); - let items = || { - app.doc - .canvases - .iter() - .flat_map(|canvas| canvas.objects.iter().map(move |item| (canvas, item))) - .filter_map(|(canvas, item)| { - let CanvasObjectKind::RasterImage(image) = &item.kind else { - return None; - }; - let asset = app.doc.assets.get(&image.asset)?; - Some((canvas, item, image, asset)) - }) - .filter(|(_, _, image, asset)| { - !rebuilds.failed.contains(&(asset.sha256, image.page_index)) - }) - }; - let missing = items() - .find(|(_, _, image, asset)| !existing.contains_key(&(asset.sha256, image.page_index))) - .map(|(_, _, image, asset)| (asset, image.page_index, None)); - let on_screen: Vec<_> = items() - .filter(|(_, _, _, asset)| asset.is_vector()) - .filter_map(|(canvas, item, image, asset)| { - let frame = canvas.content_page_frame(item.id)?; - let origin = [canvas.board_pos[0] + frame.x, canvas.board_pos[1] + frame.y]; - view.shows(origin, [frame.width, frame.height]).then(|| { - ( - asset, - image.page_index, - view.preview_edge([frame.width, frame.height], image.crop), - ) - }) - }) - .collect(); - let upgrade = on_screen - .iter() - .find(|(asset, page_index, edge)| { - existing - .get(&(asset.sha256, *page_index)) - .is_some_and(|size| size[0].max(size[1]) < *edge) - }) - .copied(); - let vectors: std::collections::BTreeSet<_> = app - .doc - .assets - .values() - .filter(|asset| asset.is_vector()) - .map(|asset| asset.sha256) - .collect(); - let shown: std::collections::BTreeSet<_> = on_screen - .iter() - .map(|(asset, page_index, _)| (asset.sha256, *page_index)) - .collect(); - let candidate = match (missing, upgrade) { - (Some(missing), _) => Some(missing), - (None, Some((asset, page_index, edge))) => { - let proxies = &mut app.session.ui.raster_proxies; - let held = |proxies: &[plotium_core::state::RasterProxy]| { - proxies.iter().map(|proxy| proxy.rgba8.len()).sum::() + }); + // Allocate a fair square-pixel allowance across unique visible pages. + // Missing pages and upgrades use the same allowance, so neither can + // evict another visible page and restart the rebuild cycle. + let thumbnails = ctx.data_mut(|data| { + std::mem::take( + data.get_temp_mut_or_default::(egui::Id::new( + "image_thumbnail_demand", + )), + ) + }); + let mut demand = std::collections::BTreeMap::new(); + for canvas in &app.doc.canvases { + for item in &canvas.objects { + if !item.visible { + continue; + } + let CanvasObjectKind::RasterImage(image) = &item.kind else { + continue; + }; + let Some(asset) = app.doc.assets.get(&image.asset) else { + continue; + }; + let Some(frame) = canvas.content_page_frame(item.id) else { + continue; }; - if !fits_budget(edge, held(proxies)) { - // Off-screen sharpness is what gives way; those previews - // come back at the minimum edge. - release_off_screen_vectors(proxies, &vectors, &shown); + let mut visible_frame = egui::Rect::from_min_size( + egui::pos2(frame.x, frame.y), + egui::vec2(frame.width, frame.height), + ); + if let Some(panel) = canvas.parent_panel(item.id).and_then(|id| canvas.panel(id)) { + if !panel.visible { + continue; + } + if panel.clip_children { + let clip = panel.frame; + visible_frame = visible_frame.intersect(egui::Rect::from_min_size( + egui::pos2(clip.x, clip.y), + egui::vec2(clip.width, clip.height), + )); + } } - fits_budget(edge, held(proxies)).then_some((asset, page_index, Some(edge))) + if !visible_frame.is_positive() { + continue; + } + let origin = [ + canvas.board_pos[0] + visible_frame.min.x, + canvas.board_pos[1] + visible_frame.min.y, + ]; + let edge = if let Some(view) = view + .as_ref() + .filter(|view| view.shows(origin, visible_frame.size().into())) + { + view.preview_edge([frame.width, frame.height], image.crop) + } else if thumbnails.contains(&canvas.resource_id) { + 256 + } else { + continue; + }; + let entry = demand + .entry((asset.sha256, image.page_index)) + .or_insert((asset, 0)); + entry.1 = entry.1.max(edge); } - (None, None) => None, - }; - let Some((asset, page_index, edge)) = candidate else { + } + let allowance = fair_edge(demand.len()); + for (_, edge) in demand.values_mut() { + *edge = (*edge).min(allowance); + } + let proxies = &mut app.session.ui.raster_proxies; + proxies.retain(|proxy| demand.contains_key(&(proxy.hash, proxy.page_index))); + for proxy in proxies.iter_mut() { + let edge = demand[&(proxy.hash, proxy.page_index)].1; + if let Some((size, rgba8)) = shrink_preview(proxy.pixel_size, &proxy.rgba8, edge) { + proxy.pixel_size = size; + proxy.rgba8 = Arc::from(rgba8); + rebuilds + .completed + .insert((proxy.hash, proxy.page_index), edge); + } + } + // Recheck demand and fit the result into its current allowance before + // admitting it. An obsolete result cannot displace a visible preview. + if let Some(((hash, page_index), decoded_edge, mut proxy)) = ready + && let Some((_, edge)) = demand.get(&(hash, page_index)) + { + if let Some((size, rgba8)) = shrink_preview(proxy.pixel_size, &proxy.rgba8, *edge) { + proxy.pixel_size = size; + proxy.rgba8 = rgba8; + } + proxies.retain(|entry| entry.hash != hash || entry.page_index != page_index); + proxies.push(plotium_core::state::RasterProxy { + hash, + page_index, + pixel_size: proxy.pixel_size, + rgba8: Arc::from(proxy.rgba8), + }); + rebuilds + .completed + .insert((hash, page_index), decoded_edge.min(*edge)); + // The board and sidebar were already painted this frame. + ctx.request_repaint(); + } + if rebuilds.active.is_some() { + return; + } + let candidate = demand.iter().find(|((hash, page), (_, edge))| { + !rebuilds.failed.contains(&(*hash, *page)) + && !proxies.iter().any(|proxy| { + proxy.hash == *hash + && proxy.page_index == *page + && (proxy.pixel_size[0].max(proxy.pixel_size[1]) >= *edge + || rebuilds + .completed + .get(&(*hash, *page)) + .is_some_and(|completed| completed >= edge)) + }) + }); + let Some(((_, page_index), (asset, edge))) = candidate else { return; }; + let page_index = *page_index; + let edge = *edge; let hash = asset.sha256; let format = asset.format.clone(); let bytes = asset.bytes.clone(); let (sender, receiver) = mpsc::channel(); std::thread::spawn(move || { - let result = match edge { - Some(edge) => plotium_io::image::rasterize_svg_rgba8(&bytes, edge), - None => rebuild_preview(&bytes, page_index), + let result = rebuild_preview_at_edge(&bytes, page_index, edge) + .map_err(|error| error.to_string()); + // The receiver disappears when the application closes. + if sender.send(result).is_err() { + log::debug!("Image preview receiver closed"); } - .map_err(|error| error.to_string()); - let _ = sender.send(result); }); + #[cfg(test)] + { + rebuilds.started += 1; + } rebuilds.active = Some(ProxyRebuild { hash, page_index, format, + edge, receiver, }); ctx.request_repaint_after(std::time::Duration::from_millis(30)); }); } +fn shrink_preview(size: [u32; 2], rgba8: &[u8], edge: u32) -> Option<([u32; 2], Vec)> { + if size[0].max(size[1]) <= edge { + return None; + } + let source = image::RgbaImage::from_raw(size[0], size[1], rgba8.to_vec())?; + let resized = image::DynamicImage::ImageRgba8(source) + .thumbnail(edge, edge) + .into_rgba8(); + Some(([resized.width(), resized.height()], resized.into_raw())) +} + pub(super) fn insert_proxy( app: &mut PlotiumApp, hash: [u8; 32], page_index: u32, proxy: plotium_io::image::ProxyImage, ) { + if proxy.rgba8.is_empty() { + return; + } app.session .ui .raster_proxies @@ -227,51 +325,46 @@ impl BoardView { } } -/// Upgrades stop at the budget: past it, eviction would rebuild the same -/// previews forever instead of sharpening them. -fn fits_budget(edge: u32, held: usize) -> bool { - held + (edge as usize).pow(2) * 4 <= PROXY_BUDGET +fn fair_edge(count: usize) -> u32 { + let mut edge = VECTOR_PREVIEW_MAX_EDGE; + while (edge as usize).pow(2) * 4 > PROXY_BUDGET / count.max(1) && edge > 1 { + edge /= 2; + } + edge } -/// Raster previews never give way: they would come back at their full size. -fn release_off_screen_vectors( - proxies: &mut Vec, - vectors: &std::collections::BTreeSet<[u8; 32]>, - shown: &std::collections::BTreeSet<([u8; 32], u32)>, -) { - proxies.retain(|proxy| { - !vectors.contains(&proxy.hash) - || shown.contains(&(proxy.hash, proxy.page_index)) - || proxy.pixel_size[0].max(proxy.pixel_size[1]) <= VECTOR_PREVIEW_MIN_EDGE - }); +pub(super) fn rebuild_preview( + bytes: &[u8], + page_index: u32, +) -> Result { + let edge = if plotium_io::image::sniff(bytes) == plotium_io::image::RasterFormat::Svg { + VECTOR_PREVIEW_MIN_EDGE + } else { + 256 + }; + rebuild_preview_at_edge(bytes, page_index, edge) } -pub(super) fn rebuild_preview( +fn rebuild_preview_at_edge( bytes: &[u8], page_index: u32, + edge: u32, ) -> Result { if plotium_io::image::sniff(bytes) == plotium_io::image::RasterFormat::Svg { - return plotium_io::image::rasterize_svg_rgba8(bytes, VECTOR_PREVIEW_MIN_EDGE); + return plotium_io::image::rasterize_svg_rgba8(bytes, edge); } if page_index == 0 { - let edge = if plotium_io::image::probe(bytes)?.class - == plotium_io::image::ResourceClass::ProxyRequired - { - 2048 - } else { - 4096 - }; return plotium_io::image::decode_proxy_rgba8(bytes, edge); } let decoded = plotium_io::image::decode_rgba8_page(bytes, page_index, false)?; let source = image::RgbaImage::from_raw(decoded.probe.width, decoded.probe.height, decoded.rgba8) .ok_or(plotium_io::image::ImageError::InvalidDecodedSize)?; - let preview = if source.width() <= 4096 && source.height() <= 4096 { + let preview = if source.width() <= edge && source.height() <= edge { source } else { image::DynamicImage::ImageRgba8(source) - .thumbnail(4096, 4096) + .thumbnail(edge, edge) .into_rgba8() }; Ok(plotium_io::image::ProxyImage { @@ -291,6 +384,88 @@ mod tests { }; const FULL: [f32; 4] = [0.0, 0.0, 1.0, 1.0]; + fn set_viewport(ctx: &egui::Context, size: f32) { + crate::ui::canvas::store_navigation_rect( + ctx, + egui::Rect::from_min_size(egui::pos2(200.0, 100.0), egui::vec2(size, size)), + ); + } + + #[test] + fn viewport_and_panel_clipping_limit_demand_and_stale_result_is_dropped() { + let ctx = egui::Context::default(); + set_viewport(&ctx, 100.0); + let mut app = PlotiumApp::default(); + app.session.board.zoom = 1.0; + let mut canvas = CanvasDocument::new("Images".into(), [89.0, 60.0]); + for hash in 0..4_u8 { + let asset = AssetId::new(); + app.doc.assets.insert( + asset, + AssetRecord { + id: asset, + sha256: [hash; 32], + format: "png".into(), + pixel_size: [1024, 1024], + bytes: Vec::new(), + }, + ); + let object = canvas.allocate_object_id(); + canvas.objects.push(CanvasObject { + id: object, + name: "Image".into(), + frame: ObjectFrame::new(if hash == 1 { 60.0 } else { 0.0 }, 0.0, 10.0, 10.0), + locked: false, + visible: true, + kind: CanvasObjectKind::RasterImage(RasterImageContent::new(asset)), + }); + let panel = canvas.create_panel_for_content(object).unwrap(); + if hash == 2 { + canvas.panel_mut(panel).unwrap().visible = false; + } else if hash == 3 { + let panel = canvas.panel_mut(panel).unwrap(); + panel.clip_children = true; + panel.frame.x = -20.0; + canvas.object_mut(object).unwrap().frame.x = 20.0; + } + } + app.doc.canvases.push(canvas); + insert_proxy( + &mut app, + [0; 32], + 0, + plotium_io::image::ProxyImage { + pixel_size: [1, 1], + rgba8: vec![0; 4], + }, + ); + let (sender, receiver) = mpsc::channel(); + sender + .send(Ok(plotium_io::image::ProxyImage { + pixel_size: [4096, 4096], + rgba8: vec![0; PROXY_BUDGET], + })) + .unwrap(); + REBUILDS.with(|state| { + *state.borrow_mut() = ProxyRebuilds { + context: Some(app.session.project_context), + active: Some(ProxyRebuild { + hash: [1; 32], + page_index: 0, + format: "png".into(), + edge: 4096, + receiver, + }), + completed: [(([0; 32], 0), 1024)].into(), + ..Default::default() + }; + }); + poll_rebuilds(&mut app, &ctx); + assert_eq!(REBUILDS.with(|state| state.borrow().started), 0); + assert_eq!(app.session.ui.raster_proxies.len(), 1); + assert_eq!(app.session.ui.raster_proxies[0].hash, [0; 32]); + } + #[test] fn vector_preview_edge_follows_screen_size_in_bounded_buckets() { assert_eq!(VIEW.preview_edge([10.0, 5.0], FULL), 1024); @@ -311,29 +486,198 @@ mod tests { } #[test] - fn only_sharp_off_screen_vector_previews_are_released() { - let proxy = |id: u8, edge: u32| plotium_core::state::RasterProxy { - hash: [id; 32], - page_index: 0, - pixel_size: [edge, edge], - rgba8: Arc::from(Vec::new()), - }; - let mut proxies = vec![ - proxy(1, 3000), - proxy(2, 4096), - proxy(3, 2048), - proxy(4, 1024), - ]; - let vectors = [[2; 32], [3; 32], [4; 32]].into_iter().collect(); - let shown = [([2; 32], 0)].into_iter().collect(); - release_off_screen_vectors(&mut proxies, &vectors, &shown); - let kept: Vec<_> = proxies.iter().map(|proxy| proxy.hash[0]).collect(); - assert_eq!(kept, [1, 2, 4]); + fn twelve_large_previews_share_the_budget_without_eviction() { + let edge = fair_edge(12); + assert_eq!(edge, 1024); + assert!((edge as usize).pow(2) * 4 * 12 <= PROXY_BUDGET); + assert_eq!(fair_edge(1), 4096); + assert!(fair_edge(500) < edge); + } + #[test] + fn twelve_large_images_keep_all_decoded_previews_in_budget() { + let mut app = PlotiumApp::default(); + let edge = fair_edge(12); + let source = br#""#; + for id in 0..12 { + let proxy = rebuild_preview_at_edge(source, 0, edge).unwrap(); + assert_eq!(proxy.pixel_size, [1024, 768]); + insert_proxy(&mut app, [id; 32], 0, proxy); + } + assert_eq!(app.session.ui.raster_proxies.len(), 12); + assert!( + app.session + .ui + .raster_proxies + .iter() + .map(|p| p.rgba8.len()) + .sum::() + <= PROXY_BUDGET + ); } - #[test] - fn sharper_previews_stop_at_the_budget() { - assert!(fits_budget(2048, 0)); - assert!(!fits_budget(4096, PROXY_BUDGET)); + fn asynchronous_large_raster_batch_converges_and_thumbnail_survives() { + let source = image::RgbaImage::from_pixel(4000, 3000, image::Rgba([40, 80, 120, 255])); + let mut encoded = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(source) + .write_to(&mut encoded, image::ImageFormat::Png) + .unwrap(); + let mut app = PlotiumApp::default(); + for id in 0..12 { + let mut bytes = encoded.get_ref().clone(); + bytes.push(id); + let asset = AssetId::new(); + app.doc.assets.insert( + asset, + AssetRecord { + id: asset, + sha256: plotium_io::image::sha256(&bytes), + format: "png".into(), + pixel_size: [4000, 3000], + bytes, + }, + ); + let mut canvas = CanvasDocument::new(format!("Image {id}"), [89.0, 60.0]); + let object = canvas.allocate_object_id(); + canvas.objects.push(CanvasObject { + id: object, + name: "Image".into(), + frame: ObjectFrame::new(0.0, 0.0, 100.0, 75.0), + locked: false, + visible: true, + kind: CanvasObjectKind::RasterImage(RasterImageContent::new(asset)), + }); + canvas.create_panel_for_content(object).unwrap(); + app.doc.canvases.push(canvas); + } + let ctx = egui::Context::default(); + set_viewport(&ctx, 1000.0); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60); + loop { + poll_rebuilds(&mut app, &ctx); + if app.session.ui.raster_proxies.len() == 12 + && REBUILDS.with(|r| r.borrow().active.is_none()) + { + break; + } + assert!( + std::time::Instant::now() < deadline, + "previews did not converge" + ); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + let started = REBUILDS.with(|r| r.borrow().started); + for _ in 0..20 { + poll_rebuilds(&mut app, &ctx); + } + assert_eq!(REBUILDS.with(|r| r.borrow().started), started); + assert_eq!(started, 12); + assert!( + app.session + .ui + .raster_proxies + .iter() + .map(|p| p.rgba8.len()) + .sum::() + <= PROXY_BUDGET + ); + // A visible sidebar row still demands its preview with the board elsewhere. + app.session.board.world_center = [1e9, 1e9]; + let canvas = app.doc.canvases[0].resource_id; + request_thumbnail(&ctx, canvas); + poll_rebuilds(&mut app, &ctx); + assert_eq!(app.session.ui.raster_proxies.len(), 1); + assert!(app.session.ui.raster_proxies[0].pixel_size[0] <= 256); + request_thumbnail(&ctx, canvas); + poll_rebuilds(&mut app, &ctx); + assert_eq!(REBUILDS.with(|r| r.borrow().started), started); + app.session.board.world_center = [0.0, 0.0]; + app.session.board.zoom = 20.0; + for canvas in app.doc.canvases.iter_mut().skip(1) { + canvas.objects[0].visible = false; + } + loop { + poll_rebuilds(&mut app, &ctx); + if REBUILDS.with(|r| r.borrow().active.is_none()) { + break; + } + assert!(std::time::Instant::now() < deadline); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(app.session.ui.raster_proxies[0].pixel_size[0] > 256); + let upgraded = REBUILDS.with(|r| r.borrow().started); + for _ in 0..20 { + poll_rebuilds(&mut app, &ctx); + } + assert_eq!(REBUILDS.with(|r| r.borrow().started), upgraded); + let mut other = PlotiumApp::default(); + poll_rebuilds(&mut other, &ctx); + assert_eq!(REBUILDS.with(|r| r.borrow().started), 0); + } + #[test] + fn small_second_tiff_page_does_not_upgrade_forever() { + let mut encoded = std::io::Cursor::new(Vec::new()); + { + let mut encoder = tiff::encoder::TiffEncoder::new(&mut encoded).unwrap(); + encoder + .write_image::(20, 20, &[0; 400]) + .unwrap(); + encoder + .write_image::(1, 2, &[40, 80]) + .unwrap(); + } + let bytes = encoded.into_inner(); + let mut app = PlotiumApp::default(); + let asset = AssetId::new(); + app.doc.assets.insert( + asset, + AssetRecord { + id: asset, + sha256: plotium_io::image::sha256(&bytes), + format: "tiff".into(), + pixel_size: [20, 20], + bytes, + }, + ); + let mut canvas = CanvasDocument::new("TIFF".into(), [89.0, 60.0]); + let object = canvas.allocate_object_id(); + let mut raster = RasterImageContent::new(asset); + raster.page_index = 1; + canvas.objects.push(CanvasObject { + id: object, + name: "Page 2".into(), + frame: ObjectFrame::new(0.0, 0.0, 100.0, 75.0), + locked: false, + visible: true, + kind: CanvasObjectKind::RasterImage(raster), + }); + canvas.create_panel_for_content(object).unwrap(); + app.doc.canvases.push(canvas); + app.session.board.world_center = [1e9, 1e9]; + let canvas_id = app.doc.canvases[0].resource_id; + let ctx = egui::Context::default(); + poll_rebuilds(&mut app, &ctx); + assert_eq!(REBUILDS.with(|r| r.borrow().started), 0); + // A newly rendered sidebar row starts work in the same render pass. + request_thumbnail(&ctx, canvas_id); + poll_rebuilds(&mut app, &ctx); + assert_eq!(REBUILDS.with(|r| r.borrow().started), 1); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + loop { + request_thumbnail(&ctx, canvas_id); + poll_rebuilds(&mut app, &ctx); + if !app.session.ui.raster_proxies.is_empty() + && REBUILDS.with(|r| r.borrow().active.is_none()) + { + break; + } + assert!(std::time::Instant::now() < deadline); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + for _ in 0..20 { + request_thumbnail(&ctx, canvas_id); + poll_rebuilds(&mut app, &ctx); + } + assert_eq!(REBUILDS.with(|r| r.borrow().started), 1); + assert_eq!(app.session.ui.raster_proxies[0].pixel_size, [1, 2]); } } diff --git a/crates/app/src/ui/file_dialogs/image_import_tests.rs b/crates/app/src/ui/file_dialogs/image_import_tests.rs index a28c935a..1da7b6c3 100644 --- a/crates/app/src/ui/file_dialogs/image_import_tests.rs +++ b/crates/app/src/ui/file_dialogs/image_import_tests.rs @@ -44,6 +44,7 @@ fn ready_job(app: &mut PlotiumApp, target: ImportImageTarget) -> ImportJob { let (_events, receiver) = mpsc::channel(); let (consent, _worker_consent) = mpsc::channel(); ImportJob { + project_context: app.session.project_context, request: ImportImageRequest { paths: Vec::new(), payloads: Vec::new(), @@ -57,12 +58,13 @@ fn ready_job(app: &mut PlotiumApp, target: ImportImageTarget) -> ImportJob { receiver, cancelled: Arc::new(AtomicBool::new(false)), consent, + staged: Vec::new(), } } #[test] fn worker_reports_ordered_states_and_a_ready_candidate() { - let (sender, receiver) = mpsc::channel(); + let (sender, receiver) = mpsc::sync_channel(32); let (_consent_sender, consent) = mpsc::channel(); worker( Vec::new(), @@ -80,21 +82,21 @@ fn worker_reports_ordered_states_and_a_ready_candidate() { receiver.recv().unwrap(), WorkerEvent::State(ImportImageState::Probing) )); + let WorkerEvent::Candidate(Ok(candidate)) = receiver.recv().unwrap() else { + panic!("expected candidate"); + }; assert!(matches!( receiver.recv().unwrap(), WorkerEvent::State(ImportImageState::ReadyToCommit) )); - let WorkerEvent::Finished(results) = receiver.recv().unwrap() else { - panic!("expected finished event") - }; - let candidate = results.into_iter().next().unwrap().ok().unwrap(); + assert!(matches!(receiver.recv().unwrap(), WorkerEvent::Finished)); assert_eq!(candidate.pixel_size, [2, 2]); assert_eq!(candidate.format, "png"); } #[test] fn cancelled_worker_releases_payload_without_a_commit_event() { - let (sender, receiver) = mpsc::channel(); + let (sender, receiver) = mpsc::sync_channel(32); let (_consent_sender, consent) = mpsc::channel(); worker( Vec::new(), @@ -146,8 +148,8 @@ fn disconnected_worker_is_removed_and_reported() { #[test] fn all_tiff_pages_share_original_bytes_and_keep_distinct_page_indices() { let (_sender_guard, sender) = mpsc::channel::(); - let (events, _receiver) = mpsc::channel(); - let candidates = prepare_bytes( + let (events, receiver) = mpsc::sync_channel(32); + prepare_bytes( "stack.tiff".to_owned(), multipage_tiff_payload(), &events, @@ -160,10 +162,19 @@ fn all_tiff_pages_share_original_bytes_and_keep_distinct_page_indices() { }, ) .unwrap(); + let candidates: Vec<_> = receiver + .try_iter() + .filter_map(|e| match e { + WorkerEvent::Candidate(Ok(c)) => Some(c), + _ => None, + }) + .collect(); assert_eq!(candidates.len(), 2); assert_eq!(candidates[0].page_index, 0); assert_eq!(candidates[1].page_index, 1); assert_eq!(candidates[1].pixel_size, [1, 2]); + assert_eq!(candidates[1].preview.pixel_size, [1, 2]); + assert_eq!(candidates[1].preview.rgba8, [1, 2, 3, 255, 4, 5, 6, 255]); assert!(Arc::ptr_eq(&candidates[0].bytes, &candidates[1].bytes)); } @@ -187,6 +198,7 @@ fn successful_import_into_empty_project_creates_one_undoable_page() { let (_events, receiver) = mpsc::channel(); let (consent, _worker_consent) = mpsc::channel(); let mut job = ImportJob { + project_context: app.session.project_context, request: ImportImageRequest { paths: Vec::new(), payloads: Vec::new(), @@ -200,6 +212,7 @@ fn successful_import_into_empty_project_creates_one_undoable_page() { receiver, cancelled: Arc::new(AtomicBool::new(false)), consent, + staged: Vec::new(), }; commit(&mut app, &mut job, vec![Ok(candidate)]); @@ -259,6 +272,7 @@ fn multiple_images_into_empty_project_create_distinct_pages_in_source_order() { let (_events, receiver) = mpsc::channel(); let (consent, _worker_consent) = mpsc::channel(); let mut job = ImportJob { + project_context: app.session.project_context, request: ImportImageRequest { paths: Vec::new(), payloads: Vec::new(), @@ -272,6 +286,7 @@ fn multiple_images_into_empty_project_create_distinct_pages_in_source_order() { receiver, cancelled: Arc::new(AtomicBool::new(false)), consent, + staged: Vec::new(), }; commit( &mut app, @@ -385,6 +400,7 @@ fn failed_import_into_empty_project_does_not_create_a_page_or_undo_step() { let (_events, receiver) = mpsc::channel(); let (consent, _worker_consent) = mpsc::channel(); let mut job = ImportJob { + project_context: app.session.project_context, request: ImportImageRequest { paths: Vec::new(), payloads: Vec::new(), @@ -398,6 +414,7 @@ fn failed_import_into_empty_project_does_not_create_a_page_or_undo_step() { receiver, cancelled: Arc::new(AtomicBool::new(false)), consent, + staged: Vec::new(), }; commit( &mut app, @@ -530,3 +547,87 @@ fn automatic_page_uses_physical_size_and_caps_width_at_nature_single_column() { assert!((portrait[0] - 89.0).abs() < 1e-4); assert!((portrait[1] - 178.0).abs() < 1e-4); } + +#[test] +fn cancelled_batch_commits_prepared_images_as_one_undo_step() { + let mut app = PlotiumApp::default(); + let mut job = ready_job(&mut app, ImportImageTarget::NewPages); + let (sender, receiver) = mpsc::sync_channel(4); + job.receiver = receiver; + sender + .send(WorkerEvent::Candidate(Ok(candidate( + "prepared.png", + [2, 2], + )))) + .unwrap_or_else(|_| panic!("test receiver must remain open")); + sender + .send(WorkerEvent::State(ImportImageState::Cancelled)) + .unwrap_or_else(|_| panic!("test receiver must remain open")); + MANAGER.with(|manager| manager.borrow_mut().jobs.push(job)); + poll(&mut app, &egui::Context::default()); + assert_eq!(app.doc.canvases.len(), 1); + assert_eq!(app.doc.canvases[0].objects[0].name, "prepared"); + assert!(!has_active_jobs()); + app.undo(); + assert!(app.doc.canvases.is_empty()); + assert!(app.doc.assets.is_empty()); +} + +#[test] +fn staged_candidates_release_decoded_allocations_before_commit() { + let mut app = PlotiumApp::default(); + let mut job = ready_job(&mut app, ImportImageTarget::NewPages); + let (sender, receiver) = mpsc::sync_channel(2); + job.receiver = receiver; + sender + .send(WorkerEvent::Candidate(Ok(candidate( + "prepared.png", + [2, 2], + )))) + .unwrap_or_else(|_| panic!("test receiver must remain open")); + MANAGER.with(|manager| manager.borrow_mut().jobs.push(job)); + poll(&mut app, &egui::Context::default()); + MANAGER.with(|manager| { + let mut manager = manager.borrow_mut(); + let candidate = manager.jobs[0].staged[0].as_ref().ok().unwrap(); + assert_eq!(candidate.preview.rgba8.capacity(), 0); + assert!(!candidate.bytes.is_empty()); + manager.jobs.clear(); + }); + assert!(app.doc.canvases.is_empty()); +} + +#[test] +fn project_switch_discards_prepared_image_batch() { + let mut original = PlotiumApp::default(); + let mut job = ready_job(&mut original, ImportImageTarget::NewPages); + job.staged.push(Ok(candidate("old-project.png", [2, 2]))); + let cancelled = Arc::clone(&job.cancelled); + MANAGER.with(|manager| manager.borrow_mut().jobs.push(job)); + let mut other = PlotiumApp::default(); + poll(&mut other, &egui::Context::default()); + assert!(cancelled.load(Ordering::Relaxed)); + assert!(!has_active_jobs()); + assert!(other.doc.canvases.is_empty()); + assert!(other.session.undo_stack.is_empty()); +} + +#[test] +fn disconnected_worker_reports_and_keeps_prepared_images_once() { + let mut app = PlotiumApp::default(); + let mut job = ready_job(&mut app, ImportImageTarget::NewPages); + let (sender, receiver) = mpsc::sync_channel(2); + job.receiver = receiver; + sender + .send(WorkerEvent::Candidate(Ok(candidate("ready.png", [2, 2])))) + .unwrap_or_else(|_| panic!("test receiver remains open")); + drop(sender); + MANAGER.with(|manager| manager.borrow_mut().jobs.push(job)); + poll(&mut app, &egui::Context::default()); + assert_eq!(app.doc.canvases.len(), 1); + assert_eq!(app.session.status, "Imported 1 image(s); 1 failed."); + assert_eq!(app.session.undo_stack.len(), 1); + assert!(!has_active_jobs()); + app.undo(); + assert!(app.doc.canvases.is_empty()); +} diff --git a/crates/app/src/ui/mod.rs b/crates/app/src/ui/mod.rs index 6f3ca205..b4c74278 100644 --- a/crates/app/src/ui/mod.rs +++ b/crates/app/src/ui/mod.rs @@ -206,6 +206,7 @@ pub fn render( app.sync_reference_pick(); dataset_editor::window(app, ui.ctx()); data_sheet_dock(app, ui, dark); + canvas::clear_navigation_rect(&ctx); egui::CentralPanel::default() .frame(egui::Frame::new().fill(workspace_fill(dark)).inner_margin( central_workspace_margin( @@ -232,6 +233,9 @@ pub fn render( ); }); + // Collect sidebar demand and the final board camera before scheduling previews. + file_dialogs::image_import::poll_rebuilds(app, &ctx); + canvas_settings_window(app, &ctx); figure_typography::figure_typography_window(app, &ctx); panel_note_edit_window(app, &ctx); diff --git a/crates/app/src/ui/primary_sidebar/thumbnails.rs b/crates/app/src/ui/primary_sidebar/thumbnails.rs index bf3fa53c..36ae1cac 100644 --- a/crates/app/src/ui/primary_sidebar/thumbnails.rs +++ b/crates/app/src/ui/primary_sidebar/thumbnails.rs @@ -32,6 +32,10 @@ pub(super) fn canvas_row( if !ui.is_rect_visible(rect) { return response; } + crate::ui::file_dialogs::image_import::request_thumbnail( + ui.ctx(), + app.doc.canvases[ci].resource_id, + ); let visuals = ui.style().interact_selectable(&response, selected); let painter = ui.painter_at(rect); if selected || response.hovered() || response.has_focus() { diff --git a/docs/src/content/docs/guides/images.md b/docs/src/content/docs/guides/images.md index b26ede49..442eb42b 100644 --- a/docs/src/content/docs/guides/images.md +++ b/docs/src/content/docs/guides/images.md @@ -24,8 +24,9 @@ as Paint. Pasting in a text field continues to paste text normally. When Plotium creates a page for an image, it uses the image's physical size when DPI information is available and assumes 96 DPI otherwise. The page is at most 89 mm wide, and its height follows the image's aspect ratio. The image fills the -page without distortion. Importing several images is one undoable action; a -failed or cancelled import leaves the project unchanged. +page without distortion. Importing several images is one undoable action. If preparation is cancelled or some files fail, the images +already prepared successfully are added together and can be undone in one step. +If none succeeded, the project is unchanged. For formats that need special handling, use these commands: @@ -33,8 +34,12 @@ For formats that need special handling, use these commands: PNG or WebP. **Add Images…** tells you when an image is animated. - **Add Images Without Metadata…** removes metadata by converting the image to PNG before adding it. -- **Add All TIFF Pages…** adds every readable page of a multi-page TIFF. The - standard **Add Images…** command adds only its first page. +- **Add All TIFF Pages…** reads the pages of each TIFF in order. If a page + cannot be read, earlier pages are kept and the remaining pages of that TIFF + are skipped. The standard **Add Images…** command adds only the first page. + +Preview detail adapts to the visible size and available memory; publication +export still uses the source. ## Adjust an image diff --git a/docs/src/content/docs/zh-cn/guides/images.md b/docs/src/content/docs/zh-cn/guides/images.md index 025c4bea..f5ea7715 100644 --- a/docs/src/content/docs/zh-cn/guides/images.md +++ b/docs/src/content/docs/zh-cn/guides/images.md @@ -20,15 +20,18 @@ description: 把显微图、照片等图片添加到图形页面和 Panel 中。 Plotium 创建图片页面时,会优先采用图片的 DPI 信息确定实际尺寸;没有 DPI 信息时按 96 DPI 计算。页面宽度不超过 89 mm,高度保持图片的原始宽高比,图片会无变形地 -铺满页面。一次导入多张图片只产生一个撤销步骤;导入失败或取消不会改动项目。 +铺满页面。一次导入多张图片只产生一个撤销步骤。准备阶段取消或部分文件失败时,已经准备成功的 +图片会一起加入项目,并可一步撤销;没有成功项时,项目保持不变。 以下格式需要使用专门的命令: - **Add Animated Image First Frame…** 导入动画 PNG 或 WebP 的第一帧。用 **Add Images…** 添加动画图片时,Plotium 会提示它是动画。 - **Add Images Without Metadata…** 先把图片转换为 PNG,以去除元数据,再添加到项目。 -- **Add All TIFF Pages…** 添加多页 TIFF 中所有可读取的页面;常规的 **Add Images…** - 只添加第一页。 +- **Add All TIFF Pages…** 按顺序读取每个 TIFF 的页面。如果某页无法读取,已读取的页面会保留, + 该 TIFF 的剩余页面会被跳过。常规的 **Add Images…** 只添加第一页。 + +预览精度会适应可见尺寸和可用内存,出版导出仍使用原始图片。 ## 调整图片