use std::io::Write;
use std::path::PathBuf;
use stet_core::context::Context;
use stet_core::eps::{content_is_epsf, read_eps_bounding_box, strip_dos_eps_header};
use stet_engine::eval::{parse_and_exec, parse_and_exec_file};
use stet_graphics::icc::{BpcMode, IccCacheOptions};
use stet_ops::build_system_dict;
use stet_pdf::PdfDevice;
use stet_pdf_reader::PdfDocument;
use stet_render::SkiaDevice;
#[derive(Clone, Default)]
struct IccCliConfig {
no_icc: bool,
output_profile_path: Option<String>,
cmyk_profile_path: Option<String>,
bpc_mode: BpcMode,
use_output_intent: bool,
}
impl IccCliConfig {
fn source_cmyk_path(&self) -> Option<&str> {
self.cmyk_profile_path
.as_deref()
.or(self.output_profile_path.as_deref())
}
}
struct SharedWriter(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);
impl std::io::Write for SharedWriter {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0.lock().unwrap().extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
mod inspect;
fn main() {
#[cfg(target_os = "linux")]
if std::env::var("WAYLAND_DISPLAY").is_ok() && std::env::var("DISPLAY").is_ok() {
unsafe { std::env::remove_var("WAYLAND_DISPLAY") };
}
let args: Vec<String> = std::env::args().collect();
if let Some(arg1) = args.get(1).map(String::as_str) {
match arg1 {
"--help" | "-h" | "-?" => {
print_help();
std::process::exit(0);
}
"--version" | "-V" => {
println!("stet {}", env!("CARGO_PKG_VERSION"));
std::process::exit(0);
}
_ => {}
}
}
if args.get(1).map(String::as_str) == Some("inspect") {
std::process::exit(run_inspect_subcommand(&args[2..]));
}
let mut dpi: Option<f64> = None;
let mut threads: Option<usize> = None;
let mut device_name: Option<String> = None;
let mut no_icc = false;
let mut no_aa = false;
let mut output_profile_path: Option<String> = None;
let mut cmyk_profile_path: Option<String> = None;
let mut bpc_mode = BpcMode::Auto;
let mut bpc_explicit = false;
let mut use_output_intent = true;
let mut pages_spec: Option<String> = None;
let mut timeout_secs: Option<f64> = None;
let mut max_vm_mb: Option<u64> = None;
let mut password: Option<String> = None;
let mut target_width: Option<u32> = None;
let mut target_height: Option<u32> = None;
let mut page_size: Option<(f64, f64)> = None;
let mut file_args: Vec<String> = Vec::new();
let mut i = 1;
while i < args.len() {
match args[i].as_str() {
"--page" => {
if i + 1 < args.len() {
page_size = Some(parse_page_size(&args[i + 1]).unwrap_or_else(|e| {
eprintln!("Error: {}", e);
std::process::exit(1);
}));
i += 2;
continue;
} else {
eprintln!("Error: --page requires a value (e.g. 'a4' or '620x1000')");
std::process::exit(1);
}
}
"--dpi" => {
if i + 1 < args.len() {
dpi = Some(args[i + 1].parse().unwrap_or_else(|_| {
eprintln!("Error: invalid DPI value '{}'", args[i + 1]);
std::process::exit(1);
}));
i += 2;
continue;
} else {
eprintln!("Error: --dpi requires a value");
std::process::exit(1);
}
}
"--threads" => {
if i + 1 < args.len() {
let n: usize = args[i + 1].parse().unwrap_or_else(|_| {
eprintln!("Error: invalid thread count '{}'", args[i + 1]);
std::process::exit(1);
});
if n == 0 {
eprintln!("Error: --threads must be at least 1");
std::process::exit(1);
}
threads = Some(n);
i += 2;
continue;
} else {
eprintln!("Error: --threads requires a value");
std::process::exit(1);
}
}
"--device" => {
if i + 1 < args.len() {
device_name = Some(args[i + 1].clone());
i += 2;
continue;
} else {
eprintln!("Error: --device requires a value");
std::process::exit(1);
}
}
"--no-icc" => {
no_icc = true;
i += 1;
continue;
}
"--use-output-intent" => {
use_output_intent = true;
i += 1;
continue;
}
"--no-output-intent" => {
use_output_intent = false;
i += 1;
continue;
}
"--no-aa" => {
no_aa = true;
i += 1;
continue;
}
"--output-profile" => {
if i + 1 < args.len() {
output_profile_path = Some(args[i + 1].clone());
i += 2;
continue;
} else {
eprintln!("Error: --output-profile requires a path");
std::process::exit(1);
}
}
"--cmyk-profile" => {
if i + 1 < args.len() {
cmyk_profile_path = Some(args[i + 1].clone());
i += 2;
continue;
} else {
eprintln!("Error: --cmyk-profile requires a path");
std::process::exit(1);
}
}
"--bpc" => {
if i + 1 < args.len() {
bpc_mode = match args[i + 1].as_str() {
"on" => BpcMode::On,
"off" => BpcMode::Off,
"auto" => BpcMode::Auto,
other => {
eprintln!(
"Error: --bpc must be one of: on, off, auto (got '{}')",
other
);
std::process::exit(1);
}
};
bpc_explicit = true;
i += 2;
continue;
} else {
eprintln!("Error: --bpc requires a value (on|off|auto)");
std::process::exit(1);
}
}
"--max-vm" => {
if i + 1 < args.len() {
match args[i + 1].parse::<u64>() {
Ok(mb) if mb > 0 => {
max_vm_mb = Some(mb);
i += 2;
continue;
}
_ => {
eprintln!("Error: --max-vm requires a positive size in megabytes");
std::process::exit(1);
}
}
} else {
eprintln!("Error: --max-vm requires a value in megabytes");
std::process::exit(1);
}
}
"--timeout" => {
if i + 1 < args.len() {
match args[i + 1].parse::<f64>() {
Ok(secs) if secs > 0.0 && secs.is_finite() => {
timeout_secs = Some(secs);
i += 2;
continue;
}
_ => {
eprintln!("Error: --timeout requires a positive number of seconds");
std::process::exit(1);
}
}
} else {
eprintln!("Error: --timeout requires a value in seconds");
std::process::exit(1);
}
}
"--pages" => {
if i + 1 < args.len() {
pages_spec = Some(args[i + 1].clone());
i += 2;
continue;
} else {
eprintln!("Error: --pages requires a value (e.g., 1-5, 3, 1-3,7,10-12)");
std::process::exit(1);
}
}
"--password" => {
if i + 1 < args.len() {
password = Some(args[i + 1].clone());
i += 2;
continue;
} else {
eprintln!("Error: --password requires a value");
std::process::exit(1);
}
}
"--width" => {
if i + 1 < args.len() {
target_width = Some(args[i + 1].parse().unwrap_or_else(|_| {
eprintln!("Error: invalid --width value '{}'", args[i + 1]);
std::process::exit(1);
}));
if target_width == Some(0) {
eprintln!("Error: --width must be at least 1");
std::process::exit(1);
}
i += 2;
continue;
} else {
eprintln!("Error: --width requires a pixel value");
std::process::exit(1);
}
}
"--height" => {
if i + 1 < args.len() {
target_height = Some(args[i + 1].parse().unwrap_or_else(|_| {
eprintln!("Error: invalid --height value '{}'", args[i + 1]);
std::process::exit(1);
}));
if target_height == Some(0) {
eprintln!("Error: --height must be at least 1");
std::process::exit(1);
}
i += 2;
continue;
} else {
eprintln!("Error: --height requires a pixel value");
std::process::exit(1);
}
}
_ => {}
}
file_args.push(args[i].clone());
i += 1;
}
if no_icc && cmyk_profile_path.is_some() {
eprintln!("Error: --cmyk-profile cannot be combined with --no-icc");
std::process::exit(1);
}
if no_icc && bpc_explicit {
eprintln!("Error: --bpc cannot be combined with --no-icc");
std::process::exit(1);
}
if (target_width.is_some() || target_height.is_some()) && dpi.is_some() {
eprintln!("Error: --width/--height cannot be combined with --dpi");
std::process::exit(1);
}
let _ = bpc_explicit; let icc_cfg = IccCliConfig {
no_icc,
output_profile_path,
cmyk_profile_path,
bpc_mode,
use_output_intent,
};
let device = device_name.unwrap_or_else(|| {
if file_args.is_empty() {
"png".to_string()
} else if cfg!(feature = "viewer") {
"viewer".to_string()
} else {
"png".to_string()
}
});
let default_pool_size = if device == "viewer" {
let cpus = std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(8);
(cpus * 3 / 4).max(1)
} else {
8
};
let pool_size = threads.unwrap_or(default_pool_size);
rayon::ThreadPoolBuilder::new()
.num_threads(pool_size)
.build_global()
.unwrap_or_else(|e| {
eprintln!("Error: failed to set thread count: {}", e);
std::process::exit(1);
});
let page_filter = pages_spec.map(|spec| {
parse_page_ranges(&spec).unwrap_or_else(|e| {
eprintln!("Error: {}", e);
eprintln!("Expected format: 1-5, 3, 1-3,7,10-12");
std::process::exit(1);
})
});
if page_size.is_some() && !file_args.is_empty() && file_args.iter().all(|f| is_pdf_file(f)) {
eprintln!(
"Error: --page applies to PostScript/EPS input; PDF pages carry their own \
size (use --width/--height to scale PDF output)"
);
std::process::exit(1);
}
if page_size.is_some() && (target_width.is_some() || target_height.is_some()) {
eprintln!("Error: --page cannot be combined with --width/--height");
std::process::exit(1);
}
if (target_width.is_some() || target_height.is_some())
&& !matches!(device.as_str(), "png" | "viewport-png")
{
eprintln!(
"Error: --width/--height is only supported for --device png (got '{}')",
device
);
std::process::exit(1);
}
match device.as_str() {
"png" => {
run_png_mode(
dpi,
file_args,
&icc_cfg,
no_aa,
page_filter,
false,
password.as_deref(),
target_width,
target_height,
page_size,
timeout_secs,
max_vm_mb,
);
}
"viewport-png" => {
run_png_mode(
dpi,
file_args,
&icc_cfg,
no_aa,
page_filter,
true,
password.as_deref(),
target_width,
target_height,
page_size,
timeout_secs,
max_vm_mb,
);
}
"pdf" => {
let any_pdf = file_args.iter().any(|f| is_pdf_file(f));
let all_pdf = !file_args.is_empty() && file_args.iter().all(|f| is_pdf_file(f));
if any_pdf && !all_pdf {
eprintln!(
"Error: --device pdf requires either all-PostScript or all-PDF inputs, not a mix"
);
std::process::exit(1);
}
if all_pdf {
run_pdf_input_pdf(&file_args, &icc_cfg, &page_filter, password.as_deref());
} else {
let _ = &password;
run_pdf_mode(
dpi,
file_args,
&icc_cfg,
no_aa,
page_filter,
page_size,
timeout_secs,
max_vm_mb,
);
}
}
"null" => {
run_null_mode(
dpi,
file_args,
&icc_cfg,
no_aa,
page_filter,
page_size,
timeout_secs,
max_vm_mb,
);
}
#[cfg(feature = "viewer")]
"viewer" => run_viewer_mode(
dpi,
file_args,
&icc_cfg,
no_aa,
page_filter,
password,
page_size,
timeout_secs,
max_vm_mb,
),
#[cfg(not(feature = "viewer"))]
"viewer" => {
eprintln!("Error: viewer not available (built without 'viewer' feature)");
std::process::exit(1);
}
other => {
eprintln!("Error: unknown device '{}'", other);
eprintln!("Available devices: png, viewport-png, pdf, null, viewer");
std::process::exit(1);
}
}
}
#[allow(clippy::too_many_arguments)]
fn run_png_mode(
dpi_override: Option<f64>,
file_args: Vec<String>,
icc_cfg: &IccCliConfig,
no_aa: bool,
page_filter: Option<std::collections::HashSet<i32>>,
use_viewport: bool,
password: Option<&str>,
target_width: Option<u32>,
target_height: Option<u32>,
page_size: Option<(f64, f64)>,
timeout_secs: Option<f64>,
max_vm_mb: Option<u64>,
) {
if !file_args.is_empty() && file_args.iter().all(|f| is_pdf_file(f)) {
let dpi = dpi_override.unwrap_or(300.0);
run_pdf_input_png(
dpi,
&file_args,
&page_filter,
no_aa,
use_viewport,
icc_cfg,
password,
target_width,
target_height,
);
return;
}
if target_width.is_some() || target_height.is_some() {
eprintln!(
"Error: --width/--height is not yet supported for PostScript input — use --dpi for now"
);
std::process::exit(1);
}
let mut ctx = create_context(icc_cfg, timeout_secs, max_vm_mb);
ctx.page_filter = page_filter;
let cmyk_bytes = ctx.icc_cache.system_cmyk_bytes().cloned();
ctx.device_factory = Some(Box::new(move |w, h| {
let mut dev = SkiaDevice::new(w, h);
if let Some(ref bytes) = cmyk_bytes {
dev.set_system_cmyk_bytes(bytes.clone());
}
dev.set_no_aa(no_aa);
dev.set_use_viewport_path(use_viewport);
Box::new(dev)
}));
if !file_args.is_empty() {
run_file_jobs(
&mut ctx,
dpi_override,
&file_args,
"png",
page_size,
None,
None,
);
} else {
run_repl(&mut ctx);
}
}
fn run_pdf_mode(
dpi_override: Option<f64>,
file_args: Vec<String>,
icc_cfg: &IccCliConfig,
_no_aa: bool,
page_filter: Option<std::collections::HashSet<i32>>,
page_size: Option<(f64, f64)>,
timeout_secs: Option<f64>,
max_vm_mb: Option<u64>,
) {
let mut ctx = create_context(icc_cfg, timeout_secs, max_vm_mb);
ctx.page_filter = page_filter;
let dpi_val = dpi_override.unwrap_or(300.0);
ctx.device_factory = Some(Box::new(move |w, h| {
Box::new(PdfDevice::new(w, h, dpi_val))
}));
stet_ops::register_pdf_authoring_ops(&mut ctx);
if !file_args.is_empty() {
run_file_jobs(
&mut ctx,
dpi_override,
&file_args,
"pdf",
page_size,
None,
None,
);
} else {
eprintln!("Error: PDF device requires input files");
std::process::exit(1);
}
}
fn run_null_mode(
dpi_override: Option<f64>,
file_args: Vec<String>,
icc_cfg: &IccCliConfig,
_no_aa: bool,
page_filter: Option<std::collections::HashSet<i32>>,
page_size: Option<(f64, f64)>,
timeout_secs: Option<f64>,
max_vm_mb: Option<u64>,
) {
use stet_core::device::NullDevice;
let mut ctx = create_context(icc_cfg, timeout_secs, max_vm_mb);
ctx.page_filter = page_filter;
ctx.device_factory = Some(Box::new(|w, h| Box::new(NullDevice::new(w, h))));
if !file_args.is_empty() {
run_file_jobs(
&mut ctx,
dpi_override,
&file_args,
"null",
page_size,
None,
None,
);
} else {
run_repl(&mut ctx);
}
}
#[cfg(feature = "viewer")]
fn run_viewer_mode(
dpi_override: Option<f64>,
file_args: Vec<String>,
icc_cfg: &IccCliConfig,
no_aa: bool,
page_filter: Option<std::collections::HashSet<i32>>,
cli_password: Option<String>,
page_size: Option<(f64, f64)>,
timeout_secs: Option<f64>,
max_vm_mb: Option<u64>,
) {
use stet_core::device::NullDevice;
let system_cmyk_bytes = if !icc_cfg.no_icc {
if let Some(path) = icc_cfg.source_cmyk_path() {
std::fs::read(path).ok().map(std::sync::Arc::new)
} else {
stet_graphics::icc::find_system_cmyk_profile_bytes()
}
} else {
None
};
let (
interp_end,
viewer_end,
dl_sender,
advance_rx,
file_drop_rx,
interrupt_flag,
password_response_rx,
) = stet_viewer::create_channels();
let first_file = file_args.first().cloned();
let first_page_size = first_file.as_deref().and_then(|path| {
let lower = path.to_lowercase();
if lower.ends_with(".eps") || lower.ends_with(".epsf") {
let data = std::fs::read(path).ok()?;
let ps_data = strip_dos_eps_header(&data);
let (llx, lly, urx, ury) = read_eps_bounding_box(ps_data)?;
let w = urx - llx;
let h = ury - lly;
if w > 0.0 && h > 0.0 {
Some((w, h))
} else {
None
}
} else {
None }
});
let page_sender = interp_end.page_sender;
let page_sender_for_interp = page_sender.clone();
let dl_receiver = interp_end.dl_receiver;
std::thread::spawn(move || {
let mut page_num = 1u32;
while let Ok((dl, dpi, w, h, cmyk_bytes, cmyk_proofing)) = dl_receiver.recv() {
if w == 0 && h == 0 {
if dpi < 0.0 {
let _ = page_sender.send(stet_viewer::ViewerMsg::JobDone);
} else {
let _ = page_sender.send(stet_viewer::ViewerMsg::NewJob);
page_num = 1;
}
continue;
}
let _ = page_sender.send(stet_viewer::ViewerMsg::Page(stet_viewer::PageReady {
display_list: dl,
width: w,
height: h,
dpi,
page_num,
cmyk_bytes,
cmyk_proofing,
}));
page_num += 1;
}
});
let _screen_info_receiver = interp_end.screen_info_receiver;
let icc_cfg_thread = icc_cfg.clone();
let timeout_secs_thread = timeout_secs;
let max_vm_mb_thread = max_vm_mb;
let interrupt_flag_thread = interrupt_flag.clone();
let password_response_rx_thread = password_response_rx;
let page_sender_thread = page_sender_for_interp;
let cli_password_thread = cli_password;
std::thread::spawn(move || {
let mut ctx = create_context(&icc_cfg_thread, timeout_secs_thread, max_vm_mb_thread);
ctx.page_filter = page_filter;
ctx.interrupt_flag = Some(interrupt_flag_thread.clone());
ctx.display_list_sender = Some(dl_sender);
ctx.device_factory = Some(Box::new(|w, h| Box::new(NullDevice::new(w, h))));
if file_args.is_empty() {
install_device(&mut ctx, dpi_override, "png");
run_repl(&mut ctx);
if let Some(ref sender) = ctx.display_list_sender {
let _ = sender.send((
stet_graphics::display_list::DisplayList::new(),
-1.0,
0,
0,
None,
false,
));
}
} else {
let ps_files: Vec<String> = file_args
.iter()
.filter(|f| !is_pdf_file(f))
.cloned()
.collect();
let pdf_files: Vec<String> = file_args
.iter()
.filter(|f| is_pdf_file(f))
.cloned()
.collect();
for (i, path) in pdf_files.iter().enumerate() {
if i > 0 || !ps_files.is_empty() {
if let Some(ref sender) = ctx.display_list_sender {
let _ = sender.send((
stet_graphics::display_list::DisplayList::new(),
0.0,
0,
0,
None,
false,
));
}
}
if let Some(ref sender) = ctx.display_list_sender {
render_dropped_pdf(
path,
dpi_override,
sender,
&ctx.icc_cache,
icc_cfg_thread.use_output_intent,
&interrupt_flag_thread,
Some(&page_sender_thread),
Some(&password_response_rx_thread),
cli_password_thread.as_deref(),
);
}
}
if !ps_files.is_empty() {
if !pdf_files.is_empty() {
if let Some(ref sender) = ctx.display_list_sender {
let _ = sender.send((
stet_graphics::display_list::DisplayList::new(),
0.0,
0,
0,
None,
false,
));
}
}
run_file_jobs_viewer(&mut ctx, dpi_override, &ps_files, page_size, advance_rx);
}
if let Some(ref sender) = ctx.display_list_sender {
let _ = sender.send((
stet_graphics::display_list::DisplayList::new(),
-1.0,
0,
0,
None,
false,
));
}
}
let established_dpi = dpi_override;
while let Ok(mut path) = file_drop_rx.recv() {
loop {
while let Ok(newer) = file_drop_rx.try_recv() {
path = newer;
}
interrupt_flag_thread.store(false, std::sync::atomic::Ordering::Relaxed);
let sender = match ctx.display_list_sender {
Some(ref s) => s.clone(),
None => return,
};
let _ = sender.send((
stet_graphics::display_list::DisplayList::new(),
0.0,
0,
0,
None,
false,
));
if is_pdf_file(&path) {
render_dropped_pdf(
&path,
established_dpi,
&sender,
&ctx.icc_cache,
icc_cfg_thread.use_output_intent,
&interrupt_flag_thread,
Some(&page_sender_thread),
Some(&password_response_rx_thread),
None,
);
} else {
run_file_jobs(
&mut ctx,
established_dpi,
&[path.clone()],
"viewer",
page_size,
None,
None,
);
}
let _ = sender.send((
stet_graphics::display_list::DisplayList::new(),
-1.0,
0,
0,
None,
false,
));
if interrupt_flag_thread.load(std::sync::atomic::Ordering::Relaxed) {
match file_drop_rx.try_recv() {
Ok(next) => {
path = next;
continue;
}
Err(_) => {
break;
}
}
}
break;
}
}
});
let page_rx = viewer_end.page_receiver;
let screen_info_sender = viewer_end.screen_info_sender;
let advance_sender = viewer_end.advance_sender;
let mut first_event: Option<stet_viewer::ViewerMsg> = None;
loop {
match page_rx.recv() {
Ok(msg @ stet_viewer::ViewerMsg::Page(_)) => {
first_event = Some(msg);
break;
}
Ok(msg @ stet_viewer::ViewerMsg::PasswordRequired { .. }) => {
first_event = Some(msg);
break;
}
Ok(stet_viewer::ViewerMsg::JobDone) => {
break;
}
Ok(_) => {
continue;
}
Err(_) => {
break;
}
}
}
if let Some(first) = first_event {
let (fwd_tx, fwd_rx) = std::sync::mpsc::channel();
fwd_tx.send(first).ok();
std::thread::spawn(move || {
for msg in page_rx {
if fwd_tx.send(msg).is_err() {
break;
}
}
});
let new_viewer_end = stet_viewer::ViewerEnd {
page_receiver: fwd_rx,
screen_info_sender,
advance_sender,
file_drop_sender: viewer_end.file_drop_sender,
interrupt_flag: viewer_end.interrupt_flag,
password_response_sender: viewer_end.password_response_sender,
};
stet_viewer::run_viewer(
new_viewer_end,
dpi_override,
first_file.as_deref(),
first_page_size,
system_cmyk_bytes,
no_aa,
);
}
std::process::exit(0);
}
fn print_help() {
println!(
"stet {} — PostScript Level 3 interpreter and PDF renderer.
Usage:
stet [OPTIONS] <FILE>...
stet inspect <FILE.pdf> [--password <PW>]
stet --help
stet --version
With no FILE, stet launches the interactive viewer.
Output devices:
--device png Render each page to PNG (default for files).
--device pdf Render to PDF (vector output).
--device viewer Launch the interactive desktop viewer.
--device viewport-png Render via the viewport pipeline (audit mode).
--device null No rendering output (test / scripting use).
Common options:
--dpi <DPI> DPI for raster output (default 300).
--page <SIZE> Page size for PostScript/EPS input, in points:
a named size (letter, legal, tabloid, ledger,
executive, a0-a6, b4, b5) or WIDTHxHEIGHT, e.g.
\"620x1000\". Append -landscape or -portrait to
orient a named size (\"a4-landscape\"). A plain
%!PS program gets the default page unless it
calls setpagedevice -- %%BoundingBox sets the
page only for EPS -- so this is how to render a
program whose artwork is larger than US Letter.
Overrides an EPS %%BoundingBox when both apply.
--pages <SPEC> Page selection: \"3\", \"1-5\", \"1-3,7,10-12\".
--max-vm <MB> Ceiling on PostScript VM (strings, arrays, dicts).
Defaults to 8192; exceeding it raises VMerror
rather than aborting. Separate from the renderer's
image and band buffers.
--timeout <SECONDS> Abort a job running longer than this. PostScript is
Turing-complete, so there is no limit by default;
set one when the input is untrusted.
--width <PX> Override page width (PDF input only). Cannot
be combined with --dpi.
--height <PX> Override page height (PDF input only). Cannot
be combined with --dpi.
--threads <N> Parallel band-rendering thread count. Defaults to
75% of cores in viewer mode and 8 otherwise, where
sequential PNG writing limits the benefit of more.
--no-aa Disable anti-aliasing.
--password <PW> Password for encrypted PDF input.
Colour management:
--no-icc Skip system CMYK profile loading; use the
PLRM CMYK→sRGB formulas. Cannot combine
with --cmyk-profile or --bpc.
--cmyk-profile <PATH> Override the system CMYK source profile.
--output-profile <PATH> Output ICC profile (forward-compatible
with planned PDF/X-4 work).
--use-output-intent Honour the PDF's OutputIntent profile as
the source CMYK profile (default ON).
--no-output-intent Ignore the PDF's OutputIntent and fall
back to the system CMYK profile.
--bpc <on|off|auto> Black-point compensation mode (default auto).
Subcommands:
inspect <FILE.pdf> Print a structural summary of a PDF
(metadata, outline, annotations, form
fields, embedded files, layers,
warnings). Use `stet inspect --help` for
details.
Examples:
stet # launch the viewer
stet doc.ps # render PostScript
stet --device png --pages 1 doc.pdf # render PDF page 1 to PNG
stet --device pdf in.ps # PostScript → PDF
stet --device pdf in.pdf # PDF → PDF (content-fidelity rewrite)
stet inspect doc.pdf # show PDF structure
Documentation: https://github.com/AndyCappDev/stet
Issues: https://github.com/AndyCappDev/stet/issues",
env!("CARGO_PKG_VERSION")
);
}
fn parse_page_ranges(spec: &str) -> Result<std::collections::HashSet<i32>, String> {
let mut pages = std::collections::HashSet::new();
for part in spec.split(',') {
let part = part.trim();
if part.is_empty() {
continue;
}
if let Some((start_s, end_s)) = part.split_once('-') {
let start: i32 = start_s
.trim()
.parse()
.map_err(|_| format!("Invalid page range: '{}'", part))?;
let end: i32 = end_s
.trim()
.parse()
.map_err(|_| format!("Invalid page range: '{}'", part))?;
if start < 1 || end < 1 {
return Err(format!("Page numbers must be positive: '{}'", part));
}
if start > end {
return Err(format!("Invalid page range (start > end): '{}'", part));
}
pages.extend(start..=end);
} else {
let num: i32 = part
.parse()
.map_err(|_| format!("Invalid page number: '{}'", part))?;
if num < 1 {
return Err(format!("Page numbers must be positive: '{}'", part));
}
pages.insert(num);
}
}
if pages.is_empty() {
return Err("Empty page range specification".to_string());
}
Ok(pages)
}
fn build_icc_cache(icc_cfg: &IccCliConfig) -> stet_graphics::icc::IccCache {
use stet_graphics::icc::IccCache;
if icc_cfg.no_icc {
return IccCache::new_with_options(IccCacheOptions {
bpc_mode: BpcMode::Off,
source_cmyk_profile: None,
});
}
if let Some(path) = icc_cfg.cmyk_profile_path.as_deref() {
let bytes = std::fs::read(path).unwrap_or_else(|e| {
eprintln!("Error: cannot read --cmyk-profile '{}': {}", path, e);
std::process::exit(1);
});
validate_cmyk_icc(&bytes, path);
eprintln!("[ICC] Loaded source CMYK profile: {}", path);
return IccCache::new_with_options(IccCacheOptions {
bpc_mode: icc_cfg.bpc_mode,
source_cmyk_profile: Some(bytes),
});
}
if let Some(path) = icc_cfg.output_profile_path.as_deref() {
let bytes = std::fs::read(path).unwrap_or_else(|e| {
eprintln!("Error: cannot read output profile '{}': {}", path, e);
std::process::exit(1);
});
if bytes.len() < 40 || &bytes[36..40] != b"acsp" {
eprintln!("Error: '{}' is not a valid ICC profile", path);
std::process::exit(1);
}
eprintln!("[ICC] Loaded output profile: {}", path);
return IccCache::new_with_options(IccCacheOptions {
bpc_mode: icc_cfg.bpc_mode,
source_cmyk_profile: Some(bytes),
});
}
let mut cache = IccCache::new_with_options(IccCacheOptions {
bpc_mode: icc_cfg.bpc_mode,
source_cmyk_profile: None,
});
cache.search_system_cmyk_profile();
cache
}
fn validate_cmyk_icc(bytes: &[u8], path: &str) {
if bytes.len() < 40 || &bytes[36..40] != b"acsp" {
eprintln!("Error: '{}' is not a valid ICC profile", path);
std::process::exit(1);
}
if &bytes[16..20] != b"CMYK" {
let cs = String::from_utf8_lossy(&bytes[16..20]);
eprintln!(
"Error: --cmyk-profile '{}' has data color space '{}'; expected CMYK",
path,
cs.trim()
);
std::process::exit(1);
}
}
fn create_context(
icc_cfg: &IccCliConfig,
timeout_secs: Option<f64>,
max_vm_mb: Option<u64>,
) -> Context {
let mut ctx = Context::new();
ctx.set_timeout(timeout_secs.map(std::time::Duration::from_secs_f64));
if let Some(mb) = max_vm_mb {
ctx.max_local_vm = (mb as usize).saturating_mul(1024 * 1024);
}
ctx.icc_cache = build_icc_cache(icc_cfg);
ctx.exec_sync_fn = Some(stet_engine::eval::exec_sync);
build_system_dict(&mut ctx);
stet::embedded_resources::register_all(&mut ctx.files);
ctx.font_resource_path = Some("Font".to_string());
if let Some(rp) = find_resource_path() {
ctx.resource_base_path = Some(rp.clone());
let font_path = PathBuf::from(&rp).join("Font");
if font_path.is_dir() {
ctx.font_resource_path = Some(font_path.to_string_lossy().to_string());
}
}
run_init_scripts(&mut ctx);
ctx
}
#[cfg(feature = "viewer")]
fn run_file_jobs_viewer(
ctx: &mut Context,
dpi_override: Option<f64>,
file_args: &[String],
page_size: Option<(f64, f64)>,
advance_rx: std::sync::mpsc::Receiver<()>,
) {
run_file_jobs(
ctx,
dpi_override,
file_args,
"viewer",
page_size,
None,
Some(&advance_rx),
);
}
fn run_file_jobs(
ctx: &mut Context,
dpi_override: Option<f64>,
file_args: &[String],
device: &str,
page_size: Option<(f64, f64)>,
viewer_wait: Option<&std::sync::Arc<std::sync::atomic::AtomicU64>>,
advance_receiver: Option<&std::sync::mpsc::Receiver<()>>,
) {
use stet_graphics::display_list::DisplayList;
let num_jobs = file_args.len();
for (job_idx, filename) in file_args.iter().enumerate() {
let display_name = std::path::Path::new(filename)
.canonicalize()
.map(|p| p.display().to_string())
.unwrap_or_else(|_| filename.to_string());
eprintln!("\n{}", "=".repeat(60));
eprintln!(
"Processing Job {}/{}: {}",
job_idx + 1,
num_jobs,
display_name
);
eprintln!("{}", "=".repeat(60));
let filename_lower = filename.to_ascii_lowercase();
let output_base = filename
.strip_suffix(".ps")
.or_else(|| filename.strip_suffix(".PS"))
.or_else(|| filename.strip_suffix(".eps"))
.or_else(|| filename.strip_suffix(".EPS"))
.or_else(|| filename.strip_suffix(".epsf"))
.or_else(|| filename.strip_suffix(".EPSF"))
.unwrap_or(filename);
let ext = if device == "pdf" { "pdf" } else { "png" };
ctx.output_path = Some(format!("{}.{}", output_base, ext));
let source = match std::fs::read(filename) {
Ok(s) => s,
Err(e) => {
eprintln!("Error: cannot read '{}': {}", filename, e);
std::process::exit(1);
}
};
let ps_data = strip_dos_eps_header(&source);
let is_eps = filename_lower.ends_with(".eps")
|| filename_lower.ends_with(".epsf")
|| content_is_epsf(ps_data);
if job_idx > 0
&& let Some(ref sender) = ctx.display_list_sender
{
let _ = sender.send((DisplayList::new(), 0.0, 0, 0, None, false));
}
let job_start = std::time::Instant::now();
let wait_before = viewer_wait
.map(|w| w.load(std::sync::atomic::Ordering::Relaxed))
.unwrap_or(0);
let exec_result = execjob(
ctx,
dpi_override,
ps_data,
filename,
device,
page_size,
is_eps,
);
let wait_after = viewer_wait
.map(|w| w.load(std::sync::atomic::Ordering::Relaxed))
.unwrap_or(0);
let viewer_wait_dur = std::time::Duration::from_nanos(wait_after - wait_before);
let job_duration = job_start.elapsed() - viewer_wait_dur;
match exec_result {
Ok(()) => {
eprintln!(
"\nJob execution time: {:.3} seconds",
job_duration.as_secs_f64()
);
eprintln!(
"Job {} completed successfully: {}",
job_idx + 1,
display_name
);
}
Err(e) => {
eprintln!(
"\nJob execution time: {:.3} seconds",
job_duration.as_secs_f64()
);
match e {
stet_core::error::PsError::Quit => {
eprintln!("Job {} completed (quit): {}", job_idx + 1, display_name);
}
_ => {
eprintln!("Job {} FAILED: {}", job_idx + 1, display_name);
}
}
}
}
if let Some(adv_rx) = advance_receiver
&& job_idx + 1 < num_jobs
{
if let Some(ref sender) = ctx.display_list_sender {
let _ = sender.send((DisplayList::new(), -1.0, 0, 0, None, false));
}
let _ = adv_rx.recv();
}
}
eprintln!("\n{}", "=".repeat(60));
eprintln!(
"Processed {} job{}",
num_jobs,
if num_jobs == 1 { "" } else { "s" }
);
eprintln!("{}", "=".repeat(60));
eprintln!("\nFinal operand stack:");
print_stack(ctx);
eprintln!("\nexecution stack");
print_exec_stack(ctx);
if let Some(code) = ctx.exit_code {
std::process::exit(code);
}
}
fn run_repl(ctx: &mut Context) {
match parse_and_exec(ctx, b"{executive} stopped pop") {
Ok(()) => {}
Err(stet_core::error::PsError::Quit) => {}
Err(stet_core::error::PsError::Stop) => {}
Err(e) => eprintln!("Error: {}", e),
}
}
fn execjob(
ctx: &mut Context,
dpi_override: Option<f64>,
ps_data: &[u8],
filename: &str,
device_name: &str,
page_size: Option<(f64, f64)>,
is_eps: bool,
) -> Result<(), stet_core::error::PsError> {
use stet_core::error::PsError;
use stet_core::object::PsValue;
let save_obj = ctx.vm_save();
let save_id = match save_obj.value {
PsValue::Save(sl) => sl.0,
_ => unreachable!(),
};
ctx.job_start_save_depth = ctx.save_stack.depth();
ctx.o_stack.clear();
ctx.e_stack.clear();
ctx.loops.clear();
ctx.d_stack.truncate(3);
let _ = parse_and_exec(ctx, b"initgraphics");
ctx.vm_alloc_mode = false;
ctx.display_list.clear();
ctx.null_device_used = false;
ctx.in_error_handler = false;
ctx.current_operator = None;
if let Some((w, h)) = page_size {
install_device_with_size(ctx, dpi_override, w, h, device_name);
if let Some(ref mut dev) = ctx.device {
dev.set_trim_box(0.0, 0.0, w, h);
}
} else if is_eps {
if let Some((llx, lly, urx, ury)) = read_eps_bounding_box(ps_data) {
let w = urx - llx;
let h = ury - lly;
if w > 0.0 && h > 0.0 {
install_device_with_size(ctx, dpi_override, w, h, device_name);
if let Some(ref mut dev) = ctx.device {
dev.set_trim_box(0.0, 0.0, w, h);
}
} else {
install_device(ctx, dpi_override, device_name);
}
} else {
install_device(ctx, dpi_override, device_name);
}
} else {
install_device(ctx, dpi_override, device_name);
}
let exec_result = if is_eps {
(|| {
if let Some((llx, lly, _urx, _ury)) = read_eps_bounding_box(ps_data) {
if llx != 0.0 || lly != 0.0 {
let wrapper = format!("gsave {} {} translate", -llx, -lly);
parse_and_exec(ctx, wrapper.as_bytes())?;
parse_and_exec_file(ctx, ps_data, filename)?;
parse_and_exec(ctx, b"grestore showpage")
} else {
parse_and_exec_file(ctx, ps_data, filename)?;
parse_and_exec(ctx, b"showpage")
}
} else {
parse_and_exec_file(ctx, ps_data, filename)?;
parse_and_exec(ctx, b"showpage")
}
})()
} else {
parse_and_exec_file(ctx, ps_data, filename)
};
let job_result = match &exec_result {
Err(PsError::Stop) => {
if is_newerror_set(ctx) {
let _ = parse_and_exec(ctx, b"{ handleerror } stopped pop");
exec_result
} else {
Ok(())
}
}
_ => exec_result,
};
if job_result.is_ok()
&& let Some(w) = stet::diagnostics::dropped_final_page(ctx)
{
eprintln!("Warning: {} {}.", filename, w);
eprintln!(" {}", w.hint());
}
if let Some(mut dev) = ctx.device.take() {
if let Err(e) = dev.finish_with_context(ctx) {
eprintln!("render error: {}", e);
}
ctx.device = Some(dev);
}
ctx.o_stack.clear();
ctx.e_stack.clear();
ctx.loops.clear();
ctx.d_stack.truncate(3);
let _ = ctx.vm_restore(save_id);
ctx.display_list.clear();
ctx.in_error_handler = false;
ctx.current_operator = None;
job_result
}
fn is_newerror_set(ctx: &Context) -> bool {
use stet_core::dict::DictKey;
use stet_core::object::PsValue;
let newerror_id = ctx
.names
.find(b"newerror")
.unwrap_or(stet_core::object::NameId(0));
match ctx.dicts.get(ctx.dollar_error, &DictKey::Name(newerror_id)) {
Some(obj) => matches!(obj.value, PsValue::Bool(true)),
None => true, }
}
fn run_init_scripts(ctx: &mut Context) {
let saved_d_stack = ctx.d_stack.clone();
ctx.d_stack.truncate(1);
let captured = std::sync::Arc::new(std::sync::Mutex::new(Vec::<u8>::new()));
let old_stdout = std::mem::replace(&mut ctx.stdout, Box::new(SharedWriter(captured.clone())));
let init_script = b"{(resources/Init/sysdict.ps) run} stopped { (Init failed\\n) print } if";
let exec_ok = match parse_and_exec(ctx, init_script) {
Ok(()) => true,
Err(e) => {
match e {
stet_core::error::PsError::Quit => {}
_ => eprintln!("Warning: init script error: {}", e),
}
false
}
};
ctx.stdout = old_stdout;
let output = captured.lock().unwrap();
let init_failed = !exec_ok || output.windows(11).any(|w| w == b"Init failed");
if !output.is_empty() {
use std::io::Write;
let _ = ctx.stdout.write_all(&output);
}
drop(output);
if !init_failed && ctx.d_stack.len() >= 3 {
sync_context_after_init(ctx);
} else {
ctx.d_stack = saved_d_stack;
ctx.o_stack.clear();
ctx.e_stack.clear();
}
ctx.vm_alloc_mode = false;
ctx.initializing = false;
ctx.dicts.set_access(
ctx.systemdict,
stet_core::object::ObjFlags::ACCESS_READ_ONLY,
);
}
fn sync_context_after_init(ctx: &mut Context) {
use stet_core::dict::DictKey;
use stet_core::object::PsValue;
let sd = ctx.systemdict;
let lookup = |ctx: &Context, name: &[u8]| -> Option<stet_core::object::EntityId> {
let id = ctx.names.find(name)?;
let obj = ctx.dicts.get(sd, &DictKey::Name(id))?;
match obj.value {
PsValue::Dict(e) => Some(e),
_ => None,
}
};
if let Some(e) = lookup(ctx, b"$error") {
ctx.dollar_error = e;
}
if let Some(e) = lookup(ctx, b"errordict") {
ctx.errordict = e;
}
if let Some(e) = lookup(ctx, b"FontDirectory") {
ctx.font_directory = e;
}
if let Some(e) = lookup(ctx, b"userdict") {
ctx.userdict = e;
}
if let Some(e) = lookup(ctx, b"globaldict") {
ctx.globaldict = e;
}
}
const NAMED_PAGE_SIZES: &[(&str, f64, f64)] = &[
("letter", 612.0, 792.0),
("legal", 612.0, 1008.0),
("tabloid", 792.0, 1224.0),
("ledger", 1224.0, 792.0),
("executive", 522.0, 756.0),
("a0", 2384.0, 3370.0),
("a1", 1684.0, 2384.0),
("a2", 1191.0, 1684.0),
("a3", 842.0, 1191.0),
("a4", 595.0, 842.0),
("a5", 420.0, 595.0),
("a6", 297.0, 420.0),
("b4", 709.0, 1001.0),
("b5", 499.0, 709.0),
];
fn parse_page_size(spec: &str) -> Result<(f64, f64), String> {
let lower = spec.trim().to_ascii_lowercase();
let (base, orientation) = match lower.rsplit_once(['-', ',']) {
Some((b, o)) if o == "landscape" || o == "portrait" => (b, Some(o)),
_ => (lower.as_str(), None),
};
let (mut w, mut h) = if let Some(&(_, w, h)) =
NAMED_PAGE_SIZES.iter().find(|(name, _, _)| *name == base)
{
(w, h)
} else {
let (ws, hs) = base.split_once('x').ok_or_else(|| {
format!(
"invalid --page value '{}' — expected a named size ({}) or WIDTHxHEIGHT in points, e.g. '620x1000'",
spec,
NAMED_PAGE_SIZES
.iter()
.map(|(n, _, _)| *n)
.collect::<Vec<_>>()
.join(", ")
)
})?;
let w: f64 = ws
.trim()
.parse()
.map_err(|_| format!("invalid --page width '{}'", ws))?;
let h: f64 = hs
.trim()
.parse()
.map_err(|_| format!("invalid --page height '{}'", hs))?;
(w, h)
};
if !w.is_finite() || !h.is_finite() || w <= 0.0 || h <= 0.0 {
return Err(format!(
"--page dimensions must be positive, got {}x{}",
w, h
));
}
match orientation {
Some("landscape") if h > w => std::mem::swap(&mut w, &mut h),
Some("portrait") if w > h => std::mem::swap(&mut w, &mut h),
_ => {}
}
Ok((w, h))
}
fn install_device(ctx: &mut Context, dpi_override: Option<f64>, device: &str) {
let resource_name = match device {
"viewer" => "viewer",
"pdf" => "pdf",
"null" => "null",
_ => "png",
};
let setup = if let Some(dpi) = dpi_override {
format!(
"/{0} /OutputDevice findresource dup length dict copy \
dup /HWResolution [{1} {1}] put setpagedevice",
resource_name, dpi
)
} else {
format!(
"/{} /OutputDevice findresource setpagedevice",
resource_name
)
};
let saved = ctx.allow_ps_resolution;
ctx.allow_ps_resolution = true;
let result = parse_and_exec(ctx, setup.as_bytes());
ctx.allow_ps_resolution = saved;
if let Err(e) = result {
eprintln!(
"Warning: setpagedevice via resource failed ({}), using fallback",
e
);
install_device_fallback(ctx, dpi_override.unwrap_or(300.0));
}
}
fn install_device_fallback(ctx: &mut Context, dpi: f64) {
use stet_fonts::geometry::Matrix;
let scale = dpi / 72.0;
let dev_width = (612.0 * scale).round() as u32;
let dev_height = (792.0 * scale).round() as u32;
let device = SkiaDevice::new(dev_width, dev_height);
ctx.device = Some(Box::new(device));
let default_ctm = Matrix::new(scale, 0.0, 0.0, -scale, 0.0, dev_height as f64);
ctx.gstate.ctm = default_ctm;
ctx.gstate.default_ctm = default_ctm;
}
fn install_device_with_size(
ctx: &mut Context,
dpi_override: Option<f64>,
width: f64,
height: f64,
device: &str,
) {
let resource_name = match device {
"viewer" => "viewer",
"pdf" => "pdf",
"null" => "null",
_ => "png",
};
let setup = if let Some(dpi) = dpi_override {
format!(
"/{0} /OutputDevice findresource dup length dict copy \
dup /HWResolution [{1} {1}] put \
dup /PageSize [{2} {3}] put setpagedevice",
resource_name, dpi, width, height
)
} else {
format!(
"/{0} /OutputDevice findresource dup length dict copy \
dup /PageSize [{1} {2}] put setpagedevice",
resource_name, width, height
)
};
let saved = ctx.allow_ps_resolution;
ctx.allow_ps_resolution = true;
let result = parse_and_exec(ctx, setup.as_bytes());
ctx.allow_ps_resolution = saved;
if let Err(e) = result {
eprintln!(
"Warning: setpagedevice with size failed ({}), using fallback",
e
);
install_device_fallback(ctx, dpi_override.unwrap_or(300.0));
}
}
fn print_stack(ctx: &Context) {
let slice = ctx.o_stack.as_slice();
if slice.is_empty() {
eprintln!("[]");
return;
}
let mut buf = Vec::new();
buf.push(b'[');
for (i, obj) in slice.iter().enumerate() {
if i > 0 {
buf.extend_from_slice(b", ");
}
stet_ops::type_ops::write_obj_equal(ctx, obj, &mut buf);
}
buf.push(b']');
std::io::stderr().write_all(&buf).ok();
eprintln!();
}
fn print_exec_stack(ctx: &Context) {
let slice = ctx.e_stack.as_slice();
if slice.is_empty() {
eprintln!("[]");
return;
}
let mut buf = Vec::new();
buf.push(b'[');
for (i, obj) in slice.iter().enumerate() {
if i > 0 {
buf.extend_from_slice(b", ");
}
stet_ops::type_ops::write_obj_equal(ctx, obj, &mut buf);
}
buf.push(b']');
std::io::stderr().write_all(&buf).ok();
eprintln!();
}
fn is_pdf_file(filename: &str) -> bool {
filename.to_ascii_lowercase().ends_with(".pdf")
}
#[allow(clippy::too_many_arguments)]
fn render_dropped_pdf(
path: &str,
dpi_override: Option<f64>,
dl_sender: &std::sync::mpsc::Sender<stet_viewer::DisplayListMsg>,
icc_cache: &stet_graphics::icc::IccCache,
use_output_intent: bool,
interrupt_flag: &std::sync::Arc<std::sync::atomic::AtomicBool>,
page_sender: Option<&std::sync::mpsc::Sender<stet_viewer::ViewerMsg>>,
password_response_rx: Option<&std::sync::mpsc::Receiver<Option<String>>>,
initial_password: Option<&str>,
) {
let dpi = dpi_override.unwrap_or(150.0);
let data = match std::fs::read(path) {
Ok(d) => d,
Err(e) => {
eprintln!("Error: cannot read '{}': {}", path, e);
return;
}
};
let mut password_attempt: Option<String> = initial_password.map(|s| s.to_string());
let mut any_password_tried = false;
let mut doc = loop {
let result = match password_attempt.as_deref() {
None => PdfDocument::from_bytes_with_icc(&data, icc_cache.clone()),
Some(pw) => {
PdfDocument::from_bytes_with_password(&data, icc_cache.clone(), pw.as_bytes())
}
};
match result {
Ok(d) => break d,
Err(stet_pdf_reader::PdfError::PasswordRequired) => {
let (Some(tx), Some(rx)) = (page_sender, password_response_rx) else {
eprintln!(
"Error: '{}' is password-protected (use --password or drop onto viewer)",
path
);
return;
};
if tx
.send(stet_viewer::ViewerMsg::PasswordRequired {
filename: path.to_string(),
retry: any_password_tried,
})
.is_err()
{
return;
}
match rx.recv() {
Ok(Some(pw)) => {
password_attempt = Some(pw);
any_password_tried = true;
if interrupt_flag.load(std::sync::atomic::Ordering::Relaxed) {
return;
}
continue;
}
_ => return,
}
}
Err(e) => {
eprintln!("Error: cannot parse '{}': {}", path, e);
return;
}
}
};
if use_output_intent && doc.apply_output_intent_as_default_cmyk() {
eprintln!("[ICC] Using PDF OutputIntent profile for {}", path);
}
let effective_cmyk_bytes = doc.icc_cache().system_cmyk_bytes().cloned();
let cmyk_proofing = doc.icc_cache().proofing_enabled();
let page_count = doc.page_count();
eprintln!("PDF: {} ({} pages)", path, page_count);
let start = std::time::Instant::now();
for page in 0..page_count {
if interrupt_flag.load(std::sync::atomic::Ordering::Relaxed) {
return;
}
match doc.render_page(page, dpi) {
Ok(display_list) => {
let (w, h) = doc.page_size(page).unwrap_or((612.0, 792.0));
let scale = dpi / 72.0;
let pixel_w = (w * scale).round() as u32;
let pixel_h = (h * scale).round() as u32;
let _ = dl_sender.send((
display_list,
dpi,
pixel_w,
pixel_h,
effective_cmyk_bytes.clone(),
cmyk_proofing,
));
}
Err(e) => {
eprintln!(" Page {}: render error: {}", page + 1, e);
}
}
}
eprintln!(
"PDF interpret time: {:.3} seconds",
start.elapsed().as_secs_f64()
);
}
fn compute_fit_dims(
page_w_pt: f64,
page_h_pt: f64,
target_w: Option<u32>,
target_h: Option<u32>,
) -> (u32, u32, f64) {
let scale = match (target_w, target_h) {
(Some(w), Some(h)) => {
let sx = w as f64 / page_w_pt;
let sy = h as f64 / page_h_pt;
sx.min(sy)
}
(Some(w), None) => w as f64 / page_w_pt,
(None, Some(h)) => h as f64 / page_h_pt,
(None, None) => {
return (page_w_pt.round() as u32, page_h_pt.round() as u32, 72.0);
}
};
let dpi = scale * 72.0;
let out_w = ((page_w_pt * scale).round().max(1.0)) as u32;
let out_h = ((page_h_pt * scale).round().max(1.0)) as u32;
(out_w, out_h, dpi)
}
#[allow(clippy::too_many_arguments)]
fn render_pdf_page_to_rgba(
doc: &PdfDocument,
page: usize,
dpi: f64,
no_aa: bool,
use_viewport: bool,
target_width: Option<u32>,
target_height: Option<u32>,
) -> Result<(Vec<u8>, u32, u32), stet_pdf_reader::PdfError> {
let (page_w, page_h) = doc.page_size(page)?;
let (pixel_w, pixel_h, effective_dpi) = if target_width.is_some() || target_height.is_some() {
compute_fit_dims(page_w, page_h, target_width, target_height)
} else {
let scale = dpi / 72.0;
(
(page_w * scale).round() as u32,
(page_h * scale).round() as u32,
dpi,
)
};
let display_list = doc.render_page(page, effective_dpi)?;
let rgba = if use_viewport {
stet_render::render_to_rgba_viewport(
&display_list,
pixel_w,
pixel_h,
effective_dpi,
Some(doc.icc_cache()),
no_aa,
)
} else {
stet_render::render_to_rgba(
&display_list,
pixel_w,
pixel_h,
effective_dpi,
Some(doc.icc_cache()),
no_aa,
)
};
Ok((rgba, pixel_w, pixel_h))
}
#[allow(clippy::too_many_arguments)]
fn run_pdf_input_png(
dpi: f64,
file_args: &[String],
page_filter: &Option<std::collections::HashSet<i32>>,
no_aa: bool,
use_viewport: bool,
icc_cfg: &IccCliConfig,
password: Option<&str>,
target_width: Option<u32>,
target_height: Option<u32>,
) {
let icc_cache = build_icc_cache(icc_cfg);
for filename in file_args {
let data = std::fs::read(filename).unwrap_or_else(|e| {
eprintln!("Error: cannot read '{}': {}", filename, e);
std::process::exit(1);
});
let open_result = match password {
Some(pw) => {
PdfDocument::from_bytes_with_password(&data, icc_cache.clone(), pw.as_bytes())
}
None => PdfDocument::from_bytes_with_icc(&data, icc_cache.clone()),
};
let mut doc = open_result.unwrap_or_else(|e| {
match e {
stet_pdf_reader::PdfError::PasswordRequired => eprintln!(
"Error: '{}' is password-protected (use --password)",
filename
),
_ => eprintln!("Error: cannot parse '{}': {}", filename, e),
}
std::process::exit(1);
});
if icc_cfg.use_output_intent
&& icc_cfg.source_cmyk_path().is_none()
&& doc.apply_output_intent_as_default_cmyk()
{
eprintln!("[ICC] Using PDF OutputIntent profile for {}", filename);
}
let output_base = filename
.strip_suffix(".pdf")
.or_else(|| filename.strip_suffix(".PDF"))
.unwrap_or(filename);
let start = std::time::Instant::now();
let page_count = doc.page_count();
eprintln!("\n{}", "=".repeat(60));
eprintln!("Processing PDF: {} ({} pages)", filename, page_count);
eprintln!("{}", "=".repeat(60));
for page in 0..page_count {
let page_1based = page as i32 + 1;
if let Some(filter) = page_filter
&& !filter.contains(&page_1based)
{
continue;
}
match render_pdf_page_to_rgba(
&doc,
page,
dpi,
no_aa,
use_viewport,
target_width,
target_height,
) {
Ok((rgba, w, h)) => {
let out_path = if page_count == 1 {
format!("{}.png", output_base)
} else {
format!("{}-{:03}.png", output_base, page_1based)
};
write_png_file(&out_path, &rgba, w, h);
eprintln!(" Page {}: {}x{} → {}", page_1based, w, h, out_path);
}
Err(e) => {
eprintln!(" Page {}: render error: {}", page_1based, e);
}
}
}
eprintln!(
"PDF render time: {:.3} seconds",
start.elapsed().as_secs_f64()
);
}
}
fn run_pdf_input_pdf(
file_args: &[String],
icc_cfg: &IccCliConfig,
page_filter: &Option<std::collections::HashSet<i32>>,
password: Option<&str>,
) {
use stet_core::device::OutputDevice;
let icc_cache = build_icc_cache(icc_cfg);
for filename in file_args {
let data = std::fs::read(filename).unwrap_or_else(|e| {
eprintln!("Error: cannot read '{}': {}", filename, e);
std::process::exit(1);
});
let open_result = match password {
Some(pw) => {
PdfDocument::from_bytes_with_password(&data, icc_cache.clone(), pw.as_bytes())
}
None => PdfDocument::from_bytes_with_icc(&data, icc_cache.clone()),
};
let mut doc = open_result.unwrap_or_else(|e| {
match e {
stet_pdf_reader::PdfError::PasswordRequired => eprintln!(
"Error: '{}' is password-protected (use --password)",
filename
),
_ => eprintln!("Error: cannot parse '{}': {}", filename, e),
}
std::process::exit(1);
});
if icc_cfg.use_output_intent
&& icc_cfg.source_cmyk_path().is_none()
&& doc.apply_output_intent_as_default_cmyk()
{
eprintln!("[ICC] Using PDF OutputIntent profile for {}", filename);
}
let base = filename
.strip_suffix(".pdf")
.or_else(|| filename.strip_suffix(".PDF"))
.unwrap_or(filename);
let output_path = format!("{}-out.pdf", base);
let same_path = match (
std::fs::canonicalize(filename).ok(),
std::fs::canonicalize(&output_path).ok(),
) {
(Some(a), Some(b)) => a == b,
_ => std::path::Path::new(filename) == std::path::Path::new(&output_path),
};
if same_path {
eprintln!(
"Error: refusing to overwrite input '{}' (rename the input file)",
filename
);
std::process::exit(1);
}
let start = std::time::Instant::now();
let page_count = doc.page_count();
eprintln!("\n{}", "=".repeat(60));
eprintln!(
"Processing PDF: {} ({} pages) → {}",
filename, page_count, output_path
);
eprintln!("{}", "=".repeat(60));
let mut device = PdfDevice::new(0, 0, 72.0);
device.set_emit_page_box_clip(false);
let output_intents = doc.output_intents();
if !output_intents.is_empty() {
device.set_output_intents(output_intents);
}
let mut pages_emitted = 0;
for page in 0..page_count {
let page_1based = page as i32 + 1;
if let Some(filter) = page_filter
&& !filter.contains(&page_1based)
{
continue;
}
let (w_pts, h_pts) = match doc.page_size(page) {
Ok(sz) => sz,
Err(e) => {
eprintln!(" Page {}: page_size error: {}", page_1based, e);
continue;
}
};
let display_list = match doc.render_page(page, 72.0) {
Ok(dl) => dl,
Err(e) => {
eprintln!(" Page {}: render error: {}", page_1based, e);
continue;
}
};
device.set_page_size(w_pts.round().max(1.0) as u32, h_pts.round().max(1.0) as u32);
if let Err(e) = device.replay_and_show(display_list, &output_path) {
eprintln!(" Page {}: replay error: {}", page_1based, e);
continue;
}
pages_emitted += 1;
eprintln!(" Page {}: {:.0}x{:.0} pts", page_1based, w_pts, h_pts);
}
if pages_emitted == 0 {
eprintln!("Error: no pages emitted for '{}'", filename);
std::process::exit(1);
}
if let Err(e) = device.finish() {
eprintln!("Error: writing '{}': {}", output_path, e);
std::process::exit(1);
}
eprintln!(
"PDF rewrite time: {:.3} seconds",
start.elapsed().as_secs_f64()
);
}
}
fn write_png_file(path: &str, rgba: &[u8], width: u32, height: u32) {
let file = std::fs::File::create(path).unwrap_or_else(|e| {
eprintln!("Error: cannot create '{}': {}", path, e);
std::process::exit(1);
});
let w = std::io::BufWriter::new(file);
let mut encoder = png::Encoder::new(w, width, height);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
encoder.set_compression(png::Compression::Default);
encoder.set_adaptive_filter(png::AdaptiveFilterType::Adaptive);
let mut writer = encoder.write_header().unwrap();
writer.write_image_data(rgba).unwrap();
}
fn find_resource_path() -> Option<String> {
if let Ok(exe) = std::env::current_exe() {
let mut dir = exe.parent().map(PathBuf::from);
for _ in 0..5 {
if let Some(ref d) = dir {
let candidate = d.join("resources");
if candidate.is_dir() {
return Some(candidate.to_string_lossy().to_string());
}
dir = d.parent().map(PathBuf::from);
}
}
}
None
}
fn run_inspect_subcommand(args: &[String]) -> i32 {
let mut password: Option<String> = None;
let mut path: Option<String> = None;
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--password" => {
if i + 1 >= args.len() {
eprintln!("Error: --password requires a value");
return 1;
}
password = Some(args[i + 1].clone());
i += 2;
}
"--help" | "-h" => {
println!("stet inspect <file.pdf> [--password <pw>]");
println!();
println!("Prints a structural summary of a PDF: metadata, outline,");
println!("annotations, form fields, embedded files, and parse warnings.");
return 0;
}
other if other.starts_with('-') => {
eprintln!("Error: unknown flag '{other}' for `stet inspect`");
return 1;
}
_ => {
if path.is_some() {
eprintln!("Error: `stet inspect` accepts a single file path");
return 1;
}
path = Some(args[i].clone());
i += 1;
}
}
}
let Some(path) = path else {
eprintln!("Error: `stet inspect` requires a file path");
eprintln!("Usage: stet inspect <file.pdf> [--password <pw>]");
return 1;
};
inspect::run_inspect(&path, password.as_deref().map(str::as_bytes))
}
#[cfg(test)]
mod tests {
use super::{compute_fit_dims, parse_page_size};
const LETTER_W: f64 = 612.0;
const LETTER_H: f64 = 792.0;
fn close(a: f64, b: f64) -> bool {
(a - b).abs() < 0.01
}
#[test]
fn fit_letter_portrait_into_256_square_is_height_constrained() {
let (w, h, dpi) = compute_fit_dims(LETTER_W, LETTER_H, Some(256), Some(256));
assert_eq!(h, 256);
assert_eq!(w, 198); assert!(close(dpi, 256.0 * 72.0 / 792.0));
}
#[test]
fn fit_letter_landscape_into_256_square_is_width_constrained() {
let (w, h, dpi) = compute_fit_dims(LETTER_H, LETTER_W, Some(256), Some(256));
assert_eq!(w, 256);
assert_eq!(h, 198);
assert!(close(dpi, 256.0 * 72.0 / 792.0));
}
#[test]
fn width_only_preserves_aspect() {
let (w, h, dpi) = compute_fit_dims(LETTER_W, LETTER_H, Some(512), None);
assert_eq!(w, 512);
assert_eq!(h, 663); assert!(close(dpi, 512.0 * 72.0 / 612.0));
}
#[test]
fn height_only_preserves_aspect() {
let (w, h, dpi) = compute_fit_dims(LETTER_W, LETTER_H, None, Some(512));
assert_eq!(h, 512);
assert_eq!(w, 396); assert!(close(dpi, 512.0 * 72.0 / 792.0));
}
#[test]
fn fit_preserves_minimum_dimension_of_one() {
let (w, h, _dpi) = compute_fit_dims(1.0, 10000.0, Some(32), Some(32));
assert!(w >= 1);
assert!(h >= 1);
}
#[test]
fn thumbnailer_128_bucket_letter_portrait() {
let (w, h, dpi) = compute_fit_dims(LETTER_W, LETTER_H, Some(128), Some(128));
assert_eq!(w, 99);
assert_eq!(h, 128);
assert!(close(dpi, 128.0 * 72.0 / 792.0)); }
#[test]
fn page_size_named_and_explicit() {
assert_eq!(parse_page_size("a4"), Ok((595.0, 842.0)));
assert_eq!(parse_page_size("LETTER"), Ok((612.0, 792.0)));
assert_eq!(parse_page_size("620x1000"), Ok((620.0, 1000.0)));
assert_eq!(parse_page_size(" 620 x 1000 "), Ok((620.0, 1000.0)));
assert_eq!(parse_page_size("100.5x200.25"), Ok((100.5, 200.25)));
}
#[test]
fn page_size_orientation_swaps_rather_than_rotates() {
assert_eq!(parse_page_size("a4-landscape"), Ok((842.0, 595.0)));
assert_eq!(parse_page_size("a4-portrait"), Ok((595.0, 842.0)));
assert_eq!(parse_page_size("a4-landscape"), parse_page_size("842x595"));
assert_eq!(parse_page_size("ledger"), Ok((1224.0, 792.0)));
assert_eq!(parse_page_size("ledger-portrait"), Ok((792.0, 1224.0)));
}
#[test]
fn page_size_rejects_bad_input() {
assert!(parse_page_size("0x100").is_err());
assert!(parse_page_size("-5x10").is_err());
assert!(parse_page_size("100x0").is_err());
assert!(parse_page_size("bogus").is_err());
assert!(parse_page_size("620x").is_err());
assert!(parse_page_size("abcxdef").is_err());
assert!(parse_page_size("").is_err());
let msg = parse_page_size("bogus").unwrap_err();
assert!(
msg.contains("letter"),
"message should list named sizes: {msg}"
);
assert!(
msg.contains("WIDTHxHEIGHT"),
"message should show the explicit form: {msg}"
);
}
#[test]
fn page_size_hyphen_only_special_for_orientation() {
assert!(parse_page_size("a4-sideways").is_err());
}
}