Skip to main content

tiff_writer/
builder.rs

1//! Image builder for configuring a single TIFF IFD.
2
3use std::collections::HashSet;
4
5use tiff_core::*;
6
7use crate::encoder;
8use crate::sample::TiffWriteSample;
9
10/// LERC encoding options for the TIFF writer.
11///
12/// Controls the LERC2 error tolerance and optional additional compression
13/// applied to the encoded LERC blob before storage in the TIFF block.
14#[derive(Debug, Clone, Copy, PartialEq)]
15pub struct LercOptions {
16    /// Maximum encoding error per sample value. Set to `0.0` for lossless.
17    pub max_z_error: f64,
18    /// Optional additional compression applied to the LERC blob.
19    pub additional_compression: LercAdditionalCompression,
20}
21
22impl Default for LercOptions {
23    fn default() -> Self {
24        Self {
25            max_z_error: 0.0,
26            additional_compression: LercAdditionalCompression::None,
27        }
28    }
29}
30
31/// JPEG encoding options for the TIFF writer.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub struct JpegOptions {
34    /// Quality in the range 1..=100.
35    pub quality: u8,
36}
37
38impl Default for JpegOptions {
39    fn default() -> Self {
40        Self { quality: 75 }
41    }
42}
43
44/// Describes how image data is organized: strips or tiles.
45#[derive(Debug, Clone, Copy)]
46pub enum DataLayout {
47    /// Strip-based: each strip contains `rows_per_strip` rows.
48    Strips { rows_per_strip: u32 },
49    /// Tile-based: each tile is `width x height` pixels.
50    Tiles { width: u32, height: u32 },
51}
52
53/// Builder for configuring a single image (IFD) within a TIFF file.
54#[derive(Debug, Clone)]
55pub struct ImageBuilder {
56    pub(crate) width: u32,
57    pub(crate) height: u32,
58    pub(crate) samples_per_pixel: u16,
59    pub(crate) bits_per_sample: u16,
60    pub(crate) sample_format: SampleFormat,
61    pub(crate) compression: Compression,
62    pub(crate) predictor: Predictor,
63    pub(crate) photometric: PhotometricInterpretation,
64    pub(crate) extra_samples: Vec<ExtraSample>,
65    pub(crate) color_map: Option<ColorMap>,
66    pub(crate) ink_set: Option<InkSet>,
67    pub(crate) ycbcr_subsampling: Option<[u16; 2]>,
68    pub(crate) ycbcr_positioning: Option<YCbCrPositioning>,
69    pub(crate) planar_configuration: PlanarConfiguration,
70    pub(crate) layout: DataLayout,
71    pub(crate) extra_tags: Vec<Tag>,
72    pub(crate) subfile_type: u32,
73    pub(crate) lerc_options: Option<LercOptions>,
74    pub(crate) jpeg_options: Option<JpegOptions>,
75    pub(crate) deflate_level: Option<u32>,
76}
77
78impl ImageBuilder {
79    /// Create a new image builder with required dimensions.
80    pub fn new(width: u32, height: u32) -> Self {
81        Self {
82            width,
83            height,
84            samples_per_pixel: 1,
85            bits_per_sample: 8,
86            sample_format: SampleFormat::Uint,
87            compression: Compression::None,
88            predictor: Predictor::None,
89            photometric: PhotometricInterpretation::MinIsBlack,
90            extra_samples: Vec::new(),
91            color_map: None,
92            ink_set: None,
93            ycbcr_subsampling: None,
94            ycbcr_positioning: None,
95            planar_configuration: PlanarConfiguration::Chunky,
96            layout: DataLayout::Strips {
97                rows_per_strip: height.min(256),
98            },
99            extra_tags: Vec::new(),
100            subfile_type: 0,
101            lerc_options: None,
102            jpeg_options: None,
103            deflate_level: None,
104        }
105    }
106
107    pub fn samples_per_pixel(mut self, spp: u16) -> Self {
108        self.samples_per_pixel = spp;
109        self
110    }
111
112    pub fn bits_per_sample(mut self, bps: u16) -> Self {
113        self.bits_per_sample = bps;
114        self
115    }
116
117    pub fn sample_format(mut self, fmt: SampleFormat) -> Self {
118        self.sample_format = fmt;
119        self
120    }
121
122    /// Configure from a TiffWriteSample type. Sets bits_per_sample and sample_format.
123    pub fn sample_type<T: TiffWriteSample>(mut self) -> Self {
124        self.bits_per_sample = T::BITS_PER_SAMPLE;
125        self.sample_format =
126            SampleFormat::from_code(T::SAMPLE_FORMAT).unwrap_or(SampleFormat::Uint);
127        self
128    }
129
130    pub fn compression(mut self, c: Compression) -> Self {
131        self.compression = c;
132        if !matches!(c, Compression::Lerc) {
133            self.lerc_options = None;
134        }
135        if !matches!(c, Compression::Jpeg) {
136            self.jpeg_options = None;
137        }
138        if matches!(c, Compression::Lerc | Compression::Jpeg) {
139            self.predictor = Predictor::None;
140        }
141        self
142    }
143
144    pub fn predictor(mut self, p: Predictor) -> Self {
145        self.predictor = p;
146        self
147    }
148
149    /// Set the Deflate compression level (0-9).
150    ///
151    /// Applies to `Compression::Deflate`/`Compression::DeflateOld` blocks.
152    /// The additional Deflate layer of `LERC+Deflate` always uses the codec
153    /// default level.
154    pub fn deflate_level(mut self, level: u32) -> Self {
155        self.deflate_level = Some(level);
156        self
157    }
158
159    pub fn photometric(mut self, p: PhotometricInterpretation) -> Self {
160        self.photometric = p;
161        self
162    }
163
164    /// Set TIFF ExtraSamples semantics for channels beyond the base color model.
165    pub fn extra_samples(mut self, extra_samples: Vec<ExtraSample>) -> Self {
166        self.extra_samples = extra_samples;
167        self
168    }
169
170    /// Set a palette ColorMap for `PhotometricInterpretation::Palette`.
171    pub fn color_map(mut self, color_map: ColorMap) -> Self {
172        self.color_map = Some(color_map);
173        self
174    }
175
176    /// Set the InkSet tag for separated photometric data.
177    pub fn ink_set(mut self, ink_set: InkSet) -> Self {
178        self.ink_set = Some(ink_set);
179        self
180    }
181
182    /// Set TIFF YCbCr chroma subsampling factors.
183    pub fn ycbcr_subsampling(mut self, subsampling: [u16; 2]) -> Self {
184        self.ycbcr_subsampling = Some(subsampling);
185        self
186    }
187
188    /// Set TIFF YCbCr sample positioning.
189    pub fn ycbcr_positioning(mut self, positioning: YCbCrPositioning) -> Self {
190        self.ycbcr_positioning = Some(positioning);
191        self
192    }
193
194    /// Set chunky (interleaved) or separate planar sample layout for multi-band images.
195    pub fn planar_configuration(mut self, p: PlanarConfiguration) -> Self {
196        self.planar_configuration = p;
197        self
198    }
199
200    /// Configure strip-based layout.
201    pub fn strips(mut self, rows_per_strip: u32) -> Self {
202        self.layout = DataLayout::Strips { rows_per_strip };
203        self
204    }
205
206    /// The configured strip/tile data layout.
207    pub fn data_layout(&self) -> DataLayout {
208        self.layout
209    }
210
211    /// Configure tile-based layout.
212    pub fn tiles(mut self, tile_width: u32, tile_height: u32) -> Self {
213        self.layout = DataLayout::Tiles {
214            width: tile_width,
215            height: tile_height,
216        };
217        self
218    }
219
220    /// Add an arbitrary extra tag to the IFD.
221    pub fn tag(mut self, tag: Tag) -> Self {
222        self.extra_tags.push(tag);
223        self
224    }
225
226    /// Mark this IFD as a reduced-resolution overview.
227    pub fn overview(mut self) -> Self {
228        self.subfile_type = 1;
229        self
230    }
231
232    /// Set LERC compression with the given options.
233    ///
234    /// This sets `compression = Lerc` and `predictor = None` (LERC performs
235    /// its own quantization and does not use TIFF predictors).
236    pub fn lerc_options(mut self, options: LercOptions) -> Self {
237        self.compression = Compression::Lerc;
238        self.predictor = Predictor::None;
239        self.lerc_options = Some(options);
240        self.jpeg_options = None;
241        self
242    }
243
244    /// Set JPEG compression with the given options.
245    ///
246    /// This sets `compression = Jpeg` and `predictor = None` (JPEG uses its
247    /// own transform and entropy coding pipeline rather than TIFF predictors).
248    ///
249    /// Multi-band JPEG requires `planar_configuration(Planar)` so each encoded
250    /// strip/tile is a single grayscale component.
251    pub fn jpeg_options(mut self, options: JpegOptions) -> Self {
252        self.compression = Compression::Jpeg;
253        self.predictor = Predictor::None;
254        self.jpeg_options = Some(options);
255        self.lerc_options = None;
256        self
257    }
258
259    /// Checked total number of blocks (strips or tiles) for this image configuration.
260    pub fn checked_block_count(&self) -> crate::error::Result<usize> {
261        let blocks_per_plane = match self.checked_layout()? {
262            DataLayout::Strips { rows_per_strip } => {
263                let rps = rows_per_strip as usize;
264                (self.height as usize).div_ceil(rps)
265            }
266            DataLayout::Tiles { width, height } => {
267                let tw = width as usize;
268                let th = height as usize;
269                let tiles_across = (self.width as usize).div_ceil(tw);
270                let tiles_down = (self.height as usize).div_ceil(th);
271                tiles_across
272                    .checked_mul(tiles_down)
273                    .ok_or_else(|| layout_overflow("tile count"))?
274            }
275        };
276        if matches!(self.planar_configuration, PlanarConfiguration::Planar) {
277            blocks_per_plane
278                .checked_mul(self.samples_per_pixel as usize)
279                .ok_or_else(|| layout_overflow("planar block count"))
280        } else {
281            Ok(blocks_per_plane)
282        }
283    }
284
285    /// Checked expected number of samples for the block at `index`.
286    pub fn checked_block_sample_count(&self, index: usize) -> crate::error::Result<usize> {
287        let block_count = self.checked_block_count()?;
288        if index >= block_count {
289            return Err(crate::error::Error::BlockIndexOutOfRange {
290                index,
291                total: block_count,
292            });
293        }
294        let samples_per_pixel = self.block_samples_per_pixel() as usize;
295        let plane_block_index = self.checked_block_plane_index(index)?;
296        match self.checked_layout()? {
297            DataLayout::Strips { rows_per_strip } => {
298                let rps = rows_per_strip as usize;
299                let start_row = plane_block_index
300                    .checked_mul(rps)
301                    .ok_or_else(|| layout_overflow("strip start row"))?;
302                let end_row = plane_block_index
303                    .checked_add(1)
304                    .and_then(|value| value.checked_mul(rps))
305                    .ok_or_else(|| layout_overflow("strip end row"))?
306                    .min(self.height as usize);
307                let rows = end_row.saturating_sub(start_row);
308                rows.checked_mul(self.width as usize)
309                    .and_then(|value| value.checked_mul(samples_per_pixel))
310                    .ok_or_else(|| layout_overflow("strip sample count"))
311            }
312            DataLayout::Tiles { width, height } => {
313                // Tiles are always full-sized (padded at edges)
314                (width as usize)
315                    .checked_mul(height as usize)
316                    .and_then(|value| value.checked_mul(samples_per_pixel))
317                    .ok_or_else(|| layout_overflow("tile sample count"))
318            }
319        }
320    }
321
322    /// Checked estimated uncompressed image bytes.
323    pub fn checked_estimated_uncompressed_bytes(&self) -> crate::error::Result<u64> {
324        let bps = u64::from(self.bits_per_sample.div_ceil(8));
325        (self.width as u64)
326            .checked_mul(self.height as u64)
327            .and_then(|value| value.checked_mul(self.samples_per_pixel as u64))
328            .and_then(|value| value.checked_mul(bps))
329            .ok_or_else(|| layout_overflow("estimated uncompressed byte count"))
330    }
331
332    /// The TIFF tag codes for offset and bytecount arrays.
333    pub fn offset_tag_codes(&self) -> (u16, u16) {
334        match self.layout {
335            DataLayout::Strips { .. } => (TAG_STRIP_OFFSETS, TAG_STRIP_BYTE_COUNTS),
336            DataLayout::Tiles { .. } => (TAG_TILE_OFFSETS, TAG_TILE_BYTE_COUNTS),
337        }
338    }
339
340    /// Checked build of the layout-specific tags.
341    pub fn checked_layout_tags(&self) -> crate::error::Result<Vec<Tag>> {
342        match self.checked_layout()? {
343            DataLayout::Strips { rows_per_strip } => Ok(vec![Tag::new(
344                TAG_ROWS_PER_STRIP,
345                TagValue::Long(vec![rows_per_strip]),
346            )]),
347            DataLayout::Tiles { width, height } => Ok(vec![
348                Tag::new(TAG_TILE_WIDTH, TagValue::Long(vec![width])),
349                Tag::new(TAG_TILE_LENGTH, TagValue::Long(vec![height])),
350            ]),
351        }
352    }
353
354    /// Checked build of the serialized TIFF tags for this image definition.
355    pub fn checked_build_tags(&self, is_bigtiff: bool) -> crate::error::Result<Vec<Tag>> {
356        let mut extra_tags = self.extra_tags.clone();
357        if let Some(lerc_tag) = self.lerc_parameters_tag() {
358            extra_tags.push(lerc_tag);
359        }
360        self.validate()?;
361        let extra_samples = self.effective_extra_samples()?;
362        if !extra_samples.is_empty() {
363            extra_tags.push(Tag::new(
364                TAG_EXTRA_SAMPLES,
365                TagValue::Short(
366                    extra_samples
367                        .iter()
368                        .copied()
369                        .map(ExtraSample::to_code)
370                        .collect(),
371                ),
372            ));
373        }
374        if let Some(color_map) = &self.color_map {
375            extra_tags.push(Tag::new(
376                TAG_COLOR_MAP,
377                TagValue::Short(color_map.encode_tag_values()),
378            ));
379        }
380        if let Some(ink_set) = self.ink_set {
381            extra_tags.push(Tag::new(
382                TAG_INK_SET,
383                TagValue::Short(vec![ink_set.to_code()]),
384            ));
385        }
386        if let Some([h, v]) = self.effective_ycbcr_subsampling() {
387            extra_tags.push(Tag::new(TAG_YCBCR_SUBSAMPLING, TagValue::Short(vec![h, v])));
388        }
389        if let Some(positioning) = self.ycbcr_positioning {
390            extra_tags.push(Tag::new(
391                TAG_YCBCR_POSITIONING,
392                TagValue::Short(vec![positioning.to_code()]),
393            ));
394        }
395
396        let (offsets_tag_code, byte_counts_tag_code) = self.offset_tag_codes();
397        let layout_tags = self.checked_layout_tags()?;
398
399        Ok(encoder::build_image_tags(&encoder::ImageTagParams {
400            width: self.width,
401            height: self.height,
402            samples_per_pixel: self.samples_per_pixel,
403            bits_per_sample: self.bits_per_sample,
404            sample_format: self.sample_format.to_code(),
405            compression: self.compression.to_code(),
406            photometric: self.photometric.to_code(),
407            predictor: self.predictor.to_code(),
408            planar_configuration: self.planar_configuration.to_code(),
409            subfile_type: self.subfile_type,
410            extra_tags: &extra_tags,
411            offsets_tag_code,
412            byte_counts_tag_code,
413            num_blocks: self.checked_block_count()?,
414            layout_tags: &layout_tags,
415            is_bigtiff,
416        }))
417    }
418
419    /// Row width in pixels for compression pipeline (tile_width or image_width).
420    pub fn block_row_width(&self) -> usize {
421        match self.layout {
422            DataLayout::Strips { .. } => self.width as usize,
423            DataLayout::Tiles { width, .. } => width as usize,
424        }
425    }
426
427    /// Samples per pixel represented in a single block.
428    pub fn block_samples_per_pixel(&self) -> u16 {
429        if matches!(self.planar_configuration, PlanarConfiguration::Planar) {
430            1
431        } else {
432            self.samples_per_pixel
433        }
434    }
435
436    fn checked_block_plane_index(&self, index: usize) -> crate::error::Result<usize> {
437        if matches!(self.planar_configuration, PlanarConfiguration::Planar) {
438            let blocks_per_plane = self.checked_blocks_per_plane()?;
439            if blocks_per_plane == 0 {
440                return Err(crate::error::Error::InvalidConfig(
441                    "block count must be greater than zero".into(),
442                ));
443            }
444            Ok(index % blocks_per_plane)
445        } else {
446            Ok(index)
447        }
448    }
449
450    fn checked_blocks_per_plane(&self) -> crate::error::Result<usize> {
451        match self.checked_layout()? {
452            DataLayout::Strips { rows_per_strip } => {
453                let rps = rows_per_strip as usize;
454                Ok((self.height as usize).div_ceil(rps))
455            }
456            DataLayout::Tiles { width, height } => {
457                let tw = width as usize;
458                let th = height as usize;
459                let tiles_across = (self.width as usize).div_ceil(tw);
460                let tiles_down = (self.height as usize).div_ceil(th);
461                tiles_across
462                    .checked_mul(tiles_down)
463                    .ok_or_else(|| layout_overflow("tile count"))
464            }
465        }
466    }
467
468    /// Checked height of the block at `index` in pixels.
469    ///
470    /// Tiles are always full-sized (padded at edges). Strips may be shorter
471    /// for the final strip.
472    pub fn checked_block_height(&self, index: usize) -> crate::error::Result<u32> {
473        let block_count = self.checked_block_count()?;
474        if index >= block_count {
475            return Err(crate::error::Error::BlockIndexOutOfRange {
476                index,
477                total: block_count,
478            });
479        }
480        match self.checked_layout()? {
481            DataLayout::Tiles { height, .. } => Ok(height),
482            DataLayout::Strips { rows_per_strip } => {
483                let plane_index = self.checked_block_plane_index(index)?;
484                let rps = rows_per_strip as usize;
485                let start_row = plane_index
486                    .checked_mul(rps)
487                    .ok_or_else(|| layout_overflow("strip start row"))?;
488                let remaining = (self.height as usize).saturating_sub(start_row);
489                Ok(remaining.min(rps) as u32)
490            }
491        }
492    }
493
494    /// YCbCr subsampling recorded in the file.
495    ///
496    /// Defaults to 2x2 for JPEG-compressed YCbCr output, matching the JPEG
497    /// encoder's chroma subsampling.
498    pub(crate) fn effective_ycbcr_subsampling(&self) -> Option<[u16; 2]> {
499        if self.ycbcr_subsampling.is_some() {
500            return self.ycbcr_subsampling;
501        }
502        (matches!(self.photometric, PhotometricInterpretation::YCbCr)
503            && matches!(self.compression, Compression::Jpeg))
504        .then_some([2, 2])
505    }
506
507    /// Chroma subsampling applied by the JPEG encoder for interleaved YCbCr
508    /// blocks. `None` for grayscale/planar JPEG blocks.
509    pub fn jpeg_chroma_sampling(&self) -> Option<[u16; 2]> {
510        (matches!(self.photometric, PhotometricInterpretation::YCbCr)
511            && matches!(self.compression, Compression::Jpeg)
512            && self.block_samples_per_pixel() == 3)
513            .then(|| self.effective_ycbcr_subsampling().unwrap_or([1, 1]))
514    }
515
516    /// Build the `TAG_LERC_PARAMETERS` tag if LERC compression is configured.
517    pub fn lerc_parameters_tag(&self) -> Option<Tag> {
518        if !matches!(self.compression, Compression::Lerc) {
519            return None;
520        }
521        let opts = self.lerc_options.unwrap_or_default();
522        Some(Tag::new(
523            TAG_LERC_PARAMETERS,
524            TagValue::Long(vec![
525                LERC_VERSION_2_4,
526                opts.additional_compression.to_code(),
527            ]),
528        ))
529    }
530
531    /// Validate the configuration.
532    pub fn validate(&self) -> crate::error::Result<()> {
533        if self.width == 0 || self.height == 0 {
534            return Err(crate::error::Error::InvalidConfig(
535                "image dimensions must be positive".into(),
536            ));
537        }
538        if self.samples_per_pixel == 0 {
539            return Err(crate::error::Error::InvalidConfig(
540                "samples_per_pixel must be greater than zero".into(),
541            ));
542        }
543        self.validate_extra_tags()?;
544        if !matches!(self.bits_per_sample, 8 | 16 | 32 | 64) {
545            return Err(crate::error::Error::InvalidConfig(format!(
546                "bits_per_sample must be 8, 16, 32, or 64, got {}",
547                self.bits_per_sample
548            )));
549        }
550        match self.layout {
551            DataLayout::Strips { rows_per_strip: 0 } => {
552                return Err(crate::error::Error::InvalidConfig(
553                    "rows_per_strip must be greater than zero".into(),
554                ));
555            }
556            DataLayout::Tiles { width, height } => {
557                if width == 0 || height == 0 {
558                    return Err(crate::error::Error::InvalidConfig(format!(
559                        "tile_width and tile_height must be greater than zero, got {}x{}",
560                        width, height
561                    )));
562                }
563                if width % 16 != 0 || height % 16 != 0 {
564                    return Err(crate::error::Error::InvalidConfig(format!(
565                        "tile dimensions must be multiples of 16, got {}x{}",
566                        width, height
567                    )));
568                }
569            }
570            _ => {}
571        }
572        self.checked_block_count()?;
573        self.checked_block_sample_count(0)?;
574        self.checked_estimated_uncompressed_bytes()?;
575        match self.compression {
576            Compression::None
577            | Compression::Lzw
578            | Compression::Deflate
579            | Compression::DeflateOld
580            | Compression::Lerc => {}
581            Compression::Jpeg if cfg!(feature = "jpeg") => {}
582            Compression::Zstd if cfg!(feature = "zstd") => {}
583            unsupported => {
584                return Err(crate::error::Error::InvalidConfig(format!(
585                    "{} compression is not supported by this writer build",
586                    unsupported.name()
587                )))
588            }
589        }
590        if !matches!(self.predictor, Predictor::None)
591            && matches!(self.compression, Compression::None)
592        {
593            return Err(crate::error::Error::InvalidConfig(
594                "TIFF predictors require a supported compression scheme".into(),
595            ));
596        }
597        if matches!(self.compression, Compression::Lerc)
598            && !matches!(self.predictor, Predictor::None)
599        {
600            return Err(crate::error::Error::InvalidConfig(
601                "LERC compression does not support TIFF predictors".into(),
602            ));
603        }
604        let supported_float_bits = matches!(self.bits_per_sample, 32 | 64)
605            || (cfg!(feature = "f16") && self.bits_per_sample == 16);
606        if matches!(self.sample_format, SampleFormat::Float) && !supported_float_bits {
607            let supported = if cfg!(feature = "f16") {
608                "16, 32, or 64"
609            } else {
610                "32 or 64"
611            };
612            return Err(crate::error::Error::InvalidConfig(format!(
613                "float sample format requires {supported} bits per sample, got {}",
614                self.bits_per_sample
615            )));
616        }
617        if cfg!(feature = "f16")
618            && matches!(self.compression, Compression::Lerc)
619            && matches!(self.sample_format, SampleFormat::Float)
620            && self.bits_per_sample == 16
621        {
622            return Err(crate::error::Error::InvalidConfig(
623                "LERC compression does not support 16-bit float samples".into(),
624            ));
625        }
626        match self.predictor {
627            Predictor::Horizontal => {
628                if matches!(self.sample_format, SampleFormat::Float) {
629                    return Err(crate::error::Error::InvalidConfig(
630                        "horizontal predictor requires integer sample formats; \
631                         use Predictor::FloatingPoint for float samples"
632                            .into(),
633                    ));
634                }
635            }
636            Predictor::FloatingPoint => {
637                if !matches!(self.sample_format, SampleFormat::Float) {
638                    return Err(crate::error::Error::InvalidConfig(
639                        "floating-point predictor requires float sample formats".into(),
640                    ));
641                }
642            }
643            Predictor::None => {}
644        }
645        if let Some(level) = self.deflate_level {
646            if level > 9 {
647                return Err(crate::error::Error::InvalidConfig(format!(
648                    "deflate_level must be 0-9, got {level}"
649                )));
650            }
651            if !matches!(
652                self.compression,
653                Compression::Deflate | Compression::DeflateOld
654            ) {
655                return Err(crate::error::Error::InvalidConfig(
656                    "deflate_level requires Deflate compression".into(),
657                ));
658            }
659        }
660        self.validate_color_model()?;
661        if matches!(self.compression, Compression::Jpeg) {
662            self.validate_jpeg_config()?;
663        }
664        Ok(())
665    }
666
667    fn validate_extra_tags(&self) -> crate::error::Result<()> {
668        const MANAGED_TAGS: &[u16] = &[
669            TAG_NEW_SUBFILE_TYPE,
670            TAG_IMAGE_WIDTH,
671            TAG_IMAGE_LENGTH,
672            TAG_BITS_PER_SAMPLE,
673            TAG_COMPRESSION,
674            TAG_PHOTOMETRIC_INTERPRETATION,
675            TAG_STRIP_OFFSETS,
676            TAG_SAMPLES_PER_PIXEL,
677            TAG_ROWS_PER_STRIP,
678            TAG_STRIP_BYTE_COUNTS,
679            TAG_PLANAR_CONFIGURATION,
680            TAG_PREDICTOR,
681            TAG_COLOR_MAP,
682            TAG_TILE_WIDTH,
683            TAG_TILE_LENGTH,
684            TAG_TILE_OFFSETS,
685            TAG_TILE_BYTE_COUNTS,
686            TAG_INK_SET,
687            TAG_EXTRA_SAMPLES,
688            TAG_SAMPLE_FORMAT,
689            TAG_YCBCR_SUBSAMPLING,
690            TAG_YCBCR_POSITIONING,
691            TAG_LERC_PARAMETERS,
692        ];
693
694        let mut seen = HashSet::with_capacity(self.extra_tags.len());
695        for tag in &self.extra_tags {
696            if !seen.insert(tag.code) {
697                return Err(crate::error::Error::InvalidConfig(format!(
698                    "extra TIFF tag {} is defined more than once",
699                    tag.code
700                )));
701            }
702            if MANAGED_TAGS.contains(&tag.code) {
703                return Err(crate::error::Error::InvalidConfig(format!(
704                    "TIFF tag {} is managed by ImageBuilder and cannot be supplied as an extra tag",
705                    tag.code
706                )));
707            }
708            if tag.tag_type != tag.value.tag_type() || tag.count != tag.value.count() {
709                return Err(crate::error::Error::InvalidConfig(format!(
710                    "extra TIFF tag {} has type/count metadata inconsistent with its value",
711                    tag.code
712                )));
713            }
714        }
715        Ok(())
716    }
717
718    fn checked_layout(&self) -> crate::error::Result<DataLayout> {
719        match self.layout {
720            DataLayout::Strips { rows_per_strip: 0 } => Err(crate::error::Error::InvalidConfig(
721                "rows_per_strip must be greater than zero".into(),
722            )),
723            DataLayout::Tiles { width, height } if width == 0 || height == 0 => {
724                Err(crate::error::Error::InvalidConfig(format!(
725                    "tile_width and tile_height must be greater than zero, got {}x{}",
726                    width, height
727                )))
728            }
729            DataLayout::Tiles { width, height } if width % 16 != 0 || height % 16 != 0 => {
730                Err(crate::error::Error::InvalidConfig(format!(
731                    "tile dimensions must be multiples of 16, got {}x{}",
732                    width, height
733                )))
734            }
735            layout => Ok(layout),
736        }
737    }
738
739    fn validate_color_model(&self) -> crate::error::Result<()> {
740        if !matches!(self.photometric, PhotometricInterpretation::Palette)
741            && self.color_map.is_some()
742        {
743            return Err(crate::error::Error::InvalidConfig(
744                "ColorMap is only valid with palette photometric interpretation".into(),
745            ));
746        }
747
748        if !matches!(self.photometric, PhotometricInterpretation::Separated)
749            && self.ink_set.is_some()
750        {
751            return Err(crate::error::Error::InvalidConfig(
752                "InkSet is only valid with separated photometric interpretation".into(),
753            ));
754        }
755
756        let base_samples: u16 = match self.photometric {
757            PhotometricInterpretation::MinIsWhite | PhotometricInterpretation::MinIsBlack => 1,
758            PhotometricInterpretation::Rgb => 3,
759            PhotometricInterpretation::Palette => {
760                let color_map =
761                    self.color_map
762                        .as_ref()
763                        .ok_or(crate::error::Error::InvalidConfig(
764                            "palette photometric interpretation requires a ColorMap".into(),
765                        ))?;
766                let expected_entries =
767                    1usize
768                        .checked_shl(self.bits_per_sample as u32)
769                        .ok_or_else(|| {
770                            crate::error::Error::InvalidConfig(format!(
771                                "palette BitsPerSample {} exceeds usize shift width",
772                                self.bits_per_sample
773                            ))
774                        })?;
775                if color_map.len() != expected_entries {
776                    return Err(crate::error::Error::InvalidConfig(format!(
777                        "palette ColorMap has {} entries but BitsPerSample={} requires {}",
778                        color_map.len(),
779                        self.bits_per_sample,
780                        expected_entries
781                    )));
782                }
783                1
784            }
785            PhotometricInterpretation::Mask => 1,
786            PhotometricInterpretation::Separated => match self.ink_set.unwrap_or(InkSet::Cmyk) {
787                InkSet::Cmyk => 4,
788                InkSet::NotCmyk | InkSet::Unknown(_) => {
789                    return Err(crate::error::Error::InvalidConfig(
790                        "separated photometric interpretation currently requires InkSet::Cmyk"
791                            .into(),
792                    ))
793                }
794            },
795            PhotometricInterpretation::YCbCr => 3,
796            PhotometricInterpretation::CieLab => 3,
797        };
798
799        let _ = self.effective_extra_samples_for_base(base_samples)?;
800
801        if matches!(self.photometric, PhotometricInterpretation::YCbCr) {
802            if !matches!(self.sample_format, SampleFormat::Uint) || self.bits_per_sample != 8 {
803                return Err(crate::error::Error::InvalidConfig(
804                    "YCbCr photometric interpretation requires 8-bit unsigned samples".into(),
805                ));
806            }
807            if let Some(subsampling) = self.ycbcr_subsampling {
808                let supported = subsampling == [1, 1]
809                    || (matches!(self.compression, Compression::Jpeg) && subsampling == [2, 2]);
810                if !supported {
811                    return Err(crate::error::Error::InvalidConfig(format!(
812                        "YCbCr subsampling {:?} is not supported by the current writer; \
813                         supported values are [1, 1], and [2, 2] with JPEG compression",
814                        subsampling
815                    )));
816                }
817            }
818        } else if self.ycbcr_subsampling.is_some() || self.ycbcr_positioning.is_some() {
819            return Err(crate::error::Error::InvalidConfig(
820                "YCbCr-specific tags require YCbCr photometric interpretation".into(),
821            ));
822        }
823
824        Ok(())
825    }
826
827    fn effective_extra_samples(&self) -> crate::error::Result<Vec<ExtraSample>> {
828        let base_samples = match self.photometric {
829            PhotometricInterpretation::MinIsWhite | PhotometricInterpretation::MinIsBlack => 1,
830            PhotometricInterpretation::Rgb => 3,
831            PhotometricInterpretation::Palette => 1,
832            PhotometricInterpretation::Mask => 1,
833            PhotometricInterpretation::Separated => 4,
834            PhotometricInterpretation::YCbCr => 3,
835            PhotometricInterpretation::CieLab => 3,
836        };
837        self.effective_extra_samples_for_base(base_samples)
838    }
839
840    fn effective_extra_samples_for_base(
841        &self,
842        base_samples: u16,
843    ) -> crate::error::Result<Vec<ExtraSample>> {
844        let implied_extra_samples = self
845            .samples_per_pixel
846            .checked_sub(base_samples)
847            .ok_or_else(|| {
848                crate::error::Error::InvalidConfig(format!(
849                    "{} photometric interpretation requires at least {} samples, got {}",
850                    photometric_name(self.photometric),
851                    base_samples,
852                    self.samples_per_pixel
853                ))
854            })?;
855        if self.extra_samples.len() > implied_extra_samples as usize {
856            return Err(crate::error::Error::InvalidConfig(format!(
857                "{} photometric interpretation has {} total channels but {} ExtraSamples",
858                photometric_name(self.photometric),
859                self.samples_per_pixel,
860                self.extra_samples.len()
861            )));
862        }
863
864        let mut extra_samples = self.extra_samples.clone();
865        extra_samples.resize(implied_extra_samples as usize, ExtraSample::Unspecified);
866        Ok(extra_samples)
867    }
868
869    fn validate_jpeg_config(&self) -> crate::error::Result<()> {
870        let options = self.jpeg_options.unwrap_or_default();
871        if !(1..=100).contains(&options.quality) {
872            return Err(crate::error::Error::InvalidConfig(format!(
873                "JPEG quality must be in the range 1..=100, got {}",
874                options.quality
875            )));
876        }
877        if self.bits_per_sample != 8 {
878            return Err(crate::error::Error::InvalidConfig(format!(
879                "JPEG compression requires 8-bit samples, got {} bits",
880                self.bits_per_sample
881            )));
882        }
883        if !matches!(self.sample_format, SampleFormat::Uint) {
884            return Err(crate::error::Error::InvalidConfig(format!(
885                "JPEG compression requires unsigned integer samples, got {:?}",
886                self.sample_format
887            )));
888        }
889        if !matches!(self.predictor, Predictor::None) {
890            return Err(crate::error::Error::InvalidConfig(
891                "JPEG compression does not support TIFF predictors".into(),
892            ));
893        }
894
895        let block_width = self.block_row_width();
896        if block_width > u16::MAX as usize {
897            return Err(crate::error::Error::InvalidConfig(format!(
898                "JPEG block width must be <= {}, got {}",
899                u16::MAX,
900                block_width
901            )));
902        }
903        let max_block_height = match self.layout {
904            DataLayout::Strips { rows_per_strip } => rows_per_strip.max(1),
905            DataLayout::Tiles { height, .. } => height,
906        };
907        if max_block_height > u16::MAX as u32 {
908            return Err(crate::error::Error::InvalidConfig(format!(
909                "JPEG block height must be <= {}, got {}",
910                u16::MAX,
911                max_block_height
912            )));
913        }
914
915        let block_samples_per_pixel = self.block_samples_per_pixel();
916        match block_samples_per_pixel {
917            1 => {}
918            3 => {
919                if !matches!(self.photometric, PhotometricInterpretation::YCbCr) {
920                    return Err(crate::error::Error::InvalidConfig(
921                        "interleaved 3-sample JPEG blocks require YCbCr photometric \
922                         interpretation; use planar configuration for other color models"
923                            .into(),
924                    ));
925                }
926            }
927            other => {
928                return Err(crate::error::Error::InvalidConfig(format!(
929                    "JPEG write supports 1 or 3 samples per encoded block, got {other}; \
930                     use planar configuration for other band counts"
931                )));
932            }
933        }
934
935        if matches!(
936            self.photometric,
937            PhotometricInterpretation::Palette | PhotometricInterpretation::Mask
938        ) {
939            return Err(crate::error::Error::InvalidConfig(format!(
940                "{:?} photometric interpretation is not supported with JPEG compression",
941                self.photometric
942            )));
943        }
944
945        Ok(())
946    }
947}
948
949fn photometric_name(photometric: PhotometricInterpretation) -> &'static str {
950    match photometric {
951        PhotometricInterpretation::MinIsWhite => "MinIsWhite",
952        PhotometricInterpretation::MinIsBlack => "MinIsBlack",
953        PhotometricInterpretation::Rgb => "RGB",
954        PhotometricInterpretation::Palette => "Palette",
955        PhotometricInterpretation::Mask => "TransparencyMask",
956        PhotometricInterpretation::Separated => "Separated",
957        PhotometricInterpretation::YCbCr => "YCbCr",
958        PhotometricInterpretation::CieLab => "CIELab",
959    }
960}
961
962fn layout_overflow(context: &'static str) -> crate::error::Error {
963    crate::error::Error::InvalidConfig(format!("{context} overflows layout size limits"))
964}
965
966#[cfg(test)]
967mod tests {
968    use super::ImageBuilder;
969    use tiff_core::{
970        PhotometricInterpretation, PlanarConfiguration, Tag, TagType, TagValue, TAG_IMAGE_WIDTH,
971    };
972
973    #[test]
974    fn validate_rejects_zero_strip_and_tile_dimensions() {
975        let err = ImageBuilder::new(16, 16).strips(0).validate().unwrap_err();
976        assert!(
977            matches!(err, crate::error::Error::InvalidConfig(message) if message.contains("rows_per_strip"))
978        );
979
980        let err = ImageBuilder::new(16, 16)
981            .tiles(0, 16)
982            .validate()
983            .unwrap_err();
984        assert!(
985            matches!(err, crate::error::Error::InvalidConfig(message) if message.contains("tile_width"))
986        );
987
988        let err = ImageBuilder::new(16, 16)
989            .tiles(16, 0)
990            .validate()
991            .unwrap_err();
992        assert!(
993            matches!(err, crate::error::Error::InvalidConfig(message) if message.contains("tile_height"))
994        );
995
996        let err = ImageBuilder::new(16, 16)
997            .tiles(0, 0)
998            .validate()
999            .unwrap_err();
1000        assert!(
1001            matches!(err, crate::error::Error::InvalidConfig(message) if message.contains("tile_width") && message.contains("tile_height"))
1002        );
1003    }
1004
1005    #[test]
1006    fn checked_helpers_reject_zero_strip_and_tile_dimensions() {
1007        let builder = ImageBuilder::new(16, 16).strips(0);
1008        let err = builder.checked_block_count().unwrap_err();
1009        assert!(
1010            matches!(err, crate::error::Error::InvalidConfig(message) if message.contains("rows_per_strip"))
1011        );
1012        let err = builder.checked_layout_tags().unwrap_err();
1013        assert!(
1014            matches!(err, crate::error::Error::InvalidConfig(message) if message.contains("rows_per_strip"))
1015        );
1016        let err = builder.checked_build_tags(false).unwrap_err();
1017        assert!(
1018            matches!(err, crate::error::Error::InvalidConfig(message) if message.contains("rows_per_strip"))
1019        );
1020
1021        let builder = ImageBuilder::new(16, 16).tiles(0, 16);
1022        let err = builder.checked_block_count().unwrap_err();
1023        assert!(
1024            matches!(err, crate::error::Error::InvalidConfig(message) if message.contains("tile_width"))
1025        );
1026        let err = builder.checked_layout_tags().unwrap_err();
1027        assert!(
1028            matches!(err, crate::error::Error::InvalidConfig(message) if message.contains("tile_width"))
1029        );
1030        let err = builder.checked_build_tags(false).unwrap_err();
1031        assert!(
1032            matches!(err, crate::error::Error::InvalidConfig(message) if message.contains("tile_width"))
1033        );
1034
1035        let builder = ImageBuilder::new(16, 16).tiles(16, 0);
1036        let err = builder.checked_block_count().unwrap_err();
1037        assert!(
1038            matches!(err, crate::error::Error::InvalidConfig(message) if message.contains("tile_height"))
1039        );
1040        let err = builder.checked_layout_tags().unwrap_err();
1041        assert!(
1042            matches!(err, crate::error::Error::InvalidConfig(message) if message.contains("tile_height"))
1043        );
1044        let err = builder.checked_build_tags(false).unwrap_err();
1045        assert!(
1046            matches!(err, crate::error::Error::InvalidConfig(message) if message.contains("tile_height"))
1047        );
1048
1049        let builder = ImageBuilder::new(16, 16).tiles(15, 16);
1050        let err = builder.checked_layout_tags().unwrap_err();
1051        assert!(
1052            matches!(err, crate::error::Error::InvalidConfig(message) if message.contains("multiples of 16"))
1053        );
1054    }
1055
1056    #[test]
1057    fn checked_build_tags_returns_color_model_errors() {
1058        let err = ImageBuilder::new(16, 16)
1059            .photometric(PhotometricInterpretation::Rgb)
1060            .samples_per_pixel(1)
1061            .checked_build_tags(false)
1062            .unwrap_err();
1063        assert!(
1064            matches!(err, crate::error::Error::InvalidConfig(message) if message.contains("requires at least 3 samples"))
1065        );
1066    }
1067
1068    #[test]
1069    fn checked_helpers_reject_out_of_range_block_indices() {
1070        let builder = ImageBuilder::new(16, 16).sample_type::<u8>().tiles(16, 16);
1071        assert!(matches!(
1072            builder.checked_block_sample_count(1),
1073            Err(crate::error::Error::BlockIndexOutOfRange { index: 1, total: 1 })
1074        ));
1075        assert!(matches!(
1076            builder.checked_block_height(1),
1077            Err(crate::error::Error::BlockIndexOutOfRange { index: 1, total: 1 })
1078        ));
1079    }
1080
1081    #[test]
1082    fn validation_rejects_conflicting_duplicate_and_incoherent_extra_tags() {
1083        let managed =
1084            ImageBuilder::new(1, 1).tag(Tag::new(TAG_IMAGE_WIDTH, TagValue::Long(vec![2])));
1085        assert!(matches!(
1086            managed.validate(),
1087            Err(crate::error::Error::InvalidConfig(message)) if message.contains("managed")
1088        ));
1089
1090        let duplicate = ImageBuilder::new(1, 1)
1091            .tag(Tag::new(65000, TagValue::Short(vec![1])))
1092            .tag(Tag::new(65000, TagValue::Short(vec![2])));
1093        assert!(matches!(
1094            duplicate.validate(),
1095            Err(crate::error::Error::InvalidConfig(message)) if message.contains("more than once")
1096        ));
1097
1098        let mut incoherent = Tag::new(65000, TagValue::Short(vec![1]));
1099        incoherent.tag_type = TagType::Long;
1100        let incoherent = ImageBuilder::new(1, 1).tag(incoherent);
1101        assert!(matches!(
1102            incoherent.validate(),
1103            Err(crate::error::Error::InvalidConfig(message)) if message.contains("inconsistent")
1104        ));
1105    }
1106
1107    #[test]
1108    fn unsupported_predictor_requests_are_reported_instead_of_ignored() {
1109        let err = ImageBuilder::new(1, 1)
1110            .sample_type::<u8>()
1111            .compression(tiff_core::Compression::Jpeg)
1112            .predictor(tiff_core::Predictor::Horizontal)
1113            .validate()
1114            .unwrap_err();
1115        #[cfg(feature = "jpeg")]
1116        assert!(
1117            matches!(err, crate::error::Error::InvalidConfig(message) if message.contains("JPEG compression does not support"))
1118        );
1119        #[cfg(not(feature = "jpeg"))]
1120        assert!(
1121            matches!(err, crate::error::Error::InvalidConfig(message) if message.contains("not supported"))
1122        );
1123
1124        let err = ImageBuilder::new(1, 1)
1125            .sample_type::<u8>()
1126            .predictor(tiff_core::Predictor::Horizontal)
1127            .validate()
1128            .unwrap_err();
1129        assert!(
1130            matches!(err, crate::error::Error::InvalidConfig(message) if message.contains("require a supported compression"))
1131        );
1132    }
1133
1134    #[test]
1135    fn validation_rejects_unsupported_writer_codecs_before_block_writes() {
1136        for compression in [
1137            tiff_core::Compression::OldJpeg,
1138            tiff_core::Compression::PackBits,
1139            tiff_core::Compression::WebP,
1140        ] {
1141            assert!(matches!(
1142                ImageBuilder::new(1, 1)
1143                    .sample_type::<u8>()
1144                    .compression(compression)
1145                    .validate(),
1146                Err(crate::error::Error::InvalidConfig(message))
1147                    if message.contains("not supported")
1148            ));
1149        }
1150
1151        #[cfg(not(feature = "jpeg"))]
1152        assert!(ImageBuilder::new(1, 1)
1153            .sample_type::<u8>()
1154            .compression(tiff_core::Compression::Jpeg)
1155            .validate()
1156            .is_err());
1157        #[cfg(not(feature = "zstd"))]
1158        assert!(ImageBuilder::new(1, 1)
1159            .sample_type::<u8>()
1160            .compression(tiff_core::Compression::Zstd)
1161            .validate()
1162            .is_err());
1163    }
1164
1165    #[test]
1166    fn validate_rejects_mismatched_predictor_and_sample_format() {
1167        let err = ImageBuilder::new(4, 4)
1168            .sample_type::<f32>()
1169            .compression(tiff_core::Compression::Deflate)
1170            .predictor(tiff_core::Predictor::Horizontal)
1171            .validate()
1172            .unwrap_err();
1173        assert!(
1174            matches!(err, crate::error::Error::InvalidConfig(message) if message.contains("horizontal predictor"))
1175        );
1176
1177        let err = ImageBuilder::new(4, 4)
1178            .sample_type::<u16>()
1179            .compression(tiff_core::Compression::Deflate)
1180            .predictor(tiff_core::Predictor::FloatingPoint)
1181            .validate()
1182            .unwrap_err();
1183        assert!(
1184            matches!(err, crate::error::Error::InvalidConfig(message) if message.contains("floating-point predictor"))
1185        );
1186
1187        let f16_builder = ImageBuilder::new(4, 4)
1188            .bits_per_sample(16)
1189            .sample_format(tiff_core::SampleFormat::Float);
1190        #[cfg(not(feature = "f16"))]
1191        {
1192            let err = f16_builder.validate().unwrap_err();
1193            assert!(
1194                matches!(err, crate::error::Error::InvalidConfig(message) if message.contains("32 or 64 bits"))
1195            );
1196        }
1197        #[cfg(feature = "f16")]
1198        assert!(f16_builder.validate().is_ok());
1199
1200        assert!(ImageBuilder::new(4, 4)
1201            .sample_type::<u16>()
1202            .compression(tiff_core::Compression::Deflate)
1203            .predictor(tiff_core::Predictor::Horizontal)
1204            .validate()
1205            .is_ok());
1206        assert!(ImageBuilder::new(4, 4)
1207            .sample_type::<f32>()
1208            .compression(tiff_core::Compression::Deflate)
1209            .predictor(tiff_core::Predictor::FloatingPoint)
1210            .validate()
1211            .is_ok());
1212    }
1213
1214    #[cfg(feature = "f16")]
1215    #[test]
1216    fn validate_rejects_lerc_with_f16_samples() {
1217        let err = ImageBuilder::new(4, 4)
1218            .sample_type::<half::f16>()
1219            .compression(tiff_core::Compression::Lerc)
1220            .validate()
1221            .unwrap_err();
1222        assert!(
1223            matches!(err, crate::error::Error::InvalidConfig(message) if message.contains("LERC compression does not support 16-bit float samples"))
1224        );
1225    }
1226
1227    #[test]
1228    fn validate_rejects_overflowing_layout_sizes() {
1229        let err = ImageBuilder::new(u32::MAX, u32::MAX)
1230            .sample_type::<u8>()
1231            .samples_per_pixel(u16::MAX)
1232            .planar_configuration(PlanarConfiguration::Planar)
1233            .tiles(16, 16)
1234            .validate()
1235            .unwrap_err();
1236        assert!(
1237            matches!(err, crate::error::Error::InvalidConfig(message) if message.contains("block count"))
1238        );
1239
1240        let large_multiple_of_16 = u32::MAX - 15;
1241        let err = ImageBuilder::new(1, 1)
1242            .sample_type::<u8>()
1243            .samples_per_pixel(2)
1244            .tiles(large_multiple_of_16, large_multiple_of_16)
1245            .validate()
1246            .unwrap_err();
1247        assert!(
1248            matches!(err, crate::error::Error::InvalidConfig(message) if message.contains("sample count"))
1249        );
1250
1251        let err = ImageBuilder::new(u32::MAX, u32::MAX)
1252            .sample_type::<u64>()
1253            .samples_per_pixel(2)
1254            .strips(256)
1255            .validate()
1256            .unwrap_err();
1257        assert!(
1258            matches!(err, crate::error::Error::InvalidConfig(message) if message.contains("byte count"))
1259        );
1260    }
1261}