Files
pdfcraft/crates/ui-egui/tests/it/create.rs
T
Grzegorz MartensandClaude Opus 5.5 907a9fa917 M11: monochrome downsampling: black-and-white images above 450 ppi are averaged down to 300
The PDF Optimizer and Reduce File Size downsampled colour and grey images
but not black-and-white ones. Settings::mono is now MonoSettings {
downsample, target_ppi, above_ppi, compression }, 300 ppi above 450 ppi
and CCITT Group 4 by default (Monochrome Images ▸ Downsample to;
doc_optimize mono: { downsample, ppi, above_ppi, compression }).

A 1-bit image or image mask (no filter, general-purpose filters, or CCITT
fax after any of them) and eight-bit grey of only black and white are averaged by
area: each new pixel takes the sample covering more of it, a tie 0, so a
dark line half a new pixel wide stays. They are stored as Group 4, or as
1-bit Flate when that is smaller; with Retain a resampled image keeps its
kind of compression. Images with a soft mask keep their size.

CCITT decoding moves from Export ▸ Images into pdfcraft-filters
(decode_ccitt, CcittParams, through hayro-ccitt), and
pdfcraft_cos::Document::decoded_image reads a fax image's /DecodeParms
for it (undoing filters before CCITT, such as ASCII85Decode, with any
/DecodeParms given by reference made direct first), so export
and the optimizer share one reader, and Group 4 scans can be
downsampled. A 600 ppi text page went from 79 KB to 40 KB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z
2026-10-29 20:48:03 +01:00

