1use base64::Engine as _;
10use std::sync::OnceLock;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum ImageProtocol {
19 Kitty,
21 Iterm2,
23 Unsupported,
25}
26
27pub fn detect_protocol() -> ImageProtocol {
31 static CACHED: OnceLock<ImageProtocol> = OnceLock::new();
32 *CACHED.get_or_init(detect_protocol_uncached)
33}
34
35fn detect_protocol_uncached() -> ImageProtocol {
36 if let Ok(prog) = std::env::var("TERM_PROGRAM") {
38 let lower = prog.to_ascii_lowercase();
39 if lower == "iterm.app" || lower == "iterm2" {
40 return ImageProtocol::Iterm2;
41 }
42 if lower == "wezterm" {
44 return ImageProtocol::Kitty;
45 }
46 }
47
48 if std::env::var("GHOSTTY_RESOURCES_DIR").is_ok() {
50 return ImageProtocol::Kitty;
51 }
52
53 if let Ok(term) = std::env::var("TERM") {
55 let lower = term.to_ascii_lowercase();
56 if lower.contains("kitty") {
57 return ImageProtocol::Kitty;
58 }
59 if lower.contains("xterm-kitty") {
60 return ImageProtocol::Kitty;
61 }
62 }
63
64 if std::env::var("KITTY_WINDOW_ID").is_ok() {
66 return ImageProtocol::Kitty;
67 }
68
69 ImageProtocol::Unsupported
70}
71
72const KITTY_CHUNK_SIZE: usize = 4096;
78
79pub fn encode_kitty(image_bytes: &[u8], cols: usize) -> String {
86 let b64 = base64::engine::general_purpose::STANDARD.encode(image_bytes);
87 let mut out = String::with_capacity(b64.len() + 256);
88
89 let chunks: Vec<&str> = b64
90 .as_bytes()
91 .chunks(KITTY_CHUNK_SIZE)
92 .map(|c| std::str::from_utf8(c).unwrap_or(""))
93 .collect();
94
95 for (i, chunk) in chunks.iter().enumerate() {
96 let is_first = i == 0;
97 let is_last = i == chunks.len() - 1;
98 let more = u8::from(!is_last);
99
100 if is_first {
101 write_kitty_chunk(&mut out, &format!("a=T,f=100,c={cols},m={more}"), chunk);
104 } else {
105 write_kitty_chunk(&mut out, &format!("m={more}"), chunk);
107 }
108 }
109
110 out
111}
112
113fn write_kitty_chunk(out: &mut String, control: &str, payload: &str) {
114 out.push_str("\x1b_G");
116 out.push_str(control);
117 out.push(';');
118 out.push_str(payload);
119 out.push_str("\x1b\\");
120}
121
122pub fn encode_iterm2(image_bytes: &[u8], cols: usize) -> String {
132 let b64 = base64::engine::general_purpose::STANDARD.encode(image_bytes);
133 let size = image_bytes.len();
134 format!("\x1b]1337;File=size={size};width={cols};inline=1:{b64}\x07")
136}
137
138pub fn placeholder(mime_type: &str, width: Option<u32>, height: Option<u32>) -> String {
144 match (width, height) {
145 (Some(w), Some(h)) => format!("[image: {mime_type}, {w}x{h}]"),
146 _ => format!("[image: {mime_type}]"),
147 }
148}
149
150pub fn image_dimensions(data: &[u8]) -> Option<(u32, u32)> {
158 if data.len() >= 24 && data.starts_with(b"\x89PNG\r\n\x1A\n") {
160 let w = u32::from_be_bytes([data[16], data[17], data[18], data[19]]);
161 let h = u32::from_be_bytes([data[20], data[21], data[22], data[23]]);
162 return Some((w, h));
163 }
164
165 if data.len() >= 4 && data[0] == 0xFF && data[1] == 0xD8 {
167 return jpeg_dimensions(data);
168 }
169
170 if data.len() >= 10 && (data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a")) {
172 let w = u32::from(u16::from_le_bytes([data[6], data[7]]));
173 let h = u32::from(u16::from_le_bytes([data[8], data[9]]));
174 return Some((w, h));
175 }
176
177 None
178}
179
180fn jpeg_dimensions(data: &[u8]) -> Option<(u32, u32)> {
181 let mut i = 2;
182 while i < data.len() {
183 while i < data.len() && data[i] != 0xFF {
185 i += 1;
186 }
187 if i >= data.len() {
188 return None;
189 }
190
191 while i < data.len() && data[i] == 0xFF {
193 i += 1;
194 }
195 if i >= data.len() {
196 return None;
197 }
198
199 let marker = data[i];
200 i += 1;
201
202 if matches!(marker, 0x01 | 0xD0..=0xD7) {
204 continue;
205 }
206
207 if matches!(marker, 0xDA | 0xD9) {
209 return None;
210 }
211
212 if i + 1 >= data.len() {
213 return None;
214 }
215 let seg_len = usize::from(u16::from_be_bytes([data[i], data[i + 1]]));
216 if seg_len < 2 || i.saturating_add(seg_len) > data.len() {
217 return None;
218 }
219
220 if is_jpeg_sof_marker(marker) {
222 if seg_len < 7 {
223 return None;
224 }
225 let h = u32::from(u16::from_be_bytes([data[i + 3], data[i + 4]]));
226 let w = u32::from(u16::from_be_bytes([data[i + 5], data[i + 6]]));
227 return Some((w, h));
228 }
229
230 i += seg_len;
231 }
232 None
233}
234
235const fn is_jpeg_sof_marker(marker: u8) -> bool {
236 matches!(
237 marker,
238 0xC0..=0xC3 | 0xC5..=0xC7 | 0xC9..=0xCB | 0xCD..=0xCF
239 )
240}
241
242pub fn render_inline(image_b64: &str, mime_type: &str, max_cols: usize) -> String {
256 let _ = max_cols;
257
258 let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(image_b64) else {
262 return placeholder(mime_type, None, None);
263 };
264
265 let dims = image_dimensions(&bytes);
266 placeholder(mime_type, dims.map(|(w, _)| w), dims.map(|(_, h)| h))
267}
268
269#[cfg(test)]
274mod tests {
275 use super::*;
276
277 #[test]
278 fn kitty_single_chunk_small_image() {
279 let data = b"hello";
281 let result = encode_kitty(data, 40);
282 assert!(result.starts_with("\x1b_G"), "Should start with APC");
283 assert!(result.contains("a=T"), "First chunk should have a=T");
284 assert!(result.contains("f=100"), "Should auto-detect format");
285 assert!(result.contains("c=40"), "Should set column constraint");
286 assert!(result.contains("m=0"), "Single chunk should have m=0");
287 assert!(result.ends_with("\x1b\\"), "Should end with ST");
288 }
289
290 #[test]
291 fn kitty_multi_chunk_large_payload() {
292 let data = vec![0u8; 4096];
294 let result = encode_kitty(&data, 80);
295 let chunk_count = result.matches("\x1b_G").count();
297 assert!(
298 chunk_count >= 2,
299 "Should have at least 2 chunks, got {chunk_count}"
300 );
301 assert!(result.contains("m=1"), "First chunk should signal more");
303 let last_chunk_start = result.rfind("\x1b_G").unwrap();
305 let last_chunk = &result[last_chunk_start..];
306 assert!(last_chunk.contains("m=0"), "Last chunk should signal done");
307 }
308
309 #[test]
310 fn iterm2_format() {
311 let data = b"test image";
312 let result = encode_iterm2(data, 60);
313 assert!(
314 result.starts_with("\x1b]1337;File="),
315 "Should start with OSC 1337"
316 );
317 assert!(result.contains("inline=1"), "Should be inline");
318 assert!(
319 result.contains(&format!("size={}", data.len())),
320 "Should include file size"
321 );
322 assert!(result.contains("width=60"), "Should include width");
323 assert!(result.ends_with('\x07'), "Should end with BEL");
324 }
325
326 #[test]
327 fn placeholder_with_dimensions() {
328 let result = placeholder("image/png", Some(800), Some(600));
329 assert_eq!(result, "[image: image/png, 800x600]");
330 }
331
332 #[test]
333 fn placeholder_without_dimensions() {
334 let result = placeholder("image/jpeg", None, None);
335 assert_eq!(result, "[image: image/jpeg]");
336 }
337
338 #[test]
339 fn png_dimensions() {
340 let mut data = vec![0u8; 32];
342 data[..8].copy_from_slice(b"\x89PNG\r\n\x1A\n");
343 data[8..12].copy_from_slice(&13u32.to_be_bytes());
345 data[12..16].copy_from_slice(b"IHDR");
346 data[16..20].copy_from_slice(&100u32.to_be_bytes());
347 data[20..24].copy_from_slice(&50u32.to_be_bytes());
348
349 let dims = image_dimensions(&data);
350 assert_eq!(dims, Some((100, 50)));
351 }
352
353 #[test]
354 fn gif_dimensions() {
355 let mut data = vec![0u8; 16];
356 data[..6].copy_from_slice(b"GIF89a");
357 data[6..8].copy_from_slice(&320u16.to_le_bytes());
358 data[8..10].copy_from_slice(&240u16.to_le_bytes());
359
360 let dims = image_dimensions(&data);
361 assert_eq!(dims, Some((320, 240)));
362 }
363
364 #[test]
365 fn jpeg_dimensions() {
366 let data = vec![
368 0xFF, 0xD8, 0xFF, 0xC0, 0x00, 0x11, 0x08, 0x00, 0x32, 0x00, 0x64, 0x03, 0x01, 0x11, 0x00, 0x02, 0x11, 0x00, 0x03, 0x11, 0x00, ];
379
380 let dims = image_dimensions(&data);
381 assert_eq!(dims, Some((100, 50)));
382 }
383
384 #[test]
385 fn jpeg_dimensions_with_fill_bytes_before_sof() {
386 let data = vec![
388 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x02, 0xFF, 0xFF, 0xC0, 0x00, 0x11, 0x08, 0x00, 0x32, 0x00, 0x64, 0x03, 0x01, 0x11, 0x00, 0x02, 0x11, 0x00, 0x03, 0x11, 0x00, ];
402
403 assert_eq!(image_dimensions(&data), Some((100, 50)));
404 }
405
406 #[test]
407 fn jpeg_dimensions_supports_extended_sof_markers() {
408 let data = vec![
410 0xFF, 0xD8, 0xFF, 0xC5, 0x00, 0x11, 0x08, 0x00, 0x2A, 0x00, 0x54, 0x03, 0x01, 0x11, 0x00, 0x02, 0x11, 0x00, 0x03, 0x11, 0x00, ];
421
422 assert_eq!(image_dimensions(&data), Some((84, 42)));
423 }
424
425 #[test]
426 fn unknown_format_returns_none() {
427 let data = b"definitely not an image";
428 assert_eq!(image_dimensions(data), None);
429 }
430
431 #[test]
432 fn render_inline_returns_placeholder_for_invalid_base64() {
433 let result = render_inline("%%%not-base64%%%", "image/png", 80);
434 assert_eq!(result, "[image: image/png]");
435 }
436
437 #[test]
438 fn render_inline_with_unknown_image_bytes_omits_dimensions() {
439 let b64 = base64::engine::general_purpose::STANDARD.encode(b"not-an-image");
440 let result = render_inline(&b64, "image/webp", 80);
441 assert_eq!(result, "[image: image/webp]");
442 }
443
444 #[test]
445 fn render_inline_unsupported_with_decodable_image() {
446 let result = placeholder("image/png", Some(640), Some(480));
450 assert!(result.contains("640x480"));
451 assert!(result.contains("image/png"));
452 }
453
454 #[test]
455 fn detect_protocol_is_deterministic() {
456 let p1 = detect_protocol();
458 let p2 = detect_protocol();
459 assert_eq!(p1, p2);
460 }
461
462 #[test]
465 fn image_dimensions_empty_data() {
466 assert_eq!(image_dimensions(&[]), None);
467 }
468
469 #[test]
470 fn image_dimensions_truncated_png_header() {
471 let data = b"\x89PNG\r\n\x1A\n\x00\x00";
473 assert_eq!(image_dimensions(data), None);
474 }
475
476 #[test]
477 fn image_dimensions_truncated_gif_header() {
478 let data = b"GIF89a\x01";
479 assert_eq!(image_dimensions(data), None);
480 }
481
482 #[test]
483 fn image_dimensions_jpeg_truncated_sof() {
484 let data = vec![0xFF, 0xD8, 0xFF, 0xC0, 0x00, 0x05];
486 assert_eq!(image_dimensions(&data), None);
487 }
488
489 #[test]
490 fn image_dimensions_jpeg_no_sof_marker() {
491 let data = vec![0xFF, 0xD8, 0x00, 0x00];
493 assert_eq!(image_dimensions(&data), None);
494 }
495
496 #[test]
497 fn image_dimensions_gif87a() {
498 let mut data = vec![0u8; 16];
499 data[..6].copy_from_slice(b"GIF87a");
500 data[6..8].copy_from_slice(&128u16.to_le_bytes());
501 data[8..10].copy_from_slice(&64u16.to_le_bytes());
502 assert_eq!(image_dimensions(&data), Some((128, 64)));
503 }
504
505 #[test]
508 fn placeholder_width_only() {
509 let result = placeholder("image/png", Some(100), None);
510 assert_eq!(result, "[image: image/png]");
511 }
512
513 #[test]
514 fn placeholder_height_only() {
515 let result = placeholder("image/png", None, Some(200));
516 assert_eq!(result, "[image: image/png]");
517 }
518
519 #[test]
522 fn kitty_empty_data_produces_empty_output() {
523 let result = encode_kitty(&[], 40);
524 assert!(result.is_empty());
526 }
527
528 #[test]
531 fn iterm2_empty_data() {
532 let result = encode_iterm2(&[], 40);
533 assert!(result.contains("size=0"));
534 assert!(result.contains("width=40"));
535 }
536
537 #[test]
540 fn render_inline_with_valid_png_includes_dimensions() {
541 let mut png_data = vec![0u8; 32];
542 png_data[..8].copy_from_slice(b"\x89PNG\r\n\x1A\n");
543 png_data[8..12].copy_from_slice(&13u32.to_be_bytes());
544 png_data[12..16].copy_from_slice(b"IHDR");
545 png_data[16..20].copy_from_slice(&200u32.to_be_bytes());
546 png_data[20..24].copy_from_slice(&150u32.to_be_bytes());
547
548 let b64 = base64::engine::general_purpose::STANDARD.encode(&png_data);
549 let result = render_inline(&b64, "image/png", 80);
550 assert_eq!(result, "[image: image/png, 200x150]");
551 }
552
553 #[test]
556 fn image_protocol_equality() {
557 assert_eq!(ImageProtocol::Kitty, ImageProtocol::Kitty);
558 assert_ne!(ImageProtocol::Kitty, ImageProtocol::Iterm2);
559 assert_ne!(ImageProtocol::Iterm2, ImageProtocol::Unsupported);
560 }
561
562 mod proptest_terminal_images {
563 use super::*;
564 use proptest::prelude::*;
565
566 proptest! {
567 #[test]
569 fn kitty_bookends(data in proptest::collection::vec(any::<u8>(), 1..512), cols in 1..200usize) {
570 let result = encode_kitty(&data, cols);
571 assert!(result.starts_with("\x1b_G"), "must start with APC");
572 assert!(result.ends_with("\x1b\\"), "must end with ST");
573 }
574
575 #[test]
577 fn kitty_chunk_count_lower_bound(data in proptest::collection::vec(any::<u8>(), 1..8192)) {
578 let result = encode_kitty(&data, 80);
579 let b64_len = (data.len() * 4).div_ceil(3); let expected_chunks = b64_len.div_ceil(4096);
581 let actual_chunks = result.matches("\x1b_G").count();
582 assert!(actual_chunks >= expected_chunks.min(1));
583 }
584
585 #[test]
587 fn kitty_first_chunk_has_action(data in proptest::collection::vec(any::<u8>(), 1..100)) {
588 let result = encode_kitty(&data, 40);
589 let first_st = result.find("\x1b\\").unwrap();
591 let first_chunk = &result[..first_st];
592 assert!(first_chunk.contains("a=T"));
593 assert!(first_chunk.contains("f=100"));
594 }
595
596 #[test]
598 fn iterm2_format_invariants(data in proptest::collection::vec(any::<u8>(), 0..512), cols in 1..200usize) {
599 let result = encode_iterm2(&data, cols);
600 assert!(result.starts_with("\x1b]1337;File="));
601 assert!(result.contains(&format!("size={}", data.len())));
602 assert!(result.contains(&format!("width={cols}")));
603 assert!(result.contains("inline=1"));
604 assert!(result.ends_with('\x07'));
605 }
606
607 #[test]
609 fn placeholder_both_dims(w in 1..10000u32, h in 1..10000u32, mime in "[a-z]+/[a-z]+") {
610 let result = placeholder(&mime, Some(w), Some(h));
611 assert!(result.contains(&format!("{w}x{h}")));
612 assert!(result.contains(&mime));
613 }
614
615 #[test]
617 fn placeholder_missing_dim(w in 1..10000u32, h in 1..10000u32) {
618 let dim_pattern = format!("{w}x{h}");
619 let result_no_h = placeholder("image/png", Some(w), None);
620 assert!(!result_no_h.contains(&dim_pattern));
621 assert_eq!(result_no_h, "[image: image/png]");
622 let result_no_w = placeholder("image/png", None, Some(h));
623 assert!(!result_no_w.contains(&dim_pattern));
624 assert_eq!(result_no_w, "[image: image/png]");
625 let result_none = placeholder("image/png", None, None);
626 assert_eq!(result_none, "[image: image/png]");
627 }
628
629 #[test]
631 fn png_dimensions_roundtrip(w in 1..10000u32, h in 1..10000u32) {
632 let mut data = vec![0u8; 32];
633 data[..8].copy_from_slice(b"\x89PNG\r\n\x1A\n");
634 data[8..12].copy_from_slice(&13u32.to_be_bytes());
635 data[12..16].copy_from_slice(b"IHDR");
636 data[16..20].copy_from_slice(&w.to_be_bytes());
637 data[20..24].copy_from_slice(&h.to_be_bytes());
638 assert_eq!(image_dimensions(&data), Some((w, h)));
639 }
640
641 #[test]
643 fn gif_dimensions_roundtrip(w in 1..65535u16, h in 1..65535u16) {
644 let mut data = vec![0u8; 16];
645 data[..6].copy_from_slice(b"GIF89a");
646 data[6..8].copy_from_slice(&w.to_le_bytes());
647 data[8..10].copy_from_slice(&h.to_le_bytes());
648 assert_eq!(image_dimensions(&data), Some((u32::from(w), u32::from(h))));
649 }
650
651 #[test]
653 fn unknown_format_none(data in proptest::collection::vec(any::<u8>(), 0..64)) {
654 if data.len() >= 8 && data.starts_with(b"\x89PNG\r\n\x1A\n") {
656 return Ok(());
657 }
658 if data.len() >= 4 && data.first() == Some(&0xFF) && data.get(1) == Some(&0xD8) {
659 return Ok(());
660 }
661 if data.len() >= 10 && (data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a")) {
662 return Ok(());
663 }
664 assert_eq!(image_dimensions(&data), None);
665 }
666
667 #[test]
669 fn render_inline_never_panics(b64 in "\\PC{0,100}", mime in "[a-z]+/[a-z]+") {
670 let _ = render_inline(&b64, &mime, 80);
671 }
672
673 #[test]
675 fn render_inline_png_has_dims(w in 1..5000u32, h in 1..5000u32) {
676 let mut png = vec![0u8; 32];
677 png[..8].copy_from_slice(b"\x89PNG\r\n\x1A\n");
678 png[8..12].copy_from_slice(&13u32.to_be_bytes());
679 png[12..16].copy_from_slice(b"IHDR");
680 png[16..20].copy_from_slice(&w.to_be_bytes());
681 png[20..24].copy_from_slice(&h.to_be_bytes());
682 let b64 = base64::engine::general_purpose::STANDARD.encode(&png);
683 let result = render_inline(&b64, "image/png", 80);
684 assert!(result.contains(&format!("{w}x{h}")));
685 }
686
687 #[test]
689 fn render_inline_preserves_mime_label(
690 data in proptest::collection::vec(any::<u8>(), 0..512),
691 mime in "[a-z]{1,10}/[a-z0-9.+-]{1,20}"
692 ) {
693 let b64 = base64::engine::general_purpose::STANDARD.encode(&data);
694 let result = render_inline(&b64, &mime, 80);
695 assert!(result.contains(&mime));
696 }
697
698 #[test]
700 fn sof_marker_classification(marker in 0u8..=255u8) {
701 let expected = matches!(
702 marker,
703 0xC0..=0xC3 | 0xC5..=0xC7 | 0xC9..=0xCB | 0xCD..=0xCF
704 );
705 assert_eq!(is_jpeg_sof_marker(marker), expected);
706 }
707 }
708 }
709}