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 password: Option<String> = None;
let mut target_width: Option<u32> = None;
let mut target_height: Option<u32> = None;
let mut file_args: Vec<String> = Vec::new();
let mut i = 1;
while i < args.len() {
match args[i].as_str() {
"--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);
}
}
"--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 (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,
);
}
"viewport-png" => {
run_png_mode(
dpi,
file_args,
&icc_cfg,
no_aa,
page_filter,
true,
password.as_deref(),
target_width,
target_height,
);
}
"pdf" => {
let _ = &password;
run_pdf_mode(dpi, file_args, &icc_cfg, no_aa, page_filter);
}
"null" => {
run_null_mode(dpi, file_args, &icc_cfg, no_aa, page_filter);
}
#[cfg(feature = "viewer")]
"viewer" => run_viewer_mode(dpi, file_args, &icc_cfg, no_aa, page_filter, password),
#[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>,
) {
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);
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", 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>>,
) {
let mut ctx = create_context(icc_cfg);
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", 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>>,
) {
use stet_core::device::NullDevice;
let mut ctx = create_context(icc_cfg);
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", 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>,
) {
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)) = 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,
}));
page_num += 1;
}
});
let _screen_info_receiver = interp_end.screen_info_receiver;
let icc_cfg_thread = icc_cfg.clone();
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);
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,
));
}
} 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,
));
}
}
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,
));
}
}
run_file_jobs_viewer(&mut ctx, dpi_override, &ps_files, 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,
));
}
}
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,
));
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",
None,
None,
);
}
let _ = sender.send((
stet_graphics::display_list::DisplayList::new(),
-1.0,
0,
0,
None,
));
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).
--pages <SPEC> Page selection: \"3\", \"1-5\", \"1-3,7,10-12\".
--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
(default: rayon's default = num_cpus).
--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 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) -> Context {
let mut ctx = Context::new();
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],
advance_rx: std::sync::mpsc::Receiver<()>,
) {
run_file_jobs(
ctx,
dpi_override,
file_args,
"viewer",
None,
Some(&advance_rx),
);
}
fn run_file_jobs(
ctx: &mut Context,
dpi_override: Option<f64>,
file_args: &[String],
device: &str,
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));
}
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, 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));
}
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,
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.in_error_handler = false;
ctx.current_operator = None;
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 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;
}
}
fn install_device(ctx: &mut Context, dpi_override: Option<f64>, device: &str) {
if device == "null" {
let _ = parse_and_exec(ctx, b"nulldevice");
return;
}
let resource_name = match device {
"viewer" => "viewer",
"pdf" => "pdf",
_ => "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,
) {
if device == "null" {
let _ = parse_and_exec(ctx, b"nulldevice");
return;
}
let resource_name = match device {
"viewer" => "viewer",
"pdf" => "pdf",
_ => "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 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(),
));
}
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 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;
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)); }
}