use super::ast::Block;
pub use super::ast::MermaidResult;
use crate::channels::telegram::markdown::escape_html;
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/";
pub(crate) const MERMAID_INK_SVG_BASE: &str = "https://mermaid.ink/svg/";
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;
pub(crate) const PREVALIDATE_TIMEOUT_SECS: u64 = 30;
pub(crate) const PREVALIDATE_CONNECT_TIMEOUT_SECS: u64 = 5;
const BODY_RETRY_DELAY_MS: u64 = 300;
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)
}
pub(crate) fn ink_url_svg(source: &str) -> String {
format!("{}{}", MERMAID_INK_SVG_BASE, base64url(source))
}
fn ink_url_params(source: &str, params: &str) -> String {
format!("{}{}{}", MERMAID_INK_BASE, base64url(source), params)
}
pub(crate) const STANDARD_VIEWPORT_MAX_WIDTH: u32 = 1200;
pub(crate) const STANDARD_VIEWPORT_MAX_HEIGHT: u32 = 1800;
pub(crate) fn is_diagram_capped(w: u32, h: u32) -> bool {
w > STANDARD_VIEWPORT_MAX_WIDTH
|| h > STANDARD_VIEWPORT_MAX_HEIGHT
|| (w > 0 && h > 0 && ((w as f32 / h as f32) > 2.5 || (h as f32 / w as f32) > 3.0))
}
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
}
fn fail(
stage: &str,
source: &str,
note: impl Into<String>,
err: Option<&reqwest::Error>,
) -> MermaidResult {
let note = note.into();
let detail = err.map(|e| e.to_string()).unwrap_or_default();
tracing::warn!(
stage,
source_len = source.len(),
timed_out = err.map(|e| e.is_timeout()).unwrap_or(false),
error = %detail,
note = %note,
"mermaid render failed"
);
MermaidResult::Failed(note)
}
async fn read_body_with_retry(
client: &reqwest::Client,
url: &str,
first: reqwest::Response,
source: &str,
stage: &str,
) -> Result<Vec<u8>, MermaidResult> {
match first.bytes().await {
Ok(b) => return Ok(b.to_vec()),
Err(e) => {
tracing::warn!(
stage,
source_len = source.len(),
timed_out = e.is_timeout(),
error = %e,
"mermaid render body read failed; retrying once"
);
}
}
tokio::time::sleep(Duration::from_millis(BODY_RETRY_DELAY_MS)).await;
let retry = match client.get(url).send().await {
Ok(r) => r,
Err(e) => {
return Err(fail(
&format!("{stage}-retry"),
source,
"diagram renderer unreachable",
Some(&e),
));
}
};
let status = retry.status().as_u16();
let content_type = retry
.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 = retry.text().await.unwrap_or_default();
return Err(fail(
&format!("{stage}-retry"),
source,
error_note(status, &body),
None,
));
}
match retry.bytes().await {
Ok(b) => Ok(b.to_vec()),
Err(e) => Err(fail(
&format!("{stage}-retry"),
source,
"diagram renderer dropped the image",
Some(&e),
)),
}
}
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()
.connect_timeout(std::time::Duration::from_secs(
PREVALIDATE_CONNECT_TIMEOUT_SECS,
))
.timeout(std::time::Duration::from_secs(PREVALIDATE_TIMEOUT_SECS))
.build()
{
Ok(c) => c,
Err(e) => return fail("client", source, "diagram renderer unavailable", Some(&e)),
};
let resp = match client.get(&url).send().await {
Ok(r) => r,
Err(e) => {
let note = if e.is_timeout() {
"diagram renderer timed out"
} else {
"diagram renderer unreachable"
};
return fail("connect", source, note, Some(&e));
}
};
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 read_body_with_retry(&client, &url, resp, source, "body").await {
Ok(b) => b,
Err(outcome) => return outcome,
};
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(e) => {
return fail(
"clamp-connect",
source,
format!(
"rendered diagram {w}x{h} exceeds the photo box and the width-clamp retry failed"
),
Some(&e),
);
}
};
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 fail("clamp-response", source, error_note(cstatus, &cbody), None);
}
let cbytes =
match read_body_with_retry(&client, &clamp_url, cresp, source, "body-clamp")
.await
{
Ok(b) => b,
Err(outcome) => return outcome,
};
if let Some((cw, ch)) = png_dims(&cbytes).filter(|&(cw, ch)| !photo_fits(cw, ch)) {
return fail(
"clamp-dims",
source,
format!(
"rendered diagram exceeds the photo box even at the width clamp: {cw}x{ch} px"
),
None,
);
}
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}");
let md = format!("{}", svg_link_md(source));
(
md,
Some(MediaEntry {
id,
url: Some(url.clone()),
bytes: None,
}),
)
}
MermaidResult::ImageBytes(bytes) => {
let id = format!("diag{index}");
let capped = png_dims(bytes)
.map(|(w, h)| is_diagram_capped(w, h))
.unwrap_or(false);
let md = if capped {
format!("{}", svg_link_md(source))
} else {
format!("")
};
(
md,
Some(MediaEntry {
id,
url: None,
bytes: Some(bytes.clone()),
}),
)
}
MermaidResult::Failed(err) => (markdown_failure_block_with_link(err, source), None),
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 markdown_failure_block_with_link(err: &str, source: &str) -> String {
format!(
"{}\n\n[svg]({})",
markdown_failure_block(err, source),
ink_url_svg(source)
)
}
pub(crate) fn image_html(url: &str) -> String {
format!("<figure><img src=\"{}\"/></figure>", escape_html(url))
}
pub(crate) fn svg_link_html(source: &str) -> String {
format!(
"\n<a href=\"{}\">[svg]</a>",
escape_html(&ink_url_svg(source))
)
}
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_html(err),
escape_html(source)
)
}
pub(crate) fn svg_link_md(source: &str) -> String {
format!("\n[Open SVG vector]({})\n", ink_url_svg(source))
}
pub fn unified_mermaid_rules() -> &'static str {
"Correction rules:\n\
1. Syntax & Notes: Fix the exact token or line reported by the renderer. For sequence diagrams, \
'Note over A,B:' supports at most two participants spanning the range — do not list three or more comma-separated actors. \
Do not use backticks or HTML tags in labels (only '<br/>' is allowed for line breaks).\n\
2. Mobile Layout & Aspect Ratio: Always use top-down vertical layouts ('flowchart TD' or 'direction TB'). \
Never use wide 'LR' layouts or unconstrained horizontal subgraphs that shrink illegibly on mobile screens."
}
pub fn format_mermaid_error(context: &str, errors: &[String]) -> String {
let quoted = errors.join("\n");
let rules = unified_mermaid_rules();
if context.is_empty() {
format!(
"Mermaid diagram syntax error.\n\n\
Renderer diagnostic:\n{quoted}\n\n\
{rules}"
)
} else {
format!(
"Mermaid diagram syntax error in {context}.\n\n\
Renderer diagnostic:\n{quoted}\n\n\
{rules}"
)
}
}