From adb644c5e1e7a6c723d4cb3daf5267ca2a772b53 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 23:29:17 -0400 Subject: [PATCH] M10.2/M11.1: Create a PDF (blank, from PNG/JPEG images at their resolution with transparency, from plain text) in a new create crate (L4); opening an image or text file converts it to a new unsaved PDF; Reduce file size (resource merge across the document, GC, object streams) writes a compact copy; Create group Ready; doc_create/doc_reduce tools (49 tools). Fix: the Hand tool did not pan (the page widget took the drag) Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 12 ++ Cargo.toml | 1 + crates/automation/src/lib.rs | 49 +++++ crates/automation/src/tools.rs | 21 ++ crates/automation/tests/automation.rs | 15 ++ crates/create/Cargo.toml | 18 ++ crates/create/README.md | 12 ++ crates/create/src/lib.rs | 286 ++++++++++++++++++++++++++ crates/create/src/tests.rs | 99 +++++++++ crates/engine/Cargo.toml | 1 + crates/engine/src/catalog.rs | 10 +- crates/engine/src/commands.rs | 4 + crates/engine/src/lib.rs | 31 +++ crates/engine/src/tests.rs | 21 ++ crates/ui-egui/src/canvas.rs | 9 + crates/ui-egui/src/commands.rs | 4 + crates/ui-egui/src/create_ui.rs | 128 ++++++++++++ crates/ui-egui/src/lib.rs | 13 +- crates/ui-egui/tests/commands.rs | 2 +- crates/ui-egui/tests/create.rs | 46 +++++ crates/ui-egui/tests/view.rs | 21 ++ parity/acrobat-features.toml | 44 ++-- 22 files changed, 817 insertions(+), 30 deletions(-) create mode 100644 crates/create/Cargo.toml create mode 100644 crates/create/README.md create mode 100644 crates/create/src/lib.rs create mode 100644 crates/create/src/tests.rs create mode 100644 crates/ui-egui/src/create_ui.rs create mode 100644 crates/ui-egui/tests/create.rs diff --git a/Cargo.lock b/Cargo.lock index febc7d00e..051ced87b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3072,6 +3072,17 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "printcraft-create" +version = "0.1.0" +dependencies = [ + "hayro-syntax", + "png 0.18.1", + "printcraft-cos", + "printcraft-fonts", + "thiserror 2.0.21", +] + [[package]] name = "printcraft-crypt" version = "0.1.0" @@ -3103,6 +3114,7 @@ dependencies = [ "png 0.18.1", "printcraft-annot", "printcraft-cos", + "printcraft-create", "printcraft-edit", "printcraft-forms", "printcraft-model", diff --git a/Cargo.toml b/Cargo.toml index 6173d989b..d9b0543d5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ printcraft-annot = { path = "crates/annot", version = "0.1.0" } printcraft-fonts = { path = "crates/fonts", version = "0.1.0" } printcraft-model = { path = "crates/model", version = "0.1.0" } printcraft-edit = { path = "crates/edit", version = "0.1.0" } +printcraft-create = { path = "crates/create", version = "0.1.0" } printcraft-forms = { path = "crates/forms", version = "0.1.0" } printcraft-render = { path = "crates/render", version = "0.1.0" } printcraft-engine = { path = "crates/engine", version = "0.1.0" } diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 17c8832fb..6ae08a88e 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -216,6 +216,15 @@ impl Automation { self.apply(&a, Edit::DuplicatePages { pages })? } "page_set_box" => self.page_set_box(&a)?, + "doc_create" => self.doc_create(&a)?, + "doc_reduce" => { + let id = self.doc(&a)?.id; + let before = self.doc(&a)?.bytes.len(); + let path = self.resolve(a.str("path")?, true)?; + let (bytes, merged) = self.session.reduced_bytes(id).map_err(failed)?; + write_atomic(&path, &bytes)?; + json!({ "path": path.to_string_lossy(), "bytes_before": before, "bytes_after": bytes.len(), "merged_objects": merged }) + } "doc_export_images" | "doc_export_text" => self.export(name, &a)?, "doc_header_footer" | "doc_watermark" | "doc_background" | "doc_remove_marks" => self.marks(name, &a)?, "doc_unprotect" => { @@ -377,6 +386,46 @@ impl Automation { Ok(json!({ "count": files.len(), "files": files })) } + fn doc_create(&mut self, a: &Args) -> Result { + let (name, bytes) = match a.str("from")? { + "blank" => { + let n = a.opt_int("pages")?.unwrap_or(1).clamp(1, 10_000) as usize; + let (w, h) = (a.opt_num("width")?.unwrap_or(612.0), a.opt_num("height")?.unwrap_or(792.0)); + ("Untitled.pdf".to_string(), self.session.create_blank(w, h, n).map_err(failed)?) + } + "images" => { + let mut images = Vec::new(); + for p in a.strs("paths")? { + let path = self.resolve(p, false)?; + let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; + images.push((path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), bytes)); + } + let name = if images.len() == 1 { + format!("{}.pdf", images[0].0.rsplit_once('.').map_or(images[0].0.as_str(), |(s, _)| s)) + } else { + "Images.pdf".into() + }; + (name, self.session.create_from_images(&images).map_err(failed)?) + } + "text" => { + let (title, text) = match (a.opt_str("text")?, a.opt_str("path")?) { + (Some(t), _) => ("Text".to_string(), t.to_string()), + (None, Some(p)) => { + let path = self.resolve(p, false)?; + let t = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; + (path.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(), String::from_utf8_lossy(&t).into_owned()) + } + (None, None) => return Err(ToolError::InvalidArgs("text needs `text` or `path`".into())), + }; + (format!("{title}.pdf"), self.session.create_from_text(&title, &text).map_err(failed)?) + } + other => return Err(ToolError::InvalidArgs(format!("unknown source {other:?}"))), + }; + let name = a.opt_str("name")?.map(str::to_owned).unwrap_or(name); + let id = self.session.open_new(name, bytes).map_err(failed)?; + Ok(summary(self.session.get(id).ok_or_else(|| failed("the document vanished"))?)) + } + fn page_set_box(&mut self, a: &Args) -> Result { use printcraft_engine::{BoxSpec, PageBox}; let doc = self.doc(a)?; diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 98d69a494..63f48b5a6 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -369,6 +369,27 @@ pub fn tools() -> Vec { }), &["doc", "page", "type", "at"], )), + t( + "doc_create", + "Create a PDF", + "Create a new, unsaved document and return it like doc_open: `blank` (pages, width, height in points; default 1 US Letter page), `images` (paths of PNG/JPEG files, one page each at the image's resolution) or `text` (a .txt path, or `text` directly). Save it with doc_save and a path.", + ) + .with(schema( + json!({ + "from": { "type": "string", "enum": ["blank", "images", "text"] }, + "paths": { "type": "array", "items": { "type": "string" }, "minItems": 1 }, + "text": { "type": "string" }, + "path": { "type": "string" }, + "pages": { "type": "integer", "minimum": 1, "maximum": 10000 }, + "width": { "type": "number", "minimum": 3 }, + "height": { "type": "number", "minimum": 3 }, + "name": { "type": "string", "description": "Name for the new document (default derived from the source)." }, + }), + &["from"], + )), + t("doc_reduce", "Reduce file size", "Write a compacted copy of the document to `path` (identical fonts and images merged, unused objects dropped, compressed object streams). The open document is unchanged.") + .cmd("optimize.reduce") + .with(schema(json!({ "doc": doc(), "path": { "type": "string" } }), &["doc", "path"])), t("edit_undo", "Undo", "Undo the last edit of a document.").cmd("edit.undo").with(schema(json!({ "doc": doc() }), &["doc"])), t("edit_redo", "Redo", "Redo the last undone edit of a document.").cmd("edit.redo").with(schema(json!({ "doc": doc() }), &["doc"])), t("command_list", "List commands", "Every registered PrintCraft command with its menu, shortcut, whether it is enabled now, and the tool that automates it.") diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index daf817471..2e998d927 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -547,3 +547,18 @@ fn fill_and_sign_through_tools() { assert_eq!(list["count"], 3); assert!(matches!(a.call("fill_sign_add", &json!({ "doc": doc, "page": 1, "type": "text", "at": [1, 1] })), Err(ToolError::InvalidArgs(_)))); } + +#[test] +fn creating_and_reducing_through_tools() { + let dir = workdir("create"); + std::fs::write(dir.join("notes.txt"), "Meeting notes\nAction items").unwrap(); + let mut a = auto(&dir); + let blank = ok(&mut a, "doc_create", json!({ "from": "blank", "pages": 2 })); + assert_eq!((blank["pages"].as_u64(), blank["dirty"].as_bool()), (Some(2), Some(true))); + let t = ok(&mut a, "doc_create", json!({ "from": "text", "path": "notes.txt" }))["doc"].as_u64().unwrap(); + assert_eq!(page_text(&mut a, t), ["Meeting notes\nAction items"]); + ok(&mut a, "doc_save", json!({ "doc": t, "path": "notes.pdf" })); + let r = ok(&mut a, "doc_reduce", json!({ "doc": t, "path": "notes-small.pdf" })); + assert!(r["bytes_after"].as_u64().unwrap() > 0 && dir.join("notes-small.pdf").exists()); + assert!(matches!(a.call("doc_create", &json!({ "from": "images", "paths": ["notes.txt"] })), Err(ToolError::Failed(_)))); +} diff --git a/crates/create/Cargo.toml b/crates/create/Cargo.toml new file mode 100644 index 000000000..4b73c2706 --- /dev/null +++ b/crates/create/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "printcraft-create" +description = "Create PDFs: blank documents, from images (PNG, JPEG) and from plain text" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] +printcraft-cos = { workspace = true } +printcraft-fonts = { workspace = true } +png = "0.18" +thiserror = { workspace = true } + +[dev-dependencies] +hayro-syntax = { workspace = true } diff --git a/crates/create/README.md b/crates/create/README.md new file mode 100644 index 000000000..eba6b357e --- /dev/null +++ b/crates/create/README.md @@ -0,0 +1,12 @@ +# printcraft-create + +Layer L4. Create a PDF (Acrobat's Create a PDF tool), execution plan M10.2: + +- `blank(width, height, pages)`: an empty document; +- `from_images(&[(name, bytes)])`: one page per image, sized from the image's resolution + (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_text(text, …)`: plain text set in Helvetica, wrapped and paginated. + +Every function returns a `printcraft_cos::Document`; the caller writes it. diff --git a/crates/create/src/lib.rs b/crates/create/src/lib.rs new file mode 100644 index 000000000..626ce345e --- /dev/null +++ b/crates/create/src/lib.rs @@ -0,0 +1,286 @@ +//! printcraft-create — create PDFs from nothing, images or text (L4). See the README. + +use printcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; +use printcraft_fonts::{literal, win_ansi, wrap}; + +#[derive(Debug, thiserror::Error, PartialEq)] +pub enum CreateError { + #[error("{0}: {1}")] + Image(String, String), + #[error("{0}")] + Invalid(String), +} + +/// US Letter in points. +pub const LETTER: (f64, f64) = (612.0, 792.0); + +/// The largest page side PDF allows (ISO 32000-2 Annex C: 14 400 units). +const MAX_SIDE: f64 = 14_400.0; + +fn add_page(doc: &mut Document, w: f64, h: f64, resources: Dict, content: Option>) -> ObjRef { + let pages = doc.root().and_then(|r| doc.get(r).as_dict().and_then(|d| d.reference(b"Pages"))).expect("new_empty has a page tree"); + let mut page = Dict::new(); + page.set(b"Type".to_vec(), Object::name("Page")); + page.set(b"Parent".to_vec(), Object::Ref(pages)); + page.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), Object::Real(w), Object::Real(h)])); + page.set(b"Resources".to_vec(), Object::Dict(resources)); + if let Some(c) = content { + let s = doc.add(Object::Stream(Stream::flate(Dict::new(), &c))); + page.set(b"Contents".to_vec(), Object::Ref(s)); + } + let r = doc.add(page); + let _ = doc.update_dict(pages, |d| { + let mut kids = d.get(b"Kids").and_then(|k| k.as_array().cloned()).unwrap_or_default(); + kids.push(Object::Ref(r)); + d.set(b"Count".to_vec(), Object::Int(kids.len() as i64)); + d.set(b"Kids".to_vec(), Object::Array(kids)); + }); + r +} + +fn set_title(doc: &mut Document, title: &str) { + let mut info = Dict::new(); + info.set(b"Title".to_vec(), PdfString::text(title)); + info.set(b"Producer".to_vec(), PdfString::text("PrintCraft")); + let r = doc.add(info); + doc.trailer_mut().set(b"Info".to_vec(), Object::Ref(r)); +} + +/// A document of `pages` empty pages of `width × height` points. +pub fn blank(width: f64, height: f64, pages: usize) -> Result { + if !(width.is_finite() && height.is_finite() && (3.0..=MAX_SIDE).contains(&width) && (3.0..=MAX_SIDE).contains(&height)) + || pages == 0 + || pages > 10_000 + { + return Err(CreateError::Invalid("invalid page size or count".into())); + } + let mut doc = Document::new_empty(); + for _ in 0..pages { + add_page(&mut doc, width, height, Dict::new(), None); + } + Ok(doc) +} + +// ── images ────────────────────────────────────────────────────────────────────────────────── + +/// An image ready to embed: its XObject dictionary, encoded data, optional soft mask, and size +/// in pixels and dots per inch. +struct Embedded { + dict: Dict, + data: Vec, + filtered: bool, + smask: Option<(Dict, Vec)>, + px: (u32, u32), + dpi: (f64, f64), +} + +fn jpeg(name: &str, bytes: &[u8]) -> Result { + let bad = |m: &str| CreateError::Image(name.into(), m.into()); + if bytes.len() < 4 || bytes[0] != 0xFF || bytes[1] != 0xD8 { + return Err(bad("not a JPEG file")); + } + let (mut i, mut size, mut comps, mut dpi, mut adobe) = (2usize, None, 0u8, (72.0, 72.0), false); + while i + 4 <= bytes.len() { + if bytes[i] != 0xFF { + i += 1; + continue; + } + let marker = bytes[i + 1]; + if marker == 0xD8 || marker == 0x01 || (0xD0..=0xD7).contains(&marker) { + i += 2; + continue; + } + let len = u16::from_be_bytes([bytes[i + 2], bytes[i + 3]]) as usize; + let seg = bytes.get(i + 4..i + 2 + len).ok_or_else(|| bad("truncated"))?; + match marker { + // APP0 JFIF density. + 0xE0 if seg.starts_with(b"JFIF\0") && seg.len() >= 12 => { + let (unit, x, y) = (seg[7], u16::from_be_bytes([seg[8], seg[9]]) as f64, u16::from_be_bytes([seg[10], seg[11]]) as f64); + if x > 0.0 && y > 0.0 { + dpi = match unit { + 1 => (x, y), + 2 => (x * 2.54, y * 2.54), + _ => dpi, + }; + } + } + 0xEE if seg.starts_with(b"Adobe") => adobe = true, + 0xC0..=0xCF if marker != 0xC4 && marker != 0xC8 && marker != 0xCC => { + if seg.len() < 6 { + return Err(bad("bad frame header")); + } + size = Some((u16::from_be_bytes([seg[3], seg[4]]) as u32, u16::from_be_bytes([seg[1], seg[2]]) as u32)); + comps = seg[5]; + break; + } + 0xDA => break, + _ => {} + } + i += 2 + len; + } + let (w, h) = size.filter(|(w, h)| *w > 0 && *h > 0).ok_or_else(|| bad("no image size"))?; + let mut d = Dict::new(); + let cs = match comps { + 1 => "DeviceGray", + 3 => "DeviceRGB", + 4 => { + if adobe { + // Adobe writes CMYK JPEGs inverted. + d.set(b"Decode".to_vec(), Object::Array([1, 0, 1, 0, 1, 0, 1, 0].iter().map(|v| Object::Int(*v)).collect())); + } + "DeviceCMYK" + } + n => return Err(bad(&format!("{n} colour components are not supported"))), + }; + d.set(b"ColorSpace".to_vec(), Object::name(cs)); + d.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + d.set(b"Filter".to_vec(), Object::name("DCTDecode")); + Ok(Embedded { dict: d, data: bytes.to_vec(), filtered: true, smask: None, px: (w, h), dpi }) +} + +fn png_image(name: &str, bytes: &[u8]) -> Result { + let bad = |m: String| CreateError::Image(name.into(), m); + let mut dec = png::Decoder::new(std::io::Cursor::new(bytes)); + dec.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16); + let mut reader = dec.read_info().map_err(|e| bad(e.to_string()))?; + let dpi = match reader.info().pixel_dims { + Some(png::PixelDimensions { xppu, yppu, unit: png::Unit::Meter }) if xppu > 0 && yppu > 0 => (xppu as f64 * 0.0254, yppu as f64 * 0.0254), + _ => (72.0, 72.0), + }; + let mut buf = vec![0; reader.output_buffer_size().ok_or_else(|| bad("image too large".into()))?]; + let frame = reader.next_frame(&mut buf).map_err(|e| bad(e.to_string()))?; + let (w, h) = (frame.width, frame.height); + let data = &buf[..frame.buffer_size()]; + let (channels, cs) = match frame.color_type { + png::ColorType::Grayscale => (1, "DeviceGray"), + png::ColorType::GrayscaleAlpha => (2, "DeviceGray"), + png::ColorType::Rgb => (3, "DeviceRGB"), + png::ColorType::Rgba => (4, "DeviceRGB"), + png::ColorType::Indexed => return Err(bad("unexpected palette output".into())), + }; + let has_alpha = channels == 2 || channels == 4; + let colour_n = if has_alpha { channels - 1 } else { channels }; + let mut colour = Vec::with_capacity(w as usize * h as usize * colour_n); + let mut alpha = Vec::new(); + for px in data.chunks_exact(channels) { + colour.extend_from_slice(&px[..colour_n]); + if has_alpha { + alpha.push(px[colour_n]); + } + } + let mut d = Dict::new(); + d.set(b"ColorSpace".to_vec(), Object::name(cs)); + d.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + let smask = (has_alpha && alpha.iter().any(|a| *a != 255)).then(|| { + let mut m = Dict::new(); + m.set(b"Type".to_vec(), Object::name("XObject")); + m.set(b"Subtype".to_vec(), Object::name("Image")); + m.set(b"Width".to_vec(), Object::Int(w as i64)); + m.set(b"Height".to_vec(), Object::Int(h as i64)); + m.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + m.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + (m, alpha) + }); + Ok(Embedded { dict: d, data: colour, filtered: false, smask, px: (w, h), dpi }) +} + +/// Detect the image format from its bytes. +fn embed(name: &str, bytes: &[u8]) -> Result { + if bytes.starts_with(&[0xFF, 0xD8]) { + jpeg(name, bytes) + } else if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { + png_image(name, bytes) + } else { + Err(CreateError::Image(name.into(), "only PNG and JPEG images are supported so far".into())) + } +} + +/// One page per image, each the size of its image at the image's resolution. +pub fn from_images(images: &[(String, Vec)]) -> Result { + if images.is_empty() { + return Err(CreateError::Invalid("no images".into())); + } + let mut doc = Document::new_empty(); + for (name, bytes) in images { + let img = embed(name, bytes)?; + 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); + // Keep huge images within the largest page PDF allows. + let k = (MAX_SIDE / w.max(h)).min(1.0); + w *= k; + h *= k; + let mut d = img.dict; + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(img.px.0 as i64)); + d.set(b"Height".to_vec(), Object::Int(img.px.1 as i64)); + if let Some((m, alpha)) = img.smask { + let mr = doc.add(Object::Stream(Stream::flate(m, &alpha))); + d.set(b"SMask".to_vec(), Object::Ref(mr)); + } + let stream = if img.filtered { Stream::from_raw(d, img.data) } else { Stream::flate(d, &img.data) }; + let xr = doc.add(Object::Stream(stream)); + let mut xobj = Dict::new(); + xobj.set(b"Im0".to_vec(), Object::Ref(xr)); + let mut res = Dict::new(); + res.set(b"XObject".to_vec(), Object::Dict(xobj)); + let content = format!("q {w:.3} 0 0 {h:.3} 0 0 cm /Im0 Do Q\n").into_bytes(); + add_page(&mut doc, w, h, res, Some(content)); + } + if let Some((name, _)) = images.first() { + set_title(&mut doc, name.rsplit_once('.').map_or(name.as_str(), |(s, _)| s)); + } + Ok(doc) +} + +// ── text ──────────────────────────────────────────────────────────────────────────────────── + +/// Plain text set in Helvetica on pages of `page` size with 1-inch margins. +pub fn from_text(title: &str, text: &str, page: (f64, f64), font_size: f64) -> Result { + let size = if font_size.is_finite() && font_size > 0.0 { font_size.clamp(4.0, 72.0) } else { 11.0 }; + let (w, h) = page; + let margin = 72.0; + let line_h = size * 1.25; + let width = (w - 2.0 * margin).max(36.0); + let lines: Vec = text + .replace("\r\n", "\n") + .replace('\t', " ") + .split('\u{c}') + .flat_map(|p| wrap(p, size, width).into_iter().chain(std::iter::once("\u{c}".to_string()))) + .collect(); + let per_page = (((h - 2.0 * margin) / line_h).floor() as usize).max(1); + let mut doc = Document::new_empty(); + let mut font = Dict::new(); + font.set(b"Type".to_vec(), Object::name("Font")); + font.set(b"Subtype".to_vec(), Object::name("Type1")); + font.set(b"BaseFont".to_vec(), Object::name("Helvetica")); + font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); + let fr = doc.add(font); + let mut page_lines: Vec> = vec![Vec::new()]; + for l in lines.iter().take(lines.len().saturating_sub(1)) { + if l == "\u{c}" || page_lines.last().is_some_and(|p| p.len() >= per_page) { + page_lines.push(Vec::new()); + if l == "\u{c}" { + continue; + } + } + page_lines.last_mut().expect("non-empty").push(l.clone()); + } + for lines in page_lines { + let mut c = format!("BT /F1 {size} Tf {line_h:.3} TL {margin} {:.3} Td\n", h - margin - size).into_bytes(); + for l in lines { + c.extend(literal(&win_ansi(&l))); + c.extend_from_slice(b" Tj T*\n"); + } + c.extend_from_slice(b"ET\n"); + let mut fonts = Dict::new(); + fonts.set(b"F1".to_vec(), Object::Ref(fr)); + let mut res = Dict::new(); + res.set(b"Font".to_vec(), Object::Dict(fonts)); + add_page(&mut doc, w, h, res, Some(c)); + } + set_title(&mut doc, title); + Ok(doc) +} + +#[cfg(test)] +mod tests; diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs new file mode 100644 index 000000000..cd7e095a7 --- /dev/null +++ b/crates/create/src/tests.rs @@ -0,0 +1,99 @@ +use std::sync::Arc; + +use printcraft_cos::{SaveOptions, write_full}; + +use super::*; + +fn reopen(doc: &Document) -> Document { + let bytes = write_full(doc, &SaveOptions::default()).unwrap(); + hayro_syntax::Pdf::new(bytes.clone()).expect("parses"); + Document::open(Arc::new(bytes)).unwrap() +} + +fn pages(doc: &Document) -> Vec { + let pages = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap(); + let kids = doc.get(pages).as_dict().unwrap().get(b"Kids").unwrap().as_array().unwrap().clone(); + kids.iter().map(|k| doc.resolve(k).as_dict().cloned().unwrap()).collect() +} + +fn media(d: &Dict) -> Vec { + d.get(b"MediaBox").unwrap().as_array().unwrap().iter().map(|o| o.as_f64().unwrap()).collect() +} + +/// A tiny PNG: 4×2 RGBA with one transparent pixel, 144 dpi. +fn png_bytes(alpha: bool) -> Vec { + let mut out = Vec::new(); + { + let mut enc = png::Encoder::new(&mut out, 4, 2); + enc.set_color(if alpha { png::ColorType::Rgba } else { png::ColorType::Rgb }); + enc.set_depth(png::BitDepth::Eight); + enc.set_pixel_dims(Some(png::PixelDimensions { xppu: 5669, yppu: 5669, unit: png::Unit::Meter })); + let mut w = enc.write_header().unwrap(); + let n = if alpha { 4 } else { 3 }; + let mut data = vec![200u8; 8 * n]; + if alpha { + data[3] = 0; + } + w.write_image_data(&data).unwrap(); + } + out +} + +/// A minimal baseline JPEG header (SOI, JFIF at 300 dpi, SOF0 3×2 RGB, EOI). Only the headers +/// matter: 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 blank_documents() { + let doc = reopen(&blank(612.0, 792.0, 3).unwrap()); + assert_eq!(pages(&doc).len(), 3); + assert_eq!(media(&pages(&doc)[0]), [0.0, 0.0, 612.0, 792.0]); + assert!(blank(1.0, 792.0, 1).is_err() && blank(612.0, 792.0, 0).is_err()); +} + +#[test] +fn images_become_pages_at_their_resolution() { + let doc = + from_images(&[("photo.png".into(), png_bytes(true)), ("scan.jpg".into(), jpeg_bytes()), ("flat.png".into(), png_bytes(false))]).unwrap(); + let doc = reopen(&doc); + let p = pages(&doc); + assert_eq!(p.len(), 3); + // 4×2 px at 144 dpi → 2×1 pt; 3×2 px at 300 dpi → 0.72×0.48 pt. + let m0 = media(&p[0]); + assert!((m0[2] - 2.0).abs() < 0.01 && (m0[3] - 1.0).abs() < 0.01, "{m0:?}"); + let m1 = media(&p[1]); + assert!((m1[2] - 0.72).abs() < 0.01, "{m1:?}"); + let img = |d: &Dict| { + let x = doc.resolve(d.get(b"Resources").unwrap()); + let xo = doc.resolve(x.as_dict().unwrap().get(b"XObject").unwrap()); + doc.resolve(xo.as_dict().unwrap().get(b"Im0").unwrap()).as_dict().cloned().unwrap() + }; + assert!(img(&p[0]).contains(b"SMask"), "transparency is kept"); + assert!(!img(&p[2]).contains(b"SMask")); + assert_eq!(img(&p[1]).name(b"Filter"), Some(&b"DCTDecode"[..])); + assert_eq!(img(&p[1]).name(b"ColorSpace"), Some(&b"DeviceRGB"[..])); + let title = doc.resolve(doc.trailer().get(b"Info").unwrap()); + assert_eq!(title.as_dict().unwrap().get(b"Title").unwrap().as_string().unwrap().to_text(), "photo"); + assert!(matches!(from_images(&[("x.gif".into(), b"GIF89a".to_vec())]), Err(CreateError::Image(..)))); +} + +#[test] +fn text_is_wrapped_and_paginated() { + let long: String = (0..200).map(|i| format!("Line {i} of a plain text file\n")).collect(); + let doc = reopen(&from_text("notes", &format!("{long}\u{c}After a form feed"), LETTER, 11.0).unwrap()); + let p = pages(&doc); + assert!(p.len() >= 5, "{} pages", p.len()); + let content = |d: &Dict| { + let c = doc.resolve(d.get(b"Contents").unwrap()); + let Object::Stream(s) = &*c else { panic!() }; + String::from_utf8_lossy(&s.decoded().unwrap()).into_owned() + }; + assert!(content(&p[0]).contains("(Line 0 of a plain text file) Tj")); + assert!(content(p.last().unwrap()).contains("(After a form feed) Tj"), "a form feed starts a page"); +} diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 62cea75ea..72c3b62a2 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -15,6 +15,7 @@ printcraft-organize = { workspace = true } printcraft-annot = { workspace = true } printcraft-forms = { workspace = true } printcraft-edit = { workspace = true } +printcraft-create = { workspace = true } printcraft-model = { workspace = true } thiserror = { workspace = true } png = "0.18" diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index 64ba7a411..1f30dfe39 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -138,15 +138,15 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[ icon: "file-plus-2", hue: RED, badge: None, - availability: Planned("M10"), + availability: Ready, sections: &[ToolSection { title: "Create from", items: &[ - item("Single file", "file-input", "create.file", Planned("M10")), + item("Single file", "file-input", "create.file", Ready), item("Multiple files", "files", "create.multiple", Planned("M10")), - item("Images", "image", "create.images", Planned("M10")), + item("Images", "image", "create.images", Ready), item("Clipboard", "copy-plus", "create.clipboard", Planned("M10")), - item("Blank page", "file-plus-2", "create.blank", Planned("M10")), + item("Blank page", "file-plus-2", "create.blank", Ready), ], }], }, @@ -293,7 +293,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[ sections: &[ToolSection { title: "Optimize", items: &[ - item("Reduce file size", "file-down", "optimize.reduce", Planned("M11")), + item("Reduce file size", "file-down", "optimize.reduce", Ready), item("Advanced optimization", "settings-2", "optimize.advanced", Planned("M11")), item("Audit space usage", "gauge", "optimize.audit", Planned("M11")), ], diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 88fe170ea..c95d19d15 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -140,6 +140,9 @@ const HELP: Option<&str> = Some("Help"); /// Every command, in menu order. pub const COMMANDS: &[CommandSpec] = &[ c("file.open", "Open…", FILE, Some(Shortcut::cmd("O")), Nothing, "folder-open"), + c("create.blank", "New blank PDF", FILE, None, Nothing, "file-plus-2"), + c("create.file", "Create PDF from file…", FILE, None, Nothing, "file-input"), + c("create.images", "Create PDF from images…", FILE, None, Nothing, "image"), c("page.combine", "Combine files…", FILE, None, Nothing, "files"), c("file.save", "Save", FILE, Some(Shortcut::cmd("S")), Document, "save"), c("file.save_as", "Save as…", FILE, Some(Shortcut::cmd_shift("S")), Document, "save"), @@ -173,6 +176,7 @@ pub const COMMANDS: &[CommandSpec] = &[ c("sign.fill.date", "Fill & Sign: date", None, None, Annotate, "clock-3"), c("sign.fill.signature", "Fill & Sign: sign", None, None, Annotate, "signature"), c("export.image", "Export to image…", FILE, None, Document, "image"), + c("optimize.reduce", "Reduce file size…", FILE, None, Document, "file-down"), c("export.text", "Export to text…", FILE, None, Document, "type"), c("edit.header_footer", "Add header & footer…", None, None, Modification, "heading"), c("edit.header_footer.update", "Update header & footer…", None, None, Marks(crate::MarkKind::HeaderFooter), "heading"), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index e0f36716a..b0d067aa5 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -881,6 +881,8 @@ pub enum EditError { Form(#[from] printcraft_forms::FormError), #[error("{0}")] Edit(#[from] printcraft_edit::EditError), + #[error("{0}")] + Create(#[from] printcraft_create::CreateError), #[error("the edited document could not be written: {0}")] Write(String), #[error("the edited document could not be reopened: {0}")] @@ -1164,6 +1166,35 @@ impl Session { Self::refresh(doc) } + /// A new blank document (Create ▸ Blank page). + pub fn create_blank(&self, width: f64, height: f64, pages: usize) -> Result>, EditError> { + self.write_new(&printcraft_create::blank(width, height, pages)?) + } + + /// A new document with one page per image (PNG, JPEG). + pub fn create_from_images(&self, images: &[(String, Vec)]) -> Result>, EditError> { + self.write_new(&printcraft_create::from_images(images)?) + } + + /// A new document from plain text (US Letter, 11 pt Helvetica). + pub fn create_from_text(&self, title: &str, text: &str) -> Result>, EditError> { + self.write_new(&printcraft_create::from_text(title, text, printcraft_create::LETTER, 11.0)?) + } + + /// Reduce File Size: identical resources merged, unused objects dropped, objects packed + /// into compressed object streams. Returns the bytes and how many objects were merged. + /// The open document is not changed (Acrobat saves the reduced copy as a new file). + pub fn reduced_bytes(&self, id: DocId) -> Result<(Arc>, usize), EditError> { + let doc = self.get(id).ok_or(EditError::NoDocument)?; + let editor = doc.editor.as_ref().ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?; + let mut cos = editor.cos.clone(); + let all: Vec = cos.object_numbers().into_iter().map(|n| printcraft_cos::ObjRef::new(n, cos.generation(n))).collect(); + let merged = printcraft_organize::dedupe_resources(&mut cos, &all, false); + let opts = SaveOptions { mod_date: self.now().map(printcraft_cos::pdf_date), ..SaveOptions::default() }; + let bytes = write_full(&cos, &opts).map_err(|e| EditError::Write(e.to_string()))?; + Ok((Arc::new(bytes), merged)) + } + /// Combine whole files, in order, into new PDF bytes (one bookmark per file). pub fn combine(&self, sources: &[(String, Arc>)]) -> Result>, EditError> { let docs = sources.iter().map(|(n, b)| open_source(n, b)).collect::, _>>()?; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 602225437..de3bc41b8 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -717,3 +717,24 @@ fn export_images_and_text_follow_the_working_file() { assert_eq!(ex.text_of(&[0, 1]).unwrap(), "Page 1\n\u{c}Page 2\n"); assert!(ex.png(5, 72.0).is_err()); } + +#[test] +fn create_and_reduce() { + let mut s = Session::new(); + let blank = s.create_blank(612.0, 792.0, 2).unwrap(); + let id = s.open_new("Untitled.pdf", blank).unwrap(); + assert_eq!(s.get(id).unwrap().info.pages.len(), 2); + let text = s.create_from_text("notes", "hello\nworld").unwrap(); + let t = s.open_new("notes.pdf", text).unwrap(); + assert_eq!(page_texts(&s, t), ["hello\nworld"]); + // A file with the same page imported twice shares nothing until reduced. + let (mut s2, id2) = session_with(1); + let src = Arc::new(fixture(1)); + s2.apply(id2, Edit::InsertPagesFrom { name: "x".into(), bytes: src.clone(), pages: None, at: 1 }).unwrap(); + let full = s2.save_full_bytes(id2).unwrap(); + let (reduced, _) = s2.reduced_bytes(id2).unwrap(); + assert!(reduced.len() <= full.len(), "{} > {}", reduced.len(), full.len()); + let mut s3 = Session::new(); + let r = s3.open("r.pdf", None, reduced, None).unwrap(); + assert_eq!(page_texts(&s3, r), ["Page 1", "Page 1"]); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 3a0de7b7b..e1e1de827 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -799,6 +799,15 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { let out = scroll.show_viewport(ui, |ui, viewport| { let (resp_rect, resp) = ui.allocate_exact_size(vec2(content_w, content_h), Sense::click_and_drag()); + // The Hand tool pans: the content widget takes every drag, so scroll by its delta. + if hand { + if resp.dragged() { + ui.scroll_with_delta(resp.drag_delta()); + ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + } else if resp.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); + } + } let origin = resp_rect.min - vec2(0.0, y_shift); let painter = ui.painter(); let visible = viewport.translate(resp_rect.min.to_vec2()); diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index 64f6ff87f..6f957719e 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -203,6 +203,10 @@ impl PrintCraftApp { self.dialog = Some(Dialog::Signature); } } + "create.blank" => self.create_blank(), + "create.file" => self.open_dialog(), + "create.images" => self.create_from_images_dialog(), + "optimize.reduce" => self.reduce_file_size(), "page.duplicate" => { self.apply_edit(Edit::DuplicatePages { pages: targets }); } diff --git a/crates/ui-egui/src/create_ui.rs b/crates/ui-egui/src/create_ui.rs new file mode 100644 index 000000000..f96632298 --- /dev/null +++ b/crates/ui-egui/src/create_ui.rs @@ -0,0 +1,128 @@ +//! Create a PDF (blank, from images, from text) and Reduce File Size (execution plan M10.2, +//! M11.1). Opening an image or a text file converts it to a new, unsaved PDF, as Acrobat does. + +use std::sync::Arc; + +use crate::PrintCraftApp; + +/// File types Open accepts besides PDF (converted on open). +pub const CONVERTIBLE: [&str; 4] = ["png", "jpg", "jpeg", "txt"]; + +fn is_image(bytes: &[u8]) -> bool { + bytes.starts_with(&[0xFF, 0xD8]) || bytes.starts_with(b"\x89PNG\r\n\x1a\n") +} + +fn stem(name: &str) -> &str { + name.rsplit_once('.').map_or(name, |(s, _)| s) +} + +impl PrintCraftApp { + /// 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-") { + 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; + }; + Some(self.open_created_bytes(&format!("{}.pdf", stem(name)), created.map_err(|e| e.to_string()))) + } + + fn open_created_bytes(&mut self, name: &str, created: Result>, String>) -> Result<(), String> { + let bytes = created?; + let id = self.session.open_new(name, bytes).map_err(|e| e.to_string())?; + let info = &self.session.get(id).expect("just opened").info; + self.views.push(crate::DocView::new(id, info)); + self.active = Some(self.views.len() - 1); + Ok(()) + } + + /// Create ▸ Blank page: a new untitled US Letter document. + pub(crate) fn create_blank(&mut self) { + let created = self.session.create_blank(612.0, 792.0, 1).map_err(|e| e.to_string()); + if let Err(e) = self.open_created_bytes("Untitled.pdf", created) { + self.notify(format!("Couldn't create a PDF: {e}")); + } + } + + /// Create ▸ Images: several images, one page each, in one new document. + pub(crate) fn create_from_images_dialog(&mut self) { + #[cfg(not(target_arch = "wasm32"))] + { + let Some(files) = rfd::FileDialog::new().add_filter("Images", &["png", "jpg", "jpeg"]).set_title("Choose images").pick_files() else { + return; + }; + let mut images = Vec::new(); + for f in files { + match std::fs::read(&f) { + Ok(b) => images.push((f.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), b)), + Err(e) => { + self.notify(format!("Couldn't read {}: {e}", f.display())); + return; + } + } + } + self.create_from_images(images); + } + #[cfg(target_arch = "wasm32")] + self.notify("On the web, open or drop an image to convert it"); + } + + /// One new document from images (tests and automation call this directly). + pub fn create_from_images(&mut self, images: Vec<(String, Vec)>) { + if images.is_empty() { + return; + } + let name = if images.len() == 1 { format!("{}.pdf", stem(&images[0].0)) } else { "Images.pdf".to_string() }; + let created = self.session.create_from_images(&images).map_err(|e| e.to_string()); + if let Err(e) = self.open_created_bytes(&name, created) { + self.notify(format!("Couldn't create a PDF: {e}")); + } + } + + /// Reduce File Size: write a compacted copy (the open document is unchanged). + pub(crate) fn reduce_file_size(&mut self) { + let Some((_, id)) = self.active_ids() else { return }; + let Some(doc) = self.session.get(id) else { return }; + let (before, name) = (doc.bytes.len(), format!("{} (reduced).pdf", stem(&doc.name))); + let (bytes, _) = match self.session.reduced_bytes(id) { + Ok(r) => r, + Err(e) => { + self.notify(format!("Couldn't reduce the file: {e}")); + return; + } + }; + let saved = |app: &mut PrintCraftApp, place: String| { + let pct = 100.0 * (1.0 - bytes.len() as f64 / before.max(1) as f64); + app.notify(format!( + "Saved {place}: {} → {} ({pct:.0}% smaller)", + crate::panels::human_size(before), + crate::panels::human_size(bytes.len()) + )); + }; + #[cfg(not(target_arch = "wasm32"))] + { + let path = match &self.save_override { + Some(p) => Some(p.clone()), + None => rfd::FileDialog::new().add_filter("PDF", &["pdf"]).set_file_name(&name).save_file().map(|p| p.to_string_lossy().into_owned()), + }; + let Some(path) = path else { return }; + match crate::editing::write_atomically(&path, &bytes) { + Ok(()) => saved(self, path), + Err(e) => self.notify(format!("Couldn't write {path}: {e}")), + } + } + #[cfg(target_arch = "wasm32")] + match crate::editing::download(&name, &bytes) { + Ok(()) => saved(self, name), + Err(e) => self.notify(format!("Couldn't download {name}: {e}")), + } + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6187169b4..9608510e3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -11,6 +11,7 @@ mod commands; pub mod comments; mod comments_panel; pub mod control; +mod create_ui; mod crop; mod export_ui; mod marks_ui; @@ -277,6 +278,10 @@ impl PrintCraftApp { /// Open a document and make it the active tab. Encrypted files raise the password prompt. pub fn open_bytes(&mut self, name: &str, path: Option, bytes: Vec) -> Result<(), String> { + // Images and text files become new, unsaved PDFs (Create a PDF). + if let Some(r) = self.open_converted(name, &bytes) { + return r; + } self.try_open(name, path, std::sync::Arc::new(bytes), None) } @@ -412,7 +417,9 @@ impl PrintCraftApp { pub fn open_dialog(&mut self) { #[cfg(not(target_arch = "wasm32"))] - if let Some(p) = rfd::FileDialog::new().add_filter("PDF", &["pdf"]).pick_file() { + if let Some(p) = + rfd::FileDialog::new().add_filter("PDF", &["pdf"]).add_filter("Images and text (converted to PDF)", &create_ui::CONVERTIBLE).pick_file() + { self.open_path(&p.to_string_lossy()); } // Browsers pick files asynchronously; the bytes arrive through `inbox`. @@ -420,7 +427,9 @@ impl PrintCraftApp { { let inbox = self.inbox.clone(); wasm_bindgen_futures::spawn_local(async move { - if let Some(h) = rfd::AsyncFileDialog::new().add_filter("PDF", &["pdf"]).pick_file().await { + if let Some(h) = + rfd::AsyncFileDialog::new().add_filter("PDF", &["pdf"]).add_filter("Images and text", &create_ui::CONVERTIBLE).pick_file().await + { let bytes = h.read().await; if let Ok(mut q) = inbox.lock() { q.push((h.file_name(), bytes)); diff --git a/crates/ui-egui/tests/commands.rs b/crates/ui-egui/tests/commands.rs index 39c34cb89..a418ecef3 100644 --- a/crates/ui-egui/tests/commands.rs +++ b/crates/ui-egui/tests/commands.rs @@ -43,7 +43,7 @@ fn harness() -> Harness<'static, PrintCraftApp> { } /// Commands that open a native file picker can't run headless. -const PICKERS: &[&str] = &["file.open", "page.combine", "page.insert", "file.save_as"]; +const PICKERS: &[&str] = &["file.open", "page.combine", "page.insert", "file.save_as", "create.file", "create.images"]; #[test] fn every_registered_command_is_implemented() { diff --git a/crates/ui-egui/tests/create.rs b/crates/ui-egui/tests/create.rs new file mode 100644 index 000000000..bc4f11adc --- /dev/null +++ b/crates/ui-egui/tests/create.rs @@ -0,0 +1,46 @@ +//! Create a PDF and Reduce File Size in the real shell. + +use printcraft_ui_egui::PrintCraftApp; + +fn png() -> Vec { + let mut out = Vec::new(); + { + let mut enc = png::Encoder::new(&mut out, 8, 4); + enc.set_color(png::ColorType::Rgb); + enc.set_depth(png::BitDepth::Eight); + let mut w = enc.write_header().unwrap(); + w.write_image_data(&[120u8; 8 * 4 * 3]).unwrap(); + } + out +} + +#[test] +fn opening_images_and_text_converts_them_to_new_pdfs() { + let mut app = PrintCraftApp::new(); + app.open_bytes("photo.png", Some("/tmp/photo.png".into()), png()).unwrap(); + app.open_bytes("notes.txt", None, b"first line\nsecond line".to_vec()).unwrap(); + app.create_from_images(vec![("a.png".into(), png()), ("b.png".into(), png())]); + assert!(app.execute("create.blank")); + let docs = app.session.docs(); + let summary: Vec<(String, usize, bool)> = docs.iter().map(|d| (d.name.clone(), d.info.pages.len(), d.dirty)).collect(); + assert_eq!( + summary, + [("photo.pdf".to_string(), 1, true), ("notes.pdf".into(), 1, true), ("Images.pdf".into(), 2, true), ("Untitled.pdf".into(), 1, true)] + ); + assert!(docs[0].path.is_none(), "a converted file has no PDF path yet: Save asks where"); + assert!(app.open_bytes("junk.png", None, b"\x89PNG\r\n\x1a\nnot really".to_vec()).is_err()); +} + +#[test] +fn reduce_file_size_writes_a_compact_copy() { + let dir = std::env::temp_dir().join(format!("printcraft-reduce-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let out = dir.join("reduced.pdf"); + let mut app = PrintCraftApp::new(); + app.open_bytes("notes.txt", None, "lorem ipsum ".repeat(500).into_bytes()).unwrap(); + app.save_override = Some(out.to_string_lossy().into_owned()); + assert!(app.execute("optimize.reduce")); + let bytes = std::fs::read(&out).unwrap(); + assert!(bytes.starts_with(b"%PDF-")); + assert!(app.session.docs()[0].dirty, "the open document is unchanged"); +} diff --git a/crates/ui-egui/tests/view.rs b/crates/ui-egui/tests/view.rs index 68d6ac8bf..8e3fa1b94 100644 --- a/crates/ui-egui/tests/view.rs +++ b/crates/ui-egui/tests/view.rs @@ -180,3 +180,24 @@ fn fonts_tab_lists_fonts_and_embedding() { h.get_by_label("Helvetica"); h.get_by_label_contains("Not embedded"); } + +#[test] +fn the_hand_tool_pans_by_dragging() { + let mut h = harness(&[("layout", "continuous"), ("zoom", "150"), ("quick", "hand")]); + h.run_steps(4); + let before = rect(&h, 0).expect("page 1 on screen"); + let start = egui::pos2(700.0, 700.0); + h.hover_at(start); + h.run_steps(1); + h.drag_at(start); + h.run_steps(1); + for k in 1..=5 { + h.hover_at(start - egui::vec2(0.0, 60.0 * k as f32)); + h.run_steps(1); + } + h.drop_at(start - egui::vec2(0.0, 300.0)); + h.run_steps(4); + let after = rect(&h, 0).expect("still on screen"); + assert!(before.top() - after.top() > 200.0, "dragging up scrolls down: {before:?} → {after:?}"); + assert!(h.state().views[0].selected_text().is_none(), "no text selection with the hand"); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index bf2a7358a..d6004fd6b 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1031,11 +1031,11 @@ title = "Hand tool for panning" area = "B" tier = "P0" milestone = "M3" -status = "planned" +status = "shipped" commands = [] tools = [] -evidence = [] -notes = "" +evidence = ["crates/ui-egui/tests/view.rs::the_hand_tool_pans_by_dragging"] +notes = "drag to pan; Grab/Grabbing cursors" [[feature]] id = "view.rotate-view" @@ -6643,11 +6643,11 @@ title = "Create PDF from images (JPEG, PNG, TIFF, GIF, BMP, JPEG 2000)" area = "J" tier = "P0" milestone = "M10" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "partial" +commands = ["create.images", "create.file"] +tools = ["doc_create"] +evidence = ["crates/ui-egui/tests/create.rs::opening_images_and_text_converts_them_to_new_pdfs", "crates/create/src/tests.rs::images_become_pages_at_their_resolution"] +notes = "PNG and JPEG (grey, RGB, CMYK; transparency kept); TIFF, GIF, BMP, JPEG 2000 not yet" [[feature]] id = "create.from-multipage-tiff" @@ -6667,11 +6667,11 @@ title = "Create PDF from plain text" area = "J" tier = "P0" milestone = "M10" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["create.file"] +tools = ["doc_create"] +evidence = ["crates/create/src/tests.rs::text_is_wrapped_and_paginated", "crates/automation/tests/automation.rs::creating_and_reducing_through_tools"] +notes = "Helvetica 11 pt on US Letter; form feeds start pages" [[feature]] id = "create.from-markdown" @@ -6715,10 +6715,10 @@ title = "Create a blank PDF" area = "J" tier = "P0" milestone = "M10" -status = "planned" -commands = [] -tools = [] -evidence = [] +status = "shipped" +commands = ["create.blank"] +tools = ["doc_create"] +evidence = ["crates/ui-egui/tests/create.rs::opening_images_and_text_converts_them_to_new_pdfs", "crates/create/src/tests.rs::blank_documents", "crates/automation/tests/automation.rs::creating_and_reducing_through_tools"] notes = "" [[feature]] @@ -7067,11 +7067,11 @@ title = "Reduce file size (one click)" area = "K" tier = "P0" milestone = "M11" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "partial" +commands = ["optimize.reduce"] +tools = ["doc_reduce"] +evidence = ["crates/ui-egui/tests/create.rs::reduce_file_size_writes_a_compact_copy", "crates/engine/src/tests.rs::create_and_reduce", "crates/automation/tests/automation.rs::creating_and_reducing_through_tools"] +notes = "merges identical resources, drops unused objects, object streams; no image downsampling yet" [[feature]] id = "optimize.compatibility-level"