1use base64::Engine as _;
8use std::sync::OnceLock;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum ImageProtocol {
17 Kitty,
19 Iterm2,
21 Unsupported,
23}
24
25pub fn detect_protocol() -> ImageProtocol {
29 static CACHED: OnceLock<ImageProtocol> = OnceLock::new();
30 *CACHED.get_or_init(detect_protocol_uncached)
31}
32
33fn detect_protocol_uncached() -> ImageProtocol {
34 if let Ok(prog) = std::env::var("TERM_PROGRAM") {
36 let lower = prog.to_ascii_lowercase();
37 if lower == "iterm.app" || lower == "iterm2" {
38 return ImageProtocol::Iterm2;
39 }
40 if lower == "wezterm" {
42 return ImageProtocol::Kitty;
43 }
44 }
45
46 if std::env::var("GHOSTTY_RESOURCES_DIR").is_ok() {
48 return ImageProtocol::Kitty;
49 }
50
51 if let Ok(term) = std::env::var("TERM") {
53 let lower = term.to_ascii_lowercase();
54 if lower.contains("kitty") {
55 return ImageProtocol::Kitty;
56 }
57 if lower.contains("xterm-kitty") {
58 return ImageProtocol::Kitty;
59 }
60 }
61
62 if std::env::var("KITTY_WINDOW_ID").is_ok() {
64 return ImageProtocol::Kitty;
65 }
66
67 ImageProtocol::Unsupported
68}
69
70const KITTY_CHUNK_SIZE: usize = 4096;
76
77pub fn encode_kitty(image_bytes: &[u8], cols: usize) -> String {
84 let b64 = base64::engine::general_purpose::STANDARD.encode(image_bytes);
85 let mut out = String::with_capacity(b64.len() + 256);
86
87 let chunks: Vec<&str> = b64
88 .as_bytes()
89 .chunks(KITTY_CHUNK_SIZE)
90 .map(|c| std::str::from_utf8(c).unwrap_or(""))
91 .collect();
92
93 for (i, chunk) in chunks.iter().enumerate() {
94 let is_first = i == 0;
95 let is_last = i == chunks.len() - 1;
96 let more = u8::from(!is_last);
97
98 if is_first {
99 write_kitty_chunk(&mut out, &format!("a=T,f=100,c={cols},m={more}"), chunk);
102 } else {
103 write_kitty_chunk(&mut out, &format!("m={more}"), chunk);
105 }
106 }
107
108 out
109}
110
111fn write_kitty_chunk(out: &mut String, control: &str, payload: &str) {
112 out.push_str("\x1b_G");
114 out.push_str(control);
115 out.push(';');
116 out.push_str(payload);
117 out.push_str("\x1b\\");
118}
119
120pub fn encode_iterm2(image_bytes: &[u8], cols: usize) -> String {
130 let b64 = base64::engine::general_purpose::STANDARD.encode(image_bytes);
131 let size = image_bytes.len();
132 format!("\x1b]1337;File=size={size};width={cols};inline=1:{b64}\x07")
134}
135
136pub fn placeholder(mime_type: &str, width: Option<u32>, height: Option<u32>) -> String {
142 match (width, height) {
143 (Some(w), Some(h)) => format!("[image: {mime_type}, {w}x{h}]"),
144 _ => format!("[image: {mime_type}]"),
145 }
146}
147
148pub fn image_dimensions(data: &[u8]) -> Option<(u32, u32)> {
156 if data.len() >= 24 && data.starts_with(b"\x89PNG\r\n\x1A\n") && &data[12..16] == b"IHDR" {
159 let w = u32::from_be_bytes([data[16], data[17], data[18], data[19]]);
160 let h = u32::from_be_bytes([data[20], data[21], data[22], data[23]]);
161 return Some((w, h));
162 }
163
164 if data.len() >= 4 && data[0] == 0xFF && data[1] == 0xD8 {
166 return jpeg_dimensions(data);
167 }
168
169 if data.len() >= 10 && (data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a")) {
171 let w = u32::from(u16::from_le_bytes([data[6], data[7]]));
172 let h = u32::from(u16::from_le_bytes([data[8], data[9]]));
173 return Some((w, h));
174 }
175
176 None
177}
178
179fn jpeg_dimensions(data: &[u8]) -> Option<(u32, u32)> {
180 let mut i = 2;
181 while i < data.len() {
182 while i < data.len() && data[i] != 0xFF {
184 i += 1;
185 }
186 if i >= data.len() {
187 return None;
188 }
189
190 while i < data.len() && data[i] == 0xFF {
192 i += 1;
193 }
194 if i >= data.len() {
195 return None;
196 }
197
198 let marker = data[i];
199 i += 1;
200
201 if matches!(marker, 0x01 | 0xD0..=0xD7) {
203 continue;
204 }
205
206 if matches!(marker, 0xDA | 0xD9) {
208 return None;
209 }
210
211 if i + 1 >= data.len() {
212 return None;
213 }
214 let seg_len = usize::from(u16::from_be_bytes([data[i], data[i + 1]]));
215 if seg_len < 2 || i.saturating_add(seg_len) > data.len() {
216 return None;
217 }
218
219 if is_jpeg_sof_marker(marker) {
221 if seg_len < 8 {
222 return None;
223 }
224 let h = u32::from(u16::from_be_bytes([data[i + 3], data[i + 4]]));
225 let w = u32::from(u16::from_be_bytes([data[i + 5], data[i + 6]]));
226 return Some((w, h));
227 }
228
229 i += seg_len;
230 }
231 None
232}
233
234const fn is_jpeg_sof_marker(marker: u8) -> bool {
235 matches!(
236 marker,
237 0xC0..=0xC3 | 0xC5..=0xC7 | 0xC9..=0xCB | 0xCD..=0xCF
238 )
239}
240
241pub fn render_inline(image_b64: &str, mime_type: &str, max_cols: usize) -> String {
254 let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(image_b64) else {
255 return placeholder(mime_type, None, None);
256 };
257
258 render_inline_bytes(&bytes, mime_type, max_cols, detect_protocol())
259}
260
261fn render_inline_bytes(
262 bytes: &[u8],
263 mime_type: &str,
264 max_cols: usize,
265 protocol: ImageProtocol,
266) -> String {
267 let dims = image_dimensions(bytes);
268 let placeholder = placeholder(mime_type, dims.map(|(w, _)| w), dims.map(|(_, h)| h));
269
270 if bytes.is_empty() {
271 return placeholder;
272 }
273
274 let cols = max_cols.max(1);
275 match protocol {
276 ImageProtocol::Kitty => {
277 let encoded = encode_kitty(bytes, cols);
278 if encoded.is_empty() {
279 placeholder
280 } else {
281 encoded
282 }
283 }
284 ImageProtocol::Iterm2 => encode_iterm2(bytes, cols),
285 ImageProtocol::Unsupported => placeholder,
286 }
287}
288
289#[cfg(test)]
294mod tests {
295 use super::*;
296
297 #[test]
298 fn kitty_single_chunk_small_image() {
299 let data = b"hello";
301 let result = encode_kitty(data, 40);
302 assert!(result.starts_with("\x1b_G"), "Should start with APC");
303 assert!(result.contains("a=T"), "First chunk should have a=T");
304 assert!(result.contains("f=100"), "Should auto-detect format");
305 assert!(result.contains("c=40"), "Should set column constraint");
306 assert!(result.contains("m=0"), "Single chunk should have m=0");
307 assert!(result.ends_with("\x1b\\"), "Should end with ST");
308 }
309
310 #[test]
311 fn kitty_multi_chunk_large_payload() {
312 let data = vec![0u8; 4096];
314 let result = encode_kitty(&data, 80);
315 let chunk_count = result.matches("\x1b_G").count();
317 assert!(
318 chunk_count >= 2,
319 "Should have at least 2 chunks, got {chunk_count}"
320 );
321 assert!(result.contains("m=1"), "First chunk should signal more");
323 let last_chunk_start = result.rfind("\x1b_G").unwrap();
325 let last_chunk = &result[last_chunk_start..];
326 assert!(last_chunk.contains("m=0"), "Last chunk should signal done");
327 }
328
329 #[test]
330 fn iterm2_format() {
331 let data = b"test image";
332 let result = encode_iterm2(data, 60);
333 assert!(
334 result.starts_with("\x1b]1337;File="),
335 "Should start with OSC 1337"
336 );
337 assert!(result.contains("inline=1"), "Should be inline");
338 assert!(
339 result.contains(&format!("size={}", data.len())),
340 "Should include file size"
341 );
342 assert!(result.contains("width=60"), "Should include width");
343 assert!(result.ends_with('\x07'), "Should end with BEL");
344 }
345
346 #[test]
347 fn placeholder_with_dimensions() {
348 let result = placeholder("image/png", Some(800), Some(600));
349 assert_eq!(result, "[image: image/png, 800x600]");
350 }
351
352 #[test]
353 fn placeholder_without_dimensions() {
354 let result = placeholder("image/jpeg", None, None);
355 assert_eq!(result, "[image: image/jpeg]");
356 }
357
358 #[test]
359 fn png_dimensions() {
360 let mut data = vec![0u8; 32];
362 data[..8].copy_from_slice(b"\x89PNG\r\n\x1A\n");
363 data[8..12].copy_from_slice(&13u32.to_be_bytes());
365 data[12..16].copy_from_slice(b"IHDR");
366 data[16..20].copy_from_slice(&100u32.to_be_bytes());
367 data[20..24].copy_from_slice(&50u32.to_be_bytes());
368
369 let dims = image_dimensions(&data);
370 assert_eq!(dims, Some((100, 50)));
371 }
372
373 #[test]
374 fn gif_dimensions() {
375 let mut data = vec![0u8; 16];
376 data[..6].copy_from_slice(b"GIF89a");
377 data[6..8].copy_from_slice(&320u16.to_le_bytes());
378 data[8..10].copy_from_slice(&240u16.to_le_bytes());
379
380 let dims = image_dimensions(&data);
381 assert_eq!(dims, Some((320, 240)));
382 }
383
384 #[test]
385 fn jpeg_dimensions() {
386 let data = vec![
388 0xFF, 0xD8, 0xFF, 0xC0, 0x00, 0x11, 0x08, 0x00, 0x32, 0x00, 0x64, 0x03, 0x01, 0x11, 0x00, 0x02, 0x11, 0x00, 0x03, 0x11, 0x00, ];
399
400 let dims = image_dimensions(&data);
401 assert_eq!(dims, Some((100, 50)));
402 }
403
404 #[test]
405 fn jpeg_dimensions_with_fill_bytes_before_sof() {
406 let data = vec![
408 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, ];
422
423 assert_eq!(image_dimensions(&data), Some((100, 50)));
424 }
425
426 #[test]
427 fn jpeg_dimensions_supports_extended_sof_markers() {
428 let data = vec![
430 0xFF, 0xD8, 0xFF, 0xC5, 0x00, 0x11, 0x08, 0x00, 0x2A, 0x00, 0x54, 0x03, 0x01, 0x11, 0x00, 0x02, 0x11, 0x00, 0x03, 0x11, 0x00, ];
441
442 assert_eq!(image_dimensions(&data), Some((84, 42)));
443 }
444
445 #[test]
446 fn unknown_format_returns_none() {
447 let data = b"definitely not an image";
448 assert_eq!(image_dimensions(data), None);
449 }
450
451 #[test]
452 fn render_inline_returns_placeholder_for_invalid_base64() {
453 let result = render_inline("%%%not-base64%%%", "image/png", 80);
454 assert_eq!(result, "[image: image/png]");
455 }
456
457 #[test]
458 fn render_inline_with_unknown_image_bytes_omits_dimensions() {
459 let b64 = base64::engine::general_purpose::STANDARD.encode(b"not-an-image");
460 let result = render_inline(&b64, "image/webp", 80);
461 match detect_protocol() {
462 ImageProtocol::Kitty => assert!(result.starts_with("\x1b_G")),
463 ImageProtocol::Iterm2 => assert!(result.starts_with("\x1b]1337;File=")),
464 ImageProtocol::Unsupported => assert_eq!(result, "[image: image/webp]"),
465 }
466 }
467
468 #[test]
469 fn render_inline_unsupported_with_decodable_image() {
470 let result =
471 render_inline_bytes(b"pretend-png", "image/png", 80, ImageProtocol::Unsupported);
472 assert_eq!(result, "[image: image/png]");
473 }
474
475 #[test]
476 fn render_inline_unsupported_with_known_dimensions_keeps_placeholder_metadata() {
477 let result = placeholder("image/png", Some(640), Some(480));
478 assert!(result.contains("640x480"));
479 assert!(result.contains("image/png"));
480 }
481
482 #[test]
483 fn render_inline_kitty_with_decodable_image_uses_escape_sequence() {
484 let result = render_inline_bytes(b"hello", "image/png", 40, ImageProtocol::Kitty);
485 assert!(result.starts_with("\x1b_G"));
486 assert!(result.contains("a=T"));
487 assert!(result.ends_with("\x1b\\"));
488 }
489
490 #[test]
491 fn render_inline_iterm2_with_decodable_image_uses_escape_sequence() {
492 let result = render_inline_bytes(b"hello", "image/png", 40, ImageProtocol::Iterm2);
493 assert!(result.starts_with("\x1b]1337;File="));
494 assert!(result.contains("inline=1"));
495 assert!(result.ends_with('\x07'));
496 }
497
498 #[test]
499 fn render_inline_supported_with_empty_bytes_falls_back_to_placeholder() {
500 let kitty = render_inline_bytes(b"", "image/png", 40, ImageProtocol::Kitty);
501 assert_eq!(kitty, "[image: image/png]");
502
503 let iterm2 = render_inline_bytes(b"", "image/png", 40, ImageProtocol::Iterm2);
504 assert_eq!(iterm2, "[image: image/png]");
505 }
506
507 #[test]
508 fn detect_protocol_is_deterministic() {
509 let p1 = detect_protocol();
511 let p2 = detect_protocol();
512 assert_eq!(p1, p2);
513 }
514
515 #[test]
518 fn image_dimensions_empty_data() {
519 assert_eq!(image_dimensions(&[]), None);
520 }
521
522 #[test]
523 fn image_dimensions_truncated_png_header() {
524 let data = b"\x89PNG\r\n\x1A\n\x00\x00";
526 assert_eq!(image_dimensions(data), None);
527 }
528
529 #[test]
530 fn image_dimensions_truncated_gif_header() {
531 let data = b"GIF89a\x01";
532 assert_eq!(image_dimensions(data), None);
533 }
534
535 #[test]
536 fn image_dimensions_jpeg_truncated_sof() {
537 let data = vec![0xFF, 0xD8, 0xFF, 0xC0, 0x00, 0x05];
539 assert_eq!(image_dimensions(&data), None);
540 }
541
542 #[test]
543 fn image_dimensions_jpeg_no_sof_marker() {
544 let data = vec![0xFF, 0xD8, 0x00, 0x00];
546 assert_eq!(image_dimensions(&data), None);
547 }
548
549 #[test]
550 fn image_dimensions_gif87a() {
551 let mut data = vec![0u8; 16];
552 data[..6].copy_from_slice(b"GIF87a");
553 data[6..8].copy_from_slice(&128u16.to_le_bytes());
554 data[8..10].copy_from_slice(&64u16.to_le_bytes());
555 assert_eq!(image_dimensions(&data), Some((128, 64)));
556 }
557
558 #[test]
561 fn placeholder_width_only() {
562 let result = placeholder("image/png", Some(100), None);
563 assert_eq!(result, "[image: image/png]");
564 }
565
566 #[test]
567 fn placeholder_height_only() {
568 let result = placeholder("image/png", None, Some(200));
569 assert_eq!(result, "[image: image/png]");
570 }
571
572 #[test]
575 fn kitty_empty_data_produces_empty_output() {
576 let result = encode_kitty(&[], 40);
577 assert!(result.is_empty());
579 }
580
581 #[test]
584 fn iterm2_empty_data() {
585 let result = encode_iterm2(&[], 40);
586 assert!(result.contains("size=0"));
587 assert!(result.contains("width=40"));
588 }
589
590 #[test]
593 fn render_inline_with_valid_png_includes_dimensions() {
594 let mut png_data = vec![0u8; 32];
595 png_data[..8].copy_from_slice(b"\x89PNG\r\n\x1A\n");
596 png_data[8..12].copy_from_slice(&13u32.to_be_bytes());
597 png_data[12..16].copy_from_slice(b"IHDR");
598 png_data[16..20].copy_from_slice(&200u32.to_be_bytes());
599 png_data[20..24].copy_from_slice(&150u32.to_be_bytes());
600
601 let result = render_inline_bytes(&png_data, "image/png", 80, ImageProtocol::Unsupported);
602 assert_eq!(result, "[image: image/png, 200x150]");
603 }
604
605 #[test]
608 fn image_protocol_equality() {
609 assert_eq!(ImageProtocol::Kitty, ImageProtocol::Kitty);
610 assert_ne!(ImageProtocol::Kitty, ImageProtocol::Iterm2);
611 assert_ne!(ImageProtocol::Iterm2, ImageProtocol::Unsupported);
612 }
613
614 mod proptest_terminal_images {
615 use super::*;
616 use proptest::prelude::*;
617
618 proptest! {
619 #[test]
621 fn kitty_bookends(data in proptest::collection::vec(any::<u8>(), 1..512), cols in 1..200usize) {
622 let result = encode_kitty(&data, cols);
623 assert!(result.starts_with("\x1b_G"), "must start with APC");
624 assert!(result.ends_with("\x1b\\"), "must end with ST");
625 }
626
627 #[test]
629 fn kitty_chunk_count_lower_bound(data in proptest::collection::vec(any::<u8>(), 1..8192)) {
630 let result = encode_kitty(&data, 80);
631 let b64_len = (data.len() * 4).div_ceil(3); let expected_chunks = b64_len.div_ceil(4096);
633 let actual_chunks = result.matches("\x1b_G").count();
634 assert!(actual_chunks >= expected_chunks.min(1));
635 }
636
637 #[test]
639 fn kitty_first_chunk_has_action(data in proptest::collection::vec(any::<u8>(), 1..100)) {
640 let result = encode_kitty(&data, 40);
641 let first_st = result.find("\x1b\\").unwrap();
643 let first_chunk = &result[..first_st];
644 assert!(first_chunk.contains("a=T"));
645 assert!(first_chunk.contains("f=100"));
646 }
647
648 #[test]
650 fn iterm2_format_invariants(data in proptest::collection::vec(any::<u8>(), 0..512), cols in 1..200usize) {
651 let result = encode_iterm2(&data, cols);
652 assert!(result.starts_with("\x1b]1337;File="));
653 assert!(result.contains(&format!("size={}", data.len())));
654 assert!(result.contains(&format!("width={cols}")));
655 assert!(result.contains("inline=1"));
656 assert!(result.ends_with('\x07'));
657 }
658
659 #[test]
661 fn placeholder_both_dims(w in 1..10000u32, h in 1..10000u32, mime in "[a-z]+/[a-z]+") {
662 let result = placeholder(&mime, Some(w), Some(h));
663 assert!(result.contains(&format!("{w}x{h}")));
664 assert!(result.contains(&mime));
665 }
666
667 #[test]
669 fn placeholder_missing_dim(w in 1..10000u32, h in 1..10000u32) {
670 let dim_pattern = format!("{w}x{h}");
671 let result_no_h = placeholder("image/png", Some(w), None);
672 assert!(!result_no_h.contains(&dim_pattern));
673 assert_eq!(result_no_h, "[image: image/png]");
674 let result_no_w = placeholder("image/png", None, Some(h));
675 assert!(!result_no_w.contains(&dim_pattern));
676 assert_eq!(result_no_w, "[image: image/png]");
677 let result_none = placeholder("image/png", None, None);
678 assert_eq!(result_none, "[image: image/png]");
679 }
680
681 #[test]
683 fn png_dimensions_roundtrip(w in 1..10000u32, h in 1..10000u32) {
684 let mut data = vec![0u8; 32];
685 data[..8].copy_from_slice(b"\x89PNG\r\n\x1A\n");
686 data[8..12].copy_from_slice(&13u32.to_be_bytes());
687 data[12..16].copy_from_slice(b"IHDR");
688 data[16..20].copy_from_slice(&w.to_be_bytes());
689 data[20..24].copy_from_slice(&h.to_be_bytes());
690 assert_eq!(image_dimensions(&data), Some((w, h)));
691 }
692
693 #[test]
695 fn gif_dimensions_roundtrip(w in 1..65535u16, h in 1..65535u16) {
696 let mut data = vec![0u8; 16];
697 data[..6].copy_from_slice(b"GIF89a");
698 data[6..8].copy_from_slice(&w.to_le_bytes());
699 data[8..10].copy_from_slice(&h.to_le_bytes());
700 assert_eq!(image_dimensions(&data), Some((u32::from(w), u32::from(h))));
701 }
702
703 #[test]
705 fn unknown_format_none(data in proptest::collection::vec(any::<u8>(), 0..64)) {
706 if data.len() >= 8 && data.starts_with(b"\x89PNG\r\n\x1A\n") {
708 return Ok(());
709 }
710 if data.len() >= 4 && data.first() == Some(&0xFF) && data.get(1) == Some(&0xD8) {
711 return Ok(());
712 }
713 if data.len() >= 10 && (data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a")) {
714 return Ok(());
715 }
716 assert_eq!(image_dimensions(&data), None);
717 }
718
719 #[test]
721 fn render_inline_never_panics(b64 in "\\PC{0,100}", mime in "[a-z]+/[a-z]+") {
722 let _ = render_inline(&b64, &mime, 80);
723 }
724
725 #[test]
727 fn render_inline_png_has_dims(w in 1..5000u32, h in 1..5000u32) {
728 let mut png = vec![0u8; 32];
729 png[..8].copy_from_slice(b"\x89PNG\r\n\x1A\n");
730 png[8..12].copy_from_slice(&13u32.to_be_bytes());
731 png[12..16].copy_from_slice(b"IHDR");
732 png[16..20].copy_from_slice(&w.to_be_bytes());
733 png[20..24].copy_from_slice(&h.to_be_bytes());
734 let result = render_inline_bytes(&png, "image/png", 80, ImageProtocol::Unsupported);
735 assert!(result.contains(&format!("{w}x{h}")));
736 }
737
738 #[test]
740 fn render_inline_preserves_mime_label(
741 data in proptest::collection::vec(any::<u8>(), 0..512),
742 mime in "[a-z]{1,10}/[a-z0-9.+-]{1,20}"
743 ) {
744 let result = render_inline_bytes(&data, &mime, 80, ImageProtocol::Unsupported);
745 assert!(result.contains(&mime));
746 }
747
748 #[test]
750 fn sof_marker_classification(marker in 0u8..=255u8) {
751 let expected = matches!(
752 marker,
753 0xC0..=0xC3 | 0xC5..=0xC7 | 0xC9..=0xCB | 0xCD..=0xCF
754 );
755 assert_eq!(is_jpeg_sof_marker(marker), expected);
756 }
757 }
758 }
759}