1#![deny(
28 clippy::unwrap_used,
29 clippy::expect_used,
30 clippy::indexing_slicing,
31 clippy::panic,
32 clippy::unreachable
33)]
34
35use std::{
36 fs::File,
37 io::{self, Read, Seek},
38 path::{Path, PathBuf},
39};
40
41pub const CACHE_VERSION: u32 = 9;
51
52const OLE_MAGIC: [u8; 8] = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1];
53const PNG_MAGIC: [u8; 8] = [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
54const MAX_STREAM: u64 = 8 * 1024 * 1024;
56const MAX_SCAN: u64 = 64 * 1024 * 1024;
58const MIN_SIDE: u32 = 8;
59const MAX_SIDE: u32 = 4096;
60
61pub fn supports(format_id: &str) -> bool {
63 (format_id.starts_with("inventor-") && format_id != "inventor-project")
64 || format_id.starts_with("solidedge-")
65 || matches!(
66 format_id,
67 "solidworks-part"
68 | "solidworks-assembly"
69 | "solidworks-drawing"
70 | "nx-part"
71 | "rhino"
72 | "catia-part"
73 | "catia-product"
74 | "jt"
75 | "sketchup"
76 | "creo-part"
77 | "creo-assembly"
78 | "fusion-design"
79 )
80}
81
82#[derive(Clone, Debug, PartialEq)]
84pub struct Thumbnail {
85 pub content_type: &'static str,
87 pub bytes: Vec<u8>,
88}
89
90#[derive(Clone, Debug)]
91struct Found {
92 thumbnail: Thumbnail,
93 area: u64,
94}
95
96fn better(best: &mut Option<Found>, candidate: Option<Found>) {
97 let Some(c) = candidate else { return };
98 let replace = match best {
99 None => true,
100 Some(b) => {
101 c.area > b.area
102 || (c.area == b.area
103 && c.thumbnail.content_type == "image/png"
104 && b.thumbnail.content_type != "image/png")
105 }
106 };
107 if replace {
108 *best = Some(c);
109 }
110}
111
112pub fn extract(path: &Path) -> io::Result<Option<Thumbnail>> {
114 extract_from(io::BufReader::new(File::open(path)?))
115}
116
117pub fn extract_from<R: Read + Seek>(mut file: R) -> io::Result<Option<Thumbnail>> {
119 let mut head = [0u8; 1024];
120 let n = read_up_to(&mut file, &mut head)?;
121 let head = head.get(..n).unwrap_or(&head);
122 file.rewind()?;
123 if head.starts_with(&OLE_MAGIC) {
124 return Ok(from_compound_file(file));
125 }
126 if head.starts_with(crate::nxpart::MAGIC) {
127 return from_nx_part(file);
128 }
129 if crate::swpart::is_container(head) {
130 return from_sw_container(file);
131 }
132 if head.starts_with(crate::mesh::rhino::MAGIC) {
133 return from_rhino(file);
134 }
135 if crate::fusion::is_archive(head) {
136 return from_fusion(file);
137 }
138 let mut data = Vec::new();
139 file.take(MAX_SCAN).read_to_end(&mut data)?;
140 Ok(find_in(&data, false).map(|f| f.thumbnail))
141}
142
143fn from_rhino<R: Read>(file: R) -> io::Result<Option<Thumbnail>> {
146 let mut data = Vec::new();
147 file.take(MAX_SCAN).read_to_end(&mut data)?;
148 Ok(crate::mesh::rhino::preview_dib(&data)
149 .and_then(|dib| dib_at(&dib))
150 .map(|f| f.thumbnail))
151}
152
153fn from_sw_container<R: Read + Seek>(file: R) -> io::Result<Option<Thumbnail>> {
155 let Some(mut sw) = crate::swpart::SwContainer::open(file)? else {
156 return Ok(None);
157 };
158 let Some(data) = sw.read(crate::swpart::PREVIEW_SECTION, MAX_STREAM)? else {
159 return Ok(None);
160 };
161 Ok(png_at(&data).map(|f| f.thumbnail))
162}
163
164fn from_fusion<R: Read + Seek>(file: R) -> io::Result<Option<Thumbnail>> {
166 let Some(mut archive) = crate::fusion::Archive::open(file)? else {
167 return Ok(None);
168 };
169 let Some(name) = archive.entry(crate::fusion::PREVIEW_PNG) else {
170 return Ok(None);
171 };
172 let Some(data) = archive.read(&name, MAX_STREAM)? else {
173 return Ok(None);
174 };
175 Ok(png_at(&data).map(|f| f.thumbnail))
176}
177
178fn from_nx_part<R: Read + Seek>(file: R) -> io::Result<Option<Thumbnail>> {
180 let Some(mut part) = crate::nxpart::NxPart::open(file)? else {
181 return Ok(None);
182 };
183 let Some(data) = part.read(crate::nxpart::PREVIEW_STREAM, MAX_STREAM)? else {
184 return Ok(None);
185 };
186 Ok(jpeg_size(&data)
187 .filter(|&(w, h)| plausible(w, h))
188 .map(|_| Thumbnail {
189 content_type: "image/jpeg",
190 bytes: data,
191 }))
192}
193
194fn jpeg_size(data: &[u8]) -> Option<(u32, u32)> {
197 if !data.starts_with(&[0xFF, 0xD8]) {
198 return None;
199 }
200 let mut i = 2;
201 loop {
202 while *data.get(i)? == 0xFF && *data.get(i + 1)? == 0xFF {
204 i += 1;
205 }
206 if *data.get(i)? != 0xFF {
207 return None;
208 }
209 let marker = *data.get(i + 1)?;
210 if marker == 0x01 || (0xD0..=0xD7).contains(&marker) {
211 i += 2;
212 continue;
213 }
214 let len = u16::from_be_bytes([*data.get(i + 2)?, *data.get(i + 3)?]) as usize;
215 if len < 2 {
216 return None;
217 }
218 let is_sof = (0xC0..=0xCF).contains(&marker) && !matches!(marker, 0xC4 | 0xC8 | 0xCC);
219 if is_sof {
220 let h = u16::from_be_bytes([*data.get(i + 5)?, *data.get(i + 6)?]);
221 let w = u16::from_be_bytes([*data.get(i + 7)?, *data.get(i + 8)?]);
222 return Some((u32::from(w), u32::from(h)));
223 }
224 if marker == 0xDA || marker == 0xD9 {
225 return None; }
227 i += 2 + len;
228 }
229}
230
231fn read_up_to<R: Read>(file: &mut R, buf: &mut [u8]) -> io::Result<usize> {
232 let mut n = 0;
233 while let Some(rest) = buf.get_mut(n..).filter(|r| !r.is_empty()) {
234 match file.read(rest)? {
235 0 => break,
236 k => n += k,
237 }
238 }
239 Ok(n)
240}
241
242fn from_compound_file<F: Read + Seek>(inner: F) -> Option<Thumbnail> {
245 let mut cfb = cfb::CompoundFile::open(inner).ok()?;
246 let mut streams: Vec<(u8, PathBuf)> = cfb
247 .walk()
248 .filter(|e| e.is_stream() && e.len() <= MAX_STREAM)
249 .map(|e| {
250 let name = e.name().to_ascii_lowercase();
251 let rank = if name.ends_with("summaryinformation") {
252 0
253 } else if name.contains("preview") || name.contains("thumbnail") {
254 1
255 } else {
256 2
257 };
258 (rank, e.path().to_path_buf())
259 })
260 .collect();
261 streams.sort_by_key(|(rank, _)| *rank);
262 let mut best: Option<Found> = None;
263 let mut best_rank = u8::MAX;
264 for (rank, path) in streams {
265 if best.is_some() && rank > best_rank {
266 break; }
268 let mut data = Vec::new();
269 let Ok(mut stream) = cfb.open_stream(&path) else {
270 continue;
271 };
272 if stream.read_to_end(&mut data).is_err() {
273 continue;
274 }
275 let found = find_in(&data, true);
276 if found.is_some() && best.is_none() {
277 best_rank = rank;
278 }
279 better(&mut best, found);
280 }
281 best.map(|f| f.thumbnail)
282}
283
284fn find_in(data: &[u8], dibs: bool) -> Option<Found> {
286 let mut best = None;
287 let mut from = 0;
288 while let Some(pos) = data
289 .get(from..)
290 .and_then(|rest| rest.windows(PNG_MAGIC.len()).position(|w| w == PNG_MAGIC))
291 {
292 let start = from + pos;
293 if let Some(rest) = data.get(start..) {
294 better(&mut best, png_at(rest));
295 }
296 from = start + 1;
297 }
298 if dibs {
299 for i in 0..data.len().saturating_sub(40) {
300 let Some(rest) = data.get(i..) else { break };
301 if matches!(le_u32(rest, 0), Some(40 | 108 | 124)) {
302 better(&mut best, dib_at(rest));
303 }
304 }
305 }
306 best
307}
308
309fn quad(data: &[u8], o: usize) -> Option<[u8; 4]> {
311 data.get(o..o.checked_add(4)?)
312 .and_then(|b| <[u8; 4]>::try_from(b).ok())
313}
314
315fn le_u32(data: &[u8], o: usize) -> Option<u32> {
316 quad(data, o).map(u32::from_le_bytes)
317}
318
319fn plausible(width: u32, height: u32) -> bool {
320 (MIN_SIDE..=MAX_SIDE).contains(&width) && (MIN_SIDE..=MAX_SIDE).contains(&height)
321}
322
323fn png_at(data: &[u8]) -> Option<Found> {
326 let be32 = |o: usize| quad(data, o).map(u32::from_be_bytes);
327 let mut pos = PNG_MAGIC.len();
328 let (mut width, mut height) = (0, 0);
329 loop {
330 let len = usize::try_from(be32(pos)?).ok()?;
331 let end = pos.checked_add(12)?.checked_add(len)?;
332 let kind = data.get(pos + 4..pos + 8)?;
333 if end > data.len() || !kind.iter().all(u8::is_ascii_alphabetic) {
334 return None;
335 }
336 if crc32fast::hash(data.get(pos + 4..end - 4)?) != be32(end - 4)? {
337 return None;
338 }
339 if pos == PNG_MAGIC.len() {
340 if kind != b"IHDR" || len != 13 {
341 return None;
342 }
343 width = be32(pos + 8)?;
344 height = be32(pos + 12)?;
345 }
346 pos = end;
347 if kind == b"IEND" {
348 break;
349 }
350 }
351 if !plausible(width, height) {
352 return None;
353 }
354 Some(Found {
355 area: u64::from(width) * u64::from(height),
356 thumbnail: Thumbnail {
357 content_type: "image/png",
358 bytes: data.get(..pos)?.to_vec(),
359 },
360 })
361}
362
363fn dib_at(data: &[u8]) -> Option<Found> {
366 let le32 = |o: usize| le_u32(data, o);
367 let le16 = |o: usize| {
368 data.get(o..o + 2)
369 .and_then(|b| <[u8; 2]>::try_from(b).ok())
370 .map(u16::from_le_bytes)
371 };
372 let header = usize::try_from(le32(0)?).ok()?;
373 if !matches!(header, 40 | 108 | 124) || data.len() < header {
374 return None;
375 }
376 let width = le32(4)? as i32;
377 let height = le32(8)? as i32;
378 let (planes, bits, compression) = (le16(12)?, le16(14)?, le32(16)?);
379 let (image_size, colors_used) = (le32(20)?, le32(32)?);
380 if planes != 1
381 || !matches!(bits, 1 | 4 | 8 | 16 | 24 | 32)
382 || !(compression == 0 || (compression == 3 && matches!(bits, 16 | 32)))
383 || colors_used > 256
384 {
385 return None;
386 }
387 let (w, h) = (u32::try_from(width).ok()?, height.unsigned_abs());
388 if !plausible(w, h) {
389 return None;
390 }
391 let palette = 4 * if bits <= 8 && colors_used == 0 {
392 1usize << bits
393 } else {
394 colors_used as usize
395 };
396 let masks = if compression == 3 && header == 40 {
397 12
398 } else {
399 0
400 };
401 let stride = (w as usize * bits as usize).div_ceil(32) * 4;
402 let pixels = stride.checked_mul(h as usize)?;
403 if image_size != 0 && !(pixels / 2..=pixels * 2).contains(&(image_size as usize)) {
405 return None;
406 }
407 let total = header + masks + palette + pixels;
408 if total > data.len() {
409 return None;
410 }
411 let file_size = u32::try_from(14 + total).ok()?;
412 let offset = u32::try_from(14 + header + masks + palette).ok()?;
413 let mut bmp = Vec::with_capacity(14 + total);
414 bmp.extend_from_slice(b"BM");
415 bmp.extend_from_slice(&file_size.to_le_bytes());
416 bmp.extend_from_slice(&[0; 4]);
417 bmp.extend_from_slice(&offset.to_le_bytes());
418 bmp.extend_from_slice(data.get(..total)?);
419 Some(Found {
420 area: u64::from(w) * u64::from(h),
421 thumbnail: Thumbnail {
422 content_type: "image/bmp",
423 bytes: bmp,
424 },
425 })
426}
427
428fn cache_base(cache_dir: &Path, sha256: &str) -> io::Result<(PathBuf, String)> {
430 if sha256.len() != 64 || !sha256.bytes().all(|b| b.is_ascii_hexdigit()) {
431 return Err(io::Error::new(
432 io::ErrorKind::InvalidInput,
433 "invalid content hash",
434 ));
435 }
436 let dir = cache_dir.join(sha256.get(..2).unwrap_or_default());
437 Ok((dir, format!("{sha256}.thumb.v{CACHE_VERSION}")))
438}
439
440pub fn lookup(cache_dir: &Path, sha256: &str) -> io::Result<Option<Option<Thumbnail>>> {
443 let (dir, base) = cache_base(cache_dir, sha256)?;
444 for (ext, content_type) in [
445 ("png", "image/png"),
446 ("bmp", "image/bmp"),
447 ("jpg", "image/jpeg"),
448 ] {
449 if let Ok(bytes) = std::fs::read(dir.join(format!("{base}.{ext}"))) {
450 return Ok(Some(Some(Thumbnail {
451 content_type,
452 bytes,
453 })));
454 }
455 }
456 if dir.join(format!("{base}.none")).exists() {
457 return Ok(Some(None));
458 }
459 Ok(None)
460}
461
462pub fn store(cache_dir: &Path, sha256: &str, found: Option<&Thumbnail>) -> io::Result<()> {
464 let (dir, base) = cache_base(cache_dir, sha256)?;
465 std::fs::create_dir_all(&dir)?;
466 let (name, bytes): (String, &[u8]) = match found {
467 Some(t) if t.content_type == "image/png" => (format!("{base}.png"), &t.bytes),
468 Some(t) if t.content_type == "image/jpeg" => (format!("{base}.jpg"), &t.bytes),
469 Some(t) => (format!("{base}.bmp"), &t.bytes),
470 None => (format!("{base}.none"), b""),
471 };
472 let tmp = dir.join(format!("{name}.{}.tmp", std::process::id()));
474 std::fs::write(&tmp, bytes)?;
475 std::fs::rename(&tmp, dir.join(name))
476}
477
478pub fn cached(cache_dir: &Path, sha256: &str, blob: &Path) -> io::Result<Option<Thumbnail>> {
481 if let Some(known) = lookup(cache_dir, sha256)? {
482 return Ok(known);
483 }
484 let found = extract(blob)?;
485 store(cache_dir, sha256, found.as_ref())?;
486 Ok(found)
487}
488
489pub const RENDERED_SIZE: u32 = 512;
491
492pub fn rendered(format_id: &str) -> bool {
502 matches!(
503 format_id,
504 "nx-part"
505 | "jt"
506 | "rhino"
507 | "solidworks-part"
508 | "solidworks-assembly"
509 | "inventor-part"
510 | "inventor-assembly"
511 | "catia-part"
512 | "catia-product"
513 | "sketchup"
514 | "creo-part"
515 | "creo-assembly"
516 | "fusion-design"
517 )
518}
519
520pub fn render_mesh(mesh_bytes: &[u8]) -> Option<Thumbnail> {
522 let mesh = crate::mesh::Mesh::decode(mesh_bytes).ok()?;
523 mesh.check().ok()?;
524 crate::mesh::render::png(&mesh, RENDERED_SIZE).map(|bytes| Thumbnail {
525 content_type: "image/png",
526 bytes,
527 })
528}
529
530#[cfg(test)]
531mod tests {
532 use super::*;
533 use std::io::{Cursor, Write};
534
535 pub(crate) fn png(w: u32, h: u32) -> Vec<u8> {
538 fn chunk(out: &mut Vec<u8>, kind: &[u8], data: &[u8]) {
539 out.extend_from_slice(&(data.len() as u32).to_be_bytes());
540 let mut body = kind.to_vec();
541 body.extend_from_slice(data);
542 out.extend_from_slice(&body);
543 out.extend_from_slice(&crc32fast::hash(&body).to_be_bytes());
544 }
545 let mut out = PNG_MAGIC.to_vec();
546 let mut ihdr = Vec::new();
547 ihdr.extend_from_slice(&w.to_be_bytes());
548 ihdr.extend_from_slice(&h.to_be_bytes());
549 ihdr.extend_from_slice(&[8, 2, 0, 0, 0]);
550 chunk(&mut out, b"IHDR", &ihdr);
551 chunk(
552 &mut out,
553 b"IDAT",
554 &[0x78, 0x9c, 0x03, 0x00, 0x00, 0x00, 0x00, 0x01],
555 );
556 chunk(&mut out, b"IEND", &[]);
557 out
558 }
559
560 fn dib(w: u32, h: u32) -> Vec<u8> {
562 let stride = (w as usize * 24).div_ceil(32) * 4;
563 let mut out = Vec::new();
564 for v in [40u32, w, h] {
565 out.extend_from_slice(&v.to_le_bytes());
566 }
567 out.extend_from_slice(&1u16.to_le_bytes());
568 out.extend_from_slice(&24u16.to_le_bytes());
569 for v in [0u32, 0, 2835, 2835, 0, 0] {
570 out.extend_from_slice(&v.to_le_bytes());
571 }
572 out.extend((0..stride * h as usize).map(|i| (i % 251) as u8));
573 out
574 }
575
576 fn compound(streams: &[(&str, Vec<u8>)]) -> Vec<u8> {
577 let mut cfb = cfb::CompoundFile::create(Cursor::new(Vec::new())).unwrap();
578 for (name, data) in streams {
579 if let Some(parent) = Path::new(name).parent().filter(|p| *p != Path::new("/")) {
580 if !cfb.exists(parent) {
581 cfb.create_storage_all(parent).unwrap();
582 }
583 }
584 cfb.create_stream(name).unwrap().write_all(data).unwrap();
585 }
586 cfb.flush().unwrap();
587 cfb.into_inner().into_inner()
588 }
589
590 struct Temp(PathBuf);
592
593 impl Temp {
594 fn path(&self) -> &Path {
595 &self.0
596 }
597 }
598
599 impl Drop for Temp {
600 fn drop(&mut self) {
601 let _ = std::fs::remove_dir_all(&self.0);
602 let _ = std::fs::remove_file(&self.0);
603 }
604 }
605
606 fn temp_name() -> PathBuf {
607 use std::sync::atomic::{AtomicU32, Ordering};
608 static NEXT: AtomicU32 = AtomicU32::new(0);
609 std::env::temp_dir().join(format!(
610 "teamcraft-thumb-{}-{}",
611 std::process::id(),
612 NEXT.fetch_add(1, Ordering::Relaxed)
613 ))
614 }
615
616 fn write_temp(bytes: &[u8]) -> Temp {
617 let path = temp_name();
618 std::fs::write(&path, bytes).unwrap();
619 Temp(path)
620 }
621
622 #[test]
623 fn inventor_style_png_in_the_summary_information() {
624 let mut summary = vec![0u8; 48];
626 summary.extend_from_slice(&[0xFF, 0xFF, 0xFF, 0xFF, 0, 0, 0, 0]);
627 summary.extend_from_slice(&png(512, 512));
628 let file = compound(&[
629 ("/\u{5}SummaryInformation", summary),
630 ("/RSeStorage/Texture", png(1024, 1024)),
632 ]);
633 let t = from_compound_file(Cursor::new(file)).unwrap();
634 assert_eq!(t.content_type, "image/png");
635 assert_eq!(t.bytes, png(512, 512));
636 }
637
638 #[test]
639 fn rhino_models_give_their_stored_bitmap() {
640 let t = extract_from(Cursor::new(crate::mesh::rhino::tests::model()))
641 .unwrap()
642 .unwrap();
643 assert_eq!(t.content_type, "image/bmp");
644 assert!(t.bytes.starts_with(b"BM"));
645 assert!(supports("rhino") && rendered("rhino"));
646 }
647
648 #[test]
649 fn solid_edge_style_dib_becomes_a_bmp() {
650 let mut summary = vec![1, 2, 3, 4, 0xFF, 0xFF, 0xFF, 0xFF, 8, 0, 0, 0];
651 summary.extend_from_slice(&dib(96, 64));
652 let file = compound(&[("/\u{5}SummaryInformation", summary)]);
653 let t = from_compound_file(Cursor::new(file)).unwrap();
654 assert_eq!(t.content_type, "image/bmp");
655 assert_eq!(&t.bytes[..2], b"BM");
656 assert_eq!(
657 u32::from_le_bytes(t.bytes[2..6].try_into().unwrap()) as usize,
658 t.bytes.len()
659 );
660 assert_eq!(u32::from_le_bytes(t.bytes[10..14].try_into().unwrap()), 54);
661 }
662
663 #[test]
664 fn preview_streams_and_plain_files() {
665 let file = compound(&[("/PreviewPNG", png(640, 480)), ("/Contents", vec![7; 4096])]);
667 let t = from_compound_file(Cursor::new(file)).unwrap();
668 assert_eq!(t.bytes, png(640, 480));
669 let mut bytes = vec![0x13; 1000];
671 bytes.extend_from_slice(&png(640, 480));
672 bytes.extend_from_slice(&[0x42; 100]);
673 let f = write_temp(&bytes);
674 assert_eq!(extract(f.path()).unwrap().unwrap().bytes, png(640, 480));
675 let mut broken = png(64, 64);
677 let last = broken.len() - 1;
678 broken[last] ^= 0xFF;
679 let f = write_temp(&broken);
680 assert_eq!(extract(f.path()).unwrap(), None);
681 let f = write_temp(&png(64, 64)[..40]);
682 assert_eq!(extract(f.path()).unwrap(), None);
683 assert!(png_at(&png(2, 2)).is_none());
685 assert!(dib_at(&dib(4, 4)).is_none());
686 }
687
688 #[test]
689 fn results_are_cached() {
690 let dir = Temp(temp_name());
691 std::fs::create_dir_all(dir.path()).unwrap();
692 let blob = write_temp(&compound(&[("/PreviewPNG", png(32, 32))]));
693 let sha = "ab".repeat(32);
694 let first = cached(dir.path(), &sha, blob.path()).unwrap().unwrap();
695 assert!(dir
696 .path()
697 .join("ab")
698 .join(format!("{sha}.thumb.v{CACHE_VERSION}.png"))
699 .exists());
700 drop(blob); let missing = dir.path().join("gone");
702 assert_eq!(cached(dir.path(), &sha, &missing).unwrap().unwrap(), first);
703 let empty = write_temp(b"no preview here");
704 let other = "cd".repeat(32);
705 assert_eq!(cached(dir.path(), &other, empty.path()).unwrap(), None);
706 assert!(dir
707 .path()
708 .join("cd")
709 .join(format!("{other}.thumb.v{CACHE_VERSION}.none"))
710 .exists());
711 assert!(cached(dir.path(), "../x", empty.path()).is_err());
712 assert!(supports("inventor-assembly") && supports("solidedge-weldment"));
713 assert!(supports("nx-part") && supports("catia-part"));
714 assert!(!supports("inventor-project") && !supports("step"));
715 assert!(rendered("nx-part") && rendered("catia-part") && rendered("inventor-part"));
716 assert!(rendered("catia-product") && supports("catia-product"));
717 assert!(!rendered("catia-drawing") && !rendered("inventor-drawing"));
718 }
719
720 #[test]
721 fn the_largest_image_wins() {
722 let mut data = vec![0x11; 50];
723 data.extend(png(64, 64));
724 data.extend([0x22; 10]);
725 data.extend(png(256, 128));
726 data.extend([0x33; 10]);
727 data.extend(png(100, 100));
728 let found = find_in(&data, false).unwrap();
729 assert_eq!(found.thumbnail.bytes, png(256, 128));
730 assert_eq!(found.area, 256 * 128);
731 let mut with_dib = data.clone();
733 with_dib.extend(dib(400, 300));
734 assert_eq!(
735 find_in(&with_dib, false).unwrap().thumbnail.content_type,
736 "image/png"
737 );
738 assert_eq!(
739 find_in(&with_dib, true).unwrap().thumbnail.content_type,
740 "image/bmp"
741 );
742 assert!(find_in(b"nothing", true).is_none());
743 }
744
745 #[test]
746 fn size_limits() {
747 assert!(plausible(MIN_SIDE, MAX_SIDE) && plausible(MAX_SIDE, MIN_SIDE));
748 assert!(!plausible(MIN_SIDE - 1, 64) && !plausible(64, MAX_SIDE + 1));
749 assert!(png_at(&png(8, 8)).is_some());
750 assert!(png_at(&png(4097, 16)).is_none());
751 }
752
753 #[test]
754 fn png_structure_is_checked() {
755 let good = png(32, 32);
757 let mut renamed = good.clone();
758 renamed[12..16].copy_from_slice(b"IHDX");
759 assert!(png_at(&renamed).is_none());
760 let mut bad_kind = good.clone();
762 bad_kind[12] = b'1';
763 assert!(png_at(&bad_kind).is_none());
764 assert!(png_at(&good[..good.len() - 12]).is_none());
766 let mut trailing = good.clone();
768 trailing.extend_from_slice(b"garbage");
769 assert_eq!(png_at(&trailing).unwrap().thumbnail.bytes, good);
770 }
771
772 #[test]
773 fn dib_variants() {
774 let mut top_down = dib(16, 16);
776 top_down[8..12].copy_from_slice(&(-16i32).to_le_bytes());
777 let found = dib_at(&top_down).unwrap();
778 assert_eq!(found.area, 256);
779 let mut rle = dib(16, 16);
781 rle[16..20].copy_from_slice(&1u32.to_le_bytes());
782 assert!(dib_at(&rle).is_none());
783 let mut bogus_size = dib(16, 16);
785 bogus_size[20..24].copy_from_slice(&1_000_000u32.to_le_bytes());
786 assert!(dib_at(&bogus_size).is_none());
787 let full = dib(16, 16);
789 assert!(dib_at(&full[..full.len() - 1]).is_none());
790 let mut planes = dib(16, 16);
792 planes[12..14].copy_from_slice(&2u16.to_le_bytes());
793 assert!(dib_at(&planes).is_none());
794 }
795
796 #[test]
797 fn eight_bit_dibs_carry_their_palette() {
798 let (w, h) = (16u32, 8u32);
799 let mut d = Vec::new();
800 for v in [40u32, w, h] {
801 d.extend_from_slice(&v.to_le_bytes());
802 }
803 d.extend_from_slice(&1u16.to_le_bytes());
804 d.extend_from_slice(&8u16.to_le_bytes());
805 for v in [0u32, 0, 0, 0, 0, 0] {
806 d.extend_from_slice(&v.to_le_bytes());
807 }
808 d.extend(vec![0x40; 256 * 4]); d.extend(vec![1; (w * h) as usize]);
810 let bmp = dib_at(&d).unwrap().thumbnail.bytes;
811 let offset = u32::from_le_bytes(bmp[10..14].try_into().unwrap());
812 assert_eq!(offset, 14 + 40 + 1024, "pixels follow the palette");
813 assert_eq!(bmp.len(), 14 + d.len());
814 }
815
816 #[test]
817 fn jpeg_size_skips_other_segments() {
818 let mut d = vec![0xFF, 0xD8, 0xFF, 0xC4, 0x00, 0x04, 0xAA, 0xBB];
820 d.extend_from_slice(&[0xFF, 0xC0, 0x00, 0x11, 0x08, 0x00, 0x20, 0x00, 0x40]);
821 d.extend_from_slice(&[0; 10]);
822 assert_eq!(jpeg_size(&d), Some((64, 32)));
823 assert_eq!(jpeg_size(&[0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x01, 0, 0]), None);
825 }
826
827 #[test]
828 fn missing_files_are_errors_and_plain_files_have_no_preview() {
829 assert!(extract(&temp_name()).is_err());
830 let f = write_temp(b"just some text without images");
831 assert_eq!(extract(f.path()).unwrap(), None);
832 }
833
834 fn jpeg_header(w: u16, h: u16) -> Vec<u8> {
836 let mut d = vec![0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10];
837 d.extend_from_slice(b"JFIF\0\x01\x01\0\0\x01\0\x01\0\0");
838 d.extend_from_slice(&[0xFF, 0xFF, 0xC0, 0x00, 0x11, 0x08]);
839 d.extend_from_slice(&h.to_be_bytes());
840 d.extend_from_slice(&w.to_be_bytes());
841 d.extend_from_slice(&[0x03; 10]);
842 d
843 }
844
845 #[test]
846 fn jpeg_size_reads_the_frame_header() {
847 assert_eq!(jpeg_size(&jpeg_header(320, 200)), Some((320, 200)));
848 let mut p = jpeg_header(64, 48);
850 assert_eq!(p[22], 0xC0);
851 p[22] = 0xC2;
852 assert_eq!(jpeg_size(&p), Some((64, 48)));
853 assert_eq!(jpeg_size(b"\xFF\xD8\xFF\xE0\x00"), None);
855 assert_eq!(jpeg_size(b"\xFF\xD8\xFF\xE0\x00\x01"), None);
856 assert_eq!(jpeg_size(b"\xFF\xD8\xFF\xDA\x00\x08"), None);
857 assert_eq!(jpeg_size(b"not a jpeg"), None);
858 let full = jpeg_header(320, 200);
859 for cut in 0..full.len() {
860 let _ = jpeg_size(&full[..cut]);
861 }
862 }
863}