mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-14 19:27:04 -04:00
Issue #248: pdfcraft-cli render writes the format its --out name says (.png, .jpg, .tif or .pam) (#255)
`render --out p1.png` wrote a netpbm PAM stream (P7) whatever the name, so a `.png` could not be opened by any PNG reader. The CLI already links the engine, whose export module encodes PNG, JPEG and TIFF, so the output format now follows the extension: `.png`, `.jpg`/`.jpeg` (quality 90), `.tif`/`.tiff`, and `.pam` for the raw stream as before. Any other name is refused up front, before the page is rendered, with the accepted list. `export::encode_image` wraps the three encoders behind `ImageFormat` so the CLI and `Exporter::image` share one match. Tests (apps/pdfcraft-cli/tests/render.rs): a `.png` gets the PNG signature and an IHDR with the rendered size; `.JPG` and `.tif` get JPEG/TIFF magic; `.pam` still gets the P7 header and the right number of bytes; `.bmp` fails with the message and writes nothing. Co-authored-by: wstock <[email protected]> Co-authored-by: Claude Fable 5.1 <[email protected]> Co-authored-by: Brandon Thomas <[email protected]>
This commit is contained in:
3 files changed
+110
-11
No files matched your search
@@ -2,7 +2,7 @@
|
||||
//!
|
||||
//! ```text
|
||||
//! pdfcraft-cli info <file.pdf> [--password PW] document summary as JSON
|
||||
//! pdfcraft-cli render <file.pdf> --page N [--dpi 96] --out x.pam
|
||||
//! pdfcraft-cli render <file.pdf> --page N [--dpi 96] --out x.png (.png, .jpg, .tif or .pam)
|
||||
//! pdfcraft-cli text <file.pdf> [--page N] extracted text (pages separated by form feeds)
|
||||
//! pdfcraft-cli edit <in.pdf> --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(())
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
Reference in new issue
Block a user