use std::collections::{HashMap, VecDeque};
use std::io::Cursor;
use std::sync::{LazyLock, Mutex};
use base64::{Engine as _, engine::general_purpose::STANDARD};
use iced::advanced::{image as advanced_image, text};
use iced::widget::{image, markdown};
use iced::{ContentFit, Element, Font, Length};
use crate::util::{MEDIA_MARKER_RE, UnwrapPoison, file_name_or_path, parse_media_marker};
use ::image as image_crate;
pub(crate) fn preprocess(content: &str) -> String {
let s = replace_transcription(content);
let s = replace_media_markers(&s);
replace_saved_annotations(&s)
}
fn replace_transcription(s: &str) -> String {
static RE: LazyLock<regex::Regex> = LazyLock::new(|| {
regex::Regex::new(r"\[(Audio|Video) transcription of [^\]]+\]:\s*")
.expect("transcription regex must compile")
});
RE.replace_all(s, |caps: ®ex::Captures| {
match caps.get(1).map(|m| m.as_str()) {
Some("Audio") => "🔊 ",
_ => "🎬 ",
}
})
.to_string()
}
fn replace_media_markers(s: &str) -> String {
MEDIA_MARKER_RE
.replace_all(s, |caps: ®ex::Captures| {
let (kind, path) = parse_media_marker(caps);
match kind {
"IMAGE" => format!(""),
"AUDIO" => format!("🎵 {}", file_name_or_path(path)),
"VIDEO" => format!("🎬 Video: {}", file_name_or_path(path)),
_ => caps.get_match().as_str().to_string(),
}
})
.to_string()
}
fn replace_saved_annotations(s: &str) -> String {
static SAVED_IMAGE_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
regex::Regex::new(r"\n*\[Saved image: [^\]]*\]")
.expect("saved image annotation regex must compile")
});
static SAVED_VIDEO_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
regex::Regex::new(r"\[Saved video: ([^\]]*)\]")
.expect("saved video annotation regex must compile")
});
let s = SAVED_IMAGE_RE.replace_all(s, "").to_string();
SAVED_VIDEO_RE
.replace_all(&s, |caps: ®ex::Captures| {
let path = caps.get(1).map_or("", |m| m.as_str());
format!("🎬 Video: {}", file_name_or_path(path))
})
.to_string()
}
const MAX_DATA_URI_ENCODED_BYTES: usize = 20 * 1024 * 1024;
const MAX_IMAGE_LONGEST_SIDE_PX: u32 = 2048;
const MAX_DATA_URI_DECODE_ALLOC_BYTES: u64 = 64 * 1024 * 1024;
const MAX_CACHED_DATA_URI_IMAGES: usize = 64;
#[must_use]
fn data_uri_base64_payload(uri: &str) -> Option<&str> {
let rest = uri.strip_prefix("data:")?;
let payload_start = rest.rfind(";base64,")? + ";base64,".len();
Some(&rest[payload_start..])
}
#[must_use]
fn decode_data_uri_image(uri: &str) -> Option<(u32, u32, Vec<u8>)> {
let payload = data_uri_base64_payload(uri)?;
if payload.len() > MAX_DATA_URI_ENCODED_BYTES {
return None;
}
let bytes = STANDARD.decode(payload).ok()?;
let format = image_crate::guess_format(&bytes).ok()?;
let header = image_crate::ImageReader::with_format(Cursor::new(&bytes), format);
let (width, height) = header.into_dimensions().ok()?;
if width.max(height) > MAX_IMAGE_LONGEST_SIDE_PX {
return None;
}
let mut limits = image_crate::Limits::default();
limits.max_image_width = Some(MAX_IMAGE_LONGEST_SIDE_PX);
limits.max_image_height = Some(MAX_IMAGE_LONGEST_SIDE_PX);
limits.max_alloc = Some(MAX_DATA_URI_DECODE_ALLOC_BYTES);
let mut decoder = image_crate::ImageReader::with_format(Cursor::new(&bytes), format);
decoder.limits(limits);
let rgba = decoder.decode().ok()?.to_rgba8();
Some((rgba.width(), rgba.height(), rgba.into_raw()))
}
#[derive(Debug)]
enum CachedDataUriImage {
Decoded(image::Handle),
Failed,
}
static DATA_URI_IMAGE_CACHE: LazyLock<Mutex<DataUriImageCache>> = LazyLock::new(|| {
Mutex::new(DataUriImageCache {
entries: HashMap::new(),
lru: VecDeque::new(),
})
});
struct DataUriImageCache {
entries: HashMap<String, CachedDataUriImage>,
lru: VecDeque<String>,
}
impl DataUriImageCache {
fn get(&mut self, uri: &str) -> Option<&CachedDataUriImage> {
let hit = self.entries.get(uri);
if hit.is_some() {
if let Some(pos) = self.lru.iter().position(|key| key.as_str() == uri) {
let key = self.lru.remove(pos).expect("position found by iteration");
self.lru.push_back(key);
}
}
hit
}
fn insert(&mut self, uri: String, outcome: CachedDataUriImage) {
self.entries.insert(uri.clone(), outcome);
self.lru.push_back(uri);
while self.entries.len() > MAX_CACHED_DATA_URI_IMAGES {
let oldest = self
.lru
.pop_front()
.expect("lru mirrors entries, so a front key always exists");
self.entries.remove(&oldest);
}
}
}
#[must_use]
fn data_uri_image_handle(uri: &str) -> Option<image::Handle> {
let payload = data_uri_base64_payload(uri)?;
if payload.len() > MAX_DATA_URI_ENCODED_BYTES {
return None;
}
let mut cache = DATA_URI_IMAGE_CACHE.lock().unwrap_poison();
match cache.get(uri) {
Some(CachedDataUriImage::Decoded(handle)) => return Some(handle.clone()),
Some(CachedDataUriImage::Failed) => return None,
None => {}
}
let outcome = match decode_data_uri_image(uri) {
Some((width, height, rgba)) => {
CachedDataUriImage::Decoded(image::Handle::from_rgba(width, height, rgba))
}
None => CachedDataUriImage::Failed,
};
let handle = match &outcome {
CachedDataUriImage::Decoded(handle) => Some(handle.clone()),
CachedDataUriImage::Failed => None,
};
cache.insert(uri.to_string(), outcome);
handle
}
pub(crate) struct SelectableMediaViewer;
pub(crate) fn selectable_markdown_view(
items: &[markdown::Item],
settings: impl Into<markdown::Settings>,
) -> Element<'_, markdown::Uri, iced::Theme, iced::Renderer> {
markdown::view_with(items, settings, &SelectableMediaViewer)
}
impl<'a, Theme, Renderer> markdown::Viewer<'a, markdown::Uri, Theme, Renderer>
for SelectableMediaViewer
where
Theme: markdown::Catalog + iced_selection::text::Catalog + 'a,
Renderer: text::Renderer<Paragraph = iced::advanced::graphics::text::Paragraph, Font = Font>
+ advanced_image::Renderer<Handle = image::Handle>
+ 'a,
{
fn on_link_click(url: markdown::Uri) -> markdown::Uri {
url
}
fn image(
&self,
settings: markdown::Settings,
url: &'a markdown::Uri,
_title: &'a str,
_alt: &markdown::Text,
) -> Element<'a, markdown::Uri, Theme, Renderer> {
let url_str = url.as_str();
if url_str.starts_with("data:") {
return match data_uri_image_handle(url_str) {
Some(handle) => image::Image::new(handle)
.width(Length::Fill)
.content_fit(ContentFit::Contain)
.into(),
None => iced::widget::text("🖼️ image")
.size(settings.text_size)
.into(),
};
}
let path = std::path::Path::new(url_str);
if path.exists() {
image::Image::new(url_str)
.width(Length::Fill)
.content_fit(ContentFit::Contain)
.into()
} else {
let filename = file_name_or_path(url_str);
iced::widget::text(format!("🖼️ {filename}"))
.size(settings.text_size)
.into()
}
}
fn heading(
&self,
settings: markdown::Settings,
level: &'a markdown::HeadingLevel,
text: &'a markdown::Text,
index: usize,
) -> Element<'a, markdown::Uri, Theme, Renderer> {
iced_selection::markdown::heading(settings, level, text, index, |url| url)
}
fn paragraph(
&self,
settings: markdown::Settings,
text: &markdown::Text,
) -> Element<'a, markdown::Uri, Theme, Renderer> {
iced_selection::markdown::paragraph(settings, text, |url| url)
}
fn code_block(
&self,
settings: markdown::Settings,
_language: Option<&'a str>,
_code: &'a str,
lines: &'a [markdown::Text],
) -> Element<'a, markdown::Uri, Theme, Renderer> {
iced_selection::markdown::code_block(settings, lines, |url| url)
}
fn unordered_list(
&self,
settings: markdown::Settings,
bullets: &'a [markdown::Bullet],
) -> Element<'a, markdown::Uri, Theme, Renderer> {
iced_selection::markdown::unordered_list(self, settings, bullets)
}
fn ordered_list(
&self,
settings: markdown::Settings,
start: u64,
bullets: &'a [markdown::Bullet],
) -> Element<'a, markdown::Uri, Theme, Renderer> {
iced_selection::markdown::ordered_list(self, settings, start, bullets)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn replace_image_marker() {
assert_eq!(
preprocess("Look [IMAGE:/tmp/photo.png] here"),
"Look  here"
);
}
#[test]
fn replace_image_marker_with_spaces_in_path() {
assert_eq!(
preprocess("img [IMAGE:/tmp/my file.png]"),
"img "
);
}
#[test]
fn replace_audio_marker() {
assert_eq!(
preprocess("Listen [AUDIO:/tmp/recording.ogg]"),
"Listen 🎵 recording.ogg"
);
}
#[test]
fn replace_audio_marker_nested_path() {
assert_eq!(
preprocess("hear [AUDIO:/dir/subdir/rec.ogg]"),
"hear 🎵 rec.ogg"
);
}
#[test]
fn replace_video_marker() {
assert_eq!(
preprocess("Watch [VIDEO:/tmp/video.mp4]"),
"Watch 🎬 Video: video.mp4"
);
}
#[test]
fn replace_audio_transcription() {
assert_eq!(
preprocess("[Audio transcription of recording.ogg]: Hello world"),
"🔊 Hello world"
);
}
#[test]
fn replace_audio_transcription_multiline() {
assert_eq!(
preprocess("[Audio transcription of voice.ogg]: Line one\nLine two"),
"🔊 Line one\nLine two"
);
}
#[test]
fn replace_video_transcription_multiline() {
assert_eq!(
preprocess("[Video transcription of clip.mp4]: Line one\nLine two\n\nEdit this"),
"🎬 Line one\nLine two\n\nEdit this"
);
}
#[test]
fn video_transcription_prevents_overlap_with_video_marker() {
let result =
preprocess("[Video transcription of clip.mp4]: hi there [VIDEO:/tmp/other.mp4]");
assert_eq!(result, "🎬 hi there 🎬 Video: other.mp4");
}
#[test]
fn audio_transcription_prevents_overlap_with_audio_marker() {
let result =
preprocess("[Audio transcription of msg.ogg]: hi there [AUDIO:/tmp/other.ogg]");
assert_eq!(result, "🔊 hi there 🎵 other.ogg");
}
#[test]
fn multiple_markers_mixed() {
let input = "![]() [IMAGE:/tmp/a.png] and [AUDIO:/tmp/b.ogg] end [VIDEO:/tmp/c.mp4]";
let expected = "![]()  and 🎵 b.ogg end 🎬 Video: c.mp4";
assert_eq!(preprocess(input), expected);
}
#[test]
fn no_markers_unchanged() {
assert_eq!(preprocess("Hello world"), "Hello world");
}
#[test]
fn empty_string() {
assert_eq!(preprocess(""), "");
}
#[test]
fn strip_saved_image_annotation() {
assert_eq!(
preprocess("[IMAGE:data:image/png;base64,AAAA]\n\n[Saved image: /uploads/img.png]"),
""
);
}
#[test]
fn strip_saved_image_annotation_at_start() {
assert_eq!(preprocess("[Saved image: /uploads/img.png]"), "");
}
#[test]
fn saved_video_annotation_becomes_placeholder() {
assert_eq!(
preprocess("Clip [Saved video: /uploads/clip.mp4] here"),
"Clip 🎬 Video: clip.mp4 here"
);
}
#[test]
fn saved_video_annotation_keeps_clip_visible_with_transcription() {
assert_eq!(
preprocess(
"[Video transcription of clip.mp4]: Line one\n\n[Saved video: /uploads/clip.mp4]"
),
"🎬 Line one\n\n🎬 Video: clip.mp4"
);
}
#[test]
fn file_name_or_path_unix() {
assert_eq!(file_name_or_path("/foo/bar.txt"), "bar.txt");
}
#[test]
fn file_name_or_path_nested() {
assert_eq!(file_name_or_path("/foo/bar/doc.txt"), "doc.txt");
}
#[test]
fn file_name_or_path_no_dir() {
assert_eq!(file_name_or_path("bar.txt"), "bar.txt");
}
#[test]
fn file_name_or_path_trailing_slash() {
assert_eq!(file_name_or_path("/foo/bar/"), "bar");
}
fn tiny_png(width: u32, height: u32) -> Vec<u8> {
let img = ::image::RgbaImage::from_pixel(width, height, ::image::Rgba([255, 0, 0, 255]));
let mut buf = Vec::new();
img.write_to(&mut Cursor::new(&mut buf), ::image::ImageFormat::Png)
.expect("test PNG must encode");
buf
}
fn data_uri(png: &[u8]) -> String {
format!("data:image/png;base64,{}", STANDARD.encode(png))
}
#[test]
fn data_uri_base64_payload_extracts_payload() {
assert_eq!(
data_uri_base64_payload("data:image/png;base64,AAAA"),
Some("AAAA")
);
}
#[test]
fn data_uri_base64_payload_handles_mediatype_params() {
assert_eq!(
data_uri_base64_payload("data:image/png;charset=utf-8;base64,BBBB"),
Some("BBBB")
);
}
#[test]
fn data_uri_base64_payload_rejects_non_data_uris() {
assert_eq!(data_uri_base64_payload("/tmp/photo.png"), None);
assert_eq!(data_uri_base64_payload("https://example.com/img.png"), None);
assert_eq!(data_uri_base64_payload("data:image/png,raw-bytes"), None);
}
#[test]
fn data_uri_base64_payload_allows_empty_payload() {
assert_eq!(data_uri_base64_payload("data:image/png;base64,"), Some(""));
}
#[test]
fn decode_data_uri_image_decodes_png() {
let uri = data_uri(&tiny_png(2, 1));
let (width, height, rgba) =
decode_data_uri_image(&uri).expect("valid PNG data URI decodes");
assert_eq!((width, height), (2, 1));
assert_eq!(rgba.len(), 8); assert_eq!(&rgba[0..4], &[255, 0, 0, 255]);
}
#[test]
fn decode_data_uri_image_rejects_invalid_base64() {
assert_eq!(
decode_data_uri_image("data:image/png;base64,%%%not-base64%%%"),
None
);
}
#[test]
fn decode_data_uri_image_rejects_non_image_bytes() {
let uri = format!("data:text/plain;base64,{}", STANDARD.encode(b"hello world"));
assert_eq!(decode_data_uri_image(&uri), None);
}
#[test]
fn decode_data_uri_image_rejects_oversized_payload() {
let huge = "A".repeat(MAX_DATA_URI_ENCODED_BYTES + 1);
let uri = format!("data:image/png;base64,{huge}");
assert_eq!(decode_data_uri_image(&uri), None);
}
#[test]
fn decode_data_uri_image_rejects_oversized_dimensions() {
let uri = data_uri(&tiny_png(MAX_IMAGE_LONGEST_SIDE_PX + 1, 1));
assert_eq!(decode_data_uri_image(&uri), None);
}
#[test]
fn decode_data_uri_image_rejects_missing_base64_marker() {
assert_eq!(decode_data_uri_image("data:image/png,AAAA"), None);
}
#[test]
fn data_uri_image_handle_caches_stable_handle() {
let uri = data_uri(&tiny_png(3, 2));
let first = data_uri_image_handle(&uri).expect("valid data URI decodes");
let second = data_uri_image_handle(&uri).expect("cached hit still decodes");
assert_eq!(first.id(), second.id());
}
#[test]
fn data_uri_image_handle_caches_failures() {
let uri = "data:image/png;base64,%%%not-base64%%%";
assert_eq!(data_uri_image_handle(uri), None);
{
let cache = DATA_URI_IMAGE_CACHE.lock().unwrap_poison();
assert!(
matches!(cache.entries.get(uri), Some(CachedDataUriImage::Failed)),
"failed decode must be cached, got {:?}",
cache.entries.get(uri)
);
}
assert_eq!(data_uri_image_handle(uri), None);
}
#[test]
fn data_uri_image_handle_rejects_oversized_payload_without_caching() {
let huge = "A".repeat(MAX_DATA_URI_ENCODED_BYTES + 1);
let uri = format!("data:image/png;base64,{huge}");
assert_eq!(data_uri_image_handle(&uri), None);
let cache = DATA_URI_IMAGE_CACHE.lock().unwrap_poison();
assert!(
!cache.entries.contains_key(&uri),
"over-cap payload must not be cached"
);
}
#[test]
fn data_uri_marker_survives_markdown_parse() {
let processed = preprocess("[IMAGE:data:image/png;base64,AAAA]");
let items: Vec<_> = markdown::parse(&processed).collect();
let [markdown::Item::Image { url, .. }] = items.as_slice() else {
panic!("expected a single Image item, got {items:?}");
};
assert_eq!(url.as_str(), "data:image/png;base64,AAAA");
}
}