use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicU64, Ordering};
use image::DynamicImage;
const PAGE_MAX_PX: u32 = 1600;
pub fn render_page(path: &Path, page: u32) -> Option<DynamicImage> {
if !path.is_file() {
return None;
}
let page = page.max(1);
let out = temp_png_path();
let ok = run_pdftocairo(path, &out, page)
|| run_pdftoppm(path, &out, page)
|| (page == 1 && (run_qlmanage(path, &out) || run_sips(path, &out)));
let img = if ok {
image::ImageReader::open(&out)
.ok()
.and_then(|r| r.with_guessed_format().ok())
.and_then(|r| r.decode().ok())
} else {
None
};
let _ = std::fs::remove_file(&out); img
}
fn run_pdftocairo(path: &Path, out: &Path, page: u32) -> bool {
run_poppler("pdftocairo", path, out, page)
}
fn run_pdftoppm(path: &Path, out: &Path, page: u32) -> bool {
run_poppler("pdftoppm", path, out, page)
}
fn run_poppler(tool: &str, path: &Path, out: &Path, page: u32) -> bool {
let prefix = out.with_extension("");
let page = page.to_string();
let status = Command::new(tool)
.arg("-png")
.arg("-f")
.arg(&page)
.arg("-l")
.arg(&page)
.arg("-singlefile")
.arg("-scale-to")
.arg(PAGE_MAX_PX.to_string())
.arg(path)
.arg(&prefix)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
matches!(status, Ok(s) if s.success()) && out_is_nonempty(out)
}
fn run_qlmanage(path: &Path, out: &Path) -> bool {
let dir = temp_dir_path();
if std::fs::create_dir_all(&dir).is_err() {
return false;
}
let status = Command::new("qlmanage")
.arg("-t")
.arg("-s")
.arg(PAGE_MAX_PX.to_string())
.arg("-o")
.arg(&dir)
.arg(path)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
let mut ok = false;
if matches!(status, Ok(s) if s.success()) {
if let Some(name) = path.file_name() {
let produced = dir.join(format!("{}.png", name.to_string_lossy()));
ok = out_is_nonempty(&produced) && std::fs::copy(&produced, out).is_ok();
}
}
let _ = std::fs::remove_dir_all(&dir);
ok
}
fn run_sips(path: &Path, out: &Path) -> bool {
let status = Command::new("sips")
.arg("-s")
.arg("format")
.arg("png")
.arg("--resampleHeightWidthMax")
.arg(PAGE_MAX_PX.to_string())
.arg(path)
.arg("--out")
.arg(out)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
matches!(status, Ok(s) if s.success()) && out_is_nonempty(out)
}
pub fn page_count(path: &Path) -> Option<u32> {
if !path.is_file() {
return None;
}
let output = Command::new("pdfinfo")
.arg(path)
.stderr(Stdio::null())
.output()
.ok()?;
if !output.status.success() {
return None;
}
let text = String::from_utf8_lossy(&output.stdout);
for line in text.lines() {
if let Some(rest) = line.strip_prefix("Pages:") {
if let Ok(n) = rest.trim().parse::<u32>() {
return (n >= 1).then_some(n);
}
}
}
None
}
fn out_is_nonempty(out: &Path) -> bool {
std::fs::metadata(out).map(|m| m.len() > 0).unwrap_or(false)
}
fn temp_png_path() -> PathBuf {
std::env::temp_dir().join(format!(
"konoma-pdf-{}-{}.png",
std::process::id(),
next_id()
))
}
fn temp_dir_path() -> PathBuf {
std::env::temp_dir().join(format!("konoma-pdf-{}-{}.d", std::process::id(), next_id()))
}
fn next_id() -> u64 {
static N: AtomicU64 = AtomicU64::new(0);
N.fetch_add(1, Ordering::Relaxed)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nonexistent_returns_none() {
assert!(render_page(Path::new("/no/such/file.pdf"), 1).is_none());
assert!(render_page(Path::new("/no/such/file.pdf"), 3).is_none());
assert!(page_count(Path::new("/no/such/file.pdf")).is_none());
}
#[test]
fn renders_sample_pdf_when_a_tool_is_available() {
let p = Path::new("samples/sample.pdf");
if !p.exists() {
return; }
match render_page(p, 1) {
Some(img) => assert!(
img.width() > 0 && img.height() > 0,
"ラスタライズ結果の寸法が 0"
),
None => eprintln!("skip: PDF ラスタライザ不在(qlmanage/sips/poppler いずれも不可)"),
}
}
#[test]
fn multipage_sample_counts_and_renders_each_page() {
let p = Path::new("samples/sample.pdf");
if !p.exists() {
return;
}
let Some(pages) = page_count(p) else {
eprintln!("skip: pdfinfo 不在(poppler 無し)= ページ数不明・単ページ扱い");
return;
};
assert!(pages >= 1, "ページ数は 1 以上");
for pg in 1..=pages {
let img = render_page(p, pg).expect("poppler があれば各ページがラスタライズできる");
assert!(img.width() > 0 && img.height() > 0, "page {pg} 寸法が 0");
}
assert!(render_page(p, pages + 1).is_none(), "範囲外ページは None");
}
}