1use base64::Engine;
2use once_cell::sync::Lazy;
3use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5use std::collections::{HashMap, HashSet};
6use std::env;
7use std::fs;
8use std::io::{Cursor, Read, Write};
9use std::path::{Path, PathBuf};
10use std::process::{Command, Stdio};
11use std::sync::{Arc, Mutex};
12use std::time::Duration;
13use syntect::easy::HighlightLines;
14use syntect::highlighting::{Color, FontStyle, ThemeSet};
15use syntect::parsing::SyntaxSet;
16use unicode_script::{Script, UnicodeScript};
17
18const FONT_HEIGHT_TO_WIDTH_RATIO: f32 = 1.68;
19const DEFAULT_TAB_WIDTH: usize = 4;
20const ANSI_TAB_WIDTH: usize = 6;
21const WINDOW_CONTROLS_HEIGHT: f32 = 18.0;
22const WINDOW_CONTROLS_X_OFFSET: f32 = 12.0;
23const WINDOW_CONTROLS_SPACING: f32 = 19.0;
24const DEFAULT_WEBP_QUALITY: f32 = 90.0;
25const DEFAULT_RASTER_SCALE: f32 = 4.0;
26const DEFAULT_RASTER_MAX_PIXELS: u64 = 8_000_000;
27const DEFAULT_PNG_OPT_LEVEL: u8 = 4;
28const MAX_PNG_OPT_LEVEL: u8 = 6;
29const DEFAULT_PNG_QUANTIZE_QUALITY: u8 = 85;
30const DEFAULT_PNG_QUANTIZE_SPEED: u8 = 4;
31const DEFAULT_PNG_QUANTIZE_DITHER: f32 = 1.0;
32const DEFAULT_TITLE_SIZE: f32 = 12.0;
33const DEFAULT_TITLE_OPACITY: f32 = 0.85;
34const DEFAULT_TITLE_MAX_WIDTH: usize = 80;
35const AUTO_FALLBACK_NF: &[&str] = &["Symbols Nerd Font Mono"];
36const AUTO_FALLBACK_CJK: &[&str] = &[
37 "Noto Sans Mono CJK SC",
38 "Noto Sans Mono CJK TC",
39 "Noto Sans Mono CJK HK",
40 "Noto Sans Mono CJK JP",
41 "Noto Sans Mono CJK KR",
42 "Noto Sans CJK SC",
43 "Noto Sans CJK TC",
44 "Noto Sans CJK HK",
45 "Noto Sans CJK JP",
46 "Noto Sans CJK KR",
47 "Source Han Sans SC",
48 "Source Han Sans TC",
49 "Source Han Sans HK",
50 "Source Han Sans JP",
51 "Source Han Sans KR",
52 "PingFang SC",
53 "PingFang TC",
54 "PingFang HK",
55 "Hiragino Sans GB",
56 "Hiragino Sans",
57 "Apple SD Gothic Neo",
58 "Microsoft YaHei",
59 "Microsoft JhengHei",
60 "SimSun",
61 "MS Gothic",
62 "Meiryo",
63 "Yu Gothic",
64 "Malgun Gothic",
65 "WenQuanYi Micro Hei",
66 "WenQuanYi Zen Hei",
67];
68const AUTO_FALLBACK_CJK_SC: &[&str] = &[
69 "Noto Sans Mono CJK SC",
70 "Noto Sans CJK SC",
71 "Source Han Sans SC",
72 "PingFang SC",
73 "Microsoft YaHei",
74 "SimSun",
75 "WenQuanYi Micro Hei",
76 "WenQuanYi Zen Hei",
77];
78const AUTO_FALLBACK_CJK_TC: &[&str] = &[
79 "Noto Sans Mono CJK TC",
80 "Noto Sans CJK TC",
81 "Source Han Sans TC",
82 "PingFang TC",
83 "Microsoft JhengHei",
84];
85const AUTO_FALLBACK_CJK_HK: &[&str] = &[
86 "Noto Sans Mono CJK HK",
87 "Noto Sans CJK HK",
88 "Source Han Sans HK",
89 "PingFang HK",
90 "Microsoft JhengHei",
91];
92const AUTO_FALLBACK_CJK_JP: &[&str] = &[
93 "Noto Sans Mono CJK JP",
94 "Noto Sans CJK JP",
95 "Source Han Sans JP",
96 "Hiragino Sans",
97 "Yu Gothic",
98 "MS Gothic",
99 "Meiryo",
100];
101const AUTO_FALLBACK_CJK_KR: &[&str] = &[
102 "Noto Sans Mono CJK KR",
103 "Noto Sans CJK KR",
104 "Source Han Sans KR",
105 "Apple SD Gothic Neo",
106 "Malgun Gothic",
107];
108const AUTO_FALLBACK_GLOBAL: &[&str] = &[
109 "Noto Sans",
110 "Noto Sans Mono",
111 "Segoe UI",
112 "Arial Unicode MS",
113];
114const AUTO_FALLBACK_EMOJI: &[&str] = &["Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji"];
115const NOTOFONTS_STATE_URL: &str =
116 "https://raw.githubusercontent.com/notofonts/notofonts.github.io/main/state.json";
117const NOTOFONTS_FILES_REPO: &str = "notofonts/notofonts.github.io";
118const NOTO_EMOJI_URLS: &[&str] = &[
119 "https://raw.githubusercontent.com/googlefonts/noto-emoji/main/fonts/NotoColorEmoji.ttf",
120 "https://raw.githubusercontent.com/notofonts/noto-emoji/main/fonts/NotoColorEmoji.ttf",
121];
122const NOTO_CJK_SC_URLS: &[&str] = &[
123 "https://raw.githubusercontent.com/notofonts/noto-cjk/main/Sans/OTF/SimplifiedChinese/NotoSansCJKsc-Regular.otf",
124 "https://raw.githubusercontent.com/googlefonts/noto-cjk/main/Sans/OTF/SimplifiedChinese/NotoSansCJKsc-Regular.otf",
125];
126const NOTO_CJK_TC_URLS: &[&str] = &[
127 "https://raw.githubusercontent.com/notofonts/noto-cjk/main/Sans/OTF/TraditionalChinese/NotoSansCJKtc-Regular.otf",
128 "https://raw.githubusercontent.com/googlefonts/noto-cjk/main/Sans/OTF/TraditionalChinese/NotoSansCJKtc-Regular.otf",
129];
130const NOTO_CJK_HK_URLS: &[&str] = &[
131 "https://raw.githubusercontent.com/notofonts/noto-cjk/main/Sans/OTF/HongKong/NotoSansCJKhk-Regular.otf",
132 "https://raw.githubusercontent.com/googlefonts/noto-cjk/main/Sans/OTF/HongKong/NotoSansCJKhk-Regular.otf",
133];
134const NOTO_CJK_JP_URLS: &[&str] = &[
135 "https://raw.githubusercontent.com/notofonts/noto-cjk/main/Sans/OTF/Japanese/NotoSansCJKjp-Regular.otf",
136 "https://raw.githubusercontent.com/googlefonts/noto-cjk/main/Sans/OTF/Japanese/NotoSansCJKjp-Regular.otf",
137];
138const NOTO_CJK_KR_URLS: &[&str] = &[
139 "https://raw.githubusercontent.com/notofonts/noto-cjk/main/Sans/OTF/Korean/NotoSansCJKkr-Regular.otf",
140 "https://raw.githubusercontent.com/googlefonts/noto-cjk/main/Sans/OTF/Korean/NotoSansCJKkr-Regular.otf",
141];
142const DEFAULT_GITHUB_PROXIES: &[&str] = &["https://fastgit.cc/", "https://ghfast.top/"];
143
144#[derive(Debug, Clone, Serialize, Deserialize)]
145#[serde(default)]
146pub struct Config {
147 pub theme: String,
148 pub background: String,
149 #[serde(deserialize_with = "deserialize_box")]
150 pub padding: Vec<f32>,
151 #[serde(deserialize_with = "deserialize_box")]
152 pub margin: Vec<f32>,
153 pub width: f32,
154 pub height: f32,
155 #[serde(rename = "window")]
156 pub window_controls: bool,
157 #[serde(rename = "show_line_numbers")]
158 pub show_line_numbers: bool,
159 pub language: Option<String>,
160 pub execute_timeout_ms: u64,
161 pub wrap: usize,
162 #[serde(deserialize_with = "deserialize_lines")]
163 pub lines: Vec<i32>,
164 pub border: Border,
165 pub shadow: Shadow,
166 pub font: Font,
167 #[serde(rename = "line_height")]
168 pub line_height: f32,
169 pub raster: RasterOptions,
170 pub png: PngOptions,
171 pub title: TitleOptions,
172}
173
174impl Default for Config {
175 fn default() -> Self {
176 Self {
177 theme: "charm".to_string(),
178 background: "#171717".to_string(),
179 padding: vec![20.0, 40.0, 20.0, 20.0],
180 margin: vec![0.0],
181 width: 0.0,
182 height: 0.0,
183 window_controls: false,
184 show_line_numbers: false,
185 language: None,
186 execute_timeout_ms: 10_000,
187 wrap: 0,
188 lines: vec![0, -1],
189 border: Border::default(),
190 shadow: Shadow::default(),
191 font: Font::default(),
192 line_height: 1.2,
193 raster: RasterOptions::default(),
194 png: PngOptions::default(),
195 title: TitleOptions::default(),
196 }
197 }
198}
199
200#[derive(Debug, Clone, Serialize, Deserialize)]
201#[serde(default)]
202pub struct Border {
203 pub radius: f32,
204 pub width: f32,
205 pub color: String,
206}
207
208impl Default for Border {
209 fn default() -> Self {
210 Self {
211 radius: 0.0,
212 width: 0.0,
213 color: "#515151".to_string(),
214 }
215 }
216}
217
218#[derive(Debug, Clone, Serialize, Deserialize)]
219#[serde(default)]
220pub struct Shadow {
221 pub blur: f32,
222 pub x: f32,
223 pub y: f32,
224}
225
226impl Default for Shadow {
227 fn default() -> Self {
228 Self {
229 blur: 0.0,
230 x: 0.0,
231 y: 0.0,
232 }
233 }
234}
235
236#[derive(Debug, Clone, Serialize, Deserialize)]
237#[serde(default)]
238pub struct Font {
239 pub family: String,
240 pub file: Option<String>,
241 pub size: f32,
242 pub ligatures: bool,
243 pub fallbacks: Vec<String>,
244 #[serde(rename = "system_fallback")]
245 pub system_fallback: FontSystemFallback,
246 #[serde(rename = "auto_download")]
247 pub auto_download: bool,
248 #[serde(rename = "force_update")]
249 pub force_update: bool,
250 #[serde(rename = "cjk_region")]
251 pub cjk_region: CjkRegion,
252 #[serde(rename = "dirs")]
253 pub dirs: Vec<String>,
254}
255
256impl Default for Font {
257 fn default() -> Self {
258 Self {
259 family: "monospace".to_string(),
260 file: None,
261 size: 14.0,
262 ligatures: true,
263 fallbacks: Vec::new(),
264 system_fallback: FontSystemFallback::default(),
265 auto_download: true,
266 force_update: false,
267 cjk_region: CjkRegion::default(),
268 dirs: Vec::new(),
269 }
270 }
271}
272
273#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
274#[serde(rename_all = "lowercase")]
275pub enum FontSystemFallback {
276 #[default]
277 Auto,
278 Always,
279 Never,
280}
281
282#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq, Hash)]
283#[serde(rename_all = "lowercase")]
284pub enum CjkRegion {
285 #[default]
286 Auto,
287 Sc,
288 Tc,
289 Hk,
290 Jp,
291 Kr,
292}
293
294#[derive(Debug, Clone, Serialize, Deserialize)]
295#[serde(default)]
296pub struct RasterOptions {
297 pub scale: f32,
298 pub max_pixels: u64,
299 pub backend: RasterBackend,
300}
301
302impl Default for RasterOptions {
303 fn default() -> Self {
304 Self {
305 scale: DEFAULT_RASTER_SCALE,
306 max_pixels: DEFAULT_RASTER_MAX_PIXELS,
307 backend: RasterBackend::default(),
308 }
309 }
310}
311
312#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
313#[serde(rename_all = "lowercase")]
314pub enum RasterBackend {
315 #[default]
316 Auto,
317 Resvg,
318 Rsvg,
319}
320
321#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
322#[serde(rename_all = "lowercase")]
323pub enum TitleAlign {
324 Left,
325 #[default]
326 Center,
327 Right,
328}
329
330#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
331#[serde(rename_all = "lowercase")]
332pub enum TitlePathStyle {
333 #[default]
334 Absolute,
335 Relative,
336 Basename,
337}
338
339#[derive(Debug, Clone, Serialize, Deserialize)]
340#[serde(default)]
341pub struct TitleOptions {
342 pub enabled: bool,
343 pub text: Option<String>,
344 pub path_style: TitlePathStyle,
345 pub tmux_format: String,
346 pub align: TitleAlign,
347 pub size: f32,
348 pub color: String,
349 pub opacity: f32,
350 pub max_width: usize,
351 pub ellipsis: String,
352}
353
354impl Default for TitleOptions {
355 fn default() -> Self {
356 Self {
357 enabled: true,
358 text: None,
359 path_style: TitlePathStyle::Absolute,
360 tmux_format: "#{session_name}:#{window_index}.#{pane_index} #{pane_title}".to_string(),
361 align: TitleAlign::Center,
362 size: DEFAULT_TITLE_SIZE,
363 color: "#C5C8C6".to_string(),
364 opacity: DEFAULT_TITLE_OPACITY,
365 max_width: DEFAULT_TITLE_MAX_WIDTH,
366 ellipsis: "…".to_string(),
367 }
368 }
369}
370
371#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
372#[serde(rename_all = "lowercase")]
373pub enum PngStrip {
374 None,
375 #[default]
376 Safe,
377 All,
378}
379
380#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
381#[serde(rename_all = "lowercase")]
382pub enum PngQuantPreset {
383 Fast,
384 Balanced,
385 Best,
386}
387
388#[derive(Debug, Clone, Serialize, Deserialize)]
389#[serde(default)]
390pub struct PngOptions {
391 pub optimize: bool,
392 pub level: u8,
393 pub strip: PngStrip,
394 pub quantize: bool,
395 pub quantize_preset: Option<PngQuantPreset>,
396 pub quantize_quality: u8,
397 pub quantize_speed: u8,
398 pub quantize_dither: f32,
399}
400
401impl Default for PngOptions {
402 fn default() -> Self {
403 Self {
404 optimize: true,
405 level: DEFAULT_PNG_OPT_LEVEL,
406 strip: PngStrip::Safe,
407 quantize: false,
408 quantize_preset: None,
409 quantize_quality: DEFAULT_PNG_QUANTIZE_QUALITY,
410 quantize_speed: DEFAULT_PNG_QUANTIZE_SPEED,
411 quantize_dither: DEFAULT_PNG_QUANTIZE_DITHER,
412 }
413 }
414}
415
416#[derive(Debug, Clone)]
417pub enum InputSource {
418 Text(String),
419 File(PathBuf),
420 Command(String),
421}
422
423#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
424#[serde(rename_all = "lowercase")]
425pub enum OutputFormat {
426 Svg,
427 Png,
428 Webp,
429}
430
431#[derive(Debug, Clone)]
432pub struct RenderRequest {
433 pub input: InputSource,
434 pub config: Config,
435 pub format: OutputFormat,
436}
437
438#[derive(Debug, Clone)]
439pub struct RenderResult {
440 pub format: OutputFormat,
441 pub bytes: Vec<u8>,
442}
443
444#[derive(thiserror::Error, Debug)]
445pub enum Error {
446 #[error("not implemented: {0}")]
447 NotImplemented(&'static str),
448 #[error("invalid input: {0}")]
449 InvalidInput(String),
450 #[error("io error: {0}")]
451 Io(#[from] std::io::Error),
452 #[error("render error: {0}")]
453 Render(String),
454 #[error("execution timeout")]
455 Timeout,
456}
457
458pub type Result<T> = std::result::Result<T, Error>;
459
460pub fn render(request: &RenderRequest) -> Result<RenderResult> {
461 let bytes = match request.format {
462 OutputFormat::Svg => render_svg(&request.input, &request.config)?,
463 OutputFormat::Png => render_png(&request.input, &request.config)?,
464 OutputFormat::Webp => render_webp(&request.input, &request.config)?,
465 };
466 Ok(RenderResult {
467 format: request.format,
468 bytes,
469 })
470}
471
472pub fn render_svg(input: &InputSource, config: &Config) -> Result<Vec<u8>> {
473 Ok(render_svg_with_plan(input, config)?.bytes)
474}
475
476struct RenderedSvg {
477 bytes: Vec<u8>,
478 font_plan: FontPlan,
479}
480
481fn render_svg_with_plan(input: &InputSource, config: &Config) -> Result<RenderedSvg> {
482 let loaded = load_input(input, Duration::from_millis(config.execute_timeout_ms))?;
483 let is_ansi = is_ansi_input(&loaded, config);
484 let line_window = &config.lines;
485
486 let (lines, default_fg, line_offset) = if is_ansi {
487 let cut = cut_text(&loaded.text, line_window);
488 let mut lines = parse_ansi(&cut.text);
489 if config.wrap > 0 {
490 lines = wrap_ansi_lines(&lines, config.wrap);
491 }
492 (lines, "#C5C8C6".to_string(), cut.start)
493 } else {
494 let mut text = detab(&loaded.text, DEFAULT_TAB_WIDTH);
495 let cut = cut_text(&text, line_window);
496 text = cut.text;
497 if config.wrap > 0 {
498 text = wrap_text(&text, config.wrap);
499 }
500 let (lines, default_fg) = highlight_code(
501 &text,
502 loaded.path.as_deref(),
503 config.language.as_deref(),
504 &config.theme,
505 )?;
506 (lines, default_fg, cut.start)
507 };
508
509 let title_text = resolve_title_text(input, config);
510 let needs = collect_font_fallback_needs(&lines, title_text.as_deref());
511 let script_plan = resolve_script_font_plan(config, &needs);
512 let script_plan = match script_plan {
513 Ok(plan) => plan,
514 Err(err) => {
515 eprintln!("cryosnap: font plan failed: {}", err);
516 ScriptFontPlan::default()
517 }
518 };
519 let _ = ensure_fonts_available(config, &needs, &script_plan);
520 let app_families = load_app_font_families(config).unwrap_or_default();
521 let font_plan = build_font_plan(config, &needs, &app_families, &script_plan.families);
522 let font_css = svg_font_face_css(config)?;
523 let svg = build_svg(
524 &lines,
525 config,
526 &default_fg,
527 font_css,
528 line_offset,
529 title_text.as_deref(),
530 &font_plan.font_family,
531 );
532 Ok(RenderedSvg {
533 bytes: svg.into_bytes(),
534 font_plan,
535 })
536}
537
538pub fn render_png(input: &InputSource, config: &Config) -> Result<Vec<u8>> {
539 let rendered = render_svg_with_plan(input, config)?;
540 render_png_from_svg_with_plan(
541 &rendered.bytes,
542 config,
543 rendered.font_plan.needs_system_fonts,
544 )
545}
546
547pub fn render_webp(input: &InputSource, config: &Config) -> Result<Vec<u8>> {
548 let rendered = render_svg_with_plan(input, config)?;
549 render_webp_from_svg_with_plan(
550 &rendered.bytes,
551 config,
552 rendered.font_plan.needs_system_fonts,
553 )
554}
555
556pub fn render_png_from_svg(svg: &[u8], config: &Config) -> Result<Vec<u8>> {
557 let needs = font_needs_from_svg(svg, config);
558 render_png_from_svg_with_plan(svg, config, needs.needs_system_fonts)
559}
560
561fn render_png_from_svg_with_plan(
562 svg: &[u8],
563 config: &Config,
564 needs_system_fonts: bool,
565) -> Result<Vec<u8>> {
566 if let Some(png) = try_render_png_with_rsvg(svg, config)? {
567 let png = if config.png.quantize {
568 quantize_png_bytes(&png, &config.png)?
569 } else {
570 png
571 };
572 return optimize_png(png, &config.png);
573 }
574
575 let pixmap = rasterize_svg(svg, config, needs_system_fonts)?;
576 let png = if config.png.quantize {
577 quantize_pixmap_to_png(&pixmap, &config.png)?
578 } else {
579 pixmap
580 .encode_png()
581 .map_err(|err| Error::Render(format!("png encode: {err}")))?
582 };
583 optimize_png(png, &config.png)
584}
585
586pub fn render_webp_from_svg(svg: &[u8], config: &Config) -> Result<Vec<u8>> {
587 let needs = font_needs_from_svg(svg, config);
588 render_webp_from_svg_with_plan(svg, config, needs.needs_system_fonts)
589}
590
591fn render_webp_from_svg_with_plan(
592 svg: &[u8],
593 config: &Config,
594 needs_system_fonts: bool,
595) -> Result<Vec<u8>> {
596 if matches!(config.raster.backend, RasterBackend::Rsvg) {
597 return Err(Error::Render(
598 "rsvg backend does not support webp output".to_string(),
599 ));
600 }
601 let pixmap = rasterize_svg(svg, config, needs_system_fonts)?;
602 pixmap_to_webp(&pixmap)
603}
604
605#[derive(Debug, Default, Clone, Copy)]
606struct SvgFontNeeds {
607 needs_system_fonts: bool,
608}
609
610fn font_needs_from_svg(svg: &[u8], config: &Config) -> SvgFontNeeds {
611 let mut needs = FontFallbackNeeds::default();
612 let svg_text = std::str::from_utf8(svg).ok();
613 if let Some(text) = svg_text {
614 scan_text_fallbacks(text, &mut needs);
615 }
616 let script_plan = resolve_script_font_plan(config, &needs);
617 let script_plan = match script_plan {
618 Ok(plan) => plan,
619 Err(err) => {
620 eprintln!("cryosnap: font plan failed: {}", err);
621 ScriptFontPlan::default()
622 }
623 };
624 let _ = ensure_fonts_available(config, &needs, &script_plan);
625 let app_families = load_app_font_families(config).unwrap_or_default();
626 let families = build_font_families(config, &needs, &script_plan.families);
627 let mut needs_system_fonts = needs_system_fonts(config, &app_families, &families);
628 if svg_text.is_none() && matches!(config.font.system_fallback, FontSystemFallback::Auto) {
629 needs_system_fonts = true;
630 }
631
632 SvgFontNeeds { needs_system_fonts }
633}
634
635fn try_render_png_with_rsvg(svg: &[u8], config: &Config) -> Result<Option<Vec<u8>>> {
636 let backend = config.raster.backend;
637 if matches!(backend, RasterBackend::Resvg) {
638 return Ok(None);
639 }
640 let Some(bin) = RSVG_CONVERT.as_ref().cloned() else {
641 if matches!(backend, RasterBackend::Rsvg) {
642 return Err(Error::Render("rsvg-convert not found in PATH".to_string()));
643 }
644 return Ok(None);
645 };
646 match rsvg_convert_png(svg, config, &bin) {
647 Ok(png) => Ok(Some(png)),
648 Err(err) => {
649 if matches!(backend, RasterBackend::Rsvg) {
650 Err(err)
651 } else {
652 Ok(None)
653 }
654 }
655 }
656}
657
658static RSVG_CONVERT: Lazy<Option<PathBuf>> = Lazy::new(find_rsvg_convert);
659
660fn find_rsvg_convert() -> Option<PathBuf> {
661 let names: &[&str] = if cfg!(windows) {
662 &["rsvg-convert.exe", "rsvg-convert"]
663 } else {
664 &["rsvg-convert"]
665 };
666 let path = env::var_os("PATH")?;
667 for dir in env::split_paths(&path) {
668 for name in names {
669 let candidate = dir.join(name);
670 if candidate.is_file() {
671 return Some(candidate);
672 }
673 }
674 }
675 None
676}
677
678fn rsvg_convert_png(svg: &[u8], config: &Config, bin: &Path) -> Result<Vec<u8>> {
679 let (width, height) = svg_dimensions(svg)?;
680 let scale = raster_scale(config, width, height)?;
681
682 let mut cmd = Command::new(bin);
683 cmd.arg("--format").arg("png");
684 if (scale - 1.0).abs() > f32::EPSILON {
685 cmd.arg("--zoom").arg(format!("{scale:.6}"));
686 }
687 cmd.arg("-");
688 let mut child = cmd
689 .stdin(Stdio::piped())
690 .stdout(Stdio::piped())
691 .stderr(Stdio::piped())
692 .spawn()
693 .map_err(|err| Error::Render(format!("rsvg-convert spawn: {err}")))?;
694
695 if let Some(stdin) = child.stdin.as_mut() {
696 stdin
697 .write_all(svg)
698 .map_err(|err| Error::Render(format!("rsvg-convert stdin: {err}")))?;
699 } else {
700 return Err(Error::Render("rsvg-convert stdin unavailable".to_string()));
701 }
702
703 let output = child
704 .wait_with_output()
705 .map_err(|err| Error::Render(format!("rsvg-convert wait: {err}")))?;
706 if !output.status.success() {
707 let stderr = String::from_utf8_lossy(&output.stderr);
708 let message = stderr.trim();
709 if message.is_empty() {
710 return Err(Error::Render("rsvg-convert failed".to_string()));
711 }
712 return Err(Error::Render(format!("rsvg-convert failed: {message}")));
713 }
714 if output.stdout.is_empty() {
715 return Err(Error::Render(
716 "rsvg-convert returned empty output".to_string(),
717 ));
718 }
719 Ok(output.stdout)
720}
721
722fn svg_dimensions(svg: &[u8]) -> Result<(u32, u32)> {
723 let opt = usvg::Options::default();
724 let tree = usvg::Tree::from_data(svg, &opt)
725 .map_err(|err| Error::Render(format!("usvg parse: {err}")))?;
726 let size = tree.size().to_int_size();
727 Ok((size.width(), size.height()))
728}
729
730fn rasterize_svg(
731 svg: &[u8],
732 config: &Config,
733 needs_system_fonts: bool,
734) -> Result<tiny_skia::Pixmap> {
735 let mut opt = usvg::Options::default();
736 let fontdb = build_fontdb(config, needs_system_fonts)?;
737 *opt.fontdb_mut() = fontdb;
738
739 let tree = usvg::Tree::from_data(svg, &opt)
740 .map_err(|err| Error::Render(format!("usvg parse: {err}")))?;
741 let size = tree.size().to_int_size();
742 let scale = raster_scale(config, size.width(), size.height())?;
743 let width = scale_dimension(size.width(), scale)?;
744 let height = scale_dimension(size.height(), scale)?;
745
746 let mut pixmap = tiny_skia::Pixmap::new(width, height)
747 .ok_or_else(|| Error::Render(format!("invalid pixmap size {width}x{height}")))?;
748 let mut pixmap_mut = pixmap.as_mut();
749 let transform = if (scale - 1.0).abs() < f32::EPSILON {
750 tiny_skia::Transform::identity()
751 } else {
752 tiny_skia::Transform::from_scale(scale, scale)
753 };
754 resvg::render(&tree, transform, &mut pixmap_mut);
755
756 Ok(pixmap)
757}
758
759fn raster_scale(config: &Config, base_width: u32, base_height: u32) -> Result<f32> {
760 let mut scale = if config.width == 0.0 && config.height == 0.0 {
761 config.raster.scale
762 } else {
763 1.0
764 };
765 if !scale.is_finite() || scale <= 0.0 {
766 return Err(Error::Render("invalid raster scale".to_string()));
767 }
768 if config.raster.max_pixels > 0 {
769 let base_pixels = base_width as f64 * base_height as f64;
770 if base_pixels > 0.0 {
771 let max_pixels = config.raster.max_pixels as f64;
772 let requested_pixels = base_pixels * (scale as f64).powi(2);
773 if requested_pixels > max_pixels {
774 let max_scale = (max_pixels / base_pixels).sqrt() as f32;
775 if max_scale.is_finite() && max_scale > 0.0 {
776 scale = scale.min(max_scale);
777 }
778 }
779 }
780 }
781 Ok(scale)
782}
783
784fn scale_dimension(value: u32, scale: f32) -> Result<u32> {
785 let scaled = (value as f32 * scale).round();
786 if !scaled.is_finite() || scaled <= 0.0 {
787 return Err(Error::Render("invalid raster scale".to_string()));
788 }
789 if scaled > u32::MAX as f32 {
790 return Err(Error::Render("raster size overflow".to_string()));
791 }
792 Ok(scaled as u32)
793}
794
795fn resolve_title_text(input: &InputSource, config: &Config) -> Option<String> {
796 if !config.title.enabled || !config.window_controls {
797 return None;
798 }
799 if let Some(text) = config.title.text.as_ref() {
800 let trimmed = text.trim();
801 if !trimmed.is_empty() {
802 return Some(trimmed.to_string());
803 }
804 }
805 let auto = match input {
806 InputSource::File(path) => title_text_from_path(path, config.title.path_style),
807 InputSource::Command(cmd) => format!("cmd: {}", cmd),
808 InputSource::Text(_) => return None,
809 };
810 let sanitized = sanitize_title_text(&auto);
811 if sanitized.is_empty() {
812 None
813 } else {
814 Some(sanitized)
815 }
816}
817
818fn title_text_from_path(path: &Path, style: TitlePathStyle) -> String {
819 match style {
820 TitlePathStyle::Basename => path
821 .file_name()
822 .map(|name| name.to_string_lossy().to_string())
823 .unwrap_or_else(|| path.to_string_lossy().to_string()),
824 TitlePathStyle::Relative => {
825 let cwd = std::env::current_dir().ok();
826 if let Some(cwd) = cwd {
827 if let Ok(relative) = path.strip_prefix(&cwd) {
828 return relative.to_string_lossy().to_string();
829 }
830 }
831 path.to_string_lossy().to_string()
832 }
833 TitlePathStyle::Absolute => path
834 .canonicalize()
835 .unwrap_or_else(|_| path.to_path_buf())
836 .to_string_lossy()
837 .to_string(),
838 }
839}
840
841fn sanitize_title_text(text: &str) -> String {
842 text.replace(['\n', '\r'], " ").trim().to_string()
843}
844
845fn text_width_cells(text: &str) -> usize {
846 let mut width = 0usize;
847 for ch in text.chars() {
848 if ch == '\t' {
849 width += DEFAULT_TAB_WIDTH;
850 } else {
851 width += unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
852 }
853 }
854 width
855}
856
857fn truncate_to_cells(text: &str, max_cells: usize, ellipsis: &str) -> String {
858 if max_cells == 0 {
859 return String::new();
860 }
861 let width = text_width_cells(text);
862 if width <= max_cells {
863 return text.to_string();
864 }
865 let ellipsis_width = text_width_cells(ellipsis);
866 if ellipsis_width >= max_cells {
867 return ellipsis.chars().take(1).collect();
868 }
869 let mut out = String::new();
870 let mut current = 0usize;
871 for ch in text.chars() {
872 let w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
873 if current + w > max_cells - ellipsis_width {
874 break;
875 }
876 out.push(ch);
877 current += w;
878 }
879 out.push_str(ellipsis);
880 out
881}
882
883#[derive(Debug, Default, Clone)]
884struct FontFallbackNeeds {
885 needs_unicode: bool,
886 needs_nf: bool,
887 needs_cjk: bool,
888 needs_emoji: bool,
889 scripts: HashSet<Script>,
890}
891
892#[derive(Debug, Clone)]
893struct FontPlan {
894 font_family: String,
895 needs_system_fonts: bool,
896}
897
898#[derive(Debug, Default, Clone)]
899struct ScriptFontPlan {
900 families: Vec<String>,
901 downloads: Vec<ScriptDownload>,
902}
903
904#[derive(Debug, Clone)]
905struct ScriptDownload {
906 family: String,
907 repo: String,
908 file_path: String,
909 filename: String,
910 tag: Option<String>,
911}
912
913#[derive(Debug, Clone, Copy)]
914enum FontStylePreference {
915 Sans,
916 Serif,
917}
918
919fn is_private_use(ch: char) -> bool {
920 let cp = ch as u32;
921 (0xE000..=0xF8FF).contains(&cp)
922 || (0xF0000..=0xFFFFD).contains(&cp)
923 || (0x100000..=0x10FFFD).contains(&cp)
924}
925
926fn is_cjk(ch: char) -> bool {
927 let cp = ch as u32;
928 matches!(
929 cp,
930 0x4E00..=0x9FFF
931 | 0x3400..=0x4DBF
932 | 0x20000..=0x2A6DF
933 | 0x2A700..=0x2B73F
934 | 0x2B740..=0x2B81F
935 | 0x2B820..=0x2CEAF
936 | 0x2CEB0..=0x2EBEF
937 | 0x2F800..=0x2FA1F
938 | 0x3040..=0x309F
939 | 0x30A0..=0x30FF
940 | 0x31F0..=0x31FF
941 | 0x1100..=0x11FF
942 | 0x3130..=0x318F
943 | 0xAC00..=0xD7AF
944 | 0x3100..=0x312F
945 | 0x31A0..=0x31BF
946 )
947}
948
949fn is_emoji(ch: char) -> bool {
950 let cp = ch as u32;
951 matches!(
952 cp,
953 0x2300..=0x23FF
954 | 0x2600..=0x27BF
955 | 0x2B00..=0x2BFF
956 | 0x1F000..=0x1FAFF
957 )
958}
959
960fn scan_text_fallbacks(text: &str, needs: &mut FontFallbackNeeds) {
961 for ch in text.chars() {
962 if ch > '\u{7f}' {
963 needs.needs_unicode = true;
964 }
965 if is_private_use(ch) {
966 needs.needs_nf = true;
967 }
968 if is_cjk(ch) {
969 needs.needs_cjk = true;
970 }
971 if is_emoji(ch) {
972 needs.needs_emoji = true;
973 }
974 if ch > '\u{7f}' {
975 let script = ch.script();
976 if !matches!(script, Script::Common | Script::Inherited | Script::Unknown) {
977 needs.scripts.insert(script);
978 }
979 }
980 }
981}
982
983fn collect_font_fallback_needs(lines: &[Line], title_text: Option<&str>) -> FontFallbackNeeds {
984 let mut needs = FontFallbackNeeds::default();
985 for line in lines {
986 for span in &line.spans {
987 scan_text_fallbacks(&span.text, &mut needs);
988 }
989 }
990 if let Some(title) = title_text {
991 scan_text_fallbacks(title, &mut needs);
992 }
993 needs
994}
995
996fn push_family(out: &mut Vec<String>, seen: &mut HashSet<String>, name: &str) {
997 let trimmed = name.trim();
998 if trimmed.is_empty() {
999 return;
1000 }
1001 let key = trimmed.to_ascii_lowercase();
1002 if seen.insert(key) {
1003 out.push(trimmed.to_string());
1004 }
1005}
1006
1007fn family_key(name: &str) -> String {
1008 name.trim().to_ascii_lowercase()
1009}
1010
1011fn is_generic_family(name: &str) -> bool {
1012 matches!(
1013 name.trim().to_ascii_lowercase().as_str(),
1014 "serif" | "sans-serif" | "sans" | "monospace" | "cursive" | "fantasy"
1015 )
1016}
1017
1018fn fallback_style_preference(config: &Config) -> FontStylePreference {
1019 let family = config.font.family.trim().to_ascii_lowercase();
1020 if matches!(family.as_str(), "serif") || family.contains("serif") {
1021 FontStylePreference::Serif
1022 } else {
1023 FontStylePreference::Sans
1024 }
1025}
1026
1027fn normalize_repo_key(value: &str) -> String {
1028 value
1029 .chars()
1030 .filter(|c| c.is_ascii_alphanumeric())
1031 .map(|c| c.to_ascii_lowercase())
1032 .collect()
1033}
1034
1035fn build_repo_key_index(state: &NotofontsState) -> HashMap<String, String> {
1036 let mut index = HashMap::new();
1037 for key in state.0.keys() {
1038 index.insert(normalize_repo_key(key), key.clone());
1039 }
1040 index
1041}
1042
1043fn is_cjk_script(script: Script) -> bool {
1044 matches!(
1045 script,
1046 Script::Han | Script::Hiragana | Script::Katakana | Script::Hangul | Script::Bopomofo
1047 )
1048}
1049
1050fn script_repo_key(script: Script, index: &HashMap<String, String>) -> Option<String> {
1051 match script {
1052 Script::Common | Script::Inherited | Script::Unknown => return None,
1053 Script::Latin | Script::Greek | Script::Cyrillic => {
1054 return Some("latin-greek-cyrillic".to_string())
1055 }
1056 _ => {}
1057 }
1058 if is_cjk_script(script) {
1059 return None;
1060 }
1061 let name = script.full_name();
1062 let normalized = normalize_repo_key(name);
1063 index.get(&normalized).cloned()
1064}
1065
1066fn parse_cjk_region_from_locale(value: &str) -> Option<CjkRegion> {
1067 let raw = value.trim();
1068 if raw.is_empty() {
1069 return None;
1070 }
1071 let mut base = raw.to_ascii_lowercase();
1072 if let Some(pos) = base.find(['.', '@']) {
1073 base.truncate(pos);
1074 }
1075 let normalized = base.replace('-', "_");
1076 let parts = normalized
1077 .split('_')
1078 .filter(|part| !part.is_empty())
1079 .collect::<Vec<_>>();
1080 if parts.is_empty() {
1081 return None;
1082 }
1083 match parts[0] {
1084 "ja" => return Some(CjkRegion::Jp),
1085 "ko" => return Some(CjkRegion::Kr),
1086 "zh" => {
1087 if parts.iter().any(|part| matches!(*part, "hk" | "mo")) {
1088 return Some(CjkRegion::Hk);
1089 }
1090 if parts.contains(&"tw") {
1091 return Some(CjkRegion::Tc);
1092 }
1093 if parts.contains(&"hant") {
1094 return Some(CjkRegion::Tc);
1095 }
1096 if parts.iter().any(|part| matches!(*part, "cn" | "sg")) {
1097 return Some(CjkRegion::Sc);
1098 }
1099 if parts.contains(&"hans") {
1100 return Some(CjkRegion::Sc);
1101 }
1102 }
1103 _ => {}
1104 }
1105 None
1106}
1107
1108fn locale_cjk_region() -> Option<CjkRegion> {
1109 for key in ["LC_ALL", "LC_CTYPE", "LANG"] {
1110 if let Ok(value) = env::var(key) {
1111 if let Some(region) = parse_cjk_region_from_locale(&value) {
1112 return Some(region);
1113 }
1114 }
1115 }
1116 None
1117}
1118
1119fn push_cjk_region(out: &mut Vec<CjkRegion>, seen: &mut HashSet<CjkRegion>, region: CjkRegion) {
1120 if seen.insert(region) {
1121 out.push(region);
1122 }
1123}
1124
1125fn collect_cjk_regions(config: &Config, needs: &FontFallbackNeeds) -> Vec<CjkRegion> {
1126 if !needs.needs_cjk {
1127 return Vec::new();
1128 }
1129 let mut out = Vec::new();
1130 let mut seen = HashSet::new();
1131
1132 if needs.scripts.contains(&Script::Hiragana) || needs.scripts.contains(&Script::Katakana) {
1133 push_cjk_region(&mut out, &mut seen, CjkRegion::Jp);
1134 }
1135 if needs.scripts.contains(&Script::Hangul) {
1136 push_cjk_region(&mut out, &mut seen, CjkRegion::Kr);
1137 }
1138 if needs.scripts.contains(&Script::Bopomofo) {
1139 push_cjk_region(&mut out, &mut seen, CjkRegion::Tc);
1140 }
1141
1142 if needs.scripts.contains(&Script::Han) || out.is_empty() {
1143 let region = match config.font.cjk_region {
1144 CjkRegion::Auto => locale_cjk_region().unwrap_or(CjkRegion::Sc),
1145 other => other,
1146 };
1147 push_cjk_region(&mut out, &mut seen, region);
1148 }
1149
1150 out
1151}
1152
1153fn cjk_region_families(region: CjkRegion) -> &'static [&'static str] {
1154 match region {
1155 CjkRegion::Sc => AUTO_FALLBACK_CJK_SC,
1156 CjkRegion::Tc => AUTO_FALLBACK_CJK_TC,
1157 CjkRegion::Hk => AUTO_FALLBACK_CJK_HK,
1158 CjkRegion::Jp => AUTO_FALLBACK_CJK_JP,
1159 CjkRegion::Kr => AUTO_FALLBACK_CJK_KR,
1160 CjkRegion::Auto => AUTO_FALLBACK_CJK_SC,
1161 }
1162}
1163
1164fn cjk_region_urls(region: CjkRegion) -> &'static [&'static str] {
1165 match region {
1166 CjkRegion::Sc => NOTO_CJK_SC_URLS,
1167 CjkRegion::Tc => NOTO_CJK_TC_URLS,
1168 CjkRegion::Hk => NOTO_CJK_HK_URLS,
1169 CjkRegion::Jp => NOTO_CJK_JP_URLS,
1170 CjkRegion::Kr => NOTO_CJK_KR_URLS,
1171 CjkRegion::Auto => NOTO_CJK_SC_URLS,
1172 }
1173}
1174
1175fn cjk_region_filename(region: CjkRegion) -> &'static str {
1176 match region {
1177 CjkRegion::Sc => "NotoSansCJKsc-Regular.otf",
1178 CjkRegion::Tc => "NotoSansCJKtc-Regular.otf",
1179 CjkRegion::Hk => "NotoSansCJKhk-Regular.otf",
1180 CjkRegion::Jp => "NotoSansCJKjp-Regular.otf",
1181 CjkRegion::Kr => "NotoSansCJKkr-Regular.otf",
1182 CjkRegion::Auto => "NotoSansCJKsc-Regular.otf",
1183 }
1184}
1185
1186fn choose_family_name(
1187 families: &HashMap<String, NotofontsFamily>,
1188 style: FontStylePreference,
1189) -> Option<String> {
1190 let candidates = families
1191 .iter()
1192 .filter(|(_, info)| info.latest_release.is_some() || !info.files.is_empty())
1193 .map(|(name, _)| name.clone())
1194 .collect::<Vec<_>>();
1195 if candidates.is_empty() {
1196 return None;
1197 }
1198 let mut best: Option<(i32, &String)> = None;
1199 for name in &candidates {
1200 let score = score_family_name(name, style);
1201 match best {
1202 None => best = Some((score, name)),
1203 Some((best_score, best_name)) => {
1204 if score > best_score
1205 || (score == best_score && name.len() < best_name.len())
1206 || (score == best_score && name.len() == best_name.len() && name < best_name)
1207 {
1208 best = Some((score, name));
1209 }
1210 }
1211 }
1212 }
1213 best.map(|(_, name)| name.clone())
1214}
1215
1216fn tag_from_release_url(url: &str) -> Option<String> {
1217 url.rsplit('/').next().map(|v| v.to_string())
1218}
1219
1220fn score_family_name(name: &str, style: FontStylePreference) -> i32 {
1221 let lower = name.to_ascii_lowercase();
1222 let mut score = 0;
1223 match style {
1224 FontStylePreference::Sans => {
1225 if lower.contains("noto sans") {
1226 score += 300;
1227 } else if lower.contains("sans") {
1228 score += 200;
1229 }
1230 if lower.contains("kufi") {
1231 score += 120;
1232 }
1233 }
1234 FontStylePreference::Serif => {
1235 if lower.contains("noto serif") {
1236 score += 300;
1237 } else if lower.contains("serif") {
1238 score += 200;
1239 }
1240 if lower.contains("naskh") {
1241 score += 120;
1242 }
1243 }
1244 }
1245 if lower.contains("supplement") {
1246 score -= 200;
1247 }
1248 if lower.contains("looped") {
1249 score -= 120;
1250 }
1251 if lower.contains("display") {
1252 score -= 40;
1253 }
1254 if lower.contains("ui") {
1255 score -= 20;
1256 }
1257 score
1258}
1259
1260fn repo_from_release_url(url: &str) -> Option<String> {
1261 let suffix = url.split("github.com/").nth(1)?;
1262 let mut parts = suffix.split('/');
1263 let owner = parts.next()?;
1264 let repo = parts.next()?;
1265 if owner.is_empty() || repo.is_empty() {
1266 return None;
1267 }
1268 Some(format!("{owner}/{repo}"))
1269}
1270
1271fn score_font_path(path: &str) -> Option<i32> {
1272 let lower = path.to_ascii_lowercase();
1273 let ext_score = if lower.ends_with(".ttf") {
1274 100
1275 } else if lower.ends_with(".otf") {
1276 80
1277 } else {
1278 return None;
1279 };
1280 let mut score = ext_score;
1281 if lower.contains("/full/") {
1282 score += 60;
1283 }
1284 if lower.contains("/hinted/") {
1285 score += 45;
1286 }
1287 if lower.contains("/googlefonts/") {
1288 score += 30;
1289 }
1290 if lower.contains("/unhinted/") {
1291 score += 10;
1292 }
1293 if lower.contains("regular") {
1294 score += 200;
1295 }
1296 if lower.contains("italic") {
1297 score -= 120;
1298 }
1299 if lower.contains("variable") || lower.contains('[') {
1300 score -= 20;
1301 }
1302 if lower.contains("slim") {
1303 score -= 10;
1304 }
1305 Some(score)
1306}
1307
1308fn pick_best_font_file(files: &[String]) -> Option<String> {
1309 let mut best: Option<(i32, &String)> = None;
1310 for file in files {
1311 let Some(score) = score_font_path(file) else {
1312 continue;
1313 };
1314 match best {
1315 None => best = Some((score, file)),
1316 Some((best_score, best_file)) => {
1317 if score > best_score || (score == best_score && file.len() < best_file.len()) {
1318 best = Some((score, file));
1319 }
1320 }
1321 }
1322 }
1323 best.map(|(_, file)| file.clone())
1324}
1325
1326fn resolve_script_font_plan(config: &Config, needs: &FontFallbackNeeds) -> Result<ScriptFontPlan> {
1327 if needs.scripts.is_empty() || !needs.needs_unicode {
1328 return Ok(ScriptFontPlan::default());
1329 }
1330 let state = load_notofonts_state(force_update_enabled(config))?;
1331 let index = build_repo_key_index(&state);
1332 let style = fallback_style_preference(config);
1333 let mut families = Vec::new();
1334 let mut downloads = Vec::new();
1335 let mut seen_repo = HashSet::new();
1336 let mut seen_family = HashSet::new();
1337 let mut seen_download = HashSet::new();
1338
1339 let mut scripts = needs.scripts.iter().copied().collect::<Vec<_>>();
1340 scripts.sort_by_key(|script| script.full_name().to_string());
1341 for script in scripts {
1342 let Some(repo_key) = script_repo_key(script, &index) else {
1343 continue;
1344 };
1345 if !seen_repo.insert(repo_key.clone()) {
1346 continue;
1347 }
1348 let Some(repo) = state.0.get(&repo_key) else {
1349 continue;
1350 };
1351 let Some(family) = choose_family_name(&repo.families, style) else {
1352 continue;
1353 };
1354 let Some(family_info) = repo.families.get(&family) else {
1355 continue;
1356 };
1357 let Some(file_path) = pick_best_font_file(&family_info.files) else {
1358 continue;
1359 };
1360 let repo_name = if file_path.starts_with("fonts/") {
1361 NOTOFONTS_FILES_REPO.to_string()
1362 } else {
1363 family_info
1364 .latest_release
1365 .as_ref()
1366 .and_then(|release| repo_from_release_url(&release.url))
1367 .unwrap_or_else(|| format!("notofonts/{repo_key}"))
1368 };
1369 let raw_name = Path::new(&file_path)
1370 .file_name()
1371 .and_then(|value| value.to_str());
1372 let Some(raw_name) = raw_name else {
1373 continue;
1374 };
1375 let filename = format!("{}__{}", repo_name.replace('/', "_"), raw_name);
1376 let tag = if repo_name == NOTOFONTS_FILES_REPO {
1377 None
1378 } else {
1379 family_info
1380 .latest_release
1381 .as_ref()
1382 .and_then(|release| tag_from_release_url(&release.url))
1383 };
1384 if seen_family.insert(family.clone()) {
1385 families.push(family.clone());
1386 }
1387 let download_key = format!("{repo_name}|{file_path}");
1388 if seen_download.insert(download_key) {
1389 downloads.push(ScriptDownload {
1390 family,
1391 repo: repo_name,
1392 file_path,
1393 filename,
1394 tag,
1395 });
1396 }
1397 }
1398
1399 Ok(ScriptFontPlan {
1400 families,
1401 downloads,
1402 })
1403}
1404
1405fn build_font_families(
1406 config: &Config,
1407 needs: &FontFallbackNeeds,
1408 script_families: &[String],
1409) -> Vec<String> {
1410 let mut families = Vec::new();
1411 let mut seen = HashSet::new();
1412 push_family(&mut families, &mut seen, &config.font.family);
1413 for name in &config.font.fallbacks {
1414 push_family(&mut families, &mut seen, name);
1415 }
1416 for name in script_families {
1417 push_family(&mut families, &mut seen, name);
1418 }
1419
1420 if needs.needs_nf {
1421 for name in AUTO_FALLBACK_NF {
1422 push_family(&mut families, &mut seen, name);
1423 }
1424 }
1425 if needs.needs_cjk {
1426 for name in AUTO_FALLBACK_CJK {
1427 push_family(&mut families, &mut seen, name);
1428 }
1429 }
1430 if needs.needs_unicode {
1431 for name in AUTO_FALLBACK_GLOBAL {
1432 push_family(&mut families, &mut seen, name);
1433 }
1434 }
1435 if needs.needs_emoji {
1436 for name in AUTO_FALLBACK_EMOJI {
1437 push_family(&mut families, &mut seen, name);
1438 }
1439 }
1440
1441 families
1442}
1443
1444fn family_requires_system(name: &str, app_families: &HashSet<String>) -> bool {
1445 if is_generic_family(name) {
1446 return true;
1447 }
1448 let key = family_key(name);
1449 !app_families.contains(&key)
1450}
1451
1452fn needs_system_fonts(
1453 config: &Config,
1454 app_families: &HashSet<String>,
1455 families: &[String],
1456) -> bool {
1457 match config.font.system_fallback {
1458 FontSystemFallback::Never => return false,
1459 FontSystemFallback::Always => return true,
1460 FontSystemFallback::Auto => {}
1461 }
1462 let mut needs = false;
1463 if config.font.file.is_none() && family_requires_system(&config.font.family, app_families) {
1464 needs = true;
1465 }
1466 if !needs {
1467 for name in families {
1468 if config.font.file.is_some() && name.eq_ignore_ascii_case(&config.font.family) {
1469 continue;
1470 }
1471 if family_requires_system(name, app_families) {
1472 needs = true;
1473 break;
1474 }
1475 }
1476 }
1477 needs
1478}
1479
1480fn build_font_plan(
1481 config: &Config,
1482 needs: &FontFallbackNeeds,
1483 app_families: &HashSet<String>,
1484 script_families: &[String],
1485) -> FontPlan {
1486 let families = build_font_families(config, needs, script_families);
1487 let font_family = families.join(", ");
1488 let needs_system_fonts = needs_system_fonts(config, app_families, &families);
1489 FontPlan {
1490 font_family,
1491 needs_system_fonts,
1492 }
1493}
1494
1495fn build_fontdb(config: &Config, needs_system_fonts: bool) -> Result<usvg::fontdb::Database> {
1496 let mut fontdb = usvg::fontdb::Database::new();
1497 if let Some(font_file) = &config.font.file {
1498 let bytes = std::fs::read(font_file)?;
1499 fontdb.load_font_data(bytes);
1500 }
1501 for dir in resolve_font_dirs(config)? {
1502 if dir.is_dir() {
1503 fontdb.load_fonts_dir(dir);
1504 }
1505 }
1506 if needs_system_fonts {
1507 fontdb.load_system_fonts();
1508 }
1509 Ok(fontdb)
1510}
1511
1512fn resolve_font_dirs(config: &Config) -> Result<Vec<PathBuf>> {
1513 let raw = env::var("CRYOSNAP_FONT_DIRS").ok();
1514 if let Some(raw) = raw {
1515 return parse_font_dir_list(&raw);
1516 }
1517 if !config.font.dirs.is_empty() {
1518 return Ok(config
1519 .font
1520 .dirs
1521 .iter()
1522 .filter_map(|value| expand_home_dir(value))
1523 .collect());
1524 }
1525 Ok(vec![default_font_dir()?])
1526}
1527
1528fn parse_font_dir_list(raw: &str) -> Result<Vec<PathBuf>> {
1529 let mut out = Vec::new();
1530 for part in raw.split(',') {
1531 let trimmed = part.trim();
1532 if trimmed.is_empty() {
1533 continue;
1534 }
1535 if let Some(path) = expand_home_dir(trimmed) {
1536 out.push(path);
1537 }
1538 }
1539 Ok(out)
1540}
1541
1542fn expand_home_dir(value: &str) -> Option<PathBuf> {
1543 if value == "~" || value.starts_with("~/") || value.starts_with("~\\") {
1544 let home = home_dir()?;
1545 let rest = value.trim_start_matches('~');
1546 return Some(if rest.is_empty() {
1547 home
1548 } else {
1549 home.join(rest.trim_start_matches(['/', '\\']))
1550 });
1551 }
1552 Some(PathBuf::from(value))
1553}
1554
1555fn default_font_dir() -> Result<PathBuf> {
1556 Ok(default_app_dir()?.join("fonts"))
1557}
1558
1559fn default_app_dir() -> Result<PathBuf> {
1560 if let Ok(path) = env::var("CRYOSNAP_HOME") {
1561 return Ok(PathBuf::from(path));
1562 }
1563 let home = home_dir()
1564 .ok_or_else(|| Error::InvalidInput("unable to resolve home directory".to_string()))?;
1565 Ok(home.join(".cryosnap"))
1566}
1567
1568fn home_dir() -> Option<PathBuf> {
1569 if cfg!(windows) {
1570 if let Some(path) = env::var_os("USERPROFILE") {
1571 return Some(PathBuf::from(path));
1572 }
1573 if let (Some(drive), Some(path)) = (env::var_os("HOMEDRIVE"), env::var_os("HOMEPATH")) {
1574 return Some(PathBuf::from(drive).join(path));
1575 }
1576 None
1577 } else {
1578 env::var_os("HOME").map(PathBuf::from)
1579 }
1580}
1581
1582fn collect_font_families(db: &usvg::fontdb::Database) -> HashSet<String> {
1583 let mut families = HashSet::new();
1584 for face in db.faces() {
1585 for (family, _) in &face.families {
1586 families.insert(family_key(family));
1587 }
1588 }
1589 families
1590}
1591
1592fn load_app_font_families(config: &Config) -> Result<HashSet<String>> {
1593 let mut fontdb = usvg::fontdb::Database::new();
1594 for dir in resolve_font_dirs(config)? {
1595 if dir.is_dir() {
1596 fontdb.load_fonts_dir(dir);
1597 }
1598 }
1599 Ok(collect_font_families(&fontdb))
1600}
1601
1602fn load_system_font_families() -> HashSet<String> {
1603 let mut fontdb = usvg::fontdb::Database::new();
1604 fontdb.load_system_fonts();
1605 collect_font_families(&fontdb)
1606}
1607
1608fn auto_download_enabled(config: &Config) -> bool {
1609 if let Ok(value) = env::var("CRYOSNAP_FONT_AUTO_DOWNLOAD") {
1610 let value = value.trim().to_ascii_lowercase();
1611 return !(value == "0" || value == "false" || value == "no" || value == "off");
1612 }
1613 config.font.auto_download
1614}
1615
1616fn force_update_enabled(config: &Config) -> bool {
1617 if let Ok(value) = env::var("CRYOSNAP_FONT_FORCE_UPDATE") {
1618 let value = value.trim().to_ascii_lowercase();
1619 return !(value == "0" || value == "false" || value == "no" || value == "off");
1620 }
1621 config.font.force_update
1622}
1623
1624#[derive(Debug, Clone, Deserialize)]
1625struct NotofontsState(HashMap<String, NotofontsRepo>);
1626
1627#[derive(Debug, Clone, Deserialize)]
1628struct NotofontsRepo {
1629 #[serde(default)]
1630 families: HashMap<String, NotofontsFamily>,
1631}
1632
1633#[derive(Debug, Clone, Deserialize)]
1634struct NotofontsFamily {
1635 #[serde(default)]
1636 latest_release: Option<NotofontsRelease>,
1637 #[serde(default)]
1638 files: Vec<String>,
1639}
1640
1641#[derive(Debug, Clone, Deserialize)]
1642struct NotofontsRelease {
1643 url: String,
1644}
1645
1646static NOTOFONTS_STATE: Lazy<Mutex<Option<Arc<NotofontsState>>>> = Lazy::new(|| Mutex::new(None));
1647static HTTP_AGENT: Lazy<ureq::Agent> = Lazy::new(|| {
1648 ureq::AgentBuilder::new()
1649 .timeout(Duration::from_secs(600))
1650 .build()
1651});
1652
1653fn github_proxy_candidates() -> Vec<String> {
1654 if let Ok(value) = env::var("CRYOSNAP_GITHUB_PROXY") {
1655 let parts = value
1656 .split(',')
1657 .map(|v| v.trim())
1658 .filter(|v| !v.is_empty())
1659 .map(|v| v.to_string())
1660 .collect::<Vec<_>>();
1661 if !parts.is_empty() {
1662 return parts;
1663 }
1664 }
1665 DEFAULT_GITHUB_PROXIES
1666 .iter()
1667 .map(|v| v.to_string())
1668 .collect()
1669}
1670
1671fn cache_dir() -> Result<PathBuf> {
1672 Ok(default_app_dir()?.join("cache"))
1673}
1674
1675fn apply_github_proxy(url: &str, proxy: &str) -> String {
1676 let mut base = proxy.trim().to_string();
1677 if !base.ends_with('/') {
1678 base.push('/');
1679 }
1680 format!("{base}{url}")
1681}
1682
1683enum FetchOutcome {
1684 Ok(Box<ureq::Response>, Option<String>),
1685 NotModified,
1686}
1687
1688fn build_github_candidates() -> Vec<Option<String>> {
1689 let mut seen = HashSet::new();
1690 let mut candidates = Vec::new();
1691
1692 candidates.push(None);
1693 for proxy in github_proxy_candidates() {
1694 if seen.insert(proxy.clone()) {
1695 candidates.push(Some(proxy));
1696 }
1697 }
1698 candidates
1699}
1700
1701fn looks_like_json(bytes: &[u8]) -> bool {
1702 for &b in bytes {
1703 if !b.is_ascii_whitespace() {
1704 return b == b'{' || b == b'[';
1705 }
1706 }
1707 false
1708}
1709
1710fn fetch_with_candidates(url: &str, headers: &[(&str, &str)]) -> Result<FetchOutcome> {
1711 let candidates = build_github_candidates();
1712
1713 let mut last_error: Option<String> = None;
1714 for proxy_opt in candidates {
1715 let target = match &proxy_opt {
1716 Some(proxy) => apply_github_proxy(url, proxy),
1717 None => url.to_string(),
1718 };
1719 let mut req = HTTP_AGENT
1720 .get(&target)
1721 .set("User-Agent", "cryosnap/auto-font");
1722 for (key, value) in headers {
1723 req = req.set(key, value);
1724 }
1725 match req.call() {
1726 Ok(resp) => {
1727 return Ok(FetchOutcome::Ok(Box::new(resp), proxy_opt.clone()));
1728 }
1729 Err(ureq::Error::Status(304, _)) => return Ok(FetchOutcome::NotModified),
1730 Err(err) => {
1731 last_error = Some(format!("{err}"));
1732 continue;
1733 }
1734 }
1735 }
1736 Err(Error::Render(format!(
1737 "download failed: {}",
1738 last_error.unwrap_or_else(|| "unknown error".to_string())
1739 )))
1740}
1741
1742fn fetch_bytes_with_cache(url: &str, cache_name: &str, force_update: bool) -> Result<Vec<u8>> {
1743 let cache_dir = cache_dir()?;
1744 let data_path = cache_dir.join(cache_name);
1745 let etag_path = cache_dir.join(format!("{cache_name}.etag"));
1746 let mut headers: Vec<(&str, &str)> = Vec::new();
1747 let mut etag_holder: Option<String> = None;
1748 if data_path.exists() && !force_update {
1749 if let Ok(etag) = fs::read_to_string(&etag_path) {
1750 let tag = etag.trim().to_string();
1751 if !tag.is_empty() {
1752 etag_holder = Some(tag);
1753 }
1754 }
1755 }
1756 if let Some(tag) = etag_holder.as_ref() {
1757 headers.push(("If-None-Match", tag.as_str()));
1758 }
1759 let candidates = build_github_candidates();
1760 let mut last_error: Option<String> = None;
1761 for proxy_opt in candidates {
1762 let target = match &proxy_opt {
1763 Some(proxy) => apply_github_proxy(url, proxy),
1764 None => url.to_string(),
1765 };
1766 let mut req = HTTP_AGENT
1767 .get(&target)
1768 .set("User-Agent", "cryosnap/auto-font");
1769 for (key, value) in &headers {
1770 req = req.set(key, value);
1771 }
1772 match req.call() {
1773 Ok(resp) => {
1774 let etag_value = resp.header("ETag").map(|v| v.to_string());
1775 let mut reader = resp.into_reader();
1776 let mut buf = Vec::new();
1777 reader.read_to_end(&mut buf)?;
1778 if !looks_like_json(&buf) {
1779 last_error = Some("invalid response".to_string());
1780 continue;
1781 }
1782 if let Some(parent) = data_path.parent() {
1783 fs::create_dir_all(parent)?;
1784 }
1785 fs::write(&data_path, &buf)?;
1786 if let Some(etag) = etag_value {
1787 let _ = fs::write(&etag_path, etag.as_bytes());
1788 }
1789 return Ok(buf);
1790 }
1791 Err(ureq::Error::Status(304, _)) => {
1792 if data_path.exists() {
1793 let cached = fs::read(&data_path)?;
1794 if looks_like_json(&cached) {
1795 return Ok(cached);
1796 }
1797 let _ = fs::remove_file(&data_path);
1798 let _ = fs::remove_file(&etag_path);
1799 }
1800 last_error = Some("font state cache missing".to_string());
1801 continue;
1802 }
1803 Err(err) => {
1804 last_error = Some(format!("{err}"));
1805 continue;
1806 }
1807 }
1808 }
1809 if data_path.exists() {
1810 let cached = fs::read(&data_path)?;
1811 if looks_like_json(&cached) {
1812 return Ok(cached);
1813 }
1814 let _ = fs::remove_file(&data_path);
1815 let _ = fs::remove_file(&etag_path);
1816 }
1817 Err(Error::Render(format!(
1818 "download failed: {}",
1819 last_error.unwrap_or_else(|| "unknown error".to_string())
1820 )))
1821}
1822
1823fn download_url_with_etag(url: &str, target: &Path, force_update: bool) -> Result<bool> {
1824 let etag_path = target.with_extension(format!(
1825 "{}.etag",
1826 target
1827 .extension()
1828 .and_then(|v| v.to_str())
1829 .unwrap_or("font")
1830 ));
1831 let mut headers: Vec<(&str, &str)> = Vec::new();
1832 let mut etag_holder: Option<String> = None;
1833 if target.exists() && !force_update {
1834 if let Ok(etag) = fs::read_to_string(&etag_path) {
1835 let tag = etag.trim().to_string();
1836 if !tag.is_empty() {
1837 etag_holder = Some(tag);
1838 }
1839 }
1840 }
1841 if let Some(tag) = etag_holder.as_ref() {
1842 headers.push(("If-None-Match", tag.as_str()));
1843 }
1844 match fetch_with_candidates(url, &headers)? {
1845 FetchOutcome::Ok(resp, _proxy) => {
1846 let etag_value = resp.header("ETag").map(|v| v.to_string());
1847 let mut reader = resp.into_reader();
1848 let temp = target.with_extension("download");
1849 let mut file = fs::File::create(&temp)?;
1850 std::io::copy(&mut reader, &mut file)?;
1851 file.sync_all()?;
1852 fs::rename(&temp, target)?;
1853 if let Some(etag) = etag_value {
1854 let _ = fs::write(&etag_path, etag.as_bytes());
1855 }
1856 Ok(true)
1857 }
1858 FetchOutcome::NotModified => Ok(false),
1859 }
1860}
1861
1862fn load_notofonts_state(force_update: bool) -> Result<Arc<NotofontsState>> {
1863 if !force_update {
1864 if let Ok(guard) = NOTOFONTS_STATE.lock() {
1865 if let Some(state) = guard.as_ref() {
1866 return Ok(state.clone());
1867 }
1868 }
1869 }
1870 let bytes = fetch_bytes_with_cache(NOTOFONTS_STATE_URL, "notofonts_state.json", force_update)?;
1871 let state: NotofontsState = serde_json::from_slice(&bytes)
1872 .map_err(|err| Error::Render(format!("font state parse: {err}")))?;
1873 let state = Arc::new(state);
1874 if !force_update {
1875 if let Ok(mut guard) = NOTOFONTS_STATE.lock() {
1876 *guard = Some(state.clone());
1877 }
1878 }
1879 Ok(state)
1880}
1881
1882struct FontPackage {
1883 id: &'static str,
1884 family: &'static str,
1885 filename: &'static str,
1886 url: &'static str,
1887 download_sha256: &'static str,
1888 file_sha256: &'static str,
1889 archive_entry: Option<&'static str>,
1890}
1891
1892const FONT_PACKAGE_NF: FontPackage = FontPackage {
1893 id: "symbols-nerd-font-mono",
1894 family: "Symbols Nerd Font Mono",
1895 filename: "SymbolsNerdFontMono-Regular.ttf",
1896 url:
1897 "https://github.com/ryanoasis/nerd-fonts/releases/download/v3.2.1/NerdFontsSymbolsOnly.zip",
1898 download_sha256: "bc59c2ea74d022a6262ff9e372fde5c36cd5ae3f82a567941489ecfab4f03d66",
1899 file_sha256: "6f7e339af33bde250a4d7360a3176ab1ffe4e99c00eef0d71b4c322364c595f3",
1900 archive_entry: Some("SymbolsNerdFontMono-Regular.ttf"),
1901};
1902
1903fn ensure_fonts_available(
1904 config: &Config,
1905 needs: &FontFallbackNeeds,
1906 script_plan: &ScriptFontPlan,
1907) -> Result<()> {
1908 if !auto_download_enabled(config) {
1909 return Ok(());
1910 }
1911 let force_update = force_update_enabled(config);
1912 if !needs.needs_nf && !needs.needs_cjk && !needs.needs_emoji && script_plan.downloads.is_empty()
1913 {
1914 return Ok(());
1915 }
1916 let font_dirs = resolve_font_dirs(config)?;
1917 let Some(primary_dir) = font_dirs.first() else {
1918 return Ok(());
1919 };
1920 let app_families = load_app_font_families(config).unwrap_or_default();
1921 let allow_system = !matches!(config.font.system_fallback, FontSystemFallback::Never);
1922 let system_families = if allow_system {
1923 load_system_font_families()
1924 } else {
1925 HashSet::new()
1926 };
1927
1928 fs::create_dir_all(primary_dir)?;
1929
1930 for download in &script_plan.downloads {
1931 let app_has = app_families.contains(&family_key(&download.family));
1932 let system_has = allow_system && system_families.contains(&family_key(&download.family));
1933 if system_has && !app_has {
1934 continue;
1935 }
1936 if app_has {
1937 if !force_update {
1938 continue;
1939 }
1940 let target = primary_dir.join(&download.filename);
1941 if !target.exists() {
1942 continue;
1943 }
1944 }
1945 if let Err(err) = download_notofonts_file(download, primary_dir, force_update) {
1946 eprintln!(
1947 "cryosnap: font download failed for {}: {}",
1948 download.family, err
1949 );
1950 }
1951 }
1952
1953 if needs.needs_nf
1954 && !any_family_present(&[FONT_PACKAGE_NF.family], &app_families)
1955 && !(allow_system && any_family_present(&[FONT_PACKAGE_NF.family], &system_families))
1956 {
1957 if let Err(err) = download_font_package(&FONT_PACKAGE_NF, primary_dir) {
1958 eprintln!(
1959 "cryosnap: font download failed for {}: {}",
1960 FONT_PACKAGE_NF.id, err
1961 );
1962 }
1963 }
1964
1965 if needs.needs_cjk {
1966 let cjk_regions = collect_cjk_regions(config, needs);
1967 for region in cjk_regions {
1968 let families = cjk_region_families(region);
1969 let app_has = any_family_present(families, &app_families);
1970 let system_has = allow_system && any_family_present(families, &system_families);
1971 if system_has && !app_has {
1972 continue;
1973 }
1974 let filename = cjk_region_filename(region);
1975 let target = primary_dir.join(filename);
1976 if app_has {
1977 if !force_update {
1978 continue;
1979 }
1980 if !target.exists() {
1981 continue;
1982 }
1983 }
1984 if let Err(err) =
1985 download_raw_font(cjk_region_urls(region), primary_dir, filename, force_update)
1986 {
1987 eprintln!("cryosnap: font download failed for cjk: {}", err);
1988 }
1989 }
1990 }
1991
1992 if needs.needs_emoji {
1993 let app_has = any_family_present(AUTO_FALLBACK_EMOJI, &app_families);
1994 let system_has = allow_system && any_family_present(AUTO_FALLBACK_EMOJI, &system_families);
1995 if !system_has || app_has {
1996 let filename = "NotoColorEmoji.ttf";
1997 let target = primary_dir.join(filename);
1998 if !app_has || (force_update && target.exists()) {
1999 if let Err(err) =
2000 download_raw_font(NOTO_EMOJI_URLS, primary_dir, filename, force_update)
2001 {
2002 eprintln!("cryosnap: font download failed for emoji: {}", err);
2003 }
2004 }
2005 }
2006 }
2007
2008 Ok(())
2009}
2010
2011fn any_family_present(families: &[&str], set: &HashSet<String>) -> bool {
2012 families.iter().any(|name| set.contains(&family_key(name)))
2013}
2014
2015fn download_raw_font(urls: &[&str], dir: &Path, filename: &str, force_update: bool) -> Result<()> {
2016 let target = dir.join(filename);
2017 let mut last_error: Option<Error> = None;
2018 for url in urls {
2019 match download_url_with_etag(url, &target, force_update) {
2020 Ok(_) => return Ok(()),
2021 Err(err) => last_error = Some(err),
2022 }
2023 }
2024 Err(last_error
2025 .unwrap_or_else(|| Error::Render("font download failed: no available urls".to_string())))
2026}
2027
2028fn download_notofonts_file(
2029 download: &ScriptDownload,
2030 dir: &Path,
2031 force_update: bool,
2032) -> Result<()> {
2033 let target = dir.join(&download.filename);
2034 if !force_update && target.exists() {
2035 return Ok(());
2036 }
2037 let mut refs = vec!["main".to_string(), "master".to_string()];
2038 if let Some(tag) = &download.tag {
2039 if !tag.is_empty() {
2040 refs.push(tag.clone());
2041 }
2042 }
2043 let mut last_error: Option<Error> = None;
2044 for reference in refs {
2045 let url = format!(
2046 "https://raw.githubusercontent.com/{}/{}/{}",
2047 download.repo, reference, download.file_path
2048 );
2049 match download_url_with_etag(&url, &target, force_update) {
2050 Ok(_) => return Ok(()),
2051 Err(err) => last_error = Some(err),
2052 }
2053 }
2054 Err(last_error
2055 .unwrap_or_else(|| Error::Render("font download failed: no available refs".to_string())))
2056}
2057
2058fn download_url_to_file(url: &str, target: &Path) -> Result<()> {
2059 match fetch_with_candidates(url, &[])? {
2060 FetchOutcome::Ok(resp, _proxy) => {
2061 let mut reader = resp.into_reader();
2062 let mut file = fs::File::create(target)?;
2063 std::io::copy(&mut reader, &mut file)?;
2064 file.sync_all()?;
2065 Ok(())
2066 }
2067 FetchOutcome::NotModified => Ok(()),
2068 }
2069}
2070
2071fn download_zip_with_candidates(url: &str, target: &Path) -> Result<()> {
2072 let candidates = build_github_candidates();
2073 let mut last_error: Option<String> = None;
2074 for proxy_opt in candidates {
2075 let target_url = match &proxy_opt {
2076 Some(proxy) => apply_github_proxy(url, proxy),
2077 None => url.to_string(),
2078 };
2079 let req = HTTP_AGENT
2080 .get(&target_url)
2081 .set("User-Agent", "cryosnap/auto-font");
2082 match req.call() {
2083 Ok(resp) => {
2084 let temp = target.with_extension("download");
2085 let mut reader = resp.into_reader();
2086 let mut file = fs::File::create(&temp)?;
2087 std::io::copy(&mut reader, &mut file)?;
2088 file.sync_all()?;
2089 if let Err(err) = validate_zip_archive(&temp) {
2090 last_error = Some(err.to_string());
2091 let _ = fs::remove_file(&temp);
2092 continue;
2093 }
2094 fs::rename(&temp, target)?;
2095 return Ok(());
2096 }
2097 Err(ureq::Error::Status(status, _)) => {
2098 last_error = Some(format!("status {status}"));
2099 continue;
2100 }
2101 Err(err) => {
2102 last_error = Some(format!("{err}"));
2103 continue;
2104 }
2105 }
2106 }
2107 Err(Error::Render(format!(
2108 "download failed: {}",
2109 last_error.unwrap_or_else(|| "unknown error".to_string())
2110 )))
2111}
2112
2113fn validate_zip_archive(path: &Path) -> Result<()> {
2114 let file = fs::File::open(path)?;
2115 match zip::ZipArchive::new(file) {
2116 Ok(_) => Ok(()),
2117 Err(err) => Err(Error::Render(format!("zip read: {err}"))),
2118 }
2119}
2120
2121fn download_font_package(pkg: &FontPackage, dir: &Path) -> Result<()> {
2122 let target = dir.join(pkg.filename);
2123 if verify_sha256(&target, pkg.file_sha256)? {
2124 return Ok(());
2125 }
2126 let temp = dir.join(format!("{}.download", pkg.filename));
2127 if pkg.archive_entry.is_some() {
2128 download_zip_with_candidates(pkg.url, &temp)?;
2129 } else {
2130 download_url_to_file(pkg.url, &temp)?;
2131 }
2132 if !verify_sha256(&temp, pkg.download_sha256)? {
2133 let _ = fs::remove_file(&temp);
2134 return Err(Error::Render(format!(
2135 "font checksum mismatch for {}",
2136 pkg.id
2137 )));
2138 }
2139 if let Some(entry) = pkg.archive_entry {
2140 extract_zip_entry(&temp, entry, &target)?;
2141 let _ = fs::remove_file(&temp);
2142 } else {
2143 fs::rename(&temp, &target)?;
2144 }
2145 if !verify_sha256(&target, pkg.file_sha256)? {
2146 return Err(Error::Render(format!(
2147 "font checksum mismatch for {}",
2148 pkg.id
2149 )));
2150 }
2151 Ok(())
2152}
2153
2154fn extract_zip_entry(archive_path: &Path, entry: &str, target: &Path) -> Result<()> {
2155 let file = fs::File::open(archive_path)?;
2156 let mut archive =
2157 zip::ZipArchive::new(file).map_err(|err| Error::Render(format!("zip read: {err}")))?;
2158 let mut entry_file = archive
2159 .by_name(entry)
2160 .map_err(|err| Error::Render(format!("zip entry {entry}: {err}")))?;
2161 let mut out = fs::File::create(target)?;
2162 std::io::copy(&mut entry_file, &mut out)?;
2163 out.sync_all()?;
2164 Ok(())
2165}
2166
2167fn verify_sha256(path: &Path, expected: &str) -> Result<bool> {
2168 if !path.exists() {
2169 return Ok(false);
2170 }
2171 if expected.trim().is_empty() {
2172 return Ok(true);
2173 }
2174 let actual = sha256_hex(path)?;
2175 Ok(actual.eq_ignore_ascii_case(expected.trim()))
2176}
2177
2178fn sha256_hex(path: &Path) -> Result<String> {
2179 let mut file = fs::File::open(path)?;
2180 let mut hasher = Sha256::new();
2181 let mut buf = [0u8; 8192];
2182 loop {
2183 let read = file.read(&mut buf)?;
2184 if read == 0 {
2185 break;
2186 }
2187 hasher.update(&buf[..read]);
2188 }
2189 let digest = hasher.finalize();
2190 let mut out = String::with_capacity(digest.len() * 2);
2191 for byte in digest {
2192 out.push_str(&format!("{:02x}", byte));
2193 }
2194 Ok(out)
2195}
2196
2197fn pixmap_to_webp(pixmap: &tiny_skia::Pixmap) -> Result<Vec<u8>> {
2198 let width = pixmap.width();
2199 let height = pixmap.height();
2200 let rgba = unpremultiply_rgba(pixmap.data());
2201 let encoder = webp::Encoder::from_rgba(&rgba, width, height);
2202 let webp = encoder.encode(DEFAULT_WEBP_QUALITY);
2203 Ok(webp.to_vec())
2204}
2205
2206fn unpremultiply_rgba(data: &[u8]) -> Vec<u8> {
2207 let mut out = Vec::with_capacity(data.len());
2208 for chunk in data.chunks_exact(4) {
2209 let a = chunk[3] as u16;
2210 if a == 0 {
2211 out.extend_from_slice(&[0, 0, 0, 0]);
2212 continue;
2213 }
2214 let r = ((chunk[0] as u16 * 255 + a / 2) / a) as u8;
2215 let g = ((chunk[1] as u16 * 255 + a / 2) / a) as u8;
2216 let b = ((chunk[2] as u16 * 255 + a / 2) / a) as u8;
2217 out.extend_from_slice(&[r, g, b, chunk[3]]);
2218 }
2219 out
2220}
2221
2222fn quantize_pixmap_to_png(pixmap: &tiny_skia::Pixmap, config: &PngOptions) -> Result<Vec<u8>> {
2223 let rgba = unpremultiply_rgba(pixmap.data());
2224 quantize_rgba_to_png(&rgba, pixmap.width(), pixmap.height(), config)
2225}
2226
2227fn quantize_png_bytes(png: &[u8], config: &PngOptions) -> Result<Vec<u8>> {
2228 let (rgba, width, height) = decode_png_rgba(png)?;
2229 quantize_rgba_to_png(&rgba, width, height, config)
2230}
2231
2232fn decode_png_rgba(png: &[u8]) -> Result<(Vec<u8>, u32, u32)> {
2233 let mut decoder = png::Decoder::new(Cursor::new(png));
2234 decoder.set_transformations(png::Transformations::normalize_to_color8());
2235 let mut reader = decoder
2236 .read_info()
2237 .map_err(|err| Error::Render(format!("png decode: {err}")))?;
2238 let buffer_size = reader
2239 .output_buffer_size()
2240 .ok_or_else(|| Error::Render("png decode: missing buffer size".to_string()))?;
2241 let mut buf = vec![0; buffer_size];
2242 let info = reader
2243 .next_frame(&mut buf)
2244 .map_err(|err| Error::Render(format!("png decode: {err}")))?;
2245 let data = &buf[..info.buffer_size()];
2246 let rgba = match info.color_type {
2247 png::ColorType::Rgba => data.to_vec(),
2248 png::ColorType::Rgb => rgb_to_rgba(data),
2249 png::ColorType::GrayscaleAlpha => gray_alpha_to_rgba(data),
2250 png::ColorType::Grayscale => gray_to_rgba(data),
2251 png::ColorType::Indexed => {
2252 return Err(Error::Render(
2253 "png decode: indexed color not expanded".to_string(),
2254 ));
2255 }
2256 };
2257 Ok((rgba, info.width, info.height))
2258}
2259
2260fn rgb_to_rgba(data: &[u8]) -> Vec<u8> {
2261 let mut out = Vec::with_capacity(data.len() / 3 * 4);
2262 for chunk in data.chunks_exact(3) {
2263 out.extend_from_slice(&[chunk[0], chunk[1], chunk[2], 255]);
2264 }
2265 out
2266}
2267
2268fn gray_to_rgba(data: &[u8]) -> Vec<u8> {
2269 let mut out = Vec::with_capacity(data.len() * 4);
2270 for &g in data {
2271 out.extend_from_slice(&[g, g, g, 255]);
2272 }
2273 out
2274}
2275
2276fn gray_alpha_to_rgba(data: &[u8]) -> Vec<u8> {
2277 let mut out = Vec::with_capacity(data.len() / 2 * 4);
2278 for chunk in data.chunks_exact(2) {
2279 let g = chunk[0];
2280 let a = chunk[1];
2281 out.extend_from_slice(&[g, g, g, a]);
2282 }
2283 out
2284}
2285
2286#[derive(Clone, Copy)]
2287struct QuantizeSettings {
2288 quality: u8,
2289 speed: u8,
2290 dither: f32,
2291}
2292
2293impl PngQuantPreset {
2294 fn settings(self) -> QuantizeSettings {
2295 match self {
2296 PngQuantPreset::Fast => QuantizeSettings {
2297 quality: 70,
2298 speed: 7,
2299 dither: 0.5,
2300 },
2301 PngQuantPreset::Balanced => QuantizeSettings {
2302 quality: DEFAULT_PNG_QUANTIZE_QUALITY,
2303 speed: DEFAULT_PNG_QUANTIZE_SPEED,
2304 dither: DEFAULT_PNG_QUANTIZE_DITHER,
2305 },
2306 PngQuantPreset::Best => QuantizeSettings {
2307 quality: 95,
2308 speed: 1,
2309 dither: 1.0,
2310 },
2311 }
2312 }
2313}
2314
2315fn quantize_settings(config: &PngOptions) -> QuantizeSettings {
2316 if let Some(preset) = config.quantize_preset {
2317 return preset.settings();
2318 }
2319 QuantizeSettings {
2320 quality: config.quantize_quality,
2321 speed: config.quantize_speed,
2322 dither: config.quantize_dither,
2323 }
2324}
2325
2326fn quantize_rgba_to_png(
2327 rgba: &[u8],
2328 width: u32,
2329 height: u32,
2330 config: &PngOptions,
2331) -> Result<Vec<u8>> {
2332 let expected = width as usize * height as usize * 4;
2333 if rgba.len() != expected {
2334 return Err(Error::Render(
2335 "png quantize: invalid rgba buffer".to_string(),
2336 ));
2337 }
2338 let mut pixels = Vec::with_capacity(width as usize * height as usize);
2339 for chunk in rgba.chunks_exact(4) {
2340 pixels.push(imagequant::RGBA::new(
2341 chunk[0], chunk[1], chunk[2], chunk[3],
2342 ));
2343 }
2344
2345 let mut attr = imagequant::new();
2346 let settings = quantize_settings(config);
2347 let quality = settings.quality.min(100);
2348 let speed = settings.speed.clamp(1, 10);
2349 attr.set_quality(0, quality)
2350 .map_err(|err| Error::Render(format!("png quantize quality: {err:?}")))?;
2351 attr.set_speed(speed as i32)
2352 .map_err(|err| Error::Render(format!("png quantize speed: {err:?}")))?;
2353 let mut image = attr
2354 .new_image(pixels, width as usize, height as usize, 0.0)
2355 .map_err(|err| Error::Render(format!("png quantize image: {err:?}")))?;
2356 let mut result = attr
2357 .quantize(&mut image)
2358 .map_err(|err| Error::Render(format!("png quantize: {err:?}")))?;
2359 let dither = settings.dither.clamp(0.0, 1.0);
2360 result
2361 .set_dithering_level(dither)
2362 .map_err(|err| Error::Render(format!("png quantize dither: {err:?}")))?;
2363 let (palette, indices) = result
2364 .remapped(&mut image)
2365 .map_err(|err| Error::Render(format!("png quantize remap: {err:?}")))?;
2366 encode_indexed_png(&palette, &indices, width, height)
2367}
2368
2369fn encode_indexed_png(
2370 palette: &[imagequant::RGBA],
2371 indices: &[u8],
2372 width: u32,
2373 height: u32,
2374) -> Result<Vec<u8>> {
2375 if indices.len() != width as usize * height as usize {
2376 return Err(Error::Render(
2377 "png quantize: invalid index buffer".to_string(),
2378 ));
2379 }
2380 let mut palette_bytes = Vec::with_capacity(palette.len() * 3);
2381 let mut trns = Vec::with_capacity(palette.len());
2382 let mut has_alpha = false;
2383 for color in palette {
2384 palette_bytes.extend_from_slice(&[color.r, color.g, color.b]);
2385 trns.push(color.a);
2386 if color.a < 255 {
2387 has_alpha = true;
2388 }
2389 }
2390 let mut out = Vec::new();
2391 let mut encoder = png::Encoder::new(&mut out, width, height);
2392 encoder.set_color(png::ColorType::Indexed);
2393 encoder.set_depth(png::BitDepth::Eight);
2394 encoder.set_palette(palette_bytes);
2395 if has_alpha {
2396 encoder.set_trns(trns);
2397 }
2398 let mut writer = encoder
2399 .write_header()
2400 .map_err(|err| Error::Render(format!("png encode: {err}")))?;
2401 writer
2402 .write_image_data(indices)
2403 .map_err(|err| Error::Render(format!("png encode: {err}")))?;
2404 drop(writer);
2405 Ok(out)
2406}
2407
2408fn optimize_png(png: Vec<u8>, config: &PngOptions) -> Result<Vec<u8>> {
2409 if !config.optimize {
2410 return Ok(png);
2411 }
2412 let level = config.level.min(MAX_PNG_OPT_LEVEL);
2413 let mut options = oxipng::Options::from_preset(level);
2414 options.strip = match config.strip {
2415 PngStrip::None => oxipng::StripChunks::None,
2416 PngStrip::Safe => oxipng::StripChunks::Safe,
2417 PngStrip::All => oxipng::StripChunks::All,
2418 };
2419 oxipng::optimize_from_memory(&png, &options)
2420 .map_err(|err| Error::Render(format!("png optimize: {err}")))
2421}
2422
2423struct LoadedInput {
2424 text: String,
2425 path: Option<PathBuf>,
2426 kind: InputKind,
2427}
2428
2429#[derive(Debug, Clone, Copy)]
2430enum InputKind {
2431 Code,
2432 Ansi,
2433}
2434
2435fn load_input(input: &InputSource, timeout: Duration) -> Result<LoadedInput> {
2436 match input {
2437 InputSource::Text(text) => Ok(LoadedInput {
2438 text: text.clone(),
2439 path: None,
2440 kind: InputKind::Code,
2441 }),
2442 InputSource::File(path) => {
2443 let text = std::fs::read_to_string(path)?;
2444 Ok(LoadedInput {
2445 text,
2446 path: Some(path.clone()),
2447 kind: InputKind::Code,
2448 })
2449 }
2450 InputSource::Command(cmd) => {
2451 let text = execute_command(cmd, timeout)?;
2452 Ok(LoadedInput {
2453 text,
2454 path: None,
2455 kind: InputKind::Ansi,
2456 })
2457 }
2458 }
2459}
2460
2461fn is_ansi_input(loaded: &LoadedInput, config: &Config) -> bool {
2462 if let Some(lang) = &config.language {
2463 if lang.eq_ignore_ascii_case("ansi") {
2464 return true;
2465 }
2466 }
2467 if matches!(loaded.kind, InputKind::Ansi) {
2468 return true;
2469 }
2470 loaded.text.contains('\u{1b}')
2471}
2472
2473fn execute_command(cmd: &str, timeout: Duration) -> Result<String> {
2474 use portable_pty::{native_pty_system, CommandBuilder, PtySize};
2475 use std::io::Read;
2476 use std::sync::mpsc;
2477 use std::thread;
2478
2479 let args = shell_words::split(cmd)
2480 .map_err(|err| Error::InvalidInput(format!("command parse: {err}")))?;
2481 if args.is_empty() {
2482 return Err(Error::InvalidInput("empty command".to_string()));
2483 }
2484
2485 let (cols, rows) = terminal_size::terminal_size()
2486 .map(|(w, h)| (w.0, h.0))
2487 .unwrap_or((80, 24));
2488
2489 let pty_system = native_pty_system();
2490 let pair = pty_system
2491 .openpty(PtySize {
2492 rows,
2493 cols,
2494 pixel_width: 0,
2495 pixel_height: 0,
2496 })
2497 .map_err(|err| Error::Render(format!("open pty: {err}")))?;
2498
2499 let mut command = CommandBuilder::new(&args[0]);
2500 if args.len() > 1 {
2501 command.args(&args[1..]);
2502 }
2503
2504 let mut child = pair
2505 .slave
2506 .spawn_command(command)
2507 .map_err(|err| Error::Render(format!("spawn command: {err}")))?;
2508 drop(pair.slave);
2509 let mut killer = child.clone_killer();
2510
2511 let mut reader = pair
2512 .master
2513 .try_clone_reader()
2514 .map_err(|err| Error::Render(format!("pty reader: {err}")))?;
2515 drop(pair.master);
2516
2517 let read_handle = thread::spawn(move || {
2518 let mut buf = Vec::new();
2519 let _ = reader.read_to_end(&mut buf);
2520 buf
2521 });
2522
2523 let (status_tx, status_rx) = mpsc::channel();
2524 thread::spawn(move || {
2525 let status = child.wait();
2526 let _ = status_tx.send(status);
2527 });
2528
2529 let status = match status_rx.recv_timeout(timeout) {
2530 Ok(status) => status,
2531 Err(_) => {
2532 let _ = killer.kill();
2533 return Err(Error::Timeout);
2534 }
2535 };
2536 let output = read_handle.join().unwrap_or_default();
2537 let output_str = String::from_utf8_lossy(&output).to_string();
2538
2539 match status {
2540 Ok(exit) => {
2541 if !exit.success() {
2542 return Err(Error::Render(format!("command exited with {exit}")));
2543 }
2544 }
2545 Err(err) => return Err(Error::Render(format!("command wait: {err}"))),
2546 }
2547
2548 if output_str.is_empty() {
2549 return Err(Error::InvalidInput("no command output".to_string()));
2550 }
2551
2552 Ok(output_str)
2553}
2554
2555#[derive(Debug, Clone, Default, PartialEq)]
2556struct TextStyle {
2557 fg: Option<String>,
2558 bg: Option<String>,
2559 bold: bool,
2560 italic: bool,
2561 underline: bool,
2562 strike: bool,
2563}
2564
2565#[derive(Debug, Clone, Default)]
2566struct Span {
2567 text: String,
2568 style: TextStyle,
2569}
2570
2571#[derive(Debug, Clone, Default)]
2572struct Line {
2573 spans: Vec<Span>,
2574}
2575
2576fn highlight_code(
2577 text: &str,
2578 path: Option<&Path>,
2579 language: Option<&str>,
2580 theme_name: &str,
2581) -> Result<(Vec<Line>, String)> {
2582 static SYNTAX_SET: Lazy<SyntaxSet> = Lazy::new(SyntaxSet::load_defaults_newlines);
2583 static THEME_SET: Lazy<ThemeSet> = Lazy::new(ThemeSet::load_defaults);
2584
2585 let ps = &*SYNTAX_SET;
2586 let ts = &*THEME_SET;
2587
2588 let mut theme = ts
2589 .themes
2590 .get(theme_name)
2591 .cloned()
2592 .or_else(|| {
2593 if theme_name.eq_ignore_ascii_case("charm") {
2594 Some(charm_theme())
2595 } else {
2596 None
2597 }
2598 })
2599 .or_else(|| ts.themes.get("base16-ocean.dark").cloned())
2600 .or_else(|| ts.themes.values().next().cloned())
2601 .ok_or_else(|| Error::Render("no themes available".to_string()))?;
2602
2603 if theme_name.eq_ignore_ascii_case("charm") {
2604 theme = charm_theme();
2605 }
2606
2607 let syntax = match language {
2608 Some(lang) => ps
2609 .find_syntax_by_token(lang)
2610 .or_else(|| ps.find_syntax_by_extension(lang))
2611 .unwrap_or_else(|| ps.find_syntax_plain_text()),
2612 None => {
2613 if let Some(path) = path {
2614 ps.find_syntax_for_file(path)
2615 .ok()
2616 .flatten()
2617 .unwrap_or_else(|| ps.find_syntax_plain_text())
2618 } else {
2619 let first_line = text.lines().next().unwrap_or("");
2620 ps.find_syntax_by_first_line(first_line)
2621 .unwrap_or_else(|| ps.find_syntax_plain_text())
2622 }
2623 }
2624 };
2625
2626 let default_fg = theme.settings.foreground.unwrap_or(Color::WHITE);
2627 let default_fg_hex = color_to_hex(default_fg);
2628
2629 let mut highlighter = HighlightLines::new(syntax, &theme);
2630 let mut lines = Vec::new();
2631
2632 let raw_lines: Vec<&str> = text.split('\n').collect();
2633 for (idx, raw) in raw_lines.iter().enumerate() {
2634 let mut line_with_end = raw.to_string();
2635 if idx + 1 < raw_lines.len() {
2636 line_with_end.push('\n');
2637 }
2638
2639 let regions = highlighter
2640 .highlight_line(&line_with_end, ps)
2641 .map_err(|err| Error::Render(format!("highlight: {err}")))?;
2642 let mut line = Line::default();
2643
2644 for (style, piece) in regions {
2645 let mut text_piece = piece.to_string();
2646 if text_piece.ends_with('\n') {
2647 text_piece.pop();
2648 if text_piece.ends_with('\r') {
2649 text_piece.pop();
2650 }
2651 }
2652 if text_piece.is_empty() {
2653 continue;
2654 }
2655
2656 let mut span_style = TextStyle::default();
2657 if style.foreground.a > 0 {
2658 span_style.fg = Some(color_to_hex(style.foreground));
2659 }
2660 if style.background.a > 0 {
2661 span_style.bg = Some(color_to_hex(style.background));
2662 }
2663 if style.font_style.contains(FontStyle::BOLD) {
2664 span_style.bold = true;
2665 }
2666 if style.font_style.contains(FontStyle::ITALIC) {
2667 span_style.italic = true;
2668 }
2669 if style.font_style.contains(FontStyle::UNDERLINE) {
2670 span_style.underline = true;
2671 }
2672
2673 push_span(&mut line.spans, text_piece, span_style);
2674 }
2675 lines.push(line);
2676 }
2677
2678 Ok((lines, default_fg_hex))
2679}
2680
2681fn parse_ansi(text: &str) -> Vec<Line> {
2682 let mut parser = vte::Parser::new();
2683 let mut performer = AnsiPerformer::new();
2684 parser.advance(&mut performer, text.as_bytes());
2685 performer.into_lines()
2686}
2687
2688struct AnsiPerformer {
2689 lines: Vec<Line>,
2690 style: TextStyle,
2691 col: usize,
2692}
2693
2694impl AnsiPerformer {
2695 fn new() -> Self {
2696 Self {
2697 lines: vec![Line::default()],
2698 style: TextStyle::default(),
2699 col: 0,
2700 }
2701 }
2702
2703 fn current_line_mut(&mut self) -> &mut Line {
2704 if self.lines.is_empty() {
2705 self.lines.push(Line::default());
2706 }
2707 self.lines.last_mut().unwrap()
2708 }
2709
2710 fn push_char(&mut self, ch: char) {
2711 let width = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
2712 let style = self.style.clone();
2713 let line = self.current_line_mut();
2714 if let Some(last) = line.spans.last_mut() {
2715 if last.style == style {
2716 last.text.push(ch);
2717 } else {
2718 line.spans.push(Span {
2719 text: ch.to_string(),
2720 style,
2721 });
2722 }
2723 } else {
2724 line.spans.push(Span {
2725 text: ch.to_string(),
2726 style,
2727 });
2728 }
2729 self.col += width;
2730 }
2731
2732 fn new_line(&mut self) {
2733 self.lines.push(Line::default());
2734 self.col = 0;
2735 }
2736
2737 fn expand_tab(&mut self) {
2738 let mut count = 0;
2739 while !(self.col + count).is_multiple_of(ANSI_TAB_WIDTH) {
2740 count += 1;
2741 }
2742 if count == 0 {
2743 count = ANSI_TAB_WIDTH;
2744 }
2745 for _ in 0..count {
2746 self.push_char(' ');
2747 }
2748 }
2749
2750 fn reset_style(&mut self) {
2751 self.style = TextStyle::default();
2752 }
2753}
2754
2755impl vte::Perform for AnsiPerformer {
2756 fn print(&mut self, c: char) {
2757 self.push_char(c);
2758 }
2759
2760 fn execute(&mut self, byte: u8) {
2761 match byte {
2762 b'\n' => self.new_line(),
2763 b'\r' => {
2764 self.col = 0;
2765 }
2766 b'\t' => self.expand_tab(),
2767 _ => {}
2768 }
2769 }
2770
2771 fn csi_dispatch(
2772 &mut self,
2773 params: &vte::Params,
2774 _intermediates: &[u8],
2775 _ignore: bool,
2776 action: char,
2777 ) {
2778 if action != 'm' {
2779 return;
2780 }
2781
2782 let mut values = params_to_vec(params);
2783 if values.is_empty() {
2784 values.push(0);
2785 }
2786
2787 let mut i = 0;
2788 while i < values.len() {
2789 match values[i] {
2790 0 => self.reset_style(),
2791 1 => self.style.bold = true,
2792 3 => self.style.italic = true,
2793 4 => self.style.underline = true,
2794 9 => self.style.strike = true,
2795 22 => self.style.bold = false,
2796 23 => self.style.italic = false,
2797 24 => self.style.underline = false,
2798 29 => self.style.strike = false,
2799 30..=37 => self.style.fg = Some(ansi_color(values[i] as u8)),
2800 39 => self.style.fg = None,
2801 40..=47 => self.style.bg = Some(ansi_color((values[i] - 10) as u8)),
2802 49 => self.style.bg = None,
2803 90..=97 => self.style.fg = Some(ansi_color((values[i] - 60) as u8)),
2804 100..=107 => self.style.bg = Some(ansi_color((values[i] - 90) as u8)),
2805 38 => {
2806 if let Some((color, consumed)) = parse_extended_color(&values[i + 1..]) {
2807 self.style.fg = Some(color);
2808 i += consumed;
2809 }
2810 }
2811 48 => {
2812 if let Some((color, consumed)) = parse_extended_color(&values[i + 1..]) {
2813 self.style.bg = Some(color);
2814 i += consumed;
2815 }
2816 }
2817 _ => {}
2818 }
2819 i += 1;
2820 }
2821 }
2822}
2823
2824impl AnsiPerformer {
2825 fn into_lines(mut self) -> Vec<Line> {
2826 if self.lines.is_empty() {
2827 self.lines.push(Line::default());
2828 }
2829 self.lines
2830 }
2831}
2832
2833fn params_to_vec(params: &vte::Params) -> Vec<u16> {
2834 let mut values = Vec::new();
2835 for p in params.iter() {
2836 if p.is_empty() {
2837 values.push(0);
2838 } else {
2839 values.push(p[0]);
2840 }
2841 }
2842 values
2843}
2844
2845fn parse_extended_color(values: &[u16]) -> Option<(String, usize)> {
2846 if values.is_empty() {
2847 return None;
2848 }
2849 match values[0] {
2850 5 => {
2851 if values.len() >= 2 {
2852 Some((xterm_color(values[1] as u8), 2))
2853 } else {
2854 None
2855 }
2856 }
2857 2 => {
2858 if values.len() >= 4 {
2859 let r = values[1] as u8;
2860 let g = values[2] as u8;
2861 let b = values[3] as u8;
2862 Some((format!("#{r:02X}{g:02X}{b:02X}"), 4))
2863 } else {
2864 None
2865 }
2866 }
2867 _ => None,
2868 }
2869}
2870
2871fn ansi_color(code: u8) -> String {
2872 let palette = [
2873 "#282a2e", "#D74E6F", "#31BB71", "#D3E561", "#8056FF", "#ED61D7", "#04D7D7", "#C5C8C6",
2874 "#4B4B4B", "#FE5F86", "#00D787", "#EBFF71", "#8F69FF", "#FF7AEA", "#00FEFE", "#FFFFFF",
2875 ];
2876 let idx = match code {
2877 30..=37 => (code - 30) as usize,
2878 40..=47 => (code - 40) as usize,
2879 90..=97 => (code - 90 + 8) as usize,
2880 100..=107 => (code - 100 + 8) as usize,
2881 _ => code as usize,
2882 };
2883 if idx < palette.len() {
2884 palette[idx].to_string()
2885 } else {
2886 "#C5C8C6".to_string()
2887 }
2888}
2889
2890fn xterm_color(idx: u8) -> String {
2891 if idx < 16 {
2892 return ansi_color(idx);
2893 }
2894 if idx >= 232 {
2895 let v = 8 + (idx - 232) * 10;
2896 return format!("#{v:02X}{v:02X}{v:02X}");
2897 }
2898 let idx = idx - 16;
2899 let r = idx / 36;
2900 let g = (idx % 36) / 6;
2901 let b = idx % 6;
2902 let to_comp = |v: u8| if v == 0 { 0 } else { 55 + 40 * v };
2903 let rr = to_comp(r);
2904 let gg = to_comp(g);
2905 let bb = to_comp(b);
2906 format!("#{rr:02X}{gg:02X}{bb:02X}")
2907}
2908
2909fn color_to_hex(color: syntect::highlighting::Color) -> String {
2910 format!("#{:02X}{:02X}{:02X}", color.r, color.g, color.b)
2911}
2912
2913fn deserialize_box<'de, D>(deserializer: D) -> std::result::Result<Vec<f32>, D::Error>
2914where
2915 D: serde::Deserializer<'de>,
2916{
2917 let value = serde_json::Value::deserialize(deserializer)?;
2918 parse_box_value(&value).map_err(serde::de::Error::custom)
2919}
2920
2921fn parse_box_value(value: &serde_json::Value) -> std::result::Result<Vec<f32>, String> {
2922 match value {
2923 serde_json::Value::Number(n) => n
2924 .as_f64()
2925 .map(|v| vec![v as f32])
2926 .ok_or_else(|| "invalid number".to_string()),
2927 serde_json::Value::String(s) => parse_box_string(s),
2928 serde_json::Value::Array(arr) => {
2929 let mut out = Vec::new();
2930 for item in arr {
2931 match item {
2932 serde_json::Value::Number(n) => {
2933 out.push(n.as_f64().ok_or_else(|| "invalid number".to_string())? as f32);
2934 }
2935 serde_json::Value::String(s) => {
2936 let parsed = parse_box_string(s)?;
2937 out.extend(parsed);
2938 }
2939 _ => return Err("invalid array value".to_string()),
2940 }
2941 }
2942 if matches!(out.len(), 1 | 2 | 4) {
2943 Ok(out)
2944 } else {
2945 Err(format!("expected 1, 2, or 4 values, got {}", out.len()))
2946 }
2947 }
2948 serde_json::Value::Null => Ok(vec![0.0]),
2949 _ => Err("invalid box value".to_string()),
2950 }
2951}
2952
2953fn parse_box_string(input: &str) -> std::result::Result<Vec<f32>, String> {
2954 let parts: Vec<&str> = input.split([',', ' ']).filter(|s| !s.is_empty()).collect();
2955 if parts.is_empty() {
2956 return Ok(vec![0.0]);
2957 }
2958 let mut out = Vec::new();
2959 for part in parts {
2960 let value = part
2961 .parse::<f32>()
2962 .map_err(|_| format!("invalid number {}", part))?;
2963 out.push(value);
2964 }
2965 if matches!(out.len(), 1 | 2 | 4) {
2966 Ok(out)
2967 } else {
2968 Err(format!("expected 1, 2, or 4 values, got {}", out.len()))
2969 }
2970}
2971
2972fn deserialize_lines<'de, D>(deserializer: D) -> std::result::Result<Vec<i32>, D::Error>
2973where
2974 D: serde::Deserializer<'de>,
2975{
2976 let value = serde_json::Value::deserialize(deserializer)?;
2977 parse_lines_value(&value).map_err(serde::de::Error::custom)
2978}
2979
2980fn parse_lines_value(value: &serde_json::Value) -> std::result::Result<Vec<i32>, String> {
2981 match value {
2982 serde_json::Value::Number(n) => n
2983 .as_i64()
2984 .map(|v| vec![v as i32])
2985 .ok_or_else(|| "invalid number".to_string()),
2986 serde_json::Value::String(s) => parse_lines_string(s),
2987 serde_json::Value::Array(arr) => {
2988 let mut out = Vec::new();
2989 for item in arr {
2990 match item {
2991 serde_json::Value::Number(n) => {
2992 out.push(n.as_i64().ok_or_else(|| "invalid number".to_string())? as i32);
2993 }
2994 serde_json::Value::String(s) => {
2995 let parsed = parse_lines_string(s)?;
2996 out.extend(parsed);
2997 }
2998 _ => return Err("invalid array value".to_string()),
2999 }
3000 }
3001 if matches!(out.len(), 1 | 2) {
3002 Ok(out)
3003 } else {
3004 Err(format!("expected 1 or 2 values, got {}", out.len()))
3005 }
3006 }
3007 serde_json::Value::Null => Ok(vec![]),
3008 _ => Err("invalid lines value".to_string()),
3009 }
3010}
3011
3012fn parse_lines_string(input: &str) -> std::result::Result<Vec<i32>, String> {
3013 let parts: Vec<&str> = input.split([',', ' ']).filter(|s| !s.is_empty()).collect();
3014 if parts.is_empty() {
3015 return Ok(vec![]);
3016 }
3017 let mut out = Vec::new();
3018 for part in parts {
3019 let value = part
3020 .parse::<i32>()
3021 .map_err(|_| format!("invalid number {}", part))?;
3022 out.push(value);
3023 }
3024 if matches!(out.len(), 1 | 2) {
3025 Ok(out)
3026 } else {
3027 Err(format!("expected 1 or 2 values, got {}", out.len()))
3028 }
3029}
3030
3031fn charm_theme() -> syntect::highlighting::Theme {
3032 use std::str::FromStr;
3033 use syntect::highlighting::{
3034 Color, FontStyle, ScopeSelectors, Theme, ThemeItem, ThemeSettings,
3035 };
3036
3037 let mut theme = Theme {
3038 name: Some("charm".to_string()),
3039 author: Some("cryosnap".to_string()),
3040 settings: ThemeSettings {
3041 foreground: Some(Color {
3042 r: 0xC4,
3043 g: 0xC4,
3044 b: 0xC4,
3045 a: 0xFF,
3046 }),
3047 background: Some(Color {
3048 r: 0x17,
3049 g: 0x17,
3050 b: 0x17,
3051 a: 0xFF,
3052 }),
3053 ..ThemeSettings::default()
3054 },
3055 scopes: Vec::new(),
3056 };
3057
3058 let mut push = |scope: &str, fg: &str, style: FontStyle| {
3059 let scope = ScopeSelectors::from_str(scope)
3060 .unwrap_or_else(|_| ScopeSelectors::from_str("text").unwrap());
3061 let color = hex_to_color(fg);
3062 theme.scopes.push(ThemeItem {
3063 scope,
3064 style: syntect::highlighting::StyleModifier {
3065 foreground: Some(color),
3066 background: None,
3067 font_style: Some(style),
3068 },
3069 });
3070 };
3071
3072 push("comment", "#676767", FontStyle::empty());
3073 push("comment.preproc", "#FF875F", FontStyle::empty());
3074 push("keyword", "#00AAFF", FontStyle::empty());
3075 push("keyword.reserved", "#FF48DD", FontStyle::empty());
3076 push("keyword.namespace", "#FF5F87", FontStyle::empty());
3077 push("storage.type", "#635ADF", FontStyle::empty());
3078 push("operator", "#FF7F83", FontStyle::empty());
3079 push("punctuation", "#E8E8A8", FontStyle::empty());
3080 push("constant.numeric", "#6EEFC0", FontStyle::empty());
3081 push("string", "#E38356", FontStyle::empty());
3082 push("string.escape", "#AFFFD7", FontStyle::empty());
3083 push("entity.name.function", "#00DC7F", FontStyle::empty());
3084 push("entity.name.tag", "#B083EA", FontStyle::empty());
3085 push("entity.name.attribute", "#7A7AE6", FontStyle::empty());
3086 push(
3087 "entity.name.class",
3088 "#F1F1F1",
3089 FontStyle::BOLD | FontStyle::UNDERLINE,
3090 );
3091 push("entity.name.decorator", "#FFFF87", FontStyle::empty());
3092
3093 theme
3094}
3095
3096fn hex_to_color(hex: &str) -> syntect::highlighting::Color {
3097 let hex = hex.trim_start_matches('#');
3098 if hex.len() != 6 {
3099 return syntect::highlighting::Color::WHITE;
3100 }
3101 let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(0);
3102 let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(0);
3103 let b = u8::from_str_radix(&hex[4..6], 16).unwrap_or(0);
3104 syntect::highlighting::Color { r, g, b, a: 0xFF }
3105}
3106
3107struct CutResult {
3108 text: String,
3109 start: usize,
3110}
3111
3112fn cut_text(text: &str, window: &[i32]) -> CutResult {
3113 if window.is_empty() {
3114 return CutResult {
3115 text: text.to_string(),
3116 start: 0,
3117 };
3118 }
3119 if window.len() == 1 && window[0] == 0 {
3120 return CutResult {
3121 text: text.to_string(),
3122 start: 0,
3123 };
3124 }
3125 if window.len() == 2 && window[0] == 0 && window[1] == -1 {
3126 return CutResult {
3127 text: text.to_string(),
3128 start: 0,
3129 };
3130 }
3131
3132 let lines: Vec<&str> = text.split('\n').collect();
3133 let total = lines.len() as i32;
3134 let mut start;
3135 let mut end = total;
3136
3137 match window.len() {
3138 1 => {
3139 if window[0] > 0 {
3140 start = window[0];
3141 } else {
3142 start = total + window[0];
3143 }
3144 }
3145 _ => {
3146 start = window[0];
3147 end = window[1];
3148 }
3149 }
3150
3151 if start < 0 {
3152 start = 0;
3153 }
3154 if start > total {
3155 start = total;
3156 }
3157 end += 1;
3158 if end < start {
3159 end = start;
3160 }
3161 if end > total {
3162 end = total;
3163 }
3164
3165 let start_usize = start as usize;
3166 let end_usize = end as usize;
3167 if start_usize >= lines.len() {
3168 return CutResult {
3169 text: String::new(),
3170 start: start_usize,
3171 };
3172 }
3173 CutResult {
3174 text: lines[start_usize..end_usize].join("\n"),
3175 start: start_usize,
3176 }
3177}
3178
3179fn detab(text: &str, tab_width: usize) -> String {
3180 let mut out = String::new();
3181 let mut col = 0usize;
3182 for ch in text.chars() {
3183 if ch == '\t' {
3184 let mut count = 0;
3185 while !(col + count).is_multiple_of(tab_width) {
3186 count += 1;
3187 }
3188 if count == 0 {
3189 count = tab_width;
3190 }
3191 for _ in 0..count {
3192 out.push(' ');
3193 }
3194 col += count;
3195 } else {
3196 if ch == '\n' {
3197 col = 0;
3198 } else {
3199 col += unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
3200 }
3201 out.push(ch);
3202 }
3203 }
3204 out
3205}
3206
3207fn wrap_text(text: &str, width: usize) -> String {
3208 if width == 0 {
3209 return text.to_string();
3210 }
3211 let mut out_lines = Vec::new();
3212 for line in text.split('\n') {
3213 let mut current = String::new();
3214 let mut current_width = 0usize;
3215 for ch in line.chars() {
3216 let w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
3217 if current_width + w > width && !current.is_empty() {
3218 out_lines.push(current);
3219 current = String::new();
3220 current_width = 0;
3221 }
3222 current.push(ch);
3223 current_width += w;
3224 if current_width >= width {
3225 out_lines.push(current);
3226 current = String::new();
3227 current_width = 0;
3228 }
3229 }
3230 out_lines.push(current);
3231 }
3232 out_lines.join("\n")
3233}
3234
3235fn wrap_ansi_lines(lines: &[Line], width: usize) -> Vec<Line> {
3236 if width == 0 {
3237 return lines.to_vec();
3238 }
3239 let mut out = Vec::new();
3240 for line in lines {
3241 out.extend(split_line_by_width(line, width));
3242 }
3243 out
3244}
3245
3246fn split_line_by_width(line: &Line, width: usize) -> Vec<Line> {
3247 if width == 0 {
3248 return vec![line.clone()];
3249 }
3250 let mut out = Vec::new();
3251 let mut current = Line::default();
3252 let mut current_width = 0usize;
3253
3254 for span in &line.spans {
3255 let mut buf = String::new();
3256 for ch in span.text.chars() {
3257 let w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
3258 if current_width + w > width && !current.spans.is_empty() {
3259 if !buf.is_empty() {
3260 current.spans.push(Span {
3261 text: buf.clone(),
3262 style: span.style.clone(),
3263 });
3264 buf.clear();
3265 }
3266 out.push(current);
3267 current = Line::default();
3268 current_width = 0;
3269 }
3270 buf.push(ch);
3271 current_width += w;
3272 if current_width >= width {
3273 current.spans.push(Span {
3274 text: buf.clone(),
3275 style: span.style.clone(),
3276 });
3277 buf.clear();
3278 out.push(current);
3279 current = Line::default();
3280 current_width = 0;
3281 }
3282 }
3283 if !buf.is_empty() {
3284 current.spans.push(Span {
3285 text: buf.clone(),
3286 style: span.style.clone(),
3287 });
3288 }
3289 }
3290
3291 if !current.spans.is_empty() {
3292 out.push(current);
3293 }
3294 if out.is_empty() {
3295 out.push(Line::default());
3296 }
3297 out
3298}
3299
3300fn push_span(spans: &mut Vec<Span>, text: String, style: TextStyle) {
3301 if let Some(last) = spans.last_mut() {
3302 if last.style == style {
3303 last.text.push_str(&text);
3304 return;
3305 }
3306 }
3307 spans.push(Span { text, style });
3308}
3309
3310fn build_svg(
3311 lines: &[Line],
3312 config: &Config,
3313 default_fg: &str,
3314 font_css: Option<String>,
3315 line_offset: usize,
3316 title_text: Option<&str>,
3317 font_family: &str,
3318) -> String {
3319 let padding = expand_box(&config.padding);
3320 let margin = expand_box(&config.margin);
3321 let mut pad_top = padding[0];
3322 let pad_right = padding[1];
3323 let pad_bottom = padding[2];
3324 let pad_left = padding[3];
3325 let margin_top = margin[0];
3326 let margin_right = margin[1];
3327 let margin_bottom = margin[2];
3328 let margin_left = margin[3];
3329
3330 if config.window_controls {
3331 pad_top += WINDOW_CONTROLS_HEIGHT;
3332 }
3333
3334 let line_height_px = config.font.size * config.line_height;
3335 let char_width = config.font.size / FONT_HEIGHT_TO_WIDTH_RATIO;
3336 let line_count = std::cmp::max(1, lines.len());
3337
3338 let line_number_cells = if config.show_line_numbers {
3339 let digits = std::cmp::max(3, line_count.to_string().len());
3340 digits + 2
3341 } else {
3342 0
3343 };
3344
3345 let mut max_cells = 0usize;
3346 for line in lines {
3347 let width = line_width_cells(line);
3348 max_cells = max_cells.max(width);
3349 }
3350 max_cells += line_number_cells;
3351
3352 let content_width = max_cells as f32 * char_width;
3353 let content_height = line_count as f32 * line_height_px;
3354
3355 let mut terminal_width = content_width + pad_left + pad_right;
3356 let mut terminal_height = content_height + pad_top + pad_bottom;
3357 let mut image_width = terminal_width + margin_left + margin_right;
3358 let mut image_height = terminal_height + margin_top + margin_bottom;
3359
3360 if config.width > 0.0 {
3361 image_width = config.width;
3362 terminal_width = (image_width - margin_left - margin_right).max(0.0);
3363 }
3364 if config.height > 0.0 {
3365 image_height = config.height;
3366 terminal_height = (image_height - margin_top - margin_bottom).max(0.0);
3367 }
3368
3369 let content_width = (terminal_width - pad_left - pad_right).max(0.0);
3370 let content_height = (terminal_height - pad_top - pad_bottom).max(0.0);
3371
3372 let max_visible_lines = if config.height > 0.0 {
3373 let lines_fit = (content_height / line_height_px).floor() as usize;
3374 std::cmp::max(1, lines_fit)
3375 } else {
3376 line_count
3377 };
3378
3379 let mut svg = String::new();
3380 svg.push_str(&format!(
3381 r#"<svg xmlns="http://www.w3.org/2000/svg" width="{:.2}" height="{:.2}">"#,
3382 image_width, image_height
3383 ));
3384 if let Some(font_css) = font_css {
3385 svg.push_str(r#"<defs><style type="text/css">"#);
3386 svg.push_str(&font_css);
3387 svg.push_str("</style></defs>");
3388 }
3389
3390 if config.shadow.blur > 0.0 || config.shadow.x != 0.0 || config.shadow.y != 0.0 {
3391 svg.push_str(r#"<defs><filter id="shadow" filterUnits="userSpaceOnUse">"#);
3392 svg.push_str(&format!(
3393 r#"<feGaussianBlur in="SourceAlpha" stdDeviation="{:.2}"/>"#,
3394 config.shadow.blur
3395 ));
3396 svg.push_str(&format!(
3397 r#"<feOffset dx="{:.2}" dy="{:.2}" result="offsetblur"/>"#,
3398 config.shadow.x, config.shadow.y
3399 ));
3400 svg.push_str(r#"<feMerge><feMergeNode/><feMergeNode in="SourceGraphic"/></feMerge>"#);
3401 svg.push_str("</filter></defs>");
3402 }
3403
3404 let terminal_x = margin_left;
3405 let terminal_y = margin_top;
3406 let mut terminal_attrs = String::new();
3407 if config.border.radius > 0.0 {
3408 terminal_attrs.push_str(&format!(
3409 r#" rx="{:.2}" ry="{:.2}""#,
3410 config.border.radius, config.border.radius
3411 ));
3412 }
3413 if config.border.width > 0.0 {
3414 terminal_attrs.push_str(&format!(
3415 r#" stroke="{}" stroke-width="{:.2}""#,
3416 config.border.color, config.border.width
3417 ));
3418 }
3419 if config.shadow.blur > 0.0 || config.shadow.x != 0.0 || config.shadow.y != 0.0 {
3420 terminal_attrs.push_str(r#" filter="url(#shadow)""#);
3421 }
3422
3423 let border_inset = config.border.width / 2.0;
3424 svg.push_str(&format!(
3425 r#"<rect x="{:.2}" y="{:.2}" width="{:.2}" height="{:.2}" fill="{}"{} />"#,
3426 terminal_x + border_inset,
3427 terminal_y + border_inset,
3428 (terminal_width - config.border.width).max(0.0),
3429 (terminal_height - config.border.width).max(0.0),
3430 config.background,
3431 terminal_attrs
3432 ));
3433
3434 svg.push_str(&format!(
3435 r#"<defs><clipPath id="contentClip"><rect x="{:.2}" y="{:.2}" width="{:.2}" height="{:.2}"/></clipPath></defs>"#,
3436 terminal_x + pad_left,
3437 terminal_y + pad_top - config.font.size,
3438 content_width,
3439 (content_height + config.font.size).max(0.0)
3440 ));
3441
3442 if config.window_controls {
3443 let r = 5.5;
3444 let x = terminal_x + border_inset + WINDOW_CONTROLS_X_OFFSET;
3445 let y = terminal_y + WINDOW_CONTROLS_X_OFFSET;
3446 svg.push_str(&format!(
3447 r##"<circle cx="{:.2}" cy="{:.2}" r="{:.2}" fill="#FF5A54"/>"##,
3448 x, y, r
3449 ));
3450 svg.push_str(&format!(
3451 r##"<circle cx="{:.2}" cy="{:.2}" r="{:.2}" fill="#E6BF29"/>"##,
3452 x + WINDOW_CONTROLS_SPACING,
3453 y,
3454 r
3455 ));
3456 svg.push_str(&format!(
3457 r##"<circle cx="{:.2}" cy="{:.2}" r="{:.2}" fill="#52C12B"/>"##,
3458 x + WINDOW_CONTROLS_SPACING * 2.0,
3459 y,
3460 r
3461 ));
3462
3463 if let Some(title_text) = title_text {
3464 let title = sanitize_title_text(title_text);
3465 if !title.is_empty() {
3466 let title_size = if config.title.size > 0.0 {
3467 config.title.size
3468 } else {
3469 (config.font.size - 2.0).max(8.0)
3470 };
3471 let char_width = title_size / FONT_HEIGHT_TO_WIDTH_RATIO;
3472 let controls_right = x + WINDOW_CONTROLS_SPACING * 2.0 + r;
3473 let title_margin = WINDOW_CONTROLS_X_OFFSET;
3474 let left_reserved = (controls_right - terminal_x) + title_margin;
3475 let right_reserved = title_margin;
3476 let available_px = match config.title.align {
3477 TitleAlign::Center => terminal_width - 2.0 * left_reserved,
3478 _ => terminal_width - left_reserved - right_reserved,
3479 };
3480
3481 if available_px > 0.0 {
3482 let max_cells_from_width =
3483 (available_px / char_width).floor().max(0.0) as usize;
3484 let mut max_cells = max_cells_from_width;
3485 if config.title.max_width > 0 {
3486 max_cells = max_cells.min(config.title.max_width);
3487 }
3488 let truncated = truncate_to_cells(&title, max_cells, &config.title.ellipsis);
3489 if !truncated.is_empty() {
3490 let (title_x, anchor) = match config.title.align {
3491 TitleAlign::Left => (terminal_x + left_reserved, "start"),
3492 TitleAlign::Center => (terminal_x + terminal_width / 2.0, "middle"),
3493 TitleAlign::Right => {
3494 (terminal_x + terminal_width - right_reserved, "end")
3495 }
3496 };
3497 let title_y = terminal_y + WINDOW_CONTROLS_X_OFFSET + (title_size * 0.35);
3498 let opacity = config.title.opacity.clamp(0.0, 1.0);
3499 let opacity_attr = if opacity < 1.0 {
3500 format!(r#" fill-opacity="{:.2}""#, opacity)
3501 } else {
3502 String::new()
3503 };
3504 svg.push_str(&format!(
3505 r#"<text x="{:.2}" y="{:.2}" fill="{}" font-family="{}" font-size="{:.2}px" text-anchor="{}"{}>{}</text>"#,
3506 title_x,
3507 title_y,
3508 escape_attr(&config.title.color),
3509 escape_attr(font_family),
3510 title_size,
3511 anchor,
3512 opacity_attr,
3513 escape_text(&truncated)
3514 ));
3515 }
3516 }
3517 }
3518 }
3519 }
3520
3521 svg.push_str(&format!(
3522 r#"<g font-family="{}" font-size="{:.2}px" clip-path="url(#contentClip)">"#,
3523 escape_attr(font_family),
3524 config.font.size
3525 ));
3526 let mut bg_layer = String::new();
3527 let mut text_layer = String::new();
3528
3529 let line_number_width_px = line_number_cells as f32 * char_width;
3530 for (idx, line) in lines.iter().take(max_visible_lines).enumerate() {
3531 let line_idx = idx as f32;
3532 let y = terminal_y + pad_top + line_height_px * (line_idx + 1.0);
3533 let base_x = terminal_x + pad_left;
3534
3535 if config.show_line_numbers {
3536 let number_text = format!(
3537 "{:>width$} ",
3538 idx + 1 + line_offset,
3539 width = line_number_cells - 2
3540 );
3541 text_layer.push_str(&format!(
3542 r##"<text x="{:.2}" y="{:.2}" fill="#777777" xml:space="preserve">{}</text>"##,
3543 base_x,
3544 y,
3545 escape_text(&number_text)
3546 ));
3547 }
3548
3549 let text_x = base_x + line_number_width_px;
3550 text_layer.push_str(&format!(
3551 r#"<text x="{:.2}" y="{:.2}" fill="{}" xml:space="preserve">"#,
3552 text_x, y, default_fg
3553 ));
3554
3555 let mut cursor_x = text_x;
3556 for span in &line.spans {
3557 let text = &span.text;
3558 let width_px = span_width_px(text, char_width);
3559 if let Some(bg) = &span.style.bg {
3560 let rect_y = y - config.font.size;
3561 bg_layer.push_str(&format!(
3562 r#"<rect x="{:.2}" y="{:.2}" width="{:.2}" height="{:.2}" fill="{}"/>"#,
3563 cursor_x, rect_y, width_px, line_height_px, bg
3564 ));
3565 }
3566
3567 let mut attrs = String::new();
3568 if let Some(fg) = &span.style.fg {
3569 attrs.push_str(&format!(r#" fill="{}""#, fg));
3570 }
3571 if span.style.bold {
3572 attrs.push_str(r#" font-weight="bold""#);
3573 }
3574 if span.style.italic {
3575 attrs.push_str(r#" font-style="italic""#);
3576 }
3577 if span.style.underline || span.style.strike {
3578 let mut deco = Vec::new();
3579 if span.style.underline {
3580 deco.push("underline");
3581 }
3582 if span.style.strike {
3583 deco.push("line-through");
3584 }
3585 attrs.push_str(&format!(r#" text-decoration="{}""#, deco.join(" ")));
3586 }
3587
3588 text_layer.push_str(&format!(
3589 r#"<tspan xml:space="preserve"{}>{}</tspan>"#,
3590 attrs,
3591 escape_text(text)
3592 ));
3593 cursor_x += width_px;
3594 }
3595 text_layer.push_str("</text>");
3596 }
3597
3598 svg.push_str(&bg_layer);
3599 svg.push_str(&text_layer);
3600 svg.push_str("</g></svg>");
3601 svg
3602}
3603
3604fn expand_box(values: &[f32]) -> [f32; 4] {
3605 match values.len() {
3606 1 => [values[0], values[0], values[0], values[0]],
3607 2 => [values[0], values[1], values[0], values[1]],
3608 4 => [values[0], values[1], values[2], values[3]],
3609 _ => [0.0, 0.0, 0.0, 0.0],
3610 }
3611}
3612
3613fn line_width_cells(line: &Line) -> usize {
3614 let mut width = 0usize;
3615 for span in &line.spans {
3616 for ch in span.text.chars() {
3617 if ch == '\t' {
3618 let mut count = 0;
3619 while !(width + count).is_multiple_of(DEFAULT_TAB_WIDTH) {
3620 count += 1;
3621 }
3622 if count == 0 {
3623 count = DEFAULT_TAB_WIDTH;
3624 }
3625 width += count;
3626 } else {
3627 width += unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
3628 }
3629 }
3630 }
3631 width
3632}
3633
3634fn span_width_px(text: &str, char_width: f32) -> f32 {
3635 let mut width = 0usize;
3636 for ch in text.chars() {
3637 if ch == '\t' {
3638 let mut count = 0;
3639 while !(width + count).is_multiple_of(DEFAULT_TAB_WIDTH) {
3640 count += 1;
3641 }
3642 if count == 0 {
3643 count = DEFAULT_TAB_WIDTH;
3644 }
3645 width += count;
3646 } else {
3647 width += unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
3648 }
3649 }
3650 width as f32 * char_width
3651}
3652
3653fn escape_text(text: &str) -> String {
3654 text.replace('&', "&")
3655 .replace('<', "<")
3656 .replace('>', ">")
3657}
3658
3659fn escape_attr(text: &str) -> String {
3660 escape_text(text).replace('"', """)
3661}
3662
3663fn svg_font_face_css(config: &Config) -> Result<Option<String>> {
3664 let mut rules = Vec::new();
3665 let mut push_rule = |family: &str, data: Vec<u8>, format: &str, mime: &str| {
3666 let encoded = base64::engine::general_purpose::STANDARD.encode(data);
3667 rules.push(format!(
3668 "@font-face {{ font-family: '{}'; src: url(data:{};base64,{}) format('{}'); }}",
3669 escape_attr(family),
3670 mime,
3671 encoded,
3672 format
3673 ));
3674 };
3675 if let Some(font_file) = &config.font.file {
3676 let bytes = std::fs::read(font_file)?;
3677 let ext = Path::new(font_file)
3678 .extension()
3679 .and_then(|v| v.to_str())
3680 .unwrap_or("")
3681 .to_ascii_lowercase();
3682 let (format, mime) = match ext.as_str() {
3683 "ttf" => ("truetype", "font/ttf"),
3684 "woff2" => ("woff2", "font/woff2"),
3685 "woff" => ("woff", "font/woff"),
3686 _ => ("truetype", "font/ttf"),
3687 };
3688 push_rule(&config.font.family, bytes, format, mime);
3689 }
3690
3691 if rules.is_empty() {
3692 Ok(None)
3693 } else {
3694 Ok(Some(rules.join("")))
3695 }
3696}
3697
3698#[cfg(test)]
3699mod tests {
3700 use super::*;
3701 use std::collections::HashMap;
3702 use std::sync::{Mutex, OnceLock};
3703
3704 #[test]
3705 fn deserialize_box_values() {
3706 let cfg: Config = serde_json::from_str(r#"{"padding":"10,20","margin":[1,2,3,4]}"#)
3707 .expect("parse config");
3708 assert_eq!(cfg.padding, vec![10.0, 20.0]);
3709 assert_eq!(cfg.margin, vec![1.0, 2.0, 3.0, 4.0]);
3710 }
3711
3712 #[test]
3713 fn deserialize_lines_values() {
3714 let cfg: Config = serde_json::from_str(r#"{"lines":"2,4"}"#).expect("parse config");
3715 assert_eq!(cfg.lines, vec![2, 4]);
3716 }
3717
3718 #[test]
3719 fn cut_text_window() {
3720 let input = "a\nb\nc\nd";
3721 let result = cut_text(input, &[1, 2]);
3722 assert_eq!(result.text, "b\nc");
3723 assert_eq!(result.start, 1);
3724 }
3725
3726 #[test]
3727 fn detab_expands() {
3728 let input = "a\tb";
3729 let out = detab(input, 4);
3730 assert_eq!(out, "a b");
3731 }
3732
3733 #[test]
3734 fn wrap_text_basic() {
3735 let input = "abcd";
3736 let out = wrap_text(input, 3);
3737 assert_eq!(out, "abc\nd");
3738 }
3739
3740 #[test]
3741 fn parse_ansi_colors() {
3742 let input = "A\x1b[31mB\x1b[0mC";
3743 let lines = parse_ansi(input);
3744 assert_eq!(lines.len(), 1);
3745 let spans = &lines[0].spans;
3746 assert!(spans
3747 .iter()
3748 .any(|s| s.text == "B" && s.style.fg == Some("#D74E6F".to_string())));
3749 }
3750
3751 #[test]
3752 fn wrap_ansi_lines_basic() {
3753 let line = Line {
3754 spans: vec![Span {
3755 text: "abcdef".to_string(),
3756 style: TextStyle::default(),
3757 }],
3758 };
3759 let out = wrap_ansi_lines(&[line], 3);
3760 assert_eq!(out.len(), 2);
3761 assert_eq!(out[0].spans[0].text, "abc");
3762 assert_eq!(out[1].spans[0].text, "def");
3763 }
3764
3765 #[test]
3766 fn build_svg_includes_border_shadow() {
3767 let line = Line {
3768 spans: vec![Span {
3769 text: "hi".to_string(),
3770 style: TextStyle::default(),
3771 }],
3772 };
3773 let mut cfg = Config::default();
3774 cfg.font.family = "Test".to_string();
3775 cfg.border.radius = 4.0;
3776 cfg.border.width = 1.0;
3777 cfg.shadow.blur = 6.0;
3778 cfg.window_controls = true;
3779 cfg.show_line_numbers = true;
3780 let svg = build_svg(&[line], &cfg, "#FFFFFF", None, 0, None, &cfg.font.family);
3781 assert!(svg.contains("filter id=\"shadow\""));
3782 assert!(svg.contains("clipPath"));
3783 assert!(svg.contains("font-family=\"Test\""));
3784 assert!(svg.contains("circle"));
3785 }
3786
3787 #[test]
3788 fn render_svg_basic() {
3789 let mut cfg = Config::default();
3790 cfg.font.family = "Test".to_string();
3791 let request = RenderRequest {
3792 input: InputSource::Text("fn main() {}".to_string()),
3793 config: cfg,
3794 format: OutputFormat::Svg,
3795 };
3796 let result = render(&request).expect("render svg");
3797 let svg = String::from_utf8(result.bytes).expect("utf8");
3798 assert!(svg.contains("<svg"));
3799 }
3800
3801 #[test]
3802 fn svg_font_face_css_respects_family() {
3803 let mut cfg = Config::default();
3804 let path =
3805 std::env::temp_dir().join(format!("cryosnap-font-test-{}.ttf", std::process::id()));
3806 std::fs::write(&path, b"font").expect("write temp font");
3807 cfg.font.family = "Custom".to_string();
3808 cfg.font.file = Some(path.to_string_lossy().to_string());
3809 let css = svg_font_face_css(&cfg).expect("css");
3810 assert!(css.is_some());
3811
3812 cfg.font.file = None;
3813 let css = svg_font_face_css(&cfg).expect("css");
3814 assert!(css.is_none());
3815 let _ = std::fs::remove_file(path);
3816 }
3817
3818 #[test]
3819 fn normalize_repo_key_strips_punct() {
3820 assert_eq!(normalize_repo_key("N'Ko"), "nko");
3821 assert_eq!(normalize_repo_key("Sign-Writing"), "signwriting");
3822 assert_eq!(normalize_repo_key("Old Hungarian"), "oldhungarian");
3823 }
3824
3825 #[test]
3826 fn script_repo_key_maps_latin() {
3827 let mut repos = HashMap::new();
3828 repos.insert(
3829 "latin-greek-cyrillic".to_string(),
3830 NotofontsRepo {
3831 families: HashMap::new(),
3832 },
3833 );
3834 let state = NotofontsState(repos);
3835 let index = build_repo_key_index(&state);
3836 assert_eq!(
3837 script_repo_key(Script::Latin, &index).as_deref(),
3838 Some("latin-greek-cyrillic")
3839 );
3840 }
3841
3842 #[test]
3843 fn tag_from_release_url_extracts() {
3844 let url = "https://github.com/notofonts/devanagari/releases/tag/NotoSansDevanagari-v2.006";
3845 assert_eq!(
3846 tag_from_release_url(url).as_deref(),
3847 Some("NotoSansDevanagari-v2.006")
3848 );
3849 }
3850
3851 #[test]
3852 fn repo_from_release_url_extracts() {
3853 let url = "https://github.com/notofonts/devanagari/releases/tag/NotoSansDevanagari-v2.006";
3854 assert_eq!(
3855 repo_from_release_url(url).as_deref(),
3856 Some("notofonts/devanagari")
3857 );
3858 }
3859
3860 #[test]
3861 fn choose_family_prefers_sans() {
3862 let mut families = HashMap::new();
3863 families.insert(
3864 "Noto Sans Devanagari".to_string(),
3865 NotofontsFamily {
3866 latest_release: Some(NotofontsRelease {
3867 url: "https://github.com/notofonts/devanagari/releases/tag/NotoSansDevanagari-v2.006".to_string(),
3868 }),
3869 files: Vec::new(),
3870 },
3871 );
3872 families.insert(
3873 "Noto Serif Devanagari".to_string(),
3874 NotofontsFamily {
3875 latest_release: Some(NotofontsRelease {
3876 url: "https://github.com/notofonts/devanagari/releases/tag/NotoSerifDevanagari-v2.006".to_string(),
3877 }),
3878 files: Vec::new(),
3879 },
3880 );
3881 let picked = choose_family_name(&families, FontStylePreference::Sans).expect("family");
3882 assert_eq!(picked, "Noto Sans Devanagari");
3883 }
3884
3885 #[test]
3886 fn choose_family_avoids_supplement_when_possible() {
3887 let mut families = HashMap::new();
3888 families.insert(
3889 "Noto Sans Tamil".to_string(),
3890 NotofontsFamily {
3891 latest_release: Some(NotofontsRelease {
3892 url: "https://github.com/notofonts/tamil/releases/tag/NotoSansTamil-v2.006"
3893 .to_string(),
3894 }),
3895 files: Vec::new(),
3896 },
3897 );
3898 families.insert(
3899 "Noto Sans Tamil Supplement".to_string(),
3900 NotofontsFamily {
3901 latest_release: Some(NotofontsRelease {
3902 url: "https://github.com/notofonts/tamil/releases/tag/NotoSansTamilSupplement-v2.006".to_string(),
3903 }),
3904 files: Vec::new(),
3905 },
3906 );
3907 let picked = choose_family_name(&families, FontStylePreference::Sans).expect("family");
3908 assert_eq!(picked, "Noto Sans Tamil");
3909 }
3910
3911 #[test]
3912 fn choose_family_avoids_looped_when_possible() {
3913 let mut families = HashMap::new();
3914 families.insert(
3915 "Noto Sans Thai".to_string(),
3916 NotofontsFamily {
3917 latest_release: Some(NotofontsRelease {
3918 url: "https://github.com/notofonts/thai/releases/tag/NotoSansThai-v2.006"
3919 .to_string(),
3920 }),
3921 files: Vec::new(),
3922 },
3923 );
3924 families.insert(
3925 "Noto Sans Thai Looped".to_string(),
3926 NotofontsFamily {
3927 latest_release: Some(NotofontsRelease {
3928 url: "https://github.com/notofonts/thai/releases/tag/NotoSansThaiLooped-v2.006"
3929 .to_string(),
3930 }),
3931 files: Vec::new(),
3932 },
3933 );
3934 let picked = choose_family_name(&families, FontStylePreference::Sans).expect("family");
3935 assert_eq!(picked, "Noto Sans Thai");
3936 }
3937
3938 #[test]
3939 fn score_font_path_prefers_regular() {
3940 let regular = score_font_path("fonts/NotoSans/ttf/NotoSans-Regular.ttf").unwrap();
3941 let bold = score_font_path("fonts/NotoSans/ttf/NotoSans-Bold.ttf").unwrap();
3942 assert!(regular > bold);
3943 }
3944
3945 #[test]
3946 fn title_truncates_with_ellipsis() {
3947 let out = truncate_to_cells("abcdef", 4, "…");
3948 assert_eq!(out, "abc…");
3949 }
3950
3951 #[test]
3952 fn title_uses_absolute_path() {
3953 let mut cfg = Config {
3954 window_controls: true,
3955 ..Config::default()
3956 };
3957 cfg.title.enabled = true;
3958 cfg.title.path_style = TitlePathStyle::Absolute;
3959 let path = std::env::temp_dir().join(format!("cryosnap-title-{}.txt", std::process::id()));
3960 std::fs::write(&path, "hi").expect("write temp");
3961 let input = InputSource::File(path.clone());
3962 let title = resolve_title_text(&input, &cfg).expect("title");
3963 assert!(Path::new(&title).is_absolute());
3964 let _ = std::fs::remove_file(path);
3965 }
3966
3967 #[test]
3968 fn resolve_title_text_disabled_returns_none() {
3969 let cfg = Config {
3970 window_controls: true,
3971 title: TitleOptions {
3972 enabled: false,
3973 ..TitleOptions::default()
3974 },
3975 ..Config::default()
3976 };
3977 let input = InputSource::Text("hi".to_string());
3978 assert!(resolve_title_text(&input, &cfg).is_none());
3979 }
3980
3981 #[test]
3982 fn resolve_title_text_window_controls_off_returns_none() {
3983 let cfg = Config {
3984 window_controls: false,
3985 title: TitleOptions {
3986 enabled: true,
3987 text: Some("Title".to_string()),
3988 ..TitleOptions::default()
3989 },
3990 ..Config::default()
3991 };
3992 let input = InputSource::Text("hi".to_string());
3993 assert!(resolve_title_text(&input, &cfg).is_none());
3994 }
3995
3996 #[test]
3997 fn resolve_title_text_from_command() {
3998 let cfg = Config {
3999 window_controls: true,
4000 ..Config::default()
4001 };
4002 let input = InputSource::Command("echo hi".to_string());
4003 let title = resolve_title_text(&input, &cfg).expect("title");
4004 assert!(title.contains("cmd: echo hi"));
4005 }
4006
4007 #[test]
4008 fn title_text_from_path_basename_and_relative() {
4009 let _lock = cwd_lock().lock().expect("lock");
4010 let root = std::env::temp_dir().join(format!("cryosnap-title-test-{}", std::process::id()));
4011 std::fs::create_dir_all(&root).expect("create temp dir");
4012 let path = root.join("sample.txt");
4013 std::fs::write(&path, "hi").expect("write temp");
4014
4015 let basename = title_text_from_path(&path, TitlePathStyle::Basename);
4016 assert_eq!(basename, "sample.txt");
4017
4018 let cwd = std::env::current_dir().expect("cwd");
4019 std::env::set_current_dir(&root).expect("chdir");
4020 let resolved = std::env::current_dir().expect("cwd").join("sample.txt");
4021 let relative = title_text_from_path(&resolved, TitlePathStyle::Relative);
4022 assert_eq!(relative, "sample.txt");
4023 std::env::set_current_dir(cwd).expect("restore");
4024
4025 let _ = std::fs::remove_file(path);
4026 let _ = std::fs::remove_dir_all(root);
4027 }
4028
4029 #[test]
4030 fn sanitize_title_text_removes_newlines() {
4031 let out = sanitize_title_text("hello\nworld\rtest");
4032 assert_eq!(out, "hello world test");
4033 }
4034
4035 #[test]
4036 fn truncate_to_cells_edge_cases() {
4037 assert_eq!(truncate_to_cells("abcdef", 0, "…"), "");
4038 assert_eq!(truncate_to_cells("abcdef", 1, "..."), ".");
4039 }
4040
4041 #[test]
4042 fn unpremultiply_rgba_handles_zero_alpha() {
4043 let data = [10u8, 20, 30, 0, 100, 50, 25, 128];
4044 let out = unpremultiply_rgba(&data);
4045 assert_eq!(&out[..4], &[0, 0, 0, 0]);
4046 assert_eq!(out.len(), 8);
4047 }
4048
4049 #[test]
4050 fn encode_indexed_png_rejects_invalid_length() {
4051 let palette = vec![imagequant::RGBA::new(0, 0, 0, 255)];
4052 let indices = vec![0, 0];
4053 let err = encode_indexed_png(&palette, &indices, 1, 1).unwrap_err();
4054 assert!(err.to_string().contains("invalid index buffer"));
4055 }
4056
4057 #[test]
4058 fn render_webp_from_svg_rejects_rsvg_backend() {
4059 let mut cfg = Config::default();
4060 cfg.raster.backend = RasterBackend::Rsvg;
4061 let svg = br#"<svg width="10" height="10" xmlns="http://www.w3.org/2000/svg"></svg>"#;
4062 let err = render_webp_from_svg(svg, &cfg).unwrap_err();
4063 assert!(err.to_string().contains("rsvg backend"));
4064 }
4065
4066 #[test]
4067 fn is_ansi_input_detects_escape() {
4068 let loaded = LoadedInput {
4069 text: "hi\x1b[31m".to_string(),
4070 path: None,
4071 kind: InputKind::Code,
4072 };
4073 let cfg = Config::default();
4074 assert!(is_ansi_input(&loaded, &cfg));
4075 }
4076
4077 #[test]
4078 fn load_input_file_reads_text() {
4079 let root = std::env::temp_dir().join(format!("cryosnap-load-input-{}", std::process::id()));
4080 std::fs::create_dir_all(&root).expect("create dir");
4081 let path = root.join("input.txt");
4082 std::fs::write(&path, "hello").expect("write");
4083 let input = InputSource::File(path.clone());
4084 let loaded = load_input(&input, Duration::from_millis(1000)).expect("load");
4085 assert_eq!(loaded.text, "hello");
4086 assert_eq!(loaded.path, Some(path));
4087 let _ = std::fs::remove_dir_all(root);
4088 }
4089
4090 #[test]
4091 fn execute_command_rejects_empty() {
4092 let err = execute_command(" ", Duration::from_millis(1000)).unwrap_err();
4093 assert!(err.to_string().contains("empty command"));
4094 }
4095
4096 #[test]
4097 fn render_png_basic() {
4098 let mut cfg = Config::default();
4099 cfg.font.family = "Test".to_string();
4100 let request = RenderRequest {
4101 input: InputSource::Text("hi".to_string()),
4102 config: cfg,
4103 format: OutputFormat::Png,
4104 };
4105 let result = render(&request).expect("render png");
4106 assert!(result.bytes.starts_with(b"\x89PNG"));
4107 }
4108
4109 #[test]
4110 fn render_png_optimize_disabled() {
4111 let mut cfg = Config::default();
4112 cfg.font.family = "Test".to_string();
4113 cfg.png.optimize = false;
4114 let request = RenderRequest {
4115 input: InputSource::Text("hi".to_string()),
4116 config: cfg,
4117 format: OutputFormat::Png,
4118 };
4119 let result = render(&request).expect("render png");
4120 assert!(result.bytes.starts_with(b"\x89PNG"));
4121 }
4122
4123 #[test]
4124 fn render_png_quantize_basic() {
4125 let mut cfg = Config::default();
4126 cfg.font.family = "Test".to_string();
4127 cfg.png.quantize = true;
4128 cfg.png.optimize = false;
4129 let request = RenderRequest {
4130 input: InputSource::Text("hi".to_string()),
4131 config: cfg,
4132 format: OutputFormat::Png,
4133 };
4134 let result = render(&request).expect("render png");
4135 assert!(result.bytes.starts_with(b"\x89PNG"));
4136 }
4137
4138 #[test]
4139 fn decode_png_rgba_roundtrip() {
4140 let rgba = [255u8, 0, 0, 255, 0, 255, 0, 128];
4141 let mut bytes = Vec::new();
4142 {
4143 let mut encoder = png::Encoder::new(&mut bytes, 2, 1);
4144 encoder.set_color(png::ColorType::Rgba);
4145 encoder.set_depth(png::BitDepth::Eight);
4146 let mut writer = encoder.write_header().expect("header");
4147 writer.write_image_data(&rgba).expect("write");
4148 }
4149 let (decoded, width, height) = decode_png_rgba(&bytes).expect("decode");
4150 assert_eq!(width, 2);
4151 assert_eq!(height, 1);
4152 assert_eq!(decoded, rgba);
4153 }
4154
4155 #[test]
4156 fn decode_png_rgb_expands_alpha() {
4157 let rgb = [10u8, 20, 30, 40, 50, 60];
4158 let mut bytes = Vec::new();
4159 {
4160 let mut encoder = png::Encoder::new(&mut bytes, 2, 1);
4161 encoder.set_color(png::ColorType::Rgb);
4162 encoder.set_depth(png::BitDepth::Eight);
4163 let mut writer = encoder.write_header().expect("header");
4164 writer.write_image_data(&rgb).expect("write");
4165 }
4166 let (decoded, width, height) = decode_png_rgba(&bytes).expect("decode");
4167 assert_eq!(width, 2);
4168 assert_eq!(height, 1);
4169 assert_eq!(decoded, vec![10u8, 20, 30, 255, 40, 50, 60, 255]);
4170 }
4171
4172 #[test]
4173 fn quantize_preset_overrides_values() {
4174 let mut cfg = Config::default();
4175 cfg.png.quantize_preset = Some(PngQuantPreset::Fast);
4176 cfg.png.quantize_quality = 99;
4177 cfg.png.quantize_speed = 1;
4178 cfg.png.quantize_dither = 0.0;
4179 let settings = quantize_settings(&cfg.png);
4180 assert_eq!(settings.quality, 70);
4181 assert_eq!(settings.speed, 7);
4182 assert!((settings.dither - 0.5).abs() < f32::EPSILON);
4183 }
4184
4185 #[test]
4186 fn quantize_png_bytes_basic() {
4187 let rgba = [0u8, 0, 0, 255, 255, 255, 255, 255];
4188 let mut bytes = Vec::new();
4189 {
4190 let mut encoder = png::Encoder::new(&mut bytes, 2, 1);
4191 encoder.set_color(png::ColorType::Rgba);
4192 encoder.set_depth(png::BitDepth::Eight);
4193 let mut writer = encoder.write_header().expect("header");
4194 writer.write_image_data(&rgba).expect("write");
4195 }
4196 let png = quantize_png_bytes(&bytes, &PngOptions::default()).expect("quantize");
4197 assert!(png.starts_with(b"\x89PNG"));
4198 }
4199
4200 #[test]
4201 fn render_svg_includes_nf_fallback_when_needed() {
4202 let cfg = Config::default();
4203 let request = RenderRequest {
4204 input: InputSource::Text("\u{f121}".to_string()),
4205 config: cfg,
4206 format: OutputFormat::Svg,
4207 };
4208 let result = render(&request).expect("render svg");
4209 let svg = String::from_utf8(result.bytes).expect("utf8");
4210 assert!(svg.contains("Symbols Nerd Font Mono"));
4211 }
4212
4213 #[test]
4214 fn render_svg_respects_lines_offset() {
4215 let mut cfg = Config::default();
4216 cfg.font.family = "Test".to_string();
4217 cfg.show_line_numbers = true;
4218 cfg.lines = vec![1, 1];
4219 let request = RenderRequest {
4220 input: InputSource::Text("a\nb\nc".to_string()),
4221 config: cfg,
4222 format: OutputFormat::Svg,
4223 };
4224 let result = render(&request).expect("render svg");
4225 let svg = String::from_utf8(result.bytes).expect("utf8");
4226 assert!(svg.contains("> 2 </text>"));
4227 assert!(svg.contains(">b</tspan>"));
4228 assert!(!svg.contains(">a</tspan>"));
4229 }
4230
4231 #[test]
4232 fn render_webp_basic() {
4233 let mut cfg = Config::default();
4234 cfg.font.family = "Test".to_string();
4235 let request = RenderRequest {
4236 input: InputSource::Text("hi".to_string()),
4237 config: cfg,
4238 format: OutputFormat::Webp,
4239 };
4240 let result = render(&request).expect("render webp");
4241 assert!(result.bytes.starts_with(b"RIFF"));
4242 assert!(result.bytes.len() > 12);
4243 assert_eq!(&result.bytes[8..12], b"WEBP");
4244 }
4245
4246 #[test]
4247 fn raster_scale_defaults() {
4248 let cfg = Config::default();
4249 let scale = raster_scale(&cfg, 100, 100).expect("scale");
4250 assert_eq!(scale, DEFAULT_RASTER_SCALE);
4251 }
4252
4253 #[test]
4254 fn raster_scale_with_dimensions() {
4255 let cfg = Config {
4256 width: 800.0,
4257 ..Config::default()
4258 };
4259 let scale = raster_scale(&cfg, 100, 100).expect("scale");
4260 assert_eq!(scale, 1.0);
4261 let cfg = Config {
4262 height: 600.0,
4263 ..Config::default()
4264 };
4265 let scale = raster_scale(&cfg, 100, 100).expect("scale");
4266 assert_eq!(scale, 1.0);
4267 }
4268
4269 #[test]
4270 fn raster_scale_clamps_pixels() {
4271 let mut cfg = Config::default();
4272 cfg.raster.max_pixels = 1_000;
4273 let scale = raster_scale(&cfg, 100, 100).expect("scale");
4274 assert!(scale < cfg.raster.scale);
4275 }
4276
4277 #[test]
4278 fn scale_dimension_rejects_zero() {
4279 let result = scale_dimension(1, 0.0);
4280 assert!(matches!(result, Err(Error::Render(_))));
4281 }
4282
4283 #[cfg(unix)]
4284 #[test]
4285 fn execute_command_echo() {
4286 let output =
4287 execute_command("printf 'hello'", Duration::from_millis(2000)).expect("execute");
4288 assert!(output.contains("hello"));
4289 }
4290
4291 #[cfg(unix)]
4292 #[test]
4293 fn execute_command_timeout() {
4294 let result = execute_command("sleep 2", Duration::from_millis(10));
4295 assert!(matches!(result, Err(Error::Timeout)));
4296 }
4297
4298 #[cfg(unix)]
4299 #[test]
4300 fn execute_command_failure() {
4301 let result = execute_command("false", Duration::from_millis(2000));
4302 assert!(matches!(result, Err(Error::Render(_))));
4303 }
4304
4305 #[cfg(unix)]
4306 #[test]
4307 fn execute_command_no_output() {
4308 let result = execute_command("printf ''", Duration::from_millis(2000));
4309 assert!(matches!(result, Err(Error::InvalidInput(_))));
4310 }
4311
4312 fn cwd_lock() -> &'static Mutex<()> {
4313 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
4314 LOCK.get_or_init(|| Mutex::new(()))
4315 }
4316}