mod base64;
mod sha1;
pub use base64::{
decode as base64_decode, encode as base64_encode, encode_mime as base64_encode_mime,
};
pub use sha1::hex as sha1_hex;
use crate::walk;
use carta_ast::Block;
use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MediaItem {
pub mime: Option<String>,
pub bytes: Vec<u8>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct MediaBag {
items: BTreeMap<String, MediaItem>,
}
impl MediaBag {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn insert(&mut self, name: impl Into<String>, mime: Option<String>, bytes: Vec<u8>) {
self.items.insert(name.into(), MediaItem { mime, bytes });
}
#[must_use]
pub fn get(&self, name: &str) -> Option<&MediaItem> {
self.items.get(name)
}
#[must_use]
pub fn contains(&self, name: &str) -> bool {
self.items.contains_key(name)
}
pub fn iter(&self) -> impl Iterator<Item = (&str, &MediaItem)> {
self.items.iter().map(|(name, item)| (name.as_str(), item))
}
pub fn names(&self) -> impl Iterator<Item = &str> {
self.items.keys().map(String::as_str)
}
#[must_use]
pub fn len(&self) -> usize {
self.items.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
}
#[must_use]
pub fn decode_data_uri(url: &str) -> Option<(Vec<u8>, String)> {
let rest = url.strip_prefix("data:")?;
let (header, payload) = rest.split_once(',')?;
let header = header.strip_suffix(";base64")?;
let mime = match header.split(';').next() {
Some(kind) if !kind.is_empty() => kind,
_ => "text/plain",
};
let bytes = base64_decode(payload)?;
Some((bytes, mime.to_owned()))
}
#[must_use]
pub fn extension_for_mime(mime: &str) -> &str {
match mime {
"image/png" => "png",
"image/jpeg" => "jpg",
"image/gif" => "gif",
"image/svg+xml" => "svg",
"application/pdf" => "pdf",
other => other
.rsplit('/')
.next()
.and_then(|subtype| subtype.split('+').next())
.unwrap_or(other),
}
}
#[must_use]
pub fn content_addressed_name(mime: &str, bytes: &[u8]) -> String {
format!("{}.{}", sha1_hex(bytes), extension_for_mime(mime))
}
#[must_use]
pub fn extracted_path(dir: &str, name: &str) -> String {
format!("{}/{}", dir.trim_end_matches('/'), name)
}
pub fn rewrite_extracted_references(blocks: &mut [Block], media: &MediaBag, dir: &str) {
walk::for_each_image_target(blocks, &mut |target| {
if media.contains(target.url.as_str()) {
target.url = extracted_path(dir, target.url.as_str()).into();
}
});
}
pub fn embed_referenced_media(
blocks: &mut [Block],
media: &mut MediaBag,
mut resolve: impl FnMut(&str) -> Option<Vec<u8>>,
) {
let mut references: Vec<String> = Vec::new();
let mut seen: BTreeSet<String> = BTreeSet::new();
walk::for_each_image_target(blocks, &mut |target| {
let url = target.url.to_string();
if seen.insert(url.clone()) {
references.push(url);
}
});
for url in references {
if media.contains(&url) || is_remote_reference(&url) {
continue;
}
if let Some(bytes) = resolve(&url) {
let mime = mime_for_extension(&url);
media.insert(url, mime, bytes);
}
}
}
fn is_remote_reference(url: &str) -> bool {
url.starts_with("data:") || url.starts_with("//") || url.contains("://")
}
#[must_use]
pub fn image_mime_for_extension(reference: &str) -> Option<&'static str> {
let extension = reference.rsplit_once('.')?.1.to_ascii_lowercase();
let mime = match extension.as_str() {
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"bmp" => "image/bmp",
"tif" | "tiff" => "image/tiff",
"svg" => "image/svg+xml",
"webp" => "image/webp",
_ => return None,
};
Some(mime)
}
fn mime_for_extension(reference: &str) -> Option<String> {
let extension = reference.rsplit('.').next()?.to_ascii_lowercase();
let mime = match extension.as_str() {
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"svg" => "image/svg+xml",
"webp" => "image/webp",
_ => return None,
};
Some(mime.to_owned())
}
#[cfg(test)]
mod tests {
use super::{
MediaBag, content_addressed_name, embed_referenced_media, extension_for_mime,
extracted_path, rewrite_extracted_references,
};
use carta_ast::{Block, Inline, Target};
#[test]
fn extension_falls_back_to_subtype_without_suffix() {
assert_eq!(extension_for_mime("image/png"), "png");
assert_eq!(extension_for_mime("image/jpeg"), "jpg");
assert_eq!(extension_for_mime("image/svg+xml"), "svg");
assert_eq!(extension_for_mime("application/pdf"), "pdf");
assert_eq!(extension_for_mime("image/webp"), "webp");
assert_eq!(extension_for_mime("application/geo+json"), "geo");
}
#[test]
fn content_addressed_name_is_stable_for_equal_bytes() {
let name = content_addressed_name("image/png", b"the same bytes");
assert_eq!(name, content_addressed_name("image/png", b"the same bytes"));
assert_eq!(name.rsplit('.').next(), Some("png"));
assert_eq!(name.len(), 40 + 1 + 3);
}
#[test]
fn bag_keeps_entries_in_name_order() {
let mut bag = MediaBag::new();
bag.insert("z.png", Some("image/png".to_owned()), vec![1]);
bag.insert("a.png", None, vec![2]);
let names: Vec<&str> = bag.names().collect();
assert_eq!(names, ["a.png", "z.png"]);
assert_eq!(bag.len(), 2);
assert!(bag.contains("a.png"));
assert_eq!(
bag.get("a.png").map(|item| item.bytes.clone()),
Some(vec![2])
);
}
#[test]
fn extracted_path_joins_with_a_single_slash() {
assert_eq!(extracted_path("media", "a.png"), "media/a.png");
assert_eq!(extracted_path("assets/img", "a.png"), "assets/img/a.png");
assert_eq!(extracted_path("media/", "a.png"), "media/a.png");
}
#[test]
fn rewrite_points_bag_references_at_their_extracted_paths() {
let mut bag = MediaBag::new();
bag.insert("a.png", Some("image/png".to_owned()), vec![1]);
let mut blocks = vec![
Block::Para(vec![image("a.png")]),
Block::Para(vec![image("https://example.com/b.png")]),
];
rewrite_extracted_references(&mut blocks, &bag, "media");
let urls: Vec<&str> = blocks.iter().map(image_url).collect();
assert_eq!(urls, ["media/a.png", "https://example.com/b.png"]);
}
fn image(url: &str) -> Inline {
Inline::Image(
Box::default(),
Vec::new(),
Box::new(Target {
url: url.into(),
title: carta_ast::Text::default(),
}),
)
}
fn image_url(block: &Block) -> &str {
let Block::Para(inlines) = block else {
panic!("expected para");
};
let Some(Inline::Image(_, _, target)) = inlines.first() else {
panic!("expected image");
};
target.url.as_str()
}
#[test]
fn embed_resolves_each_local_reference_once_and_types_it() {
let mut bag = MediaBag::new();
let mut blocks = vec![
Block::Para(vec![image("a.png"), image("photo.JPG")]),
Block::Para(vec![image("a.png")]),
Block::Para(vec![image("https://example.com/b.png")]),
Block::Para(vec![image("data:image/png;base64,AAAA")]),
Block::Para(vec![image("gone.gif")]),
];
let mut resolved = Vec::new();
embed_referenced_media(&mut blocks, &mut bag, |reference| {
resolved.push(reference.to_owned());
if reference == "gone.gif" {
None
} else {
Some(reference.as_bytes().to_vec())
}
});
assert_eq!(resolved, ["a.png", "photo.JPG", "gone.gif"]);
let entries: Vec<(&str, Option<&str>)> = bag
.iter()
.map(|(name, item)| (name, item.mime.as_deref()))
.collect();
assert_eq!(
entries,
[
("a.png", Some("image/png")),
("photo.JPG", Some("image/jpeg")),
]
);
}
#[test]
fn embed_skips_a_reference_the_bag_already_holds() {
let mut bag = MediaBag::new();
bag.insert("a.png", Some("image/png".to_owned()), vec![9]);
let mut blocks = vec![Block::Para(vec![image("a.png")])];
let mut consulted = false;
embed_referenced_media(&mut blocks, &mut bag, |_| {
consulted = true;
Some(vec![1])
});
assert!(!consulted);
assert_eq!(
bag.get("a.png").map(|item| item.bytes.clone()),
Some(vec![9])
);
}
#[test]
fn insert_replaces_an_existing_name() {
let mut bag = MediaBag::new();
bag.insert("x", None, vec![1]);
bag.insert("x", Some("image/png".to_owned()), vec![2, 3]);
assert_eq!(bag.len(), 1);
let item = bag.get("x").expect("entry present");
assert_eq!(item.bytes, vec![2, 3]);
assert_eq!(item.mime.as_deref(), Some("image/png"));
}
}