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 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-10 16:20:50 -04:00
1 parent 74be522c18
commit 4fd2c2b3a3
26 files changed
+178 -53

No files matched your search

+15 -15
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 = "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"
+6 -2
View File
@@ -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)?,
+1 -1
View File
@@ -971,7 +971,7 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["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(
+6 -1
View File
@@ -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(_))
+15 -2
View File
@@ -208,10 +208,19 @@ fn jpeg(px: &[u8], n: usize, w: u32, h: u32, quality: u8) -> Option<Vec<u8>> {
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<Stream> {
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) {
+2 -2
View File
@@ -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.
+33
View File
@@ -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<Object>| {
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();
+24 -14
View File
@@ -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<Vec<u8>>, 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"))]
{
+2
View File
@@ -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})
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -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})
Can't render this file because it contains an unexpected character in line 8 and column 16.
+2
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 7 and column 81.
+2
View File
@@ -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})
Can't render this file because it contains an unexpected character in line 6 and column 51.
+2
View File
@@ -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})
Can't render this file because it contains an unexpected character in line 6 and column 60.
+2
View File
@@ -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})
Can't render this file because it contains an unexpected character in line 6 and column 39.
+2
View File
@@ -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})
Can't render this file because it contains an unexpected character in line 7 and column 44.
+2
View File
@@ -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})
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -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})
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -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})
Can't render this file because it contains an unexpected character in line 9 and column 16.
+2
View File
@@ -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})
Can't render this file because it contains an unexpected character in line 9 and column 16.
+2
View File
@@ -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})
Can't render this file because it contains an unexpected character in line 13 and column 16.
+2
View File
@@ -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})
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -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})
Can't render this file because it contains an unexpected character in line 9 and column 16.
+2
View File
@@ -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})
Can't render this file because it contains an unexpected character in line 9 and column 16.
+3 -3
View File
@@ -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);
}
}
}
+41 -11
View File
@@ -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<u8>, 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<u8> = (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]
+2 -2
View File
@@ -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"