M11: "the smaller wins" counts the soft mask in the tests and the docs

- A noisy 640 × 800 soft mask under 1200 × 1500 text: resampled to the
  image's new size it shrinks only a little, by less than the grey
  settings' JPEG adds over the packed image, so packing wins; not
  counting the new mask passed every test before.
- The hex-text half of the bomb test also stores eight-bit
  black-and-white, which must pack, as its comment says.
- The optimize README, the mono-compression parity note and both
  progress logs say the soft mask counts.
- The Copy Image parity note no longer lists CCITT as missing: it
  copies through Export's decoder, which reads CCITT now.

From reviews with the pdfcraft-review skill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
This commit is contained in:
Grzegorz Martens
2026-10-29 20:48:02 +01:00
co-authored by Claude Opus 5.5
parent e8f3c6bc3e
commit ec4b265b57
5 changed files with 37 additions and 28 deletions
+1 -1
View File
@@ -205,7 +205,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-24 (M3, window move on Wayland, #3970 follow-up):** After a move or resize by PdfCraft's own title bar or edges, the button release given back to egui goes before the pointer leaving the window, so hover no longer sticks at the old place until the pointer moves. Found by CodeRabbit in review of #3970; a unit test covers it. Estimates unchanged.
- **2026-10-24 (M14, #4253):** The annotation quick bar remembers Keep tool selected across restarts, including unpinning. Old and malformed settings keep safe defaults; the opt-in UI control channel reads and changes the same preference. Estimates unchanged.
- **2026-10-24 (M11, black-and-white pictures):** A scan saved as a 1-bit PNG became an eight-bit grey image when made into a PDF, larger than the PNG; Create PDF now stores pictures of only black and white at one bit (Group 4 when smaller), the optimizer packs eight-bit black-and-white grey images to one bit unless the grey settings make them smaller (a dithered scan), and Export ▸ Images reads CCITT fax images.
- **2026-10-24 (M11, black-and-white pictures):** A scan saved as a 1-bit PNG became an eight-bit grey image when made into a PDF, larger than the PNG; Create PDF now stores pictures of only black and white at one bit (Group 4 when smaller), the optimizer packs eight-bit black-and-white grey images to one bit unless the grey settings make them smaller, soft mask counted (a dithered scan), and Export ▸ Images reads CCITT fax images.
- **2026-10-23 (M11, monochrome compression):** The PDF Optimizer and Reduce File Size store black-and-white images (scans, image masks) losslessly as CCITT Group 4 when that is smaller, with a Monochrome Images setting in the Optimizer and `doc_optimize` `mono`. JBIG2 and monochrome downsampling are next.
- **2026-10-23 (M7, #4280):** Edit text names each standard stand-in font after its face (`PCEdTimesBold`, ...) instead of one shared `PCEdHelv`. Rewriting a bold paragraph whose subset lacks an accent no longer repoints the paragraphs set earlier in a regular stand-in at Times-Bold, and the editor's frame stays on the paragraph being edited. Pages saved by older builds keep their shared name and are not repaired. Estimates unchanged.
- **2026-10-23 (M8, #3205 follow-up):** Phone-pattern redaction matches complete labelled Italian geographic numbers such as `Tel. +39 0426/123456`, with synthetic matcher and mark/apply/save/reopen coverage; unlabelled international slash numbers remain unsupported.
+2 -2
View File
@@ -39,8 +39,8 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going
general-purpose ones, is stored again as CCITT Group 4 (`pdfcraft_filters::encode_g4`) when
that is smaller as stored. An eight-bit grey image of only 0 and 255 is packed to one bit first
(not an indexed image, whose samples are palette indices, nor one with a colour-key `/Mask`),
unless the grey settings make it smaller still, as they can a dithered scan by downsampling it
to JPEG. It is lossless: the samples decode to the same bits, so the colour
unless the grey settings make it smaller still, its soft mask counted (resampled with it there,
kept as it is here), as they can a dithered scan by downsampling it to JPEG. It is lossless: the samples decode to the same bits, so the colour
space, decode array and masks stay. Group 4 wins on scans, whose ragged edges defeat Flate;
on clean computer-drawn 1-bit art Flate can be smaller, and the image then stays. Not yet:
JBIG2, monochrome downsampling, inline images, soft masks and `/Mask` images, and rows wider
+31 -22
View File
@@ -602,10 +602,16 @@ fn images_whose_data_decodes_far_past_their_samples_are_left_alone() {
let page_scan = bilevel(2560, 1200, false);
let one_bit = as_hex(&mut doc, &page_scan, 1, (2560, 1200));
let eight_bit = as_hex(&mut doc, &shades, 8, (w, h));
page(&mut doc, &[("A", one_bit), ("B", eight_bit)], "q 460.8 0 0 216 20 500 cm /A Do Q q 144 0 0 57.6 20 400 cm /B Do Q");
let packed = as_hex(&mut doc, &self::eight_bit(&text, w, h), 8, (w, h));
page(
&mut doc,
&[("A", one_bit), ("B", eight_bit), ("C", packed)],
"q 460.8 0 0 216 20 500 cm /A Do Q q 144 0 0 57.6 20 400 cm /B Do Q q 300 0 0 120 20 200 cm /C Do Q",
);
optimize(&mut doc, &Settings::default()).unwrap();
assert_eq!(stream(&doc, one_bit).dict.name(b"Filter"), Some(&b"CCITTFaxDecode"[..]), "1-bit hex text in lines: Group 4");
assert!(stream(&doc, eight_bit).dict.int(b"Width").is_some_and(|nw| nw < i64::from(w)), "8-bit hex text in lines: resampled");
assert_eq!(stream(&doc, packed).dict.int(b"BitsPerComponent"), Some(1), "8-bit black-and-white hex text in lines: packed");
}
/// Eight-bit samples (0 or 255) from rows of one-bit ones.
@@ -673,30 +679,33 @@ fn eight_bit_black_and_white_takes_the_smaller_of_one_bit_and_the_grey_settings(
/// The smaller of one bit and the grey settings counts the soft mask each leaves: packed, the
/// image keeps its mask as it is; resampled by the grey settings, the mask is resampled with it.
/// Here clean text, which packs to well under its JPEG, under a noisy mask the grey settings
/// shrink by far more than the image's saving: the grey settings win.
/// Clean text, 1200 × 1500 at 300 ppi, under a noisy mask. A mask as large: the grey settings
/// shrink it by far more than packing saves on the image, and win. A mask of 640 × 800 (a soft
/// mask may be another size than its image): resampled to the image's new size it shrinks only a
/// little, by less than the grey settings' JPEG adds over the packed image, and packing wins.
#[test]
fn eight_bit_black_and_white_with_a_soft_mask_counts_the_mask() {
let (w, h) = (1200u32, 1500u32);
let mut doc = Document::new_empty();
let mut seed = 0x2545_F491_4F6C_DD1Du64;
let noise: Vec<u8> = (0..w * h)
.map(|_| {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
(seed >> 24) as u8
})
.collect();
let mask = mono_image(&mut doc, w, h, &noise, None, &[("BitsPerComponent", 8.into()), ("ColorSpace", Object::name("DeviceGray"))]);
let grey = [("BitsPerComponent", 8.into()), ("ColorSpace", Object::name("DeviceGray")), ("SMask", Object::Ref(mask))];
let img = mono_image(&mut doc, w, h, &eight_bit(&bilevel(w, h, false), w, h), None, &grey);
// 288 × 360 pt is 300 ppi, above where the grey settings downsample.
page(&mut doc, &[("A", img)], "q 288 0 0 360 20 400 cm /A Do Q");
optimize(&mut doc, &Settings::default()).unwrap();
let s = stream(&doc, img);
assert_eq!((s.dict.int(b"BitsPerComponent"), s.dict.name(b"Filter")), (Some(8), Some(&b"DCTDecode"[..])), "{:?}", s.dict);
assert!(stream(&doc, mask).raw.len() < (w * h) as usize / 2, "the mask resampled with it");
for ((mw, mh), grey_wins) in [((w, h), true), ((640, 800), false)] {
let mut doc = Document::new_empty();
let mut seed = 0x2545_F491_4F6C_DD1Du64;
let noise: Vec<u8> = (0..mw * mh)
.map(|_| {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
(seed >> 24) as u8
})
.collect();
let mask = mono_image(&mut doc, mw, mh, &noise, None, &[("BitsPerComponent", 8.into()), ("ColorSpace", Object::name("DeviceGray"))]);
let grey = [("BitsPerComponent", 8.into()), ("ColorSpace", Object::name("DeviceGray")), ("SMask", Object::Ref(mask))];
let img = mono_image(&mut doc, w, h, &eight_bit(&bilevel(w, h, false), w, h), None, &grey);
page(&mut doc, &[("A", img)], "q 288 0 0 360 20 400 cm /A Do Q");
optimize(&mut doc, &Settings::default()).unwrap();
let s = stream(&doc, img);
let expected = if grey_wins { (Some(8), Some(&b"DCTDecode"[..])) } else { (Some(1), Some(&b"CCITTFaxDecode"[..])) };
assert_eq!((s.dict.int(b"BitsPerComponent"), s.dict.name(b"Filter")), expected, "mask {mw} × {mh}: {:?}", s.dict);
}
}
/// Eight-bit grey images of only black and white (as some scanners and converters write scans)
+1 -1
View File
@@ -257,7 +257,7 @@ Estimates unchanged.
| Date | Change | Summary |
|---|---|---|
| 2026-10-24 | minor | M11: Create PDF stores pictures of only black and white at one bit (Group 4 or Flate, whichever is smaller) instead of eight-bit grey; the optimizer packs eight-bit black-and-white grey images to one bit before Monochrome Images compresses them, unless the grey settings make them smaller (a dithered scan); Export ▸ Images decodes CCITT fax images. Estimates unchanged. |
| 2026-10-24 | minor | M11: Create PDF stores pictures of only black and white at one bit (Group 4 or Flate, whichever is smaller) instead of eight-bit grey; the optimizer packs eight-bit black-and-white grey images to one bit before Monochrome Images compresses them, unless the grey settings make them smaller, soft mask counted (a dithered scan); Export ▸ Images decodes CCITT fax images. Estimates unchanged. |
| 2026-10-23 | minor | M11: black-and-white images (1-bit, and image masks) are stored losslessly as CCITT Group 4 when that is smaller: PDF Optimizer ▸ Images ▸ Monochrome Images (on by default, so Reduce File Size too), `doc_optimize` `mono`; `pdfcraft_filters::encode_g4`. JBIG2 and monochrome downsampling not yet. Estimates unchanged. |
| 2026-10-27 | minor | M10: OCR stages SHA-256-verified ONNX models after the upstream RTEN URL brownout; discovery prefers a complete ONNX pair per directory, falls back to a complete RTEN pair, and never mixes formats or directories; language/web limits and estimates unchanged. |
| 2026-10-24 | minor | M3, #3970 follow-up: the button release given back after a window move or resize goes before the pointer leaving, so hover doesn't stick. Estimates unchanged. |
+2 -2
View File
@@ -1599,7 +1599,7 @@ status = "partial"
commands = []
tools = []
evidence = ["crates/ui-egui/tests/it/viewer.rs::a_clicked_picture_is_selected_and_copied_in_its_original_format", "crates/ui-egui/tests/it/viewer.rs::right_click_on_a_picture_copies_it_at_full_size", "crates/ui-egui/tests/it/viewer.rs::a_picture_inside_form_artwork_under_text_is_selected_and_copied", "crates/engine/src/tests.rs::copy_image_finds_a_picture_inside_form_artwork_under_text"]
notes = "Select tool: a click selects a raster image (also inside Form artwork or under text) and ⌘C copies it as a file in its original format (JPEG as stored, else PNG); right-click ▸ Copy Image copies its pixels at full resolution. Missing: inline images (BI/ID/EI), JPEG 2000/JBIG2/CCITT images, multi-format clipboard (file and bitmap at once), the web build"
notes = "Select tool: a click selects a raster image (also inside Form artwork or under text) and ⌘C copies it as a file in its original format (JPEG as stored, else PNG); right-click ▸ Copy Image copies its pixels at full resolution. Missing: inline images (BI/ID/EI), JPEG 2000/JBIG2 images, multi-format clipboard (file and bitmap at once), the web build"
[[feature]]
id = "view.rulers"
@@ -7385,7 +7385,7 @@ status = "partial"
commands = ["optimize.advanced", "optimize.reduce"]
tools = ["doc_optimize", "doc_reduce"]
evidence = ["crates/optimize/tests/optimize.rs::eight_bit_black_and_white_images_become_one_bit", "crates/filters/src/ccitt.rs::bitmaps_round_trip", "crates/filters/src/ccitt.rs::a_page_of_text_like_marks_round_trips_and_shrinks", "crates/filters/src/ccitt.rs::what_it_cannot_encode_is_an_error", "crates/optimize/tests/optimize.rs::black_and_white_images_become_ccitt_group_4", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_ccitt_group_4", "crates/ui-egui/tests/it/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy"]
notes = "CCITT Group 4, lossless: PDF Optimizer ▸ Images ▸ Monochrome Images (on by default, so Reduce File Size does it too; doc_optimize mono) stores a 1-bit image of one colour component (grey, indexed, a separation) or an image mask, kept with no filter or general-purpose ones, as Group 4 when that is smaller as stored; the samples decode to the same bits, so colour space, decode array and masks stay. An eight-bit grey image of only black and white is packed to one bit first, then stored the same way (indexed images and colour-key masks aside), unless the grey settings make it smaller still (a dithered scan downsampled to JPEG). Group 4 wins on scans; on clean computer-drawn 1-bit art Flate can be smaller, and the image then stays. Not yet: JBIG2 (lossless or lossy, which Adobe's help says beats CCITT for monochrome), monochrome downsampling, inline images, soft masks and /Mask images, rows wider than 32,767 samples. Redaction doesn't decode Group 4 yet (a mark over part of such an image removes it whole; nothing leaks)."
notes = "CCITT Group 4, lossless: PDF Optimizer ▸ Images ▸ Monochrome Images (on by default, so Reduce File Size does it too; doc_optimize mono) stores a 1-bit image of one colour component (grey, indexed, a separation) or an image mask, kept with no filter or general-purpose ones, as Group 4 when that is smaller as stored; the samples decode to the same bits, so colour space, decode array and masks stay. An eight-bit grey image of only black and white is packed to one bit first, then stored the same way (indexed images and colour-key masks aside), unless the grey settings make it smaller still, its soft mask counted (a dithered scan downsampled to JPEG). Group 4 wins on scans; on clean computer-drawn 1-bit art Flate can be smaller, and the image then stays. Not yet: JBIG2 (lossless or lossy, which Adobe's help says beats CCITT for monochrome), monochrome downsampling, inline images, soft masks and /Mask images, rows wider than 32,767 samples. Redaction doesn't decode Group 4 yet (a mark over part of such an image removes it whole; nothing leaks)."
[[feature]]
id = "optimize.only-if-smaller"