use std::collections::HashMap;
use std::path::Path;
use super::model::ImageData;
pub const MAX_IMAGE_BYTES: usize = 16 * 1024 * 1024;
pub const MAX_TOTAL_IMAGE_BYTES: usize = 256 * 1024 * 1024;
const FETCH_TIMEOUT_SECS: u64 = 30;
pub struct ImageResolver {
cache: HashMap<String, Option<ImageData>>,
total_bytes: usize,
pub offline: bool,
pub notes: Vec<String>,
}
impl Default for ImageResolver {
fn default() -> Self {
Self::new()
}
}
impl ImageResolver {
pub fn new() -> Self {
ImageResolver {
cache: HashMap::new(),
total_bytes: 0,
offline: false,
notes: Vec::new(),
}
}
pub fn resolve(&mut self, value: &str, root: Option<&Path>) -> Option<ImageData> {
let value = value.trim();
if value.is_empty() {
return None;
}
if let Some(hit) = self.cache.get(value) {
return hit.clone();
}
let resolved = self.load(value, root);
if let Some(img) = &resolved {
self.total_bytes += img.bytes.len();
}
self.cache.insert(value.to_string(), resolved.clone());
resolved
}
fn load(&mut self, value: &str, root: Option<&Path>) -> Option<ImageData> {
if self.total_bytes >= MAX_TOTAL_IMAGE_BYTES {
self.notes.push(format!(
"image budget of {} MB exhausted; remaining image cells left as text",
MAX_TOTAL_IMAGE_BYTES / (1024 * 1024)
));
return None;
}
let bytes = match value_shape(value) {
Shape::DataUri(payload) => decode_base64(payload)?,
Shape::Url if self.offline => return None,
Shape::Url => match fetch_url(value) {
Ok(b) => b,
Err(e) => {
self.notes
.push(format!("image fetch failed for {value}: {e}"));
return None;
}
},
Shape::Path => {
let path = super::producers::resolve_path(root, value);
match std::fs::read(&path) {
Ok(b) => b,
Err(e) => {
self.notes
.push(format!("image file unreadable: {}: {e}", path.display()));
return None;
}
}
}
Shape::Base64 => decode_base64(value)?,
Shape::Unknown => return None,
};
self.build(value, bytes)
}
fn build(&mut self, value: &str, bytes: Vec<u8>) -> Option<ImageData> {
if bytes.len() > MAX_IMAGE_BYTES {
self.notes.push(format!(
"image at {value} is {} MB, over the {} MB per-image limit; left as text",
bytes.len() / (1024 * 1024),
MAX_IMAGE_BYTES / (1024 * 1024)
));
return None;
}
let Some((mime, natural)) = probe(&bytes) else {
self.notes
.push(format!("value at {value} is not a recognised image format"));
return None;
};
let (bytes, mime, natural) = match downscale(&bytes, mime, natural) {
Some(smaller) => smaller,
None => (bytes, mime.to_string(), natural),
};
Some(ImageData {
bytes,
mime,
natural,
})
}
}
pub const MAX_EMBED_EDGE: u32 = 640;
const JPEG_QUALITY: u8 = 82;
fn downscale(
bytes: &[u8],
mime: &str,
natural: (u32, u32),
) -> Option<(Vec<u8>, String, (u32, u32))> {
let (w, h) = natural;
if w.max(h) <= MAX_EMBED_EDGE || w == 0 || h == 0 {
return None;
}
let format = match mime {
"image/jpeg" => image::ImageFormat::Jpeg,
"image/png" => image::ImageFormat::Png,
_ => return None,
};
let decoded = image::load_from_memory_with_format(bytes, format).ok()?;
let scale = f64::from(MAX_EMBED_EDGE) / f64::from(w.max(h));
let (tw, th) = (
(f64::from(w) * scale).round().max(1.0) as u32,
(f64::from(h) * scale).round().max(1.0) as u32,
);
let small = decoded.thumbnail(tw, th);
let mut out = std::io::Cursor::new(Vec::new());
let mime = match format {
image::ImageFormat::Jpeg => {
let rgb = small.to_rgb8();
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, JPEG_QUALITY)
.encode_image(&rgb)
.ok()?;
"image/jpeg"
}
_ => {
small.write_to(&mut out, image::ImageFormat::Png).ok()?;
"image/png"
}
};
let out = out.into_inner();
if out.len() >= bytes.len() {
return None;
}
Some((out, mime.to_string(), (small.width(), small.height())))
}
enum Shape<'a> {
DataUri(&'a str),
Url,
Path,
Base64,
Unknown,
}
fn value_shape(value: &str) -> Shape<'_> {
if let Some(rest) = value.strip_prefix("data:")
&& let Some((meta, payload)) = rest.split_once(',')
&& meta.contains("base64")
{
return Shape::DataUri(payload);
}
if value.starts_with("http://") || value.starts_with("https://") {
return Shape::Url;
}
if value.len() > 64
&& !value.contains('/')
&& !value.contains('\\')
&& value
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'+' || b == b'/' || b == b'=')
{
return Shape::Base64;
}
if value.len() < 4096 && !value.contains('\n') {
return Shape::Path;
}
Shape::Unknown
}
fn decode_base64(text: &str) -> Option<Vec<u8>> {
use base64::Engine;
use base64::engine::general_purpose::{STANDARD, STANDARD_NO_PAD, URL_SAFE, URL_SAFE_NO_PAD};
let cleaned: String = text.chars().filter(|c| !c.is_ascii_whitespace()).collect();
let attempts = [
STANDARD.decode(&cleaned),
STANDARD_NO_PAD.decode(&cleaned),
URL_SAFE.decode(&cleaned),
URL_SAFE_NO_PAD.decode(&cleaned),
];
attempts
.into_iter()
.flatten()
.find(|bytes| !bytes.is_empty())
}
fn fetch_url(url: &str) -> Result<Vec<u8>, String> {
let mut handle = curl::easy::Easy::new();
handle.url(url).map_err(|e| e.to_string())?;
handle.follow_location(true).map_err(|e| e.to_string())?;
handle
.timeout(std::time::Duration::from_secs(FETCH_TIMEOUT_SECS))
.map_err(|e| e.to_string())?;
let mut buf = Vec::new();
{
let mut transfer = handle.transfer();
transfer
.write_function(|data| {
buf.extend_from_slice(data);
Ok(data.len())
})
.map_err(|e| e.to_string())?;
transfer.perform().map_err(|e| e.to_string())?;
}
let code = handle.response_code().map_err(|e| e.to_string())?;
if !(200..300).contains(&code) {
return Err(format!("HTTP {code}"));
}
Ok(buf)
}
pub fn probe(bytes: &[u8]) -> Option<(&'static str, (u32, u32))> {
if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
return jpeg_size(bytes).map(|d| ("image/jpeg", d));
}
if bytes.starts_with(b"\x89PNG\r\n\x1a\n") && bytes.len() >= 24 {
let w = u32::from_be_bytes(bytes[16..20].try_into().ok()?);
let h = u32::from_be_bytes(bytes[20..24].try_into().ok()?);
return Some(("image/png", (w, h)));
}
if (bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a")) && bytes.len() >= 10 {
let w = u16::from_le_bytes([bytes[6], bytes[7]]) as u32;
let h = u16::from_le_bytes([bytes[8], bytes[9]]) as u32;
return Some(("image/gif", (w, h)));
}
if bytes.starts_with(b"BM") && bytes.len() >= 26 {
let w = i32::from_le_bytes(bytes[18..22].try_into().ok()?).unsigned_abs();
let h = i32::from_le_bytes(bytes[22..26].try_into().ok()?).unsigned_abs();
return Some(("image/bmp", (w, h)));
}
None
}
fn jpeg_size(bytes: &[u8]) -> Option<(u32, u32)> {
let mut i = 2;
while i + 9 < bytes.len() {
if bytes[i] != 0xFF {
i += 1;
continue;
}
let marker = bytes[i + 1];
if marker == 0xD8 || marker == 0x01 || (0xD0..=0xD7).contains(&marker) {
i += 2;
continue;
}
let len = u16::from_be_bytes([bytes[i + 2], bytes[i + 3]]) as usize;
let is_sof =
(0xC0..=0xCF).contains(&marker) && marker != 0xC4 && marker != 0xC8 && marker != 0xCC;
if is_sof {
let h = u16::from_be_bytes([bytes[i + 5], bytes[i + 6]]) as u32;
let w = u16::from_be_bytes([bytes[i + 7], bytes[i + 8]]) as u32;
return Some((w, h));
}
if len < 2 {
return None;
}
i += 2 + len;
}
None
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
pub(crate) fn png_1x1() -> Vec<u8> {
let mut v = b"\x89PNG\r\n\x1a\n".to_vec();
v.extend_from_slice(&[0, 0, 0, 13]);
v.extend_from_slice(b"IHDR");
v.extend_from_slice(&1u32.to_be_bytes());
v.extend_from_slice(&1u32.to_be_bytes());
v.extend_from_slice(&[8, 6, 0, 0, 0]);
v
}
fn jpeg(w: u16, h: u16) -> Vec<u8> {
let mut v = vec![0xFF, 0xD8];
v.extend_from_slice(&[0xFF, 0xE0, 0x00, 0x10]);
v.extend_from_slice(&[0u8; 14]);
v.extend_from_slice(&[0xFF, 0xC0, 0x00, 0x11, 0x08]);
v.extend_from_slice(&h.to_be_bytes());
v.extend_from_slice(&w.to_be_bytes());
v.extend_from_slice(&[3]);
v
}
fn noisy_jpeg(size: u32) -> Vec<u8> {
let mut buf = image::RgbImage::new(size, size);
let mut seed = 0x2545_F491_4F6C_DD1Du64;
for px in buf.pixels_mut() {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
let b = seed.to_le_bytes();
*px = image::Rgb([b[0], b[1], b[2]]);
}
let mut out = std::io::Cursor::new(Vec::new());
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 90)
.encode_image(&buf)
.unwrap();
out.into_inner()
}
#[test]
fn a_large_picture_is_embedded_downscaled() {
let big = noisy_jpeg(1600);
let mut r = ImageResolver::new();
let got = r.build("photo.jpg", big.clone()).unwrap();
assert_eq!(
got.natural,
(MAX_EMBED_EDGE, MAX_EMBED_EDGE),
"the recorded size must describe the bytes actually embedded, or \
every writer's layout maths is wrong"
);
assert!(
got.bytes.len() < big.len() / 2,
"expected a real saving, got {} from {}",
got.bytes.len(),
big.len()
);
assert_eq!(got.mime, "image/jpeg");
assert!(probe(&got.bytes).is_some(), "and it must still be an image");
}
#[test]
fn a_small_picture_is_left_exactly_as_it_was() {
let small = noisy_jpeg(64);
let mut r = ImageResolver::new();
let got = r.build("thumb.jpg", small.clone()).unwrap();
assert_eq!(got.bytes, small);
assert_eq!(got.natural, (64, 64));
}
#[test]
fn a_format_without_a_codec_is_still_embedded() {
let mut gif = b"GIF89a".to_vec();
gif.extend_from_slice(&2000u16.to_le_bytes());
gif.extend_from_slice(&2000u16.to_le_bytes());
gif.extend_from_slice(&[0u8; 64]);
let mut r = ImageResolver::new();
let got = r.build("big.gif", gif.clone()).unwrap();
assert_eq!(got.bytes, gif, "unshrinkable is not the same as unusable");
assert_eq!(got.natural, (2000, 2000));
}
#[test]
fn probe_reads_png_gif_bmp_and_jpeg_dimensions() {
assert_eq!(probe(&png_1x1()), Some(("image/png", (1, 1))));
assert_eq!(probe(&jpeg(640, 480)), Some(("image/jpeg", (640, 480))));
let mut gif = b"GIF89a".to_vec();
gif.extend_from_slice(&[0x20, 0x00, 0x10, 0x00]);
assert_eq!(probe(&gif), Some(("image/gif", (32, 16))));
let mut bmp = b"BM".to_vec();
bmp.extend_from_slice(&[0u8; 16]);
bmp.extend_from_slice(&100i32.to_le_bytes());
bmp.extend_from_slice(&(-50i32).to_le_bytes());
assert_eq!(probe(&bmp), Some(("image/bmp", (100, 50))));
}
#[test]
fn probe_rejects_non_image_bytes() {
assert_eq!(probe(b"{\"not\": \"an image\"}"), None);
assert_eq!(probe(b""), None);
}
#[test]
fn base64_round_trips_through_a_data_uri() {
let png = png_1x1();
let b64 = encode_base64(&png);
let uri = format!("data:image/png;base64,{b64}");
let mut r = ImageResolver::new();
let got = r.resolve(&uri, None).expect("decoded");
assert_eq!(got.bytes, png);
assert_eq!(got.mime, "image/png");
assert_eq!(got.natural, (1, 1));
}
#[test]
fn a_local_path_resolves_against_the_run_root() {
let d = std::env::temp_dir().join(format!(
"paperboy_img_{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&d).unwrap();
std::fs::write(d.join("face.png"), png_1x1()).unwrap();
let mut r = ImageResolver::new();
let got = r.resolve("face.png", Some(&d)).expect("read");
assert_eq!(got.mime, "image/png");
std::fs::remove_dir_all(&d).ok();
}
#[test]
fn an_unreadable_value_resolves_to_nothing_with_a_note() {
let mut r = ImageResolver::new();
assert!(r.resolve("no/such/file.png", None).is_none());
assert!(
r.notes.iter().any(|n| n.contains("unreadable")),
"{:?}",
r.notes
);
}
#[test]
fn a_value_that_is_not_an_image_format_is_left_as_text() {
let d = std::env::temp_dir().join(format!(
"paperboy_img_bad_{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&d).unwrap();
std::fs::write(d.join("notes.txt"), "just some text").unwrap();
let mut r = ImageResolver::new();
assert!(r.resolve("notes.txt", Some(&d)).is_none());
assert!(r.notes.iter().any(|n| n.contains("not a recognised")));
std::fs::remove_dir_all(&d).ok();
}
#[test]
fn the_same_source_is_only_loaded_once() {
let d = std::env::temp_dir().join(format!(
"paperboy_img_cache_{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&d).unwrap();
let f = d.join("face.png");
std::fs::write(&f, png_1x1()).unwrap();
let mut r = ImageResolver::new();
assert!(r.resolve("face.png", Some(&d)).is_some());
std::fs::remove_file(&f).unwrap();
assert!(r.resolve("face.png", Some(&d)).is_some());
std::fs::remove_dir_all(&d).ok();
}
fn encode_base64(bytes: &[u8]) -> String {
use base64::Engine;
base64::engine::general_purpose::STANDARD.encode(bytes)
}
}