test: qualify conservative tone retention and format review regressions

This commit is contained in:
FredApps
2026-10-30 14:55:44 +01:00
parent d7f3e67141
commit 4791cacd2c
5 changed files with 45 additions and 44 deletions
+24 -2
View File
@@ -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<i64>, Option<i64>)> {
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<u8> {
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()
}
+4 -7
View File
@@ -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);
+16 -31
View File
@@ -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::<Vec<u8>>(), "the same picture, ties black");
assert_eq!(doc.decoded_image(&s).unwrap(), text.iter().map(|b| !b).collect::<Vec<u8>>(), "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] {
-3
View File
@@ -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
+1 -1
View File
@@ -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]]