use std::collections::HashMap;
use std::io::Cursor;
use std::path::{Path, PathBuf};
use docling_core::PictureImage;
const MAX_IMAGE_BYTES: u64 = 32 * 1024 * 1024;
pub(crate) trait ImageResolver {
fn resolve(&self, src: &str) -> Option<PictureImage>;
}
pub(crate) struct NoFetch;
impl ImageResolver for NoFetch {
fn resolve(&self, _src: &str) -> Option<PictureImage> {
None
}
}
pub(crate) struct FsImageResolver {
base_dir: Option<PathBuf>,
}
impl FsImageResolver {
pub(crate) fn new(base_dir: Option<PathBuf>) -> Self {
Self { base_dir }
}
}
impl ImageResolver for FsImageResolver {
fn resolve(&self, src: &str) -> Option<PictureImage> {
let src = src.trim();
if src.is_empty() {
return None;
}
if src.starts_with("data:") {
return from_data_uri(src);
}
if src.starts_with("http://") || src.starts_with("https://") {
return fetch_remote(src);
}
let rel = src.strip_prefix("file://").unwrap_or(src);
let path = Path::new(rel);
let full = if path.is_absolute() {
path.to_path_buf()
} else {
self.base_dir.as_ref()?.join(rel)
};
let data = std::fs::read(&full).ok()?;
super::ooxml::picture_image(full.to_str().unwrap_or(rel), data)
}
}
pub(crate) struct MapImageResolver {
images: HashMap<String, PictureImage>,
}
impl MapImageResolver {
pub(crate) fn new(images: HashMap<String, PictureImage>) -> Self {
Self { images }
}
}
impl ImageResolver for MapImageResolver {
fn resolve(&self, src: &str) -> Option<PictureImage> {
self.images.get(src.trim()).cloned()
}
}
fn from_data_uri(uri: &str) -> Option<PictureImage> {
let rest = uri.strip_prefix("data:")?;
let (meta, payload) = rest.split_once(',')?;
let mime = meta
.split(';')
.next()
.filter(|m| !m.is_empty())
.unwrap_or("image/png");
let data = if meta.split(';').any(|t| t.eq_ignore_ascii_case("base64")) {
docling_core::base64::decode(payload)?
} else {
percent_decode(payload)
};
build_picture(mime, data)
}
fn fetch_remote(url: &str) -> Option<PictureImage> {
let mut resp = ureq::get(url).call().ok()?;
let content_type = resp
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.map(|c| {
c.split(';')
.next()
.unwrap_or("")
.trim()
.to_ascii_lowercase()
});
let data = resp
.body_mut()
.with_config()
.limit(MAX_IMAGE_BYTES)
.read_to_vec()
.ok()?;
match content_type {
Some(mime) if mime.starts_with("image/") => build_picture(mime, data),
_ => {
let path = url.split(['?', '#']).next().unwrap_or(url);
super::ooxml::picture_image(path, data)
}
}
}
pub(crate) fn build_picture(mimetype: impl Into<String>, data: Vec<u8>) -> Option<PictureImage> {
if data.is_empty() {
return None;
}
let (width, height) = image::ImageReader::new(Cursor::new(&data))
.with_guessed_format()
.ok()?
.into_dimensions()
.ok()?;
Some(PictureImage {
mimetype: mimetype.into(),
width,
height,
data,
})
}
fn percent_decode(s: &str) -> Vec<u8> {
let b = s.as_bytes();
let mut out = Vec::with_capacity(b.len());
let mut i = 0;
while i < b.len() {
if b[i] == b'%' && i + 2 < b.len() {
if let (Some(h), Some(l)) = (hex(b[i + 1]), hex(b[i + 2])) {
out.push((h << 4) | l);
i += 3;
continue;
}
}
out.push(b[i]);
i += 1;
}
out
}
fn hex(c: u8) -> Option<u8> {
match c {
b'0'..=b'9' => Some(c - b'0'),
b'a'..=b'f' => Some(c - b'a' + 10),
b'A'..=b'F' => Some(c - b'A' + 10),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use docling_core::base64::encode;
const RED_PNG: &[u8] = &[
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44,
0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90,
0x77, 0x53, 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, 0x54, 0x08, 0xd7, 0x63, 0xf8,
0xcf, 0xc0, 0x00, 0x00, 0x00, 0x03, 0x00, 0x01, 0x6e, 0x2c, 0xdc, 0x33, 0x00, 0x00, 0x00,
0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
];
#[test]
fn decodes_base64_data_uri() {
let uri = format!("data:image/png;base64,{}", encode(RED_PNG));
let img = from_data_uri(&uri).expect("decodes");
assert_eq!(img.mimetype, "image/png");
assert_eq!((img.width, img.height), (1, 1));
assert_eq!(img.data, RED_PNG);
}
#[test]
fn rejects_garbage_data_uri() {
assert!(from_data_uri("data:image/png;base64,not-an-image").is_none());
assert!(from_data_uri("data:,").is_none());
}
#[test]
fn nofetch_resolves_nothing() {
assert!(NoFetch.resolve("data:image/png;base64,AAAA").is_none());
}
#[test]
fn map_resolver_returns_by_key() {
let img = from_data_uri(&format!("data:image/png;base64,{}", encode(RED_PNG))).unwrap();
let mut map = HashMap::new();
map.insert("images/x.png".to_string(), img.clone());
let r = MapImageResolver::new(map);
assert_eq!(r.resolve("images/x.png"), Some(img));
assert!(r.resolve("images/missing.png").is_none());
}
#[test]
fn fs_resolver_reads_absolute_files_but_not_relative_without_base() {
let p = std::env::temp_dir().join(format!("docling.rs_img_{}.png", std::process::id()));
std::fs::write(&p, RED_PNG).unwrap();
let r = FsImageResolver::new(None);
let img = r.resolve(p.to_str().unwrap()).expect("reads local file");
assert_eq!((img.width, img.height), (1, 1));
let _ = std::fs::remove_file(&p);
assert!(FsImageResolver::new(None)
.resolve("nope/relative.png")
.is_none());
assert!(r
.resolve(&format!("data:image/png;base64,{}", encode(RED_PNG)))
.is_some());
}
}