Skip to main content

pi/
terminal_images.rs

1//! Terminal image helpers.
2//!
3//! The current TUI renders images as **stable text placeholders** like
4//! `[image: image/png]`. This keeps output deterministic and test-friendly.
5//!
6//! Encoding helpers for Kitty/iTerm2 are kept around for future native inline
7//! rendering support, but are not used by the TUI today.
8
9use base64::Engine as _;
10use std::sync::OnceLock;
11
12// ---------------------------------------------------------------------------
13// Protocol detection
14// ---------------------------------------------------------------------------
15
16/// The image display protocol supported by the user's terminal.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum ImageProtocol {
19    /// Kitty graphics protocol (Kitty, WezTerm, Ghostty, Konsole 22+).
20    Kitty,
21    /// iTerm2 inline image protocol.
22    Iterm2,
23    /// No inline image support.
24    Unsupported,
25}
26
27/// Detect which inline-image protocol the current terminal supports.
28///
29/// Detection is cached for the lifetime of the process.
30pub 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    // iTerm2 detection (very reliable).
37    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        // WezTerm supports Kitty.
43        if lower == "wezterm" {
44            return ImageProtocol::Kitty;
45        }
46    }
47
48    // Ghostty detection.
49    if std::env::var("GHOSTTY_RESOURCES_DIR").is_ok() {
50        return ImageProtocol::Kitty;
51    }
52
53    // TERM-based heuristics.
54    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    // KITTY_WINDOW_ID is set inside Kitty terminal.
65    if std::env::var("KITTY_WINDOW_ID").is_ok() {
66        return ImageProtocol::Kitty;
67    }
68
69    ImageProtocol::Unsupported
70}
71
72// ---------------------------------------------------------------------------
73// Kitty graphics protocol
74// ---------------------------------------------------------------------------
75
76/// Maximum bytes per Kitty chunk payload.
77const KITTY_CHUNK_SIZE: usize = 4096;
78
79/// Encode image data for the Kitty graphics protocol.
80///
81/// Returns the complete escape sequence string that, when written to stdout,
82/// displays the image inline.
83///
84/// `cols` constrains the display width in terminal columns.
85pub 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            // First chunk: include action=transmit+display, format=100 (auto-detect).
102            // c=<cols> constrains display width.
103            write_kitty_chunk(&mut out, &format!("a=T,f=100,c={cols},m={more}"), chunk);
104        } else {
105            // Continuation chunk.
106            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    // Kitty uses APC: ESC _ G <control> ; <payload> ESC \
115    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
122// ---------------------------------------------------------------------------
123// iTerm2 inline image protocol
124// ---------------------------------------------------------------------------
125
126/// Encode image data for the iTerm2 inline image protocol.
127///
128/// Returns the complete escape sequence string.
129///
130/// `cols` is used to set `width` in character cells.
131pub 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    // OSC 1337 ; File=<params> : <base64> BEL
135    format!("\x1b]1337;File=size={size};width={cols};inline=1:{b64}\x07")
136}
137
138// ---------------------------------------------------------------------------
139// Placeholder fallback
140// ---------------------------------------------------------------------------
141
142/// Generate a text placeholder for terminals that don't support inline images.
143pub 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
150// ---------------------------------------------------------------------------
151// Image dimensions helper
152// ---------------------------------------------------------------------------
153
154/// Try to read image dimensions from raw bytes without fully decoding.
155///
156/// Returns `(width, height)` or `None` if the format is unrecognized.
157pub fn image_dimensions(data: &[u8]) -> Option<(u32, u32)> {
158    // PNG: width at bytes 16..20, height at 20..24 (big-endian).
159    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    // JPEG: scan for SOF0/SOF2 markers.
166    if data.len() >= 4 && data[0] == 0xFF && data[1] == 0xD8 {
167        return jpeg_dimensions(data);
168    }
169
170    // GIF: width at 6..8, height at 8..10 (little-endian).
171    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        // Find marker prefix.
184        while i < data.len() && data[i] != 0xFF {
185            i += 1;
186        }
187        if i >= data.len() {
188            return None;
189        }
190
191        // Skip any fill bytes (0xFF) to land on the marker byte.
192        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        // Standalone markers without length payload.
203        if matches!(marker, 0x01 | 0xD0..=0xD7) {
204            continue;
205        }
206
207        // Start-of-scan or end-of-image reached before SOF.
208        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        // SOF markers (baseline/progressive and less-common extended variants).
221        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
242// ---------------------------------------------------------------------------
243// High-level render function
244// ---------------------------------------------------------------------------
245
246/// Render an image for inline terminal display.
247///
248/// - `image_b64`: base64-encoded image data (as stored in `ImageContent.data`).
249/// - `mime_type`: MIME type string (e.g. `"image/png"`).
250/// - `max_cols`: maximum display width in terminal columns.
251///
252/// Returns the string to write to the terminal.
253///
254/// Note: today this always returns a plain-text placeholder (see module docs).
255pub fn render_inline(image_b64: &str, mime_type: &str, max_cols: usize) -> String {
256    let _ = max_cols;
257
258    // Keep TUI output deterministic across terminals by always emitting a plain-text
259    // placeholder. (Protocol detection + escape-sequence rendering lives in helpers
260    // above for future use.)
261    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// ---------------------------------------------------------------------------
270// Tests
271// ---------------------------------------------------------------------------
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    #[test]
278    fn kitty_single_chunk_small_image() {
279        // Small payload that fits in one chunk.
280        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        // Create payload larger than KITTY_CHUNK_SIZE.
293        let data = vec![0u8; 4096];
294        let result = encode_kitty(&data, 80);
295        // Base64 of 4096 bytes = ~5462 chars, needs 2 chunks.
296        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        // First chunk should have m=1 (more to come).
302        assert!(result.contains("m=1"), "First chunk should signal more");
303        // Last chunk should have m=0.
304        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        // Minimal valid PNG header with 100x50 dimensions.
341        let mut data = vec![0u8; 32];
342        data[..8].copy_from_slice(b"\x89PNG\r\n\x1A\n");
343        // IHDR chunk: length=13, type=IHDR, width=100, height=50
344        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        // Minimal SOI + SOF0 segment with width=100, height=50.
367        let data = vec![
368            0xFF, 0xD8, // SOI
369            0xFF, 0xC0, // SOF0 marker
370            0x00, 0x11, // segment length
371            0x08, // precision
372            0x00, 0x32, // height
373            0x00, 0x64, // width
374            0x03, // component count
375            0x01, 0x11, 0x00, // Y
376            0x02, 0x11, 0x00, // Cb
377            0x03, 0x11, 0x00, // Cr
378        ];
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        // Valid JPEG marker stream with an APP0 segment and extra 0xFF fill bytes.
387        let data = vec![
388            0xFF, 0xD8, // SOI
389            0xFF, 0xE0, // APP0 marker
390            0x00, 0x02, // segment length (length field only)
391            0xFF, 0xFF, // fill bytes before next marker
392            0xC0, // SOF0 marker byte
393            0x00, 0x11, // segment length
394            0x08, // precision
395            0x00, 0x32, // height
396            0x00, 0x64, // width
397            0x03, // component count
398            0x01, 0x11, 0x00, // Y
399            0x02, 0x11, 0x00, // Cb
400            0x03, 0x11, 0x00, // Cr
401        ];
402
403        assert_eq!(image_dimensions(&data), Some((100, 50)));
404    }
405
406    #[test]
407    fn jpeg_dimensions_supports_extended_sof_markers() {
408        // SOF5 (differential sequential DCT) is uncommon but valid.
409        let data = vec![
410            0xFF, 0xD8, // SOI
411            0xFF, 0xC5, // SOF5 marker
412            0x00, 0x11, // segment length
413            0x08, // precision
414            0x00, 0x2A, // height = 42
415            0x00, 0x54, // width = 84
416            0x03, // component count
417            0x01, 0x11, 0x00, // Y
418            0x02, 0x11, 0x00, // Cb
419            0x03, 0x11, 0x00, // Cr
420        ];
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        // Force unsupported by not setting any terminal env vars.
447        // In CI/test environments, detect_protocol() typically returns Unsupported.
448        // We test the placeholder path directly.
449        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        // Calling detect_protocol twice returns the same value (cached).
457        let p1 = detect_protocol();
458        let p2 = detect_protocol();
459        assert_eq!(p1, p2);
460    }
461
462    // ── image_dimensions edge cases ──────────────────────────────────
463
464    #[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        // PNG signature but not enough data for dimensions
472        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        // SOI marker but SOF data cut short
485        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        // SOI followed by non-FF byte (invalid)
492        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    // ── placeholder edge cases ───────────────────────────────────────
506
507    #[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    // ── kitty with empty data ────────────────────────────────────────
520
521    #[test]
522    fn kitty_empty_data_produces_empty_output() {
523        let result = encode_kitty(&[], 40);
524        // Empty bytes → empty base64 → no chunks → empty output
525        assert!(result.is_empty());
526    }
527
528    // ── iterm2 with empty data ───────────────────────────────────────
529
530    #[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    // ── render_inline with valid PNG ─────────────────────────────────
538
539    #[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    // ── ImageProtocol enum ──────────────────────────────────────────
554
555    #[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            /// Kitty encoding always starts with APC and ends with ST for non-empty data.
568            #[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            /// Kitty chunk count grows with payload size.
576            #[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); // ceil(n * 4/3)
580                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            /// Kitty first chunk always includes `a=T` (transmit+display).
586            #[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                // First chunk starts at position 0
590                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            /// iTerm2 encoding includes size, width, and inline=1.
597            #[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            /// Placeholder with both dimensions includes WxH.
608            #[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            /// Placeholder without both dimensions omits WxH pattern.
616            #[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            /// PNG dimension extraction is correct for arbitrary width/height.
630            #[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            /// GIF dimension extraction is correct for arbitrary width/height.
642            #[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            /// Arbitrary bytes that don't match any magic return None.
652            #[test]
653            fn unknown_format_none(data in proptest::collection::vec(any::<u8>(), 0..64)) {
654                // Skip valid magic bytes
655                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            /// `render_inline` never panics regardless of base64 input.
668            #[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            /// `render_inline` with valid PNG base64 includes dimensions.
674            #[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            /// `render_inline` always includes the MIME label in the placeholder.
688            #[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            /// `is_jpeg_sof_marker` accepts exactly the documented SOF range.
699            #[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}