#190: choose DPI when creating PDFs from images (#206)

Co-authored-by: Codex <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-07 23:14:22 -04:00
1 parent 4d0078aeff
commit 607d3d1c0e
16 files changed
+294 -13

No files matched your search

+3 -1
View File
@@ -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).
+11 -2
View File
@@ -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) {
+2 -1
View File
@@ -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")?) {
+1
View File
@@ -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 },
+33
View File
@@ -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");
+4
View File
@@ -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.
+25 -1
View File
@@ -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;
+26
View File
@@ -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();
+6
View File
@@ -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)?)
+79 -3
View File
@@ -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())]);
}
+12
View File
@@ -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();
}
+4
View File
@@ -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(),
+37
View File
@@ -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();
+32
View File
@@ -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="&quot;[#PdfcraftExe]&quot; --create-images &quot;%1&quot;" 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="&quot;[#PdfcraftExe]&quot; --create-images &quot;%1&quot;" 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="&quot;[#PdfcraftExe]&quot; --create-images &quot;%1&quot;" 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="&quot;[#PdfcraftExe]&quot; --create-images &quot;%1&quot;" 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="&quot;[#PdfcraftExe]&quot; --create-images &quot;%1&quot;" 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="&quot;[#PdfcraftExe]&quot; --create-images &quot;%1&quot;" 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="&quot;[#PdfcraftExe]&quot; --create-images &quot;%1&quot;" 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="&quot;[#PdfcraftExe]&quot; --create-images &quot;%1&quot;" 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="&quot;[#PdfcraftExe]&quot; --create-images &quot;%1&quot;" 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="&quot;[#PdfcraftExe]&quot; --create-images &quot;%1&quot;" Type="string" />
</Component>
<!-- Shortcuts (#143) are plain links to pdfcraft.exe, not advertised MSI shortcuts: advertised
+14
View File
@@ -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'))) {
+5 -5
View File
@@ -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"