use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicU64, Ordering};
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());
}
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()
}
fn render_page_external(path: &Path, page: u32) -> Option<DynamicImage> {
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 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()
}
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, 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 p = Path::new("samples/sample.pdf");
if !p.exists() {
return; }
let img = render_page(p, 1, false).expect("hayro renders without any external process");
assert!(
img.width() > 0 && img.height() > 0,
"ラスタライズ結果の寸法が 0"
);
}
#[test]
fn render_page_has_transparent_margins_and_opaque_ink() {
let p = Path::new("samples/sample.pdf");
if !p.exists() {
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 p = Path::new("samples/sample.pdf");
if !p.exists() {
return; }
assert_eq!(
page_count(p),
Some(3),
"poppler の有無に関わらずページ数が取れる"
);
}
#[test]
fn page_count_handles_bad_input_without_panicking() {
let dir = std::env::temp_dir().join(format!("konoma-pdf-badinput-{}", std::process::id()));
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 p = Path::new("samples/sample.pdf");
if !p.exists() {
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 フォントが見つからない"),
}
}
}