1use gtk::prelude::*;
8
9mod codecs;
10
11static RAW_CACHE_ENABLED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
14
15pub fn set_raw_cache_enabled(enabled: bool) {
17 RAW_CACHE_ENABLED.store(enabled, std::sync::atomic::Ordering::Relaxed);
18}
19
20static RAW_FULL_DECODE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
22
23pub fn set_raw_full_decode(enabled: bool) {
25 RAW_FULL_DECODE.store(enabled, std::sync::atomic::Ordering::Relaxed);
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29enum TextureDecoder {
30 Raw,
31 Heif,
32 JpegXl,
33 Svg,
34 Jpeg,
35 Webp,
36 Jpeg2k,
37 Psd,
38 Pixbuf,
39 ImageFallback,
40}
41
42pub(super) fn load_texture_blocking(path: &std::path::Path) -> Option<gdk4::Texture> {
43 let started = std::time::Instant::now();
44 let decoder = texture_decoder_for_path(path);
45 let (result, winning_route) = decode_with_fallbacks(path, decoder);
46 log_decode_result(
47 path,
48 decoder,
49 winning_route,
50 started.elapsed().as_millis(),
51 &result,
52 );
53 result
54}
55
56fn decode_with_fallbacks(
57 path: &std::path::Path,
58 decoder: TextureDecoder,
59) -> (Option<gdk4::Texture>, TextureDecoder) {
60 if let Some(texture) = decode_with_route(path, decoder) {
61 return (Some(texture), decoder);
62 }
63 if decoder != TextureDecoder::Pixbuf
64 && let Some(texture) = decode_pixbuf_texture(path)
65 {
66 return (Some(texture), TextureDecoder::Pixbuf);
67 }
68 if let Some(texture) = decode_image_texture(path) {
69 return (Some(texture), TextureDecoder::ImageFallback);
70 }
71 (None, decoder)
72}
73
74fn decode_with_route(path: &std::path::Path, decoder: TextureDecoder) -> Option<gdk4::Texture> {
75 match decoder {
76 TextureDecoder::Raw => decode_raw_texture(path),
77 TextureDecoder::Heif => decode_heif_texture(path),
78 TextureDecoder::JpegXl => codecs::decode_jpegxl_texture(path),
79 TextureDecoder::Svg => codecs::decode_svg_texture(path),
80 TextureDecoder::Jpeg => decode_jpeg_texture(path),
81 TextureDecoder::Webp => decode_webp_texture(path),
82 TextureDecoder::Jpeg2k => codecs::decode_jpeg2k_texture(path),
83 TextureDecoder::Psd => decode_psd_texture(path),
84 TextureDecoder::Pixbuf => decode_pixbuf_texture(path),
85 TextureDecoder::ImageFallback => None,
86 }
87}
88
89fn log_decode_result(
90 path: &std::path::Path,
91 decoder: TextureDecoder,
92 winning_route: TextureDecoder,
93 elapsed_ms: u128,
94 result: &Option<gdk4::Texture>,
95) {
96 match &result {
97 Some(texture) => {
98 if winning_route == decoder {
99 log::debug!(
100 "Decoded {} via {:?} in {}ms ({}x{})",
101 path.display(),
102 decoder,
103 elapsed_ms,
104 texture.width(),
105 texture.height(),
106 );
107 } else {
108 log::debug!(
109 "Decoded {} via {:?} fallback (primary {:?} failed) in {}ms ({}x{})",
110 path.display(),
111 winning_route,
112 decoder,
113 elapsed_ms,
114 texture.width(),
115 texture.height(),
116 );
117 }
118 }
119 None => log::warn!(
120 "All decoders rejected {} (route {:?}) after {}ms",
121 path.display(),
122 decoder,
123 elapsed_ms,
124 ),
125 }
126}
127
128fn texture_decoder_for_path(path: &std::path::Path) -> TextureDecoder {
129 let ext = path
130 .extension()
131 .map(|ext| ext.to_string_lossy().to_ascii_lowercase());
132 match ext.as_deref() {
133 Some(ext) if crate::media_kinds::is_raw_ext(ext) => TextureDecoder::Raw,
134 Some("heic" | "heif" | "hif" | "avif") => TextureDecoder::Heif,
135 Some("jxl") => TextureDecoder::JpegXl,
136 Some("svg" | "svgz") => TextureDecoder::Svg,
137 Some("jpe" | "jpeg" | "jpg" | "insp") => TextureDecoder::Jpeg,
138 Some("webp") => TextureDecoder::Webp,
139 Some("jp2") => TextureDecoder::Jpeg2k,
140 Some("psd") => TextureDecoder::Psd,
141 Some("bmp" | "gif" | "png" | "tif" | "tiff") => TextureDecoder::Pixbuf,
142 _ => TextureDecoder::ImageFallback,
143 }
144}
145
146fn memory_texture(
147 width: u32,
148 height: u32,
149 format: gdk4::MemoryFormat,
150 pixels: Vec<u8>,
151 stride: usize,
152) -> Option<gdk4::Texture> {
153 let width = i32::try_from(width).ok()?;
154 let height = i32::try_from(height).ok()?;
155 let bytes = glib::Bytes::from_owned(pixels);
156 let texture = gdk4::MemoryTexture::new(width, height, format, &bytes, stride);
157 Some(texture.upcast::<gdk4::Texture>())
158}
159
160pub(super) fn decode_raw_thumbnail_texture(path: &std::path::Path) -> Option<gdk4::Texture> {
164 if let Some(tex) = extract_libraw_thumb(path) {
165 return Some(tex);
166 }
167 log::debug!(
168 "No embedded preview in {}; thumbnail falling back to full decode",
169 path.display()
170 );
171 decode_libraw_texture(path)
172}
173
174fn decode_raw_texture(path: &std::path::Path) -> Option<gdk4::Texture> {
175 let full_decode = RAW_FULL_DECODE.load(std::sync::atomic::Ordering::Relaxed);
176 if full_decode {
177 decode_full_raw_texture(path)
178 } else {
179 decode_raw_preview_or_fallback(path)
180 }
181}
182
183fn decode_full_raw_texture(path: &std::path::Path) -> Option<gdk4::Texture> {
184 decode_libraw_texture(path)
185}
186
187fn decode_raw_preview_or_fallback(path: &std::path::Path) -> Option<gdk4::Texture> {
188 if let Some(texture) = extract_libraw_thumb(path) {
189 return Some(texture);
190 }
191 log::debug!(
192 "No embedded preview in {}; falling back to full decode",
193 path.display()
194 );
195 decode_libraw_texture(path)
196}
197
198fn raw_decode_cache_dir() -> std::path::PathBuf {
200 crate::profile::cache_dir()
201 .unwrap_or_else(|| std::path::PathBuf::from("/tmp").join(crate::profile::dir_segment()))
202 .join("raw_decode")
203}
204
205fn raw_decode_cache_key(path: &std::path::Path) -> Option<String> {
209 let meta = std::fs::metadata(path).ok()?;
210 let mtime = meta
211 .modified()
212 .ok()?
213 .duration_since(std::time::UNIX_EPOCH)
214 .ok()?
215 .as_secs();
216 let size = meta.len();
217 let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
218 use std::hash::{Hash, Hasher};
219 let mut hasher = std::collections::hash_map::DefaultHasher::new();
220 canonical.hash(&mut hasher);
221 mtime.hash(&mut hasher);
222 size.hash(&mut hasher);
223 Some(format!("{:016x}.png", hasher.finish()))
224}
225
226fn read_raw_decode_cache(path: &std::path::Path) -> Option<gdk4::Texture> {
229 let key = raw_decode_cache_key(path)?;
230 let cache_file = raw_decode_cache_dir().join(&key);
231 if !cache_file.exists() {
232 return None;
233 }
234 match gdk4::Texture::from_filename(&cache_file) {
235 Ok(texture) => {
236 log::debug!("RAW decode cache hit for {}", path.display());
237 Some(texture)
238 }
239 Err(err) => {
240 log::debug!(
241 "RAW decode cache read failed for {}: {}",
242 path.display(),
243 err
244 );
245 let _ = std::fs::remove_file(&cache_file);
247 None
248 }
249 }
250}
251
252fn write_raw_decode_cache(path: &std::path::Path, texture: &gdk4::Texture) {
255 let Some(key) = raw_decode_cache_key(path) else {
256 return;
257 };
258 let cache_dir = raw_decode_cache_dir();
259 if let Err(err) = std::fs::create_dir_all(&cache_dir) {
260 log::debug!("RAW decode cache dir create failed: {}", err);
261 return;
262 }
263 let cache_file = cache_dir.join(&key);
264 if let Err(err) = texture.save_to_png(&cache_file) {
266 log::debug!(
267 "RAW decode cache write failed for {}: {}",
268 path.display(),
269 err
270 );
271 } else {
272 log::debug!("RAW decode cache written for {}", path.display());
273 }
274}
275const MIN_EMBEDDED_JPEG_SIZE: usize = 4096;
279
280fn is_lossless_jpeg(bytes: &[u8], start: usize, end: usize) -> bool {
284 let mut pos = start + 2;
285 while pos + 3 < end {
286 if bytes[pos] != 0xFF {
287 return false;
288 }
289 let marker = bytes[pos + 1];
290 match classify_jpeg_marker(marker) {
291 MarkerKind::Fill => {
292 pos += 1;
293 }
294 MarkerKind::Parameterless => {
295 pos += 2;
296 }
297 MarkerKind::Sof => return marker == 0xC3,
298 MarkerKind::Sos => return false,
299 MarkerKind::Segment => {
300 if let Some(next) = skip_segment(bytes, pos, end) {
301 pos = next;
302 } else {
303 return false;
304 }
305 }
306 }
307 }
308 false
309}
310
311enum MarkerKind {
312 Fill,
313 Parameterless,
314 Sof,
315 Sos,
316 Segment,
317}
318
319fn classify_jpeg_marker(marker: u8) -> MarkerKind {
320 match marker {
321 0xFF => MarkerKind::Fill,
322 0x01 | 0xD0..=0xD7 => MarkerKind::Parameterless,
323 0xC0..=0xCF if marker != 0xC4 && marker != 0xC8 && marker != 0xCC => MarkerKind::Sof,
324 0xDA => MarkerKind::Sos,
325 _ => MarkerKind::Segment,
326 }
327}
328
329fn skip_segment(bytes: &[u8], pos: usize, end: usize) -> Option<usize> {
332 if pos + 3 >= end {
333 return None;
334 }
335 let seg_len = u16::from_be_bytes([bytes[pos + 2], bytes[pos + 3]]) as usize;
336 if seg_len < 2 || pos + 2 + seg_len > end {
337 return None;
338 }
339 Some(pos + 2 + seg_len)
340}
341
342fn extract_largest_embedded_jpeg(path: &std::path::Path) -> Option<Vec<u8>> {
346 let bytes = std::fs::read(path).ok()?;
347 let mut best: Option<(usize, usize)> = None;
348 let len = bytes.len();
349 let mut i = 0;
350
351 while i + 3 < len {
352 if !is_embedded_jpeg_start(&bytes, i) {
353 i += 1;
354 continue;
355 }
356 if let Some(end) = find_jpeg_end(&bytes, i) {
357 best = choose_best_embedded_jpeg(&bytes, best, i, end);
358 i = end;
359 } else {
360 i += 2;
361 }
362 }
363
364 best.map(|(start, l)| bytes[start..start + l].to_vec())
365}
366
367fn is_embedded_jpeg_start(bytes: &[u8], i: usize) -> bool {
368 bytes[i] == 0xFF && bytes[i + 1] == 0xD8 && bytes[i + 2] == 0xFF && bytes[i + 3] != 0x00
369}
370
371fn choose_best_embedded_jpeg(
372 bytes: &[u8],
373 best: Option<(usize, usize)>,
374 start: usize,
375 end: usize,
376) -> Option<(usize, usize)> {
377 let payload_len = end - start;
378 let candidate_ok =
379 payload_len >= MIN_EMBEDDED_JPEG_SIZE && !is_lossless_jpeg(bytes, start, end);
380 if candidate_ok && best.is_none_or(|(_, len)| payload_len > len) {
381 Some((start, payload_len))
382 } else {
383 best
384 }
385}
386
387fn find_jpeg_end(bytes: &[u8], start: usize) -> Option<usize> {
395 let len = bytes.len();
396 let mut pos = start + 2;
397
398 loop {
399 pos = seek_marker(bytes, pos);
400 if pos + 1 >= len {
401 return Some(len);
402 }
403
404 let marker = bytes[pos + 1];
405 match marker {
406 0x00 => pos += 2,
407 0xD9 => return Some(pos + 2),
408 0xD8 => return Some(pos),
409 0xD0..=0xD7 => pos += 2,
410 0xDA => match scan_sos_entropy(bytes, pos) {
411 Some(next) => pos = next,
412 None => return Some(len),
413 },
414 0x01 => pos += 2,
415 _ => match skip_or_accept(bytes, pos) {
416 Some(next) => pos = next,
417 None => return Some(len),
418 },
419 }
420 }
421}
422
423fn seek_marker(bytes: &[u8], mut pos: usize) -> usize {
426 let len = bytes.len();
427 while pos < len && bytes[pos] != 0xFF {
428 pos += 1;
429 }
430 while pos + 1 < len && bytes[pos + 1] == 0xFF {
431 pos += 1;
432 }
433 pos
434}
435
436fn scan_sos_entropy(bytes: &[u8], pos: usize) -> Option<usize> {
439 let len = bytes.len();
440 if pos + 3 >= len {
441 return None;
442 }
443 let seg_len = u16::from_be_bytes([bytes[pos + 2], bytes[pos + 3]]) as usize;
444 if seg_len < 2 {
445 return None;
446 }
447 let mut p = pos + 2 + seg_len;
448 while p + 1 < len {
449 if bytes[p] == 0xFF {
450 let next = bytes[p + 1];
451 if next == 0x00 || (0xD0..=0xD7).contains(&next) {
452 p += 2;
453 continue;
454 }
455 return Some(p);
456 }
457 p += 1;
458 }
459 None
460}
461
462fn skip_or_accept(bytes: &[u8], pos: usize) -> Option<usize> {
465 let len = bytes.len();
466 if pos + 3 >= len {
467 return None;
468 }
469 let seg_len = u16::from_be_bytes([bytes[pos + 2], bytes[pos + 3]]) as usize;
470 if seg_len < 2 || pos + 2 + seg_len > len {
471 return None;
472 }
473 Some(pos + 2 + seg_len)
474}
475
476fn read_jpeg_exif_orientation(bytes: &[u8]) -> Option<u8> {
480 if !has_jpeg_soi(bytes) {
481 return None;
482 }
483 let mut i = 2;
484 while i + 4 <= bytes.len() {
485 if bytes[i] != 0xFF {
486 return None;
487 }
488 let marker = bytes[i + 1];
489 if is_standalone_jpeg_marker(marker) {
490 i += 2;
491 continue;
492 }
493 let (seg, next) = jpeg_segment(bytes, i)?;
494 if marker == 0xE1 && is_exif_segment(seg) {
495 return parse_tiff_orientation(&seg[6..]);
496 }
497 i = next;
498 }
499 None
500}
501
502fn has_jpeg_soi(bytes: &[u8]) -> bool {
503 bytes.len() >= 4 && bytes[0] == 0xFF && bytes[1] == 0xD8
504}
505
506fn is_standalone_jpeg_marker(marker: u8) -> bool {
507 marker == 0xD8 || marker == 0xD9 || marker == 0x01 || (0xD0..=0xD7).contains(&marker)
508}
509
510fn jpeg_segment(bytes: &[u8], i: usize) -> Option<(&[u8], usize)> {
511 let seg_len = u16::from_be_bytes([bytes[i + 2], bytes[i + 3]]) as usize;
512 if seg_len < 2 || i + 2 + seg_len > bytes.len() {
513 return None;
514 }
515 Some((&bytes[i + 4..i + 2 + seg_len], i + 2 + seg_len))
516}
517
518fn is_exif_segment(seg: &[u8]) -> bool {
519 seg.len() >= 6 && &seg[..6] == b"Exif\0\0"
520}
521
522fn parse_tiff_orientation(tiff: &[u8]) -> Option<u8> {
524 if tiff.len() < 8 {
525 return None;
526 }
527 let endian = TiffEndian::from_header(tiff)?;
528 if endian.u16(&tiff[2..4]) != 0x002A {
529 return None;
530 }
531 let ifd0 = endian.u32(&tiff[4..8]) as usize;
532 if ifd0 + 2 > tiff.len() {
533 return None;
534 }
535 let count = endian.u16(&tiff[ifd0..ifd0 + 2]) as usize;
536 for n in 0..count {
537 let off = ifd0 + 2 + n * 12;
538 let entry = tiff.get(off..off + 12)?;
539 if endian.u16(&entry[..2]) == 0x0112 {
540 let v = endian.u16(&entry[8..10]) as u8;
541 return (1..=8).contains(&v).then_some(v);
542 }
543 }
544 None
545}
546
547#[derive(Clone, Copy)]
548enum TiffEndian {
549 Little,
550 Big,
551}
552
553impl TiffEndian {
554 fn from_header(tiff: &[u8]) -> Option<Self> {
555 match &tiff[..2] {
556 b"II" => Some(Self::Little),
557 b"MM" => Some(Self::Big),
558 _ => None,
559 }
560 }
561
562 fn u16(self, p: &[u8]) -> u16 {
563 match self {
564 Self::Little => u16::from_le_bytes([p[0], p[1]]),
565 Self::Big => u16::from_be_bytes([p[0], p[1]]),
566 }
567 }
568
569 fn u32(self, p: &[u8]) -> u32 {
570 match self {
571 Self::Little => u32::from_le_bytes([p[0], p[1], p[2], p[3]]),
572 Self::Big => u32::from_be_bytes([p[0], p[1], p[2], p[3]]),
573 }
574 }
575}
576
577fn apply_exif_orientation(pixbuf: >k::gdk_pixbuf::Pixbuf, orient: u8) -> gtk::gdk_pixbuf::Pixbuf {
579 use gtk::gdk_pixbuf::PixbufRotation;
580 let rotated = match orient {
581 3 | 4 => pixbuf.rotate_simple(PixbufRotation::Upsidedown),
582 5 | 8 => pixbuf.rotate_simple(PixbufRotation::Counterclockwise),
583 6 | 7 => pixbuf.rotate_simple(PixbufRotation::Clockwise),
584 _ => Some(pixbuf.clone()),
585 }
586 .unwrap_or_else(|| pixbuf.clone());
587 match orient {
588 2 | 4 | 5 | 7 => rotated.flip(true).unwrap_or(rotated),
589 _ => rotated,
590 }
591}
592
593fn is_thumbnail_prerotated(
599 thumb_w: i32,
600 thumb_h: i32,
601 sensor_w: u32,
602 sensor_h: u32,
603 flip: std::ffi::c_int,
604) -> bool {
605 if flip != 5 && flip != 6 {
607 return false;
608 }
609 let sensor_landscape = sensor_w >= sensor_h;
610 let thumb_landscape = thumb_w >= thumb_h;
611 sensor_landscape != thumb_landscape
615}
616
617fn jpeg_bytes_to_oriented_texture(
625 bytes: &[u8],
626 flip: i32,
627 sensor_dims: (u32, u32),
628) -> Option<gdk4::Texture> {
629 let stream = gtk::gio::MemoryInputStream::from_bytes(&glib::Bytes::from(bytes));
630 let raw_pixbuf =
631 gtk::gdk_pixbuf::Pixbuf::from_stream(&stream, gtk::gio::Cancellable::NONE).ok()?;
632 let exif_orient = read_jpeg_exif_orientation(bytes);
633 let oriented = match exif_orient {
634 Some(o) if o > 1 => apply_exif_orientation(&raw_pixbuf, o),
636 _ => {
639 if is_thumbnail_prerotated(
640 raw_pixbuf.width(),
641 raw_pixbuf.height(),
642 sensor_dims.0,
643 sensor_dims.1,
644 flip,
645 ) {
646 raw_pixbuf
647 } else {
648 apply_libraw_flip(&raw_pixbuf, flip)
649 }
650 }
651 };
652 pixbuf_to_texture(&oriented)
653}
654
655fn extract_primary_embedded_jpeg(
656 path: &std::path::Path,
657 flip: i32,
658 sensor_dims: (u32, u32),
659) -> Option<gdk4::Texture> {
660 if let Some(scan) = extract_largest_embedded_jpeg(path) {
661 let scan_len = scan.len();
662 if let Some(texture) = jpeg_bytes_to_oriented_texture(&scan, flip, sensor_dims) {
663 log::debug!(
664 "Extracted embedded JPEG preview ({} bytes via SOI-scan, flip={}) from {}",
665 scan_len,
666 flip,
667 path.display()
668 );
669 return Some(texture);
670 }
671 log::debug!(
672 "SOI-scanned JPEG ({} bytes) failed to decode for {}; falling back to libraw",
673 scan_len,
674 path.display()
675 );
676 }
677 None
678}
679
680fn extract_libraw_thumb(path: &std::path::Path) -> Option<gdk4::Texture> {
684 use std::os::unix::ffi::OsStrExt;
685 let c_path = std::ffi::CString::new(path.as_os_str().as_bytes()).ok()?;
686 unsafe {
691 let lr = libraw_sys::libraw_init(0);
692 if lr.is_null() {
693 return None;
694 }
695 let _guard = LibrawHandle(lr);
696 if libraw_sys::libraw_open_file(lr, c_path.as_ptr()) != 0 {
697 return None;
698 }
699
700 let flip = (*lr).sizes.flip;
701 let sensor_dims = ((*lr).sizes.width as u32, (*lr).sizes.height as u32);
702
703 if let Some(texture) = extract_primary_embedded_jpeg(path, flip, sensor_dims) {
704 return Some(texture);
705 }
706
707 unpack_libraw_fallback_thumb(lr, flip, sensor_dims, path)
708 }
709}
710
711unsafe fn unpack_libraw_fallback_thumb(
713 lr: *mut libraw_sys::libraw_data_t,
714 flip: i32,
715 sensor_dims: (u32, u32),
716 path: &std::path::Path,
717) -> Option<gdk4::Texture> {
718 unsafe {
719 if libraw_sys::libraw_unpack_thumb(lr) != 0 {
720 log::debug!("No embedded thumbnail in {}", path.display());
721 return None;
722 }
723 let mut errcode = 0i32;
724 let img = libraw_sys::libraw_dcraw_make_mem_thumb(lr, &mut errcode);
725 if img.is_null() || errcode != 0 {
726 log::debug!(
727 "libraw_dcraw_make_mem_thumb failed ({}) for {}",
728 errcode,
729 path.display()
730 );
731 return None;
732 }
733 let _img_guard = MemImage(img);
734 let data_size = (*img).data_size as usize;
735 let bytes = std::slice::from_raw_parts((*img).data.as_ptr(), data_size);
739
740 if (*img).type_ == libraw_sys::LibRaw_image_formats_LIBRAW_IMAGE_JPEG {
741 decode_libraw_jpeg_thumb(bytes, flip, sensor_dims, data_size, path)
742 } else {
743 decode_libraw_bitmap_thumb(bytes, &*img, flip, sensor_dims, path)
744 }
745 }
746}
747
748fn decode_libraw_jpeg_thumb(
750 bytes: &[u8],
751 flip: i32,
752 sensor_dims: (u32, u32),
753 data_size: usize,
754 path: &std::path::Path,
755) -> Option<gdk4::Texture> {
756 let texture = jpeg_bytes_to_oriented_texture(bytes, flip, sensor_dims);
757 if texture.is_some() {
758 log::debug!(
759 "Extracted embedded JPEG preview ({} bytes via libraw, flip={}) from {}",
760 data_size,
761 flip,
762 path.display()
763 );
764 } else {
765 log::debug!(
766 "libraw JPEG thumb failed to decode via pixbuf for {}",
767 path.display()
768 );
769 }
770 texture
771}
772
773fn decode_libraw_bitmap_thumb(
775 bytes: &[u8],
776 img: &libraw_sys::libraw_processed_image_t,
777 flip: i32,
778 sensor_dims: (u32, u32),
779 path: &std::path::Path,
780) -> Option<gdk4::Texture> {
781 let width = img.width as i32;
782 let height = img.height as i32;
783 let colors = img.colors as i32;
784 if colors != 3 {
785 log::debug!(
786 "Embedded thumbnail has {} channels for {} -- skipping",
787 colors,
788 path.display()
789 );
790 return None;
791 }
792 let row_stride = width.checked_mul(colors)?;
793 let pixbuf = gtk::gdk_pixbuf::Pixbuf::from_mut_slice(
794 bytes.to_vec(),
795 gtk::gdk_pixbuf::Colorspace::Rgb,
796 false,
797 8,
798 width,
799 height,
800 row_stride,
801 );
802 let prerotated = is_thumbnail_prerotated(width, height, sensor_dims.0, sensor_dims.1, flip);
803 let oriented = if prerotated {
804 pixbuf
805 } else {
806 apply_libraw_flip(&pixbuf, flip)
807 };
808 let texture = pixbuf_to_texture(&oriented);
809 if texture.is_some() {
810 log::debug!(
811 "Extracted embedded bitmap preview (flip={}, prerotated={}) from {}",
812 flip,
813 prerotated,
814 path.display()
815 );
816 }
817 texture
818}
819
820fn pixbuf_to_texture(pixbuf: >k::gdk_pixbuf::Pixbuf) -> Option<gdk4::Texture> {
822 let format = if pixbuf.has_alpha() {
823 gdk4::MemoryFormat::R8g8b8a8
824 } else {
825 gdk4::MemoryFormat::R8g8b8
826 };
827 let bytes = pixbuf.read_pixel_bytes();
828 let texture = gdk4::MemoryTexture::new(
829 pixbuf.width(),
830 pixbuf.height(),
831 format,
832 &bytes,
833 pixbuf.rowstride() as usize,
834 );
835 Some(texture.upcast::<gdk4::Texture>())
836}
837
838fn apply_libraw_flip(
848 pixbuf: >k::gdk_pixbuf::Pixbuf,
849 flip: std::ffi::c_int,
850) -> gtk::gdk_pixbuf::Pixbuf {
851 use gtk::gdk_pixbuf::PixbufRotation;
852 match flip {
853 3 => pixbuf
854 .rotate_simple(PixbufRotation::Upsidedown)
855 .unwrap_or_else(|| pixbuf.clone()),
856 5 => pixbuf
857 .rotate_simple(PixbufRotation::Counterclockwise)
858 .unwrap_or_else(|| pixbuf.clone()),
859 6 => pixbuf
860 .rotate_simple(PixbufRotation::Clockwise)
861 .unwrap_or_else(|| pixbuf.clone()),
862 _ => pixbuf.clone(),
863 }
864}
865
866fn decode_libraw_texture(path: &std::path::Path) -> Option<gdk4::Texture> {
870 let cache_enabled = RAW_CACHE_ENABLED.load(std::sync::atomic::Ordering::Relaxed);
871 if cache_enabled && let Some(texture) = read_raw_decode_cache(path) {
872 return Some(texture);
873 }
874 let texture = decode_libraw_uncached(path);
875 if let Some(ref tex) = texture
876 && cache_enabled
877 {
878 write_raw_decode_cache_async(path, tex);
879 }
880 texture
881}
882
883fn decode_libraw_uncached(path: &std::path::Path) -> Option<gdk4::Texture> {
884 use std::os::unix::ffi::OsStrExt;
885 let c_path = std::ffi::CString::new(path.as_os_str().as_bytes()).ok()?;
886 unsafe {
887 let lr = init_libraw(path)?;
888 let _guard = LibrawHandle(lr);
889 configure_libraw_full_decode(lr);
890 if !run_libraw_full_decode(lr, c_path.as_ptr(), path) {
891 return None;
892 }
893 let img = make_libraw_image(lr, path)?;
894 let _img_guard = MemImage(img);
895 libraw_image_to_texture(img, path)
896 }
897}
898
899struct LibrawHandle(*mut libraw_sys::libraw_data_t);
900
901impl Drop for LibrawHandle {
902 fn drop(&mut self) {
903 unsafe { libraw_sys::libraw_close(self.0) };
904 }
905}
906
907struct MemImage(*mut libraw_sys::libraw_processed_image_t);
908
909impl Drop for MemImage {
910 fn drop(&mut self) {
911 unsafe { libraw_sys::libraw_dcraw_clear_mem(self.0) };
912 }
913}
914
915unsafe fn init_libraw(path: &std::path::Path) -> Option<*mut libraw_sys::libraw_data_t> {
916 let lr = unsafe { libraw_sys::libraw_init(0) };
917 if lr.is_null() {
918 log::debug!("libraw_init returned null for {}", path.display());
919 None
920 } else {
921 Some(lr)
922 }
923}
924
925unsafe fn configure_libraw_full_decode(lr: *mut libraw_sys::libraw_data_t) {
926 unsafe {
927 (*lr).params.use_camera_wb = 1;
928 (*lr).params.output_bps = 8;
929 (*lr).params.output_color = 1;
930 (*lr).params.user_qual = 3;
931 }
932}
933
934unsafe fn run_libraw_full_decode(
935 lr: *mut libraw_sys::libraw_data_t,
936 c_path: *const std::ffi::c_char,
937 path: &std::path::Path,
938) -> bool {
939 unsafe {
940 libraw_step(lr, path, "libraw_open_file", || {
941 libraw_sys::libraw_open_file(lr, c_path)
942 }) && libraw_step(lr, path, "libraw_unpack", || libraw_sys::libraw_unpack(lr))
943 && libraw_step(lr, path, "libraw_dcraw_process", || {
944 libraw_sys::libraw_dcraw_process(lr)
945 })
946 }
947}
948
949unsafe fn libraw_step(
950 _lr: *mut libraw_sys::libraw_data_t,
951 path: &std::path::Path,
952 label: &str,
953 f: impl FnOnce() -> i32,
954) -> bool {
955 let rc = f();
956 if rc != 0 {
957 log::debug!("{} failed ({}) for {}", label, rc, path.display());
958 false
959 } else {
960 true
961 }
962}
963
964unsafe fn make_libraw_image(
965 lr: *mut libraw_sys::libraw_data_t,
966 path: &std::path::Path,
967) -> Option<*mut libraw_sys::libraw_processed_image_t> {
968 let mut errcode = 0i32;
969 let img = unsafe { libraw_sys::libraw_dcraw_make_mem_image(lr, &mut errcode) };
970 if img.is_null() || errcode != 0 {
971 log::debug!(
972 "libraw_dcraw_make_mem_image failed ({}) for {}",
973 errcode,
974 path.display()
975 );
976 None
977 } else {
978 Some(img)
979 }
980}
981
982unsafe fn libraw_image_to_texture(
983 img: *mut libraw_sys::libraw_processed_image_t,
984 path: &std::path::Path,
985) -> Option<gdk4::Texture> {
986 let width = unsafe { (*img).width as u32 };
987 let colors = unsafe { (*img).colors };
988 if colors != 3 {
989 log::debug!(
990 "libraw produced {}-channel output for {} -- skipping",
991 colors,
992 path.display()
993 );
994 return None;
995 }
996 let height = unsafe { (*img).height as u32 };
997 let data_size = unsafe { (*img).data_size as usize };
998 let pixels = unsafe { std::slice::from_raw_parts((*img).data.as_ptr(), data_size) }.to_vec();
999 memory_texture(
1000 width,
1001 height,
1002 gdk4::MemoryFormat::R8g8b8,
1003 pixels,
1004 usize::try_from(width).ok()?.checked_mul(3)?,
1005 )
1006}
1007
1008fn write_raw_decode_cache_async(path: &std::path::Path, texture: &gdk4::Texture) {
1009 let path_clone = path.to_path_buf();
1010 let tex_clone = texture.clone();
1011 if tokio::runtime::Handle::try_current().is_ok() {
1012 tokio::task::spawn_blocking(move || {
1013 write_raw_decode_cache(&path_clone, &tex_clone);
1014 });
1015 } else {
1016 std::thread::spawn(move || {
1017 write_raw_decode_cache(&path_clone, &tex_clone);
1018 });
1019 }
1020}
1021
1022fn decode_heif_texture(path: &std::path::Path) -> Option<gdk4::Texture> {
1023 use libheif_rs::{ColorSpace, HeifContext, LibHeif, RgbChroma};
1024
1025 let libheif = LibHeif::new();
1026 let encoded = std::fs::read(path).ok()?;
1027 let context = HeifContext::read_from_bytes(&encoded).ok()?;
1028 let handle = context.primary_image_handle().ok()?;
1029 let image = libheif
1030 .decode(&handle, ColorSpace::Rgb(RgbChroma::Rgba), None)
1031 .ok()?;
1032 let plane = image.planes().interleaved?;
1033 memory_texture(
1034 plane.width,
1035 plane.height,
1036 gdk4::MemoryFormat::R8g8b8a8,
1037 plane.data.to_vec(),
1038 plane.stride,
1039 )
1040}
1041
1042fn decode_image_texture(path: &std::path::Path) -> Option<gdk4::Texture> {
1043 let reader = match image::ImageReader::open(path) {
1044 Ok(reader) => reader,
1045 Err(err) => {
1046 log::debug!("image-rs open failed for {}: {}", path.display(), err);
1047 return None;
1048 }
1049 };
1050 let reader = match reader.with_guessed_format() {
1051 Ok(reader) => reader,
1052 Err(err) => {
1053 log::debug!(
1054 "image-rs format probe failed for {}: {}",
1055 path.display(),
1056 err
1057 );
1058 return None;
1059 }
1060 };
1061 match reader.decode() {
1062 Ok(image) => dynamic_image_texture(image),
1063 Err(err) => {
1064 log::debug!("image-rs decode failed for {}: {}", path.display(), err);
1065 None
1066 }
1067 }
1068}
1069
1070fn dynamic_image_texture(image: image::DynamicImage) -> Option<gdk4::Texture> {
1071 let rgba = image.into_rgba8();
1072 let (width, height) = rgba.dimensions();
1073 memory_texture(
1074 width,
1075 height,
1076 gdk4::MemoryFormat::R8g8b8a8,
1077 rgba.into_raw(),
1078 usize::try_from(width).ok()?.checked_mul(4)?,
1079 )
1080}
1081
1082fn decode_pixbuf_texture(path: &std::path::Path) -> Option<gdk4::Texture> {
1083 let raw = match gtk::gdk_pixbuf::Pixbuf::from_file(path) {
1084 Ok(pixbuf) => pixbuf,
1085 Err(err) => {
1086 log::debug!("pixbuf decode failed for {}: {}", path.display(), err);
1087 return None;
1088 }
1089 };
1090 let pixbuf = raw.apply_embedded_orientation().unwrap_or(raw);
1091 let format = if pixbuf.has_alpha() {
1092 gdk4::MemoryFormat::R8g8b8a8
1093 } else {
1094 gdk4::MemoryFormat::R8g8b8
1095 };
1096 let bytes = pixbuf.read_pixel_bytes();
1097 let texture = gdk4::MemoryTexture::new(
1098 pixbuf.width(),
1099 pixbuf.height(),
1100 format,
1101 &bytes,
1102 pixbuf.rowstride() as usize,
1103 );
1104 Some(texture.upcast::<gdk4::Texture>())
1105}
1106
1107fn decode_jpeg_texture(path: &std::path::Path) -> Option<gdk4::Texture> {
1108 let bytes = match std::fs::read(path) {
1109 Ok(bytes) => bytes,
1110 Err(err) => {
1111 log::warn!("JPEG read failed for {}: {}", path.display(), err);
1112 return None;
1113 }
1114 };
1115 let orient = read_jpeg_exif_orientation(&bytes);
1116 match turbojpeg::decompress(&bytes, turbojpeg::PixelFormat::RGB) {
1117 Ok(image) => {
1118 let width: i32 = image.width.try_into().ok()?;
1119 let height: i32 = image.height.try_into().ok()?;
1120 let raw_pixbuf = gtk::gdk_pixbuf::Pixbuf::from_mut_slice(
1121 image.pixels,
1122 gtk::gdk_pixbuf::Colorspace::Rgb,
1123 false,
1124 8,
1125 width,
1126 height,
1127 image.pitch as i32,
1128 );
1129 let oriented = match orient {
1130 Some(o) if o != 1 => apply_exif_orientation(&raw_pixbuf, o),
1131 _ => raw_pixbuf,
1132 };
1133 pixbuf_to_texture(&oriented)
1134 }
1135 Err(err) => {
1136 log::debug!("turbojpeg decode failed for {}: {}", path.display(), err);
1137 None
1138 }
1139 }
1140}
1141
1142fn decode_webp_texture(path: &std::path::Path) -> Option<gdk4::Texture> {
1143 let bytes = match std::fs::read(path) {
1144 Ok(bytes) => bytes,
1145 Err(err) => {
1146 log::warn!("WebP read failed for {}: {}", path.display(), err);
1147 return None;
1148 }
1149 };
1150 let decoder = webp::Decoder::new(&bytes);
1151 let image = match decoder.decode() {
1152 Some(image) => image,
1153 None => {
1154 log::debug!("libwebp decode failed for {}", path.display());
1155 return None;
1156 }
1157 };
1158 let format = if image.is_alpha() {
1159 gdk4::MemoryFormat::R8g8b8a8
1160 } else {
1161 gdk4::MemoryFormat::R8g8b8
1162 };
1163 let bpp = if image.is_alpha() { 4 } else { 3 };
1164 let width = image.width();
1165 let height = image.height();
1166 memory_texture(
1167 width,
1168 height,
1169 format,
1170 image.to_vec(),
1171 usize::try_from(width).ok()?.checked_mul(bpp)?,
1172 )
1173}
1174
1175fn decode_psd_texture(path: &std::path::Path) -> Option<gdk4::Texture> {
1176 let bytes = std::fs::read(path)
1177 .map_err(|err| log::warn!("PSD read failed for {}: {}", path.display(), err))
1178 .ok()?;
1179 let psd = psd::Psd::from_bytes(&bytes)
1180 .map_err(|err| log::warn!("PSD parse failed for {}: {:?}", path.display(), err))
1181 .ok()?;
1182 let width = psd.width();
1183 let height = psd.height();
1184 memory_texture(
1185 width,
1186 height,
1187 gdk4::MemoryFormat::R8g8b8a8,
1188 psd.rgba(),
1189 usize::try_from(width).ok()?.checked_mul(4)?,
1190 )
1191}
1192
1193#[cfg(test)]
1194mod texture_decoder_tests {
1195 use super::*;
1196
1197 fn fixture(name: &str) -> std::path::PathBuf {
1198 std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1199 .join("tests/fixtures")
1200 .join(name)
1201 }
1202
1203 fn assert_fixture_texture(name: &str, dimensions: (i32, i32)) {
1204 let texture = load_texture_blocking(&fixture(name))
1205 .unwrap_or_else(|| panic!("fixture `{name}` should decode into a texture"));
1206 assert_eq!(
1207 (texture.width(), texture.height()),
1208 dimensions,
1209 "fixture `{name}` decoded with unexpected dimensions"
1210 );
1211 }
1212
1213 #[test]
1214 fn routes_special_lightbox_formats_before_loader_fallbacks() {
1215 for ext in crate::media_kinds::RAW_EXTENSIONS.iter() {
1216 assert_eq!(
1217 texture_decoder_for_path(std::path::Path::new(&format!("camera.{ext}"))),
1218 TextureDecoder::Raw,
1219 ".{ext} should run through the RAW pipeline"
1220 );
1221 }
1222
1223 for ext in ["avif", "heic", "heif", "hif"] {
1224 assert_eq!(
1225 texture_decoder_for_path(std::path::Path::new(&format!("phone.{ext}"))),
1226 TextureDecoder::Heif
1227 );
1228 }
1229 assert_eq!(
1230 texture_decoder_for_path(std::path::Path::new("wide.JXL")),
1231 TextureDecoder::JpegXl
1232 );
1233 }
1234
1235 #[test]
1236 fn every_supported_image_extension_has_a_decoder_route() {
1237 for ext in crate::media_kinds::SUPPORTED.iter() {
1238 let Some(mime) = crate::media_kinds::mime_for(ext) else {
1239 continue;
1240 };
1241 if crate::media_kinds::asset_kind(mime) == crate::media_kinds::AssetKind::Image {
1242 let route =
1243 texture_decoder_for_path(std::path::Path::new(&format!("fixture.{ext}")));
1244 assert!(
1245 matches!(
1246 route,
1247 TextureDecoder::Raw
1248 | TextureDecoder::Heif
1249 | TextureDecoder::JpegXl
1250 | TextureDecoder::Svg
1251 | TextureDecoder::Jpeg
1252 | TextureDecoder::Webp
1253 | TextureDecoder::Jpeg2k
1254 | TextureDecoder::Psd
1255 | TextureDecoder::Pixbuf
1256 | TextureDecoder::ImageFallback
1257 ),
1258 "image extension `.{ext}` has no lightbox decoder route"
1259 );
1260 }
1261 }
1262 }
1263
1264 #[test]
1265 fn fixture_standard_formats_decode_to_textures() {
1266 for name in ["sample.jpg", "sample.webp"] {
1267 assert_fixture_texture(name, (16, 12));
1268 }
1269 }
1270
1271 #[test]
1272 fn fixture_svg_decodes_to_texture() {
1273 assert_fixture_texture("sample.svg", (16, 12));
1274 }
1275
1276 #[test]
1277 fn fixture_heif_family_decodes_to_textures() {
1278 assert_fixture_texture("sample.avif", (16, 12));
1279 assert_fixture_texture("sample.heic", (64, 64));
1280 }
1281
1282 #[test]
1283 fn fixture_jpegxl_decodes_to_texture() {
1284 assert_fixture_texture("sample.jxl", (16, 12));
1285 }
1286
1287 #[test]
1288 fn fixture_dng_decode_pipeline_does_not_panic() {
1289 RAW_FULL_DECODE.store(true, std::sync::atomic::Ordering::Relaxed);
1292 let _result = load_texture_blocking(&fixture("sample.dng"));
1293 RAW_FULL_DECODE.store(false, std::sync::atomic::Ordering::Relaxed);
1294 }
1295
1296 #[test]
1297 fn largest_embedded_jpeg_picks_biggest_soi_payload() {
1298 fn build_jpeg(app_marker: u8, filler_size: usize) -> Vec<u8> {
1305 let seg_len = (filler_size + 2) as u16; let mut v = vec![0xFFu8, 0xD8, 0xFF, app_marker];
1308 v.extend_from_slice(&seg_len.to_be_bytes());
1309 v.extend_from_slice(&vec![0x42u8; filler_size]);
1310 v.extend_from_slice(&[0xFF, 0xD9]); v
1312 }
1313
1314 let small = build_jpeg(0xE0, 30); let large = build_jpeg(0xE1, 8192); let mut buf = Vec::new();
1318 buf.extend_from_slice(&[0x00; 16]);
1319 buf.extend_from_slice(&small);
1320 buf.extend_from_slice(&[0x00; 64]);
1321 buf.extend_from_slice(&large);
1322 buf.extend_from_slice(&[0x00; 16]);
1323
1324 use std::io::Write;
1325 let mut tmp = tempfile::NamedTempFile::new().unwrap();
1326 tmp.write_all(&buf).unwrap();
1327 let got = extract_largest_embedded_jpeg(tmp.path()).expect("scanner should find a JPEG");
1328
1329 assert_eq!(got.len(), large.len(), "should return the larger payload");
1330 assert_eq!(&got[..4], &[0xFF, 0xD8, 0xFF, 0xE1]);
1331 assert_eq!(&got[got.len() - 2..], &[0xFF, 0xD9]);
1332 }
1333
1334 #[test]
1335 fn largest_embedded_jpeg_skips_byte_stuffed_ff00() {
1336 let buf: Vec<u8> = vec![0x00, 0xFF, 0x00, 0xFF, 0xD8, 0xFF, 0x00, 0x42];
1340 use std::io::Write;
1341 let mut tmp = tempfile::NamedTempFile::new().unwrap();
1342 tmp.write_all(&buf).unwrap();
1343 let got = extract_largest_embedded_jpeg(tmp.path());
1344 assert!(got.is_none(), "byte-stuffed FF 00 must not match as SOI");
1345 }
1346
1347 #[test]
1348 fn largest_embedded_jpeg_accepts_implicit_eoi_at_eof() {
1349 let seg_len = (8192 + 2) as u16;
1352 let mut jpeg = vec![0xFFu8, 0xD8, 0xFF, 0xE0];
1353 jpeg.extend_from_slice(&seg_len.to_be_bytes());
1354 jpeg.extend_from_slice(&vec![0x42u8; 8192]);
1355 let mut buf = Vec::new();
1358 buf.extend_from_slice(&[0x00; 16]);
1359 buf.extend_from_slice(&jpeg);
1360
1361 use std::io::Write;
1362 let mut tmp = tempfile::NamedTempFile::new().unwrap();
1363 tmp.write_all(&buf).unwrap();
1364 let got =
1365 extract_largest_embedded_jpeg(tmp.path()).expect("scanner should accept implicit EOI");
1366
1367 assert_eq!(got.len(), buf.len() - 16, "should capture SOI to EOF");
1369 assert_eq!(&got[..4], &[0xFF, 0xD8, 0xFF, 0xE0]);
1370 }
1371
1372 #[test]
1373 fn jpeg_exif_orientation_parses_tag_0112() {
1374 let mut tiff: Vec<u8> = Vec::new();
1378 tiff.extend_from_slice(b"II"); tiff.extend_from_slice(&0x002A_u16.to_le_bytes());
1380 tiff.extend_from_slice(&8_u32.to_le_bytes()); tiff.extend_from_slice(&1_u16.to_le_bytes()); tiff.extend_from_slice(&0x0112_u16.to_le_bytes()); tiff.extend_from_slice(&3_u16.to_le_bytes()); tiff.extend_from_slice(&1_u32.to_le_bytes()); tiff.extend_from_slice(&6_u16.to_le_bytes()); tiff.extend_from_slice(&0_u16.to_le_bytes()); let mut app1: Vec<u8> = Vec::new();
1389 app1.extend_from_slice(b"Exif\0\0");
1390 app1.extend_from_slice(&tiff);
1391 let app1_len = (app1.len() + 2) as u16;
1392
1393 let mut jpeg: Vec<u8> = vec![0xFF, 0xD8]; jpeg.push(0xFF);
1395 jpeg.push(0xE1); jpeg.extend_from_slice(&app1_len.to_be_bytes());
1397 jpeg.extend_from_slice(&app1);
1398 jpeg.extend_from_slice(&[0xFF, 0xD9]); assert_eq!(read_jpeg_exif_orientation(&jpeg), Some(6));
1401 }
1402
1403 #[test]
1404 fn jpeg_exif_orientation_none_when_missing() {
1405 let jpeg = vec![0xFF, 0xD8, 0xFF, 0xD9];
1406 assert_eq!(read_jpeg_exif_orientation(&jpeg), None);
1407 }
1408
1409 #[test]
1410 fn is_lossless_jpeg_detects_sof3() {
1411 let mut lossless = vec![0xFFu8, 0xD8, 0xFF, 0xC3];
1413 lossless.extend_from_slice(&12_u16.to_be_bytes()); lossless.extend_from_slice(&[0x00; 10]); lossless.extend_from_slice(&[0xFF, 0xD9]); assert!(
1417 is_lossless_jpeg(&lossless, 0, lossless.len()),
1418 "SOF3 should be detected as lossless"
1419 );
1420 }
1421
1422 #[test]
1423 fn is_lossless_jpeg_allows_baseline() {
1424 let mut baseline = vec![0xFFu8, 0xD8, 0xFF, 0xC0];
1426 baseline.extend_from_slice(&12_u16.to_be_bytes());
1427 baseline.extend_from_slice(&[0x00; 10]);
1428 baseline.extend_from_slice(&[0xFF, 0xD9]);
1429 assert!(
1430 !is_lossless_jpeg(&baseline, 0, baseline.len()),
1431 "SOF0 should NOT be detected as lossless"
1432 );
1433 }
1434
1435 #[test]
1436 fn is_lossless_jpeg_with_app_segments_before_sof() {
1437 let mut data = vec![0xFFu8, 0xD8];
1439 data.extend_from_slice(&[0xFF, 0xE1]);
1441 data.extend_from_slice(&22_u16.to_be_bytes());
1442 data.extend_from_slice(&[0x42; 20]);
1443 data.extend_from_slice(&[0xFF, 0xC3]);
1445 data.extend_from_slice(&12_u16.to_be_bytes());
1446 data.extend_from_slice(&[0x00; 10]);
1447 data.extend_from_slice(&[0xFF, 0xD9]);
1448 assert!(is_lossless_jpeg(&data, 0, data.len()));
1449 }
1450
1451 #[test]
1452 fn soi_scanner_skips_lossless_jpeg() {
1453 fn build_jpeg_with_sof(sof_marker: u8, filler: usize) -> Vec<u8> {
1457 let mut v = vec![0xFFu8, 0xD8]; v.extend_from_slice(&[0xFF, sof_marker]);
1460 let seg_len = (filler + 2) as u16;
1461 v.extend_from_slice(&seg_len.to_be_bytes());
1462 v.extend_from_slice(&vec![0x42u8; filler]);
1463 v.extend_from_slice(&[0xFF, 0xD9]); v
1465 }
1466
1467 let lossless = build_jpeg_with_sof(0xC3, 16000); let baseline = build_jpeg_with_sof(0xC0, 8000); let mut buf = Vec::new();
1471 buf.extend_from_slice(&[0x00; 16]);
1472 buf.extend_from_slice(&lossless);
1473 buf.extend_from_slice(&[0x00; 32]);
1474 buf.extend_from_slice(&baseline);
1475 buf.extend_from_slice(&[0x00; 16]);
1476
1477 use std::io::Write;
1478 let mut tmp = tempfile::NamedTempFile::new().unwrap();
1479 tmp.write_all(&buf).unwrap();
1480 let got = extract_largest_embedded_jpeg(tmp.path())
1481 .expect("scanner should find the baseline JPEG");
1482
1483 assert_eq!(got.len(), baseline.len());
1485 assert_eq!(&got[..2], &[0xFF, 0xD8]);
1486 assert_eq!(got[3], 0xC0, "should select the SOF0 JPEG, not SOF3");
1487 }
1488
1489 #[test]
1490 fn thumbnail_prerotated_detects_portrait_on_landscape_sensor() {
1491 assert!(is_thumbnail_prerotated(480, 640, 6000, 4000, 5));
1493 }
1494
1495 #[test]
1496 fn thumbnail_prerotated_false_for_matching_aspect() {
1497 assert!(!is_thumbnail_prerotated(640, 480, 6000, 4000, 5));
1499 }
1500
1501 #[test]
1502 fn thumbnail_prerotated_false_for_no_rotation() {
1503 assert!(!is_thumbnail_prerotated(480, 640, 6000, 4000, 0));
1505 }
1506
1507 #[test]
1508 fn thumbnail_prerotated_false_for_180_rotation() {
1509 assert!(!is_thumbnail_prerotated(480, 640, 6000, 4000, 3));
1511 }
1512}