use super::ast::Block;
pub use super::ast::MermaidResult;
use futures::FutureExt;
use futures::future::BoxFuture;
use std::collections::HashMap;
use std::sync::{LazyLock, Mutex};
use std::time::{Duration, Instant};
const MERMAID_INK_BASE: &str = "https://mermaid.ink/img/";
const MERMAID_INK_PARAMS: &str = "?type=png";
const MERMAID_INK_CLAMP_PARAMS: &str = "?type=png&width=1200";
const PHOTO_MAX_TOTAL_DIMS: f32 = 9_600.0;
const PREVALIDATE_TIMEOUT_SECS: u64 = 10;
const ERROR_NOTE_MAX_CHARS: usize = 400;
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct MediaEntry {
pub(crate) id: String,
pub(crate) url: Option<String>,
pub(crate) bytes: Option<Vec<u8>>,
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct MermaidFence {
pub(crate) start: usize,
pub(crate) end: usize,
pub(crate) source: String,
}
pub(crate) fn base64url(input: &str) -> String {
use base64::Engine as _;
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(input.as_bytes())
}
pub(crate) fn looks_like_mermaid_source(source: &str) -> bool {
for raw in source.lines() {
let line = raw.trim();
if line.is_empty() || line.starts_with("%%") {
continue;
}
let mut words = line.split_whitespace();
let head = words.next().unwrap_or("").to_ascii_lowercase();
return match head.as_str() {
"graph" => words.next().is_some_and(|d| {
matches!(
d.to_ascii_lowercase().as_str(),
"td" | "tb" | "bt" | "lr" | "rl"
)
}),
"flowchart" | "sequencediagram" | "classdiagram" | "classdiagram-v2"
| "statediagram" | "statediagram-v2" | "erdiagram" | "journey" | "gantt" | "pie"
| "quadrantchart" | "requirementdiagram" | "gitgraph" | "mindmap" | "timeline"
| "zenuml" | "sankey-beta" | "xychart-beta" | "block-beta" | "packet-beta"
| "architecture-beta" => true,
_ => false,
};
}
false
}
pub(crate) fn has_mermaid_fence(text: &str) -> bool {
let mut in_fence = false;
let mut tagged_mermaid = false;
let mut untagged = false;
let mut body_start = 0usize;
let mut pos = 0usize;
for line in text.split_inclusive('\n') {
let line_end = pos + line.len();
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix("```") {
let info = rest.trim();
if in_fence {
let body = text[body_start..pos].trim_end_matches('\n');
if tagged_mermaid || (untagged && looks_like_mermaid_source(body)) {
return true;
}
}
in_fence = true;
tagged_mermaid = info.eq_ignore_ascii_case("mermaid");
untagged = info.is_empty();
body_start = line_end;
}
pos = line_end;
}
false
}
pub(crate) fn find_mermaid_fences(text: &str) -> Vec<MermaidFence> {
let mut fences = Vec::new();
let mut in_fence = false;
let mut is_mermaid = false;
let mut untagged = false;
let mut block_start = 0usize;
let mut source_start = 0usize;
let mut pos = 0usize;
for line in text.split_inclusive('\n') {
let line_end = pos + line.len();
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix("```") {
let info = rest.trim();
if in_fence {
let source = &text[source_start..pos];
if is_mermaid || (untagged && looks_like_mermaid_source(source)) {
fences.push(MermaidFence {
start: block_start,
end: if info.is_empty() { line_end } else { pos },
source: source.to_string(),
});
}
}
block_start = pos;
source_start = line_end;
is_mermaid = info.eq_ignore_ascii_case("mermaid");
untagged = info.is_empty();
in_fence = true;
}
pos = line_end;
}
fences
}
pub(crate) fn should_render_mermaid(text: &str) -> bool {
let tg = &crate::config::Config::current().channels.telegram;
tg.rich_messages && tg.mermaid_render && has_mermaid_fence(text)
}
pub(crate) fn ink_url(source: &str) -> String {
ink_url_params(source, MERMAID_INK_PARAMS)
}
fn ink_url_params(source: &str, params: &str) -> String {
format!("{}{}{}", MERMAID_INK_BASE, base64url(source), params)
}
pub(crate) fn photo_fits(w: u32, h: u32) -> bool {
(w as f32) + (h as f32) <= PHOTO_MAX_TOTAL_DIMS
}
pub(crate) fn png_dims(png: &[u8]) -> Option<(u32, u32)> {
const PNG_SIG: [u8; 8] = [0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a];
if png.len() < 24 || png[..8] != PNG_SIG {
return None;
}
if png[12..16] != *b"IHDR" {
return None;
}
let w = u32::from_be_bytes([png[16], png[17], png[18], png[19]]);
let h = u32::from_be_bytes([png[20], png[21], png[22], png[23]]);
Some((w, h))
}
const RENDER_CACHE_TTL_SECS: u64 = 600;
const RENDER_CACHE_CAP: usize = 64;
static RENDER_CACHE: LazyLock<Mutex<HashMap<String, (Instant, MermaidResult)>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
pub(crate) fn cache_get(source: &str) -> Option<MermaidResult> {
let source = source.trim_end_matches('\n');
let mut cache = RENDER_CACHE.lock().ok()?;
let now = Instant::now();
cache.retain(|_, (at, _)| now.duration_since(*at) < Duration::from_secs(RENDER_CACHE_TTL_SECS));
cache.get(source).map(|(_, outcome)| outcome.clone())
}
pub(crate) fn cache_put(source: &str, outcome: &MermaidResult) {
let source = source.trim_end_matches('\n'); if !matches!(
outcome,
MermaidResult::ImageBytes(_) | MermaidResult::ParseError(_)
) {
return;
}
let Ok(mut cache) = RENDER_CACHE.lock() else {
return;
};
if cache.len() >= RENDER_CACHE_CAP && !cache.contains_key(source) {
let oldest = cache
.iter()
.min_by_key(|(_, (at, _))| *at)
.map(|(k, _)| k.clone());
if let Some(key) = oldest {
cache.remove(&key);
}
}
cache.insert(source.to_string(), (Instant::now(), outcome.clone()));
}
fn finish(source: &str, outcome: MermaidResult) -> MermaidResult {
cache_put(source, &outcome);
outcome
}
pub(crate) async fn resolve(source: &str) -> MermaidResult {
if let Some(cached) = cache_get(source) {
return cached;
}
let url = ink_url(source);
let client = match reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(PREVALIDATE_TIMEOUT_SECS))
.build()
{
Ok(c) => c,
Err(_) => return MermaidResult::Failed("diagram renderer unavailable".into()),
};
let resp = match client.get(&url).send().await {
Ok(r) => r,
Err(e) => {
let note = if e.is_timeout() {
"diagram renderer timed out".to_string()
} else {
"diagram renderer unreachable".to_string()
};
return MermaidResult::Failed(note);
}
};
let status = resp.status().as_u16();
let content_type = resp
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
if is_image_response(status, &content_type) {
let body = match resp.bytes().await {
Ok(b) => b,
Err(_) => {
return MermaidResult::Failed("diagram renderer dropped the image".into());
}
};
match png_dims(&body) {
Some((w, h)) if !photo_fits(w, h) => {
tracing::warn!(
w,
h,
"natural render exceeds the photo box; retrying at the width clamp"
);
let clamp_url = ink_url_params(source, MERMAID_INK_CLAMP_PARAMS);
let cresp = match client.get(&clamp_url).send().await {
Ok(r) => r,
Err(_) => {
return MermaidResult::Failed(format!(
"rendered diagram {w}x{h} exceeds the photo box and the width-clamp retry failed"
));
}
};
let cstatus = cresp.status().as_u16();
let ctype = cresp
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
if !is_image_response(cstatus, &ctype) {
let cbody = cresp.text().await.unwrap_or_default();
return MermaidResult::Failed(error_note(cstatus, &cbody));
}
let cbytes = match cresp.bytes().await {
Ok(b) => b,
Err(_) => {
return MermaidResult::Failed("width-clamp retry dropped the image".into());
}
};
if let Some((cw, ch)) = png_dims(&cbytes).filter(|&(cw, ch)| !photo_fits(cw, ch)) {
return MermaidResult::Failed(format!(
"rendered diagram exceeds the photo box even at the width clamp: {cw}x{ch} px"
));
}
tracing::info!(
bytes = cbytes.len(),
"mermaid.ink clamp render ok; delivering bytes"
);
return finish(source, MermaidResult::ImageBytes(cbytes.to_vec()));
}
_ => {}
}
tracing::info!(
bytes = body.len(),
"mermaid.ink render ok; delivering bytes"
);
return finish(source, MermaidResult::ImageBytes(body.to_vec()));
}
let body = resp.text().await.unwrap_or_default();
tracing::warn!(
status,
note = %error_note(status, &body),
"mermaid.ink render failed"
);
finish(source, classify_render_failure(status, &body))
}
pub(crate) async fn preflight_parse_errors(text: &str) -> Vec<String> {
let mut errors = Vec::new();
for fence in find_mermaid_fences(text) {
if let MermaidResult::ParseError(note) = resolve(&fence.source).await {
errors.push(note);
}
}
errors
}
pub(crate) fn classify_render_failure(status: u16, body: &str) -> MermaidResult {
if (400..500).contains(&status) && !matches!(status, 408 | 429) {
MermaidResult::ParseError(error_note(status, body))
} else {
MermaidResult::Failed(error_note(status, body))
}
}
pub(crate) fn is_image_response(status: u16, content_type: &str) -> bool {
(200..300).contains(&status) && content_type.to_lowercase().starts_with("image/")
}
pub(crate) fn error_note(status: u16, body: &str) -> String {
let trimmed = body.trim();
if trimmed.is_empty() {
return format!("diagram renderer returned HTTP {status}");
}
trimmed.chars().take(ERROR_NOTE_MAX_CHARS).collect()
}
pub(crate) fn replacement_for(
outcome: &MermaidResult,
index: usize,
source: &str,
) -> (String, Option<MediaEntry>) {
match outcome {
MermaidResult::Image(url) => {
let id = format!("diag{index}");
(
format!(""),
Some(MediaEntry {
id,
url: Some(url.clone()),
bytes: None,
}),
)
}
MermaidResult::ImageBytes(bytes) => {
let id = format!("diag{index}");
(
format!(""),
Some(MediaEntry {
id,
url: None,
bytes: Some(bytes.clone()),
}),
)
}
MermaidResult::Failed(err) | MermaidResult::ParseError(err) => {
(markdown_failure_block(err, source), None)
}
}
}
async fn resolve_fence(source: &str) -> MermaidResult {
resolve(source).await
}
pub(crate) fn resolve_markdown_media(text: &str) -> BoxFuture<'static, (String, Vec<MediaEntry>)> {
let text = text.to_string();
async move {
let fences = find_mermaid_fences(&text);
if fences.is_empty() {
return (text, Vec::new());
}
let mut result = text.clone();
let mut media = Vec::new();
for (i, fence) in fences.iter().enumerate().rev() {
let outcome = resolve_fence(&fence.source).await;
let (replacement, entry) = replacement_for(&outcome, i, &fence.source);
if let Some(e) = entry {
media.push(e);
}
result.replace_range(fence.start..fence.end, &replacement);
}
media.reverse();
(result, media)
}
.boxed()
}
pub(crate) fn resolve_blocks(blocks: Vec<Block>) -> BoxFuture<'static, Vec<Block>> {
async move {
let mut out = Vec::with_capacity(blocks.len());
for block in blocks {
out.push(resolve_block(block).await);
}
out
}
.boxed()
}
fn resolve_block(block: Block) -> BoxFuture<'static, Block> {
async move {
match block {
Block::Code { lang, text }
if lang.as_deref().is_some_and(is_mermaid_lang)
|| (lang.is_none() && looks_like_mermaid_source(&text)) =>
{
let result = resolve_fence(&text).await;
Block::Mermaid {
source: text,
result,
}
}
Block::Quote(inner) => Block::Quote(resolve_blocks(inner).await),
Block::List(mut list) => {
for item in &mut list.items {
item.children = resolve_blocks(std::mem::take(&mut item.children)).await;
}
Block::List(list)
}
Block::Details {
summary,
blocks,
open,
} => Block::Details {
summary,
blocks: resolve_blocks(blocks).await,
open,
},
other => other,
}
}
.boxed()
}
fn is_mermaid_lang(lang: &str) -> bool {
lang.trim().eq_ignore_ascii_case("mermaid")
}
pub(crate) fn markdown_failure_block(err: &str, source: &str) -> String {
format!(
"> ⚠️ **Mermaid diagram could not be rendered**\n\n```\n{err}\n\nSource:\n{source}\n```"
)
}
pub(crate) fn image_html(url: &str) -> String {
format!("<figure><img src=\"{}\"/></figure>", escape(url))
}
pub(crate) fn failure_html(err: &str, source: &str) -> String {
format!(
"<b>⚠️ Mermaid diagram could not be rendered</b>\n<blockquote>{}</blockquote>\n<pre><code>{}</code></pre>",
escape(err),
escape(source)
)
}
fn escape(t: &str) -> String {
t.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
}
#[cfg(test)]
mod tests {
use super::*;
const DIAGRAM: &str = "flowchart TD\n A --> B";
#[test]
fn clean_tagged_block_locates() {
let text = format!("```mermaid\n{DIAGRAM}\n```\nafter");
assert!(has_mermaid_fence(&text));
let fences = find_mermaid_fences(&text);
assert_eq!(fences.len(), 1);
assert_eq!(fences[0].source.trim(), DIAGRAM);
}
#[test]
fn stray_bare_fence_before_tagged_no_longer_desyncs() {
let text = format!("```\n```mermaid\n{DIAGRAM}\n```\nafter");
assert!(has_mermaid_fence(&text));
let fences = find_mermaid_fences(&text);
assert_eq!(
fences.len(),
1,
"tagged diagram must locate past a stray bare opener"
);
assert_eq!(fences[0].source.trim(), DIAGRAM);
let replaced = text[..fences[0].start].to_string() + &text[fences[0].end..];
assert!(
replaced.contains("after"),
"trailing text must survive the swap"
);
}
#[test]
fn two_clean_blocks_both_locate() {
let text = "```mermaid\nflowchart TD\n A --> B\n```\ntext\n```mermaid\nflowchart LR\n C --> D\n```\n";
let fences = find_mermaid_fences(text);
assert_eq!(fences.len(), 2);
assert!(fences[0].source.contains("A --> B"));
assert!(fences[1].source.contains("C --> D"));
}
#[test]
fn non_mermaid_untagged_block_ignored() {
let text = "```\njust some text\n```";
assert!(!has_mermaid_fence(text));
assert!(find_mermaid_fences(text).is_empty());
}
}