lex_babel/formats/png/
mod.rs

1//! PNG export built on top of the HTML serializer + headless Chrome.
2//!
3//! Similar to PDF export, this renders Lex documents to HTML and uses
4//! Chrome's screenshot capability to generate a PNG image.
5
6use 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/// Format implementation that shells out to Chrome/Chromium to generate PNGs.
21#[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        // Use system fonts for faster rendering (no web font loading delay)
101        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}