mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-14 19:27:04 -04:00
Co-authored-by: Codex <[email protected]> Co-authored-by: Brandon Thomas <[email protected]>
This commit is contained in:
16 files changed
+294
-13
No files matched your search
+3
-1
@@ -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).
|
||||
|
||||
@@ -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<String> = 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) {
|
||||
|
||||
@@ -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")?) {
|
||||
|
||||
@@ -885,6 +885,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
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 },
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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.
|
||||
@@ -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<u8>)]) -> Result<Document, CreateError> {
|
||||
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<u8>)], resolution: ImageResolution) -> Result<Document, CreateError> {
|
||||
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::<Result<Vec<_>, _>>()?.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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<u8>)], resolution: ImageResolution) -> Result<Arc<Vec<u8>>, 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<Arc<Vec<u8>>, EditError> {
|
||||
self.write_new(&pdfcraft_create::from_text(title, text, pdfcraft_create::LETTER, 11.0)?)
|
||||
|
||||
@@ -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<u8>)>,
|
||||
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<u8>)>) {
|
||||
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::<Result<Vec<_>, 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<u8>)>) {
|
||||
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<u8>)>, 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())]);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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<pdfcraft_engine::optimize::SpaceUse>,
|
||||
/// Combine files: the files staged so far.
|
||||
pub combine_draft: Vec<combine_ui::CombineFile>,
|
||||
/// Images waiting for the resolution choice (released on cancel).
|
||||
pub image_import: Option<create_ui::ImageImport>,
|
||||
/// The custom stamp library, and the stamp being created.
|
||||
pub custom_stamps: Vec<stamps_ui::CustomStamp>,
|
||||
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(),
|
||||
|
||||
@@ -14,6 +14,43 @@ fn png() -> Vec<u8> {
|
||||
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();
|
||||
|
||||
@@ -74,6 +74,38 @@
|
||||
<RegistryValue Root="HKLM" Key="Software\PdfCraft\Capabilities" Name="ApplicationIcon" Value="[#PdfcraftExe],0" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\PdfCraft\Capabilities\FileAssociations" Name=".pdf" Value="PdfCraft.Document" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\RegisteredApplications" Name="PdfCraft" Value="Software\PdfCraft\Capabilities" Type="string" />
|
||||
|
||||
<!-- Image context menu (#190): convert without changing image file defaults. -->
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\SystemFileAssociations\.png\shell\PdfCraft.CreatePdf" Value="Create PDF with PdfCraft…" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\SystemFileAssociations\.png\shell\PdfCraft.CreatePdf" Name="MultiSelectModel" Value="Single" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\SystemFileAssociations\.png\shell\PdfCraft.CreatePdf\command" Value=""[#PdfcraftExe]" --create-images "%1"" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\SystemFileAssociations\.jpg\shell\PdfCraft.CreatePdf" Value="Create PDF with PdfCraft…" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\SystemFileAssociations\.jpg\shell\PdfCraft.CreatePdf" Name="MultiSelectModel" Value="Single" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\SystemFileAssociations\.jpg\shell\PdfCraft.CreatePdf\command" Value=""[#PdfcraftExe]" --create-images "%1"" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\SystemFileAssociations\.jpeg\shell\PdfCraft.CreatePdf" Value="Create PDF with PdfCraft…" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\SystemFileAssociations\.jpeg\shell\PdfCraft.CreatePdf" Name="MultiSelectModel" Value="Single" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\SystemFileAssociations\.jpeg\shell\PdfCraft.CreatePdf\command" Value=""[#PdfcraftExe]" --create-images "%1"" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\SystemFileAssociations\.tif\shell\PdfCraft.CreatePdf" Value="Create PDF with PdfCraft…" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\SystemFileAssociations\.tif\shell\PdfCraft.CreatePdf" Name="MultiSelectModel" Value="Single" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\SystemFileAssociations\.tif\shell\PdfCraft.CreatePdf\command" Value=""[#PdfcraftExe]" --create-images "%1"" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\SystemFileAssociations\.tiff\shell\PdfCraft.CreatePdf" Value="Create PDF with PdfCraft…" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\SystemFileAssociations\.tiff\shell\PdfCraft.CreatePdf" Name="MultiSelectModel" Value="Single" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\SystemFileAssociations\.tiff\shell\PdfCraft.CreatePdf\command" Value=""[#PdfcraftExe]" --create-images "%1"" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\SystemFileAssociations\.gif\shell\PdfCraft.CreatePdf" Value="Create PDF with PdfCraft…" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\SystemFileAssociations\.gif\shell\PdfCraft.CreatePdf" Name="MultiSelectModel" Value="Single" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\SystemFileAssociations\.gif\shell\PdfCraft.CreatePdf\command" Value=""[#PdfcraftExe]" --create-images "%1"" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\SystemFileAssociations\.bmp\shell\PdfCraft.CreatePdf" Value="Create PDF with PdfCraft…" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\SystemFileAssociations\.bmp\shell\PdfCraft.CreatePdf" Name="MultiSelectModel" Value="Single" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\SystemFileAssociations\.bmp\shell\PdfCraft.CreatePdf\command" Value=""[#PdfcraftExe]" --create-images "%1"" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\SystemFileAssociations\.jp2\shell\PdfCraft.CreatePdf" Value="Create PDF with PdfCraft…" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\SystemFileAssociations\.jp2\shell\PdfCraft.CreatePdf" Name="MultiSelectModel" Value="Single" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\SystemFileAssociations\.jp2\shell\PdfCraft.CreatePdf\command" Value=""[#PdfcraftExe]" --create-images "%1"" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\SystemFileAssociations\.j2k\shell\PdfCraft.CreatePdf" Value="Create PDF with PdfCraft…" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\SystemFileAssociations\.j2k\shell\PdfCraft.CreatePdf" Name="MultiSelectModel" Value="Single" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\SystemFileAssociations\.j2k\shell\PdfCraft.CreatePdf\command" Value=""[#PdfcraftExe]" --create-images "%1"" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\SystemFileAssociations\.jpx\shell\PdfCraft.CreatePdf" Value="Create PDF with PdfCraft…" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\SystemFileAssociations\.jpx\shell\PdfCraft.CreatePdf" Name="MultiSelectModel" Value="Single" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\SystemFileAssociations\.jpx\shell\PdfCraft.CreatePdf\command" Value=""[#PdfcraftExe]" --create-images "%1"" Type="string" />
|
||||
</Component>
|
||||
|
||||
<!-- Shortcuts (#143) are plain links to pdfcraft.exe, not advertised MSI shortcuts: advertised
|
||||
|
||||
@@ -54,6 +54,20 @@ Assert-Equal $app[0] 'PdfcraftExe' 'Shortcut executable key path'
|
||||
$scope = Read-Row 'SELECT `Value` FROM `Property` WHERE `Property` = ''ALLUSERS''' 1
|
||||
Assert-Equal $scope[0] '1' 'Per-machine shortcut scope'
|
||||
|
||||
# Image context menu opens the DPI chooser; the app component owns every registry row so
|
||||
# uninstall removes it. No image default association is changed.
|
||||
foreach ($ext in @('png', 'jpg', 'jpeg', 'tif', 'tiff', 'gif', 'bmp', 'jp2', 'j2k', 'jpx')) {
|
||||
$key = 'Software\Classes\SystemFileAssociations\.' + $ext + '\shell\PdfCraft.CreatePdf'
|
||||
$menu = Read-Row ('SELECT `Value`, `Component_`, `Root` FROM `Registry` WHERE `Key` = ''' + $key + ''' AND `Name` IS NULL') 3
|
||||
Assert-Equal $menu[0] 'Create PDF with PdfCraft…' "$ext context menu label"
|
||||
Assert-Equal $menu[1] 'PdfcraftApp' "$ext context menu component"
|
||||
Assert-Equal $menu[2] '2' "$ext context menu HKLM root"
|
||||
$command = Read-Row ('SELECT `Value` FROM `Registry` WHERE `Key` = ''' + $key + '\command''') 1
|
||||
Assert-Equal $command[0] '"[#PdfcraftExe]" --create-images "%1"' "$ext context menu command"
|
||||
$selection = Read-Row ('SELECT `Value` FROM `Registry` WHERE `Key` = ''' + $key + ''' AND `Name` = ''MultiSelectModel''') 1
|
||||
Assert-Equal $selection[0] 'Single' "$ext context menu selection"
|
||||
}
|
||||
|
||||
# Negative sequences are Windows Installer's success/user-exit/failure paths. Only full UI
|
||||
# shows these dialogs: an unattended /qn or /qb install must never wait for a Finish click.
|
||||
foreach ($exit in @(@('InstallComplete', '-1'), @('InstallCancelled', '-2'), @('InstallFailed', '-3'))) {
|
||||
|
||||
@@ -6811,11 +6811,11 @@ title = "Image compression options when converting"
|
||||
area = "J"
|
||||
tier = "P2"
|
||||
milestone = "M10"
|
||||
status = "planned"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = []
|
||||
notes = ""
|
||||
status = "partial"
|
||||
commands = ["create.images"]
|
||||
tools = ["doc_create"]
|
||||
evidence = ["crates/create/src/tests.rs::image_resolution_override_changes_size_without_resampling", "crates/automation/tests/automation.rs::creating_images_with_dpi_through_tools", "crates/ui-egui/tests/create.rs::image_import_dialog_chooses_dpi_and_cancels"]
|
||||
notes = "Page sizing: embedded resolution (72 dpi fallback), fixed 72 dpi, or custom 1–1200 dpi; pixels are not resampled. Compression options are still absent. Windows image context menu opens the same chooser; other OS context-menu integration not yet."
|
||||
|
||||
[[feature]]
|
||||
id = "create.pdf-printer"
|
||||
|
||||
Reference in new issue
Block a user