diff --git a/apps/pdfcraft-cli/src/main.rs b/apps/pdfcraft-cli/src/main.rs index bb1e66c6..d1dd8a55 100644 --- a/apps/pdfcraft-cli/src/main.rs +++ b/apps/pdfcraft-cli/src/main.rs @@ -2,7 +2,7 @@ //! //! ```text //! pdfcraft-cli info [--password PW] document summary as JSON -//! pdfcraft-cli render --page N [--dpi 96] --out x.pam +//! pdfcraft-cli render --page N [--dpi 96] --out x.png (.png, .jpg, .tif or .pam) //! pdfcraft-cli text [--page N] extracted text (pages separated by form feeds) //! pdfcraft-cli edit --out out.pdf [--rotate 1,3:90] [--delete 2,4] [--move 5:1] //! [--insert-blank 1] [--title T] [--author A] [--full] @@ -39,6 +39,7 @@ use std::process::{Command, ExitCode, Stdio}; use std::sync::Arc; use std::time::{Duration, Instant}; +use pdfcraft_engine::export::ImageFormat; use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind, inspect}; fn main() -> ExitCode { @@ -333,16 +334,30 @@ fn render(args: &[String]) -> Result<(), CliError> { let path = *positional(args).first().ok_or("render: missing file")?; let page: usize = flag(args, "--page").unwrap_or("1").parse().map_err(|_| "bad --page")?; let dpi: f32 = flag(args, "--dpi").unwrap_or("96").parse().map_err(|_| "bad --dpi")?; - let out = flag(args, "--out").ok_or("render: missing --out (.pam)")?; + let out = flag(args, "--out").ok_or("render: missing --out (.png, .jpg, .tif or .pam)")?; + // The file is what its name says (#248): a `.png` used to get a netpbm PAM stream. + let format = match Path::new(out).extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).as_deref() { + Some("png") => Some(ImageFormat::Png), + Some("jpg" | "jpeg") => Some(ImageFormat::Jpeg { quality: 90 }), + Some("tif" | "tiff") => Some(ImageFormat::Tiff), + Some("pam") => None, + _ => return Err(format!("render: --out {out}: use a .png, .jpg, .tif or .pam name").into()), + }; let mut r = PageRenderer::new(read(path)?, RenderConfig { password: flag(args, "--password").map(Arc::from), ..Default::default() }); let p = r.render(RenderRequest { page: page.saturating_sub(1), kind: RequestKind::Pixels, tile: None, scale: dpi / 72.0, tag: 0 }); if let Some(e) = p.error { return Err(e.into()); } - // PAM (netpbm RGB_ALPHA) keeps this tool dependency-free; convert with any image tool. - let mut f = std::fs::File::create(out).map_err(|e| e.to_string())?; - write!(f, "P7\nWIDTH {}\nHEIGHT {}\nDEPTH 4\nMAXVAL 255\nTUPLTYPE RGB_ALPHA\nENDHDR\n", p.width, p.height).map_err(|e| e.to_string())?; - f.write_all(&p.rgba).map_err(|e| e.to_string())?; + let bytes = match format { + Some(f) => pdfcraft_engine::export::encode_image(p.width, p.height, &p.rgba, f)?, + None => { + // PAM (netpbm RGB_ALPHA): the raw premultiplied pixels, for tools that read it. + let mut pam = format!("P7\nWIDTH {}\nHEIGHT {}\nDEPTH 4\nMAXVAL 255\nTUPLTYPE RGB_ALPHA\nENDHDR\n", p.width, p.height).into_bytes(); + pam.extend_from_slice(&p.rgba); + pam + } + }; + std::fs::write(out, bytes).map_err(|e| format!("{out}: {e}"))?; eprintln!("rendered page {page} at {dpi} dpi: {}×{} px in {} ms", p.width, p.height, p.millis); Ok(()) } diff --git a/apps/pdfcraft-cli/tests/render.rs b/apps/pdfcraft-cli/tests/render.rs new file mode 100644 index 00000000..b0ab2456 --- /dev/null +++ b/apps/pdfcraft-cli/tests/render.rs @@ -0,0 +1,79 @@ +//! `render --out` writes the file format its name says (#248): a `.png` is a PNG, not a netpbm +//! PAM stream with a `.png` extension. + +use std::path::PathBuf; +use std::process::{Command, Output}; + +const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli"); + +fn tmp(test: &str, name: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("pdfcraft-cli-render-{test}-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + d.join(name) +} + +/// A one-page PDF with some text, as `--out` would be given it. +fn fixture(test: &str) -> PathBuf { + let pdf = tmp(test, "in.pdf"); + let bytes = pdfcraft_engine::Session::new().create_from_text("Render test", "Hello from PdfCraft").unwrap(); + std::fs::write(&pdf, bytes.as_slice()).unwrap(); + pdf +} + +fn render(pdf: &std::path::Path, out: &std::path::Path) -> Output { + Command::new(BIN).args(["render", pdf.to_str().unwrap(), "--page", "1", "--dpi", "36", "--out", out.to_str().unwrap()]).output().unwrap() +} + +fn be32(b: &[u8]) -> u32 { + u32::from_be_bytes([b[0], b[1], b[2], b[3]]) +} + +#[test] +fn a_png_name_gets_a_png() { + let pdf = fixture("png"); + let out = tmp("png", "p1.png"); + let r = render(&pdf, &out); + assert!(r.status.success(), "{}", String::from_utf8_lossy(&r.stderr)); + let bytes = std::fs::read(&out).unwrap(); + assert_eq!(&bytes[..8], b"\x89PNG\r\n\x1a\n", "not a PNG: {:?}", &bytes[..bytes.len().min(16)]); + // IHDR: the page is 612×792 pt, rendered at 36 dpi + assert_eq!(&bytes[12..16], b"IHDR"); + assert_eq!((be32(&bytes[16..20]), be32(&bytes[20..24])), (306, 396)); + // the reported size matches the file + let stderr = String::from_utf8_lossy(&r.stderr); + assert!(stderr.contains("306×396"), "{stderr}"); +} + +#[test] +fn jpeg_and_tiff_names_get_those_formats() { + let pdf = fixture("jpg"); + let jpg = tmp("jpg", "p1.JPG"); + assert!(render(&pdf, &jpg).status.success()); + assert_eq!(&std::fs::read(&jpg).unwrap()[..2], b"\xff\xd8"); + let tif = tmp("jpg", "p1.tif"); + assert!(render(&pdf, &tif).status.success()); + let bytes = std::fs::read(&tif).unwrap(); + assert!(bytes.starts_with(b"II*\0") || bytes.starts_with(b"MM\0*"), "not a TIFF: {:?}", &bytes[..4]); +} + +#[test] +fn a_pam_name_still_gets_the_netpbm_stream() { + let pdf = fixture("pam"); + let out = tmp("pam", "p1.pam"); + assert!(render(&pdf, &out).status.success()); + let bytes = std::fs::read(&out).unwrap(); + let header = b"P7\nWIDTH 306\nHEIGHT 396\nDEPTH 4\nMAXVAL 255\nTUPLTYPE RGB_ALPHA\nENDHDR\n"; + assert!(bytes.starts_with(header), "{:?}", String::from_utf8_lossy(&bytes[..bytes.len().min(80)])); + assert_eq!(bytes.len(), header.len() + 306 * 396 * 4); +} + +#[test] +fn an_unknown_extension_fails_and_writes_nothing() { + let pdf = fixture("bmp"); + let out = tmp("bmp", "p1.bmp"); + let r = render(&pdf, &out); + assert!(!r.status.success()); + let stderr = String::from_utf8_lossy(&r.stderr); + assert!(stderr.contains(".png, .jpg, .tif or .pam"), "{stderr}"); + assert!(!out.exists()); +} diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index aa4e062d..ca90c009 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -71,11 +71,7 @@ impl Exporter { if let Some(e) = r.error { return Err(format!("page {}: {e}", page + 1)); } - match format { - ImageFormat::Png => encode_png(r.width, r.height, &r.rgba), - ImageFormat::Jpeg { quality } => encode_jpeg(r.width, r.height, &r.rgba, quality), - ImageFormat::Tiff => encode_tiff(r.width, r.height, &r.rgba), - } + encode_image(r.width, r.height, &r.rgba, format) } /// The reading-order text of a page. @@ -102,6 +98,15 @@ impl Exporter { } } +/// Premultiplied RGBA → an image file of `format`. +pub fn encode_image(width: u32, height: u32, premultiplied: &[u8], format: ImageFormat) -> Result, String> { + match format { + ImageFormat::Png => encode_png(width, height, premultiplied), + ImageFormat::Jpeg { quality } => encode_jpeg(width, height, premultiplied, quality), + ImageFormat::Tiff => encode_tiff(width, height, premultiplied), + } +} + /// Premultiplied RGBA → PNG (straight alpha). pub fn encode_png(width: u32, height: u32, premultiplied: &[u8]) -> Result, String> { let mut rgba = premultiplied.to_vec();