1use crate::error::FormatError;
7use crate::format::{Format, SerializedDocument};
8use crate::formats::html::HtmlFormat;
9use lex_core::lex::ast::Document;
10use std::collections::HashMap;
11use std::env;
12use std::fs;
13use std::io::Write;
14use std::path::PathBuf;
15use std::process::Command;
16use tempfile::tempdir;
17use url::Url;
18use which::which;
19
20#[derive(Default)]
22pub struct PngFormat {
23 html: HtmlFormat,
24}
25
26impl PngFormat {
27 pub fn new() -> Self {
28 Self {
29 html: HtmlFormat::default(),
30 }
31 }
32}
33
34impl Format for PngFormat {
35 fn name(&self) -> &str {
36 "png"
37 }
38
39 fn description(&self) -> &str {
40 "HTML-based PNG export via headless Chrome screenshot"
41 }
42
43 fn file_extensions(&self) -> &[&str] {
44 &["png"]
45 }
46
47 fn supports_serialization(&self) -> bool {
48 true
49 }
50
51 fn serialize(&self, _doc: &Document) -> Result<String, FormatError> {
52 Err(FormatError::NotSupported(
53 "PNG serialization produces binary output".to_string(),
54 ))
55 }
56
57 fn serialize_with_options(
58 &self,
59 doc: &Document,
60 options: &HashMap<String, String>,
61 ) -> Result<SerializedDocument, FormatError> {
62 let profile = PngSizeProfile::from_options(options)?;
63 let html = self.html.serialize(doc)?;
64 let final_html = inject_screenshot_css(&html, profile.css());
65 let png_bytes = render_html_to_png(&final_html, profile)?;
66 Ok(SerializedDocument::Binary(png_bytes))
67 }
68}
69
70#[derive(Clone, Copy, Debug, PartialEq, Eq)]
71pub enum PngSizeProfile {
72 QuickLook,
73 LexEd,
74 Mobile,
75}
76
77impl PngSizeProfile {
78 fn from_options(options: &HashMap<String, String>) -> Result<Self, FormatError> {
79 let quicklook = parse_bool_flag(options, "quicklook", false)?;
80 let mobile = parse_bool_flag(options, "size-mobile", false)?;
81 let lexed = parse_bool_flag(options, "size-lexed", false)?;
82
83 let count = [quicklook, mobile, lexed].iter().filter(|&&x| x).count();
84 if count > 1 {
85 return Err(FormatError::SerializationError(
86 "Cannot enable multiple PNG size profiles at once".to_string(),
87 ));
88 }
89
90 if quicklook {
91 Ok(PngSizeProfile::QuickLook)
92 } else if mobile {
93 Ok(PngSizeProfile::Mobile)
94 } else {
95 Ok(PngSizeProfile::LexEd)
96 }
97 }
98
99 fn css(&self) -> &'static str {
100 match self {
102 PngSizeProfile::QuickLook => {
103 concat!(
104 "body { margin: 20px; background: white; }\n",
105 ".lex-document { max-width: 600px; }\n",
106 "body, h1, h2, h3, h4, h5, h6 { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif !important; }\n",
107 "code, .lex-verbatim code { font-family: 'SF Mono', Menlo, Monaco, 'Courier New', monospace !important; }\n"
108 )
109 }
110 PngSizeProfile::LexEd => {
111 concat!(
112 "body { margin: 40px; background: white; }\n",
113 ".lex-document { max-width: 800px; }\n",
114 "body, h1, h2, h3, h4, h5, h6 { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif !important; }\n",
115 "code, .lex-verbatim code { font-family: 'SF Mono', Menlo, Monaco, 'Courier New', monospace !important; }\n"
116 )
117 }
118 PngSizeProfile::Mobile => {
119 concat!(
120 "body { margin: 10px; background: white; }\n",
121 ".lex-document { max-width: 350px; }\n",
122 "body, h1, h2, h3, h4, h5, h6 { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif !important; }\n",
123 "code, .lex-verbatim code { font-family: 'SF Mono', Menlo, Monaco, 'Courier New', monospace !important; }\n"
124 )
125 }
126 }
127 }
128
129 fn viewport(&self) -> (u32, u32) {
130 match self {
131 PngSizeProfile::QuickLook => (680, 800),
132 PngSizeProfile::LexEd => (1280, 960),
133 PngSizeProfile::Mobile => (450, 900),
134 }
135 }
136}
137
138fn parse_bool_flag(
139 options: &HashMap<String, String>,
140 key: &str,
141 default: bool,
142) -> Result<bool, FormatError> {
143 if let Some(value) = options.get(key) {
144 if value.is_empty() {
145 return Ok(true);
146 }
147 match value.to_lowercase().as_str() {
148 "true" | "1" | "yes" | "y" => Ok(true),
149 "false" | "0" | "no" | "n" => Ok(false),
150 other => Err(FormatError::SerializationError(format!(
151 "Invalid boolean value '{other}' for --extra-{key}"
152 ))),
153 }
154 } else {
155 Ok(default)
156 }
157}
158
159fn inject_screenshot_css(html: &str, css: &str) -> String {
160 let style_tag = format!("<style data-lex-png>\n{css}\n</style>");
161 if let Some(idx) = html.find("</head>") {
162 let mut output = String::with_capacity(html.len() + style_tag.len());
163 output.push_str(&html[..idx]);
164 output.push_str(&style_tag);
165 output.push_str(&html[idx..]);
166 output
167 } else {
168 format!("{style_tag}{html}")
169 }
170}
171
172fn render_html_to_png(html: &str, profile: PngSizeProfile) -> Result<Vec<u8>, FormatError> {
173 let chrome = resolve_chrome_binary()?;
174 let temp_dir =
175 tempdir().map_err(|e| FormatError::SerializationError(format!("Temp dir error: {e}")))?;
176 let html_path = temp_dir.path().join("lex-export.html");
177 let mut html_file =
178 fs::File::create(&html_path).map_err(|e| FormatError::SerializationError(e.to_string()))?;
179 html_file
180 .write_all(html.as_bytes())
181 .map_err(|e| FormatError::SerializationError(e.to_string()))?;
182
183 let png_path = temp_dir.path().join("lex-export.png");
184 let file_url = Url::from_file_path(&html_path).map_err(|_| {
185 FormatError::SerializationError(
186 "Failed to construct file:// URL for HTML input".to_string(),
187 )
188 })?;
189
190 let screenshot_arg = format!("--screenshot={}", png_path.display());
191 let window_arg = {
192 let (w, h) = profile.viewport();
193 format!("--window-size={w},{h}")
194 };
195
196 let status = Command::new(&chrome)
197 .arg("--headless")
198 .arg("--disable-gpu")
199 .arg("--no-sandbox")
200 .arg("--disable-dev-shm-usage")
201 .arg("--hide-scrollbars")
202 .arg(&screenshot_arg)
203 .arg(&window_arg)
204 .arg(file_url.as_str())
205 .status()
206 .map_err(|e| {
207 FormatError::SerializationError(format!(
208 "Failed to launch Chrome ({}): {}",
209 chrome.display(),
210 e
211 ))
212 })?;
213
214 if !status.success() {
215 return Err(FormatError::SerializationError(format!(
216 "Chrome exited with status {status}"
217 )));
218 }
219
220 fs::read(&png_path).map_err(|e| FormatError::SerializationError(e.to_string()))
221}
222
223fn resolve_chrome_binary() -> Result<PathBuf, FormatError> {
224 if let Some(path) = env::var_os("LEX_CHROME_BIN") {
225 if !path.is_empty() {
226 return Ok(PathBuf::from(path));
227 }
228 }
229
230 for var in ["GOOGLE_CHROME_BIN", "CHROME_BIN"] {
231 if let Some(path) = env::var_os(var) {
232 if !path.is_empty() {
233 return Ok(PathBuf::from(path));
234 }
235 }
236 }
237
238 for candidate in [
239 "google-chrome",
240 "google-chrome-stable",
241 "chromium",
242 "chromium-browser",
243 "chrome",
244 "msedge",
245 ] {
246 if let Ok(path) = which(candidate) {
247 return Ok(path);
248 }
249 }
250
251 #[cfg(target_os = "macos")]
252 {
253 let default_path = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
254 let candidate = PathBuf::from(default_path);
255 if candidate.exists() {
256 return Ok(candidate);
257 }
258 }
259
260 #[cfg(target_os = "windows")]
261 {
262 let candidates = [
263 r"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
264 r"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
265 ];
266 for candidate in candidates {
267 let path = PathBuf::from(candidate);
268 if path.exists() {
269 return Ok(path);
270 }
271 }
272 }
273
274 #[cfg(target_os = "linux")]
275 {
276 let candidates = [
277 "/usr/bin/google-chrome",
278 "/usr/bin/google-chrome-stable",
279 "/usr/bin/chromium-browser",
280 "/usr/bin/chromium",
281 ];
282 for candidate in candidates {
283 let path = PathBuf::from(candidate);
284 if path.exists() {
285 return Ok(path);
286 }
287 }
288 }
289
290 Err(FormatError::SerializationError(
291 "Unable to locate a Chrome/Chromium binary. Set LEX_CHROME_BIN to override the detection."
292 .to_string(),
293 ))
294}