Skip to main content

tiff_writer/
builder.rs

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