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:
parent
f1817459f2
commit
1e69a92c41
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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"])),
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")),
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" => {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue