diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 930a44498..8971d8534 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -19,7 +19,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "b2f1f0417e41ab934c7b979b2fd2b3b69cab52a193d035122a58a4d00f2c0566" +sha256 = "b2afa2c39b397f44feed8aa3ebbaf8103c8d69c8e75a9671e01cf08140c85a4b" [[asset]] path = "crates/ui-egui/src/i18n/zh-hant.tsv" @@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "448db933a78de5e51dfae62d9a34293da0ead418ecafeec94bf08c50510f41d8" +sha256 = "6d61957b3b6208ceb5d313638c610e81eca6ee186a4fe03ece08b4f87f39fd0c" [[asset]] path = "crates/ui-egui/src/i18n/cs.tsv" @@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "e275641e7a20916219762730e18160edba7c4d0ac7fd963338ee1fb95ec1c355" +sha256 = "47ea331f7cf88a7e8584d358999d03af8b4250904a40d450f3d23bb151ce5c78" [[asset]] path = "crates/ui-egui/src/i18n/fr.tsv" @@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "8ef5004a37a5998f789d62d708a7d1973ea5b0e3a606319449597d2908423853" +sha256 = "a4f02e370ce90fc5917915ebaa79ffe82a679e5e2b25087b7a6646430de1e457" [[asset]] path = "crates/ui-egui/src/i18n/te.tsv" @@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "ac7789cda9148a2b75c4e5156de9bdd6ddbcc5b6405f25abf06fcf8d861c6853" +sha256 = "f862797c038b3c5e2d91d387356ebe2b38d90a0e25f4a2154ae73182af575f59" [[asset]] path = "crates/ui-egui/src/i18n/ja.tsv" @@ -96,7 +96,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "69dc55fca4e7c03b7442e86ab0274b2840e8185f5ad1382a1d54929fb6b8924c" +sha256 = "38eae66397d61c9ed13bef9c393f612df08936b3ac066a3e8af3a6aea36d7b5e" [[asset]] path = "assets/icons/accessibility.svg" diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 98a469342..68de16b03 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -20,6 +20,7 @@ pub mod export; pub mod js; pub mod links; pub mod ocr; +pub mod optimizer; pub mod signature_image; pub mod xfa; @@ -1836,6 +1837,8 @@ pub enum EditError { Sign(String), #[error("{0}")] Optimize(String), + #[error("cancelled")] + Cancelled, #[error("{0} isn't possible in a signed document: it would rewrite the file and invalidate the signatures")] SignedRewrite(String), #[error("this document is signed: rewriting it would invalidate its signatures (save it incrementally instead)")] @@ -2572,21 +2575,10 @@ impl Session { /// Optimize PDF ▸ Advanced optimization: `settings` for images and objects, plus Remove /// Hidden Information's `discard` categories (user data). A full rewrite: signed documents - /// are refused. The open document is not changed. + /// are refused. The open document is not changed. This is [`Self::optimize_job`] run in + /// place, without progress. pub fn optimized_bytes(&self, id: DocId, settings: &optimize::Settings, discard: &[Hidden]) -> Result<(Arc>, OptimizeReport), EditError> { - let doc = self.get(id).ok_or(EditError::NoDocument)?; - if doc.is_signed() { - return Err(EditError::Signed); - } - let editor = doc.editor.as_ref().ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?; - let mut cos = editor.cos.clone(); - let discarded = if discard.is_empty() { Vec::new() } else { pdfcraft_redact::sanitize::remove_hidden(&mut cos, discard)? }; - let report = optimize::optimize(&mut cos, settings).map_err(|e| EditError::Optimize(e.to_string()))?; - let all: Vec = cos.object_numbers().into_iter().map(|n| pdfcraft_cos::ObjRef::new(n, cos.generation(n))).collect(); - let merged = pdfcraft_organize::dedupe_resources(&mut cos, &all, false); - let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), ..SaveOptions::default() }; - let bytes = write_full(&cos, &opts).map_err(|e| EditError::Write(e.to_string()))?; - Ok((Arc::new(bytes), OptimizeReport { optimize: report, merged, discarded })) + self.optimize_job(id, settings, discard)?.run(|_| true) } /// Export comments and/or form data: XFDF and FDF carry either or both; XML, CSV and text diff --git a/crates/engine/src/optimizer.rs b/crates/engine/src/optimizer.rs new file mode 100644 index 000000000..569dd81b0 --- /dev/null +++ b/crates/engine/src/optimizer.rs @@ -0,0 +1,104 @@ +//! Optimize PDF ▸ Advanced optimization as a job that reports its progress. +//! +//! Optimizing a large file takes a while (every image is decoded, resampled and re-encoded), so +//! it is split like OCR: [`Session::optimize_job`] captures a copy of the document and the +//! choices, and [`OptimizeJob::run`] works anywhere (the UI runs it on a worker thread), +//! reporting each [`OptimizeStage`] and stopping when asked to. + +use std::sync::Arc; + +use pdfcraft_cos::{SaveOptions, write_full}; + +use crate::{DocId, EditError, Hidden, OptimizeReport, Session, guard, optimize}; + +/// Where an optimization is. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum OptimizeStage { + /// Removing the user data chosen under Discard User Data. + #[default] + Discarding, + /// About to process image `done` (0-based) of `total`. + Images { done: usize, total: usize }, + /// Discarding objects and compressing unencoded streams. + CleaningUp, + /// Merging identical fonts and images. + Merging, + /// Writing the compressed copy. + Writing, +} + +impl OptimizeStage { + /// How far along the whole run is, from 0 to 1. Images take most of the time; the rest are + /// small fixed steps. + pub fn fraction(self) -> f32 { + match self { + OptimizeStage::Discarding => 0.0, + OptimizeStage::Images { done, total } => 0.05 + 0.75 * if total == 0 { 1.0 } else { done.min(total) as f32 / total as f32 }, + OptimizeStage::CleaningUp => 0.8, + OptimizeStage::Merging => 0.85, + OptimizeStage::Writing => 0.9, + } + } +} + +/// The optimized copy's bytes and what was done. +pub type Optimized = (Arc>, OptimizeReport); + +/// What an optimization needs, independent of the session. +pub struct OptimizeJob { + cos: pdfcraft_cos::Document, + settings: optimize::Settings, + discard: Vec, + opts: SaveOptions, +} + +impl Session { + /// Capture what optimizing document `id` with `settings` and the Remove Hidden Information + /// `discard` categories needs. A full rewrite: signed documents are refused. + pub fn optimize_job(&self, id: DocId, settings: &optimize::Settings, discard: &[Hidden]) -> Result { + let doc = self.get(id).ok_or(EditError::NoDocument)?; + if doc.is_signed() { + return Err(EditError::Signed); + } + let editor = doc.editor.as_ref().ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?; + Ok(OptimizeJob { + cos: editor.cos.clone(), + settings: settings.clone(), + discard: discard.to_vec(), + opts: SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), ..SaveOptions::default() }, + }) + } +} + +impl OptimizeJob { + /// Optimize and write the copy. `progress` is called as each stage starts and before each + /// image; returning `false` stops with [`EditError::Cancelled`]. A panic is returned as an + /// error, so a worker thread always reports back. + pub fn run(self, mut progress: impl FnMut(OptimizeStage) -> bool) -> Result { + guard(move || self.work(&mut progress)).map_err(EditError::Optimize)? + } + + fn work(self, progress: &mut dyn FnMut(OptimizeStage) -> bool) -> Result { + let OptimizeJob { mut cos, settings, discard, opts } = self; + let mut step = |stage| if progress(stage) { Ok(()) } else { Err(EditError::Cancelled) }; + step(OptimizeStage::Discarding)?; + let discarded = if discard.is_empty() { Vec::new() } else { pdfcraft_redact::sanitize::remove_hidden(&mut cos, &discard)? }; + let report = optimize::optimize_with_progress(&mut cos, &settings, &mut |s| { + step(match s { + optimize::Stage::Images { done, total } => OptimizeStage::Images { done, total }, + optimize::Stage::CleanUp => OptimizeStage::CleaningUp, + }) + .is_ok() + }) + .map_err(|e| match e { + optimize::OptimizeError::Cancelled => EditError::Cancelled, + e => EditError::Optimize(e.to_string()), + })?; + step(OptimizeStage::Merging)?; + let all: Vec = cos.object_numbers().into_iter().map(|n| pdfcraft_cos::ObjRef::new(n, cos.generation(n))).collect(); + let merged = pdfcraft_organize::dedupe_resources(&mut cos, &all, false); + step(OptimizeStage::Writing)?; + let bytes = write_full(&cos, &opts).map_err(|e| EditError::Write(e.to_string()))?; + Ok((Arc::new(bytes), OptimizeReport { optimize: report, merged, discarded })) + } +} diff --git a/crates/engine/src/signature_image.rs b/crates/engine/src/signature_image.rs index 2af4a5f20..3a2d5109c 100644 --- a/crates/engine/src/signature_image.rs +++ b/crates/engine/src/signature_image.rs @@ -254,7 +254,7 @@ mod tests { let preview = doc.image_signature_preview(0, 0).unwrap().unwrap(); assert_eq!(preview.image.rgba(), image.rgba()); let background = preview.render_background(1.0).unwrap(); - assert!(background.rgba.chunks_exact(4).all(|p| p == [255, 255, 255, 255])); + assert!(background.rgba.as_chunks::<4>().0.iter().all(|p| *p == [255, 255, 255, 255])); assert!(Arc::ptr_eq(&bytes, &doc.bytes)); assert_eq!(doc.edit_generation(), generation); assert_eq!(doc.can_undo(), Some("Add signature")); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9759a72d7..6a03dc547 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -987,6 +987,39 @@ fn create_and_reduce() { assert_eq!(page_texts(&s3, r), ["Page 1", "Page 1"]); } +#[test] +fn an_optimize_job_reports_its_stages_and_can_be_cancelled() { + use crate::optimizer::OptimizeStage; + let (s, id) = session_with(2); + let settings = optimize::Settings::default(); + let mut stages = Vec::new(); + let (bytes, _) = s + .optimize_job(id, &settings, &[]) + .unwrap() + .run(|st| { + stages.push(st); + true + }) + .unwrap(); + assert!(bytes.starts_with(b"%PDF-")); + assert_eq!(stages.first(), Some(&OptimizeStage::Discarding)); + assert_eq!(&stages[stages.len() - 3..], [OptimizeStage::CleaningUp, OptimizeStage::Merging, OptimizeStage::Writing]); + let fractions: Vec = stages.iter().map(|st| st.fraction()).collect(); + assert!(fractions.windows(2).all(|w| w[0] <= w[1]), "the bar never goes back: {fractions:?}"); + assert!(fractions.iter().all(|f| (0.0..=1.0).contains(f))); + assert_eq!( + OptimizeStage::Images { done: 0, total: 0 }.fraction(), + OptimizeStage::Images { done: 5, total: 5 }.fraction(), + "no images: no division by zero" + ); + + // Cancelled at the merge: nothing is written, the open document is untouched. + let before = s.get(id).unwrap().bytes.clone(); + let r = s.optimize_job(id, &settings, &[]).unwrap().run(|st| st != OptimizeStage::Merging); + assert!(matches!(r, Err(EditError::Cancelled)), "{r:?}"); + assert_eq!(s.get(id).unwrap().bytes, before); +} + #[test] fn flattening_keeps_the_look_and_drops_the_objects() { let mut s = Session::new().with_clock(|| 1_700_000_000); diff --git a/crates/optimize/README.md b/crates/optimize/README.md index 6b1adb38d..87d061282 100644 --- a/crates/optimize/README.md +++ b/crates/optimize/README.md @@ -5,6 +5,8 @@ Advanced optimization). ```rust let report = optimize(&mut doc, &Settings::default())?; // Reduce File Size's choices +// Or with progress: called before each image and the clean-up; `false` cancels. +let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going)?; ``` - **Images:** the effective resolution of each image is measured where pages draw it (the CTM @@ -17,5 +19,7 @@ let report = optimize(&mut doc, &Settings::default())?; // Reduce File Size's - **Clean up:** Flate for streams with no filter. The engine runs Remove Hidden Information (`pdfcraft-redact`) for the user-data categories, -then this, then merges identical objects and writes a full, compressed save. JPEG decoding and +then this, then merges identical objects and writes a full, compressed save +(`pdfcraft_engine::optimizer::OptimizeJob`, which the UI runs on a worker thread with a +progress bar). JPEG decoding and encoding and resampling come from the `image` crate. diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index 69e16ba3f..4f079e14b 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -5,7 +5,7 @@ use std::collections::{HashMap, HashSet}; use pdfcraft_content::Matrix; use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream}; -use crate::{Compression, ImageSettings, OptimizeError, Report, Settings}; +use crate::{Compression, ImageSettings, OptimizeError, Report, Settings, Stage}; /// Images with more pixels than this are left alone (memory). const MAX_PIXELS: u64 = 80_000_000; @@ -292,7 +292,13 @@ fn process(doc: &Document, s: &Stream, ppi: f64, settings: &ImageSettings, n: us (after < before).then_some((new, smask)) } -pub(crate) fn run(doc: &mut Document, pages: &[ObjRef], settings: &Settings, report: &mut Report) -> Result<(), OptimizeError> { +pub(crate) fn run( + doc: &mut Document, + pages: &[ObjRef], + settings: &Settings, + report: &mut Report, + progress: &mut dyn FnMut(Stage) -> bool, +) -> Result<(), OptimizeError> { let ppi = effective_resolutions(doc, pages); // Soft masks are processed with their image, not on their own. let masks: HashSet = ppi @@ -304,7 +310,11 @@ pub(crate) fn run(doc: &mut Document, pages: &[ObjRef], settings: &Settings, rep .collect(); let mut refs: Vec<(ObjRef, f64)> = ppi.into_iter().filter(|(r, _)| !masks.contains(r)).collect(); refs.sort_by_key(|(r, _)| r.num); - for (r, ppi) in refs { + let total = refs.len(); + for (done, (r, ppi)) in refs.into_iter().enumerate() { + if !progress(Stage::Images { done, total }) { + return Err(OptimizeError::Cancelled); + } let obj = doc.get(r); let Object::Stream(s) = &*obj else { continue }; report.images += 1; @@ -324,5 +334,8 @@ pub(crate) fn run(doc: &mut Document, pages: &[ObjRef], settings: &Settings, rep doc.set(mr, Object::Stream(m)); } } + if !progress(Stage::Images { done: total, total }) { + return Err(OptimizeError::Cancelled); + } Ok(()) } diff --git a/crates/optimize/src/lib.rs b/crates/optimize/src/lib.rs index 89b90a0a1..7f241b680 100644 --- a/crates/optimize/src/lib.rs +++ b/crates/optimize/src/lib.rs @@ -33,6 +33,17 @@ pub enum OptimizeError { NoPages, #[error(transparent)] Cos(#[from] pdfcraft_cos::CosError), + #[error("the optimization was cancelled")] + Cancelled, +} + +/// Where an optimization is, reported to [`optimize_with_progress`]'s callback. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Stage { + /// About to process image `done` (0-based) of `total`; `done == total` once all are done. + Images { done: usize, total: usize }, + /// Discarding objects, cleaning up links and compressing unencoded streams. + CleanUp, } /// How resampled (or recompressed) images are stored. @@ -118,9 +129,18 @@ pub struct Report { /// Optimize `doc` in place. pub fn optimize(doc: &mut Document, settings: &Settings) -> Result { + optimize_with_progress(doc, settings, &mut |_| true) +} + +/// [`optimize`], calling `progress` before each image and before the clean-up. Returning `false` +/// stops with [`OptimizeError::Cancelled`]; `doc` is then partly optimized and should be dropped. +pub fn optimize_with_progress(doc: &mut Document, settings: &Settings, progress: &mut dyn FnMut(Stage) -> bool) -> Result { let mut report = Report::default(); let pages = pdfcraft_annot::page_refs(doc).map_err(|_| OptimizeError::NoPages)?; - images::run(doc, &pages, settings, &mut report)?; + images::run(doc, &pages, settings, &mut report, progress)?; + if !progress(Stage::CleanUp) { + return Err(OptimizeError::Cancelled); + } if settings.discard_thumbnails { for p in &pages { if doc.get(*p).as_dict().is_some_and(|d| d.contains(b"Thumb")) { diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index 1b9b969a7..9e698756a 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -265,3 +265,42 @@ fn invalid_links_and_unreferenced_destinations_go() { let off = Settings { remove_invalid_links: false, remove_unreferenced_dests: false, ..Settings::default() }; assert_eq!(optimize(&mut again, &off).unwrap().invalid_links, 0); } + +#[test] +fn progress_is_reported_per_image_and_a_refusal_cancels() { + use pdfcraft_optimize::{OptimizeError, Stage, optimize_with_progress}; + let build = || { + let mut doc = Document::new_empty(); + let a = image(&mut doc, 300, 300, 3, false, None); + let b = image(&mut doc, 300, 300, 1, false, None); + let c = image(&mut doc, 300, 300, 3, true, None); + page(&mut doc, &[("A", a), ("B", b), ("C", c)], "q 72 0 0 72 0 0 cm /A Do Q q 72 0 0 72 100 0 cm /B Do Q q 72 0 0 72 200 0 cm /C Do Q"); + doc + }; + let mut seen = Vec::new(); + let report = optimize_with_progress(&mut build(), &Settings::default(), &mut |s| { + seen.push(s); + true + }) + .unwrap(); + assert_eq!(report.images, 3); + assert_eq!( + seen, + [ + Stage::Images { done: 0, total: 3 }, + Stage::Images { done: 1, total: 3 }, + Stage::Images { done: 2, total: 3 }, + Stage::Images { done: 3, total: 3 }, + Stage::CleanUp + ] + ); + + // Stopping after the first image. + let mut calls = 0; + let r = optimize_with_progress(&mut build(), &Settings::default(), &mut |_| { + calls += 1; + calls < 2 + }); + assert!(matches!(r, Err(OptimizeError::Cancelled)), "{r:?}"); + assert_eq!(calls, 2, "nothing runs after the refusal"); +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index f3295c6e1..b80135fff 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -594,6 +594,7 @@ impl Host for crate::PdfCraftApp { "theme_preference": self.theme_preference, "language": self.language, "notice": self.toast.as_ref().map(|t| t.0.clone()), + "progress": self.progress_notice.as_ref().map(|p| json!({ "label": p.label, "fraction": p.fraction })), "password_prompt": self.password_prompt.is_some(), "close_prompt": self.close_request.is_some(), }) diff --git a/crates/ui-egui/src/create_ui.rs b/crates/ui-egui/src/create_ui.rs index 9e2c30b5b..56e82d885 100644 --- a/crates/ui-egui/src/create_ui.rs +++ b/crates/ui-egui/src/create_ui.rs @@ -221,15 +221,9 @@ impl PdfCraftApp { } /// Reduce File Size: write a compacted copy with images downsampled (the open document is - /// unchanged). + /// unchanged). It runs in the background with a progress bar, like the PDF Optimizer. pub(crate) fn reduce_file_size(&mut self) { - // What's typed in a form field is part of the document (#166). - if !self.commit_form_typing() { - return; - } - let Some((_, id)) = self.active_ids() else { return }; - let result = self.session.reduced_bytes(id).map(|(b, _)| (b, String::new())); - self.save_optimized(id, "reduced", result); + self.start_optimize(crate::optimize_ui::OptimizeKind::Reduce, &pdfcraft_engine::optimize::Settings::default(), &[]); } /// Save an optimized copy (Reduce File Size, Optimize PDF) next to the original, reporting diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index abdcd16e2..657d73f83 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1502,6 +1502,15 @@ Created 1 link Se ha creado 1 enlace Created {n} links Se han creado {n} enlaces Text recognition is already running El reconocimiento de texto ya está en ejecución + Optimizing… removing user data Optimizando… eliminando datos de usuario + Optimizing… image {d} of {t} Optimizando… imagen {d} de {t} + Optimizing… cleaning up Optimizando… limpiando + Optimizing… merging identical objects Optimizando… combinando objetos idénticos + Optimizing… writing the copy Optimizando… escribiendo la copia + Cancelling… Cancelando… + Optimization is already running La optimización ya está en ejecución + Optimization cancelled Optimización cancelada + Couldn't optimize the file No se pudo optimizar el archivo Recognizing text… file {d} of {t} Reconociendo texto… archivo {d} de {t} Recognizing text… page {d} of {t} Reconociendo texto… página {d} de {t} Recognized 1 word Se ha reconocido 1 palabra diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index a787eb267..b99824aa0 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1499,6 +1499,15 @@ Created 1 link 1 lien créé Created {n} links {n} liens créés Text recognition is already running La reconnaissance de texte est déjà en cours + Optimizing… removing user data Optimisation… suppression des données utilisateur + Optimizing… image {d} of {t} Optimisation… image {d} sur {t} + Optimizing… cleaning up Optimisation… nettoyage + Optimizing… merging identical objects Optimisation… fusion des objets identiques + Optimizing… writing the copy Optimisation… écriture de la copie + Cancelling… Annulation… + Optimization is already running L'optimisation est déjà en cours + Optimization cancelled Optimisation annulée + Couldn't optimize the file Impossible d'optimiser le fichier Recognizing text… file {d} of {t} Reconnaissance du texte… fichier {d} sur {t} Recognizing text… page {d} of {t} Reconnaissance du texte… page {d} sur {t} Recognized 1 word 1 mot reconnu diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 6eef7ae11..b81e88556 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1508,6 +1508,15 @@ Created 1 link リンクを 1 件作成しました Created {n} links リンクを {n} 件作成しました Text recognition is already running テキスト認識はすでに実行中です + Optimizing… removing user data 最適化中… ユーザーデータを削除しています + Optimizing… image {d} of {t} 最適化中… 画像 {d} / {t} + Optimizing… cleaning up 最適化中… クリーンアップしています + Optimizing… merging identical objects 最適化中… 同一オブジェクトを統合しています + Optimizing… writing the copy 最適化中… コピーを書き込んでいます + Cancelling… キャンセルしています… + Optimization is already running 最適化はすでに実行中です + Optimization cancelled 最適化をキャンセルしました + Couldn't optimize the file ファイルを最適化できませんでした Recognizing text… file {d} of {t} テキストを認識中… ファイル {d} / {t} Recognizing text… page {d} of {t} テキストを認識中… ページ {d} / {t} Recognized 1 word 1 語を認識しました diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index f78afde6c..17d7fda3e 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -1504,6 +1504,15 @@ action wizard Start ప్రారంభించు Created 1 link 1 లింక్ సృష్టించబడింది Created {n} links {n} లింక్‌లు సృష్టించబడ్డాయి Text recognition is already running టెక్స్ట్ గుర్తింపు ఇప్పటికే అమలవుతోంది + Optimizing… removing user data ఆప్టిమైజ్ చేస్తోంది… వినియోగదారు డేటాను తీసివేస్తోంది + Optimizing… image {d} of {t} ఆప్టిమైజ్ చేస్తోంది… {t}లో {d}వ చిత్రం + Optimizing… cleaning up ఆప్టిమైజ్ చేస్తోంది… చక్కదిద్దుతోంది + Optimizing… merging identical objects ఆప్టిమైజ్ చేస్తోంది… ఒకేలా ఉన్న ఆబ్జెక్ట్‌లను విలీనం చేస్తోంది + Optimizing… writing the copy ఆప్టిమైజ్ చేస్తోంది… కాపీని రాస్తోంది + Cancelling… రద్దు చేస్తోంది… + Optimization is already running ఆప్టిమైజేషన్ ఇప్పటికే అమలవుతోంది + Optimization cancelled ఆప్టిమైజేషన్ రద్దు చేయబడింది + Couldn't optimize the file ఫైల్‌ను ఆప్టిమైజ్ చేయడం సాధ్యం కాలేదు Recognizing text… file {d} of {t} టెక్స్ట్‌ను గుర్తిస్తోంది… {t}లో {d}వ ఫైల్ Recognizing text… page {d} of {t} టెక్స్ట్‌ను గుర్తిస్తోంది… {t}లో {d}వ పేజీ Recognized 1 word 1 పదం గుర్తించబడింది diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 508aaf571..4f0487f68 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1459,6 +1459,15 @@ Created 1 link 已创建 1 个链接 Created {n} links 已创建{n}个链接 Text recognition is already running 文本识别已在运行 + Optimizing… removing user data 正在优化…删除用户数据 + Optimizing… image {d} of {t} 正在优化…第{d}张图像,共{t}张 + Optimizing… cleaning up 正在优化…清理 + Optimizing… merging identical objects 正在优化…合并相同对象 + Optimizing… writing the copy 正在优化…写入副本 + Cancelling… 正在取消… + Optimization is already running 优化已在运行 + Optimization cancelled 已取消优化 + Couldn't optimize the file 无法优化文件 Recognizing text… file {d} of {t} 正在识别文本…第{d}个文件,共{t}个 Recognizing text… page {d} of {t} 正在识别文本…第{d}页,共{t}页 Recognized 1 word 识别了 1 个单词 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 39a214713..8c041d929 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1417,6 +1417,15 @@ Created 1 link 已建立 1 個連結 Created {n} links 已建立{n}個連結 Text recognition is already running 文字識別已在執行 + Optimizing… removing user data 正在最佳化…移除使用者資料 + Optimizing… image {d} of {t} 正在最佳化…第{d}張圖片,共{t}張 + Optimizing… cleaning up 正在最佳化…清理 + Optimizing… merging identical objects 正在最佳化…合併相同物件 + Optimizing… writing the copy 正在最佳化…寫入副本 + Cancelling… 正在取消… + Optimization is already running 最佳化已在執行 + Optimization cancelled 已取消最佳化 + Couldn't optimize the file 無法最佳化檔案 Recognizing text… file {d} of {t} 正在辨識文字…第{d}個檔案,共{t}個 Recognizing text… page {d} of {t} 正在辨識文字…第{d}頁,共{t}頁 Recognized 1 word 辨識了 1 個單詞 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 32cecb170..a80d6629b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -457,6 +457,10 @@ pub struct PdfCraftApp { pub stamp_draft: stamps_ui::StampDraft, /// PDF Optimizer choices. pub optimize_draft: OptimizeDraft, + /// The running optimization (Optimize PDF ▸ Advanced optimization). + pub optimize_run: Option, + /// A background job's progress card (see [`widgets::progress_notice`]). + pub progress_notice: Option, /// Pages copied or cut in Organize Pages, ready to paste (into any document). pub page_clipboard: Option, /// Files dropped on the page grid, waiting for the pointer to say which gap they go to. @@ -659,6 +663,8 @@ impl PdfCraftApp { custom_stamps: Vec::new(), stamp_draft: Default::default(), optimize_draft: OptimizeDraft::default(), + optimize_run: None, + progress_notice: None, page_clipboard: None, grid_drop: None, last_snapshot: None, @@ -1108,6 +1114,13 @@ impl PdfCraftApp { } } + /// Draw the running job's progress card and pass a Cancel click on to the job. + fn show_progress(&mut self, ctx: &egui::Context) { + if widgets::progress_notice(self, ctx) { + self.cancel_optimize(); + } + } + fn sync_theme(&mut self, ctx: &egui::Context) { let kind = self.theme_preference.resolve(ctx.system_theme(), self.theme); if kind != self.theme { @@ -1636,6 +1649,7 @@ impl eframe::App for PdfCraftApp { } self.poll_export(); self.poll_ocr(); + self.poll_optimize(); self.poll_action(); self.process_file_requests(); #[cfg(not(target_arch = "wasm32"))] @@ -1680,6 +1694,7 @@ impl eframe::App for PdfCraftApp { ); dialogs::show(self, &ctx); // Notices too: a refused field value or a failed save must be seen in full screen. + self.show_progress(&ctx); widgets::toast(self, &ctx); return; } @@ -1703,6 +1718,7 @@ impl eframe::App for PdfCraftApp { self.process_pending_edits(); palette::show(self, &ctx); dialogs::show(self, &ctx); + self.show_progress(&ctx); widgets::toast(self, &ctx); } } diff --git a/crates/ui-egui/src/optimize_ui.rs b/crates/ui-egui/src/optimize_ui.rs index a607cfb12..ba65f465f 100644 --- a/crates/ui-egui/src/optimize_ui.rs +++ b/crates/ui-egui/src/optimize_ui.rs @@ -1,9 +1,16 @@ //! Optimize PDF ▸ Advanced optimization (Acrobat's PDF Optimizer): Images, Discard Objects, //! Discard User Data and Clean Up panels. The result is saved as a copy, like Reduce File Size. +//! +//! The optimization (this dialog's, or Reduce File Size's fixed choices) runs on a worker thread +//! and shows its progress in a notice with a bar and a Cancel button; the copy is saved once it +//! is done. + +use std::sync::{Arc, Mutex}; use egui::{Align, Layout}; -use pdfcraft_engine::Hidden; use pdfcraft_engine::optimize::{Compression, ImageSettings, QUALITIES, Settings}; +use pdfcraft_engine::optimizer::{OptimizeStage, Optimized}; +use pdfcraft_engine::{DocId, EditError, Hidden, OptimizeReport}; use crate::theme::{self, Tokens}; use crate::{PdfCraftApp, widgets}; @@ -32,6 +39,73 @@ impl Default for OptimizeDraft { } } +/// Progress of a background optimization: the stage, the result once finished, and a cancel +/// request. +#[derive(Default)] +pub struct OptimizeProgress { + pub stage: OptimizeStage, + pub result: Option>, + pub cancel: bool, +} + +/// Which command started an optimization: they save under different names, and only the +/// Optimizer's notice lists what it did. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum OptimizeKind { + /// Reduce File Size (Acrobat's defaults). + Reduce, + /// Optimize PDF ▸ Advanced optimization (the dialog's choices). + Advanced, +} + +impl OptimizeKind { + /// The suffix of the saved copy's name ("notes (reduced).pdf"). + fn suffix(self) -> &'static str { + match self { + OptimizeKind::Reduce => "reduced", + OptimizeKind::Advanced => "optimized", + } + } +} + +pub struct OptimizeRun { + pub doc: DocId, + pub kind: OptimizeKind, + pub progress: Arc>, +} + +/// The notice's text for a stage. +fn stage_label(stage: OptimizeStage) -> String { + match stage { + OptimizeStage::Discarding => tl!("Optimizing… removing user data").to_string(), + OptimizeStage::Images { done, total } => crate::i18n::fmt( + tl!("Optimizing… image {d} of {t}"), + &[("d", &(done + 1).min(total.max(1)).to_string()), ("t", &total.max(1).to_string())], + ), + OptimizeStage::CleaningUp => tl!("Optimizing… cleaning up").to_string(), + OptimizeStage::Merging => tl!("Optimizing… merging identical objects").to_string(), + OptimizeStage::Writing => tl!("Optimizing… writing the copy").to_string(), + } +} + +/// What the saved notice adds after the size: images optimized and items discarded. +fn summary(r: &OptimizeReport) -> String { + let o = &r.optimize; + let mut parts = Vec::new(); + if o.images_resampled + o.images_recompressed > 0 { + parts.push(format!( + "{} image{} optimized", + o.images_resampled + o.images_recompressed, + if o.images_resampled + o.images_recompressed == 1 { "" } else { "s" } + )); + } + let discarded: usize = r.discarded.iter().map(|(_, n)| n).sum(); + if discarded > 0 { + parts.push(format!("{discarded} item{} discarded", if discarded == 1 { "" } else { "s" })); + } + if parts.is_empty() { String::new() } else { format!("; {}", parts.join(", ")) } +} + fn image_row(ui: &mut egui::Ui, id: &str, title: &str, s: &mut ImageSettings) { ui.label(egui::RichText::new(tl!(title)).font(theme::semibold(13.0))); ui.horizontal(|ui| { @@ -188,30 +262,153 @@ pub(crate) fn audit_body(ui: &mut egui::Ui, rows: &[pdfcraft_engine::optimize::S } impl PdfCraftApp { - /// Optimize PDF with the dialog's choices and save the copy. + /// Optimize PDF with the dialog's choices on a worker thread; the copy is saved when done. pub fn optimize_with_draft(&mut self) { + let (settings, discard) = (self.optimize_draft.settings.clone(), self.optimize_draft.discard.clone()); + self.start_optimize(OptimizeKind::Advanced, &settings, &discard); + } + + /// Run an optimization of the active document on a worker thread (inline in tests and on + /// the web), showing its progress; the copy is saved when it is done. + pub(crate) fn start_optimize(&mut self, kind: OptimizeKind, settings: &Settings, discard: &[Hidden]) { // What's typed in a form field is part of the document (#166). if !self.commit_form_typing() { return; } let Some((_, id)) = self.active_ids() else { return }; - let d = self.optimize_draft.clone(); - let result = self.session.optimized_bytes(id, &d.settings, &d.discard).map(|(b, r)| { - let o = &r.optimize; - let mut parts = Vec::new(); - if o.images_resampled + o.images_recompressed > 0 { - parts.push(format!( - "{} image{} optimized", - o.images_resampled + o.images_recompressed, - if o.images_resampled + o.images_recompressed == 1 { "" } else { "s" } - )); + if self.optimize_run.is_some() { + self.notify_tr("Optimization is already running"); + return; + } + let job = match self.session.optimize_job(id, settings, discard) { + Ok(job) => job, + Err(e) => return self.save_optimized(id, kind.suffix(), Err(e)), + }; + let progress = Arc::new(Mutex::new(OptimizeProgress::default())); + let p = progress.clone(); + let work = move || { + let result = job.run(|stage| { + let Ok(mut s) = p.lock() else { return false }; + s.stage = stage; + !s.cancel + }); + if let Ok(mut s) = p.lock() { + s.result = Some(result); } - let discarded: usize = r.discarded.iter().map(|(_, n)| n).sum(); - if discarded > 0 { - parts.push(format!("{discarded} item{} discarded", if discarded == 1 { "" } else { "s" })); + }; + #[cfg(not(target_arch = "wasm32"))] + if self.run_inline { + work(); + } else if let Err(e) = std::thread::Builder::new().name("pdfcraft-optimize".into()).spawn(work) { + // No worker: report the failure instead of waiting for a result that never comes. + if let Ok(mut s) = progress.lock() { + s.result = Some(Err(EditError::Optimize(e.to_string()))); } - (b, if parts.is_empty() { String::new() } else { format!("; {}", parts.join(", ")) }) + } + #[cfg(target_arch = "wasm32")] + work(); + self.optimize_run = Some(OptimizeRun { doc: id, kind, progress }); + self.poll_optimize(); + } + + /// Stop a running optimization (nothing is saved). + pub fn cancel_optimize(&mut self) { + if let Some(r) = &self.optimize_run + && let Ok(mut s) = r.progress.lock() + { + s.cancel = true; + } + } + + /// Show progress; save the copy once the worker is done. + pub(crate) fn poll_optimize(&mut self) { + let Some(run) = self.optimize_run.as_ref() else { return }; + let (doc, kind, progress) = (run.doc, run.kind, run.progress.clone()); + let Ok(mut s) = progress.lock() else { + // The worker panicked while holding the lock: it will never report back. + self.optimize_run = None; + self.progress_notice = None; + self.notify_tr("Couldn't optimize the file"); + return; + }; + let Some(result) = s.result.take() else { + let cancelling = s.cancel; + let stage = s.stage; + drop(s); + let label = if cancelling { tl!("Cancelling…").to_string() } else { stage_label(stage) }; + self.progress_notice = Some(crate::widgets::ProgressNotice { label, fraction: stage.fraction(), cancellable: !cancelling }); + if let Some(ctx) = &self.ctx { + ctx.request_repaint_after(std::time::Duration::from_millis(50)); + } + return; + }; + drop(s); + self.optimize_run = None; + self.progress_notice = None; + match result { + Err(EditError::Cancelled) => self.notify_tr("Optimization cancelled"), + result => { + let result = result.map(|(b, r)| { + let detail = if kind == OptimizeKind::Advanced { summary(&r) } else { String::new() }; + (b, detail) + }); + self.save_optimized(doc, kind.suffix(), result); + } + } + } +} + +#[cfg(test)] +mod tests { + use egui_kittest::Harness; + use egui_kittest::kittest::Queryable; + + use super::*; + + #[test] + fn the_progress_card_shows_the_stage_and_cancels() { + let progress = Arc::new(Mutex::new(OptimizeProgress { stage: OptimizeStage::Images { done: 3, total: 10 }, ..Default::default() })); + let p = progress.clone(); + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("notes.txt", None, b"hello".to_vec()).unwrap(); + let (_, doc) = app.active_ids().unwrap(); + app.optimize_run = Some(OptimizeRun { doc, kind: OptimizeKind::Advanced, progress: p }); + app.notify("Saved notes.pdf"); + app }); - self.save_optimized(id, "optimized", result); + // 1.5 s (kittest steps a quarter second): the bar has eased in, the toast still shows. + h.run_steps(6); + if let Ok(path) = std::env::var("PDFCRAFT_OPTIMIZE_PROGRESS_SHOT") { + h.render().unwrap().save(path).unwrap(); + } + h.get_by_label("Optimizing… image 4 of 10"); + let shown = h.state().progress_notice.clone().unwrap(); + assert!((shown.fraction - 0.275).abs() < 1e-4, "{shown:?}"); + assert!(shown.cancellable); + + h.get_by_label("Cancel").click(); + h.run_steps(2); + assert!(progress.lock().unwrap().cancel, "the worker is asked to stop"); + h.get_by_label("Cancelling…"); + assert!(h.query_by_label("Cancel").is_none(), "Cancel is not offered twice"); + + // The worker stops and reports back. + progress.lock().unwrap().result = Some(Err(EditError::Cancelled)); + h.run_steps(2); + assert!(h.state().optimize_run.is_none() && h.state().progress_notice.is_none()); + assert_eq!(h.state().toast.as_ref().map(|t| t.0.as_str()), Some("Optimization cancelled")); + } + + #[test] + fn a_second_run_is_refused_while_one_is_going() { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("notes.txt", None, b"hello".to_vec()).unwrap(); + let (_, doc) = app.active_ids().unwrap(); + app.optimize_run = Some(OptimizeRun { doc, kind: OptimizeKind::Reduce, progress: Arc::default() }); + app.optimize_with_draft(); + assert_eq!(app.toast.as_ref().map(|t| t.0.as_str()), Some("Optimization is already running")); } } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 9f5d41f2a..6278a010d 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -92,10 +92,13 @@ pub fn toast(app: &mut PdfCraftApp, ctx: &egui::Context) { } let t = Tokens::get(ctx); let screen = ctx.content_rect(); + // Above the progress notice while a job runs. + let card = if app.progress_notice.is_some() { ctx.data(|d| d.get_temp::(egui::Id::new(PROGRESS_HEIGHT))).unwrap_or(0.0) } else { 0.0 }; + let lift = if card > 0.0 { 28.0 + card + 10.0 } else { 28.0 }; egui::Area::new(egui::Id::new("toast")) .order(egui::Order::Tooltip) .pivot(Align2::CENTER_BOTTOM) - .fixed_pos(screen.center_bottom() - vec2(0.0, 28.0)) + .fixed_pos(screen.center_bottom() - vec2(0.0, lift)) .show(ctx, |ui| { egui::Frame::NONE .fill(if t.dark() { Color32::from_rgb(0xEC, 0xEC, 0xEF) } else { Color32::from_rgb(0x2A, 0x2A, 0x2F) }) @@ -108,6 +111,87 @@ pub fn toast(app: &mut PdfCraftApp, ctx: &egui::Context) { ctx.request_repaint_after(std::time::Duration::from_millis(100)); } +/// A background job's progress, shown at the bottom centre until the job is done. +#[derive(Clone, Debug, PartialEq)] +pub struct ProgressNotice { + /// What is happening ("Optimizing… image 3 of 40"). + pub label: String, + /// From 0 to 1. + pub fraction: f32, + /// Show a Cancel button. + pub cancellable: bool, +} + +/// Where the progress notice keeps its height this frame, for placing the toast above it. +const PROGRESS_HEIGHT: &str = "progress-notice-height"; + +/// Draw [`PdfCraftApp::progress_notice`]: the label, a bar that eases towards the fraction with +/// a light sweeping across it, the percentage and Cancel. Returns true when Cancel was clicked. +pub fn progress_notice(app: &PdfCraftApp, ctx: &egui::Context) -> bool { + let Some(p) = &app.progress_notice else { return false }; + let t = Tokens::get(ctx); + let screen = ctx.content_rect(); + // The same inverted card as the toast. + let (fill, text) = if t.dark() { + (Color32::from_rgb(0xEC, 0xEC, 0xEF), Color32::from_rgb(0x22, 0x22, 0x26)) + } else { + (Color32::from_rgb(0x2A, 0x2A, 0x2F), Color32::WHITE) + }; + let fraction = if p.fraction.is_finite() { p.fraction.clamp(0.0, 1.0) } else { 0.0 }; + let shown = ctx.animate_value_with_time(egui::Id::new("progress-notice-bar"), fraction, 0.3); + let now = ctx.input(|i| i.time); + let mut cancel = false; + let area = egui::Area::new(egui::Id::new("progress-notice")) + .order(egui::Order::Tooltip) + .pivot(Align2::CENTER_BOTTOM) + .fixed_pos(screen.center_bottom() - vec2(0.0, 28.0)) + .show(ctx, |ui| { + egui::Frame::NONE.fill(fill).corner_radius(CornerRadius::same(10)).inner_margin(egui::Margin::symmetric(16, 12)).show(ui, |ui| { + let width = 340.0_f32.min(screen.width() - 64.0).max(160.0); + ui.set_width(width); + ui.horizontal(|ui| { + ui.label(egui::RichText::new(&p.label).color(text)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label(egui::RichText::new(format!("{:.0}%", shown * 100.0)).font(theme::medium(12.0)).color(text.gamma_multiply(0.7))); + }); + }); + ui.add_space(6.0); + let (track, resp) = ui.allocate_exact_size(vec2(width, 6.0), Sense::hover()); + // The label above names the job; the bar carries the percentage. + resp.widget_info(|| { + let mut info = egui::WidgetInfo::new(egui::WidgetType::ProgressIndicator); + info.value = Some(f64::from(fraction) * 100.0); + info + }); + let painter = ui.painter(); + painter.rect_filled(track, CornerRadius::same(3), text.gamma_multiply(0.18)); + let done = Rect::from_min_size(track.min, vec2(track.width() * shown, track.height())); + if done.width() > 0.5 { + painter.rect_filled(done, CornerRadius::same(3), t.accent); + // A soft light sweeping along the filled part says the job is alive between + // updates. + let band = 48.0; + let x = done.left() - band + ((now * 160.0) % f64::from(done.width() + band)) as f32; + let clip = painter.with_clip_rect(done); + for (i, a) in [0.10_f32, 0.22, 0.10].into_iter().enumerate() { + let r = Rect::from_min_size(egui::pos2(x + i as f32 * band / 3.0, done.top()), vec2(band / 3.0, done.height())); + clip.rect_filled(r, CornerRadius::ZERO, Color32::WHITE.gamma_multiply(a)); + } + } + if p.cancellable { + ui.add_space(4.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let b = egui::Button::new(egui::RichText::new(tl!("Cancel")).font(theme::medium(12.5)).color(text)).frame(false); + cancel = ui.add(b).clicked(); + }); + } + }); + }); + ctx.data_mut(|d| d.insert_temp(egui::Id::new(PROGRESS_HEIGHT), area.response.rect.height())); + ctx.request_repaint_after(std::time::Duration::from_millis(33)); + cancel +} + /// The ArtCraft wordmark (Storyteller's brand, docs/brand/; not open source), sized to `height`. pub fn artcraft_logo(ui: &mut egui::Ui, height: f32) -> Response { let dark = ui.visuals().dark_mode; diff --git a/crates/ui-egui/tests/create.rs b/crates/ui-egui/tests/create.rs index 85d668548..9f5390bdd 100644 --- a/crates/ui-egui/tests/create.rs +++ b/crates/ui-egui/tests/create.rs @@ -70,16 +70,30 @@ fn opening_images_and_text_converts_them_to_new_pdfs() { #[test] fn reduce_file_size_writes_a_compact_copy() { + use egui_kittest::Harness; let dir = std::env::temp_dir().join(format!("pdfcraft-reduce-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let out = dir.join("reduced.pdf"); - let mut app = PdfCraftApp::new(); - app.open_bytes("notes.txt", None, "lorem ipsum ".repeat(500).into_bytes()).unwrap(); - app.save_override = Some(out.to_string_lossy().into_owned()); - assert!(app.execute("optimize.reduce")); + let out2 = out.clone(); + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.open_bytes("notes.txt", None, "lorem ipsum ".repeat(500).into_bytes()).unwrap(); + app.save_override = Some(out2.to_string_lossy().into_owned()); + app + }); + h.run_steps(2); + assert!(h.state_mut().execute("optimize.reduce")); + // It runs on a worker thread like the PDF Optimizer, with the progress card meanwhile. + let start = std::time::Instant::now(); + while (h.state().optimize_run.is_some() || !out.exists()) && start.elapsed() < std::time::Duration::from_secs(30) { + h.run_steps(1); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + h.run_steps(1); + assert!(h.state().optimize_run.is_none() && h.state().progress_notice.is_none(), "the run finished"); let bytes = std::fs::read(&out).unwrap(); assert!(bytes.starts_with(b"%PDF-")); - assert!(app.session.docs()[0].dirty, "the open document is unchanged"); + assert!(h.state().session.docs()[0].dirty, "the open document is unchanged"); } #[test] @@ -128,7 +142,16 @@ fn the_pdf_optimizer_dialog_saves_an_optimized_copy() { assert!(d.settings.discard_tags && d.discard == vec![pdfcraft_engine::Hidden::Metadata]); } h.get_by_label("OK").click(); - h.run_steps(3); + // The click is handled next frame. The optimization then runs on a worker thread and the + // copy is saved when it is done. + h.run_steps(1); + let start = std::time::Instant::now(); + while (h.state().optimize_run.is_some() || !out.exists()) && start.elapsed() < std::time::Duration::from_secs(30) { + h.run_steps(1); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + h.run_steps(2); + assert!(h.state().progress_notice.is_none(), "the progress card is gone"); assert!(std::fs::read(&out).unwrap().starts_with(b"%PDF-")); assert_eq!(h.state().dialog, None); } diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 22b37a2a3..5528a4ea3 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7107,7 +7107,7 @@ status = "shipped" commands = ["optimize.reduce"] tools = ["doc_reduce"] evidence = ["crates/ui-egui/tests/create.rs::reduce_file_size_writes_a_compact_copy", "crates/engine/src/tests.rs::create_and_reduce", "crates/automation/tests/automation.rs::creating_and_reducing_through_tools", "crates/automation/tests/automation.rs::optimizing_through_tools"] -notes = "images above 225 ppi to 150 ppi JPEG medium, thumbnails dropped, resources merged, object streams" +notes = "images above 225 ppi to 150 ppi JPEG medium, thumbnails dropped, resources merged, object streams; runs in the background with a progress bar and Cancel" [[feature]] id = "optimize.compatibility-level" @@ -7130,8 +7130,8 @@ milestone = "M11" status = "partial" commands = ["optimize.advanced"] tools = ["doc_optimize"] -evidence = ["crates/ui-egui/tests/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy", "crates/automation/tests/automation.rs::optimizing_through_tools"] -notes = "Images, Discard Objects, Discard User Data, Clean Up; no Fonts or Transparency panels, no space audit" +evidence = ["crates/ui-egui/tests/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy", "crates/automation/tests/automation.rs::optimizing_through_tools", "crates/ui-egui/src/optimize_ui.rs::the_progress_card_shows_the_stage_and_cancels", "crates/engine/src/tests.rs::an_optimize_job_reports_its_stages_and_can_be_cancelled"] +notes = "Images, Discard Objects, Discard User Data, Clean Up; runs in the background with a progress bar (per image) and Cancel; no Fonts or Transparency panels, no space audit" [[feature]] id = "optimize.downsample-images"