diff --git a/ROADMAP.md b/ROADMAP.md index 9013e526ea..0d1332e862 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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. diff --git a/crates/optimize/README.md b/crates/optimize/README.md index 518910fea4..1f184f0ffb 100644 --- a/crates/optimize/README.md +++ b/crates/optimize/README.md @@ -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 diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index c9a5203ab6..9e82858776 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -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 = (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 = (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) diff --git a/docs/roadmap.md b/docs/roadmap.md index a5d6627fbc..c4efe2f004 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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. | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 7310893697..6b593363c7 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -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"