wsi_streamer/format/tiff/
validation.rs

1//! TIFF validation for WSI support.
2//!
3//! This module validates that TIFF files meet the requirements for serving
4//! as Whole Slide Images. Unsupported files are rejected early with clear
5//! error messages.
6//!
7//! # Supported Subset
8//!
9//! The following constraints define what slides are supported:
10//! - **Organization**: Tiled only (no strips)
11//! - **Compression**: JPEG or JPEG 2000 (no LZW, Deflate)
12//! - **Format**: Standard TIFF or BigTIFF
13//! - **Structure**: Must have tile offsets and byte counts tags
14//!
15//! Files outside this subset return appropriate errors that can be mapped
16//! to HTTP 415 Unsupported Media Type.
17
18use crate::error::TiffError;
19
20use super::parser::{ByteOrder, Ifd};
21use super::pyramid::{PyramidLevel, TiffPyramid};
22use super::tags::{Compression, TiffTag};
23
24// =============================================================================
25// Validation Result
26// =============================================================================
27
28/// Result of validating a TIFF file for WSI support.
29#[derive(Debug, Clone)]
30pub struct ValidationResult {
31    /// Whether the file is valid for WSI serving
32    pub is_valid: bool,
33
34    /// List of validation errors (empty if valid)
35    pub errors: Vec<ValidationError>,
36
37    /// List of validation warnings (non-fatal issues)
38    pub warnings: Vec<String>,
39}
40
41impl ValidationResult {
42    /// Create a successful validation result.
43    pub fn ok() -> Self {
44        ValidationResult {
45            is_valid: true,
46            errors: Vec::new(),
47            warnings: Vec::new(),
48        }
49    }
50
51    /// Create a failed validation result with a single error.
52    pub fn error(error: ValidationError) -> Self {
53        ValidationResult {
54            is_valid: false,
55            errors: vec![error],
56            warnings: Vec::new(),
57        }
58    }
59
60    /// Add an error to the result.
61    pub fn add_error(&mut self, error: ValidationError) {
62        self.is_valid = false;
63        self.errors.push(error);
64    }
65
66    /// Add a warning to the result.
67    pub fn add_warning(&mut self, warning: String) {
68        self.warnings.push(warning);
69    }
70
71    /// Convert to a TiffError if invalid.
72    ///
73    /// Returns the first error as a TiffError, or Ok(()) if valid.
74    pub fn into_result(self) -> Result<(), TiffError> {
75        if self.is_valid {
76            Ok(())
77        } else {
78            Err(self.errors.into_iter().next().unwrap().into())
79        }
80    }
81}
82
83/// A specific validation error.
84#[derive(Debug, Clone)]
85pub enum ValidationError {
86    /// Missing required tag
87    MissingTag {
88        /// Index of the IFD missing the tag
89        ifd_index: usize,
90        /// The missing tag name
91        tag: &'static str,
92    },
93    /// File uses strip organization instead of tiles
94    StripOrganization {
95        /// Index of the IFD with strip organization
96        ifd_index: usize,
97    },
98
99    /// Unsupported compression scheme
100    UnsupportedCompression {
101        /// Index of the IFD with unsupported compression
102        ifd_index: usize,
103        /// The compression value found
104        compression: u16,
105        /// Human-readable compression name
106        compression_name: String,
107    },
108
109    /// Missing required tile tags
110    MissingTileTags {
111        /// Index of the IFD missing tile tags
112        ifd_index: usize,
113        /// Which tags are missing
114        missing_tags: Vec<&'static str>,
115    },
116
117    /// No pyramid levels found
118    NoPyramidLevels,
119
120    /// Invalid tile dimensions
121    InvalidTileDimensions {
122        /// Index of the IFD with invalid dimensions
123        ifd_index: usize,
124        /// The tile width found
125        tile_width: u32,
126        /// The tile height found
127        tile_height: u32,
128        /// Description of the problem
129        message: String,
130    },
131}
132
133impl From<ValidationError> for TiffError {
134    fn from(error: ValidationError) -> Self {
135        match error {
136            ValidationError::MissingTag { tag, .. } => TiffError::MissingTag(tag),
137            ValidationError::StripOrganization { .. } => TiffError::StripOrganization,
138            ValidationError::UnsupportedCompression {
139                compression_name, ..
140            } => TiffError::UnsupportedCompression(compression_name),
141            ValidationError::MissingTileTags { missing_tags, .. } => {
142                TiffError::MissingTag(missing_tags.first().copied().unwrap_or("TileOffsets"))
143            }
144            ValidationError::NoPyramidLevels => {
145                TiffError::MissingTag("No valid pyramid levels found")
146            }
147            ValidationError::InvalidTileDimensions { message, .. } => TiffError::InvalidTagValue {
148                tag: "TileWidth/TileLength",
149                message,
150            },
151        }
152    }
153}
154
155// =============================================================================
156// IFD Validation
157// =============================================================================
158
159/// Validate a single IFD for WSI support.
160///
161/// This checks that the IFD:
162/// - Uses tiled organization (not strips)
163/// - Uses JPEG compression
164/// - Has required tile tags
165///
166/// Returns a ValidationResult that may contain errors or warnings.
167pub fn validate_ifd(ifd: &Ifd, ifd_index: usize, byte_order: ByteOrder) -> ValidationResult {
168    let mut result = ValidationResult::ok();
169
170    // Check for strip organization (unsupported)
171    if ifd.is_stripped() && !ifd.is_tiled() {
172        result.add_error(ValidationError::StripOrganization { ifd_index });
173        return result; // No point checking further
174    }
175
176    // If not tiled, skip validation (might be label/macro)
177    if !ifd.is_tiled() {
178        return result;
179    }
180
181    // Check compression
182    if let Some(compression_value) = ifd.compression(byte_order) {
183        if let Some(compression) = Compression::from_u16(compression_value) {
184            if !compression.is_supported() {
185                result.add_error(ValidationError::UnsupportedCompression {
186                    ifd_index,
187                    compression: compression_value,
188                    compression_name: compression.name().to_string(),
189                });
190            }
191        } else {
192            // Unknown compression value
193            result.add_error(ValidationError::UnsupportedCompression {
194                ifd_index,
195                compression: compression_value,
196                compression_name: format!("Unknown ({})", compression_value),
197            });
198        }
199    } else {
200        result.add_error(ValidationError::MissingTag {
201            ifd_index,
202            tag: "Compression",
203        });
204    }
205
206    // Check for required tile tags
207    let mut missing_tags = Vec::new();
208
209    if ifd.get_entry_by_tag(TiffTag::TileWidth).is_none() {
210        missing_tags.push("TileWidth");
211    }
212    if ifd.get_entry_by_tag(TiffTag::TileLength).is_none() {
213        missing_tags.push("TileLength");
214    }
215    if ifd.get_entry_by_tag(TiffTag::TileOffsets).is_none() {
216        missing_tags.push("TileOffsets");
217    }
218    if ifd.get_entry_by_tag(TiffTag::TileByteCounts).is_none() {
219        missing_tags.push("TileByteCounts");
220    }
221
222    if !missing_tags.is_empty() {
223        result.add_error(ValidationError::MissingTileTags {
224            ifd_index,
225            missing_tags,
226        });
227    }
228
229    // Validate tile dimensions
230    if let (Some(tile_width), Some(tile_height)) =
231        (ifd.tile_width(byte_order), ifd.tile_height(byte_order))
232    {
233        // Tile dimensions should be reasonable
234        if tile_width == 0 || tile_height == 0 {
235            result.add_error(ValidationError::InvalidTileDimensions {
236                ifd_index,
237                tile_width,
238                tile_height,
239                message: "Tile dimensions cannot be zero".to_string(),
240            });
241        } else if tile_width > 4096 || tile_height > 4096 {
242            // Very large tiles are unusual and may cause memory issues
243            result.add_warning(format!(
244                "IFD {}: Large tile dimensions ({}x{}) may cause memory issues",
245                ifd_index, tile_width, tile_height
246            ));
247        }
248
249        // Tiles are typically powers of 2 or multiples of 16
250        if tile_width % 16 != 0 || tile_height % 16 != 0 {
251            result.add_warning(format!(
252                "IFD {}: Tile dimensions ({}x{}) are not multiples of 16",
253                ifd_index, tile_width, tile_height
254            ));
255        }
256    }
257
258    result
259}
260
261/// Validate a pyramid level for WSI support.
262pub fn validate_level(level: &PyramidLevel, byte_order: ByteOrder) -> ValidationResult {
263    let mut result = ValidationResult::ok();
264
265    // Check compression
266    if let Some(compression_value) = level.ifd.compression(byte_order) {
267        if let Some(compression) = Compression::from_u16(compression_value) {
268            if !compression.is_supported() {
269                result.add_error(ValidationError::UnsupportedCompression {
270                    ifd_index: level.ifd_index,
271                    compression: compression_value,
272                    compression_name: compression.name().to_string(),
273                });
274            }
275        } else {
276            result.add_error(ValidationError::UnsupportedCompression {
277                ifd_index: level.ifd_index,
278                compression: compression_value,
279                compression_name: format!("Unknown ({})", compression_value),
280            });
281        }
282    } else {
283        result.add_error(ValidationError::MissingTag {
284            ifd_index: level.ifd_index,
285            tag: "Compression",
286        });
287    }
288
289    // Check tile data entries
290    if !level.has_tile_data() {
291        let mut missing = Vec::new();
292        if level.tile_offsets_entry.is_none() {
293            missing.push("TileOffsets");
294        }
295        if level.tile_byte_counts_entry.is_none() {
296            missing.push("TileByteCounts");
297        }
298        result.add_error(ValidationError::MissingTileTags {
299            ifd_index: level.ifd_index,
300            missing_tags: missing,
301        });
302    }
303
304    // Validate tile dimensions
305    if level.tile_width == 0 || level.tile_height == 0 {
306        result.add_error(ValidationError::InvalidTileDimensions {
307            ifd_index: level.ifd_index,
308            tile_width: level.tile_width,
309            tile_height: level.tile_height,
310            message: "Tile dimensions cannot be zero".to_string(),
311        });
312    }
313
314    // Check for JPEGTables on JPEG-compressed levels
315    if level.jpeg_tables_entry.is_none() {
316        if let Some(compression_value) = level.ifd.compression(byte_order) {
317            if compression_value == 7 {
318                // This is a warning, not an error - some files have inline tables
319                result.add_warning(format!(
320                    "Level {}: No JPEGTables tag found (tiles may have inline tables)",
321                    level.level_index
322                ));
323            }
324        }
325    }
326
327    result
328}
329
330/// Validate a complete pyramid for WSI support.
331///
332/// This validates that:
333/// - At least one pyramid level exists
334/// - All levels use supported compression
335/// - All levels have required tile data
336pub fn validate_pyramid(pyramid: &TiffPyramid) -> ValidationResult {
337    let mut result = ValidationResult::ok();
338    let byte_order = pyramid.header.byte_order;
339
340    // Must have at least one level
341    if pyramid.levels.is_empty() {
342        result.add_error(ValidationError::NoPyramidLevels);
343        return result;
344    }
345
346    // Validate each level
347    for level in &pyramid.levels {
348        let level_result = validate_level(level, byte_order);
349        for error in level_result.errors {
350            result.add_error(error);
351        }
352        for warning in level_result.warnings {
353            result.add_warning(warning);
354        }
355    }
356
357    result
358}
359
360// =============================================================================
361// Quick validation functions
362// =============================================================================
363
364/// Check if an IFD uses supported compression.
365///
366/// Returns Ok(()) if compression is JPEG or JPEG 2000, or an error otherwise.
367pub fn check_compression(ifd: &Ifd, byte_order: ByteOrder) -> Result<(), TiffError> {
368    if let Some(compression_value) = ifd.compression(byte_order) {
369        if let Some(compression) = Compression::from_u16(compression_value) {
370            if compression.is_supported() {
371                return Ok(());
372            }
373            return Err(TiffError::UnsupportedCompression(
374                compression.name().to_string(),
375            ));
376        }
377        return Err(TiffError::UnsupportedCompression(format!(
378            "Unknown ({})",
379            compression_value
380        )));
381    }
382    // No compression tag - reject in strict mode
383    Err(TiffError::MissingTag("Compression"))
384}
385
386/// Check if an IFD uses tiled organization.
387///
388/// Returns Ok(()) if tiled, or an error if stripped.
389pub fn check_tiled(ifd: &Ifd) -> Result<(), TiffError> {
390    if ifd.is_stripped() && !ifd.is_tiled() {
391        return Err(TiffError::StripOrganization);
392    }
393    Ok(())
394}
395
396/// Check if an IFD has all required tile tags.
397///
398/// Returns Ok(()) if all tags present, or an error with the first missing tag.
399pub fn check_tile_tags(ifd: &Ifd) -> Result<(), TiffError> {
400    if ifd.get_entry_by_tag(TiffTag::TileWidth).is_none() {
401        return Err(TiffError::MissingTag("TileWidth"));
402    }
403    if ifd.get_entry_by_tag(TiffTag::TileLength).is_none() {
404        return Err(TiffError::MissingTag("TileLength"));
405    }
406    if ifd.get_entry_by_tag(TiffTag::TileOffsets).is_none() {
407        return Err(TiffError::MissingTag("TileOffsets"));
408    }
409    if ifd.get_entry_by_tag(TiffTag::TileByteCounts).is_none() {
410        return Err(TiffError::MissingTag("TileByteCounts"));
411    }
412    Ok(())
413}
414
415/// Perform full validation on an IFD.
416///
417/// This is a convenience function that checks all requirements.
418/// Returns Ok(()) if the IFD is valid for WSI serving.
419pub fn validate_ifd_strict(
420    ifd: &Ifd,
421    ifd_index: usize,
422    byte_order: ByteOrder,
423) -> Result<(), TiffError> {
424    let result = validate_ifd(ifd, ifd_index, byte_order);
425    result.into_result()
426}
427
428// =============================================================================
429// Tests
430// =============================================================================
431
432#[cfg(test)]
433mod tests {
434    use super::*;
435    use crate::format::tiff::parser::TiffHeader;
436    use crate::format::tiff::tags::FieldType;
437    use crate::format::tiff::IfdEntry;
438    use std::collections::HashMap;
439
440    fn make_header() -> TiffHeader {
441        TiffHeader {
442            byte_order: ByteOrder::LittleEndian,
443            is_bigtiff: false,
444            first_ifd_offset: 8,
445        }
446    }
447
448    fn make_entry(tag: TiffTag, value: u32) -> IfdEntry {
449        IfdEntry {
450            tag_id: tag.as_u16(),
451            field_type: Some(FieldType::Long),
452            field_type_raw: 4,
453            count: 1,
454            value_offset_bytes: value.to_le_bytes().to_vec(),
455            is_inline: true,
456        }
457    }
458
459    fn make_tiled_ifd() -> Ifd {
460        // Create a valid tiled IFD with JPEG compression
461        let entries = vec![
462            make_entry(TiffTag::ImageWidth, 10000),
463            make_entry(TiffTag::ImageLength, 8000),
464            make_entry(TiffTag::TileWidth, 256),
465            make_entry(TiffTag::TileLength, 256),
466            IfdEntry {
467                tag_id: TiffTag::TileOffsets.as_u16(),
468                field_type: Some(FieldType::Long),
469                field_type_raw: 4,
470                count: 100,
471                value_offset_bytes: vec![0, 0, 0, 0],
472                is_inline: false,
473            },
474            IfdEntry {
475                tag_id: TiffTag::TileByteCounts.as_u16(),
476                field_type: Some(FieldType::Long),
477                field_type_raw: 4,
478                count: 100,
479                value_offset_bytes: vec![0, 0, 0, 0],
480                is_inline: false,
481            },
482            IfdEntry {
483                tag_id: TiffTag::Compression.as_u16(),
484                field_type: Some(FieldType::Short),
485                field_type_raw: 3,
486                count: 1,
487                value_offset_bytes: vec![7, 0, 0, 0], // JPEG = 7
488                is_inline: true,
489            },
490        ];
491
492        let mut entries_by_tag = HashMap::new();
493        for (i, entry) in entries.iter().enumerate() {
494            entries_by_tag.insert(entry.tag_id, i);
495        }
496
497        Ifd {
498            entries,
499            entries_by_tag,
500            next_ifd_offset: 0,
501        }
502    }
503
504    fn make_stripped_ifd() -> Ifd {
505        // Create a strip-based IFD (unsupported)
506        let entries = vec![
507            make_entry(TiffTag::ImageWidth, 1000),
508            make_entry(TiffTag::ImageLength, 800),
509            IfdEntry {
510                tag_id: TiffTag::StripOffsets.as_u16(),
511                field_type: Some(FieldType::Long),
512                field_type_raw: 4,
513                count: 10,
514                value_offset_bytes: vec![0, 0, 0, 0],
515                is_inline: false,
516            },
517            IfdEntry {
518                tag_id: TiffTag::StripByteCounts.as_u16(),
519                field_type: Some(FieldType::Long),
520                field_type_raw: 4,
521                count: 10,
522                value_offset_bytes: vec![0, 0, 0, 0],
523                is_inline: false,
524            },
525        ];
526
527        let mut entries_by_tag = HashMap::new();
528        for (i, entry) in entries.iter().enumerate() {
529            entries_by_tag.insert(entry.tag_id, i);
530        }
531
532        Ifd {
533            entries,
534            entries_by_tag,
535            next_ifd_offset: 0,
536        }
537    }
538
539    fn make_lzw_ifd() -> Ifd {
540        // Create a tiled IFD with LZW compression (unsupported)
541        let entries = vec![
542            make_entry(TiffTag::ImageWidth, 10000),
543            make_entry(TiffTag::ImageLength, 8000),
544            make_entry(TiffTag::TileWidth, 256),
545            make_entry(TiffTag::TileLength, 256),
546            IfdEntry {
547                tag_id: TiffTag::TileOffsets.as_u16(),
548                field_type: Some(FieldType::Long),
549                field_type_raw: 4,
550                count: 100,
551                value_offset_bytes: vec![0, 0, 0, 0],
552                is_inline: false,
553            },
554            IfdEntry {
555                tag_id: TiffTag::TileByteCounts.as_u16(),
556                field_type: Some(FieldType::Long),
557                field_type_raw: 4,
558                count: 100,
559                value_offset_bytes: vec![0, 0, 0, 0],
560                is_inline: false,
561            },
562            IfdEntry {
563                tag_id: TiffTag::Compression.as_u16(),
564                field_type: Some(FieldType::Short),
565                field_type_raw: 3,
566                count: 1,
567                value_offset_bytes: vec![5, 0, 0, 0], // LZW = 5
568                is_inline: true,
569            },
570        ];
571
572        let mut entries_by_tag = HashMap::new();
573        for (i, entry) in entries.iter().enumerate() {
574            entries_by_tag.insert(entry.tag_id, i);
575        }
576
577        Ifd {
578            entries,
579            entries_by_tag,
580            next_ifd_offset: 0,
581        }
582    }
583
584    // -------------------------------------------------------------------------
585    // validate_ifd tests
586    // -------------------------------------------------------------------------
587
588    #[test]
589    fn test_validate_tiled_jpeg_ifd() {
590        let ifd = make_tiled_ifd();
591        let header = make_header();
592        let result = validate_ifd(&ifd, 0, header.byte_order);
593
594        assert!(result.is_valid);
595        assert!(result.errors.is_empty());
596    }
597
598    #[test]
599    fn test_validate_stripped_ifd() {
600        let ifd = make_stripped_ifd();
601        let header = make_header();
602        let result = validate_ifd(&ifd, 0, header.byte_order);
603
604        assert!(!result.is_valid);
605        assert_eq!(result.errors.len(), 1);
606        assert!(matches!(
607            result.errors[0],
608            ValidationError::StripOrganization { ifd_index: 0 }
609        ));
610    }
611
612    #[test]
613    fn test_validate_lzw_ifd() {
614        let ifd = make_lzw_ifd();
615        let header = make_header();
616        let result = validate_ifd(&ifd, 0, header.byte_order);
617
618        assert!(!result.is_valid);
619        assert!(matches!(
620            result.errors[0],
621            ValidationError::UnsupportedCompression { compression: 5, .. }
622        ));
623    }
624
625    #[test]
626    fn test_validate_missing_tile_tags() {
627        // IFD with TileWidth/TileLength but missing TileOffsets/TileByteCounts
628        let entries = vec![
629            make_entry(TiffTag::ImageWidth, 10000),
630            make_entry(TiffTag::ImageLength, 8000),
631            make_entry(TiffTag::TileWidth, 256),
632            make_entry(TiffTag::TileLength, 256),
633            IfdEntry {
634                tag_id: TiffTag::Compression.as_u16(),
635                field_type: Some(FieldType::Short),
636                field_type_raw: 3,
637                count: 1,
638                value_offset_bytes: vec![7, 0, 0, 0],
639                is_inline: true,
640            },
641        ];
642
643        let mut entries_by_tag = HashMap::new();
644        for (i, entry) in entries.iter().enumerate() {
645            entries_by_tag.insert(entry.tag_id, i);
646        }
647
648        let ifd = Ifd {
649            entries,
650            entries_by_tag,
651            next_ifd_offset: 0,
652        };
653
654        let header = make_header();
655        let result = validate_ifd(&ifd, 0, header.byte_order);
656
657        assert!(!result.is_valid);
658        assert!(matches!(
659            result.errors[0],
660            ValidationError::MissingTileTags { .. }
661        ));
662    }
663
664    // -------------------------------------------------------------------------
665    // check_* function tests
666    // -------------------------------------------------------------------------
667
668    #[test]
669    fn test_check_compression_jpeg() {
670        let ifd = make_tiled_ifd();
671        let header = make_header();
672        assert!(check_compression(&ifd, header.byte_order).is_ok());
673    }
674
675    #[test]
676    fn test_check_compression_lzw() {
677        let ifd = make_lzw_ifd();
678        let header = make_header();
679        let result = check_compression(&ifd, header.byte_order);
680        assert!(matches!(result, Err(TiffError::UnsupportedCompression(_))));
681    }
682
683    #[test]
684    fn test_check_tiled_with_tiles() {
685        let ifd = make_tiled_ifd();
686        assert!(check_tiled(&ifd).is_ok());
687    }
688
689    #[test]
690    fn test_check_tiled_with_strips() {
691        let ifd = make_stripped_ifd();
692        let result = check_tiled(&ifd);
693        assert!(matches!(result, Err(TiffError::StripOrganization)));
694    }
695
696    #[test]
697    fn test_check_tile_tags_present() {
698        let ifd = make_tiled_ifd();
699        assert!(check_tile_tags(&ifd).is_ok());
700    }
701
702    #[test]
703    fn test_check_tile_tags_missing() {
704        let ifd = make_stripped_ifd();
705        let result = check_tile_tags(&ifd);
706        assert!(matches!(result, Err(TiffError::MissingTag(_))));
707    }
708
709    // -------------------------------------------------------------------------
710    // ValidationResult tests
711    // -------------------------------------------------------------------------
712
713    #[test]
714    fn test_validation_result_ok() {
715        let result = ValidationResult::ok();
716        assert!(result.is_valid);
717        assert!(result.errors.is_empty());
718        assert!(result.into_result().is_ok());
719    }
720
721    #[test]
722    fn test_validation_result_error() {
723        let result = ValidationResult::error(ValidationError::NoPyramidLevels);
724        assert!(!result.is_valid);
725        assert!(result.into_result().is_err());
726    }
727
728    #[test]
729    fn test_validation_error_to_tiff_error() {
730        let strip_error = ValidationError::StripOrganization { ifd_index: 0 };
731        let tiff_error: TiffError = strip_error.into();
732        assert!(matches!(tiff_error, TiffError::StripOrganization));
733
734        let compression_error = ValidationError::UnsupportedCompression {
735            ifd_index: 0,
736            compression: 5,
737            compression_name: "LZW".to_string(),
738        };
739        let tiff_error: TiffError = compression_error.into();
740        assert!(matches!(tiff_error, TiffError::UnsupportedCompression(_)));
741    }
742}