use std::collections::HashSet;
use tiff_core::*;
use crate::encoder;
use crate::sample::TiffWriteSample;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LercOptions {
pub max_z_error: f64,
pub additional_compression: LercAdditionalCompression,
}
impl Default for LercOptions {
fn default() -> Self {
Self {
max_z_error: 0.0,
additional_compression: LercAdditionalCompression::None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct JpegOptions {
pub quality: u8,
}
impl Default for JpegOptions {
fn default() -> Self {
Self { quality: 75 }
}
}
#[derive(Debug, Clone, Copy)]
pub enum DataLayout {
Strips { rows_per_strip: u32 },
Tiles { width: u32, height: u32 },
}
#[derive(Debug, Clone)]
pub struct ImageBuilder {
pub(crate) width: u32,
pub(crate) height: u32,
pub(crate) samples_per_pixel: u16,
pub(crate) bits_per_sample: u16,
pub(crate) sample_format: SampleFormat,
pub(crate) compression: Compression,
pub(crate) predictor: Predictor,
pub(crate) photometric: PhotometricInterpretation,
pub(crate) extra_samples: Vec<ExtraSample>,
pub(crate) color_map: Option<ColorMap>,
pub(crate) ink_set: Option<InkSet>,
pub(crate) ycbcr_subsampling: Option<[u16; 2]>,
pub(crate) ycbcr_positioning: Option<YCbCrPositioning>,
pub(crate) planar_configuration: PlanarConfiguration,
pub(crate) layout: DataLayout,
pub(crate) extra_tags: Vec<Tag>,
pub(crate) subfile_type: u32,
pub(crate) lerc_options: Option<LercOptions>,
pub(crate) jpeg_options: Option<JpegOptions>,
pub(crate) deflate_level: Option<u32>,
}
impl ImageBuilder {
pub fn new(width: u32, height: u32) -> Self {
Self {
width,
height,
samples_per_pixel: 1,
bits_per_sample: 8,
sample_format: SampleFormat::Uint,
compression: Compression::None,
predictor: Predictor::None,
photometric: PhotometricInterpretation::MinIsBlack,
extra_samples: Vec::new(),
color_map: None,
ink_set: None,
ycbcr_subsampling: None,
ycbcr_positioning: None,
planar_configuration: PlanarConfiguration::Chunky,
layout: DataLayout::Strips {
rows_per_strip: height.min(256),
},
extra_tags: Vec::new(),
subfile_type: 0,
lerc_options: None,
jpeg_options: None,
deflate_level: None,
}
}
pub fn samples_per_pixel(mut self, spp: u16) -> Self {
self.samples_per_pixel = spp;
self
}
pub fn bits_per_sample(mut self, bps: u16) -> Self {
self.bits_per_sample = bps;
self
}
pub fn sample_format(mut self, fmt: SampleFormat) -> Self {
self.sample_format = fmt;
self
}
pub fn sample_type<T: TiffWriteSample>(mut self) -> Self {
self.bits_per_sample = T::BITS_PER_SAMPLE;
self.sample_format =
SampleFormat::from_code(T::SAMPLE_FORMAT).unwrap_or(SampleFormat::Uint);
self
}
pub fn compression(mut self, c: Compression) -> Self {
self.compression = c;
if !matches!(c, Compression::Lerc) {
self.lerc_options = None;
}
if !matches!(c, Compression::Jpeg) {
self.jpeg_options = None;
}
if matches!(c, Compression::Lerc | Compression::Jpeg) {
self.predictor = Predictor::None;
}
self
}
pub fn predictor(mut self, p: Predictor) -> Self {
self.predictor = p;
self
}
pub fn deflate_level(mut self, level: u32) -> Self {
self.deflate_level = Some(level);
self
}
pub fn photometric(mut self, p: PhotometricInterpretation) -> Self {
self.photometric = p;
self
}
pub fn extra_samples(mut self, extra_samples: Vec<ExtraSample>) -> Self {
self.extra_samples = extra_samples;
self
}
pub fn color_map(mut self, color_map: ColorMap) -> Self {
self.color_map = Some(color_map);
self
}
pub fn ink_set(mut self, ink_set: InkSet) -> Self {
self.ink_set = Some(ink_set);
self
}
pub fn ycbcr_subsampling(mut self, subsampling: [u16; 2]) -> Self {
self.ycbcr_subsampling = Some(subsampling);
self
}
pub fn ycbcr_positioning(mut self, positioning: YCbCrPositioning) -> Self {
self.ycbcr_positioning = Some(positioning);
self
}
pub fn planar_configuration(mut self, p: PlanarConfiguration) -> Self {
self.planar_configuration = p;
self
}
pub fn strips(mut self, rows_per_strip: u32) -> Self {
self.layout = DataLayout::Strips { rows_per_strip };
self
}
pub fn data_layout(&self) -> DataLayout {
self.layout
}
pub fn tiles(mut self, tile_width: u32, tile_height: u32) -> Self {
self.layout = DataLayout::Tiles {
width: tile_width,
height: tile_height,
};
self
}
pub fn tag(mut self, tag: Tag) -> Self {
self.extra_tags.push(tag);
self
}
pub fn overview(mut self) -> Self {
self.subfile_type = 1;
self
}
pub fn lerc_options(mut self, options: LercOptions) -> Self {
self.compression = Compression::Lerc;
self.predictor = Predictor::None;
self.lerc_options = Some(options);
self.jpeg_options = None;
self
}
pub fn jpeg_options(mut self, options: JpegOptions) -> Self {
self.compression = Compression::Jpeg;
self.predictor = Predictor::None;
self.jpeg_options = Some(options);
self.lerc_options = None;
self
}
pub fn checked_block_count(&self) -> crate::error::Result<usize> {
let blocks_per_plane = match self.checked_layout()? {
DataLayout::Strips { rows_per_strip } => {
let rps = rows_per_strip as usize;
(self.height as usize).div_ceil(rps)
}
DataLayout::Tiles { width, height } => {
let tw = width as usize;
let th = height as usize;
let tiles_across = (self.width as usize).div_ceil(tw);
let tiles_down = (self.height as usize).div_ceil(th);
tiles_across
.checked_mul(tiles_down)
.ok_or_else(|| layout_overflow("tile count"))?
}
};
if matches!(self.planar_configuration, PlanarConfiguration::Planar) {
blocks_per_plane
.checked_mul(self.samples_per_pixel as usize)
.ok_or_else(|| layout_overflow("planar block count"))
} else {
Ok(blocks_per_plane)
}
}
pub fn checked_block_sample_count(&self, index: usize) -> crate::error::Result<usize> {
let block_count = self.checked_block_count()?;
if index >= block_count {
return Err(crate::error::Error::BlockIndexOutOfRange {
index,
total: block_count,
});
}
let samples_per_pixel = self.block_samples_per_pixel() as usize;
let plane_block_index = self.checked_block_plane_index(index)?;
match self.checked_layout()? {
DataLayout::Strips { rows_per_strip } => {
let rps = rows_per_strip as usize;
let start_row = plane_block_index
.checked_mul(rps)
.ok_or_else(|| layout_overflow("strip start row"))?;
let end_row = plane_block_index
.checked_add(1)
.and_then(|value| value.checked_mul(rps))
.ok_or_else(|| layout_overflow("strip end row"))?
.min(self.height as usize);
let rows = end_row.saturating_sub(start_row);
rows.checked_mul(self.width as usize)
.and_then(|value| value.checked_mul(samples_per_pixel))
.ok_or_else(|| layout_overflow("strip sample count"))
}
DataLayout::Tiles { width, height } => {
(width as usize)
.checked_mul(height as usize)
.and_then(|value| value.checked_mul(samples_per_pixel))
.ok_or_else(|| layout_overflow("tile sample count"))
}
}
}
pub fn checked_estimated_uncompressed_bytes(&self) -> crate::error::Result<u64> {
let bps = u64::from(self.bits_per_sample.div_ceil(8));
(self.width as u64)
.checked_mul(self.height as u64)
.and_then(|value| value.checked_mul(self.samples_per_pixel as u64))
.and_then(|value| value.checked_mul(bps))
.ok_or_else(|| layout_overflow("estimated uncompressed byte count"))
}
pub fn offset_tag_codes(&self) -> (u16, u16) {
match self.layout {
DataLayout::Strips { .. } => (TAG_STRIP_OFFSETS, TAG_STRIP_BYTE_COUNTS),
DataLayout::Tiles { .. } => (TAG_TILE_OFFSETS, TAG_TILE_BYTE_COUNTS),
}
}
pub fn checked_layout_tags(&self) -> crate::error::Result<Vec<Tag>> {
match self.checked_layout()? {
DataLayout::Strips { rows_per_strip } => Ok(vec![Tag::new(
TAG_ROWS_PER_STRIP,
TagValue::Long(vec![rows_per_strip]),
)]),
DataLayout::Tiles { width, height } => Ok(vec![
Tag::new(TAG_TILE_WIDTH, TagValue::Long(vec![width])),
Tag::new(TAG_TILE_LENGTH, TagValue::Long(vec![height])),
]),
}
}
pub fn checked_build_tags(&self, is_bigtiff: bool) -> crate::error::Result<Vec<Tag>> {
let mut extra_tags = self.extra_tags.clone();
if let Some(lerc_tag) = self.lerc_parameters_tag() {
extra_tags.push(lerc_tag);
}
self.validate()?;
let extra_samples = self.effective_extra_samples()?;
if !extra_samples.is_empty() {
extra_tags.push(Tag::new(
TAG_EXTRA_SAMPLES,
TagValue::Short(
extra_samples
.iter()
.copied()
.map(ExtraSample::to_code)
.collect(),
),
));
}
if let Some(color_map) = &self.color_map {
extra_tags.push(Tag::new(
TAG_COLOR_MAP,
TagValue::Short(color_map.encode_tag_values()),
));
}
if let Some(ink_set) = self.ink_set {
extra_tags.push(Tag::new(
TAG_INK_SET,
TagValue::Short(vec![ink_set.to_code()]),
));
}
if let Some([h, v]) = self.effective_ycbcr_subsampling() {
extra_tags.push(Tag::new(TAG_YCBCR_SUBSAMPLING, TagValue::Short(vec![h, v])));
}
if let Some(positioning) = self.ycbcr_positioning {
extra_tags.push(Tag::new(
TAG_YCBCR_POSITIONING,
TagValue::Short(vec![positioning.to_code()]),
));
}
let (offsets_tag_code, byte_counts_tag_code) = self.offset_tag_codes();
let layout_tags = self.checked_layout_tags()?;
Ok(encoder::build_image_tags(&encoder::ImageTagParams {
width: self.width,
height: self.height,
samples_per_pixel: self.samples_per_pixel,
bits_per_sample: self.bits_per_sample,
sample_format: self.sample_format.to_code(),
compression: self.compression.to_code(),
photometric: self.photometric.to_code(),
predictor: self.predictor.to_code(),
planar_configuration: self.planar_configuration.to_code(),
subfile_type: self.subfile_type,
extra_tags: &extra_tags,
offsets_tag_code,
byte_counts_tag_code,
num_blocks: self.checked_block_count()?,
layout_tags: &layout_tags,
is_bigtiff,
}))
}
pub fn block_row_width(&self) -> usize {
match self.layout {
DataLayout::Strips { .. } => self.width as usize,
DataLayout::Tiles { width, .. } => width as usize,
}
}
pub fn block_samples_per_pixel(&self) -> u16 {
if matches!(self.planar_configuration, PlanarConfiguration::Planar) {
1
} else {
self.samples_per_pixel
}
}
fn checked_block_plane_index(&self, index: usize) -> crate::error::Result<usize> {
if matches!(self.planar_configuration, PlanarConfiguration::Planar) {
let blocks_per_plane = self.checked_blocks_per_plane()?;
if blocks_per_plane == 0 {
return Err(crate::error::Error::InvalidConfig(
"block count must be greater than zero".into(),
));
}
Ok(index % blocks_per_plane)
} else {
Ok(index)
}
}
fn checked_blocks_per_plane(&self) -> crate::error::Result<usize> {
match self.checked_layout()? {
DataLayout::Strips { rows_per_strip } => {
let rps = rows_per_strip as usize;
Ok((self.height as usize).div_ceil(rps))
}
DataLayout::Tiles { width, height } => {
let tw = width as usize;
let th = height as usize;
let tiles_across = (self.width as usize).div_ceil(tw);
let tiles_down = (self.height as usize).div_ceil(th);
tiles_across
.checked_mul(tiles_down)
.ok_or_else(|| layout_overflow("tile count"))
}
}
}
pub fn checked_block_height(&self, index: usize) -> crate::error::Result<u32> {
let block_count = self.checked_block_count()?;
if index >= block_count {
return Err(crate::error::Error::BlockIndexOutOfRange {
index,
total: block_count,
});
}
match self.checked_layout()? {
DataLayout::Tiles { height, .. } => Ok(height),
DataLayout::Strips { rows_per_strip } => {
let plane_index = self.checked_block_plane_index(index)?;
let rps = rows_per_strip as usize;
let start_row = plane_index
.checked_mul(rps)
.ok_or_else(|| layout_overflow("strip start row"))?;
let remaining = (self.height as usize).saturating_sub(start_row);
Ok(remaining.min(rps) as u32)
}
}
}
pub(crate) fn effective_ycbcr_subsampling(&self) -> Option<[u16; 2]> {
if self.ycbcr_subsampling.is_some() {
return self.ycbcr_subsampling;
}
(matches!(self.photometric, PhotometricInterpretation::YCbCr)
&& matches!(self.compression, Compression::Jpeg))
.then_some([2, 2])
}
pub fn jpeg_chroma_sampling(&self) -> Option<[u16; 2]> {
(matches!(self.photometric, PhotometricInterpretation::YCbCr)
&& matches!(self.compression, Compression::Jpeg)
&& self.block_samples_per_pixel() == 3)
.then(|| self.effective_ycbcr_subsampling().unwrap_or([1, 1]))
}
pub fn lerc_parameters_tag(&self) -> Option<Tag> {
if !matches!(self.compression, Compression::Lerc) {
return None;
}
let opts = self.lerc_options.unwrap_or_default();
Some(Tag::new(
TAG_LERC_PARAMETERS,
TagValue::Long(vec![
LERC_VERSION_2_4,
opts.additional_compression.to_code(),
]),
))
}
pub fn validate(&self) -> crate::error::Result<()> {
if self.width == 0 || self.height == 0 {
return Err(crate::error::Error::InvalidConfig(
"image dimensions must be positive".into(),
));
}
if self.samples_per_pixel == 0 {
return Err(crate::error::Error::InvalidConfig(
"samples_per_pixel must be greater than zero".into(),
));
}
self.validate_extra_tags()?;
if !matches!(self.bits_per_sample, 8 | 16 | 32 | 64) {
return Err(crate::error::Error::InvalidConfig(format!(
"bits_per_sample must be 8, 16, 32, or 64, got {}",
self.bits_per_sample
)));
}
match self.layout {
DataLayout::Strips { rows_per_strip: 0 } => {
return Err(crate::error::Error::InvalidConfig(
"rows_per_strip must be greater than zero".into(),
));
}
DataLayout::Tiles { width, height } => {
if width == 0 || height == 0 {
return Err(crate::error::Error::InvalidConfig(format!(
"tile_width and tile_height must be greater than zero, got {}x{}",
width, height
)));
}
if width % 16 != 0 || height % 16 != 0 {
return Err(crate::error::Error::InvalidConfig(format!(
"tile dimensions must be multiples of 16, got {}x{}",
width, height
)));
}
}
_ => {}
}
self.checked_block_count()?;
self.checked_block_sample_count(0)?;
self.checked_estimated_uncompressed_bytes()?;
match self.compression {
Compression::None
| Compression::Lzw
| Compression::Deflate
| Compression::DeflateOld
| Compression::Lerc => {}
Compression::Jpeg if cfg!(feature = "jpeg") => {}
Compression::Zstd if cfg!(feature = "zstd") => {}
unsupported => {
return Err(crate::error::Error::InvalidConfig(format!(
"{} compression is not supported by this writer build",
unsupported.name()
)))
}
}
if !matches!(self.predictor, Predictor::None)
&& matches!(self.compression, Compression::None)
{
return Err(crate::error::Error::InvalidConfig(
"TIFF predictors require a supported compression scheme".into(),
));
}
if matches!(self.compression, Compression::Lerc)
&& !matches!(self.predictor, Predictor::None)
{
return Err(crate::error::Error::InvalidConfig(
"LERC compression does not support TIFF predictors".into(),
));
}
let supported_float_bits = matches!(self.bits_per_sample, 32 | 64)
|| (cfg!(feature = "f16") && self.bits_per_sample == 16);
if matches!(self.sample_format, SampleFormat::Float) && !supported_float_bits {
let supported = if cfg!(feature = "f16") {
"16, 32, or 64"
} else {
"32 or 64"
};
return Err(crate::error::Error::InvalidConfig(format!(
"float sample format requires {supported} bits per sample, got {}",
self.bits_per_sample
)));
}
if cfg!(feature = "f16")
&& matches!(self.compression, Compression::Lerc)
&& matches!(self.sample_format, SampleFormat::Float)
&& self.bits_per_sample == 16
{
return Err(crate::error::Error::InvalidConfig(
"LERC compression does not support 16-bit float samples".into(),
));
}
match self.predictor {
Predictor::Horizontal => {
if matches!(self.sample_format, SampleFormat::Float) {
return Err(crate::error::Error::InvalidConfig(
"horizontal predictor requires integer sample formats; \
use Predictor::FloatingPoint for float samples"
.into(),
));
}
}
Predictor::FloatingPoint => {
if !matches!(self.sample_format, SampleFormat::Float) {
return Err(crate::error::Error::InvalidConfig(
"floating-point predictor requires float sample formats".into(),
));
}
}
Predictor::None => {}
}
if let Some(level) = self.deflate_level {
if level > 9 {
return Err(crate::error::Error::InvalidConfig(format!(
"deflate_level must be 0-9, got {level}"
)));
}
if !matches!(
self.compression,
Compression::Deflate | Compression::DeflateOld
) {
return Err(crate::error::Error::InvalidConfig(
"deflate_level requires Deflate compression".into(),
));
}
}
self.validate_color_model()?;
if matches!(self.compression, Compression::Jpeg) {
self.validate_jpeg_config()?;
}
Ok(())
}
fn validate_extra_tags(&self) -> crate::error::Result<()> {
const MANAGED_TAGS: &[u16] = &[
TAG_NEW_SUBFILE_TYPE,
TAG_IMAGE_WIDTH,
TAG_IMAGE_LENGTH,
TAG_BITS_PER_SAMPLE,
TAG_COMPRESSION,
TAG_PHOTOMETRIC_INTERPRETATION,
TAG_STRIP_OFFSETS,
TAG_SAMPLES_PER_PIXEL,
TAG_ROWS_PER_STRIP,
TAG_STRIP_BYTE_COUNTS,
TAG_PLANAR_CONFIGURATION,
TAG_PREDICTOR,
TAG_COLOR_MAP,
TAG_TILE_WIDTH,
TAG_TILE_LENGTH,
TAG_TILE_OFFSETS,
TAG_TILE_BYTE_COUNTS,
TAG_INK_SET,
TAG_EXTRA_SAMPLES,
TAG_SAMPLE_FORMAT,
TAG_YCBCR_SUBSAMPLING,
TAG_YCBCR_POSITIONING,
TAG_LERC_PARAMETERS,
];
let mut seen = HashSet::with_capacity(self.extra_tags.len());
for tag in &self.extra_tags {
if !seen.insert(tag.code) {
return Err(crate::error::Error::InvalidConfig(format!(
"extra TIFF tag {} is defined more than once",
tag.code
)));
}
if MANAGED_TAGS.contains(&tag.code) {
return Err(crate::error::Error::InvalidConfig(format!(
"TIFF tag {} is managed by ImageBuilder and cannot be supplied as an extra tag",
tag.code
)));
}
if tag.tag_type != tag.value.tag_type() || tag.count != tag.value.count() {
return Err(crate::error::Error::InvalidConfig(format!(
"extra TIFF tag {} has type/count metadata inconsistent with its value",
tag.code
)));
}
}
Ok(())
}
fn checked_layout(&self) -> crate::error::Result<DataLayout> {
match self.layout {
DataLayout::Strips { rows_per_strip: 0 } => Err(crate::error::Error::InvalidConfig(
"rows_per_strip must be greater than zero".into(),
)),
DataLayout::Tiles { width, height } if width == 0 || height == 0 => {
Err(crate::error::Error::InvalidConfig(format!(
"tile_width and tile_height must be greater than zero, got {}x{}",
width, height
)))
}
DataLayout::Tiles { width, height } if width % 16 != 0 || height % 16 != 0 => {
Err(crate::error::Error::InvalidConfig(format!(
"tile dimensions must be multiples of 16, got {}x{}",
width, height
)))
}
layout => Ok(layout),
}
}
fn validate_color_model(&self) -> crate::error::Result<()> {
if !matches!(self.photometric, PhotometricInterpretation::Palette)
&& self.color_map.is_some()
{
return Err(crate::error::Error::InvalidConfig(
"ColorMap is only valid with palette photometric interpretation".into(),
));
}
if !matches!(self.photometric, PhotometricInterpretation::Separated)
&& self.ink_set.is_some()
{
return Err(crate::error::Error::InvalidConfig(
"InkSet is only valid with separated photometric interpretation".into(),
));
}
let base_samples: u16 = match self.photometric {
PhotometricInterpretation::MinIsWhite | PhotometricInterpretation::MinIsBlack => 1,
PhotometricInterpretation::Rgb => 3,
PhotometricInterpretation::Palette => {
let color_map =
self.color_map
.as_ref()
.ok_or(crate::error::Error::InvalidConfig(
"palette photometric interpretation requires a ColorMap".into(),
))?;
let expected_entries =
1usize
.checked_shl(self.bits_per_sample as u32)
.ok_or_else(|| {
crate::error::Error::InvalidConfig(format!(
"palette BitsPerSample {} exceeds usize shift width",
self.bits_per_sample
))
})?;
if color_map.len() != expected_entries {
return Err(crate::error::Error::InvalidConfig(format!(
"palette ColorMap has {} entries but BitsPerSample={} requires {}",
color_map.len(),
self.bits_per_sample,
expected_entries
)));
}
1
}
PhotometricInterpretation::Mask => 1,
PhotometricInterpretation::Separated => match self.ink_set.unwrap_or(InkSet::Cmyk) {
InkSet::Cmyk => 4,
InkSet::NotCmyk | InkSet::Unknown(_) => {
return Err(crate::error::Error::InvalidConfig(
"separated photometric interpretation currently requires InkSet::Cmyk"
.into(),
))
}
},
PhotometricInterpretation::YCbCr => 3,
PhotometricInterpretation::CieLab => 3,
};
let _ = self.effective_extra_samples_for_base(base_samples)?;
if matches!(self.photometric, PhotometricInterpretation::YCbCr) {
if !matches!(self.sample_format, SampleFormat::Uint) || self.bits_per_sample != 8 {
return Err(crate::error::Error::InvalidConfig(
"YCbCr photometric interpretation requires 8-bit unsigned samples".into(),
));
}
if let Some(subsampling) = self.ycbcr_subsampling {
let supported = subsampling == [1, 1]
|| (matches!(self.compression, Compression::Jpeg) && subsampling == [2, 2]);
if !supported {
return Err(crate::error::Error::InvalidConfig(format!(
"YCbCr subsampling {:?} is not supported by the current writer; \
supported values are [1, 1], and [2, 2] with JPEG compression",
subsampling
)));
}
}
} else if self.ycbcr_subsampling.is_some() || self.ycbcr_positioning.is_some() {
return Err(crate::error::Error::InvalidConfig(
"YCbCr-specific tags require YCbCr photometric interpretation".into(),
));
}
Ok(())
}
fn effective_extra_samples(&self) -> crate::error::Result<Vec<ExtraSample>> {
let base_samples = match self.photometric {
PhotometricInterpretation::MinIsWhite | PhotometricInterpretation::MinIsBlack => 1,
PhotometricInterpretation::Rgb => 3,
PhotometricInterpretation::Palette => 1,
PhotometricInterpretation::Mask => 1,
PhotometricInterpretation::Separated => 4,
PhotometricInterpretation::YCbCr => 3,
PhotometricInterpretation::CieLab => 3,
};
self.effective_extra_samples_for_base(base_samples)
}
fn effective_extra_samples_for_base(
&self,
base_samples: u16,
) -> crate::error::Result<Vec<ExtraSample>> {
let implied_extra_samples = self
.samples_per_pixel
.checked_sub(base_samples)
.ok_or_else(|| {
crate::error::Error::InvalidConfig(format!(
"{} photometric interpretation requires at least {} samples, got {}",
photometric_name(self.photometric),
base_samples,
self.samples_per_pixel
))
})?;
if self.extra_samples.len() > implied_extra_samples as usize {
return Err(crate::error::Error::InvalidConfig(format!(
"{} photometric interpretation has {} total channels but {} ExtraSamples",
photometric_name(self.photometric),
self.samples_per_pixel,
self.extra_samples.len()
)));
}
let mut extra_samples = self.extra_samples.clone();
extra_samples.resize(implied_extra_samples as usize, ExtraSample::Unspecified);
Ok(extra_samples)
}
fn validate_jpeg_config(&self) -> crate::error::Result<()> {
let options = self.jpeg_options.unwrap_or_default();
if !(1..=100).contains(&options.quality) {
return Err(crate::error::Error::InvalidConfig(format!(
"JPEG quality must be in the range 1..=100, got {}",
options.quality
)));
}
if self.bits_per_sample != 8 {
return Err(crate::error::Error::InvalidConfig(format!(
"JPEG compression requires 8-bit samples, got {} bits",
self.bits_per_sample
)));
}
if !matches!(self.sample_format, SampleFormat::Uint) {
return Err(crate::error::Error::InvalidConfig(format!(
"JPEG compression requires unsigned integer samples, got {:?}",
self.sample_format
)));
}
if !matches!(self.predictor, Predictor::None) {
return Err(crate::error::Error::InvalidConfig(
"JPEG compression does not support TIFF predictors".into(),
));
}
let block_width = self.block_row_width();
if block_width > u16::MAX as usize {
return Err(crate::error::Error::InvalidConfig(format!(
"JPEG block width must be <= {}, got {}",
u16::MAX,
block_width
)));
}
let max_block_height = match self.layout {
DataLayout::Strips { rows_per_strip } => rows_per_strip.max(1),
DataLayout::Tiles { height, .. } => height,
};
if max_block_height > u16::MAX as u32 {
return Err(crate::error::Error::InvalidConfig(format!(
"JPEG block height must be <= {}, got {}",
u16::MAX,
max_block_height
)));
}
let block_samples_per_pixel = self.block_samples_per_pixel();
match block_samples_per_pixel {
1 => {}
3 => {
if !matches!(self.photometric, PhotometricInterpretation::YCbCr) {
return Err(crate::error::Error::InvalidConfig(
"interleaved 3-sample JPEG blocks require YCbCr photometric \
interpretation; use planar configuration for other color models"
.into(),
));
}
}
other => {
return Err(crate::error::Error::InvalidConfig(format!(
"JPEG write supports 1 or 3 samples per encoded block, got {other}; \
use planar configuration for other band counts"
)));
}
}
if matches!(
self.photometric,
PhotometricInterpretation::Palette | PhotometricInterpretation::Mask
) {
return Err(crate::error::Error::InvalidConfig(format!(
"{:?} photometric interpretation is not supported with JPEG compression",
self.photometric
)));
}
Ok(())
}
}
fn photometric_name(photometric: PhotometricInterpretation) -> &'static str {
match photometric {
PhotometricInterpretation::MinIsWhite => "MinIsWhite",
PhotometricInterpretation::MinIsBlack => "MinIsBlack",
PhotometricInterpretation::Rgb => "RGB",
PhotometricInterpretation::Palette => "Palette",
PhotometricInterpretation::Mask => "TransparencyMask",
PhotometricInterpretation::Separated => "Separated",
PhotometricInterpretation::YCbCr => "YCbCr",
PhotometricInterpretation::CieLab => "CIELab",
}
}
fn layout_overflow(context: &'static str) -> crate::error::Error {
crate::error::Error::InvalidConfig(format!("{context} overflows layout size limits"))
}
#[cfg(test)]
mod tests {
use super::ImageBuilder;
use tiff_core::{
PhotometricInterpretation, PlanarConfiguration, Tag, TagType, TagValue, TAG_IMAGE_WIDTH,
};
#[test]
fn validate_rejects_zero_strip_and_tile_dimensions() {
let err = ImageBuilder::new(16, 16).strips(0).validate().unwrap_err();
assert!(
matches!(err, crate::error::Error::InvalidConfig(message) if message.contains("rows_per_strip"))
);
let err = ImageBuilder::new(16, 16)
.tiles(0, 16)
.validate()
.unwrap_err();
assert!(
matches!(err, crate::error::Error::InvalidConfig(message) if message.contains("tile_width"))
);
let err = ImageBuilder::new(16, 16)
.tiles(16, 0)
.validate()
.unwrap_err();
assert!(
matches!(err, crate::error::Error::InvalidConfig(message) if message.contains("tile_height"))
);
let err = ImageBuilder::new(16, 16)
.tiles(0, 0)
.validate()
.unwrap_err();
assert!(
matches!(err, crate::error::Error::InvalidConfig(message) if message.contains("tile_width") && message.contains("tile_height"))
);
}
#[test]
fn checked_helpers_reject_zero_strip_and_tile_dimensions() {
let builder = ImageBuilder::new(16, 16).strips(0);
let err = builder.checked_block_count().unwrap_err();
assert!(
matches!(err, crate::error::Error::InvalidConfig(message) if message.contains("rows_per_strip"))
);
let err = builder.checked_layout_tags().unwrap_err();
assert!(
matches!(err, crate::error::Error::InvalidConfig(message) if message.contains("rows_per_strip"))
);
let err = builder.checked_build_tags(false).unwrap_err();
assert!(
matches!(err, crate::error::Error::InvalidConfig(message) if message.contains("rows_per_strip"))
);
let builder = ImageBuilder::new(16, 16).tiles(0, 16);
let err = builder.checked_block_count().unwrap_err();
assert!(
matches!(err, crate::error::Error::InvalidConfig(message) if message.contains("tile_width"))
);
let err = builder.checked_layout_tags().unwrap_err();
assert!(
matches!(err, crate::error::Error::InvalidConfig(message) if message.contains("tile_width"))
);
let err = builder.checked_build_tags(false).unwrap_err();
assert!(
matches!(err, crate::error::Error::InvalidConfig(message) if message.contains("tile_width"))
);
let builder = ImageBuilder::new(16, 16).tiles(16, 0);
let err = builder.checked_block_count().unwrap_err();
assert!(
matches!(err, crate::error::Error::InvalidConfig(message) if message.contains("tile_height"))
);
let err = builder.checked_layout_tags().unwrap_err();
assert!(
matches!(err, crate::error::Error::InvalidConfig(message) if message.contains("tile_height"))
);
let err = builder.checked_build_tags(false).unwrap_err();
assert!(
matches!(err, crate::error::Error::InvalidConfig(message) if message.contains("tile_height"))
);
let builder = ImageBuilder::new(16, 16).tiles(15, 16);
let err = builder.checked_layout_tags().unwrap_err();
assert!(
matches!(err, crate::error::Error::InvalidConfig(message) if message.contains("multiples of 16"))
);
}
#[test]
fn checked_build_tags_returns_color_model_errors() {
let err = ImageBuilder::new(16, 16)
.photometric(PhotometricInterpretation::Rgb)
.samples_per_pixel(1)
.checked_build_tags(false)
.unwrap_err();
assert!(
matches!(err, crate::error::Error::InvalidConfig(message) if message.contains("requires at least 3 samples"))
);
}
#[test]
fn checked_helpers_reject_out_of_range_block_indices() {
let builder = ImageBuilder::new(16, 16).sample_type::<u8>().tiles(16, 16);
assert!(matches!(
builder.checked_block_sample_count(1),
Err(crate::error::Error::BlockIndexOutOfRange { index: 1, total: 1 })
));
assert!(matches!(
builder.checked_block_height(1),
Err(crate::error::Error::BlockIndexOutOfRange { index: 1, total: 1 })
));
}
#[test]
fn validation_rejects_conflicting_duplicate_and_incoherent_extra_tags() {
let managed =
ImageBuilder::new(1, 1).tag(Tag::new(TAG_IMAGE_WIDTH, TagValue::Long(vec![2])));
assert!(matches!(
managed.validate(),
Err(crate::error::Error::InvalidConfig(message)) if message.contains("managed")
));
let duplicate = ImageBuilder::new(1, 1)
.tag(Tag::new(65000, TagValue::Short(vec![1])))
.tag(Tag::new(65000, TagValue::Short(vec![2])));
assert!(matches!(
duplicate.validate(),
Err(crate::error::Error::InvalidConfig(message)) if message.contains("more than once")
));
let mut incoherent = Tag::new(65000, TagValue::Short(vec![1]));
incoherent.tag_type = TagType::Long;
let incoherent = ImageBuilder::new(1, 1).tag(incoherent);
assert!(matches!(
incoherent.validate(),
Err(crate::error::Error::InvalidConfig(message)) if message.contains("inconsistent")
));
}
#[test]
fn unsupported_predictor_requests_are_reported_instead_of_ignored() {
let err = ImageBuilder::new(1, 1)
.sample_type::<u8>()
.compression(tiff_core::Compression::Jpeg)
.predictor(tiff_core::Predictor::Horizontal)
.validate()
.unwrap_err();
#[cfg(feature = "jpeg")]
assert!(
matches!(err, crate::error::Error::InvalidConfig(message) if message.contains("JPEG compression does not support"))
);
#[cfg(not(feature = "jpeg"))]
assert!(
matches!(err, crate::error::Error::InvalidConfig(message) if message.contains("not supported"))
);
let err = ImageBuilder::new(1, 1)
.sample_type::<u8>()
.predictor(tiff_core::Predictor::Horizontal)
.validate()
.unwrap_err();
assert!(
matches!(err, crate::error::Error::InvalidConfig(message) if message.contains("require a supported compression"))
);
}
#[test]
fn validation_rejects_unsupported_writer_codecs_before_block_writes() {
for compression in [
tiff_core::Compression::OldJpeg,
tiff_core::Compression::PackBits,
tiff_core::Compression::WebP,
] {
assert!(matches!(
ImageBuilder::new(1, 1)
.sample_type::<u8>()
.compression(compression)
.validate(),
Err(crate::error::Error::InvalidConfig(message))
if message.contains("not supported")
));
}
#[cfg(not(feature = "jpeg"))]
assert!(ImageBuilder::new(1, 1)
.sample_type::<u8>()
.compression(tiff_core::Compression::Jpeg)
.validate()
.is_err());
#[cfg(not(feature = "zstd"))]
assert!(ImageBuilder::new(1, 1)
.sample_type::<u8>()
.compression(tiff_core::Compression::Zstd)
.validate()
.is_err());
}
#[test]
fn validate_rejects_mismatched_predictor_and_sample_format() {
let err = ImageBuilder::new(4, 4)
.sample_type::<f32>()
.compression(tiff_core::Compression::Deflate)
.predictor(tiff_core::Predictor::Horizontal)
.validate()
.unwrap_err();
assert!(
matches!(err, crate::error::Error::InvalidConfig(message) if message.contains("horizontal predictor"))
);
let err = ImageBuilder::new(4, 4)
.sample_type::<u16>()
.compression(tiff_core::Compression::Deflate)
.predictor(tiff_core::Predictor::FloatingPoint)
.validate()
.unwrap_err();
assert!(
matches!(err, crate::error::Error::InvalidConfig(message) if message.contains("floating-point predictor"))
);
let f16_builder = ImageBuilder::new(4, 4)
.bits_per_sample(16)
.sample_format(tiff_core::SampleFormat::Float);
#[cfg(not(feature = "f16"))]
{
let err = f16_builder.validate().unwrap_err();
assert!(
matches!(err, crate::error::Error::InvalidConfig(message) if message.contains("32 or 64 bits"))
);
}
#[cfg(feature = "f16")]
assert!(f16_builder.validate().is_ok());
assert!(ImageBuilder::new(4, 4)
.sample_type::<u16>()
.compression(tiff_core::Compression::Deflate)
.predictor(tiff_core::Predictor::Horizontal)
.validate()
.is_ok());
assert!(ImageBuilder::new(4, 4)
.sample_type::<f32>()
.compression(tiff_core::Compression::Deflate)
.predictor(tiff_core::Predictor::FloatingPoint)
.validate()
.is_ok());
}
#[cfg(feature = "f16")]
#[test]
fn validate_rejects_lerc_with_f16_samples() {
let err = ImageBuilder::new(4, 4)
.sample_type::<half::f16>()
.compression(tiff_core::Compression::Lerc)
.validate()
.unwrap_err();
assert!(
matches!(err, crate::error::Error::InvalidConfig(message) if message.contains("LERC compression does not support 16-bit float samples"))
);
}
#[test]
fn validate_rejects_overflowing_layout_sizes() {
let err = ImageBuilder::new(u32::MAX, u32::MAX)
.sample_type::<u8>()
.samples_per_pixel(u16::MAX)
.planar_configuration(PlanarConfiguration::Planar)
.tiles(16, 16)
.validate()
.unwrap_err();
assert!(
matches!(err, crate::error::Error::InvalidConfig(message) if message.contains("block count"))
);
let large_multiple_of_16 = u32::MAX - 15;
let err = ImageBuilder::new(1, 1)
.sample_type::<u8>()
.samples_per_pixel(2)
.tiles(large_multiple_of_16, large_multiple_of_16)
.validate()
.unwrap_err();
assert!(
matches!(err, crate::error::Error::InvalidConfig(message) if message.contains("sample count"))
);
let err = ImageBuilder::new(u32::MAX, u32::MAX)
.sample_type::<u64>()
.samples_per_pixel(2)
.strips(256)
.validate()
.unwrap_err();
assert!(
matches!(err, crate::error::Error::InvalidConfig(message) if message.contains("byte count"))
);
}
}