M11: Optimize PDF and Reduce File Size run in the background with a progress bar (#370)

* M11: Optimize PDF and Reduce File Size run in the background with a progress bar

Advanced optimization and Reduce File Size did all their work on the UI
thread, so on a large file the window stopped repainting until they were
done and looked as if it had crashed.

- pdfcraft-optimize: optimize_with_progress reports each image and the
  clean-up; returning false stops with OptimizeError::Cancelled.
- engine: Session::optimize_job captures a copy of the document and the
  choices; OptimizeJob::run reports each OptimizeStage (removing user data,
  images, cleaning up, merging, writing), stops on request with
  EditError::Cancelled and turns a panic into an error so a worker always
  reports back. optimized_bytes is the job run in place.
- ui: both commands run the job on a worker thread (inline in tests and on
  the web), like OCR and Export. A card at the bottom centre shows the
  stage ("Optimizing... image 14 of 40"), a bar that eases towards the
  fraction with a light sweeping across it, the percentage and Cancel;
  notices stack above it. A second run is refused while one is going. The
  control channel's state includes the progress.

The headless doc_optimize and doc_reduce tools stay synchronous. New
strings are translated for es, fr, ja, zh-hans, zh-hant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Clippy: as_chunks in the signature image background test (follow-up to #355)

#360 switched the image test to as_chunks; #355 added another chunks_exact(4)
in the background test, which clippy's chunks_exact_to_as_chunks rejects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Optimize: report a failed worker spawn instead of waiting forever

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
mlkonrad
2026-10-08 18:46:34 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 Brandon Thomas
parent 23bac79756
commit 2a35a1a76f
22 changed files with 635 additions and 61 deletions
+6 -6
View File
@@ -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"
+6 -14
View File
@@ -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<Vec<u8>>, 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<pdfcraft_cos::ObjRef> = 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
+104
View File
@@ -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<Vec<u8>>, OptimizeReport);
/// What an optimization needs, independent of the session.
pub struct OptimizeJob {
cos: pdfcraft_cos::Document,
settings: optimize::Settings,
discard: Vec<Hidden>,
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<OptimizeJob, 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()))?;
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<Optimized, EditError> {
guard(move || self.work(&mut progress)).map_err(EditError::Optimize)?
}
fn work(self, progress: &mut dyn FnMut(OptimizeStage) -> bool) -> Result<Optimized, EditError> {
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<pdfcraft_cos::ObjRef> = 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 }))
}
}
+1 -1
View File
@@ -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"));
+33
View File
@@ -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<f32> = 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);
+5 -1
View File
@@ -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.
+16 -3
View File
@@ -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<ObjRef> = 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(())
}
+21 -1
View File
@@ -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<Report, OptimizeError> {
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<Report, OptimizeError> {
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")) {
+39
View File
@@ -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");
}
+1
View File
@@ -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(),
})
+2 -8
View File
@@ -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
+9
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 60.
+9
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 39.
+9
View File
@@ -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 語を認識しました
Can't render this file because it contains an unexpected character in line 6 and column 47.
+9
View File
@@ -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 పదం గుర్తించబడింది
Can't render this file because it contains an unexpected character in line 13 and column 16.
+9
View File
@@ -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 个单词
Can't render this file because it contains an unexpected character in line 9 and column 16.
+9
View File
@@ -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 個單詞
Can't render this file because it contains an unexpected character in line 9 and column 16.
+16
View File
@@ -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<optimize_ui::OptimizeRun>,
/// A background job's progress card (see [`widgets::progress_notice`]).
pub progress_notice: Option<widgets::ProgressNotice>,
/// Pages copied or cut in Organize Pages, ready to paste (into any document).
pub page_clipboard: Option<PageClip>,
/// 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);
}
}
+214 -17
View File
@@ -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<Result<Optimized, EditError>>,
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<Mutex<OptimizeProgress>>,
}
/// 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"));
}
}
+85 -1
View File
@@ -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::<f32>(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;
+29 -6
View File
@@ -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);
}
+3 -3
View File
@@ -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"