1use base64::Engine;
18use base64::engine::general_purpose::STANDARD as BASE64;
19use image::{ImageBuffer, Rgb};
20use plotters::prelude::*;
21
22use crate::draw::draw_on;
23use crate::spec::ChartSpec;
24
25#[derive(Debug, Clone, Copy)]
27pub struct KittyOpts {
28 pub width_px: u32,
29 pub height_px: u32,
30}
31
32impl Default for KittyOpts {
33 fn default() -> Self {
34 Self {
35 width_px: 640,
36 height_px: 400,
37 }
38 }
39}
40
41const KITTY_CHUNK_MAX: usize = 4096;
44
45#[must_use]
53pub fn render_kitty(spec: &ChartSpec, opts: KittyOpts) -> String {
54 match render_png(spec, opts) {
55 Some(png) => encode_png_apc(&png),
56 None => String::new(),
57 }
58}
59
60#[must_use]
64pub fn render_png(spec: &ChartSpec, opts: KittyOpts) -> Option<Vec<u8>> {
65 let width = opts.width_px;
66 let height = opts.height_px;
67 let byte_len = (width as usize)
68 .checked_mul(height as usize)?
69 .checked_mul(3)?;
70 let mut rgb: Vec<u8> = vec![0xff; byte_len];
71
72 let drew = {
73 let backend = BitMapBackend::with_buffer(&mut rgb, (width, height));
74 let root = backend.into_drawing_area();
75 draw_on(&root, spec).is_ok() && root.present().is_ok()
76 };
77
78 if !drew {
79 return None;
80 }
81
82 let buffer: ImageBuffer<Rgb<u8>, _> = ImageBuffer::from_raw(width, height, rgb)?;
83 let mut png = Vec::new();
84 let mut cursor = std::io::Cursor::new(&mut png);
85 buffer.write_to(&mut cursor, image::ImageFormat::Png).ok()?;
86 Some(png)
87}
88
89#[must_use]
93pub fn encode_png_apc(png: &[u8]) -> String {
94 let encoded = BASE64.encode(png);
95 let mut out = String::with_capacity(encoded.len() + 64);
96 let chunks: Vec<&str> = encoded
97 .as_bytes()
98 .chunks(KITTY_CHUNK_MAX)
99 .map(|c| std::str::from_utf8(c).unwrap_or(""))
100 .collect();
101
102 if chunks.is_empty() {
103 return String::new();
104 }
105
106 for (i, chunk) in chunks.iter().enumerate() {
107 let first = i == 0;
108 let last = i + 1 == chunks.len();
109 let more = i32::from(!last);
110 if first {
111 out.push_str(&format!("\x1b_Gf=100,a=T,m={more};"));
112 } else {
113 out.push_str(&format!("\x1b_Gm={more};"));
114 }
115 out.push_str(chunk);
116 out.push_str("\x1b\\");
117 }
118 out
119}
120
121#[cfg(test)]
122mod tests {
123 use super::*;
124 use crate::spec::{ChartKind, Series, SeriesPoint};
125
126 #[test]
132 fn render_png_rasterises_label_glyphs() {
133 let png = render_png(&sample(ChartKind::Bar), KittyOpts::default())
134 .expect("render_png returned None — drawing failed, not merely unstyled");
135 let img = image::load_from_memory(&png).expect("not a decodable PNG");
136 let dark = img
137 .to_rgb8()
138 .pixels()
139 .filter(|p| p.0.iter().all(|&c| c < 0x40))
140 .count();
141 assert!(
142 dark > 200,
143 "only {dark} dark pixels — axis labels and captions were not rasterised"
144 );
145 }
146
147 fn sample(kind: ChartKind) -> ChartSpec {
148 ChartSpec {
149 title: "Test".to_string(),
150 kind,
151 x_label: "X".to_string(),
152 y_label: "Y".to_string(),
153 series: vec![Series {
154 label: "A".to_string(),
155 commodity_symbol: "USD".to_string(),
156 points: vec![
157 SeriesPoint {
158 x: "Jan".to_string(),
159 y_num: 100,
160 y_denom: 1,
161 },
162 SeriesPoint {
163 x: "Feb".to_string(),
164 y_num: 200,
165 y_denom: 1,
166 },
167 ],
168 }],
169 notes: vec![],
170 }
171 }
172
173 #[test]
174 fn render_kitty_wraps_output_in_apc_framing() {
175 let out = render_kitty(
176 &sample(ChartKind::Bar),
177 KittyOpts {
178 width_px: 320,
179 height_px: 200,
180 },
181 );
182 assert!(!out.is_empty(), "kitty output should not be empty");
183 assert!(out.starts_with("\x1b_G"), "missing opening APC marker");
184 assert!(out.ends_with("\x1b\\"), "missing closing APC marker");
185 assert!(out.contains("f=100"), "first chunk must declare PNG format");
186 assert!(out.contains("a=T"), "first chunk must request display");
187 }
188
189 #[test]
190 fn render_png_produces_valid_png_magic_bytes() {
191 let png = render_png(
192 &sample(ChartKind::Line),
193 KittyOpts {
194 width_px: 320,
195 height_px: 200,
196 },
197 )
198 .expect("bitmap render should succeed");
199 assert_eq!(
200 &png[..8],
201 &[0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a],
202 "PNG magic bytes mismatch"
203 );
204 }
205
206 #[test]
207 fn encode_png_apc_round_trips_payload() {
208 let png = b"\x89PNG\r\n\x1a\nsome-fake-payload";
209 let framed = encode_png_apc(png);
210 let payload: String = framed
211 .split("\x1b\\")
212 .filter(|s| !s.is_empty())
213 .map(|chunk| {
214 let semi = chunk.find(';').expect("semicolon separator");
215 &chunk[semi + 1..]
216 })
217 .collect();
218 let decoded = BASE64.decode(payload).expect("decoded base64");
219 assert_eq!(decoded, png);
220 }
221
222 #[test]
223 fn encode_png_apc_chunks_large_payload() {
224 let png: Vec<u8> = (0..4000_u32).flat_map(u32::to_le_bytes).collect();
227 let framed = encode_png_apc(&png);
228 let start_markers = framed.matches("\x1b_G").count();
229 assert!(
230 start_markers >= 2,
231 "expected at least 2 APC chunks, got {start_markers}"
232 );
233 assert!(framed.contains("m=1"), "intermediate chunks must set m=1");
234 assert!(framed.contains("m=0"), "final chunk must set m=0");
235 }
236
237 #[test]
238 fn encode_png_apc_handles_empty_input() {
239 assert!(encode_png_apc(&[]).is_empty());
240 }
241}