383 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Create a PDF and Reduce File Size in the real shell.
use pdfcraft_ui_egui::PdfCraftApp;
fn png() -> Vec<u8> {
let mut out = Vec::new();
{
let mut enc = png::Encoder::new(&mut out, 8, 4);
enc.set_color(png::ColorType::Rgb);
enc.set_depth(png::BitDepth::Eight);
let mut w = enc.write_header().unwrap();
w.write_image_data(&[120u8; 8 * 4 * 3]).unwrap();
}
out
}
#[test]
fn image_import_dialog_chooses_dpi_and_cancels() {
use egui_kittest::{Harness, kittest::Queryable};
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.begin_image_import(vec![("scan.png".into(), png())]);
app
});
h.run_steps(3);
if let Ok(path) = std::env::var("PDFCRAFT_IMAGE_IMPORT_SHOT") {
let _gpu = crate::gpu();
h.render().unwrap().save(path).unwrap();
}
h.get_by_label("Use 72 DPI (one point per pixel)").click();
h.run_steps(1);
h.get_by_label("Create").click();
h.run_steps(3);
assert!(h.state().image_import.is_none());
let page = &h.state().session.docs()[0].info.pages[0];
assert_eq!((page.width, page.height), (8.0, 4.0));
h.state_mut().begin_image_import(vec![("scan.png".into(), png())]);
h.state_mut().image_import.as_mut().unwrap().dpi = 144.0;
h.run_steps(2);
h.get_by_label("Use custom DPI").click();
h.run_steps(1);
h.get_by_label("Create").click();
h.run_steps(3);
let page = &h.state().session.docs()[1].info.pages[0];
assert_eq!((page.width, page.height), (4.0, 2.0));
h.state_mut().begin_image_import(vec![("scan.png".into(), png())]);
h.run_steps(2);
h.get_by_label("Cancel").click();
h.run_steps(2);
assert!(h.state().image_import.is_none());
assert_eq!(h.state().session.docs().len(), 2);
}
#[test]
fn opening_images_and_text_converts_them_to_new_pdfs() {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("photo.png", Some("/tmp/photo.png".into()), png()).unwrap();
app.open_bytes("notes.txt", None, b"first line\nsecond line".to_vec()).unwrap();
app.create_from_images(vec![("a.png".into(), png()), ("b.png".into(), png())]);
assert!(app.execute("create.blank"));
let docs = app.session.docs();
let summary: Vec<(String, usize, bool)> = docs.iter().map(|d| (d.name.clone(), d.info.pages.len(), d.dirty)).collect();
assert_eq!(
summary,
[("photo.pdf".to_string(), 1, true), ("notes.pdf".into(), 1, true), ("Images.pdf".into(), 2, true), ("Untitled.pdf".into(), 1, true)]
);
assert!(docs[0].path.is_none(), "a converted file has no PDF path yet: Save asks where");
assert!(app.open_bytes("junk.png", None, b"\x89PNG\r\n\x1a\nnot really".to_vec()).is_err());
}
struct ReduceDir(std::path::PathBuf);
impl ReduceDir {
fn new() -> Self {
use std::sync::atomic::{AtomicU64, Ordering};
static NEXT: AtomicU64 = AtomicU64::new(0);
for _ in 0..128 {
let path = std::env::temp_dir().join(format!("pdfcraft-reduce-test-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
match std::fs::create_dir(&path) {
Ok(()) => return Self(path),
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(error) => panic!("creating Reduce test directory: {error}"),
}
}
panic!("no unused Reduce test directory after 128 attempts");
}
}
impl Drop for ReduceDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// Open `name` and run Reduce File Size on it, saving to `out` without a dialog.
fn reduce(name: &'static str, bytes: Vec<u8>, out: &std::path::Path) -> egui_kittest::Harness<'static, PdfCraftApp> {
let out2 = out.to_path_buf();
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
// Keep metadata identical: changing ModDate can make a compact PDF one byte smaller.
app.session = std::mem::take(&mut app.session).with_clock(|| 1_700_000_000);
app.open_bytes(name, None, bytes).unwrap();
app.save_override = Some(out2.to_string_lossy().into_owned());
app
});
h.run_steps(2);
assert!(h.state_mut().execute("optimize.reduce"));
// It runs on a worker thread like the PDF Optimizer, with the progress card meanwhile.
let start = std::time::Instant::now();
while h.state().optimize_run.is_some() && start.elapsed() < std::time::Duration::from_secs(30) {
h.run_steps(1);
std::thread::sleep(std::time::Duration::from_millis(10));
}
h.run_steps(2);
assert!(h.state().optimize_run.is_none() && h.state().progress_notice.is_none(), "the run finished");
h
}
#[test]
fn reduce_file_size_writes_a_compact_copy() {
use egui_kittest::kittest::Queryable;
// A photo-like picture, stored losslessly: Reduce recompresses it as JPEG.
let (w, h) = (600u32, 400u32);
let px: Vec<u8> = (0..w * h).flat_map(|i| [(i % w * 255 / w) as u8 ^ (i & 7) as u8, (i / w * 255 / h) as u8, 128]).collect();
let mut photo = Vec::new();
{
let mut enc = png::Encoder::new(&mut photo, w, h);
enc.set_color(png::ColorType::Rgb);
enc.set_depth(png::BitDepth::Eight);
enc.write_header().unwrap().write_image_data(&px).unwrap();
}
let dir = ReduceDir::new();
let out = dir.0.join("reduced.pdf");
let h = reduce("photo.png", photo, &out);
let bytes = std::fs::read(&out).unwrap();
assert!(bytes.starts_with(b"%PDF-"));
assert!(bytes.len() < h.state().session.docs()[0].bytes.len(), "the copy is smaller");
assert!(h.state().session.docs()[0].dirty, "the open document is unchanged");
h.get_by_label_contains("% smaller");
}
#[test]
fn reduce_file_size_saves_nothing_when_the_file_is_already_small() {
use egui_kittest::kittest::Queryable;
let dir = ReduceDir::new();
let out = dir.0.join("reduced.pdf");
// A new text PDF is already compact: a copy would be no smaller (#490).
let h = reduce("notes.txt", "lorem ipsum ".repeat(500).into_bytes(), &out);
assert!(!out.exists(), "no copy was written");
h.get_by_label_contains("This file is already as small as it can be made");
}
#[test]
fn clipboard_images_and_text_become_new_pdfs() {
use pdfcraft_ui_egui::Clip;
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.create_from_clip(Clip::Image { width: 40, height: 20, rgba: [10u8, 20, 30, 255].repeat(40 * 20) }).unwrap();
app.create_from_clip(Clip::Text("Pasted\nlines".into())).unwrap();
let docs = app.session.docs();
assert_eq!(docs.len(), 2);
assert_eq!(docs[0].name, "Clipboard.pdf");
// The image page takes the image's size (one pixel per point at 72 dpi).
let p = &docs[0].info.pages[0];
assert!((p.width / p.height - 2.0).abs() < 0.01, "{} × {}", p.width, p.height);
assert!(app.create_from_clip(Clip::Image { width: 4, height: 4, rgba: vec![0; 3] }).is_err(), "malformed images are refused");
}
#[test]
fn the_pdf_optimizer_dialog_saves_an_optimized_copy() {
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
let dir = std::env::temp_dir().join(format!("pdfcraft-optimizer-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let out = dir.join("optimized.pdf");
let out2 = out.clone();
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("notes.txt", None, "lorem ipsum ".repeat(500).into_bytes()).unwrap();
app.save_override = Some(out2.to_string_lossy().into_owned());
app
});
h.run_steps(4);
assert!(h.state_mut().execute("optimize.advanced"));
h.run_steps(2);
h.get_by_label("PDF Optimizer");
h.get_by_label("Color Images");
h.get_by_label("Monochrome Images");
assert_eq!(h.state().optimize_draft.settings.mono, pdfcraft_engine::optimize::MonoSettings::REDUCE);
// Monochrome Images ▸ Downsample to can be turned off.
h.get_by_label("Downsample to").click();
h.run_steps(2);
assert!(!h.state().optimize_draft.settings.mono.downsample);
h.get_by_label("Discard Objects").click();
h.run_steps(2);
h.get_by_label("Discard document tags").click();
h.get_by_label("Discard User Data").click();
h.run_steps(2);
h.get_by_label("Discard document information and metadata").click();
h.run_steps(1);
{
let d = &h.state().optimize_draft;
assert!(d.settings.discard_tags && d.discard == vec![pdfcraft_engine::Hidden::Metadata]);
}
h.get_by_label("OK").click();
// The click is handled next frame. The optimization then runs on a worker thread and the
// copy is saved when it is done.
h.run_steps(1);
let start = std::time::Instant::now();
while (h.state().optimize_run.is_some() || !out.exists()) && start.elapsed() < std::time::Duration::from_secs(30) {
h.run_steps(1);
std::thread::sleep(std::time::Duration::from_millis(10));
}
h.run_steps(2);
assert!(h.state().progress_notice.is_none(), "the progress card is gone");
assert!(std::fs::read(&out).unwrap().starts_with(b"%PDF-"));
assert_eq!(h.state().dialog, None);
}
#[test]
fn the_pdf_optimizer_dialog_picks_a_preset() {
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use pdfcraft_engine::optimize::Preset;
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("notes.txt", None, b"lorem ipsum".to_vec()).unwrap();
app
});
h.run_steps(4);
assert!(h.state_mut().execute("optimize.advanced"));
h.run_steps(2);
assert_eq!(Preset::of(&h.state().optimize_draft.settings), Some(Preset::Standard));
h.get_by_label("Merge image fragments");
h.get_by_value("Standard").click();
h.run_steps(4);
h.get_by_label("Mobile").click();
h.run_steps(2);
assert_eq!(h.state().optimize_draft.settings, Preset::Mobile.settings(), "Mobile chosen");
// Changing an option makes the settings custom.
h.get_by_label("Merge image fragments").click();
h.run_steps(2);
assert!(!h.state().optimize_draft.settings.merge_fragments);
h.get_by_value("Custom");
// Pick a target PDF version and toggle structure compression on Clean Up.
h.get_by_value("Retain existing").click();
h.run_steps(4);
h.get_by_label("PDF 2.0").click();
h.run_steps(2);
assert_eq!(h.state().optimize_draft.settings.compatibility, pdfcraft_engine::optimize::Compatibility::Pdf2_0);
h.get_by_label("Clean Up").click();
h.run_steps(2);
assert!(h.state().optimize_draft.settings.object_streams);
h.get_by_label("Compress document structure (object streams)").click();
h.run_steps(2);
assert!(!h.state().optimize_draft.settings.object_streams);
}
fn mixed_files(app: &PdfCraftApp) -> Vec<(String, Vec<u8>)> {
let pdf = app.session.create_from_text("a", "from a pdf").unwrap();
vec![
("notes.txt".into(), b"from text".to_vec()),
("report.docx".into(), b"PK\x03\x04".to_vec()),
("a.pdf".into(), pdf.to_vec()),
("photo.png".into(), png()),
]
}
#[test]
fn separate_batch_creates_one_pdf_per_source_without_replacing_existing_files() {
use pdfcraft_ui_egui::FilePurpose;
let dir = std::env::temp_dir().join(format!("pdfcraft-separate-batch-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("notes.pdf"), b"keep this file").unwrap();
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.export_dir_override = Some(dir.to_string_lossy().into_owned());
let mut files = mixed_files(&app);
files.push(("notes.txt".into(), b"another text file".to_vec()));
app.use_files(FilePurpose::CreateMultipleSeparate, files);
assert_eq!(std::fs::read(dir.join("notes.pdf")).unwrap(), b"keep this file");
for name in ["notes (2).pdf", "notes (3).pdf", "photo.pdf"] {
assert!(std::fs::read(dir.join(name)).unwrap().starts_with(b"%PDF-"), "{name}");
}
assert!(!dir.join("a (2).pdf").exists(), "an already-PDF input is skipped");
assert!(!dir.join("report.pdf").exists(), "an invalid Office file is skipped");
assert!(app.views.is_empty(), "batch conversion must not open PDF tabs");
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn separate_batch_command_picks_files_and_writes_to_the_chosen_folder() {
use egui_kittest::Harness;
let dir = std::env::temp_dir().join(format!("pdfcraft-separate-command-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let source = dir.join("letter.txt");
std::fs::write(&source, b"A separate PDF").unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1000.0, 720.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.pick_override = Some(vec![source.to_string_lossy().into_owned()]);
app.export_dir_override = Some(dir.to_string_lossy().into_owned());
app
});
assert!(h.state_mut().execute("create.multiple_separate"));
h.run_steps(2);
assert!(std::fs::read(dir.join("letter.pdf")).unwrap().starts_with(b"%PDF-"));
assert!(h.state().views.is_empty());
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn multiple_files_open_as_one_document_in_the_page_grid() {
use egui_kittest::{Harness, kittest::Queryable};
let path = std::env::temp_dir().join(format!("pdfcraft-create-multiple-{}.pdf", std::process::id()));
let mut h = Harness::builder().with_size(egui::vec2(1000.0, 720.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
let files = mixed_files(&app);
app.use_files(pdfcraft_ui_egui::FilePurpose::CreateMultiple, files);
app
});
h.run_steps(4);
{
let app = h.state();
assert_eq!(app.views.len(), 1);
assert!(app.views[0].organize, "the pages are shown as a grid");
let doc = app.session.get(app.views[0].id).unwrap();
assert_eq!(doc.name, "Combined.pdf");
assert!(doc.dirty && doc.path.is_none(), "unsaved until the user saves it");
assert_eq!(doc.info.pages.len(), 3, "the Word file can't be converted and is left out");
assert_eq!(doc.info.outline.iter().map(|o| o.title.as_str()).collect::<Vec<_>>(), ["notes", "a", "photo"]);
}
h.get_by_label_contains("left out: report.docx");
h.get_by_label("Insert a file before page 2");
if let Ok(path) = std::env::var("PDFCRAFT_CREATE_MULTIPLE_SHOT") {
let _gpu = crate::gpu();
h.run_steps(20);
h.render().unwrap().save(path).unwrap();
}
// Remove the middle page and save what is left.
h.get_by_label("Page 2").click();
h.run_steps(2);
h.get_by_label("Delete pages (Delete)").click();
h.run_steps(3);
h.state_mut().save_override = Some(path.to_string_lossy().into_owned());
h.get_by_label("Save pages").click();
h.run_steps(4);
let app = h.state();
let doc = app.session.get(app.views[0].id).unwrap();
assert!(!doc.dirty && doc.info.pages.len() == 2);
assert!(std::fs::read(&path).unwrap().starts_with(b"%PDF-"));
}
#[test]
fn multiple_files_that_cannot_be_converted_open_nothing() {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.use_files(pdfcraft_ui_egui::FilePurpose::CreateMultiple, vec![("report.docx".into(), b"PK\x03\x04".to_vec())]);
assert!(app.views.is_empty());
assert!(app.toast.is_some());
}
#[test]
fn multiple_files_past_a_thousand_are_all_used() {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
let pdf = app.session.create_from_text("a", "from a pdf").unwrap();
let files: Vec<(String, Vec<u8>)> = (0..1001).map(|i| (format!("f{i}.pdf"), pdf.to_vec())).collect();
app.use_files(pdfcraft_ui_egui::FilePurpose::CreateMultiple, files);
assert_eq!(app.views.len(), 1);
let doc = app.session.get(app.views[0].id).unwrap();
assert_eq!(doc.info.pages.len(), 1001, "every picked file is used, in order");
}