From 4fd2c2b3a36741a226055af6250af802a5c0f56e Mon Sep 17 00:00:00 2001 From: taylorandrew07-lab Date: Sat, 10 Oct 2026 16:20:50 -0400 Subject: [PATCH] M11: Reduce file size shrinks office scans, and never saves a bigger copy (refs #490) (#849) Office scanners write the default decode array (/Decode [0 1 0 1 0 1]) on their page images. Reduce skipped every image with a decode array, so a scan stayed the same size. The default array is now treated as absent; any other array still skips the image. Reduce also saved its copy even when it wasn't smaller, reported as "-N% smaller". It now saves nothing and says the file is already as small as it can be made; doc_reduce returns "written": false. Optimize PDF still saves (what it discards may matter more than the size) but says "not smaller". Co-authored-by: Andrew Taylor Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ATTRIBUTION.toml | 30 ++++++++-------- crates/automation/src/lib.rs | 8 +++-- crates/automation/src/tools.rs | 2 +- crates/automation/tests/automation.rs | 7 +++- crates/optimize/src/images.rs | 17 +++++++-- crates/optimize/src/lib.rs | 4 +-- crates/optimize/tests/optimize.rs | 33 +++++++++++++++++ crates/ui-egui/src/create_ui.rs | 38 ++++++++++++-------- crates/ui-egui/src/i18n/ar.tsv | 2 ++ crates/ui-egui/src/i18n/bg.tsv | 2 ++ crates/ui-egui/src/i18n/cs.tsv | 2 ++ crates/ui-egui/src/i18n/de.tsv | 2 ++ crates/ui-egui/src/i18n/es.tsv | 2 ++ crates/ui-egui/src/i18n/fr.tsv | 2 ++ crates/ui-egui/src/i18n/hu.tsv | 2 ++ crates/ui-egui/src/i18n/it.tsv | 2 ++ crates/ui-egui/src/i18n/ja.tsv | 2 ++ crates/ui-egui/src/i18n/pt-br.tsv | 2 ++ crates/ui-egui/src/i18n/ru.tsv | 2 ++ crates/ui-egui/src/i18n/te.tsv | 2 ++ crates/ui-egui/src/i18n/uk.tsv | 2 ++ crates/ui-egui/src/i18n/zh-hans.tsv | 2 ++ crates/ui-egui/src/i18n/zh-hant.tsv | 2 ++ crates/ui-egui/src/optimize_ui.rs | 6 ++-- crates/ui-egui/tests/create.rs | 52 +++++++++++++++++++++------ parity/acrobat-features.toml | 4 +-- 26 files changed, 178 insertions(+), 53 deletions(-) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 0aeb9adb3f..fdb2c4aa98 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -19,7 +19,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "2692f1dfc21b9d7284ba69316085fb9a2f30dcb6d6f336401d7b9aa4b3fcf67a" +sha256 = "d79b409519f49bc8d257eda7abb3381a0fc62fec31c88712c95c53c95d756162" [[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 = "242e4c748a66ee6ff18d657ee3e1ef0808be0d5adc005e45723f93c835d142f8" +sha256 = "cefe9cffb13e9babbbfbcc881bf3f24ee65b7ed013d94405b04bcf57349b3371" [[asset]] path = "crates/ui-egui/src/i18n/cs.tsv" @@ -41,7 +41,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "c6c0dec458b50a15fd5a7b38ce9ec5772e38231c5db584ff9aecceb4955ce132" +sha256 = "2d3a92f0f131fa5ece645fa0a6f3299e4d2912e5776b3951caad996254b9549b" [[asset]] path = "crates/ui-egui/src/i18n/pt-br.tsv" @@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "ef28211bc332cc811a786952e4b60ba7dafb8e9a8553d75e11c4f800ad71832e" +sha256 = "3de8647fc74dc4a11979836ec0bcaeb2299965ce6779c118d0df3fbbb9e1fb05" [[asset]] path = "crates/ui-egui/src/i18n/es.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 = "ca6d842ff9568adc5f8d8223f7791f075faf94845369bc2dfc8e52fe727f8b23" +sha256 = "83d2a772ac1d08d506b4e6e19c2ebc07860614710a058d69923d5bff7e1355c5" [[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 = "7c791c69d517bcb16c487217861428b7f6a53b96e3c496e2cc6a8710e7c18365" +sha256 = "32a092a72ebc03f4bc9f849091421ba306a0ee06531249c104471f51420450e9" [[asset]] path = "crates/ui-egui/src/i18n/de.tsv" @@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "9030aef3c2790bb4dd1dda022c0d527fdce73a29bd4d1875edcbbaa95a3bf49e" +sha256 = "b598b6564cb9851cd97ccadc064071f02c3061d4f5a297baacdf3c7ef41dfd89" [[asset]] path = "crates/ui-egui/src/i18n/te.tsv" @@ -96,7 +96,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 = "bd15f74a2630e97216f56ff329e8e8d7cea2eae341f875dd7804908ad13e20f8" +sha256 = "531223a0da0367941ed6fde83cf4dbce173a978793218af372add0484d3f0428" [[asset]] path = "crates/ui-egui/src/i18n/it.tsv" @@ -107,7 +107,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "da28f8082b39ee9cff48217f69b920edde7107d140d18d212d1d1f110c5178df" +sha256 = "48ecc081b27cfa6be06af619785123cba7a786a1290d6aeb794f693c902d8246" [[asset]] path = "crates/ui-egui/src/i18n/ru.tsv" @@ -118,7 +118,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "cbb0332f3e85a5267fc0af033aa77430a35e83171c3e2e5dfe02bf103e4fc0ce" +sha256 = "1b7bc6dc28b20c10840af092f5a52e45f8fbb3740ddd907185b89a91b5d8a5ab" [[asset]] path = "crates/ui-egui/src/i18n/hu.tsv" @@ -129,7 +129,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "b4b1cef29030b23e8a3b10dc743a4a6394c0353aaddfc783bde79f6cfe8d81c5" +sha256 = "b6800b09844ec71ad26cc38670a8fb94873690f3dc9585a47352f334f7993906" [[asset]] path = "crates/ui-egui/src/i18n/bg.tsv" @@ -140,7 +140,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "efba2c1d7652d730c716eded3972982b5f6c1f797be48f4232a0d2efb7b19451" +sha256 = "e15e166aafc0c0e0a939b578c25a7a4f3808932aeed8eddbfe9bcc06e0362f3b" [[asset]] path = "crates/ui-egui/src/i18n/ja.tsv" @@ -151,7 +151,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 = "d6043524e622f7e00f272566f779530ff3dc5f4e5be93f442be79172df7d2f5f" +sha256 = "519d505722abc4e778a7ca3ff0aacc808321771d4e0b5629c7bf0b39e2dd8759" [[asset]] path = "crates/ui-egui/src/i18n/uk.tsv" @@ -162,7 +162,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "86512da6fab7b0860336437398b7ff57e03cee9db78e13dfd28b8aa8fee957a2" +sha256 = "5a7a4592c205347621e002d6eaffbd8b021db54252c9cba2759d79763901af5c" [[asset]] path = "crates/ui-egui/src/i18n/ar.tsv" @@ -173,7 +173,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "0dbeebeebfa940260e4355aa2741ba68a3aacba7a3d24d721981035fa0bef254" +sha256 = "e593813504da5443bd89d764c383872c997910ba63581b8bca7dc3879fe7b0fc" [[asset]] path = "assets/icons/accessibility.svg" diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 511904b3ea..d15652236d 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -303,8 +303,12 @@ impl Automation { let before = self.doc(&a)?.bytes.len(); let path = self.resolve(a.str("path")?, true)?; let (bytes, merged) = self.session.reduced_bytes(id).map_err(failed)?; - write_atomic(&path, &bytes)?; - json!({ "path": path.to_string_lossy(), "bytes_before": before, "bytes_after": bytes.len(), "merged_objects": merged }) + // As in the app, a copy that isn't smaller is not written (#490). + let written = bytes.len() < before; + if written { + write_atomic(&path, &bytes)?; + } + json!({ "path": path.to_string_lossy(), "written": written, "bytes_before": before, "bytes_after": bytes.len(), "merged_objects": merged }) } "doc_optimize" => self.doc_optimize(&a)?, "doc_initial_view" => self.doc_initial_view(&a)?, diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 6a89e742fe..f7a4d5bcaf 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -971,7 +971,7 @@ pub fn tools() -> Vec { }), &["from"], )), - t("doc_reduce", "Reduce file size", "Write a smaller copy of the document to `path` with Acrobat's Reduce File Size choices: images above 225 ppi downsampled to 150 ppi and JPEG-compressed (medium quality), thumbnails dropped, identical fonts and images merged, unused objects dropped, compressed object streams. The open document is unchanged.") + t("doc_reduce", "Reduce file size", "Write a smaller copy of the document to `path` with Acrobat's Reduce File Size choices: images above 225 ppi downsampled to 150 ppi and JPEG-compressed (medium quality), thumbnails dropped, identical fonts and images merged, unused objects dropped, compressed object streams. If the copy wouldn't be smaller, nothing is written and `written` is false. The open document is unchanged.") .cmd("optimize.reduce") .with(schema(json!({ "doc": doc(), "path": { "type": "string" } }), &["doc", "path"])), t("doc_initial_view", "Initial view", "Read or change how the document opens (Document Properties ▸ Initial View) and its reading options: navigation (page, bookmarks, pages, attachments, layers), layout (default, single, continuous, two_up, two_up_continuous, two_up_cover, two_up_continuous_cover), magnification (default, actual, fit_page, fit_width, fit_height, fit_visible, or a percentage), page, window options (fit_window, center_window, full_screen, display_title), interface options (hide_menubar, hide_toolbar, hide_window_ui), language and binding (left, right). Only the given ones change; returns the result. Undoable.").with(schema( diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 40e894ff0b..7a201c9403 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -1791,8 +1791,11 @@ fn creating_and_reducing_through_tools() { let t = ok(&mut a, "doc_create", json!({ "from": "text", "path": "notes.txt" }))["doc"].as_u64().unwrap(); assert_eq!(page_text(&mut a, t), ["Meeting notes\nAction items"]); ok(&mut a, "doc_save", json!({ "doc": t, "path": "notes.pdf" })); + // A new text PDF is already compact: Reduce writes nothing rather than a copy no smaller. let r = ok(&mut a, "doc_reduce", json!({ "doc": t, "path": "notes-small.pdf" })); - assert!(r["bytes_after"].as_u64().unwrap() > 0 && dir.join("notes-small.pdf").exists()); + assert!(r["bytes_after"].as_u64().unwrap() >= r["bytes_before"].as_u64().unwrap(), "{r}"); + assert_eq!(r["written"], false); + assert!(!dir.join("notes-small.pdf").exists()); assert!(matches!(a.call("doc_create", &json!({ "from": "images", "paths": ["notes.txt"] })), Err(ToolError::Failed(_)))); } @@ -2824,6 +2827,8 @@ fn optimizing_through_tools() { assert_eq!(small["pages"], 1); let reduced = ok(&mut a, "doc_reduce", json!({ "doc": doc, "path": "reduced.pdf" })); assert!(reduced["bytes_after"].as_u64().unwrap() < reduced["bytes_before"].as_u64().unwrap()); + assert_eq!(reduced["written"], true); + assert_eq!(std::fs::metadata(dir.join("reduced.pdf")).unwrap().len(), reduced["bytes_after"].as_u64().unwrap()); assert!(matches!( a.call("doc_optimize", &json!({ "doc": doc, "path": "x.pdf", "color": { "compression": "gif" } })), Err(ToolError::InvalidArgs(_)) diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index 4f079e14be..8645c82bf3 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -208,10 +208,19 @@ fn jpeg(px: &[u8], n: usize, w: u32, h: u32, quality: u8) -> Option> { Some(out) } +/// Whether `decode` is the default decode array for `n` components ([0 1] each), which changes +/// nothing. Scanners write it on page images (#490). +fn is_identity_decode(doc: &Document, decode: &Object, n: usize) -> bool { + let decode = doc.resolve(decode); + let Some(a) = decode.as_array() else { return false }; + a.len() == 2 * n && a.chunks(2).all(|p| matches!(p, [lo, hi] if doc.resolve(lo).as_f64() == Some(0.0) && doc.resolve(hi).as_f64() == Some(1.0))) +} + /// A new image stream from 8-bit samples: the original dictionary with the new size and filter. +/// A decode array is dropped: only images without one, or with the default one, get here. fn image_stream(old: &Dict, px: &[u8], n: usize, w: u32, h: u32, compression: Compression, was_dct: bool) -> Option { let mut d = old.clone(); - for k in [&b"Filter"[..], b"DecodeParms", b"Length", b"DL"] { + for k in [&b"Filter"[..], b"DecodeParms", b"Decode", b"Length", b"DL"] { d.remove(k); } d.set(b"Width".to_vec(), Object::Int(w as i64)); @@ -239,7 +248,11 @@ fn image_stream(old: &Dict, px: &[u8], n: usize, w: u32, h: u32, compression: Co /// Returns the new image and its new soft mask, if the result is smaller. fn process(doc: &Document, s: &Stream, ppi: f64, settings: &ImageSettings, n: usize) -> Option<(Stream, Option<(ObjRef, Stream)>)> { let d = &s.dict; - if d.contains(b"Decode") || d.contains(b"Mask") || d.name(b"ImageMask").is_some() || matches!(d.get(b"ImageMask"), Some(Object::Bool(true))) { + if d.get(b"Decode").is_some_and(|a| !is_identity_decode(doc, a, n)) + || d.contains(b"Mask") + || d.name(b"ImageMask").is_some() + || matches!(d.get(b"ImageMask"), Some(Object::Bool(true))) + { return None; } if d.int(b"BitsPerComponent") != Some(8) { diff --git a/crates/optimize/src/lib.rs b/crates/optimize/src/lib.rs index 7f241b6808..1d384ff516 100644 --- a/crates/optimize/src/lib.rs +++ b/crates/optimize/src/lib.rs @@ -5,8 +5,8 @@ //! smallest over its uses, through form XObjects); colour and grayscale images above a //! threshold are resampled (bicubic) to a target resolution and recompressed as JPEG or Flate. //! A new image replaces the old one only if it is smaller. Images PdfCraft can't decode -//! faithfully (CMYK and other colour spaces, masks, decode arrays, JPEG 2000, JBIG2, CCITT, -//! more than 8 bits) are left alone. +//! faithfully (CMYK and other colour spaces, masks, decode arrays other than the default, +//! JPEG 2000, JBIG2, CCITT, other than 8 bits) are left alone. //! - **Discard objects:** page thumbnails, alternate images, document tags (structure tree), //! print settings. //! - **Clean up:** Flate-compress streams that have no filter. diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index 9e698756a1..007e859f5c 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -131,6 +131,39 @@ fn images_are_measured_where_drawn_and_downsampled() { assert_ne!(&out.rgba[i..i + 3], &[255, 255, 255]); } +#[test] +fn an_identity_decode_array_does_not_stop_reduce() { + // Office scanners write /Decode [0 1 0 1 0 1] (no change) on their page images (#490). + let with_decode = |doc: &mut Document, r: ObjRef, decode: Vec| { + let mut s = stream(doc, r); + s.dict.set(b"Decode".to_vec(), Object::Array(decode)); + doc.set(r, Object::Stream(s)); + }; + let mut doc = Document::new_empty(); + // 900 px drawn 216 pt (3 in) wide → 300 ppi, like a scanned page. + let scan = image(&mut doc, 900, 900, 3, true, None); + with_decode(&mut doc, scan, [0, 1, 0, 1, 0, 1].map(Object::Int).to_vec()); + let gray = image(&mut doc, 900, 900, 1, false, None); + with_decode(&mut doc, gray, vec![Object::Real(0.0), Object::Real(1.0)]); + // A decode array that inverts the colours, and one of the wrong length: left alone. + let inverted = image(&mut doc, 900, 900, 3, true, None); + with_decode(&mut doc, inverted, [1, 0, 1, 0, 1, 0].map(Object::Int).to_vec()); + let odd = image(&mut doc, 900, 900, 3, true, None); + with_decode(&mut doc, odd, [0, 1].map(Object::Int).to_vec()); + page( + &mut doc, + &[("S", scan), ("G", gray), ("I", inverted), ("O", odd)], + "q 216 0 0 216 0 0 cm /S Do Q q 216 0 0 216 216 0 cm /G Do Q q 216 0 0 216 0 300 cm /I Do Q q 216 0 0 216 216 300 cm /O Do Q", + ); + let report = optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!(report.images_resampled, 2, "{report:?}"); + assert_eq!(stream(&doc, scan).dict.int(b"Width"), Some(450)); + assert_eq!(stream(&doc, gray).dict.int(b"Width"), Some(450)); + assert!(!stream(&doc, scan).dict.contains(b"Decode"), "the default decode array isn't carried over"); + assert_eq!(stream(&doc, inverted).dict.int(b"Width"), Some(900)); + assert_eq!(stream(&doc, odd).dict.int(b"Width"), Some(900)); +} + #[test] fn settings_choose_what_happens() { let mut doc = Document::new_empty(); diff --git a/crates/ui-egui/src/create_ui.rs b/crates/ui-egui/src/create_ui.rs index 56e82d885f..fedd2d64cb 100644 --- a/crates/ui-egui/src/create_ui.rs +++ b/crates/ui-egui/src/create_ui.rs @@ -227,15 +227,15 @@ impl PdfCraftApp { } /// Save an optimized copy (Reduce File Size, Optimize PDF) next to the original, reporting - /// the saving. + /// the saving. A Reduce File Size copy that isn't smaller is not saved (#490). pub(crate) fn save_optimized( &mut self, id: pdfcraft_engine::DocId, - suffix: &str, + kind: crate::optimize_ui::OptimizeKind, result: Result<(Arc>, String), pdfcraft_engine::EditError>, ) { let Some(doc) = self.session.get(id) else { return }; - let (before, name) = (doc.bytes.len(), format!("{} ({suffix}).pdf", stem(&doc.name))); + let (before, name) = (doc.bytes.len(), format!("{} ({}).pdf", stem(&doc.name), kind.suffix())); let (bytes, detail) = match result { Ok(r) => r, Err(e) => { @@ -244,18 +244,28 @@ impl PdfCraftApp { } }; let after = bytes.len(); - let saved = move |app: &mut PdfCraftApp, place: String| { - let pct = 100.0 * (1.0 - after as f64 / before.max(1) as f64); - app.notify_fmt( - "Saved {place}: {before} → {after} ({pct}% smaller){detail}", - &[ - ("place", &place), - ("before", &crate::panels::human_size(before)), - ("after", &crate::panels::human_size(after)), - ("pct", &format!("{pct:.0}")), - ("detail", &detail), - ], + if kind == crate::optimize_ui::OptimizeKind::Reduce && after >= before { + self.notify_fmt( + "This file is already as small as it can be made ({size}). No copy was saved.", + &[("size", &crate::panels::human_size(before))], ); + return; + } + let saved = move |app: &mut PdfCraftApp, place: String| { + let (b, a) = (crate::panels::human_size(before), crate::panels::human_size(after)); + if after < before { + let pct = 100.0 * (1.0 - after as f64 / before.max(1) as f64); + app.notify_fmt( + "Saved {place}: {before} → {after} ({pct}% smaller){detail}", + &[("place", &place), ("before", &b), ("after", &a), ("pct", &format!("{pct:.0}")), ("detail", &detail)], + ); + } else { + // Optimize PDF still saves: what it discards may matter more than the size. + app.notify_fmt( + "Saved {place}: {before} → {after} (not smaller){detail}", + &[("place", &place), ("before", &b), ("after", &a), ("detail", &detail)], + ); + } }; #[cfg(not(target_arch = "wasm32"))] { diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 2866522ef8..acb60a8471 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -1496,6 +1496,8 @@ Right Edge الحافة اليمنى Language اللغة Saved {place}: {before} → {after} ({pct}% smaller){detail} تم الحفظ {place}: {before} → {after} (أصغر بنسبة {pct}%){detail} + This file is already as small as it can be made ({size}). No copy was saved. هذا الملف صغير بالفعل قدر الإمكان ({size}). لم تُحفظ أي نسخة. + Saved {place}: {before} → {after} (not smaller){detail} تم الحفظ {place}: {before} → {after} (ليس أصغر){detail} Couldn't save the image: {e} تعذّر حفظ الصورة: {e} Adding images arrives on the web with file pickers for images إضافة الصور ستتوفر على الويب مع منتقيات ملفات الصور Replacing images on page {p} arrives on the web with image pickers ({i}) استبدال الصور في الصفحة {p} سيتوفر على الويب مع منتقيات الصور ({i}) diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 138b112c0c..5e98978d38 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1448,6 +1448,8 @@ Right Edge Десен ръб Language Език Saved {place}: {before} → {after} ({pct}% smaller){detail} Записано {place}: {before} → {after} (с {pct}% по-малко){detail} + This file is already as small as it can be made ({size}). No copy was saved. Файлът вече е възможно най-малък ({size}). Не е записано копие. + Saved {place}: {before} → {after} (not smaller){detail} Записано {place}: {before} → {after} (не е по-малко){detail} Couldn't save the image: {e} Изображението не можа да бъде записано: {e} Adding images arrives on the web with file pickers for images Добавянето на изображения в уеб версията ще стане достъпно с инструментите за избор на файлове с изображения Replacing images on page {p} arrives on the web with image pickers ({i}) Замяната на изображения на страница {p} в уеб версията ще стане достъпна с инструментите за избор на изображения ({i}) diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 11188391a3..a53a439be3 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -51,6 +51,8 @@ Document properties… Vlastnosti dokumentu… Export to image… Exportovat jako obrázek… Reduce file size… Zmenšit velikost souboru… + This file is already as small as it can be made ({size}). No copy was saved. Soubor je už tak malý, jak to jde ({size}). Žádná kopie nebyla uložena. + Saved {place}: {before} → {after} (not smaller){detail} Uloženo {place}: {before} → {after} (není menší){detail} Optimize PDF… Optimalizovat PDF… Export to text… Exportovat jako text… Export to Word… Exportovat do formátu Word… diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 9baf7af670..2830d3f412 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1476,6 +1476,8 @@ Right Edge Rechter Rand Language Sprache Saved {place}: {before} → {after} ({pct}% smaller){detail} {place} gespeichert: {before} → {after} ({pct} % kleiner){detail} + This file is already as small as it can be made ({size}). No copy was saved. Diese Datei ist bereits so klein wie möglich ({size}). Es wurde keine Kopie gespeichert. + Saved {place}: {before} → {after} (not smaller){detail} {place} gespeichert: {before} → {after} (nicht kleiner){detail} Couldn't save the image: {e} Das Bild konnte nicht gespeichert werden: {e} Adding images arrives on the web with file pickers for images Bilder hinzufügen kommt im Web zusammen mit der Bildauswahl Replacing images on page {p} arrives on the web with image pickers ({i}) Bilder auf Seite {p} ersetzen kommt im Web zusammen mit der Bildauswahl ({i}) diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 1c87023697..1d8ffcd26e 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1490,6 +1490,8 @@ Right Edge Borde derecho Language Idioma Saved {place}: {before} → {after} ({pct}% smaller){detail} Guardado {place}: {before} → {after} ({pct}% más pequeño){detail} + This file is already as small as it can be made ({size}). No copy was saved. Este archivo ya es lo más pequeño posible ({size}). No se guardó ninguna copia. + Saved {place}: {before} → {after} (not smaller){detail} Guardado {place}: {before} → {after} (no es más pequeño){detail} Couldn't save the image: {e} No se pudo guardar la imagen: {e} Adding images arrives on the web with file pickers for images Añadir imágenes llegará a la versión web con los selectores de archivos de imagen Replacing images on page {p} arrives on the web with image pickers ({i}) Reemplazar imágenes en la página {p} llegará a la versión web con los selectores de imagen ({i}) diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 88a6117d95..ab1241076f 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1475,6 +1475,8 @@ Right Edge Bord droit Language Langue Saved {place}: {before} → {after} ({pct}% smaller){detail} Enregistré {place} : {before} → {after} ({pct} % plus petit){detail} + This file is already as small as it can be made ({size}). No copy was saved. Ce fichier est déjà aussi petit que possible ({size}). Aucune copie n'a été enregistrée. + Saved {place}: {before} → {after} (not smaller){detail} Enregistré {place} : {before} → {after} (pas plus petit){detail} Couldn't save the image: {e} Impossible d'enregistrer l'image : {e} Adding images arrives on the web with file pickers for images L'ajout d'images arrivera sur le web avec les sélecteurs de fichiers d'images Replacing images on page {p} arrives on the web with image pickers ({i}) Le remplacement d'images à la page {p} arrivera sur le web avec les sélecteurs d'images ({i}) diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 3ac27fdea7..813aa27d5b 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1465,6 +1465,8 @@ Right Edge Jobb szél Language Nyelv Saved {place}: {before} → {after} ({pct}% smaller){detail} Mentve ide: {place}: {before} → {after} ({pct}%-kal kisebb){detail} + This file is already as small as it can be made ({size}). No copy was saved. Ez a fájl már a lehető legkisebb ({size}). Nem készült másolat. + Saved {place}: {before} → {after} (not smaller){detail} Mentve ide: {place}: {before} → {after} (nem kisebb){detail} Couldn't save the image: {e} A képet nem sikerült menteni: {e} Adding images arrives on the web with file pickers for images A képek hozzáadása a webes változatban a képválasztóval együtt érkezik Replacing images on page {p} arrives on the web with image pickers ({i}) A {p}. oldal képeinek cseréje a webes változatban a képválasztóval együtt érkezik ({i}) diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index fff7d958ef..38c9293955 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1491,6 +1491,8 @@ Right Edge Bordo destro Language Lingua Saved {place}: {before} → {after} ({pct}% smaller){detail} Salvato {place}: {before} → {after} ({pct}% in meno){detail} + This file is already as small as it can be made ({size}). No copy was saved. Questo file è già il più piccolo possibile ({size}). Nessuna copia salvata. + Saved {place}: {before} → {after} (not smaller){detail} Salvato {place}: {before} → {after} (non più piccolo){detail} Couldn't save the image: {e} Impossibile salvare l'immagine: {e} Adding images arrives on the web with file pickers for images Sul web, l'aggiunta di immagini arriverà con i selettori di file per immagini Replacing images on page {p} arrives on the web with image pickers ({i}) Sul web, la sostituzione di immagini a pagina {p} arriverà con i selettori di immagini ({i}) diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 0d7525cece..b44cbc77b7 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1496,6 +1496,8 @@ Right Edge 右端 Language 言語 Saved {place}: {before} → {after} ({pct}% smaller){detail} {place} に保存: {before} → {after}({pct}% 削減){detail} + This file is already as small as it can be made ({size}). No copy was saved. このファイルはすでに最小のサイズです({size})。コピーは保存されませんでした。 + Saved {place}: {before} → {after} (not smaller){detail} {place} に保存: {before} → {after}(縮小なし){detail} Couldn't save the image: {e} 画像を保存できませんでした: {e} Adding images arrives on the web with file pickers for images Web 版の画像追加は画像ファイル選択に対応した際に利用可能になります Replacing images on page {p} arrives on the web with image pickers ({i}) ページ {p} の画像置換は Web 版の画像選択に対応した際に利用可能になります({i}) diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index f0912dbdae..62527bb111 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1389,6 +1389,8 @@ Right Edge Borda direita Language Idioma Saved {place}: {before} → {after} ({pct}% smaller){detail} Salvo {place}: {before} → {after} ({pct}% menor){detail} + This file is already as small as it can be made ({size}). No copy was saved. Este arquivo já está o menor possível ({size}). Nenhuma cópia foi salva. + Saved {place}: {before} → {after} (not smaller){detail} Salvo {place}: {before} → {after} (não ficou menor){detail} Couldn't save the image: {e} Não foi possível salvar a imagem: {e} Adding images arrives on the web with file pickers for images A adição de imagens chegará à Web com seletores de arquivo para imagens Replacing images on page {p} arrives on the web with image pickers ({i}) A substituição de imagens na página {p} chegará à Web com seletores de imagem ({i}) diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index c4cf0a4299..631be2ce0b 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1497,6 +1497,8 @@ Right Edge Правый край Language Язык Saved {place}: {before} → {after} ({pct}% smaller){detail} Сохранено {place}: {before} → {after} (на {pct}% меньше){detail} + This file is already as small as it can be made ({size}). No copy was saved. Этот файл уже минимального размера ({size}). Копия не сохранена. + Saved {place}: {before} → {after} (not smaller){detail} Сохранено {place}: {before} → {after} (не меньше){detail} Couldn't save the image: {e} Не удалось сохранить изображение: {e} Adding images arrives on the web with file pickers for images Добавление изображений появится в веб-версии с выбором файлов изображений Replacing images on page {p} arrives on the web with image pickers ({i}) Замена изображений на стр. {p} появится в веб-версии с выбором изображений ({i}) diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index 69ec3fbdb0..3a2066aaaf 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -1471,6 +1471,8 @@ action wizard Start ప్రారంభించు Right Edge కుడి అంచు Language భాష Saved {place}: {before} → {after} ({pct}% smaller){detail} {place} సేవ్ చేయబడింది: {before} → {after} ({pct}% తగ్గింది){detail} + This file is already as small as it can be made ({size}). No copy was saved. ఈ ఫైల్ ఇప్పటికే సాధ్యమైనంత చిన్నదిగా ఉంది ({size}). కాపీ ఏదీ సేవ్ చేయబడలేదు. + Saved {place}: {before} → {after} (not smaller){detail} {place} సేవ్ చేయబడింది: {before} → {after} (తగ్గలేదు){detail} Couldn't save the image: {e} చిత్రాన్ని సేవ్ చేయడం సాధ్యం కాలేదు: {e} Adding images arrives on the web with file pickers for images చిత్రాలను జోడించడం, చిత్రాల కోసం ఫైల్ పికర్‌లతో పాటు వెబ్‌లో వస్తుంది Replacing images on page {p} arrives on the web with image pickers ({i}) {p}వ పేజీలోని చిత్రాలను భర్తీ చేయడం, చిత్రాల పికర్‌లతో పాటు వెబ్‌లో వస్తుంది ({i}) diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 82d78fdb4c..ae1349cd35 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1494,6 +1494,8 @@ Right Edge Правий край Language Мова Saved {place}: {before} → {after} ({pct}% smaller){detail} Збережено {place}: {before} → {after} (менше на {pct}%){detail} + This file is already as small as it can be made ({size}). No copy was saved. Цей файл уже мінімального розміру ({size}). Копію не збережено. + Saved {place}: {before} → {after} (not smaller){detail} Збережено {place}: {before} → {after} (не менше){detail} Couldn't save the image: {e} Не вдалося зберегти зображення: {e} Adding images arrives on the web with file pickers for images Додавання зображень з’явиться у вебверсії разом із вибором файлів зображень Replacing images on page {p} arrives on the web with image pickers ({i}) Заміна зображень на сторінці {p} з’явиться у вебверсії разом із вибором зображень ({i}) diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 98bf4dd748..0aca6798ea 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1455,6 +1455,8 @@ Right Edge 右边缘 Language 语言 Saved {place}: {before} → {after} ({pct}% smaller){detail} 已保存{place}:{before} → {after}(缩小了{pct}%){detail} + This file is already as small as it can be made ({size}). No copy was saved. 此文件已经是最小的了({size})。未保存副本。 + Saved {place}: {before} → {after} (not smaller){detail} 已保存{place}:{before} → {after}(未缩小){detail} Couldn't save the image: {e} 无法保存图像:{e} Adding images arrives on the web with file pickers for images Web 端添加图像将使用图像文件选择器 Replacing images on page {p} arrives on the web with image pickers ({i}) 在 Web 端替换第{p}页的图像将使用图像选择器({i}) diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 9cefb1c4c6..ae7132dd4e 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1404,6 +1404,8 @@ Right Edge 右邊緣 Language 語言 Saved {place}: {before} → {after} ({pct}% smaller){detail} 已儲存{place}:{before} → {after}(縮小了{pct}%){detail} + This file is already as small as it can be made ({size}). No copy was saved. 此檔案已經是最小的了({size})。未儲存副本。 + Saved {place}: {before} → {after} (not smaller){detail} 已儲存{place}:{before} → {after}(未縮小){detail} Couldn't save the image: {e} 無法儲存圖片:{e} Adding images arrives on the web with file pickers for images Web 端新增圖片將使用圖片檔案選擇器 Replacing images on page {p} arrives on the web with image pickers ({i}) 在 Web 端替換第{p}頁的圖片將使用圖片選擇器({i}) diff --git a/crates/ui-egui/src/optimize_ui.rs b/crates/ui-egui/src/optimize_ui.rs index ba65f465f5..3452eeb520 100644 --- a/crates/ui-egui/src/optimize_ui.rs +++ b/crates/ui-egui/src/optimize_ui.rs @@ -60,7 +60,7 @@ pub enum OptimizeKind { impl OptimizeKind { /// The suffix of the saved copy's name ("notes (reduced).pdf"). - fn suffix(self) -> &'static str { + pub(crate) fn suffix(self) -> &'static str { match self { OptimizeKind::Reduce => "reduced", OptimizeKind::Advanced => "optimized", @@ -282,7 +282,7 @@ impl PdfCraftApp { } let job = match self.session.optimize_job(id, settings, discard) { Ok(job) => job, - Err(e) => return self.save_optimized(id, kind.suffix(), Err(e)), + Err(e) => return self.save_optimized(id, kind, Err(e)), }; let progress = Arc::new(Mutex::new(OptimizeProgress::default())); let p = progress.clone(); @@ -352,7 +352,7 @@ impl PdfCraftApp { let detail = if kind == OptimizeKind::Advanced { summary(&r) } else { String::new() }; (b, detail) }); - self.save_optimized(doc, kind.suffix(), result); + self.save_optimized(doc, kind, result); } } } diff --git a/crates/ui-egui/tests/create.rs b/crates/ui-egui/tests/create.rs index f8f0e33927..ee81616b51 100644 --- a/crates/ui-egui/tests/create.rs +++ b/crates/ui-egui/tests/create.rs @@ -69,17 +69,13 @@ fn opening_images_and_text_converts_them_to_new_pdfs() { assert!(app.open_bytes("junk.png", None, b"\x89PNG\r\n\x1a\nnot really".to_vec()).is_err()); } -#[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 out2 = out.clone(); - let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(move |_cc| { +/// Open `name` and run Reduce File Size on it, saving to `out` without a dialog. +fn reduce(name: &'static str, bytes: Vec, out: &std::path::Path) -> egui_kittest::Harness<'static, PdfCraftApp> { + let out2 = out.to_path_buf(); + let mut h = egui_kittest::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, "lorem ipsum ".repeat(500).into_bytes()).unwrap(); + app.open_bytes(name, None, bytes).unwrap(); app.save_override = Some(out2.to_string_lossy().into_owned()); app }); @@ -87,15 +83,49 @@ fn reduce_file_size_writes_a_compact_copy() { 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) { + while h.state().optimize_run.is_some() && 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); + h.run_steps(2); assert!(h.state().optimize_run.is_none() && h.state().progress_notice.is_none(), "the run finished"); + h +} + +#[test] +fn reduce_file_size_writes_a_compact_copy() { + use egui_kittest::kittest::Queryable; + // A photo-like picture, stored losslessly: Reduce recompresses it as JPEG. + let (w, h) = (600u32, 400u32); + let px: Vec = (0..w * h).flat_map(|i| [(i % w * 255 / w) as u8 ^ (i & 7) as u8, (i / w * 255 / h) as u8, 128]).collect(); + let mut photo = Vec::new(); + { + let mut enc = png::Encoder::new(&mut photo, w, h); + enc.set_color(png::ColorType::Rgb); + enc.set_depth(png::BitDepth::Eight); + enc.write_header().unwrap().write_image_data(&px).unwrap(); + } + 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 h = reduce("photo.png", photo, &out); let bytes = std::fs::read(&out).unwrap(); assert!(bytes.starts_with(b"%PDF-")); + assert!(bytes.len() < h.state().session.docs()[0].bytes.len(), "the copy is smaller"); assert!(h.state().session.docs()[0].dirty, "the open document is unchanged"); + h.get_by_label_contains("% smaller"); +} + +#[test] +fn reduce_file_size_saves_nothing_when_the_file_is_already_small() { + use egui_kittest::kittest::Queryable; + let dir = std::env::temp_dir().join(format!("pdfcraft-reduce-small-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let out = dir.join("reduced.pdf"); + // A new text PDF is already compact: a copy would be no smaller (#490). + let h = reduce("notes.txt", "lorem ipsum ".repeat(500).into_bytes(), &out); + assert!(!out.exists(), "no copy was written"); + h.get_by_label_contains("This file is already as small as it can be made"); } #[test] diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index f95e15a574..4b36bea825 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7130,8 +7130,8 @@ milestone = "M11" 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; runs in the background with a progress bar and Cancel" +evidence = ["crates/ui-egui/tests/create.rs::reduce_file_size_writes_a_compact_copy", "crates/ui-egui/tests/create.rs::reduce_file_size_saves_nothing_when_the_file_is_already_small", "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", "crates/optimize/tests/optimize.rs::an_identity_decode_array_does_not_stop_reduce"] +notes = "images above 225 ppi to 150 ppi JPEG medium, thumbnails dropped, resources merged, object streams; scanner images with the default decode array are included; a copy that isn't smaller is not saved (\"already as small as it can be made\"); runs in the background with a progress bar and Cancel" [[feature]] id = "optimize.compatibility-level"