M4.67: TIFF export in RGB, CMYK or grayscale, with LZW, either byte order and the profile

document.export {format: "tiff"} writes baseline TIFF through the raster
export path (resolution, background, anti-aliasing, artboard ranges):
colorModel rgb (transparency as unassociated alpha), cmyk (ink amounts,
CMYK colours keep their inks) or gray, lzw (weezl), byteOrder little (II)
or big (MM), embedIcc, and the resolution tags. Export As offers it with
the tiffOptions dialog; TIFF moves from read-only to read and write.

The TIFF writer is shared: EPS previews now use it (and its PackBits)
instead of their own copy, so eps depends on render (layers allow it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-05 15:24:29 +07:00
co-authored by Claude Opus 5.5
parent 961ca75a28
commit d01b2fbd8a
22 changed files with 904 additions and 178 deletions
+9 -5
View File
@@ -56,7 +56,7 @@ impl ColorModel {
}
/// The profile that describes these pixels.
fn profile(self) -> String {
pub(crate) fn profile(self) -> String {
match self {
ColorModel::Rgb => cms::SRGB.into(),
ColorModel::Cmyk => cms::active_settings().cmyk,
@@ -169,13 +169,17 @@ pub(crate) fn screen_pixels(img: &Rendered, gray: bool) -> Vec<u8> {
if !gray {
return rgb.flatten().collect();
}
rgb.map(to_gray()).collect()
}
/// A colour's grey: its relative luminance (sRGB primaries) encoded with sRGB's curve, so a grey
/// keeps its value.
pub(crate) fn to_gray() -> impl Fn([u8; 3]) -> u8 {
let linear: [f32; 256] = std::array::from_fn(|v| lab::srgb_to_linear(v as f32 / 255.0));
// Relative luminance (sRGB primaries), encoded with sRGB's curve: a grey keeps its value.
rgb.map(|[r, g, b]| {
move |[r, g, b]| {
let y = 0.2126 * linear[r as usize] + 0.7152 * linear[g as usize] + 0.0722 * linear[b as usize];
(lab::linear_to_srgb(y).clamp(0.0, 1.0) * 255.0).round() as u8
})
.collect()
}
}
/// `img` flattened on white with each colour separated into the working CMYK space (ink amounts).
+32 -5
View File
@@ -1,12 +1,14 @@
//! Raster export: render a document region with [`RasterExportOptions`] (resolution, background,
//! anti-aliasing) and encode it as PNG ([`png`]: resolution in `pHYs`, Adam7 interlacing), JPEG
//! ([`jpeg`]: RGB, CMYK or grey, progressive, resolution and colour profile), lossless WebP, or
//! a palette image ([`quantize`]) as PNG-8 or [`gif`].
//! a palette image ([`quantize`]) as PNG-8 or [`gif`], or [`tiff`] (RGB, CMYK or grey, LZW, either
//! byte order, the profile).
pub mod gif;
pub mod jpeg;
pub mod png;
pub mod quantize;
pub mod tiff;
use vectorcraft_doc::{Document, Node, NodeKind};
use vectorcraft_geom::Rect;
@@ -22,6 +24,7 @@ pub enum RasterFormat {
/// Indexed PNG: a palette of up to 256 colours.
Png8,
Gif,
Tiff,
}
/// How a raster export renders and encodes.
@@ -40,6 +43,8 @@ pub struct RasterExportOptions {
pub jpeg: jpeg::JpegOptions,
/// PNG-8 and GIF: the colour reduction.
pub palette: quantize::PaletteOptions,
/// TIFF colour model, compression, byte order and profile.
pub tiff: tiff::TiffOptions,
}
impl Default for RasterExportOptions {
@@ -52,6 +57,7 @@ impl Default for RasterExportOptions {
quality: 90,
jpeg: jpeg::JpegOptions::default(),
palette: quantize::PaletteOptions::default(),
tiff: tiff::TiffOptions::default(),
}
}
}
@@ -96,19 +102,32 @@ impl RasterExportOptions {
_ => png::encode_indexed(&ix, &png::PngOptions { ppi: Some(self.ppi), interlaced: self.interlaced }),
}
}
RasterFormat::Tiff => tiff::encode(img, self.ppi, &self.tiff),
}
}
/// Does `format` write ink amounts (a CMYK JPEG or TIFF)?
fn cmyk(&self, format: RasterFormat) -> bool {
match format {
RasterFormat::Jpeg => self.jpeg.color_model == jpeg::ColorModel::Cmyk,
RasterFormat::Tiff => self.tiff.color_model == jpeg::ColorModel::Cmyk,
_ => false,
}
}
}
impl Renderer {
/// Render `region` of `doc` (an artboard or any rect) as exported and encode it as `format`.
/// Callers check the size first ([`crate::raster_size`]). A CMYK JPEG is drawn as ink amounts
/// ([`Renderer::render_region_inks`]).
/// Callers check the size first ([`crate::raster_size`]). A CMYK JPEG or TIFF is drawn as ink
/// amounts ([`Renderer::render_region_inks`]).
pub fn export_region(&mut self, doc: &Document, region: Rect, format: RasterFormat, opts: &RasterExportOptions) -> Result<Vec<u8>, String> {
if format == RasterFormat::Jpeg && opts.jpeg.color_model == jpeg::ColorModel::Cmyk {
if opts.cmyk(format) {
let (w, h) = crate::region_pixels(region, opts.scale());
let inks = self.render_region_inks(doc, region, opts.scale(), &opts.render_options(format));
return jpeg::encode(&inks, w, h, opts.quality, Some(opts.ppi), &opts.jpeg);
return match format {
RasterFormat::Tiff => tiff::encode_samples(&inks, w, h, opts.ppi, &opts.tiff),
_ => jpeg::encode(&inks, w, h, opts.quality, Some(opts.ppi), &opts.jpeg),
};
}
let img = self.render_region_with(doc, region, opts.scale(), &opts.render_options(format));
opts.encode(&img, format)
@@ -122,6 +141,12 @@ pub(crate) fn jpeg(img: &Rendered, quality: u8, ppi: Option<f64>) -> Result<Vec<
jpeg::encode(&jpeg::screen_pixels(img, false), img.width, img.height, quality, ppi, &o)
}
/// A straight-alpha pixel composited over white.
pub fn on_white(p: &[u8; 4]) -> [u8; 3] {
let a = u32::from(p[3]);
[0, 1, 2].map(|i| ((u32::from(p[i]) * a + 255 * (255 - a) + 127) / 255) as u8)
}
/// Lossless WebP.
pub(crate) fn webp(img: &Rendered) -> Result<Vec<u8>, String> {
let mut buf = Vec::new();
@@ -157,3 +182,5 @@ mod tests;
mod tests_jpeg;
#[cfg(test)]
mod tests_palette;
#[cfg(test)]
mod tests_tiff;
+184
View File
@@ -0,0 +1,184 @@
//! TIFF export: byte order, LZW, colour models and samples, resolution, profile, strips.
use std::io::Cursor;
use tiff::decoder::{Decoder, DecodingResult};
use tiff::tags::Tag;
use super::jpeg::ColorModel;
use super::tiff::{self as t, ByteOrder, Compression, Image, Layout, Photometric, TiffOptions};
fn decoder(file: &[u8]) -> Decoder<Cursor<&[u8]>> {
Decoder::new(Cursor::new(file)).expect("a TIFF")
}
fn pixels(file: &[u8]) -> Vec<u8> {
match decoder(file).read_image().expect("decodes") {
DecodingResult::U8(v) => v,
_ => panic!("8-bit samples"),
}
}
/// Straight RGBA: flat areas (as art is) with a transparent corner when `clear`.
fn art(w: u32, h: u32, clear: bool) -> Vec<u8> {
(0..w * h)
.flat_map(|i| {
let (x, y) = (i % w, i / w);
match (x * 3 / w, y * 2 / h) {
(0, 0) if clear => [0, 0, 0, 0],
(0, _) => [200, 30, 40, 255],
(1, _) => [20, 120, 220, 255],
_ => [255, 255, 255, 255],
}
})
.collect()
}
fn opts(model: ColorModel) -> TiffOptions {
TiffOptions { color_model: model, embed_icc: false, ..TiffOptions::default() }
}
#[test]
fn byte_order_writes_an_ii_or_mm_header() {
let px = art(20, 10, false);
for (order, magic) in [(ByteOrder::Little, b"II*\0"), (ByteOrder::Big, b"MM\0*")] {
let file = t::encode_samples(&px, 20, 10, 72.0, &TiffOptions { byte_order: order, ..opts(ColorModel::Rgb) }).unwrap();
assert_eq!(&file[..4], magic);
let rgb: Vec<u8> = px.chunks(4).flat_map(|p| &p[..3]).copied().collect();
assert_eq!(pixels(&file), rgb, "{order:?}");
assert_eq!(decoder(&file).get_tag_ascii_string(Tag::Software).unwrap(), "VectorCraft");
}
for (s, o) in [("little", ByteOrder::Little), ("MM", ByteOrder::Big), ("mac", ByteOrder::Big), ("ii", ByteOrder::Little), ("big", ByteOrder::Big)]
{
assert_eq!(ByteOrder::from_id(s), Some(o), "{s}");
}
assert_eq!(ByteOrder::from_id("middle"), None);
}
#[test]
fn lzw_is_smaller_and_decodes_the_same() {
let px = art(120, 90, true);
let plain = t::encode_samples(&px, 120, 90, 72.0, &TiffOptions { lzw: false, ..opts(ColorModel::Rgb) }).unwrap();
let lzw = t::encode_samples(&px, 120, 90, 72.0, &opts(ColorModel::Rgb)).unwrap();
assert!(lzw.len() * 4 < plain.len(), "{} vs {}", lzw.len(), plain.len());
assert_eq!(decoder(&lzw).get_tag_unsigned::<u16>(Tag::Compression).unwrap(), 5);
assert_eq!(decoder(&plain).get_tag_unsigned::<u16>(Tag::Compression).unwrap(), 1);
assert_eq!(pixels(&lzw), px, "transparency kept as an alpha channel");
assert_eq!(pixels(&plain), px);
// The image crate reads it too.
let img = image::load_from_memory_with_format(&lzw, image::ImageFormat::Tiff).unwrap().to_rgba8();
assert_eq!(img.into_raw(), px);
}
#[test]
fn colour_models_write_their_samples() {
let px = art(9, 6, true);
let rgba = t::encode_samples(&px, 9, 6, 72.0, &opts(ColorModel::Rgb)).unwrap();
assert_eq!(decoder(&rgba).colortype().unwrap(), tiff::ColorType::RGBA(8));
assert_eq!(decoder(&rgba).get_tag_u16_vec(Tag::ExtraSamples).unwrap(), [2], "unassociated alpha");
let opaque = art(9, 6, false);
let rgb = t::encode_samples(&opaque, 9, 6, 72.0, &opts(ColorModel::Rgb)).unwrap();
assert_eq!(decoder(&rgb).colortype().unwrap(), tiff::ColorType::RGB(8));
assert!(decoder(&rgb).find_tag(Tag::ExtraSamples).unwrap().is_none());
let gray = t::encode_samples(&opaque, 9, 6, 72.0, &opts(ColorModel::Gray)).unwrap();
assert_eq!(decoder(&gray).colortype().unwrap(), tiff::ColorType::Gray(8));
assert_eq!(decoder(&gray).get_tag_unsigned::<u16>(Tag::PhotometricInterpretation).unwrap(), 1, "black is zero");
let g = pixels(&gray);
assert_eq!((g[0], g[8]), (super::jpeg::to_gray()([200, 30, 40]), 255), "lightness; white stays white");
let on_white = t::encode_samples(&px, 9, 6, 72.0, &opts(ColorModel::Gray)).unwrap();
assert_eq!(decoder(&on_white).colortype().unwrap(), tiff::ColorType::Gray(8), "grey has no alpha: few readers take it");
assert_eq!(pixels(&on_white)[0], 255, "the transparent corner is white");
// CMYK: four samples a pixel, ink amounts as given.
let inks: Vec<u8> = (0..9 * 6 * 4).map(|i| (i * 7 % 256) as u8).collect();
let cmyk = t::encode_samples(&inks, 9, 6, 72.0, &opts(ColorModel::Cmyk)).unwrap();
let mut d = decoder(&cmyk);
assert_eq!(d.colortype().unwrap(), tiff::ColorType::CMYK(8));
assert_eq!(d.get_tag_unsigned::<u16>(Tag::SamplesPerPixel).unwrap(), 4);
assert_eq!(d.get_tag_unsigned::<u16>(Tag::PhotometricInterpretation).unwrap(), 5, "separated");
assert_eq!(d.get_tag_unsigned::<u16>(Tag::Unknown(332)).unwrap(), 1, "CMYK inks");
assert_eq!(pixels(&cmyk), inks);
}
#[test]
fn resolution_tags_and_the_profile() {
let px = art(4, 4, false);
for (ppi, rational) in [(300.0, (300, 1)), (72.5, (7250, 100))] {
let file = t::encode_samples(&px, 4, 4, ppi, &opts(ColorModel::Rgb)).unwrap();
let mut d = decoder(&file);
for tag in [Tag::XResolution, Tag::YResolution] {
assert_eq!(d.get_tag(tag).unwrap(), tiff::decoder::ifd::Value::Rational(rational.0, rational.1));
}
assert_eq!(d.get_tag_unsigned::<u16>(Tag::ResolutionUnit).unwrap(), 2, "inches");
}
for model in ColorModel::ALL {
let src = if model == ColorModel::Cmyk { vec![0; 64] } else { px.clone() };
let file = t::encode_samples(&src, 4, 4, 72.0, &TiffOptions { embed_icc: true, ..opts(model) }).unwrap();
let icc = decoder(&file).get_tag_u8_vec(Tag::IccProfile).unwrap();
assert_eq!(&icc[36..40], b"acsp", "{model:?}: an ICC profile");
let space: &[u8; 4] = match model {
ColorModel::Rgb => b"RGB ",
ColorModel::Cmyk => b"CMYK",
ColorModel::Gray => b"GRAY",
};
assert_eq!(&icc[16..20], space, "{model:?}");
}
let file = t::encode_samples(&px, 4, 4, 72.0, &opts(ColorModel::Rgb)).unwrap();
assert!(decoder(&file).find_tag(Tag::IccProfile).unwrap().is_none());
}
#[test]
fn big_images_take_several_strips_in_either_order() {
let (w, h) = (300, 100);
let px = art(w, h, false);
for order in ByteOrder::ALL {
for lzw in [false, true] {
let file = t::encode_samples(&px, w, h, 72.0, &TiffOptions { lzw, byte_order: order, ..opts(ColorModel::Gray) }).unwrap();
let mut d = decoder(&file);
let offsets = d.get_tag_u32_vec(Tag::StripOffsets).unwrap();
// 300 bytes a row: 27 rows a strip.
assert_eq!(d.get_tag_u32(Tag::RowsPerStrip).unwrap(), 27);
assert_eq!(offsets.len(), 4);
assert!(offsets.iter().all(|o| o % 2 == 0), "strips start on a word");
assert_eq!(pixels(&file).len(), (w * h) as usize, "{order:?} lzw {lzw}");
}
}
}
#[test]
fn packbits_and_bilevel_images_decode() {
// 10 × 3 at one bit a pixel: two bytes a row, WhiteIsZero.
let rows = [0b1010_1010u8, 0b1100_0000, 0xFF, 0xC0, 0, 0];
let image = Image { width: 10, height: 3, data: &rows, photometric: Photometric::WhiteIsZero, samples: 1, bits: 1, alpha: false };
let file = t::write(&image, &Layout { byte_order: ByteOrder::Big, compression: Compression::PackBits, ppi: 72.0, icc: None }).unwrap();
let mut d = decoder(&file);
assert_eq!(d.colortype().unwrap(), tiff::ColorType::Gray(1));
assert_eq!(d.get_tag_unsigned::<u16>(Tag::Compression).unwrap(), 32773);
// The decoder turns WhiteIsZero into black-is-zero.
assert_eq!(pixels(&file), rows.map(|b| !b));
// A buffer that doesn't match the size is refused, not written.
let short = Image { data: &rows[..5], ..image };
assert!(t::write(&short, &Layout { byte_order: ByteOrder::Little, compression: Compression::None, ppi: 72.0, icc: None }).is_err());
}
#[test]
fn packbits_round_trips() {
for data in [vec![], vec![7], vec![1, 1, 1, 1, 2, 3, 3, 4, 4, 4], (0..400).map(|i| (i / 3 % 7) as u8).collect(), vec![9; 300]] {
let mut packed = vec![];
t::packbits(&data, &mut packed);
let mut out = vec![];
let mut i = 0;
while i < packed.len() {
let n = packed[i] as i8;
if n >= 0 {
out.extend(&packed[i + 1..i + 2 + n as usize]);
i += 2 + n as usize;
} else {
out.extend(std::iter::repeat_n(packed[i + 1], (1 - n as i32) as usize));
i += 2;
}
}
assert_eq!(out, data);
}
}
+365
View File
@@ -0,0 +1,365 @@
//! TIFF export (baseline TIFF 6.0): little-endian (`II`) or big-endian (`MM`), strips of about
//! 8 KiB stored as they are, LZW-compressed (weezl, with TIFF's early code-size switch) or
//! PackBits; black and white, grey, RGB or CMYK (separated, ink amounts) samples with an optional
//! unassociated alpha channel, the resolution and an embedded colour profile. [`write`] is the one
//! TIFF writer: raster exports ([`encode`]) and EPS previews both use it.
use std::borrow::Cow;
use vectorcraft_color::cms;
use super::jpeg::{self, ColorModel};
use super::on_white;
use crate::Rendered;
/// The order of the bytes in the file's numbers (the samples are bytes either way).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ByteOrder {
/// Least significant byte first (`II`).
#[default]
Little,
/// Most significant byte first (`MM`).
Big,
}
impl ByteOrder {
pub const ALL: [ByteOrder; 2] = [ByteOrder::Little, ByteOrder::Big];
pub fn id(self) -> &'static str {
match self {
ByteOrder::Little => "little",
ByteOrder::Big => "big",
}
}
pub fn label(self) -> &'static str {
match self {
ByteOrder::Little => "Little-endian (II)",
ByteOrder::Big => "Big-endian (MM)",
}
}
/// By id or label, ignoring case; also by the file's marker (`ii`, `mm`) and the platforms the
/// orders are known by (`ibm`, `mac`).
pub fn from_id(s: &str) -> Option<Self> {
let s = s.to_ascii_lowercase();
match s.as_str() {
"ii" | "ibm" | "pc" | "intel" => Some(ByteOrder::Little),
"mm" | "mac" | "motorola" => Some(ByteOrder::Big),
_ => Self::ALL.into_iter().find(|o| o.id() == s || o.label().eq_ignore_ascii_case(&s)),
}
}
fn u16(self, v: u16) -> [u8; 2] {
match self {
ByteOrder::Little => v.to_le_bytes(),
ByteOrder::Big => v.to_be_bytes(),
}
}
fn u32(self, v: u32) -> [u8; 4] {
match self {
ByteOrder::Little => v.to_le_bytes(),
ByteOrder::Big => v.to_be_bytes(),
}
}
}
/// How the strips are compressed.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Compression {
None,
Lzw,
/// Run lengths, each row on its own.
PackBits,
}
impl Compression {
fn code(self) -> u16 {
match self {
Compression::None => 1,
Compression::Lzw => 5,
Compression::PackBits => 32773,
}
}
}
/// What the samples mean.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Photometric {
/// Grey or bilevel, 0 is white (a set bit is black).
WhiteIsZero,
/// Grey, 0 is black.
BlackIsZero,
Rgb,
/// Ink amounts, 0 is no ink (CMYK).
Separated,
}
impl Photometric {
fn code(self) -> u16 {
match self {
Photometric::WhiteIsZero => 0,
Photometric::BlackIsZero => 1,
Photometric::Rgb => 2,
Photometric::Separated => 5,
}
}
}
/// The pixels [`write`] stores.
pub struct Image<'a> {
pub width: u32,
pub height: u32,
/// Rows of samples, `bits` per sample, each row starting on a byte.
pub data: &'a [u8],
pub photometric: Photometric,
/// Samples per pixel, the alpha one included.
pub samples: u16,
/// Bits per sample: 1 or 8.
pub bits: u16,
/// The last sample is unassociated alpha.
pub alpha: bool,
}
/// How [`write`] stores the pixels and what else goes in the file.
pub struct Layout<'a> {
pub byte_order: ByteOrder,
pub compression: Compression,
/// Pixels per inch.
pub ppi: f64,
/// A colour profile to embed.
pub icc: Option<&'a [u8]>,
}
/// Uncompressed bytes per strip (the size the TIFF specification recommends).
const STRIP_BYTES: usize = 8 << 10;
/// A field's values.
enum Values {
Ascii(Vec<u8>),
Short(Vec<u16>),
Long(Vec<u32>),
Rational(u32, u32),
Undefined(Vec<u8>),
}
impl Values {
/// Field type and count.
fn kind(&self) -> (u16, usize) {
match self {
Values::Ascii(v) => (2, v.len()),
Values::Short(v) => (3, v.len()),
Values::Long(v) => (4, v.len()),
Values::Rational(..) => (5, 1),
Values::Undefined(v) => (7, v.len()),
}
}
/// Size in bytes.
fn len(&self) -> usize {
match self {
Values::Ascii(v) | Values::Undefined(v) => v.len(),
Values::Short(v) => v.len() * 2,
Values::Long(v) => v.len() * 4,
Values::Rational(..) => 8,
}
}
fn bytes(&self, o: ByteOrder) -> Vec<u8> {
match self {
Values::Ascii(v) | Values::Undefined(v) => v.clone(),
Values::Short(v) => v.iter().flat_map(|x| o.u16(*x)).collect(),
Values::Long(v) => v.iter().flat_map(|x| o.u32(*x)).collect(),
Values::Rational(n, d) => [o.u32(*n), o.u32(*d)].concat(),
}
}
}
/// Write `img` as a TIFF file: header, the image file directory, the values too long for it, then
/// the strips.
pub fn write(img: &Image, l: &Layout) -> Result<Vec<u8>, String> {
let too_large = || "the image is too large for TIFF (at most 4 GB): lower the resolution".to_string();
let (w, h) = (img.width as usize, img.height as usize);
let row = (w * usize::from(img.samples) * usize::from(img.bits)).div_ceil(8);
if w == 0 || h == 0 || !matches!(img.bits, 1 | 8) || img.samples == 0 || row.checked_mul(h) != Some(img.data.len()) {
return Err("TIFF encoding failed: the pixel buffer doesn't match the image size".into());
}
let rows_per_strip = (STRIP_BYTES / row).clamp(1, h);
let mut strips: Vec<u8> = Vec::with_capacity(img.data.len() / 2);
let mut offsets = vec![];
for strip in img.data.chunks(rows_per_strip * row) {
offsets.push(strips.len());
match l.compression {
Compression::None => strips.extend_from_slice(strip),
Compression::Lzw => {
let lzw = weezl::encode::Encoder::with_tiff_size_switch(weezl::BitOrder::Msb, 8)
.encode(strip)
.map_err(|e| format!("TIFF encoding failed: {e}"))?;
strips.extend(lzw);
}
Compression::PackBits => strip.chunks(row).for_each(|r| packbits(r, &mut strips)),
}
// Strips start on a word boundary.
if strips.len() % 2 == 1 {
strips.push(0);
}
}
let counts: Vec<usize> = offsets.iter().zip(offsets.iter().skip(1).chain([&strips.len()])).map(|(a, b)| b - a).collect();
let mut fields: Vec<(u16, Values)> = vec![
(256, Values::Long(vec![img.width])),
(257, Values::Long(vec![img.height])),
(258, Values::Short(vec![img.bits; usize::from(img.samples)])),
(259, Values::Short(vec![l.compression.code()])),
(262, Values::Short(vec![img.photometric.code()])),
// StripOffsets: filled in once the directory's size is known.
(273, Values::Long(vec![0; offsets.len()])),
(277, Values::Short(vec![img.samples])),
(278, Values::Long(vec![rows_per_strip as u32])),
(279, Values::Long(counts.iter().map(|c| u32::try_from(*c).unwrap_or(u32::MAX)).collect())),
(282, resolution(l.ppi)),
(283, resolution(l.ppi)),
// Chunky: the samples of a pixel together.
(284, Values::Short(vec![1])),
// Inches.
(296, Values::Short(vec![2])),
(305, Values::Ascii(b"VectorCraft\0".to_vec())),
];
if img.photometric == Photometric::Separated {
// InkSet: CMYK.
fields.push((332, Values::Short(vec![1])));
}
if img.alpha {
// Unassociated alpha.
fields.push((338, Values::Short(vec![2])));
}
if let Some(icc) = l.icc {
fields.push((34675, Values::Undefined(icc.to_vec())));
}
// Header, directory (count, 12-byte entries, next directory), longer values, strips.
let dir_len = 2 + fields.len() * 12 + 4;
let extra_len: usize = fields.iter().map(|(_, v)| v.len()).filter(|n| *n > 4).map(|n| n.next_multiple_of(2)).sum();
let strip_at = 8 + dir_len + extra_len;
let end = strip_at.checked_add(strips.len()).filter(|n| u32::try_from(*n).is_ok()).ok_or_else(too_large)?;
if let Some((_, Values::Long(v))) = fields.iter_mut().find(|(tag, _)| *tag == 273) {
*v = offsets.iter().map(|o| (strip_at + o) as u32).collect();
}
let o = l.byte_order;
let mut out = Vec::with_capacity(end);
out.extend_from_slice(if o == ByteOrder::Little { b"II" } else { b"MM" });
out.extend(o.u16(42));
out.extend(o.u32(8));
out.extend(o.u16(fields.len() as u16));
let mut extra = Vec::with_capacity(extra_len);
for (tag, v) in &fields {
let (ty, count) = v.kind();
let mut bytes = v.bytes(o);
out.extend(o.u16(*tag));
out.extend(o.u16(ty));
out.extend(o.u32(u32::try_from(count).map_err(|_| too_large())?));
if bytes.len() <= 4 {
// Left-justified in the value field.
bytes.resize(4, 0);
out.extend(bytes);
} else {
out.extend(o.u32((8 + dir_len + extra.len()) as u32));
extra.extend(&bytes);
if bytes.len() % 2 == 1 {
extra.push(0);
}
}
}
out.extend(o.u32(0));
out.extend(extra);
out.extend(strips);
Ok(out)
}
/// A resolution as a rational, to a hundredth of a pixel per inch.
fn resolution(ppi: f64) -> Values {
let ppi = if ppi.is_finite() && ppi > 0.0 { ppi } else { 72.0 };
if ppi.fract() == 0.0 && ppi <= u32::MAX as f64 {
return Values::Rational(ppi as u32, 1);
}
Values::Rational((ppi * 100.0).round().clamp(1.0, u32::MAX as f64) as u32, 100)
}
/// `data` run-length encoded (PackBits: what TIFF's compression 32773 and PostScript's
/// `RunLengthDecode` read), without an end marker.
pub fn packbits(data: &[u8], out: &mut Vec<u8>) {
let mut i = 0;
while i < data.len() {
let Some(&b) = data.get(i) else { break };
let run = data.get(i..).map_or(1, |rest| rest.iter().take(128).take_while(|x| **x == b).count());
if run >= 3 {
out.push((257 - run) as u8);
out.push(b);
i += run;
continue;
}
// Literal bytes up to the next run of three.
let mut j = i;
while j < data.len() && j - i < 128 {
let three = data.get(j..j + 3).is_some_and(|w| w[0] == w[1] && w[1] == w[2]);
if three {
break;
}
j += 1;
}
let j = j.max(i + 1).min(data.len());
out.push((j - i - 1) as u8);
out.extend(data.get(i..j).unwrap_or_default());
i = j;
}
}
/// The TIFF options of a raster export.
#[derive(Clone, Debug, PartialEq)]
pub struct TiffOptions {
pub color_model: ColorModel,
pub lzw: bool,
pub byte_order: ByteOrder,
/// Embed the colour profile of the colour model.
pub embed_icc: bool,
}
impl Default for TiffOptions {
fn default() -> Self {
Self { color_model: ColorModel::Rgb, lzw: true, byte_order: ByteOrder::Little, embed_icc: true }
}
}
/// `img` as a TIFF in `o.color_model`: RGB, with an alpha channel when some pixel isn't opaque,
/// grey on white (few readers take grey with alpha), or CMYK (its colours separated on white;
/// [`crate::Renderer::export_region`] draws inks).
pub fn encode(img: &Rendered, ppi: f64, o: &TiffOptions) -> Result<Vec<u8>, String> {
let px = match o.color_model {
ColorModel::Cmyk => jpeg::separated(img),
ColorModel::Rgb | ColorModel::Gray => img.to_straight(),
};
encode_samples(&px, img.width, img.height, ppi, o)
}
/// Pixels as a TIFF in `o.color_model` (see [`encode`]): straight RGBA for RGB and grey, ink
/// amounts for CMYK.
pub(crate) fn encode_samples(px: &[u8], width: u32, height: u32, ppi: f64, o: &TiffOptions) -> Result<Vec<u8>, String> {
let model = o.color_model;
let (data, photometric, alpha) = match model {
ColorModel::Cmyk => (Cow::Borrowed(px), Photometric::Separated, false),
ColorModel::Rgb => {
let rgba = px.as_chunks::<4>().0;
let alpha = rgba.iter().any(|p| p[3] < 255);
let data = if alpha { Cow::Borrowed(px) } else { rgba.iter().flat_map(|p| [p[0], p[1], p[2]]).collect() };
(data, Photometric::Rgb, alpha)
}
ColorModel::Gray => {
let gray = jpeg::to_gray();
(px.as_chunks::<4>().0.iter().map(|p| gray(on_white(p))).collect(), Photometric::BlackIsZero, false)
}
};
let icc = if o.embed_icc { Some(cms::icc_bytes(&model.profile()).map_err(|e| format!("TIFF colour profile: {e}"))?) } else { None };
let samples = (model.channels() + usize::from(alpha)) as u16;
let image = Image { width, height, data: &data, photometric, samples, bits: 8, alpha };
let compression = if o.lzw { Compression::Lzw } else { Compression::None };
write(&image, &Layout { byte_order: o.byte_order, compression, ppi, icc: icc.as_deref() })
}