M10: Export a PDF ▸ Export all images: JPEGs unchanged, other images as PNG with alpha; skipped images reported; doc_export_all_images

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-02 04:47:45 -04:00
parent f1817459f2
commit 1e69a92c41
15 changed files with 456 additions and 10 deletions

View File

@ -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<Value> = 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") {

View File

@ -673,6 +673,17 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["doc", "folder"],
)),
t("doc_export_all_images", "Export all images", "Write the images that pages use into a folder (`<name>_Page_<n>_Image_<k>.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"])),

View File

@ -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");

View File

@ -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"

View File

@ -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<u8>,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ImageExport {
pub images: Vec<ExtractedImage>,
/// 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<ObjRef>, out: &mut Vec<ObjRef>) {
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<Vec<u8>> {
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<u8>), 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<Space>, Vec<u8>),
}
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<Space, String> {
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<Vec<u8>> {
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<Vec<u8>> {
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<Vec<u8>, 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<u8> = 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)
}

View File

@ -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)]

View File

@ -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]);
}

View File

@ -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")),

View File

@ -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"),

View File

@ -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<printcraft_create::ImageExport, String> {
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: `<stem>_Page_<n>_Image_<index>.<ext>`.
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())

View File

@ -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");

View File

@ -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<usize>,
stem: String,
mut sink: impl FnMut(&str, Vec<u8>) -> Result<(), String>,
status: &ExportStatus,
) -> String {
let mut ex = Exporter::from_source(src);
let total = pages.len();
let set = |done: usize, msg: Option<String>| {
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<u8>| {
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)));
}

View File

@ -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" => {

View File

@ -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");
}

View File

@ -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"