diff --git a/ROADMAP.md b/ROADMAP.md index 732904c6..c84ec79d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -99,7 +99,7 @@ Hours are for a single agent (low–high). "Done" is the estimated fraction of t | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 17% | 210–420 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | M8 | Security + redaction | 100–180 | 66% | 35–65 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize. Missing: certificate security, redaction codes and pattern locales, DCT re-encoding | | M9 | Signatures (PAdES, validation) | 160–280 | 55% | 70–125 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL. Done since: macOS Keychain signing, certificate viewer, signed documents protected from rewrites. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, Windows store, PKCS #11 | -| M10 | OCR, create, export, print | 200–350 | 33% | 130–235 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, CUPS spooler, print-ready PDF). Done since: export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print. Missing: OCR languages beyond Latin, editable-text OCR output, Office export/import, Windows/web printing | +| M10 | OCR, create, export, print | 200–350 | 33% | 130–235 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, CUPS spooler, print-ready PDF). Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print. Missing: OCR languages beyond Latin, editable-text OCR output, Office export/import, Windows/web printing | | M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 165–290 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA | | M12 | Accessibility, compare, measure, search, XFA | 200–380 | 20% | 160–305 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Missing: autotag, Tags/Order/Content panels, Reading Order tool, alt-text workflow, compare, measure, search index, XFA | | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 33–66 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools`, UI control channel with drag. Missing: Action Wizard, AI providers | @@ -127,6 +127,8 @@ M0 → M1 → M2 → M3 → M4 must happen in order. After M4, M5–M12 can run Newest first. One line per session: the date, what moved, and the new overall percentage. +- **2026-10-07 (community, #190):** Create PDF from Images offers embedded resolution, 72 DPI, or custom DPI (1–1200) without resampling. The engine and doc_create share the choice; Windows image context menus open the chooser through --create-images. Overall estimate unchanged (about 30–35%). + - **2026-10-08 (community):** Cut and stack in Print > Multiple: single-sided n-up sheets can be cut into cell piles and restacked in selected page order, with aligned gutter marks and blanks kept in their cells. Available through doc_print; duplex is refused. Grid-size overflow returns an error. No change to the parity count or the overall estimate (about 30–35%). - **2026-10-07 (community, #143):** Windows MSI gets a full-UI welcome dialog with a "Create a desktop shortcut" checkbox (on by default; `INSTALLDESKTOPSHORTCUT=0` for unattended installs), progress, files in use, repair/remove, and a "Setup completed successfully" screen with Finish (plus distinct failed/cancelled screens). Start Menu and desktop shortcuts are now plain links to `pdfcraft.exe` instead of advertised MSI shortcuts. Packaging checks the compiled MSI's shortcut, checkbox and dialog tables; the ARM64 smoke test checks both links' targets, their removal on uninstall, and the opt-out. Installer polish only; feature counts unchanged. ≈ 30–35%. - **2026-10-07 (localization):** The interface translates through the same system as PhotoCraft: one TSV catalog per language (`crates/ui-egui/src/i18n/ja.tsv`), the `tl!` macro, entries keyed by command id or with plural forms, and catalog tests (format, duplicates, placeholders, command ids). Catalogs are validated strictly (unknown escapes, reserved contexts, placeholders, ellipses, plural forms), a bad line is skipped and logged at run time, `fmt` substitutes in one pass, the drawing language is per thread, and each `.tsv` is an attributed asset (`kind = "translation"`, checked by `cargo xtask assets`); validation, attribution and the language-switch control test folded in from #175. The 49 existing Japanese strings moved over unchanged, plus `Auto`. The `language` preference now defaults to `auto` (follow the system language; English when it has no catalog); `en`/`ja` still persist, and unknown values are rejected. Not covered: dialogs and panels, Windows system-language detection. ≈ 30–35% (unchanged). diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs index 585cbf54..4105c4ae 100644 --- a/apps/pdfcraft/src/main.rs +++ b/apps/pdfcraft/src/main.rs @@ -1,6 +1,7 @@ //! PdfCraft desktop app. //! //! Usage: `pdfcraft [options] [files…]` +//! `--create-images [images…]` stages the images in one PDF and asks for the page DPI. //! //! View options (applied after the files open; also the seed of the UI control channel): //! `--page N --zoom 150 --layout continuous|two-up|single --panel comments|bookmarks|pages|fields|layers|attachments|none @@ -73,6 +74,7 @@ fn main() -> eframe::Result { let mut files = Vec::new(); let mut options: Vec<(String, String)> = Vec::new(); let mut control_file: Option = None; + let mut create_images = false; let mut args = std::env::args().skip(1); while let Some(a) = args.next() { match a.as_str() { @@ -81,6 +83,7 @@ fn main() -> eframe::Result { return Ok(()); } "--control" => control_file = args.next(), + "--create-images" => create_images = true, flag if flag.starts_with("--") => { let value = args.next().unwrap_or_default(); options.push((flag.trim_start_matches("--").to_string(), value)); @@ -141,8 +144,14 @@ fn main() -> eframe::Result { if let Some(dir) = pdfcraft_ui_egui::RecoveryStore::default_dir() { app.enable_recovery(pdfcraft_ui_egui::RecoveryStore::new(dir)); } - for f in files { - app.open_path(&f); + if create_images { + if let Err(e) = app.begin_image_import_paths(&files) { + app.notify(e); + } + } else { + for f in files { + app.open_path(&f); + } } for (k, v) in options { if let Err(e) = app.set_option(&k, &v) { diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 776f9238..48c8560a 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -961,6 +961,7 @@ impl Automation { ("Untitled.pdf".to_string(), self.session.create_blank(w, h, n).map_err(failed)?) } "images" => { + let resolution = a.opt_num("dpi")?.map_or(pdfcraft_engine::ImageResolution::Embedded, pdfcraft_engine::ImageResolution::Dpi); let mut images = Vec::new(); for p in a.strs("paths")? { let path = self.resolve(p, false)?; @@ -972,7 +973,7 @@ impl Automation { } else { "Images.pdf".into() }; - (name, self.session.create_from_images(&images).map_err(failed)?) + (name, self.session.create_from_images_with_resolution(&images, resolution).map_err(failed)?) } "text" => { let (title, text) = match (a.opt_str("text")?, a.opt_str("path")?) { diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 94670c95..c4c5516c 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -885,6 +885,7 @@ pub fn tools() -> Vec { json!({ "from": { "type": "string", "enum": ["blank", "images", "text"] }, "paths": { "type": "array", "items": { "type": "string" }, "minItems": 1 }, + "dpi": { "type": "number", "minimum": 1, "maximum": 1200, "description": "For images: override the embedded resolution without resampling. 72 gives one point per pixel; omit to use each image's resolution (72 when absent)." }, "text": { "type": "string" }, "path": { "type": "string" }, "pages": { "type": "integer", "minimum": 1, "maximum": 10000 }, diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 018de77e..cccaafc1 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -1041,6 +1041,39 @@ fn creating_and_reducing_through_tools() { assert!(matches!(a.call("doc_create", &json!({ "from": "images", "paths": ["notes.txt"] })), Err(ToolError::Failed(_)))); } +#[test] +fn creating_images_with_dpi_through_tools() { + let dir = workdir("image-dpi"); + let mut png = Vec::new(); + { + let mut enc = png::Encoder::new(&mut png, 300, 150); + enc.set_color(png::ColorType::Rgb); + enc.set_depth(png::BitDepth::Eight); + enc.set_pixel_dims(Some(png::PixelDimensions { xppu: 11811, yppu: 5906, unit: png::Unit::Meter })); + enc.write_header().unwrap().write_image_data(&vec![100; 300 * 150 * 3]).unwrap(); + } + std::fs::write(dir.join("scan.png"), png).unwrap(); + let mut a = auto(&dir); + for (dpi, width, height) in [(None, 72.0, 72.0), (Some(72.0), 300.0, 150.0), (Some(300.0), 72.0, 36.0)] { + let mut args = json!({ "from": "images", "paths": ["scan.png"] }); + if let Some(dpi) = dpi { + args["dpi"] = json!(dpi); + } + let doc = ok(&mut a, "doc_create", args)["doc"].as_u64().unwrap(); + let info = ok(&mut a, "doc_info", json!({ "doc": doc })); + assert!((info["pages"][0]["width"].as_f64().unwrap() - width).abs() < 0.02); + assert!((info["pages"][0]["height"].as_f64().unwrap() - height).abs() < 0.02); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "made.pdf" })); + let reopened = ok(&mut a, "doc_open", json!({ "path": "made.pdf" }))["doc"].as_u64().unwrap(); + let render = a.call("page_render", &json!({ "doc": reopened, "page": 1, "dpi": 72 })).unwrap(); + let Content::Png { width: w, height: h, .. } = &render[0] else { panic!("expected PNG") }; + assert!((*w as f64 - width).abs() <= 1.0 && (*h as f64 - height).abs() <= 1.0); + } + for dpi in [0.0, -72.0, 1201.0] { + assert!(a.call("doc_create", &json!({ "from": "images", "paths": ["scan.png"], "dpi": dpi })).is_err()); + } +} + #[test] fn flattening_through_tools() { let dir = workdir("flatten"); diff --git a/crates/create/README.md b/crates/create/README.md index 01f0be61..22e0355d 100644 --- a/crates/create/README.md +++ b/crates/create/README.md @@ -7,6 +7,10 @@ Layer L4. Create a PDF (Acrobat's Create a PDF tool), execution plan M10.2: (PNG `pHYs`, JPEG JFIF density; 72 dpi when absent). JPEG data is embedded as is (`/DCTDecode`, grey, RGB or Adobe-inverted CMYK); PNG is decoded and stored with Flate, with transparency as a soft mask; +- `from_images_with_resolution(images, ImageResolution::Dpi(dpi))`: override the page + resolution (1–1200 dpi), without resampling or recompressing image pixels. Use 72 dpi + for one point per pixel, or `ImageResolution::Embedded` for the same behavior as + `from_images`. The largest page side is still capped at 14,400 points; - `from_text(text, …)`: plain text set in Helvetica, wrapped and paginated. Every function returns a `pdfcraft_cos::Document`; the caller writes it. diff --git a/crates/create/src/lib.rs b/crates/create/src/lib.rs index 3c499ea5..267e7af2 100644 --- a/crates/create/src/lib.rs +++ b/crates/create/src/lib.rs @@ -411,14 +411,38 @@ pub fn image_xobject(doc: &mut Document, name: &str, bytes: &[u8]) -> Result<(Ob Ok((doc.add(Object::Stream(stream)), size)) } +/// Resolution used to size PDF pages. Image pixels are never resampled. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub enum ImageResolution { + /// Embedded resolution, falling back to 72 dpi when absent. + #[default] + Embedded, + /// Override both axes with a finite resolution between 1 and 1200 dpi. + Dpi(f64), +} + /// One page per image, each the size of its image at the image's resolution. pub fn from_images(images: &[(String, Vec)]) -> Result { + from_images_with_resolution(images, ImageResolution::Embedded) +} + +/// Create image pages with embedded resolution or a fixed dpi (72 gives one point per pixel). +pub fn from_images_with_resolution(images: &[(String, Vec)], resolution: ImageResolution) -> Result { + if let ImageResolution::Dpi(dpi) = resolution + && (!dpi.is_finite() || !(1.0..=1200.0).contains(&dpi)) + { + return Err(CreateError::Invalid("image DPI must be finite and between 1 and 1200".into())); + } if images.is_empty() { return Err(CreateError::Invalid("no images".into())); } let mut doc = Document::new_empty(); for img in images.iter().map(|(name, bytes)| embed(name, bytes)).collect::, _>>()?.into_iter().flatten() { - let (mut w, mut h) = (img.px.0 as f64 * 72.0 / img.dpi.0, img.px.1 as f64 * 72.0 / img.dpi.1); + let dpi = match resolution { + ImageResolution::Embedded => img.dpi, + ImageResolution::Dpi(dpi) => (dpi, dpi), + }; + let (mut w, mut h) = (img.px.0 as f64 * 72.0 / dpi.0, img.px.1 as f64 * 72.0 / dpi.1); // Keep huge images within the largest page PDF allows. let k = (MAX_SIDE / w.max(h)).min(1.0); w *= k; diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index 8b215168..f4dd33b9 100644 --- a/crates/create/src/tests.rs +++ b/crates/create/src/tests.rs @@ -83,6 +83,32 @@ fn images_become_pages_at_their_resolution() { assert!(matches!(from_images(&[("x.gif".into(), b"GIF89a".to_vec())]), Err(CreateError::Image(..)))); } +#[test] +fn image_resolution_override_changes_size_without_resampling() { + let images = [("photo.png".into(), png_bytes(true)), ("scan.jpg".into(), jpeg_bytes())]; + for (dpi, sizes) in [(72.0, [(4.0, 2.0), (3.0, 2.0)]), (300.0, [(0.96, 0.48), (0.72, 0.48)])] { + let doc = reopen(&from_images_with_resolution(&images, ImageResolution::Dpi(dpi)).unwrap()); + for (page, size) in pages(&doc).iter().zip(sizes) { + let m = media(page); + assert!((m[2] - size.0).abs() < 0.001 && (m[3] - size.1).abs() < 0.001); + let res = doc.resolve(page.get(b"Resources").unwrap()); + let xo = doc.resolve(res.as_dict().unwrap().get(b"XObject").unwrap()); + let img = doc.resolve(xo.as_dict().unwrap().get(b"Im0").unwrap()); + let Object::Stream(stream) = &*img else { panic!("expected an image stream") }; + if stream.dict.name(b"Filter") == Some(&b"DCTDecode"[..]) { + assert_eq!(*stream.raw, jpeg_bytes()); + } else { + assert_eq!(stream.dict.int(b"Width"), Some(4)); + assert_eq!(stream.dict.int(b"Height"), Some(2)); + assert!(stream.dict.contains(b"SMask")); + } + } + } + for dpi in [0.0, -1.0, f64::NAN, f64::INFINITY, 1201.0] { + assert!(matches!(from_images_with_resolution(&images, ImageResolution::Dpi(dpi)), Err(CreateError::Invalid(_)))); + } +} + #[test] fn text_is_wrapped_and_paginated() { let long: String = (0..200).map(|i| format!("Line {i} of a plain text file\n")).collect(); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 1fedc040..386d044d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -28,6 +28,7 @@ pub use pdfcraft_organize::LabelStyle; pub use pdfcraft_organize::view::{InitialView, Layout as InitialLayout, Magnification, Navigation}; pub use pdfcraft_cos::Algorithm; +pub use pdfcraft_create::ImageResolution; pub use pdfcraft_edit::{ Added, AddedImage, AddedText, Align as TextAlign, Background, Content as AddedContent, Family as FontFamily, HeaderFooter, MarkKind, Watermark, }; @@ -2078,6 +2079,11 @@ impl Session { self.write_new(&pdfcraft_create::from_images(images)?) } + /// Create image pages at embedded resolution or a fixed dpi, without resampling. + pub fn create_from_images_with_resolution(&self, images: &[(String, Vec)], resolution: ImageResolution) -> Result>, EditError> { + self.write_new(&pdfcraft_create::from_images_with_resolution(images, resolution)?) + } + /// A new document from plain text (US Letter, 11 pt Helvetica). pub fn create_from_text(&self, title: &str, text: &str) -> Result>, EditError> { self.write_new(&pdfcraft_create::from_text(title, text, pdfcraft_create::LETTER, 11.0)?) diff --git a/crates/ui-egui/src/create_ui.rs b/crates/ui-egui/src/create_ui.rs index 1b82f79e..2ea75cb8 100644 --- a/crates/ui-egui/src/create_ui.rs +++ b/crates/ui-egui/src/create_ui.rs @@ -5,6 +5,41 @@ use std::sync::Arc; use crate::PdfCraftApp; +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum ResolutionChoice { + #[default] + Image, + Default72, + Custom, +} + +pub struct ImageImport { + pub images: Vec<(String, Vec)>, + pub choice: ResolutionChoice, + pub dpi: f64, +} + +pub(crate) fn image_import_body(ui: &mut egui::Ui, app: &mut PdfCraftApp) -> (bool, bool) { + ui.heading(tl!("Create PDF from images")); + let Some(draft) = app.image_import.as_mut() else { return (false, true) }; + ui.label(crate::i18n::fmt(tl!("Selected image files: {count}"), &[("count", &draft.images.len().to_string())])); + ui.add_space(8.0); + ui.radio_value(&mut draft.choice, ResolutionChoice::Image, tl!("Use image resolution")); + ui.label(tl!("Use each image's embedded DPI; use 72 DPI when it is absent.")); + ui.radio_value(&mut draft.choice, ResolutionChoice::Default72, tl!("Use 72 DPI (one point per pixel)")); + ui.radio_value(&mut draft.choice, ResolutionChoice::Custom, tl!("Use custom DPI")); + ui.add_enabled(draft.choice == ResolutionChoice::Custom, egui::DragValue::new(&mut draft.dpi).range(1.0..=1200.0).suffix(" DPI")); + ui.label(tl!("DPI sets the printed page size without resampling the image.")); + ui.add_space(12.0); + let mut go = false; + let mut cancel = false; + ui.horizontal(|ui| { + go = crate::widgets::pill_button(ui, tl!("Create"), true).clicked(); + cancel = crate::widgets::pill_button(ui, tl!("Cancel"), false).clicked(); + }); + (go, cancel) +} + /// File types Open accepts besides PDF (converted on open). pub const CONVERTIBLE: [&str; 12] = ["png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx", "txt", "text"]; @@ -148,7 +183,7 @@ impl PdfCraftApp { } } } - self.create_from_images(images); + self.begin_image_import(images); } #[cfg(target_arch = "wasm32")] self.notify_tr("On the web, open or drop an image to convert it"); @@ -156,11 +191,52 @@ impl PdfCraftApp { /// One new document from images (tests and automation call this directly). pub fn create_from_images(&mut self, images: Vec<(String, Vec)>) { + self.create_from_images_with_resolution(images, pdfcraft_engine::ImageResolution::Embedded); + } + + /// Stage selected images for the DPI chooser; no document is created until confirmed. + #[cfg(not(target_arch = "wasm32"))] + pub fn begin_image_import_paths(&mut self, paths: &[String]) -> Result<(), String> { + let images = paths + .iter() + .map(|p| { + let path = std::path::Path::new(p); + let bytes = std::fs::read(path).map_err(|e| format!("Couldn't read {}: {e}", path.display()))?; + let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + Ok((name, bytes)) + }) + .collect::, String>>()?; + self.begin_image_import(images); + Ok(()) + } + + /// Stage selected images for the DPI chooser; no document is created until confirmed. + pub fn begin_image_import(&mut self, images: Vec<(String, Vec)>) { if images.is_empty() { return; } - let name = if images.len() == 1 { format!("{}.pdf", stem(&images[0].0)) } else { "Images.pdf".to_string() }; - let created = self.session.create_from_images(&images).map_err(|e| e.to_string()); + self.image_import = Some(ImageImport { images, choice: ResolutionChoice::Image, dpi: 300.0 }); + self.dialog = Some(crate::Dialog::CreateImages); + } + + pub(crate) fn finish_image_import(&mut self) { + let Some(draft) = self.image_import.take() else { return }; + let resolution = match draft.choice { + ResolutionChoice::Image => pdfcraft_engine::ImageResolution::Embedded, + ResolutionChoice::Default72 => pdfcraft_engine::ImageResolution::Dpi(72.0), + ResolutionChoice::Custom => pdfcraft_engine::ImageResolution::Dpi(draft.dpi), + }; + self.create_from_images_with_resolution(draft.images, resolution); + } + + /// Create directly at the requested resolution (also used by UI tests). + pub fn create_from_images_with_resolution(&mut self, images: Vec<(String, Vec)>, resolution: pdfcraft_engine::ImageResolution) { + if images.is_empty() { + return; + } + let name = + if let Some((name, _)) = images.first().filter(|_| images.len() == 1) { format!("{}.pdf", stem(name)) } else { "Images.pdf".to_string() }; + let created = self.session.create_from_images_with_resolution(&images, resolution).map_err(|e| e.to_string()); if let Err(e) = self.open_created_bytes(&name, created) { self.notify_fmt("Couldn't create a PDF: {e}", &[("e", &e.to_string())]); } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 83d0dc10..e4497623 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -55,6 +55,7 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { let mut ocr_now = false; let mut compare_now = false; let mut combine_now = false; + let mut images_now = false; let mut stamp_now = false; let mut alt_now = false; let t = Tokens::get(ctx); @@ -76,6 +77,12 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { w.inactive.bg_fill = t.hover; w.hovered.bg_stroke = egui::Stroke::new(1.0, t.text_muted); match dialog { + Dialog::CreateImages => { + let (go, cancel) = crate::create_ui::image_import_body(ui, app); + images_now = go; + close = go || cancel; + return; + } Dialog::Properties(tab) => { ui.label(egui::RichText::new(tl!("Document Properties")).font(theme::semibold(18.0))); ui.add_space(8.0); @@ -1257,6 +1264,11 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { if combine_now { app.combine_staged(); } + if images_now { + app.finish_image_import(); + } else if dialog == Dialog::CreateImages && app.dialog != Some(Dialog::CreateImages) { + app.image_import = None; + } if ocr_now { app.start_ocr(); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index daa6fcdd..8b760293 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -172,6 +172,7 @@ pub enum QuickTool { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Dialog { + CreateImages, Properties(PropsTab), About, Shortcuts, @@ -407,6 +408,8 @@ pub struct PdfCraftApp { pub space_audit: Vec, /// Combine files: the files staged so far. pub combine_draft: Vec, + /// Images waiting for the resolution choice (released on cancel). + pub image_import: Option, /// The custom stamp library, and the stamp being created. pub custom_stamps: Vec, pub stamp_draft: stamps_ui::StampDraft, @@ -595,6 +598,7 @@ impl PdfCraftApp { cert_viewer: None, space_audit: Vec::new(), combine_draft: Vec::new(), + image_import: None, custom_stamps: Vec::new(), stamp_draft: Default::default(), optimize_draft: OptimizeDraft::default(), diff --git a/crates/ui-egui/tests/create.rs b/crates/ui-egui/tests/create.rs index ec0ca664..11d39352 100644 --- a/crates/ui-egui/tests/create.rs +++ b/crates/ui-egui/tests/create.rs @@ -14,6 +14,43 @@ fn png() -> Vec { 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") { + 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(); diff --git a/packaging/windows/pdfcraft.wxs b/packaging/windows/pdfcraft.wxs index 4396e86c..2cec443a 100644 --- a/packaging/windows/pdfcraft.wxs +++ b/packaging/windows/pdfcraft.wxs @@ -74,6 +74,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +