diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 4cc867d72a..930a44498d 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -19,7 +19,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "e14c411ac69fd96279de51aaaa9589b8810e2970c3c212585bf47104f7c2cbd7" +sha256 = "b2f1f0417e41ab934c7b979b2fd2b3b69cab52a193d035122a58a4d00f2c0566" [[asset]] path = "crates/ui-egui/src/i18n/zh-hant.tsv" @@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "27715eb6c29938938a39880f5ab6f3b0aa60ba6d0940609af1bfb96f1fd124de" +sha256 = "448db933a78de5e51dfae62d9a34293da0ead418ecafeec94bf08c50510f41d8" [[asset]] path = "crates/ui-egui/src/i18n/cs.tsv" @@ -41,7 +41,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "3bc4e3c1d1a50c50d6be907f62e1fa2fd85ea719f1b65af21eeadcbbc7db5e44" +sha256 = "6accf0a44ae93cab6fde939458c0dd651687ce36760270bd3646b94668db1cda" [[asset]] path = "crates/ui-egui/src/i18n/pt-br.tsv" @@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "3a46800299b34558642676d0d67ff31619d8e9bbeb2ba83e713a108f544f2d82" +sha256 = "56159c856872ae5a6a521b02cf6dc682702ece1dff27a8cee0bd5fad24d3e627" [[asset]] path = "crates/ui-egui/src/i18n/es.tsv" @@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "dad2013f25795cafc93d89c9ff4c0cc510ba844628b27469fe9b38c2fce5e0ea" +sha256 = "e275641e7a20916219762730e18160edba7c4d0ac7fd963338ee1fb95ec1c355" [[asset]] path = "crates/ui-egui/src/i18n/fr.tsv" @@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "c41079a024a360951e0f935b14b6d18a16c8478daac21bc7a1c57eb73b6b3590" +sha256 = "8ef5004a37a5998f789d62d708a7d1973ea5b0e3a606319449597d2908423853" [[asset]] path = "crates/ui-egui/src/i18n/te.tsv" @@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "1c8ce042942ac9169458e79261fc1d0647d4a3734e548799bf19b98eb314b9a2" +sha256 = "ac7789cda9148a2b75c4e5156de9bdd6ddbcc5b6405f25abf06fcf8d861c6853" [[asset]] path = "crates/ui-egui/src/i18n/ja.tsv" @@ -96,7 +96,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "f42c77a513e83f4d48e1792bcba8dfe60b7453daf1b240d0a7ffb6d276ce98e3" +sha256 = "69dc55fca4e7c03b7442e86ab0274b2840e8185f5ad1382a1d54929fb6b8924c" [[asset]] path = "assets/icons/accessibility.svg" diff --git a/Cargo.lock b/Cargo.lock index 6c6e75c26c..d34be389fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3793,6 +3793,9 @@ dependencies = [ "image", "js-sys", "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", "pdfcraft-cos", "pdfcraft-engine", "pdfcraft-fonts", diff --git a/ROADMAP.md b/ROADMAP.md index be04ea2f83..99dcfad5e6 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 and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, 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: 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 | +| 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; Create PDF from multiple files (PDFs, images and text, edited as pages in the grid). 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 | 23% | 155–295 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export. Missing: autotag, Tags/Order/Content panels, Reading Order tool, alt-text workflow, compare, geospatial/3D measurement, 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` (closed stdout pipes exit cleanly), 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 - **2026-10-08 (M5.7, signature resize proportions):** Image signature corner handles preserve the embedded image's original aspect ratio and anchor the opposite corner; edge midpoint handles stretch width or height independently. The live preview and committed edit share the constrained geometry, including page/view rotation and reopened PDFs whose rectangles were stretched previously. Synthetic UI tests cover all corners, edge resizing, undo and redo. Milestone and effort estimates unchanged. - **2026-10-08 (M5.7, live signature adjustment):** Embedded image signatures resize and move with every pointer frame. Movement hides the frame and handles, which return on release; Escape cancels without committing. A cached background preserves the page beneath the image, and only the completed gesture enters undo history. The read-only engine preview and `comment_image_preview` tool share bounded image decoding and render layers. Synthetic pixel tests cover live geometry, alpha, coloured page content, rotation, cancellation and encrypted save/reopen. Milestone and effort estimates unchanged. +- **2026-10-08 (M10/M4, create from multiple files):** Create a PDF ▸ Multiple files is live. The picked PDFs, images and `.txt` files open as one unsaved document in the Organize page grid, where pages are removed and reordered and Save pages writes what is shown. The grid gained a "+" at every gap that inserts files right there, and on desktop files dropped on the grid go to the gap under the pointer; Insert from file now takes several files in order, converts images and text, and selects what it inserted. File-type detection moved from the shell into `pdfcraft-create` (`source_kind`), with one engine entry point (`Session::convert_to_pdf`) shared by Open, Insert, Create and the new `doc_create_multiple` tool (which can also write one PDF per file). Not yet: Office/HTML/Markdown inputs, PDF Portfolios, progress for big batches, dropping onto the grid in the web build. Overall estimate unchanged (about 30–35%). + - **2026-10-08 (M5.7, signature placement):** Image signatures and initials preview at the pointer at their actual page size, including zoom and document/view rotation. A successful placement switches to Select with the new annotation selected; stamp resize handles use the existing engine edit, automation tool and undo history while preserving the appearance. Synthetic rendered UI and automation tests verify positioning, one placement per selection, resizing, undo/redo and save/reopen. Milestone and overall effort estimates unchanged. - **2026-10-08 (M5.7, image signatures):** Fill & Sign imports local PNG/JPEG signatures and initials through the Image tab, with previews, local persistence and undoable placement. The shared engine validates bounded images and preserves transparency and orientation; `fill_sign_add` accepts an image path. Synthetic UI and automation tests cover cancellation, invalid input, settings, undo/redo and save/reopen rendering. Overall effort estimate unchanged (about 30–35%). diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 4f072b426a..9ddff4bf15 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -202,6 +202,7 @@ impl Automation { "page_insert_file" => self.insert_file(&a)?, "page_extract" => self.page_extract(&a)?, "doc_combine" => self.doc_combine(&a)?, + "doc_create_multiple" => self.doc_create_multiple(&a)?, "doc_split" => self.doc_split(&a)?, "edit_undo" => { let id = self.doc(&a)?.id; @@ -1142,6 +1143,63 @@ impl Automation { Ok(result) } + fn doc_create_multiple(&mut self, a: &Args) -> Result { + let paths = a.strs("paths")?; + if paths.is_empty() || paths.len() > pdfcraft_engine::MAX_CREATE_FILES { + return Err(ToolError::InvalidArgs(format!("paths must list 1 to {} files", pdfcraft_engine::MAX_CREATE_FILES))); + } + let separate = match a.opt_str("mode")? { + None | Some("combine") => false, + Some("separate") => true, + Some(m) => return Err(ToolError::InvalidArgs(format!("mode must be \"combine\" or \"separate\", not {m:?}"))), + }; + if separate { + return self.create_separate(a, &paths); + } + let ranges: Vec> = match a.get("pages") { + None | Some(Value::Null) => vec![None; paths.len()], + Some(Value::Array(v)) if v.len() == paths.len() => v.iter().map(|x| x.as_str().map(str::to_owned)).collect(), + Some(_) => return Err(ToolError::InvalidArgs("pages must list a range (or null) for each path".into())), + }; + let mut sources = Vec::new(); + for (p, range) in paths.into_iter().zip(ranges) { + let path = self.resolve(p, false)?; + let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; + let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let (_, pdf) = self.session.convert_to_pdf(&name, &Arc::new(bytes)).map_err(failed)?; + let title = path.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + sources.push((title, pdf, range)); + } + let bytes = self.session.combine_ranges(&sources).map_err(failed)?; + self.deliver(a, "Combined", bytes) + } + + /// One PDF per file, written into `out_dir`; a file that fails doesn't stop the others. + fn create_separate(&mut self, a: &Args, paths: &[&str]) -> Result { + let dir = self.resolve(a.str("out_dir").map_err(|_| ToolError::InvalidArgs("mode \"separate\" needs out_dir".into()))?, true)?; + std::fs::create_dir_all(&dir).map_err(|e| failed(format!("{}: {e}", dir.display())))?; + let mut out = Vec::new(); + for &p in paths { + let converted = self.resolve(p, false).map_err(|e| e.to_string()).and_then(|src| { + let bytes = std::fs::read(&src).map_err(|e| format!("{}: {e}", src.display()))?; + let name = src.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let stem = src.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "document".into()); + let (kind, pdf) = self.session.convert_to_pdf(&name, &Arc::new(bytes)).map_err(|e| e.to_string())?; + Ok((kind, stem, pdf)) + }); + out.push(match converted { + Ok((pdfcraft_engine::SourceKind::Pdf, ..)) => json!({ "path": p, "skipped": "already a PDF" }), + Ok((_, stem, pdf)) => { + let target = unused(&dir, &stem); + write_atomic(&target, &pdf)?; + json!({ "path": p, "output": target.to_string_lossy(), "bytes": pdf.len() }) + } + Err(e) => json!({ "path": p, "error": e }), + }); + } + Ok(json!({ "files": out })) + } + fn page_extract(&mut self, a: &Args) -> Result { let doc = self.doc(a)?; let (id, name) = (doc.id, format!("{} (extract)", doc.name)); @@ -1736,6 +1794,15 @@ fn child(dir: &Path, name: &str) -> PathBuf { dir.join(safe) } +/// `.pdf` in `dir`, or ` (2).pdf` and so on when that name is taken. +fn unused(dir: &Path, stem: &str) -> PathBuf { + let first = child(dir, &format!("{stem}.pdf")); + if !first.exists() { + return first; + } + (2..10_000u32).map(|n| child(dir, &format!("{stem} ({n}).pdf"))).find(|p| !p.exists()).unwrap_or(first) +} + /// Write via a temporary file in the same directory, then rename over the target. fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> { write_atomic_with(path, bytes, staging_suffixes()) diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 6096c646c2..3d47c83caf 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -197,6 +197,19 @@ pub fn tools() -> Vec { }), &["paths"], )), + t("doc_create_multiple", "Create PDF from multiple files", "Convert several files (PDFs, PNG/JPEG/JPEG 2000/TIFF/GIF/BMP images, .txt files) to PDF in one run. mode \"combine\" (default) joins them, in order, into one PDF with a bookmark per file; pages optionally chooses each file's pages, in step with paths (a range such as \"1-3, 6\" or null). mode \"separate\" writes one PDF per file into out_dir (files that are already PDFs are skipped; existing files are never overwritten) and reports each file's result.") + .cmd("create.multiple") + .with(schema( + json!({ + "paths": { "type": "array", "items": { "type": "string" }, "minItems": 1, "maxItems": pdfcraft_engine::MAX_CREATE_FILES }, + "mode": { "type": "string", "enum": ["combine", "separate"] }, + "pages": { "type": "array", "items": { "type": ["string", "null"] } }, + "out": save_out.clone(), + "open": open.clone(), + "out_dir": { "type": "string", "description": "Folder for mode \"separate\"." }, + }), + &["paths"], + )), t("doc_split", "Split a document", "Split into several files written to out_dir: every N pages, before given pages, at top-level bookmarks (bookmarks: true; files named after them), or by file size (max_mb). Files are -partK.pdf.") .cmd("page.split") .with(schema( diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 3ea77ab3d3..38f3a1d272 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -1510,6 +1510,62 @@ fn creating_and_reducing_through_tools() { assert!(matches!(a.call("doc_create", &json!({ "from": "images", "paths": ["notes.txt"] })), Err(ToolError::Failed(_)))); } +#[test] +fn creating_from_multiple_files_through_tools() { + let dir = workdir("create-multiple"); + let mut png = Vec::new(); + { + let mut enc = png::Encoder::new(&mut png, 4, 2); + enc.set_color(png::ColorType::Rgb); + enc.set_depth(png::BitDepth::Eight); + enc.write_header().unwrap().write_image_data(&[100; 4 * 2 * 3]).unwrap(); + } + std::fs::write(dir.join("scan.png"), png).unwrap(); + std::fs::write(dir.join("notes.txt"), "hello").unwrap(); + std::fs::write(dir.join("report.docx"), b"PK\x03\x04").unwrap(); + let mut a = auto(&dir); + + // Combine: every file converted, in the order given, with a bookmark per file. + let made = ok( + &mut a, + "doc_create_multiple", + json!({ "paths": ["notes.txt", "a.pdf", "scan.png"], "pages": [null, "3", null], "out": "all.pdf", "open": true }), + ); + let doc = made["document"]["doc"].as_u64().unwrap(); + assert_eq!(page_text(&mut a, doc), ["hello", "Page 3", ""]); + let titles: Vec = ok(&mut a, "bookmark_list", json!({ "doc": doc }))["bookmarks"] + .as_array() + .unwrap() + .iter() + .map(|b| b["title"].as_str().unwrap().to_string()) + .collect(); + assert_eq!(titles, ["notes", "a", "scan"]); + assert!(dir.join("all.pdf").is_file()); + // A file that can't be converted fails the whole combine, naming it. + let err = a.call("doc_create_multiple", &json!({ "paths": ["a.pdf", "report.docx"] })).unwrap_err(); + assert!(matches!(&err, ToolError::Failed(m) if m.contains("report.docx") && m.contains("can't be converted")), "{err}"); + assert!(matches!(a.call("doc_create_multiple", &json!({ "paths": [] })), Err(ToolError::InvalidArgs(_)))); + assert!(matches!(a.call("doc_create_multiple", &json!({ "paths": ["a.pdf"], "mode": "zip" })), Err(ToolError::InvalidArgs(_)))); + assert!(matches!(a.call("doc_create_multiple", &json!({ "paths": ["a.pdf"], "mode": "separate" })), Err(ToolError::InvalidArgs(_)))); + + // Separate: one PDF per file; PDFs are skipped, a bad file doesn't stop the rest, and an + // existing file is never overwritten. + std::fs::create_dir_all(dir.join("out")).unwrap(); + std::fs::write(dir.join("out/notes.pdf"), b"mine").unwrap(); + let args = json!({ "paths": ["notes.txt", "report.docx", "a.pdf", "scan.png", "missing.txt"], "mode": "separate", "out_dir": "out" }); + let made = ok(&mut a, "doc_create_multiple", args); + let files = made["files"].as_array().unwrap(); + assert!(files[0]["output"].as_str().unwrap().ends_with("notes (2).pdf"), "{files:?}"); + assert!(files[1]["error"].as_str().unwrap().contains("can't be converted")); + assert_eq!(files[2]["skipped"], "already a PDF"); + assert!(files[3]["output"].as_str().unwrap().ends_with("scan.pdf")); + assert!(files[4]["error"].is_string()); + assert_eq!(std::fs::read(dir.join("out/notes.pdf")).unwrap(), b"mine"); + let reopened = ok(&mut a, "doc_open", json!({ "path": "out/notes (2).pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(page_text(&mut a, reopened), ["hello"]); + assert!(!dir.join("out/a.pdf").exists()); +} + #[test] fn creating_images_with_dpi_through_tools() { let dir = workdir("image-dpi"); diff --git a/crates/create/src/lib.rs b/crates/create/src/lib.rs index 267e7af2e6..ad213c089e 100644 --- a/crates/create/src/lib.rs +++ b/crates/create/src/lib.rs @@ -374,6 +374,45 @@ fn tiff_pages(name: &str, bytes: &[u8]) -> Result, CreateError> { Ok(out) } +/// What a file picked for Create is, by its bytes (and, for text, its name). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SourceKind { + Pdf, + Image, + Text, +} + +/// File extensions Create converts to PDF (images and plain text). +pub const CONVERTIBLE: [&str; 12] = ["png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx", "txt", "text"]; + +fn is_image(bytes: &[u8]) -> bool { + bytes.starts_with(&[0xFF, 0xD8]) + || bytes.starts_with(b"\x89PNG\r\n\x1a\n") + || bytes.starts_with(b"II*\0") + || bytes.starts_with(b"MM\0*") + || bytes.starts_with(b"GIF8") + // JPEG 2000: a JP2 file or a raw codestream. + || bytes.starts_with(JP2_SIGNATURE) + || bytes.starts_with(&[0xFF, 0x4F, 0xFF, 0x51]) + // BMP: "BM" and a known header size (so text starting with "BM" stays text). + || (bytes.starts_with(b"BM") + && bytes.get(14..18).and_then(|h| <[u8; 4]>::try_from(h).ok()).is_some_and(|h| matches!(u32::from_le_bytes(h), 12 | 40 | 52 | 56 | 108 | 124))) +} + +/// Whether `bytes` named `name` is a PDF, an image Create can embed, or plain text (a `.txt` or +/// `.text` file). `None` for anything else. +pub fn source_kind(name: &str, bytes: &[u8]) -> Option { + let head = bytes.get(..bytes.len().min(1024)).unwrap_or_default(); + if head.windows(5).any(|w| w == b"%PDF-") { + return Some(SourceKind::Pdf); + } + if is_image(bytes) { + return Some(SourceKind::Image); + } + let lower = name.to_ascii_lowercase(); + (lower.ends_with(".txt") || lower.ends_with(".text")).then_some(SourceKind::Text) +} + /// Detect the image format from its bytes; a TIFF may hold several pages. fn embed(name: &str, bytes: &[u8]) -> Result, CreateError> { if bytes.starts_with(&[0xFF, 0xD8]) { diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index f4dd33b9cd..9edc3fac85 100644 --- a/crates/create/src/tests.rs +++ b/crates/create/src/tests.rs @@ -258,3 +258,18 @@ fn jpeg_2000_images_are_embedded_as_is() { let doc = reopen(&from_images(&[("raw.j2k".into(), siz)]).unwrap()); assert_eq!(media(&pages(&doc)[0])[2], 64.0); } + +#[test] +fn source_kind_tells_pdfs_images_and_text_apart() { + assert_eq!(source_kind("a.bin", b"%PDF-1.7\n"), Some(SourceKind::Pdf)); + assert_eq!(source_kind("a.txt", b"junk before\n%PDF-1.4"), Some(SourceKind::Pdf), "a header after leading junk"); + assert_eq!(source_kind("a", &png_bytes(false)), Some(SourceKind::Image)); + assert_eq!(source_kind("a.txt", &jpeg_bytes()), Some(SourceKind::Image), "bytes win over the name"); + assert_eq!(source_kind("notes.TXT", b"hello"), Some(SourceKind::Text)); + assert_eq!(source_kind("notes.text", b""), Some(SourceKind::Text)); + // Text that merely starts like a BMP stays text; a truncated BMP header is not an image. + assert_eq!(source_kind("b.txt", b"BMW drivers"), Some(SourceKind::Text)); + assert_eq!(source_kind("b.bmp", b"BM"), None); + assert_eq!(source_kind("a.docx", b"PK\x03\x04"), None); + assert_eq!(source_kind("", b""), None); +} diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index 9e774127b0..c553675cd1 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -149,7 +149,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[ title: "Create from", items: &[ item("Single file", "file-input", "create.file", Ready), - item("Multiple files", "files", "create.multiple", Planned("M10")), + item("Multiple files", "files", "create.multiple", Ready), item("Images", "image", "create.images", Ready), item("Clipboard", "copy-plus", "create.clipboard", Ready), item("Blank page", "file-plus-2", "create.blank", Ready), diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 49b6327341..331652e438 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -162,6 +162,7 @@ pub const COMMANDS: &[CommandSpec] = &[ c("page.cut", "Cut pages", None, None, Assembly, "scissors"), c("page.paste", "Paste pages", None, None, Assembly, "clipboard-paste"), c("create.file", "Create PDF from file…", FILE, None, Nothing, "file-input"), + c("create.multiple", "Create PDF from multiple files…", FILE, None, Nothing, "files"), c("create.images", "Create PDF from images…", FILE, None, Nothing, "image"), c("create.clipboard", "Create PDF from clipboard", FILE, None, Nothing, "copy-plus"), c("page.combine", "Combine files…", FILE, None, Nothing, "files"), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a92a68486a..98a4693420 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -32,7 +32,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_create::{CONVERTIBLE, ImageResolution, SourceKind, source_kind}; pub use pdfcraft_edit::{ Added, AddedImage, AddedText, Align as TextAlign, Background, Content as AddedContent, Family as FontFamily, HeaderFooter, MarkKind, Watermark, }; @@ -1735,6 +1735,9 @@ pub fn guard(f: impl FnOnce() -> T) -> Result { }) } +/// The most files Create ▸ Multiple files takes in one run. +pub const MAX_CREATE_FILES: usize = 1000; + /// Parse another PDF to copy pages from. fn open_source(name: &str, bytes: &Arc>) -> Result { open_source_with(name, bytes, None) @@ -2541,6 +2544,23 @@ impl Session { self.write_new(&pdfcraft_create::from_text(title, text, pdfcraft_create::LETTER, 11.0)?) } + /// Convert a file Create understands (an image or plain text) to PDF bytes; a PDF is checked + /// (it must open and allow copying pages) and returned as it is. + pub fn convert_to_pdf(&self, name: &str, bytes: &Arc>) -> Result<(SourceKind, Arc>), EditError> { + let Some(kind) = source_kind(name, bytes) else { + return Err(EditError::Source(format!("{name}: this file type can't be converted; use a PDF, an image or a .txt file"))); + }; + let title = name.rsplit_once('.').map_or(name, |(s, _)| s); + // Image decoders read untrusted bytes: a panic in one must not take the app down. + let created = guard(|| match kind { + SourceKind::Pdf => open_source(name, bytes).map(|_| bytes.clone()), + SourceKind::Image => self.create_from_images(&[(name.to_string(), bytes.to_vec())]), + SourceKind::Text => self.create_from_text(title, &String::from_utf8_lossy(bytes)), + }) + .map_err(|_| EditError::Source(format!("{name}: the file could not be read")))?; + Ok((kind, created?)) + } + /// Reduce File Size: Acrobat's defaults (images above 225 ppi to 150 ppi, JPEG medium /// quality; thumbnails dropped), identical resources merged, unused objects dropped, objects /// packed into compressed object streams. Returns the bytes and how many objects were diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 07f6b9afad..9759a72d7b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -297,6 +297,46 @@ fn combine_extract_split_and_insert_from_file() { assert!(matches!(bad, Err(EditError::Source(_)))); } +/// A minimal baseline JPEG (headers only; the data is embedded as is). +fn jpeg_bytes() -> Vec { + let mut v = vec![0xFF, 0xD8]; + v.extend_from_slice(&[0xFF, 0xE0, 0, 16, b'J', b'F', b'I', b'F', 0, 1, 1, 1, 0x01, 0x2C, 0x01, 0x2C, 0, 0]); + v.extend_from_slice(&[0xFF, 0xC0, 0, 17, 8, 0, 2, 0, 3, 3, 1, 0x11, 0, 2, 0x11, 1, 3, 0x11, 1]); + v.extend_from_slice(&[0xFF, 0xD9]); + v +} + +#[test] +fn mixed_files_convert_and_combine_in_order() { + let mut s = Session::new(); + let pdf = Arc::new(fixture(2)); + let (kind, same) = s.convert_to_pdf("a.pdf", &pdf).unwrap(); + assert_eq!(kind, SourceKind::Pdf); + assert!(Arc::ptr_eq(&same, &pdf), "a PDF is passed through untouched"); + let (kind, image) = s.convert_to_pdf("scan.jpg", &Arc::new(jpeg_bytes())).unwrap(); + assert_eq!(kind, SourceKind::Image); + let (kind, text) = s.convert_to_pdf("notes.txt", &Arc::new(b"hello".to_vec())).unwrap(); + assert_eq!(kind, SourceKind::Text); + let combined = s.combine_ranges(&[("notes".into(), text, None), ("a".into(), pdf, Some("2".into())), ("scan".into(), image, None)]).unwrap(); + let id = s.open_new("Combined.pdf", combined).unwrap(); + assert_eq!(page_texts(&s, id), ["hello", "Page 2", ""]); + assert_eq!(outline_titles(&s.get(id).unwrap().info.outline), ["notes→1", "a→2", "scan→3"]); +} + +#[test] +fn files_that_cannot_be_converted_are_refused_clearly() { + let s = Session::new(); + let err = s.convert_to_pdf("report.docx", &Arc::new(b"PK\x03\x04".to_vec())).unwrap_err(); + assert_eq!(err, EditError::Source("report.docx: this file type can't be converted; use a PDF, an image or a .txt file".into())); + assert!(matches!(s.convert_to_pdf("empty", &Arc::new(Vec::new())), Err(EditError::Source(_)))); + // Damaged inputs of a known type fail with an error, not a panic. + assert!(s.convert_to_pdf("bad.png", &Arc::new(b"\x89PNG\r\n\x1a\nnope".to_vec())).is_err()); + assert!(s.convert_to_pdf("bad.jpg", &Arc::new(vec![0xFF, 0xD8, 0xFF])).is_err()); + assert!(s.convert_to_pdf("bad.pdf", &Arc::new(b"%PDF-1.7 nope".to_vec())).is_err()); + let err = s.convert_to_pdf("locked.pdf", &protected("pw", "o", -1)).unwrap_err(); + assert_eq!(err, EditError::Source("locked.pdf: it is password-protected".into())); +} + fn protected(user: &str, owner: &str, permissions: i32) -> Arc> { let mut doc = pdfcraft_cos::Document::open(Arc::new(fixture(2))).unwrap(); doc.set_encryption(&pdfcraft_cos::NewEncryption { diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 6a0b9fca22..f255d70e30 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -36,6 +36,13 @@ pollster = "1.0" # Checks that an installed fallback font parses and has the glyphs; the same skrifa egui uses. skrifa = "0.44" +[target.'cfg(target_os = "macos")'.dependencies] +# Where the pointer is while files are dragged over the window (winit doesn't say): safe AppKit +# calls only. All three are already in the tree through arboard and rfd. +objc2 = "0.6" +objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSEvent", "NSScreen"] } +objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSGeometry"] } + [target.'cfg(windows)'.dependencies] # Auto interface language: the Windows display language, without starting a process. sys-locale = "0.3" diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 42313e8623..875528a87a 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -280,12 +280,22 @@ pub struct DocView { pub(crate) signature_drag: crate::signature_drag::SignatureDrag, /// A non-edit action requested by the organize toolbar, handled by the app. pub pending_action: Option, + /// The grid gap the next inserted files go to (set by a "+" between pages); otherwise they + /// go after the selection. + pub insert_at: Option, + /// The page-grid gap under the pointer this frame (where dropped files go), when it can + /// take pages. + pub grid_gap: Option, } /// Organize-toolbar actions that need the app (file pickers, new tabs, dialogs). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ViewAction { InsertFromFile, + /// Insert files at a gap of the page grid (0 = before the first page). + InsertFromFileAt(usize), + /// Save the document as the grid shows it. + Save, Extract, Split, /// Copy the selected pages (Cut also deletes them). @@ -370,6 +380,8 @@ impl DocView { image_selection: None, block_drag: None, pending_action: None, + insert_at: None, + grid_gap: None, comments: Default::default(), measure: Default::default(), forms: Default::default(), @@ -1173,7 +1185,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // No dialog, close prompt or palette over the page: only then does page input count. let unobstructed = app.dialog.is_none() && app.close_request.is_none() && !app.palette_open; if view.organize { - organize_grid(view, info, &doc.renderer, doc.allows_assembly(), unobstructed, ui, &t); + organize_grid(view, info, &doc.renderer, doc.allows_assembly(), doc.dirty, unobstructed, ui, &t); return; } @@ -2484,7 +2496,7 @@ fn quick_bar(app: &mut PdfCraftApp, area: Rect, ui: &mut egui::Ui) { } /// The organize toolbar: page operations on the selection (Acrobat's Organize Pages bar). -fn organize_toolbar(view: &mut DocView, info: &DocInfo, editable: bool, ui: &mut egui::Ui, t: &Tokens) { +fn organize_toolbar(view: &mut DocView, info: &DocInfo, editable: bool, dirty: bool, ui: &mut egui::Ui, t: &Tokens) { let targets = view.target_pages(); let n = info.pages.len(); let (first, last) = (targets.first().copied().unwrap_or(0), targets.last().copied().unwrap_or(0)); @@ -2558,6 +2570,11 @@ fn organize_toolbar(view: &mut DocView, info: &DocInfo, editable: bool, ui: &mut if widgets::ghost_button(ui, "x", tl!("Close")).on_hover_text(tl!("Back to the document")).clicked() { view.organize = false; } + // What the grid shows is what is saved. + let save = ui.add_enabled_ui(dirty, |ui| widgets::pill_button(ui, tl!("Save pages"), true)).inner; + if save.on_hover_text(tl!("Save these pages as one PDF")).on_disabled_hover_text(tl!("No changes to save")).clicked() { + view.pending_action = Some(ViewAction::Save); + } }); }); }); @@ -2598,11 +2615,36 @@ fn drop_gap(cells: &[(usize, Rect)], p: Pos2) -> Option { Some(if p.x < r.center().x { *i } else { i + 1 }) } -fn organize_grid(view: &mut DocView, info: &DocInfo, pool: &RenderPool, editable: bool, auto_scroll_enabled: bool, ui: &mut egui::Ui, t: &Tokens) { +/// A "+" on a gap of the page grid: insert files there. Returns whether it was clicked. +fn gap_button(ui: &mut egui::Ui, gap: usize, at: Pos2, height: f32, label: String, t: &Tokens) -> bool { + let resp = ui.interact(Rect::from_center_size(at, Vec2::splat(22.0)), ui.id().with(("org-gap", gap)), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label.clone())); + let hot = resp.hovered(); + if hot { + ui.painter().line_segment([pos2(at.x, at.y - height / 2.0), pos2(at.x, at.y + height / 2.0)], Stroke::new(3.0, t.accent)); + } + ui.painter().circle(at, 9.0, if hot { t.accent } else { t.chrome }, Stroke::new(1.0, if hot { t.accent } else { t.border })); + let ink = Stroke::new(1.5, if hot { Color32::WHITE } else { t.text_muted }); + ui.painter().line_segment([at - vec2(4.0, 0.0), at + vec2(4.0, 0.0)], ink); + ui.painter().line_segment([at - vec2(0.0, 4.0), at + vec2(0.0, 4.0)], ink); + resp.on_hover_text(label).clicked() +} + +#[allow(clippy::too_many_arguments)] +fn organize_grid( + view: &mut DocView, + info: &DocInfo, + pool: &RenderPool, + editable: bool, + dirty: bool, + auto_scroll_enabled: bool, + ui: &mut egui::Ui, + t: &Tokens, +) { let ppp = ui.ctx().pixels_per_point(); let cell = vec2(190.0, 250.0); let mut open_page = None; - organize_toolbar(view, info, editable, ui, t); + organize_toolbar(view, info, editable, dirty, ui, t); let viewport = ui.available_rect_before_wrap(); view.viewport_screen = viewport; let auto_delta = if auto_scroll_enabled { @@ -2696,6 +2738,25 @@ fn organize_grid(view: &mut DocView, info: &DocInfo, pool: &RenderPool, editable }); } } + // Between pages (and at both ends): insert files right there. + if editable && !middle_gesture && view.org_drag.is_none() { + let mid = |r: &Rect| r.top() + 16.0 + (cell.y - 56.0) / 2.0; + for (i, c) in &cells { + let label = info + .pages + .get(*i) + .map(|p| crate::i18n::fmt(tl!("Insert a file before page {label}"), &[("label", &p.label)])) + .unwrap_or_default(); + if gap_button(ui, *i, pos2(c.left(), mid(c)), cell.y - 56.0, label, t) { + view.pending_action = Some(ViewAction::InsertFromFileAt(*i)); + } + } + if let Some((i, c)) = cells.last() + && gap_button(ui, i + 1, pos2(c.right(), mid(c)), cell.y - 56.0, tl!("Insert a file at the end").to_string(), t) + { + view.pending_action = Some(ViewAction::InsertFromFileAt(i + 1)); + } + } // While dragging: the gap the pages would go to, drawn as a bar. if let (Some(pages), Some(p)) = (view.org_drag.clone(), ui.input(|i| i.pointer.hover_pos())) { if let Some(gap) = drop_gap(&cells, p) { @@ -2730,6 +2791,27 @@ fn organize_grid(view: &mut DocView, info: &DocInfo, pool: &RenderPool, editable view.org_drag = None; } }); + // Files dragged in from outside go to the gap under the pointer. Some platforms don't say + // where the pointer is until the files are dropped; then the whole grid is the target. + let pointer = ui.input(|i| i.pointer.hover_pos()).filter(|p| viewport.contains(*p)); + view.grid_gap = pointer.filter(|_| editable && view.org_drag.is_none()).and_then(|p| drop_gap(&cells, p)); + if editable && ui.input(|i| !i.raw.hovered_files.is_empty()) { + let painter = ui.painter().with_clip_rect(viewport); + painter.rect_stroke(viewport.shrink(2.0), CornerRadius::same(6), Stroke::new(2.0, t.accent), egui::StrokeKind::Inside); + if let Some(gap) = view.grid_gap { + let at = cells.iter().find(|(i, _)| *i == gap).map(|(_, r)| (r.left() + 3.0, r.y_range())); + if let Some((x, row)) = at.or(cells.last().map(|(_, r)| (r.right() - 3.0, r.y_range()))) { + painter.line_segment([pos2(x, row.min + 10.0), pos2(x, row.max - 10.0)], Stroke::new(3.0, t.accent)); + } + } + // A "+" that follows the pointer. + if let Some(p) = pointer { + let at = p + vec2(16.0, 16.0); + painter.circle_filled(at, 9.0, t.accent); + painter.line_segment([at - vec2(4.0, 0.0), at + vec2(4.0, 0.0)], Stroke::new(1.5, Color32::WHITE)); + painter.line_segment([at - vec2(0.0, 4.0), at + vec2(0.0, 4.0)], Stroke::new(1.5, Color32::WHITE)); + } + } view.auto_scroll.paint(ui, viewport); let s = THUMB_W * ppp / info.pages.iter().map(|p| p.width).fold(1.0, f32::max); let queue: Vec = (0..info.pages.len()) diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index d26b049405..86d3a74c01 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -476,6 +476,7 @@ impl PdfCraftApp { } "create.blank" => self.create_blank(), "create.file" => self.open_dialog(), + "create.multiple" => self.create_multiple_dialog(), "create.images" => self.create_from_images_dialog(), "create.clipboard" => self.create_from_clipboard(), "optimize.reduce" => self.reduce_file_size(), diff --git a/crates/ui-egui/src/create_multiple_ui.rs b/crates/ui-egui/src/create_multiple_ui.rs new file mode 100644 index 0000000000..f297306b24 --- /dev/null +++ b/crates/ui-egui/src/create_multiple_ui.rs @@ -0,0 +1,50 @@ +//! Create a PDF ▸ Multiple files: the picked PDFs, images and text files become one new, +//! unsaved document, shown in the page grid: remove pages, insert more files between pages, +//! reorder, then save what the grid shows. + +use std::sync::Arc; + +use crate::PdfCraftApp; + +fn stem(name: &str) -> &str { + name.rsplit_once('.').map_or(name, |(s, _)| s) +} + +impl PdfCraftApp { + /// Convert the picked files, join them in the order picked, and open the result in the page + /// grid. A file that can't be converted is left out (and named in a notice). + pub(crate) fn stage_create_multiple(&mut self, files: Vec<(String, Vec)>) { + let max = pdfcraft_engine::MAX_CREATE_FILES; + let too_many = files.len() > max; + let (mut sources, mut refused) = (Vec::new(), Vec::new()); + for (name, bytes) in files.into_iter().take(max) { + match self.session.convert_to_pdf(&name, &Arc::new(bytes)) { + Ok((_, pdf)) => sources.push((stem(&name).to_string(), pdf, None)), + Err(_) => refused.push(name), + } + } + if sources.is_empty() { + self.notify_tr("None of those files can be made into a PDF; use PDFs, images or .txt files"); + return; + } + let bytes = match self.session.combine_ranges(&sources) { + Ok(bytes) => bytes, + Err(e) => return self.notify_fmt("Couldn't create a PDF: {e}", &[("e", &e.to_string())]), + }; + let n = sources.len().to_string(); + let message = if !refused.is_empty() { + crate::i18n::fmt(tl!("Created a PDF from {n} files; left out: {names}"), &[("n", &n), ("names", &refused.join(", "))]) + } else if too_many { + crate::i18n::fmt(tl!("Created a PDF from the first {n} files"), &[("n", &n)]) + } else { + crate::i18n::fmt(tl!("Created a PDF from {n} files"), &[("n", &n)]) + }; + let before = self.views.len(); + self.open_created("Combined.pdf", bytes, &message); + if self.views.len() > before + && let Some(view) = self.active.and_then(|i| self.views.get_mut(i)) + { + view.organize = true; + } + } +} diff --git a/crates/ui-egui/src/create_ui.rs b/crates/ui-egui/src/create_ui.rs index 48c8a1cbb5..9e2c30b5bd 100644 --- a/crates/ui-egui/src/create_ui.rs +++ b/crates/ui-egui/src/create_ui.rs @@ -41,20 +41,7 @@ pub(crate) fn image_import_body(ui: &mut egui::Ui, app: &mut PdfCraftApp) -> (bo } /// 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"]; - -fn is_image(bytes: &[u8]) -> bool { - bytes.starts_with(&[0xFF, 0xD8]) - || bytes.starts_with(b"\x89PNG\r\n\x1a\n") - || bytes.starts_with(b"II*\0") - || bytes.starts_with(b"MM\0*") - || bytes.starts_with(b"GIF8") - // JPEG 2000: a JP2 file or a raw codestream. - || bytes.starts_with(&[0, 0, 0, 0x0C, b'j', b'P', b' ', b' ']) - || bytes.starts_with(&[0xFF, 0x4F, 0xFF, 0x51]) - // BMP: "BM" and a known header size (so text starting with "BM" stays text). - || (bytes.starts_with(b"BM") && bytes.get(14..18).is_some_and(|h| matches!(u32::from_le_bytes([h[0], h[1], h[2], h[3]]), 12 | 40 | 52 | 56 | 108 | 124))) -} +pub use pdfcraft_engine::CONVERTIBLE; /// What Create ▸ Clipboard found on the clipboard. #[derive(Clone, Debug, PartialEq)] @@ -101,18 +88,11 @@ impl PdfCraftApp { /// Convert a non-PDF file (image, text) into a new tab. Returns `None` when `bytes` is not /// something Create understands (the caller then tries to open it as a PDF). pub(crate) fn open_converted(&mut self, name: &str, bytes: &[u8]) -> Option> { - let head = &bytes[..bytes.len().min(1024)]; - if head.windows(5).any(|w| w == b"%PDF-") { + use pdfcraft_engine::SourceKind; + if !matches!(pdfcraft_engine::source_kind(name, bytes), Some(SourceKind::Image | SourceKind::Text)) { return None; } - let created = if is_image(bytes) { - self.session.create_from_images(&[(name.to_string(), bytes.to_vec())]) - } else if name.to_ascii_lowercase().ends_with(".txt") { - let text = String::from_utf8_lossy(bytes); - self.session.create_from_text(stem(name), &text) - } else { - return None; - }; + let created = self.session.convert_to_pdf(name, &Arc::new(bytes.to_vec())).map(|(_, pdf)| pdf); Some(self.open_created_bytes(&format!("{}.pdf", stem(name)), created.map_err(|e| e.to_string()))) } diff --git a/crates/ui-egui/src/drag_pointer.rs b/crates/ui-egui/src/drag_pointer.rs new file mode 100644 index 0000000000..898d319fe6 --- /dev/null +++ b/crates/ui-egui/src/drag_pointer.rs @@ -0,0 +1,25 @@ +//! Where the pointer is while files are dragged over the window. The windowing layer reports +//! that a file hovers, but not where, and sends no pointer moves until the drag ends; the +//! system has to be asked. + +/// The pointer in this window's coordinates (egui points), when the system can tell. +#[cfg(target_os = "macos")] +pub(crate) fn in_window(ctx: &egui::Context) -> Option { + use objc2_app_kit::{NSEvent, NSScreen}; + // AppKit's screen list is only for the main thread (where egui runs). + let mtm = objc2::MainThreadMarker::new()?; + // Screen coordinates start at the bottom-left of the first screen; windows at their top-left. + let top = NSScreen::screens(mtm).firstObject()?.frame().size.height; + let pointer = NSEvent::mouseLocation(); + let inner = ctx.input(|i| i.viewport().inner_rect)?; + // egui's points are the system's, divided by the zoom factor. + let zoom = ctx.zoom_factor().max(0.01) as f64; + let (x, y) = (pointer.x / zoom, (top - pointer.y) / zoom); + let pos = egui::pos2(x as f32 - inner.min.x, y as f32 - inner.min.y); + (pos.x.is_finite() && pos.y.is_finite()).then_some(pos) +} + +#[cfg(not(target_os = "macos"))] +pub(crate) fn in_window(_ctx: &egui::Context) -> Option { + None +} diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index a1f088446a..a28a5485c9 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -123,6 +123,10 @@ impl PdfCraftApp { } match self.views.get_mut(i).and_then(|v| v.pending_action.take()) { Some(crate::canvas::ViewAction::InsertFromFile) => self.insert_from_file_dialog(), + Some(crate::canvas::ViewAction::InsertFromFileAt(at)) => self.insert_from_file_at(Some(at)), + Some(crate::canvas::ViewAction::Save) => { + self.save_active(SaveTarget::InPlace); + } Some(crate::canvas::ViewAction::Extract) => self.dialog = Some(crate::Dialog::Extract), Some(crate::canvas::ViewAction::Split) => self.dialog = Some(crate::Dialog::Split), Some(crate::canvas::ViewAction::CopyPages { cut }) => self.copy_pages(cut), diff --git a/crates/ui-egui/src/files.rs b/crates/ui-egui/src/files.rs index 9d4e10f48b..c14dd6acf4 100644 --- a/crates/ui-egui/src/files.rs +++ b/crates/ui-egui/src/files.rs @@ -17,6 +17,18 @@ pub enum FilePurpose { ReplacePages, /// Scan & OCR ▸ Recognize text in multiple files. Ocr, + /// Create a PDF ▸ Multiple files (PDFs, images and text). + CreateMultiple, +} + +/// The picker for `purpose`: PDFs, and for Create and Insert also what they convert. +fn files_picker(purpose: FilePurpose) -> rfd::AsyncFileDialog { + let dialog = rfd::AsyncFileDialog::new(); + if !matches!(purpose, FilePurpose::CreateMultiple | FilePurpose::InsertPages) { + return dialog.add_filter("PDF", &["pdf"]); + } + let all: Vec<&str> = std::iter::once("pdf").chain(pdfcraft_engine::CONVERTIBLE).collect(); + dialog.add_filter(tl!("PDF, images and text"), &all).add_filter("PDF", &["pdf"]) } /// The Replace Pages dialog: the chosen file and the ranges (1-based, inclusive). @@ -130,23 +142,37 @@ impl PdfCraftApp { self.pick_files(FilePurpose::Combine, true); } + /// Create a PDF ▸ Multiple files: ask for the files to convert. + pub fn create_multiple_dialog(&mut self) { + self.pick_files(FilePurpose::CreateMultiple, true); + } + /// Scan & OCR ▸ Recognize text ▸ In multiple files: ask for the PDFs. pub fn ocr_files_dialog(&mut self) { self.pick_files(FilePurpose::Ocr, true); } - /// Ask for a PDF whose pages to insert after the selection (Organize ▸ Insert from file). + /// Ask for files whose pages to insert after the selection (Organize ▸ Insert from file). pub fn insert_from_file_dialog(&mut self) { - if self.active.is_none() { + self.insert_from_file_at(None); + } + + /// Ask for files to insert at grid gap `at` (0 = before the first page), or after the + /// selection. + pub(crate) fn insert_from_file_at(&mut self, at: Option) { + let Some(i) = self.active else { self.notify_tr("Open a document first"); return; + }; + if let Some(v) = self.views.get_mut(i) { + v.insert_at = at; } - self.pick_files(FilePurpose::InsertPages, false); + self.pick_files(FilePurpose::InsertPages, true); } fn pick_files(&mut self, purpose: FilePurpose, multiple: bool) { #[cfg(not(target_arch = "wasm32"))] - self.pick(crate::pickers::PickFor::Files(purpose), rfd::AsyncFileDialog::new().add_filter("PDF", &["pdf"]), multiple); + self.pick(crate::pickers::PickFor::Files(purpose), files_picker(purpose), multiple); #[cfg(target_arch = "wasm32")] { let requests = self.requests.clone(); @@ -155,7 +181,7 @@ impl PdfCraftApp { // the browser has finished reading the file (#167). let request = self.file_request(purpose, Vec::new()); wasm_bindgen_futures::spawn_local(async move { - let dialog = rfd::AsyncFileDialog::new().add_filter("PDF", &["pdf"]); + let dialog = files_picker(purpose); let handles = if multiple { dialog.pick_files().await.unwrap_or_default() } else { dialog.pick_file().await.into_iter().collect() }; let mut files = Vec::new(); for h in handles { @@ -286,17 +312,14 @@ impl PdfCraftApp { pub fn use_files(&mut self, purpose: FilePurpose, files: Vec<(String, Vec)>) { match purpose { FilePurpose::Combine => self.stage_combine(files), - FilePurpose::InsertPages => { - for (name, bytes) in files { - self.insert_pages_from(&name, bytes); - } - } + FilePurpose::InsertPages => self.insert_files(files), FilePurpose::ReplacePages => { if let Some((name, bytes)) = files.into_iter().next() { self.start_replace(name, bytes); } } FilePurpose::Ocr => self.ocr_files(files), + FilePurpose::CreateMultiple => self.stage_create_multiple(files), } } @@ -326,11 +349,37 @@ impl PdfCraftApp { self.dialog = Some(crate::Dialog::ReplacePages); } - /// Insert all pages of a PDF after the organize selection (or the current page). + /// Insert all pages of a file after the organize selection (or the current page). pub fn insert_pages_from(&mut self, name: &str, bytes: Vec) { - let Some(i) = self.active else { return }; - let at = self.views[i].target_pages().last().map(|p| p + 1).unwrap_or(0); - self.apply_edit(Edit::InsertPagesFrom { name: name.to_string(), bytes: Arc::new(bytes), pages: None, at }); + self.insert_files(vec![(name.to_string(), bytes)]); + } + + /// Insert the pages of `files` (PDFs, images, text), in order, at the gap a "+" in the page + /// grid chose, or else after the selection (or the current page); then select them. + pub fn insert_files(&mut self, files: Vec<(String, Vec)>) { + let Some(view) = self.active.and_then(|i| self.views.get_mut(i)) else { return }; + let (id, chosen) = (view.id, view.insert_at.take()); + let after = view.target_pages().last().map_or(0, |p| p + 1); + let count = self.session.get(id).map_or(0, |d| d.info.pages.len()); + let start = chosen.unwrap_or(after).min(count); + let mut at = start; + for (name, bytes) in files { + let converted = + self.session.convert_to_pdf(&name, &Arc::new(bytes)).and_then(|(_, pdf)| Ok((self.session.page_count_of(&name, &pdf)?, pdf))); + match converted { + Ok((pages, bytes)) => { + if self.apply_edit(Edit::InsertPagesFrom { name, bytes, pages: None, at }) { + at = at.saturating_add(pages); + } + } + Err(e) => self.notify_error(e), + } + } + if at > start + && let Some(view) = self.active.and_then(|i| self.views.get_mut(i)) + { + view.select_pages(&(start..at).collect::>()); + } } /// Copy the selected pages (or the current page) into a new unsaved document tab. diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 704645f63b..c1e69d9f5a 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -33,6 +33,7 @@ New blank PDF Nový prázdný dokument PDF Create PDF from file… Vytvořit PDF ze souboru… Create PDF from images… Vytvořit PDF z obrázků… + Create PDF from multiple files… Vytvořit PDF z více souborů… Create PDF from clipboard Vytvořit PDF ze schránky Combine files… Sloučit soubory… Save Uložit diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 334c552a4a..abdcd16e28 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -26,6 +26,7 @@ New blank PDF Nuevo PDF en blanco Create PDF from file… Crear PDF a partir de un archivo… Create PDF from images… Crear PDF a partir de imágenes… + Create PDF from multiple files… Crear PDF a partir de varios archivos… Create PDF from clipboard Crear PDF a partir del portapapeles Combine files… Combinar archivos… Save Guardar @@ -1958,5 +1959,15 @@ Default page display Presentación de página predeterminada Default zoom Zoom predeterminado Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages. Se usa cuando un PDF no pide una presentación ni un zoom. El desplazamiento continuo nunca salta de página en página. + Created a PDF from {n} files Se creó un PDF a partir de {n} archivos + PDF, images and text PDF, imágenes y texto + Insert a file before page {label} Insertar un archivo antes de la página {label} + Insert a file at the end Insertar un archivo al final + Save pages Guardar páginas + Save these pages as one PDF Guardar estas páginas en un solo PDF + No changes to save No hay cambios que guardar + Created a PDF from {n} files; left out: {names} Se creó un PDF a partir de {n} archivos; omitidos: {names} + Created a PDF from the first {n} files Se creó un PDF a partir de los primeros {n} archivos + None of those files can be made into a PDF; use PDFs, images or .txt files Ninguno de esos archivos se puede convertir en PDF; use archivos PDF, imágenes o .txt Choose a PNG or JPEG image (up to 4 MiB). Transparency is preserved. Elija una imagen PNG o JPEG (hasta 4 MiB). Se conserva la transparencia. Couldn't import the signature image: {e} No se pudo importar la imagen de la firma: {e} diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 01ef070d17..a787eb2671 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -24,6 +24,7 @@ New blank PDF Nouveau PDF vierge Create PDF from file… Créer un PDF à partir d'un fichier… Create PDF from images… Créer un PDF à partir d'images… + Create PDF from multiple files… Créer un PDF à partir de plusieurs fichiers… Create PDF from clipboard Créer un PDF à partir du presse-papiers Combine files… Combiner des fichiers… Save Enregistrer @@ -1934,6 +1935,16 @@ Lines +/− Lignes +/− @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last} @{login} : {prs} PR, {commits} commits, +{added} / −{deleted} lignes (Δ {delta}), +{bin_added} / −{bin_deleted} fichiers binaires, {first} – {last} Set layer visibility Définir la visibilité des calques + Created a PDF from {n} files PDF créé à partir de {n} fichiers + PDF, images and text PDF, images et texte + Insert a file before page {label} Insérer un fichier avant la page {label} + Insert a file at the end Insérer un fichier à la fin + Save pages Enregistrer les pages + Save these pages as one PDF Enregistrer ces pages dans un seul PDF + No changes to save Aucune modification à enregistrer + Created a PDF from {n} files; left out: {names} PDF créé à partir de {n} fichiers ; ignorés : {names} + Created a PDF from the first {n} files PDF créé à partir des {n} premiers fichiers + None of those files can be made into a PDF; use PDFs, images or .txt files Aucun de ces fichiers ne peut être converti en PDF ; utilisez des PDF, des images ou des fichiers .txt # Combine files tab 1 file 1 fichier diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 0e2fb0869a..6eef7ae11f 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -32,6 +32,7 @@ New blank PDF 空白の PDF を作成 Create PDF from file… ファイルから PDF を作成… Create PDF from images… 画像から PDF を作成… + Create PDF from multiple files… 複数のファイルから PDF を作成… Create PDF from clipboard クリップボードから PDF を作成 Combine files… ファイルを結合… Save 保存 @@ -1934,5 +1935,15 @@ Page display: {layout} ページ表示:{layout} Fit to width scrolling 幅に合わせてスクロール Fit one full page 1ページずつ全体を表示 + Created a PDF from {n} files {n} 個のファイルから PDF を作成しました + PDF, images and text PDF、画像、テキスト + Insert a file before page {label} ページ {label} の前にファイルを挿入 + Insert a file at the end 最後にファイルを挿入 + Save pages ページを保存 + Save these pages as one PDF これらのページを 1 つの PDF として保存 + No changes to save 保存する変更はありません + Created a PDF from {n} files; left out: {names} {n} 個のファイルから PDF を作成しました。除外: {names} + Created a PDF from the first {n} files 最初の {n} 個のファイルから PDF を作成しました + None of those files can be made into a PDF; use PDFs, images or .txt files これらのファイルは PDF にできません。PDF、画像、.txt ファイルを使用してください Choose a PNG or JPEG image (up to 4 MiB). Transparency is preserved. PNG または JPEG 画像を選択してください(最大 4 MiB)。透明度は保持されます。 Couldn't import the signature image: {e} 署名画像を読み込めませんでした: {e} diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 7637345c15..56b0deb905 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -25,6 +25,7 @@ New blank PDF Novo PDF em branco Create PDF from file… Criar PDF a partir de arquivo… Create PDF from images… Criar PDF a partir de imagens… + Create PDF from multiple files… Criar PDF a partir de vários arquivos… Create PDF from clipboard Criar PDF a partir da área de transferência Combine files… Combinar arquivos… Save Salvar diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index 2734a7764e..f78afde6c5 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -27,6 +27,7 @@ comment menu Edit సవరించు Open… తెరువు… New blank PDF కొత్త ఖాళీ PDF Create PDF from file… ఫైల్ నుండి PDF సృష్టించు… + Create PDF from multiple files… అనేక ఫైల్‌ల నుండి PDF సృష్టించు… Create PDF from images… చిత్రాల నుండి PDF సృష్టించు… Create PDF from clipboard క్లిప్‌బోర్డ్ నుండి PDF సృష్టించు Combine files… ఫైల్‌లను కలుపు… diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index e7b99f878e..508aaf5710 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -69,6 +69,7 @@ New blank PDF 新建空白 PDF Create PDF from file… 从文件创建 PDF… Create PDF from images… 从图像创建 PDF… + Create PDF from multiple files… 从多个文件创建 PDF… Create PDF from clipboard 从剪贴板创建 PDF Combine files… 合并文件… Save 保存 @@ -2003,5 +2004,15 @@ XFA form: its fields and its XFA data are kept in step, so other viewers show what you fill in. XFA 表单:字段与 XFA 数据保持同步,以便其他阅读器显示您填写的内容。 @plural {n} page|{n} pages {n} 页 @plural {n} field|{n} fields {n} 个字段 + Created a PDF from {n} files 已从 {n} 个文件创建 PDF + PDF, images and text PDF、图像和文本 + Insert a file before page {label} 在第 {label} 页之前插入文件 + Insert a file at the end 在末尾插入文件 + Save pages 保存页面 + Save these pages as one PDF 将这些页面保存为一个 PDF + No changes to save 没有要保存的更改 + Created a PDF from {n} files; left out: {names} 已从 {n} 个文件创建 PDF;已忽略:{names} + Created a PDF from the first {n} files 已从前 {n} 个文件创建 PDF + None of those files can be made into a PDF; use PDFs, images or .txt files 这些文件都无法转换为 PDF;请使用 PDF、图像或 .txt 文件 Choose a PNG or JPEG image (up to 4 MiB). Transparency is preserved. 选择 PNG 或 JPEG 图像(最大 4 MiB)。透明度会保留。 Couldn't import the signature image: {e} 无法导入签名图像:{e} diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index d1762eb78e..39a214713c 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -28,6 +28,7 @@ New blank PDF 新增空白 PDF Create PDF from file… 從檔案建立 PDF… Create PDF from images… 從圖片建立 PDF… + Create PDF from multiple files… 從多個檔案建立 PDF… Create PDF from clipboard 從剪貼簿建立 PDF Combine files… 合併檔案… Save 儲存 @@ -1927,5 +1928,15 @@ Page display: {layout} 頁面顯示:{layout} Fit to width scrolling 符合寬度連續捲動 Fit one full page 單頁完整顯示 + Created a PDF from {n} files 已從 {n} 個檔案建立 PDF + PDF, images and text PDF、圖片和文字 + Insert a file before page {label} 在第 {label} 頁之前插入檔案 + Insert a file at the end 在結尾插入檔案 + Save pages 儲存頁面 + Save these pages as one PDF 將這些頁面儲存為一個 PDF + No changes to save 沒有要儲存的變更 + Created a PDF from {n} files; left out: {names} 已從 {n} 個檔案建立 PDF;已略過:{names} + Created a PDF from the first {n} files 已從前 {n} 個檔案建立 PDF + None of those files can be made into a PDF; use PDFs, images or .txt files 這些檔案都無法轉換為 PDF;請使用 PDF、圖片或 .txt 檔案 Choose a PNG or JPEG image (up to 4 MiB). Transparency is preserved. 選擇 PNG 或 JPEG 圖像(最大 4 MiB)。透明度會保留。 Couldn't import the signature image: {e} 無法匯入簽名圖像:{e} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9e29b3c0c2..32cecb1704 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -34,9 +34,11 @@ pub mod comments; mod comments_panel; mod compare_ui; pub mod control; +mod create_multiple_ui; mod create_ui; mod credits; mod crop; +mod drag_pointer; mod export_ui; mod js_ui; mod marks_ui; @@ -188,6 +190,14 @@ pub enum QuickTool { Snapshot, } +/// Files dropped on a document's page grid. +struct GridDrop { + doc: pdfcraft_engine::DocId, + files: Vec<(String, Vec)>, + /// When to stop waiting for the pointer (egui time, seconds). + deadline: f64, +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Dialog { CreateImages, @@ -449,6 +459,8 @@ pub struct PdfCraftApp { pub optimize_draft: OptimizeDraft, /// Pages copied or cut in Organize Pages, ready to paste (into any document). pub page_clipboard: Option, + /// Files dropped on the page grid, waiting for the pointer to say which gap they go to. + grid_drop: Option, /// The last snapshot (width, height, RGBA); `system_clipboard` also puts it on the /// system clipboard (tests turn that off). pub last_snapshot: Option<(u32, u32, Vec)>, @@ -648,6 +660,7 @@ impl PdfCraftApp { stamp_draft: Default::default(), optimize_draft: OptimizeDraft::default(), page_clipboard: None, + grid_drop: None, last_snapshot: None, system_clipboard: true, attach_override: None, @@ -819,6 +832,56 @@ impl PdfCraftApp { } } + /// The document whose page grid is showing and may take pages, if any. + fn grid_target(&self) -> Option { + let view = self.active.and_then(|i| self.views.get(i)).filter(|v| v.organize)?; + (self.dialog.is_none() && self.session.get(view.id)?.allows_assembly()).then_some(view.id) + } + + /// Files dropped while the page grid shows are inserted into it rather than opened: they + /// are kept until [`Self::finish_grid_drop`] knows the gap. Returns the files not taken. + #[cfg(not(target_arch = "wasm32"))] + fn drop_on_grid(&mut self, dropped: Vec, ctx: &egui::Context) -> Vec { + let Some(id) = self.grid_target().filter(|_| !dropped.is_empty()) else { return dropped }; + let mut files = Vec::new(); + for f in dropped.iter().take(pdfcraft_engine::MAX_CREATE_FILES) { + let name = f.path().file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "dropped.pdf".into()); + let bytes = if f.path().is_absolute() { std::fs::read(f.path()).map_err(|e| e.to_string()) } else { f.bytes() }; + match bytes { + Ok(b) => files.push((name, b)), + Err(e) => self.notify_fmt("Couldn't read {name}: {e}", &[("name", &name), ("e", &e)]), + } + } + if !files.is_empty() { + // Where there is no telling where the pointer is during the drag, its place arrives + // with the first move after the drop. + ctx.request_repaint(); + self.grid_drop = Some(GridDrop { doc: id, files, deadline: ctx.input(|i| i.time) + 1.0 }); + } + Vec::new() + } + + /// Insert files dropped on the page grid at the gap under the pointer, or at the end when + /// the pointer hasn't shown up in time. + fn finish_grid_drop(&mut self, ctx: &egui::Context) { + let Some(GridDrop { doc: id, deadline, .. }) = &self.grid_drop else { return }; + let (id, deadline) = (*id, *deadline); + if self.grid_target() != Some(id) { + self.grid_drop = None; + return self.notify_tr("The document changed while you were choosing a file, so nothing was changed."); + } + let Some(view) = self.active.and_then(|i| self.views.get_mut(i)) else { return }; + let gap = match view.grid_gap { + Some(gap) => gap, + None if ctx.input(|i| i.time) >= deadline => usize::MAX, + None => return ctx.request_repaint(), + }; + view.insert_at = Some(gap); + if let Some(drop) = self.grid_drop.take() { + self.insert_files(drop.files); + } + } + /// Browsers read dropped files asynchronously; the bytes land in `inbox` and open next frame. #[cfg(target_arch = "wasm32")] fn open_dropped(&mut self, f: egui::DroppedFileHandle, ctx: &egui::Context) { @@ -1461,6 +1524,19 @@ impl PdfCraftApp { } impl eframe::App for PdfCraftApp { + /// While files are dragged over the window the system sends no pointer moves, so egui + /// would keep the place the pointer entered at: tell it where the pointer really is, so the + /// page grid can show (and use) the gap under it. + fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { + if raw.hovered_files.is_empty() && raw.dropped_files.is_empty() { + return; + } + if let Some(pos) = drag_pointer::in_window(ctx) { + raw.events.push(egui::Event::PointerMoved(pos)); + } + ctx.request_repaint(); + } + fn save(&mut self, storage: &mut dyn eframe::Storage) { storage.set_string("pdfcraft", self.persist()); } @@ -1488,6 +1564,9 @@ impl eframe::App for PdfCraftApp { } } self.sync_theme(ctx); + // Before taking this frame's drop: the grid must be drawn once with the pointer where + // the files were let go before the gap is read. + self.finish_grid_drop(ctx); // Showing a document hides the Combine files tab. if self.active.is_some() { self.combine_tab.focused = false; @@ -1495,6 +1574,8 @@ impl eframe::App for PdfCraftApp { #[cfg(target_arch = "wasm32")] self.process_signature_images(); let dropped = ctx.input(|i| i.raw.dropped_files.clone()); + #[cfg(not(target_arch = "wasm32"))] + let dropped = self.drop_on_grid(dropped, ctx); for f in dropped { // Files dropped on the Combine files tab join its list instead of opening. if self.combine_showing() { diff --git a/crates/ui-egui/tests/commands.rs b/crates/ui-egui/tests/commands.rs index 28e4e01bbe..55058b4fc4 100644 --- a/crates/ui-egui/tests/commands.rs +++ b/crates/ui-egui/tests/commands.rs @@ -51,6 +51,7 @@ const PICKERS: &[&str] = &[ "file.save_as", "create.file", "create.images", + "create.multiple", "page.replace", "create.clipboard", "a11y.report", diff --git a/crates/ui-egui/tests/create.rs b/crates/ui-egui/tests/create.rs index 11d3935277..85d668548c 100644 --- a/crates/ui-egui/tests/create.rs +++ b/crates/ui-egui/tests/create.rs @@ -132,3 +132,63 @@ fn the_pdf_optimizer_dialog_saves_an_optimized_copy() { assert!(std::fs::read(&out).unwrap().starts_with(b"%PDF-")); assert_eq!(h.state().dialog, None); } + +fn mixed_files(app: &PdfCraftApp) -> Vec<(String, Vec)> { + 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 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::>(), ["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") { + 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.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()); +} diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index 3aed0f6cb0..1073478233 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -1001,6 +1001,132 @@ fn inserting_a_file_goes_after_the_selection_and_undoes() { assert_eq!(texts_of(h.state(), 0), ["Page 1", "Page 2"]); } +#[test] +fn plus_between_pages_inserts_picked_files_there_in_order() { + let dir = temp_path("insert-gap"); + std::fs::create_dir_all(&dir).unwrap(); + let (extra, note) = (dir.join("extra.pdf"), dir.join("note.txt")); + std::fs::write(&extra, fixture(2)).unwrap(); + std::fs::write(¬e, "a note").unwrap(); + let mut h = organize(3); + // Page 3 is selected, yet the files go where the "+" is: between pages 1 and 2. + h.get_by_label("Page 3").click(); + h.run_steps(2); + h.state_mut().pick_override = Some(vec![extra.to_string_lossy().into_owned(), note.to_string_lossy().into_owned()]); + h.get_by_label("Insert a file before page 2").click(); + h.run_steps(4); + assert_eq!(texts_of(h.state(), 0), ["Page 1", "Page 1", "Page 2", "a note", "Page 2", "Page 3"]); + assert_eq!(picked(&h), [1, 2, 3], "the inserted pages are selected"); + // Each file is one undo step. + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(3); + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(3); + assert_eq!(texts_of(h.state(), 0), ["Page 1", "Page 2", "Page 3"]); + // Both ends. + h.state_mut().pick_override = Some(vec![note.to_string_lossy().into_owned()]); + h.get_by_label("Insert a file before page 1").click(); + h.run_steps(4); + h.get_by_label("Insert a file at the end").click(); + h.run_steps(4); + assert_eq!(texts_of(h.state(), 0), ["a note", "Page 1", "Page 2", "Page 3", "a note"]); + // The toolbar button still inserts after the selection, not at the last "+" used. + h.get_by_label("Page 2").click(); + h.run_steps(2); + h.get_by_label("Insert pages from a file…").click(); + h.run_steps(4); + assert_eq!(texts_of(h.state(), 0), ["a note", "Page 1", "a note", "Page 2", "Page 3", "a note"]); + // A file that can't be converted changes nothing. + let before = texts_of(h.state(), 0); + h.state_mut().use_files(pdfcraft_ui_egui::FilePurpose::InsertPages, vec![("report.docx".into(), b"PK\x03\x04".to_vec())]); + h.run_steps(2); + assert_eq!(texts_of(h.state(), 0), before); +} + +/// A file dropped on the window, as the windowing layer hands it over. +#[derive(Debug)] +struct Dropped(&'static str, Vec); + +impl egui::DroppedFile for Dropped { + fn path(&self) -> &std::path::Path { + std::path::Path::new(self.0) + } + fn bytes(&self) -> Result, String> { + Ok(self.1.clone()) + } +} + +fn drop_files(h: &mut Harness<'static, PdfCraftApp>, files: Vec) { + for f in files { + h.input_mut().dropped_files.push(std::sync::Arc::new(f)); + } +} + +#[test] +fn files_dropped_between_pages_are_inserted_there() { + let mut h = organize(3); + // Over the gap between pages 1 and 2. + let gap = h.get_by_label("Insert a file before page 2").rect().center(); + h.hover_at(gap + egui::vec2(0.0, 40.0)); + h.run_steps(2); + drop_files(&mut h, vec![Dropped("extra.pdf", fixture(2)), Dropped("note.txt", b"a note".to_vec())]); + h.run_steps(4); + assert_eq!(h.state().views.len(), 1, "dropped files are not opened as documents"); + assert_eq!(texts_of(h.state(), 0), ["Page 1", "Page 1", "Page 2", "a note", "Page 2", "Page 3"]); + assert_eq!(picked(&h), [1, 2, 3], "the inserted pages are selected"); + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(3); + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(3); + assert_eq!(texts_of(h.state(), 0), ["Page 1", "Page 2", "Page 3"]); + // Right of the last page: the end. + let last = h.get_by_label("Page 3").rect(); + h.hover_at(last.center() + egui::vec2(last.width() * 0.4, 0.0)); + h.run_steps(2); + drop_files(&mut h, vec![Dropped("note.txt", b"a note".to_vec())]); + h.run_steps(4); + assert_eq!(texts_of(h.state(), 0), ["Page 1", "Page 2", "Page 3", "a note"]); +} + +#[test] +fn files_dropped_before_the_pointer_is_known_wait_for_it() { + // Some platforms don't report the pointer while files are dragged over the window. + let mut h = organize(2); + drop_files(&mut h, vec![Dropped("note.txt", b"a note".to_vec())]); + h.run_steps(1); + assert_eq!(texts_of(h.state(), 0).len(), 2, "not placed yet"); + let gap = h.get_by_label("Insert a file before page 2").rect().center(); + h.hover_at(gap + egui::vec2(0.0, 40.0)); + h.run_steps(3); + assert_eq!(texts_of(h.state(), 0), ["Page 1", "a note", "Page 2"]); +} + +#[test] +fn files_dropped_outside_the_page_grid_still_open_as_documents() { + let mut h = harness(2, |_| {}); + drop_files(&mut h, vec![Dropped("other.pdf", fixture(1))]); + h.run_steps(3); + assert_eq!(h.state().views.len(), 2); + assert_eq!(texts_of(h.state(), 0), ["Page 1", "Page 2"]); +} + +#[test] +fn save_pages_writes_what_the_grid_shows() { + let path = temp_path("grid-save.pdf"); + let mut h = organize(3); + h.get_by_label("Page 2").click(); + h.run_steps(2); + h.key_press(Key::Delete); + 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); + assert!(!dirty(&h)); + let mut app = PdfCraftApp::new(); + app.open_bytes("saved.pdf", None, std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(texts_of(&app, 0), ["Page 1", "Page 3"]); +} + #[test] fn split_dialog_writes_one_file_per_part() { let dir = temp_path("split-count"); @@ -1104,6 +1230,7 @@ fn restricted_documents_show_a_notice_and_block_page_changes() { h.key_press(Key::Delete); h.run_steps(3); assert_eq!(texts_of(h.state(), 0).len(), 2, "page changes are blocked"); + assert_eq!(h.query_all_by_label_contains("Insert a file").count(), 0, "nothing to insert into"); assert!(!dirty(&h)); h.get_by_label("Security settings").click(); h.run_steps(3); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index b6b0ea73d9..22b37a2a33 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -2638,8 +2638,8 @@ milestone = "M4" status = "shipped" commands = ["page.insert"] tools = ["page_insert_file"] -evidence = ["crates/organize/src/tests.rs::inserting_pages_from_another_file_is_an_incremental_edit", "crates/ui-egui/tests/editing.rs::inserting_a_file_goes_after_the_selection_and_undoes"] -notes = "" +evidence = ["crates/organize/src/tests.rs::inserting_pages_from_another_file_is_an_incremental_edit", "crates/ui-egui/tests/editing.rs::inserting_a_file_goes_after_the_selection_and_undoes", "crates/ui-egui/tests/editing.rs::plus_between_pages_inserts_picked_files_there_in_order"] +notes = "After the selection (toolbar, File menu) or at any gap of the page grid (a + between pages and at both ends); several files at once, in order; images and .txt files are converted first; the inserted pages end up selected. The page_insert_file tool takes PDFs only. Missing: choosing which pages of the file to insert in the app." [[feature]] id = "organize.insert-from-clipboard" @@ -2683,11 +2683,11 @@ title = "Insert by dropping files onto the page grid" area = "D" tier = "P1" milestone = "M4" -status = "planned" -commands = [] +status = "partial" +commands = ["page.organize"] tools = [] -evidence = [] -notes = "" +evidence = ["crates/ui-egui/tests/editing.rs::files_dropped_between_pages_are_inserted_there", "crates/ui-egui/tests/editing.rs::files_dropped_before_the_pointer_is_known_wait_for_it", "crates/ui-egui/tests/editing.rs::files_dropped_outside_the_page_grid_still_open_as_documents"] +notes = "Desktop: PDFs, images and .txt files dropped on the Organize page grid are inserted at the gap under the pointer, in order, and selected (elsewhere a dropped file still opens as a document). While the files hover, a bar at the gap and a + badge follow the pointer; on macOS the pointer is read from AppKit, because the windowing layer sends no moves during a file drag. Missing: on Windows and Linux there is no live position, so the grid is only outlined and the files go to the gap the pointer is at right after the drop (or the end after a second); the web build (dropped files open as documents). Covered by simulated drop events; a real drag from Finder is not in the automated tests." [[feature]] id = "organize.extract-pages" @@ -6798,12 +6798,12 @@ id = "create.create-multiple" title = "Convert many files to PDF in one run" area = "J" tier = "P1" -milestone = "M13" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +milestone = "M10" +status = "shipped" +commands = ["create.multiple"] +tools = ["doc_create_multiple"] +evidence = ["crates/engine/src/tests.rs::mixed_files_convert_and_combine_in_order", "crates/engine/src/tests.rs::files_that_cannot_be_converted_are_refused_clearly", "crates/automation/tests/automation.rs::creating_from_multiple_files_through_tools", "crates/ui-egui/tests/create.rs::multiple_files_open_as_one_document_in_the_page_grid", "crates/ui-egui/tests/editing.rs::plus_between_pages_inserts_picked_files_there_in_order", "crates/ui-egui/tests/editing.rs::save_pages_writes_what_the_grid_shows"] +notes = "Create ▸ Multiple files: the picked PDFs, images (PNG, JPEG, JPEG 2000, TIFF, GIF, BMP) and .txt files open as one unsaved document in the page grid, in the order picked (a bookmark per file); there pages are removed, reordered, more files inserted at any gap (a + button, or dropped there), and Save pages writes what is shown. One PDF per file is offered by the doc_create_multiple tool only (mode separate), not in the app. Missing: Office, HTML and Markdown inputs, PDF Portfolio output, per-file image resolution choice, progress and cancel for large batches (conversion runs on the UI thread). The combined file has combine's limits: untagged, no page labels or document info carried over, and its per-file bookmarks are not updated when pages are later moved between files." [[feature]] id = "create.image-conversion-options"