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}