Skip to main content

pi/
terminal_images.rs

1//! Terminal image helpers.
2//!
3//! Pi renders image blocks inline when the terminal advertises a supported
4//! protocol (Kitty or iTerm2). Unsupported terminals fall back to stable text
5//! placeholders like `[image: image/png]`.
6
7use base64::Engine as _;
8use std::sync::OnceLock;
9
10// ---------------------------------------------------------------------------
11// Protocol detection
12// ---------------------------------------------------------------------------
13
14/// The image display protocol supported by the user's terminal.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum ImageProtocol {
17    /// Kitty graphics protocol (Kitty, WezTerm, Ghostty, Konsole 22+).
18    Kitty,
19    /// iTerm2 inline image protocol.
20    Iterm2,
21    /// No inline image support.
22    Unsupported,
23}
24
25/// Detect which inline-image protocol the current terminal supports.
26///
27/// Detection is cached for the lifetime of the process.
28pub 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    // iTerm2 detection (very reliable).
35    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        // WezTerm supports Kitty.
41        if lower == "wezterm" {
42            return ImageProtocol::Kitty;
43        }
44    }
45
46    // Ghostty detection.
47    if std::env::var("GHOSTTY_RESOURCES_DIR").is_ok() {
48        return ImageProtocol::Kitty;
49    }
50
51    // TERM-based heuristics.
52    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    // KITTY_WINDOW_ID is set inside Kitty terminal.
63    if std::env::var("KITTY_WINDOW_ID").is_ok() {
64        return ImageProtocol::Kitty;
65    }
66
67    ImageProtocol::Unsupported
68}
69
70// ---------------------------------------------------------------------------
71// Kitty graphics protocol
72// ---------------------------------------------------------------------------
73
74/// Maximum bytes per Kitty chunk payload.
75const KITTY_CHUNK_SIZE: usize = 4096;
76
77/// Encode image data for the Kitty graphics protocol.
78///
79/// Returns the complete escape sequence string that, when written to stdout,
80/// displays the image inline.
81///
82/// `cols` constrains the display width in terminal columns.
83pub 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            // First chunk: include action=transmit+display, format=100 (auto-detect).
100            // c=<cols> constrains display width.
101            write_kitty_chunk(&mut out, &format!("a=T,f=100,c={cols},m={more}"), chunk);
102        } else {
103            // Continuation chunk.
104            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    // Kitty uses APC: ESC _ G <control> ; <payload> ESC \
113    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
120// ---------------------------------------------------------------------------
121// iTerm2 inline image protocol
122// ---------------------------------------------------------------------------
123
124/// Encode image data for the iTerm2 inline image protocol.
125///
126/// Returns the complete escape sequence string.
127///
128/// `cols` is used to set `width` in character cells.
129pub 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    // OSC 1337 ; File=<params> : <base64> BEL
133    format!("\x1b]1337;File=size={size};width={cols};inline=1:{b64}\x07")
134}
135
136// ---------------------------------------------------------------------------
137// Placeholder fallback
138// ---------------------------------------------------------------------------
139
140/// Generate a text placeholder for terminals that don't support inline images.
141pub 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
148// ---------------------------------------------------------------------------
149// Image dimensions helper
150// ---------------------------------------------------------------------------
151
152/// Try to read image dimensions from raw bytes without fully decoding.
153///
154/// Returns `(width, height)` or `None` if the format is unrecognized.
155pub fn image_dimensions(data: &[u8]) -> Option<(u32, u32)> {
156    // PNG: width at bytes 16..20, height at 20..24 (big-endian).
157    // Valid PNGs must have the IHDR chunk immediately following the signature.
158    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    // JPEG: scan for SOF0/SOF2 markers.
165    if data.len() >= 4 && data[0] == 0xFF && data[1] == 0xD8 {
166        return jpeg_dimensions(data);
167    }
168
169    // GIF: width at 6..8, height at 8..10 (little-endian).
170    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        // Find marker prefix.
183        while i < data.len() && data[i] != 0xFF {
184            i += 1;
185        }
186        if i >= data.len() {
187            return None;
188        }
189
190        // Skip any fill bytes (0xFF) to land on the marker byte.
191        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        // Standalone markers without length payload.
202        if matches!(marker, 0x01 | 0xD0..=0xD7) {
203            continue;
204        }
205
206        // Start-of-scan or end-of-image reached before SOF.
207        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        // SOF markers (baseline/progressive and less-common extended variants).
220        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
241// ---------------------------------------------------------------------------
242// High-level render function
243// ---------------------------------------------------------------------------
244
245/// Render an image for inline terminal display.
246///
247/// - `image_b64`: base64-encoded image data (as stored in `ImageContent.data`).
248/// - `mime_type`: MIME type string (e.g. `"image/png"`).
249/// - `max_cols`: maximum display width in terminal columns.
250///
251/// Returns the string to write to the terminal.
252///
253pub 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// ---------------------------------------------------------------------------
290// Tests
291// ---------------------------------------------------------------------------
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296
297    #[test]
298    fn kitty_single_chunk_small_image() {
299        // Small payload that fits in one chunk.
300        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        // Create payload larger than KITTY_CHUNK_SIZE.
313        let data = vec![0u8; 4096];
314        let result = encode_kitty(&data, 80);
315        // Base64 of 4096 bytes = ~5462 chars, needs 2 chunks.
316        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        // First chunk should have m=1 (more to come).
322        assert!(result.contains("m=1"), "First chunk should signal more");
323        // Last chunk should have m=0.
324        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        // Minimal valid PNG header with 100x50 dimensions.
361        let mut data = vec![0u8; 32];
362        data[..8].copy_from_slice(b"\x89PNG\r\n\x1A\n");
363        // IHDR chunk: length=13, type=IHDR, width=100, height=50
364        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        // Minimal SOI + SOF0 segment with width=100, height=50.
387        let data = vec![
388            0xFF, 0xD8, // SOI
389            0xFF, 0xC0, // SOF0 marker
390            0x00, 0x11, // segment length
391            0x08, // precision
392            0x00, 0x32, // height
393            0x00, 0x64, // width
394            0x03, // component count
395            0x01, 0x11, 0x00, // Y
396            0x02, 0x11, 0x00, // Cb
397            0x03, 0x11, 0x00, // Cr
398        ];
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        // Valid JPEG marker stream with an APP0 segment and extra 0xFF fill bytes.
407        let data = vec![
408            0xFF, 0xD8, // SOI
409            0xFF, 0xE0, // APP0 marker
410            0x00, 0x02, // segment length (length field only)
411            0xFF, 0xFF, // fill bytes before next marker
412            0xC0, // SOF0 marker byte
413            0x00, 0x11, // segment length
414            0x08, // precision
415            0x00, 0x32, // height
416            0x00, 0x64, // width
417            0x03, // component count
418            0x01, 0x11, 0x00, // Y
419            0x02, 0x11, 0x00, // Cb
420            0x03, 0x11, 0x00, // Cr
421        ];
422
423        assert_eq!(image_dimensions(&data), Some((100, 50)));
424    }
425
426    #[test]
427    fn jpeg_dimensions_supports_extended_sof_markers() {
428        // SOF5 (differential sequential DCT) is uncommon but valid.
429        let data = vec![
430            0xFF, 0xD8, // SOI
431            0xFF, 0xC5, // SOF5 marker
432            0x00, 0x11, // segment length
433            0x08, // precision
434            0x00, 0x2A, // height = 42
435            0x00, 0x54, // width = 84
436            0x03, // component count
437            0x01, 0x11, 0x00, // Y
438            0x02, 0x11, 0x00, // Cb
439            0x03, 0x11, 0x00, // Cr
440        ];
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        // Calling detect_protocol twice returns the same value (cached).
510        let p1 = detect_protocol();
511        let p2 = detect_protocol();
512        assert_eq!(p1, p2);
513    }
514
515    // ── image_dimensions edge cases ──────────────────────────────────
516
517    #[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        // PNG signature but not enough data for dimensions
525        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        // SOI marker but SOF data cut short
538        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        // SOI followed by non-FF byte (invalid)
545        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    // ── placeholder edge cases ───────────────────────────────────────
559
560    #[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    // ── kitty with empty data ────────────────────────────────────────
573
574    #[test]
575    fn kitty_empty_data_produces_empty_output() {
576        let result = encode_kitty(&[], 40);
577        // Empty bytes → empty base64 → no chunks → empty output
578        assert!(result.is_empty());
579    }
580
581    // ── iterm2 with empty data ───────────────────────────────────────
582
583    #[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    // ── render_inline with valid PNG ─────────────────────────────────
591
592    #[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    // ── ImageProtocol enum ──────────────────────────────────────────
606
607    #[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            /// Kitty encoding always starts with APC and ends with ST for non-empty data.
620            #[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            /// Kitty chunk count grows with payload size.
628            #[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); // ceil(n * 4/3)
632                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            /// Kitty first chunk always includes `a=T` (transmit+display).
638            #[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                // First chunk starts at position 0
642                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            /// iTerm2 encoding includes size, width, and inline=1.
649            #[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            /// Placeholder with both dimensions includes WxH.
660            #[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            /// Placeholder without both dimensions omits WxH pattern.
668            #[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            /// PNG dimension extraction is correct for arbitrary width/height.
682            #[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            /// GIF dimension extraction is correct for arbitrary width/height.
694            #[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            /// Arbitrary bytes that don't match any magic return None.
704            #[test]
705            fn unknown_format_none(data in proptest::collection::vec(any::<u8>(), 0..64)) {
706                // Skip valid magic bytes
707                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            /// `render_inline` never panics regardless of base64 input.
720            #[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            /// Unsupported terminals preserve dimensions in placeholders.
726            #[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            /// Unsupported terminals always include the MIME label in the placeholder.
739            #[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            /// `is_jpeg_sof_marker` accepts exactly the documented SOF range.
749            #[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}