mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-31 13:37:35 -06:00
test: qualify conservative tone retention and format review regressions
This commit is contained in:
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]]
|
||||
|
||||
Reference in New Issue
Block a user