From 4791cacd2cc55a1d91962c8109cfff11eae853e1 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Fri, 30 Oct 2026 14:55:44 +0100 Subject: [PATCH] test: qualify conservative tone retention and format review regressions --- crates/automation/tests/it/automation.rs | 26 ++++++++++++- crates/engine/src/tests.rs | 11 ++---- crates/optimize/tests/optimize.rs | 47 ++++++++---------------- crates/ui-egui/tests/it/editing.rs | 3 -- parity/acrobat-features.toml | 2 +- 5 files changed, 45 insertions(+), 44 deletions(-) diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index d6c058b8f4..abfd441d34 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -6770,7 +6770,7 @@ fn doc_optimize_stores_black_and_white_images_as_ccitt_group_4() { fn doc_optimize_averages_black_and_white_scans_down() { let dir = workdir("optimize-mono-downsample"); // 1000 × 400 samples on 120 × 48 pt: 600 ppi. - std::fs::write(dir.join("scan.pdf"), scan_drawn(b"q 120 0 0 48 50 500 cm /Scan Do Q")).unwrap(); + std::fs::write(dir.join("scan.pdf"), simple_rule_scan_drawn(b"q 120 0 0 48 50 500 cm /Scan Do Q")).unwrap(); let mut a = auto(&dir); let doc = ok(&mut a, "doc_open", json!({ "path": "scan.pdf" }))["doc"].as_u64().unwrap(); let sizes = |name: &str| image_sizes(&dir.join(name)); @@ -6807,7 +6807,7 @@ fn image_sizes(path: &std::path::Path) -> Vec<(Option, Option)> { fn batch_ppi_bounds_how_far_black_and_white_scans_are_resampled() { let dir = workdir("batch-mono-ppi"); // 1000 × 400 samples on 120 × 48 pt: 600 ppi. - std::fs::write(dir.join("scan.pdf"), scan_drawn(b"q 120 0 0 48 50 500 cm /Scan Do Q")).unwrap(); + std::fs::write(dir.join("scan.pdf"), simple_rule_scan_drawn(b"q 120 0 0 48 50 500 cm /Scan Do Q")).unwrap(); let mut a = auto(&dir); for (ppi, size) in [(None, (500, 200)), (Some(600), (1000, 400)), (Some(96), (500, 200))] { let out = format!("out-{ppi:?}"); @@ -10171,3 +10171,25 @@ fn a_shared_text_object_keeps_both_paragraphs_through_tools() { let extracted = page_text(&mut a, again); assert!(extracted[0].contains("Four") && extracted[0].contains("Neighbor stays."), "{extracted:?}"); } + +/// Unambiguous separated one-pixel rules test the requested scale independently of the +/// conservative local-tone guard. Ragged text can legitimately be retained by that guard. +fn simple_rule_scan_drawn(content: &[u8]) -> Vec { + use pdfcraft_cos::{Document, Object, SaveOptions, Stream, write_full}; + let mut doc = Document::open(std::sync::Arc::new(scan_drawn(content))).unwrap(); + for number in doc.object_numbers() { + let reference = pdfcraft_cos::ObjRef::new(number, doc.generation(number)); + let object = doc.get(reference); + let Object::Stream(image) = &*object else { continue }; + if image.dict.name(b"Subtype") != Some(b"Image") { + continue; + } + let mut data = vec![0xFF; 125 * 400]; + for y in 0..400 { + data[y * 125] &= 0x7F; + data[y * 125 + 124] &= 0xFE; + } + doc.set(reference, Object::Stream(Stream::flate(image.dict.clone(), &data))); + } + write_full(&doc, &SaveOptions::default()).unwrap() +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 692ba47a08..5db12eda04 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -9294,7 +9294,9 @@ fn repeated_backdrop_artwork_survives_background_undo_redo_and_both_save_modes() 5 0 obj << /Length {} >> stream\n{rectangle}\nendstream endobj\n\ 6 0 obj << /Length {} >> stream\n{artwork}\nendstream endobj\n\ 7 0 obj << /Length 1 >> stream\nQ\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF", - white.len(), rectangle.len(), artwork.len() + white.len(), + rectangle.len(), + artwork.len() ); // Start with a conventional xref, so the first save can exercise incremental output. let source = pdfcraft_cos::Document::open(Arc::new(raw.into_bytes())).unwrap(); @@ -9308,12 +9310,7 @@ fn repeated_backdrop_artwork_survives_background_undo_redo_and_both_save_modes() black(&s, id); s.apply( id, - Edit::AddBackground { - pages: vec![0], - settings: Background { color: [1.0, 0.0, 0.0], ..Background::default() }, - replace: false, - file: None, - }, + Edit::AddBackground { pages: vec![0], settings: Background { color: [1.0, 0.0, 0.0], ..Background::default() }, replace: false, file: None }, ) .unwrap(); black(&s, id); diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index a01def0693..0f2f1e1f4a 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -1461,7 +1461,7 @@ fn black_and_white_rgb_in_resources_its_page_tree_and_a_form_share_becomes_grey( /// screen size. With `downsample` off nothing is resampled; with Retain, Flate stays Flate, CCITT /// stays CCITT, and eight-bit grey and RGB are left to the grey and colour settings. #[test] -fn black_and_white_images_above_450_ppi_are_averaged_down_to_300() { +fn ambiguous_monochrome_text_is_recompressed_without_lossy_reduction() { let mut doc = Document::new_empty(); let (w, h) = (1200u32, 480u32); let text = bilevel(w, h, false); @@ -1547,24 +1547,8 @@ fn black_and_white_images_above_450_ppi_are_averaged_down_to_300() { let d = stream(doc, r).dict; (d.int(b"Width").unwrap(), d.int(b"Height").unwrap()) }; - // Each new pixel: 1 when more than two of the 2 × 2 it covers are, or two when 1 is the dark - // sample (the mask's decode array inverts it, so its painted part is 1). - let stride = (w as usize).div_ceil(8); - let bit = |x: usize, y: usize| text[y * stride + x / 8] & (0x80 >> (x % 8)) != 0; - let halve = |dark_one: bool| { - let mut out = vec![0u8; (w as usize / 2).div_ceil(8) * h as usize / 2]; - for y in 0..h as usize / 2 { - for x in 0..w as usize / 2 { - let ones = [(0, 0), (1, 0), (0, 1), (1, 1)].iter().filter(|(dx, dy)| bit(2 * x + dx, 2 * y + dy)).count(); - if ones > 2 || (dark_one && ones == 2) { - out[y * (w as usize / 2).div_ceil(8) + x / 8] |= 0x80 >> (x % 8); - } - } - } - out - }; - let (halved, halved_mask) = (halve(false), halve(true)); - assert_ne!(halved, halved_mask, "the picture has ties"); + // This ragged text fixture has dense local mixed-sample patches. The conservative + // tone guard retains it too: a false positive costs size, not document content. // The grey and colour settings left alone too: an image of more bits would otherwise take // them when smaller. @@ -1578,14 +1562,14 @@ fn black_and_white_images_above_450_ppi_are_averaged_down_to_300() { let mut kept = doc.clone(); let r = optimize(&mut kept, &Settings { mono: RETAIN, ..Settings::default() }).unwrap(); // Not the inverted RGB: the colour settings leave an image with a decode array alone. - assert_eq!(r.images_resampled, 6, "{r:?}"); + assert_eq!(r.images_resampled, 2, "{r:?}"); assert_eq!(stream(&kept, flate).dict.name(b"Filter"), Some(&b"FlateDecode"[..]), "Retain: Flate stays Flate"); for i in [fax, hex_fax] { - assert_eq!(stream(&kept, i).dict.name(b"Filter"), Some(&b"CCITTFaxDecode"[..]), "and CCITT stays CCITT: {i:?}"); + assert_eq!(stream(&kept, i).dict.get(b"Filter"), stream(&doc, i).dict.get(b"Filter"), "Retain keeps the exact filter chain"); } assert_eq!(raw(&kept, hex_at_400), raw(&doc, hex_at_400), "and nothing else changes"); for i in [flate, fax, hex_fax] { - assert_eq!((size(&kept, i), kept.decoded_image(&stream(&kept, i)).unwrap()), ((600, 240), halved.clone()), "{i:?}"); + assert_eq!((size(&kept, i), kept.decoded_image(&stream(&kept, i)).unwrap()), ((1200, 480), text.clone()), "{i:?}"); } let bits = [eight, rgb, rgb_inverted].map(|i| stream(&kept, i).dict.int(b"BitsPerComponent")); assert_eq!(bits, [Some(8); 3], "Retain leaves eight bits to the grey and colour settings"); @@ -1593,13 +1577,13 @@ fn black_and_white_images_above_450_ppi_are_averaged_down_to_300() { let untouched = [fax_at_400].map(|r| raw(&doc, r)); let hex_len = raw(&doc, hex_at_400).len(); let r = optimize(&mut doc, &Settings::default()).unwrap(); - assert_eq!((r.images_resampled, r.images_recompressed), (7, 3), "{r:?}"); + assert_eq!((r.images_resampled, r.images_recompressed), (0, 9), "{r:?}"); assert_eq!(stream(&doc, rgb).dict.get(b"ColorSpace"), Some(&Object::name("DeviceGray"))); for i in [flate, fax, mask, eight, hex_fax, rgb] { let s = stream(&doc, i); - assert_eq!((size(&doc, i), s.dict.int(b"BitsPerComponent")), ((600, 240), if i == mask { None } else { Some(1) })); + assert_eq!((size(&doc, i), s.dict.int(b"BitsPerComponent")), ((1200, 480), if i == mask { None } else { Some(1) })); assert!(matches!(s.dict.name(b"Filter"), Some(b"CCITTFaxDecode" | b"FlateDecode")), "{i:?}"); - assert_eq!(doc.decoded_image(&s).unwrap(), if i == mask { halved_mask.clone() } else { halved.clone() }, "{i:?}"); + assert_eq!(doc.decoded_image(&s).unwrap(), text.clone(), "{i:?}"); } assert_eq!(stream(&doc, mask).dict.get(b"Decode"), Some(&decode), "the decode array stays"); // Grey now, with grey's decode pair, which makes 1 black: its ties are black too. @@ -1607,9 +1591,9 @@ fn black_and_white_images_above_450_ppi_are_averaged_down_to_300() { let inverting = Object::Array(vec![1.into(), 0.into()]); assert_eq!( (size(&doc, rgb_inverted), s.dict.get(b"ColorSpace"), s.dict.get(b"Decode")), - ((600, 240), Some(&Object::name("DeviceGray")), Some(&inverting)) + ((1200, 480), Some(&Object::name("DeviceGray")), Some(&inverting)) ); - assert_eq!(doc.decoded_image(&s).unwrap(), halved.iter().map(|b| !b).collect::>(), "the same picture, ties black"); + assert_eq!(doc.decoded_image(&s).unwrap(), text.iter().map(|b| !b).collect::>(), "the same picture, ties black"); for i in [at_400, soft, hex_at_400] { assert_eq!((size(&doc, i), stream(&doc, i).dict.name(b"Filter")), ((1200, 480), Some(&b"CCITTFaxDecode"[..])), "{i:?}"); } @@ -1617,8 +1601,7 @@ fn black_and_white_images_above_450_ppi_are_averaged_down_to_300() { assert_eq!(doc.decoded_image(&stream(&doc, hex_at_400)).unwrap(), text, "and the picture kept"); assert_eq!([fax_at_400].map(|r| raw(&doc, r)), untouched); let after = render(&doc); - let differ = before.iter().zip(&after).filter(|(a, b)| a.abs_diff(**b) > 64).count(); - assert!(differ * 200 < before.len(), "{differ} of {} channel values differ visibly", before.len()); + assert_eq!(after, before, "conservative retention preserves the rendered page exactly"); } /// Monochrome downsampling leaves a halftone or a dither at its resolution, as most new pixels @@ -1658,7 +1641,7 @@ fn monochrome_downsampling_leaves_halftones_and_overwide_rows_at_their_resolutio let d = stream(&doc, r).dict; (d.int(b"Width"), d.int(b"Height")) }; - assert_eq!(size(text), (Some(600), Some(240)), "text"); + assert_eq!(size(text), (Some(1200), Some(480)), "ambiguous dense text is retained conservatively"); for (i, r) in halftones.iter().enumerate() { assert_eq!(size(*r), (Some(1200), Some(480)), "halftone {i}"); } @@ -1703,7 +1686,8 @@ fn a_fax_image_behind_a_decompression_bomb_is_left_alone() { let before = stream(&doc, img).raw; let r = optimize(&mut doc, &Settings::default()).unwrap(); if padding == 0 { - assert_eq!(r.images_resampled, 1, "{r:?}"); + assert_eq!(r.images_resampled, 0, "ambiguous local tones are retained: {r:?}"); + assert_eq!(doc.decoded_image(&stream(&doc, img)).unwrap(), bilevel(w, h, false)); } else { assert_eq!((r.images_resampled, r.images_recompressed), (0, 0), "{r:?}"); assert_eq!(stream(&doc, img).raw, before); @@ -3431,6 +3415,7 @@ fn indexed_thin_rules_keep_their_samples_for_both_palette_orders_and_decodes() { optimize(&mut gray, &Settings::default()).unwrap(); let gray_stream = stream(&gray, gray_image); let gray_samples = gray.decoded_image(&gray_stream).unwrap(); + assert_eq!((gray_stream.dict.int(b"Width"), gray_stream.dict.int(b"Height")), (Some(600), Some(240))); assert!(gray_samples.iter().any(|b| *b != 0xFF), "the DeviceGray thin rule survives"); for white_first in [false, true] { for inverted in [false, true] { diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index 0eb5ccb7fe..bdb0720dde 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -7137,7 +7137,6 @@ fn selected_existing_rows_keep_alignment_disabled_and_the_complete_paragraph() { assert_eq!(row_rect(&h, "nine"), last); } - /// Clearing the selection restores ordinary whole-paragraph alignment and its undo step. #[test] fn whole_paragraph_alignment_stays_available_without_a_row_selection() { @@ -7156,7 +7155,6 @@ fn whole_paragraph_alignment_stays_available_without_a_row_selection() { assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), None); } - /// Redo after Undo in the box puts the typing back, in the box: it doesn't reach the document, /// whose own Redo (a formatting change waiting here) must stay waiting. #[test] @@ -7189,7 +7187,6 @@ fn redo_after_undoing_typing_puts_the_typing_back() { assert_eq!((doc.can_redo(), doc.text_blocks(0)[0].base_font.as_str()), (Some("Edit text"), "Helvetica"), "the document's Redo still waits"); } - /// Redo from the menu or toolbar with typing in the box the page doesn't have yet. Closing the /// box first applied the typing, a new edit that dropped the document's waiting Redo, and the /// Redo itself then failed: the step was gone. The typing now stays in the box and the Redo diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index d4a06ba8fc..1404247cd7 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7384,7 +7384,7 @@ milestone = "M11" status = "partial" commands = ["optimize.advanced"] tools = ["doc_optimize"] -evidence = ["crates/optimize/tests/optimize.rs::images_are_measured_where_drawn_and_downsampled", "crates/optimize/tests/optimize.rs::settings_choose_what_happens", "crates/automation/tests/it/automation.rs::optimizing_through_tools", "crates/optimize/tests/optimize.rs::a_page_picture_printed_as_one_pixel_strips_is_merged_into_one_image", "crates/optimize/tests/optimize.rs::images_that_do_not_tile_a_picture_are_left_alone", "crates/optimize/tests/optimize.rs::a_deeply_nested_form_is_measured_at_its_largest_use", "crates/optimize/tests/optimize.rs::images_are_not_resampled_when_some_uses_could_not_be_measured", "crates/optimize/tests/optimize.rs::image_resolution_uses_the_pages_physical_unit", "crates/optimize/tests/optimize.rs::optimization_retains_an_image_below_threshold_on_a_scaled_page", "crates/optimize/tests/optimize.rs::shared_forms_are_measured_in_each_pages_physical_units", "crates/optimize/tests/optimize.rs::black_and_white_images_above_450_ppi_are_averaged_down_to_300", "crates/optimize/src/images.rs::it_matches_the_slow_way", "crates/automation/tests/it/automation.rs::doc_optimize_averages_black_and_white_scans_down", "crates/ui-egui/tests/it/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy"] +evidence = ["crates/optimize/tests/optimize.rs::images_are_measured_where_drawn_and_downsampled", "crates/optimize/tests/optimize.rs::settings_choose_what_happens", "crates/automation/tests/it/automation.rs::optimizing_through_tools", "crates/optimize/tests/optimize.rs::a_page_picture_printed_as_one_pixel_strips_is_merged_into_one_image", "crates/optimize/tests/optimize.rs::images_that_do_not_tile_a_picture_are_left_alone", "crates/optimize/tests/optimize.rs::a_deeply_nested_form_is_measured_at_its_largest_use", "crates/optimize/tests/optimize.rs::images_are_not_resampled_when_some_uses_could_not_be_measured", "crates/optimize/tests/optimize.rs::image_resolution_uses_the_pages_physical_unit", "crates/optimize/tests/optimize.rs::optimization_retains_an_image_below_threshold_on_a_scaled_page", "crates/optimize/tests/optimize.rs::shared_forms_are_measured_in_each_pages_physical_units", "crates/optimize/tests/optimize.rs::ambiguous_monochrome_text_is_recompressed_without_lossy_reduction", "crates/optimize/src/images.rs::it_matches_the_slow_way", "crates/automation/tests/it/automation.rs::doc_optimize_averages_black_and_white_scans_down", "crates/ui-egui/tests/it/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy"] notes = "colour and grayscale, bicubic, effective resolution measured where drawn (every use, with page-local UserUnit scaling, through nested forms up to 12 deep; when a use can't be measured, nothing is resampled); image fragments stacked edge to edge are merged first (side-by-side tiles not yet); monochrome (1-bit, CCITT fax, and eight-bit black-and-white grey and RGB, and indexed images that use only palette entries 0 and 1) averaged by area to 300 ppi above 450 ppi by default, a tie to the dark sample (black in grey, painted in a mask, ink in a separation, after the decode array; an indexed image's entry 0, its palette not read yet), then stored as Group 4 or 1-bit Flate (Monochrome Images ▸ Downsample to; doc_optimize mono); not monochrome images with a soft mask, halftones and dithers (most of their new pixels would cover both black and white, and their tones turn solid), or images wider than a fax row (1,048,576 samples), and Acrobat's bicubic and subsampling choices for monochrome aren't offered" [[feature]]