Files
pdfcraft/crates/ui-egui/tests/it/create.rs
T
Livio OswaldandClaude Opus 5.5 2242eb322f M10: Create ▸ Blank page uses the system region's paper (#5644) (#5651)
* M10: Create ▸ Blank page uses the system region's paper (#5644)

A new blank document was always US Letter. It now takes the paper of the
system region: Windows' own Region paper size (iPaperSize) first, then the
system locale on macOS or LC_ALL/LC_PAPER/LANG, A4 everywhere outside CLDR's
US-Letter regions, and Letter when the region can't be told (web).
The region parsing is shared with the Units preference.

* M10: the first locale variable set decides the paper; extensions are not regions (#5644)

LC_ALL, LC_PAPER and LANG are read in POSIX order and the first one set
decides, so LC_ALL=C no longer falls through to LANG's region; and a locale
tag's region is read from its base tag only, so en-US-u-nu-latn is US, not
NU (CodeRabbit on #5651).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GbuSKAS2k5248LGFEapMUR

* M10: the blank page test covers A4 as well as Letter (#5644)

The app keeps the paper Create ▸ Blank page makes (blank_paper, the system
region's at start), so the test makes a blank page on A4 and on Letter
whatever the host's region (CodeRabbit on #5651).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GbuSKAS2k5248LGFEapMUR

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-01 15:04:52 -05:00

404 lines
17 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");
}
/// Create ▸ Blank page uses the system region's paper, not always US Letter (#5644): the app
/// starts with the system's, and a blank page is on A4 and on Letter as the paper says
/// (CodeRabbit on #5651: a Letter host alone would not tell a hard-coded Letter apart).
#[test]
fn a_blank_page_is_on_the_system_regions_paper() {
use pdfcraft_ui_egui::paper::Paper;
assert_eq!(PdfCraftApp::new().blank_paper, Paper::system());
for paper in [Paper::A4, Paper::Letter] {
let mut app = PdfCraftApp::new();
app.blank_paper = paper;
assert!(app.execute("create.blank"));
let docs = app.session.docs();
let crop = docs.last().and_then(|d| d.info.pages.first()).map(|p| p.crop).unwrap();
let (w, h) = paper.points();
assert!(
((crop[2] - crop[0]) as f64 - w).abs() < 0.01 && ((crop[3] - crop[1]) as f64 - h).abs() < 0.01,
"{paper:?}: {crop:?} is not {w} × {h}"
);
}
}