use std::path::PathBuf;
use tokio::process::Command;
use crate::error::Error;
use crate::{DISPLAY_HEIGHT, DISPLAY_WIDTH, MAX_IMAGE_SIZE};
#[derive(Debug, Clone)]
pub struct RenderConfig {
pub chrome_path: String,
pub temp_dir: PathBuf,
pub optimize: bool,
pub color_depth: u32,
pub width: u32,
pub height: u32,
}
impl Default for RenderConfig {
fn default() -> Self {
Self {
chrome_path: std::env::var("CHROME_PATH")
.unwrap_or_else(|_| "google-chrome".to_string()),
temp_dir: PathBuf::from("/tmp/trmnl"),
optimize: true,
color_depth: 16,
width: DISPLAY_WIDTH,
height: DISPLAY_HEIGHT,
}
}
}
impl RenderConfig {
pub fn with_chrome_path(mut self, path: impl Into<String>) -> Self {
self.chrome_path = path.into();
self
}
pub fn with_temp_dir(mut self, path: impl Into<PathBuf>) -> Self {
self.temp_dir = path.into();
self
}
pub fn without_optimization(mut self) -> Self {
self.optimize = false;
self
}
}
pub async fn render_html_to_png(html: &str, config: &RenderConfig) -> Result<Vec<u8>, Error> {
tokio::fs::create_dir_all(&config.temp_dir)
.await
.map_err(|e| Error::Io(format!("Failed to create temp dir: {}", e)))?;
let html_path = config.temp_dir.join("render.html");
let screenshot_path = config.temp_dir.join("screenshot.png");
let optimized_path = config.temp_dir.join("optimized.png");
let chrome_data_dir = config.temp_dir.join("chrome-data");
tokio::fs::write(&html_path, html)
.await
.map_err(|e| Error::Io(format!("Failed to write HTML: {}", e)))?;
tokio::fs::create_dir_all(&chrome_data_dir)
.await
.map_err(|e| Error::Io(format!("Failed to create chrome data dir: {}", e)))?;
let html_url = format!("file://{}", html_path.display());
let output = Command::new(&config.chrome_path)
.arg("--headless=new")
.arg("--no-sandbox")
.arg("--disable-gpu")
.arg("--disable-dev-shm-usage")
.arg("--disable-software-rasterizer")
.arg("--no-first-run")
.arg("--disable-extensions")
.arg("--disable-background-networking")
.arg("--force-device-scale-factor=1")
.arg("--hide-scrollbars")
.arg("--default-background-color=ffffffff")
.arg(format!("--user-data-dir={}", chrome_data_dir.display()))
.arg(format!(
"--window-size={},{}",
config.width,
config.height + 100 ))
.arg(format!("--screenshot={}", screenshot_path.display()))
.arg(&html_url)
.output()
.await
.map_err(|e| Error::Chrome(format!("Failed to run Chrome: {}", e)))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
tracing::warn!("Chrome stderr: {}", stderr);
}
if !tokio::fs::try_exists(&screenshot_path)
.await
.unwrap_or(false)
{
return Err(Error::Chrome(
"Chrome did not create screenshot".to_string(),
));
}
let final_path = if config.optimize {
let convert_result = Command::new("convert")
.arg(&screenshot_path)
.arg("-crop")
.arg(format!("{}x{}+0+0", config.width, config.height))
.arg("+repage")
.arg("-colors")
.arg(config.color_depth.to_string())
.arg("-depth")
.arg("4")
.arg(&optimized_path)
.output()
.await;
match convert_result {
Ok(output) if output.status.success() => {
if tokio::fs::try_exists(&optimized_path)
.await
.unwrap_or(false)
{
optimized_path
} else {
screenshot_path
}
}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
tracing::warn!("ImageMagick optimization failed: {}", stderr);
screenshot_path
}
Err(e) => {
tracing::warn!("ImageMagick not available: {}", e);
screenshot_path
}
}
} else {
screenshot_path
};
let png_data = tokio::fs::read(&final_path)
.await
.map_err(|e| Error::Io(format!("Failed to read screenshot: {}", e)))?;
tracing::info!("Rendered PNG: {} bytes", png_data.len());
if png_data.len() > MAX_IMAGE_SIZE {
return Err(Error::ImageTooLarge {
size: png_data.len(),
max: MAX_IMAGE_SIZE,
});
}
Ok(png_data)
}
pub fn timestamped_filename() -> String {
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
format!("{}.png", timestamp)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_render_config_defaults() {
let config = RenderConfig::default();
assert_eq!(config.width, 800);
assert_eq!(config.height, 480);
assert!(config.optimize);
assert_eq!(config.color_depth, 16);
}
#[test]
fn test_timestamped_filename() {
let filename = timestamped_filename();
assert!(filename.ends_with(".png"));
let stem = filename.trim_end_matches(".png");
assert!(stem.parse::<u64>().is_ok());
}
#[test]
fn test_config_builder() {
let config = RenderConfig::default()
.with_chrome_path("/usr/bin/chromium")
.with_temp_dir("/var/tmp/trmnl")
.without_optimization();
assert_eq!(config.chrome_path, "/usr/bin/chromium");
assert_eq!(config.temp_dir, PathBuf::from("/var/tmp/trmnl"));
assert!(!config.optimize);
}
}