mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 10:08:00 -06:00
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:
co-authored by
Claude Opus 5.5
Brandon Thomas
parent
23bac79756
commit
2a35a1a76f
+6
-6
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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")) {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user