Skip to main content

teamcraft/
thumbnail.rs

1//! Preview images that CAD programs store inside their own files – the
2//! thumbnails Autodesk Vault or Teamcenter show for native files (findings in
3//! `docs/src/research/cad-previews.md`). Only the image is read, never the
4//! geometry:
5//!
6//! * Autodesk Inventor (`.ipt`, `.iam`, `.idw`, `.ipn`) and Siemens Solid Edge
7//!   (`.par`, `.psm`, `.asm`, `.pwd`, `.dft`) files are OLE compound files
8//!   ([MS-CFB]); the preview is a PNG or a Windows DIB in one of their streams,
9//!   normally the thumbnail property of the summary information ([MS-OLEPS]);
10//! * SolidWorks files up to 2014 are OLE files as well (`PreviewPNG`), newer
11//!   ones keep the PNG in a Deflate-compressed `PreviewPNG` section of their
12//!   own container (`swpart`);
13//! * NX parts keep a JPEG in their stream container (`nxpart`). It is small
14//!   and poor, so NX parts get a thumbnail rendered from their 3D preview
15//!   instead ([`rendered`], [`render_mesh`]); the JPEG stays the fallback
16//!   for NX files without one (assemblies, drawings).
17//!
18//! Every candidate is validated (PNG chunk structure and checksums, plausible
19//! DIB header and sizes); the summary information wins, then streams named
20//! like a preview, then the largest image. A DIB is served as BMP, which all
21//! browsers and web views display. The layouts follow the documented
22//! containers and open-source readers (e.g. `cad-preview`); they were not
23//! tested with real files of every CAD program version.
24//!
25//! [MS-CFB]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-cfb/
26//! [MS-OLEPS]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-oleps/
27#![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
41/// Bump to invalidate every cached thumbnail (2: SOLIDWORKS 2015+ previews,
42/// cached as "none" before; 3: NX parts rendered from their 3D preview
43/// instead of the embedded JPEG; 4: SOLIDWORKS parts and assemblies rendered
44/// from their 3D preview; 5: Rhino models; 6: Inventor parts and assemblies
45/// rendered from their 3D preview; 7: Inventor files with Zstandard
46/// segments, which kept their stored image before, #271; 8: Creo parts
47/// rendered from their 3D preview – both branches had used 7; 9: Creo
48/// assemblies rendered from their parts, turned Creo parts without a plane
49/// in the identity frame).
50pub 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];
54/// Streams larger than this hold geometry, not previews.
55const MAX_STREAM: u64 = 8 * 1024 * 1024;
56/// Bytes of a file in its own container searched for an embedded PNG.
57const MAX_SCAN: u64 = 64 * 1024 * 1024;
58const MIN_SIDE: u32 = 8;
59const MAX_SIDE: u32 = 4096;
60
61/// Formats whose files carry a preview image.
62pub 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/// An image found in a file.
83#[derive(Clone, Debug, PartialEq)]
84pub struct Thumbnail {
85    /// `image/png` or `image/bmp`.
86    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
112/// The preview image of a CAD file, if it has one.
113pub fn extract(path: &Path) -> io::Result<Option<Thumbnail>> {
114    extract_from(io::BufReader::new(File::open(path)?))
115}
116
117/// [`extract`] for any seekable reader (the fuzz targets pass bytes).
118pub 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
143/// Rhino models: the bitmap stored in the properties table (shown when the
144/// model stores no meshes for a 3D preview).
145fn 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
153/// SOLIDWORKS 2015+ files: the PNG of the `PreviewPNG` section.
154fn 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
164/// Fusion designs: the PNG Fusion stored (`Previews/big.png`).
165fn 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
178/// NX parts: the JPEG in the `/Root/images/preview` stream.
179fn 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
194/// Width and height of a baseline or progressive JPEG, read from its frame
195/// header (SOFn); `None` if the segment structure is broken.
196fn 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        // Fill bytes before a marker are allowed.
203        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; // image data before any frame header
226        }
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
242/// Summary information first, then streams named like a preview, then the
243/// largest image of any stream.
244fn 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; // a preview of a better-ranked stream was found
267        }
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
284/// The largest valid image in `data` (PNGs; DIBs too when `dibs`).
285fn 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
309/// Four bytes at `o` as an array (`None` past the end).
310fn 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
323/// A complete PNG at the start of `data`: every chunk complete with a valid
324/// checksum, `IHDR` first, `IEND` last.
325fn 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
363/// A device-independent bitmap at the start of `data` (BITMAPINFOHEADER, V4
364/// or V5, uncompressed or bit fields), returned as a BMP file.
365fn 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    // The size field is 0 for uncompressed bitmaps or close to the real size.
404    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
428/// The cache directory and file name stem of a blob's thumbnail.
429fn 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
440/// A thumbnail already in the cache: `Some(None)` when the blob is known
441/// to have none, `None` when it was never looked at.
442pub 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
462/// Stores a thumbnail (rendered or found in the file) in the cache.
463pub 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    // write to a temporary name first, so a reader never sees half a file
473    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
478/// The thumbnail of a vault blob, cached next to the 3D previews
479/// (`previews/ab/<sha256>.thumb.v<N>.png|bmp|jpg|none`).
480pub 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
489/// Side of the thumbnails rendered from a 3D preview.
490pub const RENDERED_SIZE: u32 = 512;
491
492/// Formats whose thumbnail is rendered from their 3D preview instead of the
493/// image stored in the file: NX parts (the embedded JPEG is small and poor,
494/// the tessellation in the file is good), SOLIDWORKS and Inventor parts and
495/// assemblies, Rhino and SketchUp models, CATIA V5 parts and products, Creo parts, Fusion
496/// designs (one look for every CAD system) and stand-alone JT files (a visualisation
497/// format: the tessellation is the content). Files without a 3D preview
498/// (drawings, assemblies none of whose parts are in TeamCraft, Rhino models
499/// saved without meshes, SketchUp files without faces, Fusion files without a
500/// display scene) keep the stored image.
501pub 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
520/// A PNG thumbnail drawn from a cached preview mesh (`TCMESH01`).
521pub 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    /// A minimal valid PNG (`w` × `h`, one IDAT chunk – its content is not
536    /// decoded by the extractor).
537    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    /// A 24-bit DIB (BITMAPINFOHEADER + pixels).
561    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    /// A file or directory below the system temp dir, removed when dropped.
591    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        // VT_CF clipboard data: size, format tag, then the image
625        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            // an appearance texture elsewhere must not win
631            ("/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        // old SolidWorks: PreviewPNG stream
666        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        // own container with an embedded PNG
670        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        // nothing valid: truncated or broken checksum
676        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        // implausible sizes are ignored
684        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); // the cache answers without the blob
701        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        // A bigger DIB only counts when DIBs are looked for.
732        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        // The first chunk must be IHDR.
756        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        // Chunk types are letters only.
761        let mut bad_kind = good.clone();
762        bad_kind[12] = b'1';
763        assert!(png_at(&bad_kind).is_none());
764        // Without IEND the image is incomplete.
765        assert!(png_at(&good[..good.len() - 12]).is_none());
766        // Trailing bytes after IEND are not part of the image.
767        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        // Top-down bitmaps have a negative height.
775        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        // Run-length compression is not supported.
780        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        // A size field far from the real size means it is no bitmap.
784        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        // Missing pixels.
788        let full = dib(16, 16);
789        assert!(dib_at(&full[..full.len() - 1]).is_none());
790        // Two planes are invalid.
791        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]); // palette of 256 colours
809        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        // A DHT (0xC4) segment before the frame header is not a frame header.
819        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        // A segment length below 2 is broken.
824        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    /// SOI, APP0 (JFIF, 16 bytes), fill byte, SOF0 with `w` × `h`.
835    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        // Progressive (SOF2) works the same way.
849        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        // Broken or cut-off data never panics.
854        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}