Files
designcraft/crates/images/src/lib.rs
T
aiongg ad33ff95d4 Merge #548: SVG text: find fonts by PostScript name (#430)
Conflict: crates/fonts/src/fontdb.rs: FontFace, CatalogEntry and ScannedFace keep next's aliases and kind plus the PR's postscript (both catalog paths fill it); next's family_kind and has_family, plus the PR's ScopedFonts::postscript_face
Conflict: crates/fonts/src/tests_sysfonts.rs: both tests
Conflict: crates/images/src/lib.rs: next's case-insensitive query (#404) with the PR's PostScript lookups, using next's spelled() in place of the PR's has_family helper; both tests
2026-10-12 02:06:15 +00:00

555 lines
25 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Placed graphics: format sniffing, sizes and decoding for the renderer and PDF export.
//! Rasters go through `image` (PNG, JPEG, GIF, WebP, TIFF, BMP) or [`psd`] (Photoshop's merged
//! composite); SVG is parsed with usvg (text set in the bundled fonts, then the shared ones) and
//! rasterised with resvg for the screen — PDF export draws the same tree as vectors.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use std::sync::{Arc, OnceLock};
pub use resvg::usvg;
mod eps;
mod psd;
pub use eps::{bounding_box as eps_bounding_box, eps_proxy, is_eps};
/// CSS pixels (SVG user units) → points.
pub const PT_PER_PX: f64 = 0.75;
pub fn is_pdf(bytes: &[u8]) -> bool {
bytes.starts_with(b"%PDF")
}
pub fn is_psd(bytes: &[u8]) -> bool {
bytes.starts_with(b"8BPS")
}
/// An SVG document (optionally after an XML declaration, comments or a doctype).
pub fn is_svg(bytes: &[u8]) -> bool {
let head = &bytes[..bytes.len().min(4096)];
let s = String::from_utf8_lossy(head);
let t = s.trim_start_matches('\u{feff}').trim_start();
t.starts_with('<') && t.contains("<svg")
}
/// MIME type of a placed graphic.
pub fn mime(bytes: &[u8]) -> &'static str {
if is_pdf(bytes) {
return "application/pdf";
}
if is_psd(bytes) {
return "image/vnd.adobe.photoshop";
}
if is_svg(bytes) {
return "image/svg+xml";
}
match image::guess_format(bytes) {
Ok(image::ImageFormat::Png) => "image/png",
Ok(image::ImageFormat::Jpeg) => "image/jpeg",
Ok(image::ImageFormat::Gif) => "image/gif",
Ok(image::ImageFormat::WebP) => "image/webp",
Ok(image::ImageFormat::Tiff) => "image/tiff",
Ok(image::ImageFormat::Bmp) => "image/bmp",
_ => "application/octet-stream",
}
}
fn svg_options() -> &'static usvg::Options<'static> {
static OPTS: OnceLock<usvg::Options<'static>> = OnceLock::new();
OPTS.get_or_init(|| {
let mut db = usvg::fontdb::Database::new();
for f in designcraft_fonts::bundled() {
db.load_font_data(f.to_vec());
}
// Japanese text in SVGs: the craft-fonts faces (none without CRAFT_FONTS_DIR).
for f in designcraft_fonts::japanese_document_fonts() {
db.load_font_data(f.bytes.to_vec());
}
db.set_serif_family("Source Serif 4");
db.set_sans_serif_family("Source Sans 3");
db.set_monospace_family("JetBrains Mono");
let font_resolver = usvg::FontResolver { select_font: select_font(), select_fallback: select_fallback() };
usvg::Options { fontdb: Arc::new(db), font_family: "Source Sans 3".into(), font_resolver, ..Default::default() }
})
}
/// A family the SVG names that isn't bundled comes from the shared fonts (installed ones too),
/// as it would in a text frame, and matches in any case, as it does there. A name that is no
/// family's but a face's PostScript name (as Illustrator's SVG export writes them:
/// `font-family="'MyriadPro-Regular'"`) sets the text in that face, whatever font-weight and
/// font-style say: the name already picks the weight and style, and a family's other faces would
/// contradict it. Families listed before it come first.
fn select_font() -> usvg::FontSelectionFn<'static> {
use usvg::fontdb::{Family, Query, Stretch, Style, Weight};
Box::new(move |font, db| {
let fonts = designcraft_fonts::FontDb::global();
let spelled = |db: &usvg::fontdb::Database, name: &str| {
db.faces().flat_map(|f| &f.families).find(|(n, _)| n.eq_ignore_ascii_case(name)).map(|(n, _)| n.clone())
};
for family in font.families() {
if let usvg::FontFamily::Named(name) = family
&& spelled(db, name).is_none()
{
if fonts.has_family(name) {
for style in fonts.styles(name) {
add_face(db, fonts.face(name, &style));
}
} else if postscript_face(db, name).is_none()
&& let Some((family, style)) = fonts.postscript_face(name)
{
add_face(db, fonts.face(&family, &style));
}
}
}
for family in font.families() {
match family {
usvg::FontFamily::Named(name) if spelled(db, name).is_none() => {
if let Some(id) = postscript_face(db, name) {
return Some(id);
}
}
// A family (or generic family) that matches: usvg's own selection.
_ => break,
}
}
// usvg's default query, but with each named family spelled as the database spells it:
// fontdb compares names exactly, so "DeJaVu SaNs" would quietly fall to the serif.
let names: Vec<Option<String>> = font
.families()
.iter()
.map(|f| match f {
usvg::FontFamily::Named(name) => Some(spelled(db, name).unwrap_or_else(|| name.clone())),
_ => None,
})
.collect();
let mut families: Vec<Family> = font
.families()
.iter()
.zip(&names)
.map(|(f, name)| match f {
usvg::FontFamily::Serif => Family::Serif,
usvg::FontFamily::SansSerif => Family::SansSerif,
usvg::FontFamily::Cursive => Family::Cursive,
usvg::FontFamily::Fantasy => Family::Fantasy,
usvg::FontFamily::Monospace => Family::Monospace,
usvg::FontFamily::Named(_) => Family::Name(name.as_deref().unwrap_or_default()),
})
.collect();
families.push(Family::Serif);
let stretch = match font.stretch() {
usvg::FontStretch::UltraCondensed => Stretch::UltraCondensed,
usvg::FontStretch::ExtraCondensed => Stretch::ExtraCondensed,
usvg::FontStretch::Condensed => Stretch::Condensed,
usvg::FontStretch::SemiCondensed => Stretch::SemiCondensed,
usvg::FontStretch::Normal => Stretch::Normal,
usvg::FontStretch::SemiExpanded => Stretch::SemiExpanded,
usvg::FontStretch::Expanded => Stretch::Expanded,
usvg::FontStretch::ExtraExpanded => Stretch::ExtraExpanded,
usvg::FontStretch::UltraExpanded => Stretch::UltraExpanded,
};
let style = match font.style() {
usvg::FontStyle::Normal => Style::Normal,
usvg::FontStyle::Italic => Style::Italic,
usvg::FontStyle::Oblique => Style::Oblique,
};
db.query(&Query { families: &families, weight: Weight(font.weight()), stretch, style })
})
}
/// The face in the SVG's font database with PostScript name `name` (ignoring case).
fn postscript_face(db: &usvg::fontdb::Database, name: &str) -> Option<usvg::fontdb::ID> {
db.faces().find(|f| f.post_script_name.eq_ignore_ascii_case(name)).map(|f| f.id)
}
/// Characters the chosen fonts lack (Korean, Arabic…) fall back to a shared face that has them.
fn select_fallback() -> usvg::FallbackSelectionFn<'static> {
let loaded = usvg::FontResolver::default_fallback_selector();
Box::new(move |c, exclude, db| {
loaded(c, exclude, db).or_else(|| {
let id = add_face(db, designcraft_fonts::FontDb::global().fallback_for(c, 0, None)?)?;
// usvg asks again until we run out: a face it already tried ends the search.
(!exclude.contains(&id)).then_some(id)
})
})
}
/// A shared face's bytes, handed to usvg without a copy.
struct FaceBytes(Arc<designcraft_fonts::FontFace>);
impl AsRef<[u8]> for FaceBytes {
fn as_ref(&self) -> &[u8] {
self.0.data()
}
}
/// Add `face` to the SVG's font database (once) and return its id there.
fn add_face(db: &mut Arc<usvg::fontdb::Database>, face: Arc<designcraft_fonts::FontFace>) -> Option<usvg::fontdb::ID> {
let same = |f: &usvg::fontdb::FaceInfo| {
f.index == face.index()
&& matches!(&f.source, usvg::fontdb::Source::Binary(b) if std::ptr::eq(b.as_ref().as_ref().as_ptr(), face.data().as_ptr()))
};
if let Some(f) = db.faces().find(|f| same(f)) {
return Some(f.id);
}
let index = face.index();
let ids = Arc::make_mut(db).load_font_source(usvg::fontdb::Source::Binary(Arc::new(FaceBytes(face))));
// A collection loads all its faces (skipping any it can't read): pick ours by its index.
ids.into_iter().find(|id| db.face(*id).is_some_and(|f| f.index == index))
}
/// The parsed SVG.
pub fn svg_tree(bytes: &[u8]) -> Option<usvg::Tree> {
usvg::Tree::from_data(bytes, svg_options()).ok()
}
/// Natural size of a placed graphic in points: rasters at 72 ppi (one pixel per point), PSDs
/// likewise, SVGs from their width/height (CSS pixels), PDFs are the caller's.
pub fn natural_size(bytes: &[u8]) -> Option<(f64, f64)> {
if is_svg(bytes) {
let s = svg_tree(bytes)?.size();
return Some((s.width() as f64 * PT_PER_PX, s.height() as f64 * PT_PER_PX));
}
let (w, h) = pixel_size(bytes)?;
Some((w as f64, h as f64))
}
/// Pixel dimensions of a raster.
pub fn pixel_size(bytes: &[u8]) -> Option<(u32, u32)> {
if is_psd(bytes) && bytes.len() >= 26 {
// Header: signature, version, 6 reserved, channels, height, width (big-endian).
let be = |i: usize| u32::from_be_bytes([bytes[i], bytes[i + 1], bytes[i + 2], bytes[i + 3]]);
return Some((be(18), be(14)));
}
image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?.into_dimensions().ok()
}
/// Straight (non-premultiplied) RGBA8 of a raster.
pub fn decode_rgba(bytes: &[u8]) -> Option<image::RgbaImage> {
if is_psd(bytes) {
return psd::decode(bytes);
}
Some(decode_to_rgba8(&image::load_from_memory(bytes).ok()?))
}
/// Fresh raster decodes are sRGB in the pinned image decoder. Expanding an L8 value to
/// [gray, gray, gray, 255] is exact and avoids its generic floating-point conversion.
fn decode_to_rgba8(img: &image::DynamicImage) -> image::RgbaImage {
let prepared = match img {
image::DynamicImage::ImageLuma8(gray) => prepare_luma8_rgba(gray),
_ => None,
};
rgba_or_original(img, prepared)
}
// Keep preparation failure on the existing conversion/error path. This small boundary also
// lets tests force failure deterministically without exhausting memory or changing an allocator.
fn rgba_or_original(img: &image::DynamicImage, prepared: Option<image::RgbaImage>) -> image::RgbaImage {
prepared.unwrap_or_else(|| img.to_rgba8())
}
fn prepare_luma8_rgba(gray: &image::GrayImage) -> Option<image::RgbaImage> {
let (width, height) = gray.dimensions();
let pixels = usize::try_from(width).ok()?.checked_mul(usize::try_from(height).ok()?)?;
let length = pixels.checked_mul(4)?;
let mut bytes = Vec::new();
// Unlike the generic ConvertBuffer API's infallible allocation, preserve a route back to
// the original to_rgba8 behavior when the requested buffer cannot be reserved.
bytes.try_reserve_exact(length).ok()?;
bytes.resize(length, 0);
for (output, input) in bytes.as_chunks_mut::<4>().0.iter_mut().zip(gray.pixels()) {
let value = input.0[0];
output.copy_from_slice(&[value, value, value, 255]);
}
let mut rgba = image::RgbaImage::from_raw(width, height, bytes)?;
rgba.set_color_space(gray.color_space()).ok()?;
Some(rgba)
}
#[cfg(test)]
mod luma8_tests;
/// A CMYK raster's ink values, 8 bits per ink (0 = no ink, 255 = solid), as PDF's DeviceCMYK
/// reads them.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct CmykRaster {
pub width: u32,
pub height: u32,
/// C, M, Y, K for each pixel, row by row: `width * height * 4` bytes.
pub cmyk: Vec<u8>,
/// Straight alpha, `width * height` bytes; `None` when the image is opaque.
pub alpha: Option<Vec<u8>>,
}
fn tiff_decoder(bytes: &[u8]) -> Option<tiff::decoder::Decoder<std::io::Cursor<&[u8]>>> {
let tiff = [b"II*\0", b"MM\0*", b"II+\0", b"MM\0+"].iter().any(|m| bytes.starts_with(*m));
if !tiff {
return None;
}
tiff::decoder::Decoder::new(std::io::Cursor::new(bytes)).ok()
}
/// A TIFF whose pixels are CMYK (PhotometricInterpretation Separated, four inks).
pub fn is_cmyk_tiff(bytes: &[u8]) -> bool {
tiff_decoder(bytes).and_then(|mut d| d.colortype().ok()).is_some_and(|c| matches!(c, tiff::ColorType::CMYK(_) | tiff::ColorType::CMYKA(_)))
}
/// The ink values of a CMYK TIFF, which [`decode_rgba`] converts to RGB. 8- or 16-bit CMYK,
/// pixels interleaved, opaque or with unassociated alpha. `None` for anything else — another
/// format, another ink set (InkSet 2), planar storage or premultiplied alpha — and for files
/// over the decoder's 256 MiB limit; [`is_cmyk_tiff`] tells those apart from RGB files.
pub fn decode_cmyk_tiff(bytes: &[u8]) -> Option<CmykRaster> {
use tiff::tags::Tag;
let mut d = tiff_decoder(bytes)?;
let has_alpha = match d.colortype().ok()? {
tiff::ColorType::CMYK(8 | 16) => false,
tiff::ColorType::CMYKA(8 | 16) => true,
_ => return None,
};
// InkSet (tag 332): 1 or absent = CMYK; 2 = other inks, named in InkNames.
if d.find_tag_unsigned::<u16>(Tag::Unknown(332)).ok()?.is_some_and(|s| s != 1) {
return None;
}
// ExtraSamples 2 = unassociated alpha. Premultiplied CMYK has no meaning as ink values.
if has_alpha && d.find_tag_unsigned_vec::<u16>(Tag::ExtraSamples).ok()?.and_then(|v| v.first().copied()) != Some(2) {
return None;
}
let (w, h) = d.dimensions().ok()?;
let pixels = usize::try_from(w).ok()?.checked_mul(usize::try_from(h).ok()?)?;
let samples = if has_alpha { 5 } else { 4 };
let px = match d.read_image().ok()? {
tiff::decoder::DecodingResult::U8(v) => v,
tiff::decoder::DecodingResult::U16(v) => v.iter().map(|&s| ((u32::from(s) * 255 + 32_767) / 65_535) as u8).collect(),
_ => return None,
};
// A planar file reads back as its first plane only, and extra non-alpha samples make the
// pixels wider: either way the length is not `pixels * samples`.
if px.len() != pixels.checked_mul(samples)? {
return None;
}
if !has_alpha {
return Some(CmykRaster { width: w, height: h, cmyk: px, alpha: None });
}
let mut cmyk = Vec::with_capacity(pixels.checked_mul(4)?);
let mut alpha = Vec::with_capacity(pixels);
for &[c, m, y, k, a] in px.as_chunks::<5>().0 {
cmyk.extend([c, m, y, k]);
alpha.push(a);
}
let alpha = alpha.iter().any(|&a| a < 255).then_some(alpha);
Some(CmykRaster { width: w, height: h, cmyk, alpha })
}
/// An SVG rasterised so its longer side is `max_side` pixels: premultiplied RGBA8.
pub fn render_svg(bytes: &[u8], max_side: u32) -> Option<(Vec<u8>, u32, u32)> {
let tree = svg_tree(bytes)?;
let s = tree.size();
let k = max_side as f32 / s.width().max(s.height()).max(1e-3);
let (w, h) = ((s.width() * k).round().max(1.0) as u32, (s.height() * k).round().max(1.0) as u32);
let mut pm = resvg::tiny_skia::Pixmap::new(w, h)?;
resvg::render(&tree, resvg::tiny_skia::Transform::from_scale(k, k), &mut pm.as_mut());
Some((pm.take(), w, h))
}
#[cfg(test)]
mod tests {
use super::*;
const SVG: &str = r##"<?xml version="1.0"?>
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100" viewBox="0 0 200 100">
<rect x="0" y="0" width="100" height="100" fill="#ff0000"/>
<text x="110" y="60" font-size="20">Hi</text>
</svg>"##;
#[test]
fn svg_sniff_size_and_render() {
let b = SVG.as_bytes();
assert!(is_svg(b));
assert_eq!(mime(b), "image/svg+xml");
assert_eq!(natural_size(b), Some((150.0, 75.0)));
let (px, w, h) = render_svg(b, 400).unwrap();
assert_eq!((w, h), (400, 200));
// Red on the left; the text drew some ink on the right.
assert_eq!(&px[(100 * 400 + 100) * 4..][..4], &[255, 0, 0, 255]);
assert!(px.chunks(4).skip(220).step_by(7).any(|p| p[3] > 128 && p[0] < 100), "text pixels");
assert!(!is_svg(b"\x89PNG"));
}
/// The family of the font that sets each text glyph of `svg`, and the glyph's id.
fn glyph_families(svg: &str) -> Vec<(String, u16)> {
fn walk(g: &usvg::Group, tree: &usvg::Tree, out: &mut Vec<(String, u16)>) {
for n in g.children() {
match n {
usvg::Node::Group(g) => walk(g, tree, out),
usvg::Node::Text(t) => {
for g in t.layouted().iter().flat_map(|s| &s.positioned_glyphs) {
out.extend(tree.fontdb().face(g.font).map(|f| (f.families[0].0.clone(), g.id.0)));
}
}
_ => {}
}
}
}
let tree = svg_tree(svg.as_bytes()).unwrap();
let mut out = Vec::new();
walk(tree.root(), &tree, &mut out);
out
}
#[test]
fn svg_text_uses_the_shared_fonts() {
let font = designcraft_fonts::testing::font_with("DC Test SVG Hangul", &['한', '글']).unwrap();
designcraft_fonts::FontDb::global().add_font(font);
let text = |attrs: &str, body: &str| {
format!(r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="40"><text y="30" font-size="20"{attrs}>{body}</text></svg>"#)
};
let named = r#" font-family="DC Test SVG Hangul""#;
// A family named in the SVG.
let set = glyph_families(&text(named, "한글"));
assert!(set.len() == 2 && set.iter().all(|(f, gid)| f == "DC Test SVG Hangul" && *gid != 0), "{set:?}");
// Characters the bundled default lacks: a shared face that has them, not the default.
let set = glyph_families(&text("", "한글"));
assert!(set.len() == 2 && set.iter().all(|(f, gid)| f != "Source Sans 3" && *gid != 0), "{set:?}");
}
#[test]
fn svg_family_names_match_in_any_case() {
let font = designcraft_fonts::testing::font_with("DC Test SVG Casing", &['A', 'b']).unwrap();
designcraft_fonts::FontDb::global().add_font(font);
let text = |family: &str, attrs: &str| {
format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="40"><text y="30" font-size="20" font-family="{family}"{attrs}>Ab</text></svg>"#
)
};
for family in ["DC Test SVG Casing", "dc test svg casing", "DC TEST svg Casing", "Nope, dc test SVG casing, serif"] {
let set = glyph_families(&text(family, ""));
assert!(set.len() == 2 && set.iter().all(|(f, gid)| f == "DC Test SVG Casing" && *gid != 0), "{family}: {set:?}");
}
// Weight and style still reach the query: there's only a regular face, so it sets the text.
let set = glyph_families(&text("dc test svg casing", r#" font-weight="700" font-style="italic""#));
assert!(set.len() == 2 && set.iter().all(|(f, _)| f == "DC Test SVG Casing"), "{set:?}");
// A family nobody has still falls to the bundled serif.
let set = glyph_families(&text("DC Test SVG Nobody", ""));
assert!(set.len() == 2 && set.iter().all(|(f, _)| f == "Source Serif 4"), "{set:?}");
}
/// The PostScript name of the face that sets the text of `svg` (one face for all of it).
fn text_face(svg_attrs: &str) -> String {
let svg =
format!(r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="40"><text y="30" font-size="20"{svg_attrs}>ab</text></svg>"#);
let tree = svg_tree(svg.as_bytes()).unwrap();
let mut faces = Vec::new();
for n in tree.root().children() {
if let usvg::Node::Text(t) = n {
for g in t.layouted().iter().flat_map(|s| &s.positioned_glyphs) {
faces.extend(tree.fontdb().face(g.font).map(|f| f.post_script_name.clone()));
}
}
}
faces.dedup();
assert_eq!(faces.len(), 1, "{faces:?}");
faces.remove(0)
}
#[test]
fn svg_text_finds_fonts_by_postscript_name() {
// Bundled, as Illustrator's SVG export names it; any case.
assert_eq!(text_face(r#" font-family="'SourceSerif4-Semibold'""#), "SourceSerif4-Semibold");
assert_eq!(text_face(r#" font-family="sourceserif4-semibold""#), "SourceSerif4-Semibold");
// A shared font (as an installed one would be).
let font = designcraft_fonts::testing::font_with("DC Test SVG PostScript", &['a', 'b']).unwrap();
designcraft_fonts::FontDb::global().add_font(font);
assert_eq!(text_face(r#" font-family="DCTestSVGPostScript-Regular""#), "DCTestSVGPostScript-Regular");
// The name picks that face: font-weight and font-style don't change it.
assert_eq!(text_face(r#" font-family="SourceSerif4-Semibold" font-weight="bold" font-style="italic""#), "SourceSerif4-Semibold");
// A family listed before it still comes first; one after it is a fallback.
assert_eq!(text_face(r#" font-family="'Source Sans 3', SourceSerif4-Bold""#), "SourceSans3-Regular");
assert_eq!(text_face(r#" font-family="NoSuch-Font, SourceSerif4-Bold, 'Source Sans 3'""#), "SourceSerif4-Bold");
// Family names work as before.
assert_eq!(text_face(r#" font-family="'Source Serif 4'" font-weight="bold""#), "SourceSerif4-Bold");
// An unknown name falls back as before (the serif default).
assert_eq!(text_face(r#" font-family="NoSuch-Font""#), text_face(r#" font-family="No Such Font""#));
assert_eq!(text_face(r#" font-family="NoSuch-Font""#), "SourceSerif4-Regular");
}
/// A minimal Photoshop file: 2×1 RGB, no layers, raw merged image.
fn tiny_psd() -> Vec<u8> {
let mut b = Vec::new();
b.extend(b"8BPS");
b.extend(1u16.to_be_bytes());
b.extend([0u8; 6]);
b.extend(3u16.to_be_bytes()); // channels
b.extend(1u32.to_be_bytes()); // height
b.extend(2u32.to_be_bytes()); // width
b.extend(8u16.to_be_bytes()); // depth
b.extend(3u16.to_be_bytes()); // RGB
b.extend(0u32.to_be_bytes()); // colour mode data
b.extend(0u32.to_be_bytes()); // image resources
b.extend(0u32.to_be_bytes()); // layer and mask info
b.extend(0u16.to_be_bytes()); // raw
b.extend([255, 0, 0, 255, 0, 0]); // R, G, B planes: a red pixel, then a green one
b
}
#[test]
fn psd_composite_decodes() {
let b = tiny_psd();
assert_eq!(mime(&b), "image/vnd.adobe.photoshop");
assert_eq!(pixel_size(&b), Some((2, 1)));
let img = decode_rgba(&b).unwrap();
assert_eq!(img.get_pixel(0, 0).0, [255, 0, 0, 255]);
assert_eq!(img.get_pixel(1, 0).0, [0, 255, 0, 255]);
}
fn tiff_of<C: tiff::encoder::colortype::ColorType>(w: u32, h: u32, px: &[C::Inner], alpha: bool) -> Vec<u8>
where
[C::Inner]: tiff::encoder::TiffValue,
{
let mut b = Vec::new();
let mut enc = tiff::encoder::TiffEncoder::new(std::io::Cursor::new(&mut b)).unwrap();
let mut img = enc.new_image::<C>(w, h).unwrap();
if alpha {
// CMYKA8 already has five samples: only the tag that says the fifth is alpha.
img.encoder().write_tag(tiff::tags::Tag::ExtraSamples, &[2u16][..]).unwrap();
}
img.write_data(px).unwrap();
b
}
/// CMYK TIFFs read back as their ink values; RGB ones aren't CMYK.
#[test]
fn cmyk_tiff_keeps_inks() {
use tiff::encoder::colortype::{CMYK8, CMYK16, CMYKA8, RGB8};
// 2×1: 100% K, then C0 M100 Y100 K0.
let px = [0, 0, 0, 255, 0, 255, 255, 0];
let b = tiff_of::<CMYK8>(2, 1, &px, false);
assert_eq!(mime(&b), "image/tiff");
assert!(is_cmyk_tiff(&b));
assert_eq!(decode_cmyk_tiff(&b), Some(CmykRaster { width: 2, height: 1, cmyk: px.to_vec(), alpha: None }));
// The screen still gets RGB.
assert_eq!(decode_rgba(&b).unwrap().get_pixel(0, 0).0, [0, 0, 0, 255]);
let b = tiff_of::<CMYK16>(2, 1, &[0, 0, 0, 65_535, 0, 32_896, 65_535, 0], false);
assert_eq!(decode_cmyk_tiff(&b).unwrap().cmyk, [0, 0, 0, 255, 0, 128, 255, 0]);
let b = tiff_of::<CMYKA8>(2, 1, &[0, 0, 0, 255, 255, 0, 255, 255, 0, 0], true);
let r = decode_cmyk_tiff(&b).unwrap();
assert_eq!((r.cmyk, r.alpha), (px.to_vec(), Some(vec![255, 0])));
let rgb = tiff_of::<RGB8>(1, 1, &[255, 0, 0], false);
assert!(!is_cmyk_tiff(&rgb));
assert_eq!(decode_cmyk_tiff(&rgb), None);
assert!(!is_cmyk_tiff(b"II*\0"), "truncated");
assert_eq!(decode_cmyk_tiff(&b[..b.len() / 2]), None, "truncated");
}
#[test]
fn bmp_decodes() {
let mut bmp = Vec::new();
image::RgbaImage::from_pixel(3, 2, image::Rgba([0, 0, 255, 255]))
.write_to(&mut std::io::Cursor::new(&mut bmp), image::ImageFormat::Bmp)
.unwrap();
assert_eq!(mime(&bmp), "image/bmp");
assert_eq!(pixel_size(&bmp), Some((3, 2)));
assert_eq!(decode_rgba(&bmp).unwrap().get_pixel(1, 1).0, [0, 0, 255, 255]);
}
}