mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-29 20:27:05 -06:00
Colour, greyscale and monochrome (black-and-white) images placed above the threshold resolution are resampled to the target (average, subsample or bicubic) and compressed with ZIP, JPEG at the chosen quality, or automatically (JPEGs stay JPEG, the others lossless); images already as asked are embedded unchanged. JPEG 2000, CCITT, Run Length and no compression are written as ZIP with a warning when an image needs them; images with transparency stay lossless. Images no longer ask viewers to smooth them in PDF/A files. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
225 lines
9.3 KiB
Rust
225 lines
9.3 KiB
Rust
//! Images in the PDF: the document's images as the Compression settings say (resampled above a
|
||
//! resolution, compressed with ZIP or JPEG), and pixels the writer makes itself (freeform
|
||
//! gradients).
|
||
//!
|
||
//! Every image is classified as the settings are split: colour, greyscale (a grey colour type) or
|
||
//! monochrome (greyscale that is only black and white). An image already as asked (not resampled,
|
||
//! a JPEG kept as JPEG or another kept lossless) is embedded as it is; the others are decoded,
|
||
//! resampled and encoded again. JPEG needs opaque pixels: images with transparency stay lossless.
|
||
|
||
use std::io::Cursor;
|
||
use std::sync::Arc;
|
||
|
||
use image::imageops::FilterType;
|
||
use image::{DynamicImage, ExtendedColorType, ImageDecoder, ImageFormat, ImageReader};
|
||
use krilla::image::{BitsPerComponent, CustomImage, Image, ImageColorspace};
|
||
|
||
use crate::{CompressionSettings, Downsample, ImageCodec, JpegQuality, MonoCodec};
|
||
|
||
/// Raw 8-bit pixels: colour samples (RGB, or grey with `luma`) and an optional alpha channel.
|
||
#[derive(Clone, Hash)]
|
||
struct Raw {
|
||
color: Arc<Vec<u8>>,
|
||
alpha: Option<Arc<Vec<u8>>>,
|
||
width: u32,
|
||
height: u32,
|
||
luma: bool,
|
||
}
|
||
|
||
impl CustomImage for Raw {
|
||
fn color_channel(&self) -> &[u8] {
|
||
&self.color
|
||
}
|
||
|
||
fn alpha_channel(&self) -> Option<&[u8]> {
|
||
self.alpha.as_deref().map(Vec::as_slice)
|
||
}
|
||
|
||
fn bits_per_component(&self) -> BitsPerComponent {
|
||
BitsPerComponent::Eight
|
||
}
|
||
|
||
fn size(&self) -> (u32, u32) {
|
||
(self.width, self.height)
|
||
}
|
||
|
||
fn icc_profile(&self) -> Option<&[u8]> {
|
||
None
|
||
}
|
||
|
||
fn color_space(&self) -> ImageColorspace {
|
||
if self.luma { ImageColorspace::Luma } else { ImageColorspace::Rgb }
|
||
}
|
||
}
|
||
|
||
/// A `width` × `height` image of straight (not premultiplied) RGBA pixels, written as RGB, or as
|
||
/// grey when every pixel is grey, with an alpha channel only when some pixel isn't opaque.
|
||
/// `interpolate` asks viewers to smooth it when enlarged (PDF/A forbids it). `None` when `rgba`
|
||
/// doesn't hold that many pixels.
|
||
pub(crate) fn from_rgba(rgba: &[u8], width: u32, height: u32, interpolate: bool) -> Option<Image> {
|
||
let px = rgba.as_chunks::<4>().0;
|
||
if px.len() as u64 != width as u64 * height as u64 || px.is_empty() {
|
||
return None;
|
||
}
|
||
let luma = px.iter().all(|p| p[0] == p[1] && p[1] == p[2]);
|
||
let color = if luma { px.iter().map(|p| p[0]).collect() } else { px.iter().flat_map(|p| [p[0], p[1], p[2]]).collect() };
|
||
let alpha = px.iter().any(|p| p[3] < 255).then(|| Arc::new(px.iter().map(|p| p[3]).collect()));
|
||
Image::from_custom(Raw { color: Arc::new(color), alpha, width, height, luma }, interpolate).ok()
|
||
}
|
||
|
||
/// The kinds of images the Compression section has settings for.
|
||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||
enum Kind {
|
||
Color,
|
||
Gray,
|
||
Mono,
|
||
}
|
||
|
||
/// How an image's pixels are compressed in the file.
|
||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||
enum Codec {
|
||
/// JPEG images stay JPEG (at this quality when they are resampled); the others are lossless.
|
||
Auto(u8),
|
||
Zip,
|
||
Jpeg(u8),
|
||
}
|
||
|
||
/// The settings for one kind of image.
|
||
struct Rule {
|
||
downsample: Downsample,
|
||
/// Target resolution, and the resolution above which images are resampled (ppi).
|
||
ppi: f64,
|
||
above: f64,
|
||
codec: Codec,
|
||
/// Why the codec differs from the one asked for (that one isn't available).
|
||
instead: Option<&'static str>,
|
||
}
|
||
|
||
const UNCOMPRESSED: &str = "uncompressed images are not written: images are compressed with ZIP";
|
||
|
||
/// JPEG quality (1–100) of a quality setting.
|
||
fn quality(q: JpegQuality) -> u8 {
|
||
match q {
|
||
JpegQuality::Minimum => 10,
|
||
JpegQuality::Low => 30,
|
||
JpegQuality::Medium => 55,
|
||
JpegQuality::High => 80,
|
||
JpegQuality::Maximum => 95,
|
||
}
|
||
}
|
||
|
||
fn rule(c: &CompressionSettings, kind: Kind) -> Rule {
|
||
if kind == Kind::Mono {
|
||
let m = &c.mono;
|
||
let instead = match m.compression {
|
||
MonoCodec::Zip => None,
|
||
MonoCodec::None => Some(UNCOMPRESSED),
|
||
MonoCodec::CcittG3 | MonoCodec::CcittG4 => Some("CCITT compression is not supported: monochrome images are compressed with ZIP"),
|
||
MonoCodec::RunLength => Some("Run Length compression is not supported: monochrome images are compressed with ZIP"),
|
||
};
|
||
return Rule { downsample: m.downsample, ppi: m.ppi, above: m.above_ppi, codec: Codec::Zip, instead };
|
||
}
|
||
let i = if kind == Kind::Color { &c.color } else { &c.gray };
|
||
let (codec, instead) = match i.compression {
|
||
ImageCodec::Auto => (Codec::Auto(quality(i.quality)), None),
|
||
ImageCodec::Zip => (Codec::Zip, None),
|
||
ImageCodec::Jpeg => (Codec::Jpeg(quality(i.quality)), None),
|
||
ImageCodec::None => (Codec::Zip, Some(UNCOMPRESSED)),
|
||
ImageCodec::Jpeg2000 => (Codec::Zip, Some("JPEG 2000 compression is not supported: those images are compressed with ZIP")),
|
||
};
|
||
Rule { downsample: i.downsample, ppi: i.ppi, above: i.above_ppi, codec, instead }
|
||
}
|
||
|
||
/// What [`recode`] makes of an image.
|
||
pub(crate) struct Recoded {
|
||
/// The image to write; `None` keeps the source image as it is.
|
||
pub image: Option<Image>,
|
||
/// Set when the image isn't compressed as asked.
|
||
pub warning: Option<&'static str>,
|
||
}
|
||
|
||
/// Is `img` (a grey image) only black and white?
|
||
fn bilevel(img: &DynamicImage) -> bool {
|
||
img.to_luma_alpha8().pixels().all(|p| p[0] == 0 || p[0] == 255)
|
||
}
|
||
|
||
/// Encoded image `bytes` placed `size` points wide and high, as the Compression settings `c` ask:
|
||
/// resampled when its resolution is above the threshold, compressed with ZIP or JPEG.
|
||
/// `interpolate` lets viewers smooth it. An image that can't be decoded is kept as it is.
|
||
pub(crate) fn recode(bytes: &[u8], size: (f64, f64), c: &CompressionSettings, interpolate: bool) -> Recoded {
|
||
let keep = Recoded { image: None, warning: None };
|
||
let Ok(reader) = ImageReader::new(Cursor::new(bytes)).with_guessed_format() else { return keep };
|
||
let jpeg = reader.format() == Some(ImageFormat::Jpeg);
|
||
let Ok(decoder) = reader.into_decoder() else { return keep };
|
||
let (w, h) = decoder.dimensions();
|
||
// Resolution along each side as placed; above the threshold, a side goes down to the target.
|
||
let ppi = |px: u32, pt: f64| px as f64 * 72.0 / pt.abs();
|
||
let (px, py) = (ppi(w, size.0), ppi(h, size.1));
|
||
let sized = |r: &Rule| {
|
||
let resample = r.downsample != Downsample::None && [px, py].iter().any(|p| p.is_finite() && *p > r.above);
|
||
let side = |n: u32, p: f64| if resample && p.is_finite() && p > r.ppi { ((n as f64 * r.ppi / p).round() as u32).clamp(1, n) } else { n };
|
||
(side(w, px), side(h, py))
|
||
};
|
||
// Already as asked: not resampled, and lossless or a JPEG kept as JPEG.
|
||
let kept = |r: &Rule| {
|
||
sized(r) == (w, h)
|
||
&& match r.codec {
|
||
Codec::Auto(_) => true,
|
||
Codec::Zip => !jpeg,
|
||
Codec::Jpeg(_) => jpeg,
|
||
}
|
||
};
|
||
use ExtendedColorType as T;
|
||
let grey = matches!(decoder.original_color_type(), T::L1 | T::La1 | T::L2 | T::La2 | T::L4 | T::La4 | T::L8 | T::La8 | T::L16 | T::La16);
|
||
let (kind, pixels) = if grey {
|
||
// Only the pixels tell monochrome images from greyscale ones: decoded unless both keep it.
|
||
let (g, m) = (rule(c, Kind::Gray), rule(c, Kind::Mono));
|
||
if kept(&g) && kept(&m) && g.instead == m.instead {
|
||
return Recoded { image: None, warning: g.instead };
|
||
}
|
||
let Ok(img) = DynamicImage::from_decoder(decoder) else { return keep };
|
||
(if bilevel(&img) { Kind::Mono } else { Kind::Gray }, Some(img))
|
||
} else {
|
||
(Kind::Color, None)
|
||
};
|
||
let rule = rule(c, kind);
|
||
if kept(&rule) {
|
||
return Recoded { image: None, warning: rule.instead };
|
||
}
|
||
let Some(img) = pixels.or_else(|| image::load_from_memory(bytes).ok()) else { return keep };
|
||
let (nw, nh) = sized(&rule);
|
||
let img = if (nw, nh) == (w, h) {
|
||
img
|
||
} else {
|
||
match rule.downsample {
|
||
Downsample::Average => img.thumbnail_exact(nw, nh),
|
||
Downsample::Subsample => img.resize_exact(nw, nh, FilterType::Nearest),
|
||
Downsample::Bicubic | Downsample::None => img.resize_exact(nw, nh, FilterType::CatmullRom),
|
||
}
|
||
};
|
||
let mut rgba = img.to_rgba8();
|
||
if kind == Kind::Mono {
|
||
// Resampling greys the edges: back to black and white.
|
||
for p in rgba.pixels_mut() {
|
||
let v = if p[0] >= 128 { 255 } else { 0 };
|
||
(p[0], p[1], p[2]) = (v, v, v);
|
||
}
|
||
}
|
||
let quality = match rule.codec {
|
||
Codec::Jpeg(q) => Some(q),
|
||
Codec::Auto(q) if jpeg => Some(q),
|
||
_ => None,
|
||
};
|
||
let image = quality.filter(|_| rgba.pixels().all(|p| p[3] == 255)).and_then(|q| to_jpeg(&img, kind != Kind::Color, q, interpolate));
|
||
let (width, height) = rgba.dimensions();
|
||
Recoded { image: image.or_else(|| from_rgba(rgba.as_raw(), width, height, interpolate)), warning: rule.instead }
|
||
}
|
||
|
||
/// Opaque `img` as a JPEG image at `quality` (a greyscale JPEG with `grey`).
|
||
fn to_jpeg(img: &DynamicImage, grey: bool, quality: u8, interpolate: bool) -> Option<Image> {
|
||
let img = if grey { DynamicImage::ImageLuma8(img.to_luma8()) } else { DynamicImage::ImageRgb8(img.to_rgb8()) };
|
||
let mut out = Vec::new();
|
||
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, quality).encode_image(&img).ok()?;
|
||
Image::from_jpeg(out.into(), interpolate).ok()
|
||
}
|