use std::collections::BTreeMap;
use std::ops::Range;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use bytes::Bytes;
use futures::future::try_join_all;
use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
use crate::AssetProvider;
use crate::pages::Page;
pub(crate) struct ImageRef {
range: Range<usize>,
pub(crate) dest_url: String,
title: Option<String>,
alt: String,
}
pub(crate) fn image_refs(body: &str) -> Vec<ImageRef> {
let mut refs = Vec::new();
let mut current: Option<(Range<usize>, String, Option<String>, String)> = None;
for (event, range) in Parser::new_ext(body, Options::empty()).into_offset_iter() {
match event {
Event::Start(Tag::Image {
dest_url, title, ..
}) => {
current = Some((
range,
dest_url.into_string(),
(!title.is_empty()).then(|| title.into_string()),
String::new(),
));
}
Event::Text(text) => {
if let Some((_, _, _, alt)) = current.as_mut() {
alt.push_str(&text);
}
}
Event::End(TagEnd::Image) => {
if let Some((range, dest_url, title, alt)) = current.take() {
refs.push(ImageRef {
range,
dest_url,
title,
alt,
});
}
}
_ => {}
}
}
refs
}
pub(crate) async fn collect_images(
pages: &[Page],
provider: &dyn AssetProvider,
) -> Result<BTreeMap<String, Bytes>> {
let mut keys: BTreeMap<String, ()> = BTreeMap::new();
for page in pages {
for r in image_refs(&page.body) {
if !looks_remote(&r.dest_url) {
keys.insert(r.dest_url, ());
}
}
}
let key_vec: Vec<String> = keys.into_keys().collect();
let fetched = try_join_all(key_vec.iter().map(|k| async move {
let bytes = provider.get(k).await?;
Ok::<(String, Option<Bytes>), anyhow::Error>((k.clone(), bytes))
}))
.await?;
Ok(fetched
.into_iter()
.filter_map(|(k, bytes)| bytes.map(|b| (k, b)))
.collect())
}
pub async fn resolve_images(
pages: &mut [Page],
provider: &dyn AssetProvider,
dest: &Path,
) -> Result<Vec<String>> {
let fetched = collect_images(pages, provider).await?;
let mut map: BTreeMap<String, PathBuf> = BTreeMap::new();
for (k, bytes) in &fetched {
let local = dest.join(sanitize_key(k));
if let Some(parent) = local.parent() {
tokio::fs::create_dir_all(parent)
.await
.with_context(|| format!("create parent for {}", local.display()))?;
}
tokio::fs::write(&local, bytes)
.await
.with_context(|| format!("write {}", local.display()))?;
map.insert(k.clone(), local);
}
for page in pages.iter_mut() {
let refs = image_refs(&page.body);
let mut body = page.body.clone();
for r in refs.iter().rev() {
if let Some(local) = map.get(&r.dest_url) {
let replacement =
render_image_ref(&r.alt, &local.display().to_string(), r.title.as_deref());
body.replace_range(r.range.clone(), &replacement);
}
}
page.body = body;
}
Ok(map.into_keys().collect())
}
fn render_image_ref(alt: &str, url: &str, title: Option<&str>) -> String {
let alt = escape(alt, &['\\', '[', ']']);
let url = if url
.chars()
.any(|c| c.is_whitespace() || c == '(' || c == ')')
{
format!("<{url}>")
} else {
url.to_string()
};
match title {
Some(t) => format!("", escape(t, &['\\', '"'])),
None => format!(""),
}
}
fn escape(s: &str, chars: &[char]) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
if chars.contains(&c) {
out.push('\\');
}
out.push(c);
}
out
}
pub(crate) fn looks_remote(url: &str) -> bool {
url.starts_with("http://")
|| url.starts_with("https://")
|| url.starts_with("data:")
|| url.starts_with("file://")
}
pub(crate) fn sanitize_key(key: &str) -> String {
key.replace(['/', '\\'], "__")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::assets::sync_provider;
use crate::pages::PageOrigin;
use bytes::Bytes;
fn page(body: &str) -> Page {
Page {
class: "content".into(),
body: body.into(),
origin: PageOrigin::Explicit,
}
}
#[tokio::test]
async fn rewrites_resolved_keys_only() {
let provider = sync_provider(|key| match key {
"img/diagram.svg" => Ok(Some(Bytes::from_static(b"<svg/>"))),
_ => Ok(None),
});
let tmp = tempfile::tempdir().unwrap();
let mut pages = vec and  and ",
)];
let resolved = resolve_images(&mut pages, &provider, tmp.path())
.await
.unwrap();
assert_eq!(resolved, vec!["img/diagram.svg".to_string()]);
assert!(pages[0].body.contains("img__diagram.svg"));
assert!(
pages[0].body.contains("img/missing.png"),
"missing keys preserved"
);
assert!(
pages[0].body.contains("https://e.com/p.png"),
"remote URLs preserved"
);
}
#[tokio::test]
async fn resolves_titled_image() {
let provider = sync_provider(|key| match key {
"diagram.svg" => Ok(Some(Bytes::from_static(b"<svg/>"))),
_ => Ok(None),
});
let tmp = tempfile::tempdir().unwrap();
let mut pages = vec"#)];
let resolved = resolve_images(&mut pages, &provider, tmp.path())
.await
.unwrap();
assert_eq!(resolved, vec!["diagram.svg".to_string()]);
assert!(pages[0].body.contains("diagram.svg"), "{}", pages[0].body);
}
#[tokio::test]
async fn resolves_angle_bracket_url() {
let provider = sync_provider(|key| match key {
"diagram.svg" => Ok(Some(Bytes::from_static(b"<svg/>"))),
_ => Ok(None),
});
let tmp = tempfile::tempdir().unwrap();
let mut pages = vec")];
let resolved = resolve_images(&mut pages, &provider, tmp.path())
.await
.unwrap();
assert_eq!(resolved, vec!["diagram.svg".to_string()]);
assert!(pages[0].body.contains("diagram.svg"), "{}", pages[0].body);
}
#[tokio::test]
async fn resolves_reference_style_image() {
let provider = sync_provider(|key| match key {
"diagram.svg" => Ok(Some(Bytes::from_static(b"<svg/>"))),
_ => Ok(None),
});
let tmp = tempfile::tempdir().unwrap();
let mut pages = vec![page("![d][ref]\n\n[ref]: diagram.svg \"Fig 1\"\n")];
let resolved = resolve_images(&mut pages, &provider, tmp.path())
.await
.unwrap();
assert_eq!(resolved, vec!["diagram.svg".to_string()]);
assert!(pages[0].body.contains("diagram.svg"), "{}", pages[0].body);
assert!(
!pages[0].body.contains("![d][ref]"),
"reference-style form should be normalised to inline: {}",
pages[0].body
);
}
#[tokio::test]
async fn dedup_fetches_same_key_only_once() {
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
let calls = Arc::new(AtomicUsize::new(0));
let calls_c = calls.clone();
let provider = sync_provider(move |k| {
calls_c.fetch_add(1, Ordering::SeqCst);
if k == "shared.png" {
Ok(Some(Bytes::from_static(b"x")))
} else {
Ok(None)
}
});
let tmp = tempfile::tempdir().unwrap();
let mut pages = vec"), page("")];
resolve_images(&mut pages, &provider, tmp.path())
.await
.unwrap();
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
}