From 1e69a92c414b4ca29b88fd61ec945ebd9dba8de2 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 04:47:45 -0400 Subject: [PATCH] =?UTF-8?q?M10:=20Export=20a=20PDF=20=E2=96=B8=20Export=20?= =?UTF-8?q?all=20images:=20JPEGs=20unchanged,=20other=20images=20as=20PNG?= =?UTF-8?q?=20with=20alpha;=20skipped=20images=20reported;=20doc=5Fexport?= =?UTF-8?q?=5Fall=5Fimages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- crates/automation/src/lib.rs | 14 +- crates/automation/src/tools.rs | 11 + crates/automation/tests/automation.rs | 20 ++ crates/create/Cargo.toml | 1 + crates/create/src/extract.rs | 281 ++++++++++++++++++++++++++ crates/create/src/lib.rs | 3 + crates/create/src/tests.rs | 54 +++++ crates/engine/src/catalog.rs | 1 + crates/engine/src/commands.rs | 1 + crates/engine/src/export.rs | 12 ++ crates/ui-egui/src/commands.rs | 1 + crates/ui-egui/src/export_ui.rs | 49 ++++- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/tests/export.rs | 7 + parity/acrobat-features.toml | 10 +- 15 files changed, 456 insertions(+), 10 deletions(-) create mode 100644 crates/create/src/extract.rs diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 37115b76..008a18ad 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -280,7 +280,7 @@ impl Automation { let new = self.session.open_revision(id, n).map_err(failed)?; summary(self.session.get(new).ok_or_else(|| failed("the document vanished"))?) } - "doc_export_images" | "doc_export_text" => self.export(name, &a)?, + "doc_export_images" | "doc_export_text" | "doc_export_all_images" => self.export(name, &a)?, "doc_header_footer" | "doc_watermark" | "doc_background" | "doc_remove_marks" => self.marks(name, &a)?, "doc_unprotect" => { let mut out = self.apply(&a, Edit::RemoveProtection)?; @@ -671,6 +671,18 @@ impl Automation { } let folder = self.resolve(a.str("folder")?, true)?; std::fs::create_dir_all(&folder).map_err(|e| failed(format!("{}: {e}", folder.display())))?; + if tool == "doc_export_all_images" { + let min = u32::try_from(a.opt_int("min_size")?.unwrap_or(0).max(0)).unwrap_or(u32::MAX); + let out = printcraft_engine::export::extract_images(&doc.export_source(), &pages, min).map_err(failed)?; + let mut files = Vec::new(); + for (k, img) in out.images.iter().enumerate() { + let path = folder.join(printcraft_engine::export::image_file_name(&stem, img, k + 1)); + write_atomic(&path, &img.data)?; + files.push(json!({ "path": path.to_string_lossy(), "page": img.page + 1, "width": img.width, "height": img.height })); + } + let skipped: Vec = out.skipped.iter().map(|(p, r, why)| json!({ "page": p + 1, "object": r.num, "reason": why })).collect(); + return Ok(json!({ "count": files.len(), "files": files, "skipped": skipped })); + } let dpi = a.opt_num("dpi")?.unwrap_or(150.0); let quality = a.opt_int("quality")?.unwrap_or(85).clamp(1, 100) as u8; let format = match a.opt_str("format")?.unwrap_or("png") { diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 3308106c..b81569dc 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -673,6 +673,17 @@ pub fn tools() -> Vec { }), &["doc", "folder"], )), + t("doc_export_all_images", "Export all images", "Write the images that pages use into a folder (`_Page__Image_.jpg|png`), each once: JPEG images unchanged, others as PNG with their soft mask as alpha. min_size skips images with fewer pixels on their shorter side. Images that can't be decoded yet (JPEG 2000, JBIG2, CCITT, separations) are listed under skipped. Includes unsaved edits.") + .cmd("export.all_images") + .with(schema( + json!({ + "doc": doc(), + "folder": { "type": "string" }, + "pages": pages("whose images to export (default: all)"), + "min_size": { "type": "integer", "minimum": 0, "description": "Skip images smaller than this many pixels on their shorter side (default 0)." }, + }), + &["doc", "folder"], + )), t("doc_export_text", "Export text", "Write the reading-order text of pages to a .txt file (pages separated by form feeds).") .cmd("export.text") .with(schema(json!({ "doc": doc(), "path": { "type": "string" }, "pages": pages("to export (default: all)") }), &["doc", "path"])), diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index a9fd00c3..9f8620bf 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -566,6 +566,26 @@ fn exporting_images_and_text_through_tools() { assert!(a.call("doc_export_text", &json!({ "doc": doc, "path": "/etc/x.txt" })).is_err(), "confined to the root"); } +#[test] +fn exporting_all_images_through_tools() { + let dir = workdir("export-all-images"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(ok(&mut a, "doc_export_all_images", json!({ "doc": doc, "folder": "none" }))["count"], 0, "text only"); + // A PDF made from two page renders (PNG, JPEG) holds two images. + ok(&mut a, "doc_export_images", json!({ "doc": doc, "folder": "src", "dpi": 36, "pages": [1] })); + ok(&mut a, "doc_export_images", json!({ "doc": doc, "folder": "src", "dpi": 36, "pages": [2], "format": "jpeg" })); + let made = ok(&mut a, "doc_create", json!({ "from": "images", "paths": ["src/a_page_1.png", "src/a_page_2.jpg"] }))["doc"].as_u64().unwrap(); + let r = ok(&mut a, "doc_export_all_images", json!({ "doc": made, "folder": "imgs" })); + assert_eq!(r["count"], 2, "{r}"); + let files: Vec<&str> = r["files"].as_array().unwrap().iter().map(|f| f["path"].as_str().unwrap()).collect(); + assert!(files[0].ends_with("_Page_1_Image_0001.png") && files[1].ends_with("_Page_2_Image_0002.jpg"), "{files:?}"); + assert_eq!(std::fs::read(files[1]).unwrap(), std::fs::read(dir.join("src/a_page_2.jpg")).unwrap(), "JPEG unchanged"); + assert!(std::fs::read(files[0]).unwrap().starts_with(b"\x89PNG")); + assert_eq!(ok(&mut a, "doc_export_all_images", json!({ "doc": made, "folder": "imgs2", "pages": [2] }))["count"], 1); + assert_eq!(ok(&mut a, "doc_export_all_images", json!({ "doc": made, "folder": "imgs3", "min_size": 10000 }))["count"], 0); +} + #[test] fn fill_and_sign_through_tools() { let dir = workdir("fill"); diff --git a/crates/create/Cargo.toml b/crates/create/Cargo.toml index 19c88ac5..2f5e8bf3 100644 --- a/crates/create/Cargo.toml +++ b/crates/create/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [dependencies] printcraft-cos = { workspace = true } +printcraft-model = { workspace = true } printcraft-fonts = { workspace = true } png = "0.18" tiff = "0.11" diff --git a/crates/create/src/extract.rs b/crates/create/src/extract.rs new file mode 100644 index 00000000..eca04f57 --- /dev/null +++ b/crates/create/src/extract.rs @@ -0,0 +1,281 @@ +//! Export a PDF ▸ Image ▸ Export all images, and Edit ▸ Save image as: the images that pages +//! use, as files. JPEG images are written unchanged; other images are decoded and written as +//! PNG (with their soft mask as alpha). Images PrintCraft can't decode yet (JPEG 2000, JBIG2, +//! CCITT, separations) are reported, never silently left out. + +use std::collections::HashSet; + +use printcraft_cos::{Dict, Document, ObjRef, Object, Stream}; + +/// One image, ready to write. +#[derive(Clone, Debug, PartialEq)] +pub struct ExtractedImage { + /// 0-based page the image was first found on. + pub page: usize, + pub object: ObjRef, + pub width: u32, + pub height: u32, + /// "jpg" or "png". + pub extension: &'static str, + pub data: Vec, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ImageExport { + pub images: Vec, + /// Images left out: (page, object, why). + pub skipped: Vec<(usize, ObjRef, String)>, +} + +/// Images larger than this many pixels are skipped (memory). +const MAX_PIXELS: u64 = 100_000_000; + +/// The images of `pages` (0-based; each image once, on the first page using it), skipping +/// those with fewer than `min_side` pixels on their shorter side. +pub fn extract_images(doc: &Document, pages: &[usize], min_side: u32) -> ImageExport { + let all = printcraft_model::pages(doc); + let mut out = ImageExport::default(); + let mut seen = HashSet::new(); + for &p in pages { + let Some(page) = all.get(p) else { continue }; + let mut refs = Vec::new(); + if let Some(res) = page.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()) { + collect(doc, &res, 0, &mut HashSet::new(), &mut refs); + } + for r in refs { + if !seen.insert(r) { + continue; + } + let obj = doc.get(r); + let Object::Stream(s) = &*obj else { continue }; + let (w, h) = (s.dict.int(b"Width").unwrap_or(0), s.dict.int(b"Height").unwrap_or(0)); + if w <= 0 || h <= 0 || (w.min(h) as u64) < min_side as u64 { + continue; + } + match image(doc, s) { + Ok((extension, data)) => out.images.push(ExtractedImage { page: p, object: r, width: w as u32, height: h as u32, extension, data }), + Err(why) => out.skipped.push((p, r, why)), + } + } + } + out +} + +/// The image XObjects reachable from `res`, through form XObjects, in resource order. +fn collect(doc: &Document, res: &Dict, depth: usize, forms: &mut HashSet, out: &mut Vec) { + if depth > 12 { + return; + } + let Some(xobjects) = res.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()) else { return }; + for (_, v) in xobjects.iter() { + let Some(r) = v.as_ref() else { continue }; + let obj = doc.get(r); + let Object::Stream(s) = &*obj else { continue }; + match s.dict.name(b"Subtype") { + Some(b"Image") => out.push(r), + Some(b"Form") if forms.insert(r) => { + if let Some(inner) = s.dict.get(b"Resources").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()) { + collect(doc, &inner, depth + 1, forms, out); + } + } + _ => {} + } + } +} + +fn filters(s: &Stream) -> Vec> { + match s.dict.get(b"Filter") { + Some(Object::Name(n)) => vec![n.to_vec()], + Some(Object::Array(a)) => a.iter().filter_map(|f| f.as_name().map(<[u8]>::to_vec)).collect(), + _ => Vec::new(), + } +} + +/// The file for one image: its JPEG data as is, or a PNG. +fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec), String> { + let f = filters(s); + match f.last().map(Vec::as_slice) { + Some(b"DCTDecode" | b"DCT") if f.len() == 1 => return Ok(("jpg", s.raw.to_vec())), + Some(b"JPXDecode") => return Err("JPEG 2000 images can't be exported yet".into()), + Some(b"JBIG2Decode") => return Err("JBIG2 images can't be exported yet".into()), + Some(b"CCITTFaxDecode" | b"CCF") => return Err("CCITT fax images can't be exported yet".into()), + Some(b"DCTDecode" | b"DCT") => return Err("JPEG images inside other filters can't be exported yet".into()), + _ => {} + } + let (w, h) = (s.dict.int(b"Width").unwrap_or(0) as usize, s.dict.int(b"Height").unwrap_or(0) as usize); + if (w as u64) * (h as u64) > MAX_PIXELS { + return Err(format!("{w} × {h} is too large to export")); + } + let data = s.decoded().map_err(|e| e.to_string())?; + let mask = s.dict.get(b"ImageMask").is_some_and(|m| matches!(&*doc.resolve(m), Object::Bool(true))); + let bpc = if mask { 1 } else { s.dict.int(b"BitsPerComponent").unwrap_or(8) as usize }; + if !matches!(bpc, 1 | 2 | 4 | 8 | 16) { + return Err(format!("{bpc} bits per component isn't supported")); + } + let space = if mask { Space::Gray } else { space(doc, s.dict.get(b"ColorSpace"))? }; + let n = space.components(); + let samples = unpack(&data, w, h, n, bpc).ok_or("the image data is shorter than its size says")?; + // Decode arrays: only inversion ([1 0] per component) is honoured, the common case. + let invert = s + .dict + .get(b"Decode") + .map(|d| doc.resolve(d)) + .and_then(|d| d.as_array().map(|a| a.first().and_then(Object::as_f64) > a.get(1).and_then(Object::as_f64))); + let invert = invert.unwrap_or(false) ^ mask; // a stencil mask paints its 0 samples + let max = (1u32 << bpc.min(8)) - 1; + let mut rgb = Vec::with_capacity(w * h * 3); + for px in samples.chunks_exact(n) { + let v = |i: usize| { + let x = (px[i] as u32 * 255 / max) as u8; + if invert { 255 - x } else { x } + }; + match &space { + Space::Gray => rgb.extend_from_slice(&[v(0); 3]), + Space::Rgb => rgb.extend_from_slice(&[v(0), v(1), v(2)]), + Space::Cmyk => rgb.extend_from_slice(&cmyk(v(0), v(1), v(2), v(3))), + Space::Indexed(base, table) => { + let i = px[0] as usize; + let k = base.components(); + let e = table.get(i * k..i * k + k).unwrap_or(&[0, 0, 0, 0][..k]); + rgb.extend_from_slice(&match **base { + Space::Gray => [e[0]; 3], + Space::Cmyk => cmyk(e[0], e[1], e[2], e[3]), + _ => [e[0], e[1], e[2]], + }); + } + } + } + let alpha = soft_mask(doc, s, w, h); + png(w as u32, h as u32, &rgb, alpha.as_deref()).map(|p| ("png", p)) +} + +#[derive(Clone, Debug)] +enum Space { + Gray, + Rgb, + Cmyk, + /// Base space and lookup table (one byte per base component). + Indexed(Box, Vec), +} + +impl Space { + fn components(&self) -> usize { + match self { + Space::Gray | Space::Indexed(..) => 1, + Space::Rgb => 3, + Space::Cmyk => 4, + } + } +} + +fn space(doc: &Document, cs: Option<&Object>) -> Result { + let Some(cs) = cs else { return Err("the image has no colour space".into()) }; + let cs = doc.resolve(cs); + let unsupported = |n: &[u8]| Err(format!("{} images can't be exported yet", String::from_utf8_lossy(n))); + match &*cs { + Object::Name(n) => match n.as_slice() { + b"DeviceGray" | b"G" | b"CalGray" => Ok(Space::Gray), + b"DeviceRGB" | b"RGB" | b"CalRGB" => Ok(Space::Rgb), + b"DeviceCMYK" | b"CMYK" => Ok(Space::Cmyk), + other => unsupported(other), + }, + Object::Array(a) => match a.first().and_then(Object::as_name) { + Some(b"ICCBased") => match a.get(1).map(|s| doc.resolve(s)).as_deref() { + Some(Object::Stream(icc)) => match icc.dict.int(b"N") { + Some(1) => Ok(Space::Gray), + Some(3) => Ok(Space::Rgb), + Some(4) => Ok(Space::Cmyk), + _ => Err("an ICC colour space with an unusual number of components".into()), + }, + _ => Err("a damaged ICC colour space".into()), + }, + Some(b"CalGray") => Ok(Space::Gray), + Some(b"CalRGB") => Ok(Space::Rgb), + Some(b"Indexed" | b"I") => { + let base = space(doc, a.get(1))?; + if matches!(base, Space::Indexed(..)) { + return Err("nested indexed colour spaces aren't valid".into()); + } + let table = match a.get(3).map(|t| doc.resolve(t)).as_deref() { + Some(Object::String(s)) => s.bytes.clone(), + Some(Object::Stream(t)) => t.decoded().map_err(|e| e.to_string())?, + _ => return Err("an indexed colour space without a lookup table".into()), + }; + Ok(Space::Indexed(Box::new(base), table)) + } + Some(other) => unsupported(other), + None => Err("a damaged colour space".into()), + }, + _ => Err("a damaged colour space".into()), + } +} + +/// Samples as one byte each (16-bit samples keep their high byte). +fn unpack(data: &[u8], w: usize, h: usize, n: usize, bpc: usize) -> Option> { + let row_bits = w * n * bpc; + let row = row_bits.div_ceil(8); + if data.len() < row * h { + return None; + } + let mut out = Vec::with_capacity(w * h * n); + for y in 0..h { + let r = &data[y * row..(y + 1) * row]; + match bpc { + 8 => out.extend_from_slice(&r[..w * n]), + 16 => out.extend(r.chunks_exact(2).take(w * n).map(|c| c[0])), + _ => { + let per = 8 / bpc; + let m = (1u8 << bpc) - 1; + for i in 0..w * n { + let shift = 8 - bpc * (i % per + 1); + out.push((r[i / per] >> shift) & m); + } + } + } + } + Some(out) +} + +fn cmyk(c: u8, m: u8, y: u8, k: u8) -> [u8; 3] { + let f = |x: u8| ((255 - x as u32) * (255 - k as u32) / 255) as u8; + [f(c), f(m), f(y)] +} + +/// The soft mask as 8-bit alpha, when it is an 8-bit gray image of the same size. +fn soft_mask(doc: &Document, s: &Stream, w: usize, h: usize) -> Option> { + let r = s.dict.get(b"SMask")?.as_ref()?; + let obj = doc.get(r); + let Object::Stream(m) = &*obj else { return None }; + if m.dict.int(b"Width")? as usize != w || m.dict.int(b"Height")? as usize != h { + return None; + } + let bpc = m.dict.int(b"BitsPerComponent").unwrap_or(8) as usize; + if !filters(m).iter().all(|f| !matches!(f.as_slice(), b"DCTDecode" | b"JPXDecode" | b"JBIG2Decode" | b"CCITTFaxDecode")) { + return None; + } + let px = unpack(&m.decoded().ok()?, w, h, 1, bpc)?; + let max = (1u32 << bpc.min(8)) - 1; + Some(px.into_iter().map(|v| (v as u32 * 255 / max) as u8).collect()) +} + +fn png(w: u32, h: u32, rgb: &[u8], alpha: Option<&[u8]>) -> Result, String> { + let mut out = Vec::new(); + { + let mut enc = png::Encoder::new(&mut out, w, h); + enc.set_depth(png::BitDepth::Eight); + enc.set_compression(png::Compression::Fast); + let data: Vec = match alpha { + Some(a) => { + enc.set_color(png::ColorType::Rgba); + rgb.chunks_exact(3).zip(a).flat_map(|(c, a)| [c[0], c[1], c[2], *a]).collect() + } + None => { + enc.set_color(png::ColorType::Rgb); + rgb.to_vec() + } + }; + let mut wr = enc.write_header().map_err(|e| e.to_string())?; + wr.write_image_data(&data).map_err(|e| e.to_string())?; + } + Ok(out) +} diff --git a/crates/create/src/lib.rs b/crates/create/src/lib.rs index bfe12d7b..9e2492a5 100644 --- a/crates/create/src/lib.rs +++ b/crates/create/src/lib.rs @@ -1,6 +1,9 @@ //! printcraft-create — create PDFs from nothing, images or text (L4). See the README. use printcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; + +mod extract; +pub use extract::{ExtractedImage, ImageExport, extract_images}; use printcraft_fonts::{literal, win_ansi, wrap}; #[derive(Debug, thiserror::Error, PartialEq)] diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index 4f094142..f1bee363 100644 --- a/crates/create/src/tests.rs +++ b/crates/create/src/tests.rs @@ -141,3 +141,57 @@ fn bmp_gif_and_multi_page_tiff_images() { assert_eq!(image_of(&doc, 2).name(b"ColorSpace"), Some(&b"DeviceGray"[..])); assert!(matches!(from_images(&[("x.webp".into(), b"RIFF0000WEBP".to_vec())]), Err(CreateError::Image(..)))); } + +#[test] +fn images_export_as_jpeg_unchanged_and_others_as_png() { + let doc = reopen( + &from_images(&[("photo.png".into(), png_bytes(true)), ("scan.jpg".into(), jpeg_bytes()), ("flat.png".into(), png_bytes(false))]).unwrap(), + ); + let out = extract_images(&doc, &[0, 1, 2], 0); + assert!(out.skipped.is_empty(), "{:?}", out.skipped); + let kinds: Vec<(usize, &str, u32, u32)> = out.images.iter().map(|i| (i.page, i.extension, i.width, i.height)).collect(); + assert_eq!(kinds, [(0, "png", 4, 2), (1, "jpg", 3, 2), (2, "png", 4, 2)]); + assert_eq!(out.images[1].data, jpeg_bytes(), "JPEG data is written as is"); + // The PNG round-trips the pixels, with the soft mask as alpha. + let dec = png::Decoder::new(std::io::Cursor::new(out.images[0].data.clone())); + let mut r = dec.read_info().unwrap(); + let mut buf = vec![0; r.output_buffer_size().unwrap()]; + let info = r.next_frame(&mut buf).unwrap(); + assert_eq!(info.color_type, png::ColorType::Rgba); + assert_eq!(&buf[..8], &[200, 200, 200, 0, 200, 200, 200, 200]); + // Pages filter; small images can be left out. + assert_eq!(extract_images(&doc, &[2], 0).images.len(), 1); + assert!(extract_images(&doc, &[0, 1, 2], 3).images.iter().all(|i| i.width.min(i.height) >= 3)); +} + +#[test] +fn indexed_and_one_bit_images_decode() { + let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap(); + // Replace the page's image with a 4×1 indexed image (two colours) and add a 1-bit mask. + let page = printcraft_model::pages(&doc)[0].clone(); + let res = doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().cloned().unwrap(); + let xo = doc.resolve(res.get(b"XObject").unwrap()).as_dict().cloned().unwrap(); + let (_, r) = xo.iter().next().map(|(k, v)| (k.clone(), v.as_ref().unwrap())).unwrap(); + let mut d = Dict::new(); + 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(4)); + d.set(b"Height".to_vec(), Object::Int(1)); + d.set(b"BitsPerComponent".to_vec(), Object::Int(1)); + d.set( + b"ColorSpace".to_vec(), + Object::Array(vec![ + Object::name("Indexed"), + Object::name("DeviceRGB"), + Object::Int(1), + Object::String(PdfString::literal(vec![255, 0, 0, 0, 0, 255])), + ]), + ); + doc.set(r, Object::Stream(Stream::from_raw(d, vec![0b0101_0000]))); + let out = extract_images(&doc, &[0], 0); + let dec = png::Decoder::new(std::io::Cursor::new(out.images[0].data.clone())); + let mut rd = dec.read_info().unwrap(); + let mut buf = vec![0; rd.output_buffer_size().unwrap()]; + rd.next_frame(&mut buf).unwrap(); + assert_eq!(&buf[..12], &[255, 0, 0, 0, 0, 255, 255, 0, 0, 0, 0, 255]); +} diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index d6e68a4c..ff78dbb2 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -73,6 +73,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[ item("Spreadsheet (.xlsx)", "grid-3x3", "export.xlsx", Planned("M10")), item("Presentation (.pptx)", "presentation", "export.pptx", Planned("M10")), item("Image (PNG)", "image", "export.image", Ready), + item("Export all images", "image", "export.all_images", Ready), item("HTML web page", "file-symlink", "export.html", Planned("M10")), item("Text (plain)", "type", "export.text", Ready), item("PostScript / EPS", "file-down", "export.ps", Planned("M10")), diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index e60bea35..6898180a 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -244,6 +244,7 @@ pub const COMMANDS: &[CommandSpec] = &[ c("optimize.reduce", "Reduce file size…", FILE, None, Document, "file-down"), c("optimize.advanced", "Optimize PDF…", FILE, None, Document, "settings-2"), c("export.text", "Export to text…", FILE, None, Document, "type"), + c("export.all_images", "Export all images…", FILE, None, Document, "image"), 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"), c("edit.header_footer.remove", "Remove header & footer", None, None, Marks(crate::MarkKind::HeaderFooter), "heading"), diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index b9de21a4..7ce71505 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -29,6 +29,18 @@ impl Document { } } +/// Export all images: the images `pages` (0-based) use, each once, skipping those under +/// `min_side` pixels on their shorter side. JPEGs come out unchanged, other images as PNG. +pub fn extract_images(src: &ExportSource, pages: &[usize], min_side: u32) -> Result { + let doc = printcraft_cos::Document::open_with_password(src.bytes.clone(), src.config.password.as_deref()).map_err(|e| e.to_string())?; + Ok(printcraft_create::extract_images(&doc, pages, min_side)) +} + +/// The file name for the `index`-th (1-based) exported image: `_Page__Image_.`. +pub fn image_file_name(stem: &str, image: &printcraft_create::ExtractedImage, index: usize) -> String { + format!("{stem}_Page_{}_Image_{index:04}.{}", image.page + 1, image.extension) +} + impl Exporter { pub fn new(doc: &Document) -> Self { Self::from_source(doc.export_source()) diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index 1bc40642..314b130a 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -222,6 +222,7 @@ impl PrintCraftApp { } "export.image" => self.dialog = Some(Dialog::Export(crate::export_ui::ExportKind::Image)), "export.text" => self.dialog = Some(Dialog::Export(crate::export_ui::ExportKind::Text)), + "export.all_images" => self.dialog = Some(Dialog::Export(crate::export_ui::ExportKind::AllImages)), "edit.text" => { self.quick_tool = crate::QuickTool::AddText; self.left = crate::LeftPanel::Tool("edit"); diff --git a/crates/ui-egui/src/export_ui.rs b/crates/ui-egui/src/export_ui.rs index a0612ce8..2a7194ae 100644 --- a/crates/ui-egui/src/export_ui.rs +++ b/crates/ui-egui/src/export_ui.rs @@ -16,6 +16,8 @@ use crate::{PrintCraftApp, widgets}; pub enum ExportKind { Image, Text, + /// Export all images: the images pages use, as files. + AllImages, } #[derive(Clone, Debug, PartialEq)] @@ -23,11 +25,13 @@ pub struct ExportDraft { pub dpi: f64, pub range: PageRange, pub format: ImageFormat, + /// Export all images ▸ skip images with fewer pixels than this on their shorter side. + pub min_side: u32, } impl Default for ExportDraft { fn default() -> Self { - Self { dpi: 150.0, range: PageRange::default(), format: ImageFormat::Png } + Self { dpi: 150.0, range: PageRange::default(), format: ImageFormat::Png, min_side: 0 } } } @@ -41,6 +45,7 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PrintCraftApp, t: &Tokens, kind: egui::RichText::new(match kind { ExportKind::Image => "Export to Image", ExportKind::Text => "Export to Text", + ExportKind::AllImages => "Export All Images", }) .font(theme::semibold(18.0)), ); @@ -76,6 +81,21 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PrintCraftApp, t: &Tokens, kind: } }); ui.label(egui::RichText::new(format!("One {} file per page, named after the document.", d.format.label())).small().color(t.text_faint)); + } else if kind == ExportKind::AllImages { + ui.horizontal(|ui| { + ui.label("Exclude images smaller than"); + let label = |n: u32| if n == 0 { "No limit".to_string() } else { format!("{n} pixels") }; + egui::ComboBox::from_id_salt("export-min").selected_text(label(d.min_side)).show_ui(ui, |ui| { + for n in [0, 16, 32, 64, 128, 256] { + ui.selectable_value(&mut d.min_side, n, label(n)); + } + }); + }); + ui.label( + egui::RichText::new("Each image once, named after the document and page. JPEG images are saved unchanged; others as PNG.") + .small() + .color(t.text_faint), + ); } else { ui.label(egui::RichText::new("Plain text in reading order; pages are separated by form feeds.").small().color(t.text_faint)); } @@ -103,19 +123,39 @@ fn run( kind: ExportKind, dpi: f64, format: ImageFormat, + min_side: u32, pages: Vec, stem: String, mut sink: impl FnMut(&str, Vec) -> Result<(), String>, status: &ExportStatus, ) -> String { - let mut ex = Exporter::from_source(src); let total = pages.len(); let set = |done: usize, msg: Option| { if let Ok(mut s) = status.lock() { *s = Some((done, total, msg)); } }; + if kind == ExportKind::AllImages { + set(0, None); + let out = match printcraft_engine::export::extract_images(&src, &pages, min_side) { + Ok(o) => o, + Err(e) => return format!("Export stopped: {e}"), + }; + for (k, img) in out.images.iter().enumerate() { + if let Err(e) = sink(&printcraft_engine::export::image_file_name(&stem, img, k + 1), img.data.clone()) { + return format!("Export stopped: {e}"); + } + } + let n = out.images.len(); + let mut msg = format!("Exported {n} image{}", if n == 1 { "" } else { "s" }); + if !out.skipped.is_empty() { + msg.push_str(&format!(" ({} not exported: {})", out.skipped.len(), out.skipped[0].2)); + } + return msg; + } + let mut ex = Exporter::from_source(src); match kind { + ExportKind::AllImages => unreachable!("handled above"), ExportKind::Image => { for (k, p) in pages.iter().enumerate() { set(k, None); @@ -146,6 +186,7 @@ impl PrintCraftApp { let pages = self.export_draft.range.pages(src.pages); let dpi = self.export_draft.dpi; let format = self.export_draft.format; + let min_side = self.export_draft.min_side; let status: ExportStatus = Arc::new(Mutex::new(Some((0, pages.len(), None)))); #[cfg(not(target_arch = "wasm32"))] { @@ -160,7 +201,7 @@ impl PrintCraftApp { let sink = |name: &str, bytes: Vec| { crate::editing::write_atomically(&dir.join(name).to_string_lossy(), &bytes).map_err(|e| format!("{name}: {e}")) }; - let msg = run(src, kind, dpi, format, pages, stem, sink, &st); + let msg = run(src, kind, dpi, format, min_side, pages, stem, sink, &st); if let Ok(mut s) = st.lock() { let (done, total) = s.as_ref().map_or((0, 0), |(d, t, _)| (*d, *t)); *s = Some((done.max(total), total, Some(format!("{msg} to {shown}")))); @@ -174,7 +215,7 @@ impl PrintCraftApp { } #[cfg(target_arch = "wasm32")] { - let msg = run(src, kind, dpi, format, pages, stem, |name, bytes| crate::editing::download(name, &bytes), &status); + let msg = run(src, kind, dpi, format, min_side, pages, stem, |name, bytes| crate::editing::download(name, &bytes), &status); if let Ok(mut s) = status.lock() { *s = Some((0, 0, Some(msg))); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 2dc22086..d5ece1ac 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -790,6 +790,7 @@ impl PrintCraftApp { "background" => Some(Dialog::Marks(printcraft_engine::MarkKind::Background)), "export-image" => Some(Dialog::Export(export_ui::ExportKind::Image)), "export-text" => Some(Dialog::Export(export_ui::ExportKind::Text)), + "export-all-images" => Some(Dialog::Export(export_ui::ExportKind::AllImages)), "signature" => Some(Dialog::Signature), "optimize" => Some(Dialog::Optimize), "sign" | "certify" => { diff --git a/crates/ui-egui/tests/export.rs b/crates/ui-egui/tests/export.rs index 4e05c7ec..1f98d28a 100644 --- a/crates/ui-egui/tests/export.rs +++ b/crates/ui-egui/tests/export.rs @@ -37,4 +37,11 @@ fn export_dialogs_write_images_and_text() { h.get_by_label("Export").click(); h.run_steps(4); assert!(dir.join("doc.txt").exists()); + // Export all images: this document has none, and says so. + assert!(h.state_mut().execute("export.all_images")); + h.run_steps(2); + h.get_by_label("Export All Images"); + h.get_by_label("Export").click(); + h.run_steps(4); + h.get_by_label_contains("Exported 0 images"); } diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 2f201678..6ee8a3b9 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -6907,11 +6907,11 @@ title = "Export all embedded images" area = "J" tier = "P1" milestone = "M10" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["export.all_images"] +tools = ["doc_export_all_images"] +evidence = ["crates/create/src/tests.rs::images_export_as_jpeg_unchanged_and_others_as_png", "crates/create/src/tests.rs::indexed_and_one_bit_images_decode", "crates/automation/tests/automation.rs::exporting_all_images_through_tools", "crates/ui-egui/tests/export.rs::export_dialogs_write_images_and_text"] +notes = "each image once (page resources and nested forms); JPEG unchanged, others PNG (gray/RGB/CMYK/ICC/Indexed, 1–16 bpc, stencil masks, soft mask as alpha); JPX/JBIG2/CCITT/separations reported as skipped; minimum size filter" [[feature]] id = "create.export-html"