Skip to main content

cryosnap_core/
lib.rs

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('&', "&amp;")
3655        .replace('<', "&lt;")
3656        .replace('>', "&gt;")
3657}
3658
3659fn escape_attr(text: &str) -> String {
3660    escape_text(text).replace('"', "&quot;")
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}