use std::path::Path;
use std::sync::Arc;
use hayro::hayro_interpret::font::{FontData, FontQuery};
use hayro::hayro_interpret::hayro_cmap::{CharacterCollection, CidFamily};
use hayro::hayro_interpret::InterpreterSettings;
use hayro::vello_cpu::color::palette::css::TRANSPARENT;
use hayro::vello_cpu::Pixmap;
use image::DynamicImage;
const PAGE_MAX_PX: u32 = 1600;
pub fn render_page(path: &Path, page: u32, allow_external: bool) -> Option<DynamicImage> {
if !path.is_file() {
return None;
}
let page = page.max(1);
if let Some(img) = render_page_native(path, page) {
return Some(img);
}
if !allow_external {
return None;
}
render_page_external(path, page)
}
fn render_page_native(path: &Path, page: u32) -> Option<DynamicImage> {
crate::preview::markdown::catch_silent(|| render_page_native_inner(path, page)).flatten()
}
fn render_page_native_inner(path: &Path, page: u32) -> Option<DynamicImage> {
let bytes = std::fs::read(path).ok()?;
let pdf = hayro::hayro_syntax::Pdf::new(bytes).ok()?;
let pages: Vec<_> = pdf.pages().iter().collect();
let idx = usize::try_from(page).ok()?.checked_sub(1)?;
let page_ref = *pages.get(idx)?;
let (w, h) = page_ref.render_dimensions();
if !(w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0) {
return None;
}
let scale = PAGE_MAX_PX as f32 / w.max(h);
let settings = pdf_interpreter_settings();
let render_settings = hayro::RenderSettings {
x_scale: scale,
y_scale: scale,
bg_color: TRANSPARENT,
..Default::default()
};
let cache = hayro::RenderCache::new();
let pixmap = hayro::render(page_ref, &cache, &settings, &render_settings);
pixmap_to_dynamic_image(pixmap)
}
fn pixmap_to_dynamic_image(pixmap: Pixmap) -> Option<DynamicImage> {
let (w, h) = (u32::from(pixmap.width()), u32::from(pixmap.height()));
if w == 0 || h == 0 {
return None;
}
let straight = pixmap.take_unpremultiplied();
let mut rgba = Vec::with_capacity(straight.len() * 4);
for px in straight {
rgba.extend_from_slice(&px.to_u8_array());
}
if rgba.as_chunks::<4>().0.iter().all(|px| px[3] == 0) {
return None;
}
let buf = image::RgbaImage::from_raw(w, h, rgba)?;
Some(DynamicImage::ImageRgba8(buf))
}
fn pdf_interpreter_settings() -> InterpreterSettings {
InterpreterSettings {
font_resolver: Arc::new(|query| match query {
FontQuery::Standard(s) => Some(s.get_font_data()),
FontQuery::Fallback(f) => cjk_fallback_font(f.character_collection.as_ref())
.or_else(|| Some(f.pick_standard_font().get_font_data())),
}),
..InterpreterSettings::default()
}
}
fn cjk_fallback_font(collection: Option<&CharacterCollection>) -> Option<(FontData, u32)> {
let db = crate::preview::svg::shared_fontdb();
let names = cjk_family_candidates(collection.map(|c| &c.family));
let families: Vec<resvg::usvg::fontdb::Family<'_>> = names
.iter()
.map(|n| resvg::usvg::fontdb::Family::Name(n))
.collect();
let query = resvg::usvg::fontdb::Query {
families: &families,
..Default::default()
};
let id = db.query(&query)?;
let (_source, face_index) = db.face_source(id)?;
let bytes = db.with_face_data(id, |data, _face_index| data.to_vec())?;
let font_data: FontData = Arc::new(bytes);
Some((font_data, face_index))
}
fn cjk_family_candidates(family: Option<&CidFamily>) -> Vec<&'static str> {
const JP: &[&str] = &[
"Hiragino Sans",
"Hiragino Kaku Gothic ProN",
"Hiragino Kaku Gothic Pro",
"Hiragino Mincho ProN",
"Noto Sans CJK JP",
"Noto Serif CJK JP",
"Noto Sans JP",
"Source Han Sans JP",
"Source Han Sans",
"IPAGothic",
"IPAPGothic",
"TakaoGothic",
"TakaoPGothic",
"VL Gothic",
"VL PGothic",
];
const SC: &[&str] = &[
"PingFang SC",
"Hiragino Sans GB",
"Noto Sans CJK SC",
"Noto Serif CJK SC",
"Noto Sans SC",
"Source Han Sans SC",
"Source Han Sans CN",
"WenQuanYi Zen Hei",
"WenQuanYi Micro Hei",
"Droid Sans Fallback",
"Microsoft YaHei",
"SimHei",
"SimSun",
];
const TC: &[&str] = &[
"PingFang TC",
"PingFang HK",
"Noto Sans CJK TC",
"Noto Serif CJK TC",
"Noto Sans TC",
"Source Han Sans TC",
"Source Han Sans TW",
"Microsoft JhengHei",
"AR PL UMing TW",
"AR PL UKai TW",
];
const KR: &[&str] = &[
"Apple SD Gothic Neo",
"Noto Sans CJK KR",
"Noto Serif CJK KR",
"Noto Sans KR",
"Source Han Sans KR",
"Malgun Gothic",
"NanumGothic",
"NanumBarunGothic",
"Baekmuk Dotum",
"UnDotum",
];
let primary: &[&str] = match family {
Some(CidFamily::AdobeJapan1) => JP,
Some(CidFamily::AdobeGB1) => SC,
Some(CidFamily::AdobeCNS1) => TC,
Some(CidFamily::AdobeKorea1) => KR,
_ => &[],
};
primary
.iter()
.chain(JP)
.chain(SC)
.chain(TC)
.chain(KR)
.copied()
.collect()
}
#[cfg(target_os = "macos")]
fn render_page_external(path: &Path, page: u32) -> Option<DynamicImage> {
if page != 1 {
return None;
}
let out = macos::temp_png_path();
let ok = macos::run_qlmanage(path, &out) || macos::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
}
#[cfg(not(target_os = "macos"))]
fn render_page_external(_path: &Path, _page: u32) -> Option<DynamicImage> {
None
}
#[cfg(target_os = "macos")]
mod macos {
use super::PAGE_MAX_PX;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicU64, Ordering};
pub(super) fn run_qlmanage(path: &Path, out: &Path) -> bool {
let dir = temp_dir_path();
if std::fs::create_dir_all(&dir).is_err() {
return false;
}
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700));
}
let mut cmd = Command::new("qlmanage");
cmd.arg("-t")
.arg("-s")
.arg(PAGE_MAX_PX.to_string())
.arg("-o")
.arg(&dir)
.arg(path)
.stdin(Stdio::null()) .stdout(Stdio::null())
.stderr(Stdio::null());
let status = spawn_and_wait_with_timeout(&mut cmd, TOOL_TIMEOUT);
let mut ok = false;
if matches!(status, Some(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
}
pub(super) fn run_sips(path: &Path, out: &Path) -> bool {
let mut cmd = Command::new("sips");
cmd.arg("-s")
.arg("format")
.arg("png")
.arg("--resampleHeightWidthMax")
.arg(PAGE_MAX_PX.to_string())
.arg(path)
.arg("--out")
.arg(out)
.stdin(Stdio::null()) .stdout(Stdio::null())
.stderr(Stdio::null());
matches!(spawn_and_wait_with_timeout(&mut cmd, TOOL_TIMEOUT), Some(s) if s.success())
&& out_is_nonempty(out)
}
const TOOL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
const POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(20);
pub(super) fn spawn_and_wait_with_timeout(
cmd: &mut Command,
timeout: std::time::Duration,
) -> Option<std::process::ExitStatus> {
let mut child = cmd.spawn().ok()?;
let deadline = std::time::Instant::now() + timeout;
loop {
match child.try_wait() {
Ok(Some(status)) => return Some(status),
Ok(None) => {
if std::time::Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait(); return None;
}
std::thread::sleep(POLL_INTERVAL);
}
Err(_) => return None,
}
}
}
fn out_is_nonempty(out: &Path) -> bool {
std::fs::metadata(out).map(|m| m.len() > 0).unwrap_or(false)
}
fn private_temp_dir() -> PathBuf {
static DIR: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
DIR.get_or_init(|| {
let dir = std::env::temp_dir().join(format!("konoma-pdf-{}", std::process::id()));
use std::os::unix::fs::{DirBuilderExt, PermissionsExt};
let _ = std::fs::DirBuilder::new().mode(0o700).create(&dir);
let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700));
dir
})
.clone()
}
pub(super) fn temp_png_path() -> PathBuf {
private_temp_dir().join(format!("page-{}.png", next_id()))
}
pub(super) fn temp_dir_path() -> PathBuf {
private_temp_dir().join(format!("ql-{}.d", next_id()))
}
fn next_id() -> u64 {
static N: AtomicU64 = AtomicU64::new(0);
N.fetch_add(1, Ordering::Relaxed)
}
}
pub fn page_count(path: &Path) -> Option<u32> {
page_count_impl(path, PAGE_COUNT_MAX_BYTES)
}
const PAGE_COUNT_MAX_BYTES: u64 = 64 * 1024 * 1024;
fn page_count_impl(path: &Path, max_bytes: u64) -> Option<u32> {
if !path.is_file() {
return None;
}
let meta = std::fs::metadata(path).ok()?;
if meta.len() > max_bytes {
return None;
}
let bytes = std::fs::read(path).ok()?;
crate::preview::markdown::catch_silent(|| {
let pdf = hayro_syntax::Pdf::new(bytes).ok()?;
let n = u32::try_from(pdf.pages().len()).ok()?;
(n >= 1).then_some(n)
})
.flatten()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::unique_tmp;
fn sample_path_or_skip(name: &str) -> Option<std::path::PathBuf> {
let p = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("samples")
.join(name);
if p.exists() {
Some(p)
} else {
eprintln!(
"SKIP: samples/{name} not found (excluded from the published crate) — this test verifies nothing this run"
);
None
}
}
#[test]
fn nonexistent_returns_none() {
assert!(render_page(Path::new("/no/such/file.pdf"), 1, true).is_none());
assert!(render_page(Path::new("/no/such/file.pdf"), 3, true).is_none());
assert!(render_page(Path::new("/no/such/file.pdf"), 1, false).is_none());
assert!(page_count(Path::new("/no/such/file.pdf")).is_none());
}
#[test]
fn renders_sample_pdf_via_hayro_with_no_external_tool_allowed() {
let Some(p) = sample_path_or_skip("sample.pdf") else {
return;
};
let img = render_page(&p, 1, false).expect("hayro renders without any external process");
assert!(
img.width() > 0 && img.height() > 0,
"ラスタライズ結果の寸法が 0"
);
let with_external =
render_page(&p, 1, true).expect("hayro still answers first when external is allowed");
assert_eq!(
(with_external.width(), with_external.height()),
(img.width(), img.height()),
"allow_external は hayro 経路に一切影響しない(フラグを見る前に返っている)"
);
}
#[test]
fn render_page_has_transparent_margins_and_opaque_ink() {
let Some(p) = sample_path_or_skip("sample.pdf") else {
return;
};
let img = render_page(&p, 1, false).expect("renders");
let rgba = img.to_rgba8();
assert_eq!(
rgba.get_pixel(0, 0)[3],
0,
"unpainted corner is fully transparent (terminal bg would show through)"
);
let opaque = rgba.pixels().filter(|p| p[3] > 200).count();
assert!(opaque > 50, "page has visible opaque ink (opaque={opaque})");
}
#[test]
fn page_count_is_pure_rust_and_always_available() {
let Some(p) = sample_path_or_skip("sample.pdf") else {
return;
};
assert_eq!(
page_count(&p),
Some(3),
"外部ツールの有無に関わらずページ数が取れる"
);
}
#[test]
fn page_count_handles_bad_input_without_panicking() {
let dir = unique_tmp("konoma-pdf-badinput");
std::fs::create_dir_all(&dir).unwrap();
let empty = dir.join("empty.pdf");
std::fs::write(&empty, b"").unwrap();
assert_eq!(page_count(&empty), None, "空ファイルは None");
let not_pdf = dir.join("notes.pdf");
std::fs::write(¬_pdf, b"hello, this is not a PDF file at all\n").unwrap();
assert_eq!(page_count(¬_pdf), None, "PDF でない中身は None");
let mut corrupt_bytes = b"%PDF-1.7\n".to_vec();
corrupt_bytes.extend((0u32..2000).map(|i| (i % 256) as u8));
let corrupt = dir.join("corrupt.pdf");
std::fs::write(&corrupt, &corrupt_bytes).unwrap();
let _ = page_count(&corrupt);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn multipage_sample_counts_and_renders_each_page_via_hayro() {
let Some(p) = sample_path_or_skip("sample.pdf") else {
return;
};
let pages =
page_count(&p).expect("hayro-syntax parses the bundled sample without any tool");
assert!(pages >= 1, "ページ数は 1 以上");
for pg in 1..=pages {
let img = render_page(&p, pg, false).expect("hayro renders every page without a tool");
assert!(img.width() > 0 && img.height() > 0, "page {pg} 寸法が 0");
}
assert!(
render_page(&p, pages + 1, true).is_none(),
"範囲外ページは None"
);
}
#[test]
fn cjk_family_candidates_prioritizes_the_matching_script() {
let jp = cjk_family_candidates(Some(&CidFamily::AdobeJapan1));
assert_eq!(
jp.first(),
Some(&"Hiragino Sans"),
"JP は日本語系フォントが先頭"
);
assert!(
jp.contains(&"PingFang SC"),
"他スクリプトもバックストップとして含む"
);
assert!(jp.contains(&"Apple SD Gothic Neo"));
let sc = cjk_family_candidates(Some(&CidFamily::AdobeGB1));
assert_eq!(sc.first(), Some(&"PingFang SC"), "簡体字は先頭");
let tc = cjk_family_candidates(Some(&CidFamily::AdobeCNS1));
assert_eq!(tc.first(), Some(&"PingFang TC"), "繁体字は先頭");
let kr = cjk_family_candidates(Some(&CidFamily::AdobeKorea1));
assert_eq!(kr.first(), Some(&"Apple SD Gothic Neo"), "韓国語は先頭");
let unknown = cjk_family_candidates(None);
assert!(unknown.contains(&"Hiragino Sans"));
assert!(unknown.contains(&"PingFang SC"));
assert!(unknown.contains(&"PingFang TC"));
assert!(unknown.contains(&"Apple SD Gothic Neo"));
}
#[test]
fn cjk_fallback_font_finds_real_font_bytes_when_available() {
let cc = CharacterCollection {
family: CidFamily::AdobeJapan1,
supplement: 0,
};
match cjk_fallback_font(Some(&cc)) {
Some((data, _face_index)) => {
let dynref: &(dyn AsRef<[u8]> + Send + Sync) = &*data;
let bytes: &[u8] = dynref.as_ref();
assert!(
bytes.len() > 1000,
"見つかったフォントは実データを持つ(len={})",
bytes.len()
);
}
None => eprintln!("skip: このマシンに CJK フォントが見つからない"),
}
}
#[cfg(target_os = "macos")]
#[test]
fn temp_paths_live_in_an_owner_only_directory() {
use std::os::unix::fs::PermissionsExt;
for path in [macos::temp_png_path(), macos::temp_dir_path()] {
let dir = path.parent().expect("temp path has a parent dir");
assert_ne!(
dir,
std::env::temp_dir(),
"システム共有の一時ディレクトリ直下に出力している(専用サブディレクトリを持っていない): {path:?}"
);
let meta = std::fs::metadata(dir).expect("private temp dir should exist by now");
assert!(meta.is_dir());
let mode = meta.permissions().mode() & 0o777;
assert_eq!(
mode, 0o700,
"ラスタライズ結果の置き場が owner-only(0700) になっていない: {dir:?} mode={mode:#o}"
);
}
}
#[test]
fn page_count_treats_oversized_files_as_unknown_rather_than_reading_them() {
let Some(p) = sample_path_or_skip("sample.pdf") else {
return;
};
let real_size = std::fs::metadata(&p).unwrap().len();
assert_eq!(
page_count_impl(&p, real_size + 1),
Some(3),
"cap がファイルサイズより大きければ通常どおり数えられる"
);
assert_eq!(
page_count_impl(&p, real_size - 1),
None,
"cap をファイルサイズ未満にすると(内容は妥当なままなのに)数えない=サイズガードが機能していない"
);
}
#[test]
fn native_render_returns_none_when_hayro_draws_nothing() {
let dir = unique_tmp("konoma-pdf-blank-content-stream");
std::fs::create_dir_all(&dir).unwrap();
let p = dir.join("blank.pdf");
std::fs::write(&p, minimal_blank_pdf_bytes()).unwrap();
assert!(
render_page_native_inner(&p, 1).is_none(),
"hayro が何も描かなかった Pixmap は None を返し、外部ツールへ降格すべき"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn render_page_reports_no_preview_when_hayro_draws_nothing_and_external_is_off() {
let dir = unique_tmp("konoma-pdf-blank-content-stream-fallback");
std::fs::create_dir_all(&dir).unwrap();
let p = dir.join("blank.pdf");
std::fs::write(&p, minimal_blank_pdf_bytes()).unwrap();
assert!(
render_page(&p, 1, false).is_none(),
"allow_external=false は blank を real content として見せず None のまま"
);
#[cfg(not(target_os = "macos"))]
assert!(
render_page(&p, 1, true).is_none(),
"macOS 以外では外部フォールバックが存在しないので allow_external=true でも None"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn external_fallback_declines_pages_past_the_first() {
let Some(p) = sample_path_or_skip("sample.pdf") else {
return;
};
assert!(
render_page_external(&p, 2).is_none(),
"2ページ目以降は外部フォールバック無し(プロセスも起動しない)"
);
assert!(render_page_external(&p, 7).is_none());
}
#[cfg(not(target_os = "macos"))]
#[test]
fn render_page_external_is_absent_off_macos() {
assert!(render_page_external(Path::new("/"), 1).is_none());
assert!(render_page_external(Path::new("/no/such/file.pdf"), 1).is_none());
}
fn minimal_blank_pdf_bytes() -> Vec<u8> {
let objs: [&[u8]; 4] = [
b"<< /Type /Catalog /Pages 2 0 R >>",
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << >> >>",
b"<< /Length 0 >>\nstream\n\nendstream",
];
let mut out = Vec::new();
out.extend_from_slice(b"%PDF-1.4\n");
let mut offsets = vec![0usize];
for (i, body) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
out.extend_from_slice(body);
out.extend_from_slice(b"\nendobj\n");
}
let xref_offset = out.len();
let n = objs.len() + 1;
out.extend_from_slice(format!("xref\n0 {n}\n").as_bytes());
out.extend_from_slice(b"0000000000 65535 f \n");
for off in &offsets[1..] {
out.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(
format!("trailer\n<< /Size {n} /Root 1 0 R >>\nstartxref\n{xref_offset}\n%%EOF\n")
.as_bytes(),
);
out
}
#[cfg(target_os = "macos")]
#[test]
fn spawn_and_wait_with_timeout_kills_a_command_that_never_exits() {
use std::process::{Command, Stdio};
let mut cmd = Command::new("tail");
cmd.arg("-f")
.arg("/dev/null")
.stdout(Stdio::null())
.stderr(Stdio::null());
let started = std::time::Instant::now();
let status =
macos::spawn_and_wait_with_timeout(&mut cmd, std::time::Duration::from_millis(200));
let elapsed = started.elapsed();
assert!(
status.is_none(),
"ハングするコマンドが None(タイムアウト)を返さなかった: {status:?}"
);
assert!(
elapsed < std::time::Duration::from_secs(10),
"タイムアウトが機能せずブロックし続けた: elapsed={elapsed:?}"
);
}
}