Skip to main content

cryosnap_core/
render.rs

1use crate::ansi::{parse_ansi, wrap_ansi_lines};
2use crate::fonts::{
3    build_font_families, build_font_plan, build_fontdb, collect_font_fallback_needs,
4    ensure_fonts_available, load_app_font_families, needs_system_fonts, resolve_script_font_plan,
5    scan_text_fallbacks, FontFallbackNeeds, FontPlan, ScriptFontPlan,
6};
7use crate::input::{is_ansi_input, load_input};
8use crate::layout::scale_dimension;
9use crate::png::{optimize_png, pixmap_to_webp, quantize_pixmap_to_png, quantize_png_bytes};
10use crate::svg::{build_svg, svg_font_face_css};
11use crate::syntax::highlight_code;
12use crate::text::{cut_text, detab, wrap_text};
13use crate::{
14    Config, Error, FontSystemFallback, InputSource, OutputFormat, RasterBackend, RenderRequest,
15    RenderResult, Result, TitlePathStyle, DEFAULT_TAB_WIDTH,
16};
17use once_cell::sync::Lazy;
18use std::env;
19use std::io::Write;
20use std::path::{Path, PathBuf};
21use std::process::{Command, Stdio};
22use std::time::Duration;
23
24pub fn render(request: &RenderRequest) -> Result<RenderResult> {
25    let bytes = match request.format {
26        OutputFormat::Svg => render_svg(&request.input, &request.config)?,
27        OutputFormat::Png => render_png(&request.input, &request.config)?,
28        OutputFormat::Webp => render_webp(&request.input, &request.config)?,
29    };
30    Ok(RenderResult {
31        format: request.format,
32        bytes,
33    })
34}
35
36pub fn render_svg(input: &InputSource, config: &Config) -> Result<Vec<u8>> {
37    Ok(render_svg_with_plan(input, config)?.bytes)
38}
39
40#[derive(Debug, Clone)]
41pub struct PlannedSvg {
42    pub bytes: Vec<u8>,
43    pub needs_system_fonts: bool,
44}
45
46pub fn render_svg_planned(input: &InputSource, config: &Config) -> Result<PlannedSvg> {
47    let rendered = render_svg_with_plan(input, config)?;
48    Ok(PlannedSvg {
49        bytes: rendered.bytes,
50        needs_system_fonts: rendered.font_plan.needs_system_fonts,
51    })
52}
53
54struct RenderedSvg {
55    bytes: Vec<u8>,
56    font_plan: FontPlan,
57}
58
59fn render_svg_with_plan(input: &InputSource, config: &Config) -> Result<RenderedSvg> {
60    let loaded = load_input(input, Duration::from_millis(config.execute_timeout_ms))?;
61    let is_ansi = is_ansi_input(&loaded, config);
62    let line_window = &config.lines;
63
64    let (lines, default_fg, line_offset) = if is_ansi {
65        let cut = cut_text(&loaded.text, line_window);
66        let mut lines = parse_ansi(&cut.text);
67        if config.wrap > 0 {
68            lines = wrap_ansi_lines(&lines, config.wrap);
69        }
70        (lines, "#C5C8C6".to_string(), cut.start)
71    } else {
72        let mut text = detab(&loaded.text, DEFAULT_TAB_WIDTH);
73        let cut = cut_text(&text, line_window);
74        text = cut.text;
75        if config.wrap > 0 {
76            text = wrap_text(&text, config.wrap);
77        }
78        let (lines, default_fg) = highlight_code(
79            &text,
80            loaded.path.as_deref(),
81            config.language.as_deref(),
82            &config.theme,
83        )?;
84        (lines, default_fg, cut.start)
85    };
86
87    let title_text = resolve_title_text(input, config);
88    let needs = collect_font_fallback_needs(&lines, title_text.as_deref());
89    let script_plan = resolve_script_font_plan(config, &needs);
90    let script_plan = match script_plan {
91        Ok(plan) => plan,
92        Err(err) => {
93            eprintln!("cryosnap: font plan failed: {}", err);
94            ScriptFontPlan::default()
95        }
96    };
97    let _ = ensure_fonts_available(config, &needs, &script_plan);
98    let app_families = load_app_font_families(config).unwrap_or_default();
99    let font_plan = build_font_plan(config, &needs, &app_families, &script_plan.families);
100    let font_css = svg_font_face_css(config)?;
101    let svg = build_svg(
102        &lines,
103        config,
104        &default_fg,
105        font_css,
106        line_offset,
107        title_text.as_deref(),
108        &font_plan.font_family,
109    );
110    Ok(RenderedSvg {
111        bytes: svg.into_bytes(),
112        font_plan,
113    })
114}
115
116pub fn render_png(input: &InputSource, config: &Config) -> Result<Vec<u8>> {
117    let rendered = render_svg_with_plan(input, config)?;
118    render_png_from_svg_with_plan(
119        &rendered.bytes,
120        config,
121        rendered.font_plan.needs_system_fonts,
122    )
123}
124
125pub fn render_webp(input: &InputSource, config: &Config) -> Result<Vec<u8>> {
126    let rendered = render_svg_with_plan(input, config)?;
127    render_webp_from_svg_with_plan(
128        &rendered.bytes,
129        config,
130        rendered.font_plan.needs_system_fonts,
131    )
132}
133
134pub fn render_png_from_svg(svg: &[u8], config: &Config) -> Result<Vec<u8>> {
135    let needs = font_needs_from_svg(svg, config);
136    render_png_from_svg_with_plan(svg, config, needs.needs_system_fonts)
137}
138
139fn render_png_from_svg_with_plan(
140    svg: &[u8],
141    config: &Config,
142    needs_system_fonts: bool,
143) -> Result<Vec<u8>> {
144    if let Some(png) = try_render_png_with_rsvg(svg, config)? {
145        let png = if config.png.quantize {
146            quantize_png_bytes(&png, &config.png)?
147        } else {
148            png
149        };
150        return optimize_png(png, &config.png);
151    }
152
153    let pixmap = rasterize_svg(svg, config, needs_system_fonts)?;
154    let png = if config.png.quantize {
155        quantize_pixmap_to_png(&pixmap, &config.png)?
156    } else {
157        pixmap
158            .encode_png()
159            .map_err(|err| Error::Render(format!("png encode: {err}")))?
160    };
161    optimize_png(png, &config.png)
162}
163
164pub fn render_webp_from_svg(svg: &[u8], config: &Config) -> Result<Vec<u8>> {
165    let needs = font_needs_from_svg(svg, config);
166    render_webp_from_svg_with_plan(svg, config, needs.needs_system_fonts)
167}
168
169pub fn render_png_webp_from_svg_once(
170    svg: &[u8],
171    config: &Config,
172    needs_system_fonts: bool,
173) -> Result<(Vec<u8>, Vec<u8>)> {
174    if matches!(config.raster.backend, RasterBackend::Rsvg) {
175        return Err(Error::Render(
176            "rsvg backend does not support webp output".to_string(),
177        ));
178    }
179    let pixmap = rasterize_svg(svg, config, needs_system_fonts)?;
180    let png = if config.png.quantize {
181        quantize_pixmap_to_png(&pixmap, &config.png)?
182    } else {
183        pixmap
184            .encode_png()
185            .map_err(|err| Error::Render(format!("png encode: {err}")))?
186    };
187    let png = optimize_png(png, &config.png)?;
188    let webp = pixmap_to_webp(&pixmap)?;
189    Ok((png, webp))
190}
191
192fn render_webp_from_svg_with_plan(
193    svg: &[u8],
194    config: &Config,
195    needs_system_fonts: bool,
196) -> Result<Vec<u8>> {
197    if matches!(config.raster.backend, RasterBackend::Rsvg) {
198        return Err(Error::Render(
199            "rsvg backend does not support webp output".to_string(),
200        ));
201    }
202    let pixmap = rasterize_svg(svg, config, needs_system_fonts)?;
203    pixmap_to_webp(&pixmap)
204}
205
206#[derive(Debug, Default, Clone, Copy)]
207struct SvgFontNeeds {
208    needs_system_fonts: bool,
209}
210
211fn font_needs_from_svg(svg: &[u8], config: &Config) -> SvgFontNeeds {
212    let mut needs = FontFallbackNeeds::default();
213    let svg_text = std::str::from_utf8(svg).ok();
214    if let Some(text) = svg_text {
215        scan_text_fallbacks(text, &mut needs);
216    }
217    let script_plan = resolve_script_font_plan(config, &needs);
218    let script_plan = match script_plan {
219        Ok(plan) => plan,
220        Err(err) => {
221            eprintln!("cryosnap: font plan failed: {}", err);
222            ScriptFontPlan::default()
223        }
224    };
225    let _ = ensure_fonts_available(config, &needs, &script_plan);
226    let app_families = load_app_font_families(config).unwrap_or_default();
227    let families = build_font_families(config, &needs, &script_plan.families);
228    let mut needs_system_fonts = needs_system_fonts(config, &app_families, &families);
229    if svg_text.is_none() && matches!(config.font.system_fallback, FontSystemFallback::Auto) {
230        needs_system_fonts = true;
231    }
232
233    SvgFontNeeds { needs_system_fonts }
234}
235
236fn try_render_png_with_rsvg(svg: &[u8], config: &Config) -> Result<Option<Vec<u8>>> {
237    let backend = config.raster.backend;
238    if matches!(backend, RasterBackend::Resvg) {
239        return Ok(None);
240    }
241    let Some(bin) = RSVG_CONVERT.as_ref().cloned() else {
242        if matches!(backend, RasterBackend::Rsvg) {
243            return Err(Error::Render("rsvg-convert not found in PATH".to_string()));
244        }
245        return Ok(None);
246    };
247    match rsvg_convert_png(svg, config, &bin) {
248        Ok(png) => Ok(Some(png)),
249        Err(err) => {
250            if matches!(backend, RasterBackend::Rsvg) {
251                Err(err)
252            } else {
253                Ok(None)
254            }
255        }
256    }
257}
258
259static RSVG_CONVERT: Lazy<Option<PathBuf>> = Lazy::new(find_rsvg_convert);
260
261fn find_rsvg_convert() -> Option<PathBuf> {
262    let names: &[&str] = if cfg!(windows) {
263        &["rsvg-convert.exe", "rsvg-convert"]
264    } else {
265        &["rsvg-convert"]
266    };
267    let path = env::var_os("PATH")?;
268    for dir in env::split_paths(&path) {
269        for name in names {
270            let candidate = dir.join(name);
271            if candidate.is_file() {
272                return Some(candidate);
273            }
274        }
275    }
276    None
277}
278
279fn rsvg_convert_png(svg: &[u8], config: &Config, bin: &Path) -> Result<Vec<u8>> {
280    let (width, height) = svg_dimensions(svg)?;
281    let scale = raster_scale(config, width, height)?;
282
283    let mut cmd = Command::new(bin);
284    cmd.arg("--format").arg("png");
285    if (scale - 1.0).abs() > f32::EPSILON {
286        cmd.arg("--zoom").arg(format!("{scale:.6}"));
287    }
288    cmd.arg("-");
289    let mut child = cmd
290        .stdin(Stdio::piped())
291        .stdout(Stdio::piped())
292        .stderr(Stdio::piped())
293        .spawn()
294        .map_err(|err| Error::Render(format!("rsvg-convert spawn: {err}")))?;
295
296    if let Some(stdin) = child.stdin.as_mut() {
297        stdin
298            .write_all(svg)
299            .map_err(|err| Error::Render(format!("rsvg-convert stdin: {err}")))?;
300    } else {
301        return Err(Error::Render("rsvg-convert stdin unavailable".to_string()));
302    }
303
304    let output = child
305        .wait_with_output()
306        .map_err(|err| Error::Render(format!("rsvg-convert wait: {err}")))?;
307    if !output.status.success() {
308        let stderr = String::from_utf8_lossy(&output.stderr);
309        let message = stderr.trim();
310        if message.is_empty() {
311            return Err(Error::Render("rsvg-convert failed".to_string()));
312        }
313        return Err(Error::Render(format!("rsvg-convert failed: {message}")));
314    }
315    if output.stdout.is_empty() {
316        return Err(Error::Render(
317            "rsvg-convert returned empty output".to_string(),
318        ));
319    }
320    Ok(output.stdout)
321}
322
323fn svg_dimensions(svg: &[u8]) -> Result<(u32, u32)> {
324    let opt = usvg::Options::default();
325    let tree = usvg::Tree::from_data(svg, &opt)
326        .map_err(|err| Error::Render(format!("usvg parse: {err}")))?;
327    let size = tree.size().to_int_size();
328    Ok((size.width(), size.height()))
329}
330
331fn rasterize_svg(
332    svg: &[u8],
333    config: &Config,
334    needs_system_fonts: bool,
335) -> Result<tiny_skia::Pixmap> {
336    let mut opt = usvg::Options::default();
337    let fontdb = build_fontdb(config, needs_system_fonts)?;
338    *opt.fontdb_mut() = fontdb;
339
340    let tree = usvg::Tree::from_data(svg, &opt)
341        .map_err(|err| Error::Render(format!("usvg parse: {err}")))?;
342    let size = tree.size().to_int_size();
343    let scale = raster_scale(config, size.width(), size.height())?;
344    let width = scale_dimension(size.width(), scale)?;
345    let height = scale_dimension(size.height(), scale)?;
346
347    let mut pixmap = tiny_skia::Pixmap::new(width, height)
348        .ok_or_else(|| Error::Render(format!("invalid pixmap size {width}x{height}")))?;
349    let mut pixmap_mut = pixmap.as_mut();
350    let transform = if (scale - 1.0).abs() < f32::EPSILON {
351        tiny_skia::Transform::identity()
352    } else {
353        tiny_skia::Transform::from_scale(scale, scale)
354    };
355    resvg::render(&tree, transform, &mut pixmap_mut);
356
357    Ok(pixmap)
358}
359
360pub(crate) fn raster_scale(config: &Config, base_width: u32, base_height: u32) -> Result<f32> {
361    let mut scale = if config.width == 0.0 && config.height == 0.0 {
362        config.raster.scale
363    } else {
364        1.0
365    };
366    if !scale.is_finite() || scale <= 0.0 {
367        return Err(Error::Render("invalid raster scale".to_string()));
368    }
369    if config.raster.max_pixels > 0 {
370        let base_pixels = base_width as f64 * base_height as f64;
371        if base_pixels > 0.0 {
372            let max_pixels = config.raster.max_pixels as f64;
373            let requested_pixels = base_pixels * (scale as f64).powi(2);
374            if requested_pixels > max_pixels {
375                let max_scale = (max_pixels / base_pixels).sqrt() as f32;
376                if max_scale.is_finite() && max_scale > 0.0 {
377                    scale = scale.min(max_scale);
378                }
379            }
380        }
381    }
382    Ok(scale)
383}
384
385pub(crate) fn resolve_title_text(input: &InputSource, config: &Config) -> Option<String> {
386    if !config.title.enabled || !config.window_controls {
387        return None;
388    }
389    if let Some(text) = config.title.text.as_ref() {
390        let trimmed = text.trim();
391        if !trimmed.is_empty() {
392            return Some(trimmed.to_string());
393        }
394    }
395    let auto = match input {
396        InputSource::File(path) => title_text_from_path(path, config.title.path_style),
397        InputSource::Command(cmd) => format!("cmd: {}", cmd),
398        InputSource::Text(_) => return None,
399    };
400    let sanitized = sanitize_title_text(&auto);
401    if sanitized.is_empty() {
402        None
403    } else {
404        Some(sanitized)
405    }
406}
407
408pub(crate) fn title_text_from_path(path: &Path, style: TitlePathStyle) -> String {
409    match style {
410        TitlePathStyle::Basename => path
411            .file_name()
412            .map(|name| name.to_string_lossy().to_string())
413            .unwrap_or_else(|| path.to_string_lossy().to_string()),
414        TitlePathStyle::Relative => {
415            let cwd = std::env::current_dir().ok();
416            if let Some(cwd) = cwd {
417                if let Ok(relative) = path.strip_prefix(&cwd) {
418                    return relative.to_string_lossy().to_string();
419                }
420            }
421            path.to_string_lossy().to_string()
422        }
423        TitlePathStyle::Absolute => path
424            .canonicalize()
425            .unwrap_or_else(|_| path.to_path_buf())
426            .to_string_lossy()
427            .to_string(),
428    }
429}
430
431pub(crate) fn sanitize_title_text(text: &str) -> String {
432    text.replace(['\n', '\r'], " ").trim().to_string()
433}