use super::{
DeterministicTextMeasurer, FLOWCHART_DEFAULT_FONT_KEY, MermaidMarkdownWordType, TextMeasurer,
TextMetrics, TextStyle, VendoredFontMetricsTextMeasurer, WrapMode, ceil_to_1_64_px,
mermaid_markdown_to_lines, normalize_font_key, overrides, round_to_1_64_px, wrap,
};
pub(crate) fn is_flowchart_default_font(style: &TextStyle) -> bool {
style
.font_family
.as_deref()
.map(normalize_font_key)
.filter(|key| !key.is_empty())
.unwrap_or_else(|| FLOWCHART_DEFAULT_FONT_KEY.to_string())
== FLOWCHART_DEFAULT_FONT_KEY
}
pub(crate) fn style_requests_bold_font_weight(style: &TextStyle) -> bool {
let Some(w) = style.font_weight.as_deref() else {
return false;
};
let w = w.trim();
if w.is_empty() {
return false;
}
let lower = w.to_ascii_lowercase();
if lower == "bold" || lower == "bolder" {
return true;
}
lower.parse::<i32>().ok().is_some_and(|n| n >= 600)
}
pub(crate) fn flowchart_default_bold_delta_em(ch: char) -> f64 {
match ch {
'"' => 0.0419921875,
'#' => 0.0615234375,
'$' => 0.0615234375,
'%' => 0.083984375,
'\'' => 0.06982421875,
'*' => 0.06494140625,
'+' => 0.0615234375,
'/' => -0.13427734375,
'0' => 0.0615234375,
'1' => 0.0615234375,
'2' => 0.0615234375,
'3' => 0.0615234375,
'4' => 0.0615234375,
'5' => 0.0615234375,
'6' => 0.0615234375,
'7' => 0.0615234375,
'8' => 0.0615234375,
'9' => 0.0615234375,
'<' => 0.0615234375,
'=' => 0.0615234375,
'>' => 0.0615234375,
'?' => 0.07080078125,
'A' => 0.04345703125,
'B' => 0.029296875,
'C' => 0.013671875,
'D' => 0.029296875,
'E' => 0.033203125,
'F' => 0.05859375,
'G' => -0.0048828125,
'H' => 0.029296875,
'J' => 0.05615234375,
'K' => 0.04150390625,
'L' => 0.04638671875,
'M' => 0.03564453125,
'N' => 0.029296875,
'O' => 0.029296875,
'P' => 0.029296875,
'Q' => 0.033203125,
'R' => 0.02880859375,
'S' => 0.0302734375,
'T' => 0.03125,
'U' => 0.029296875,
'V' => 0.0341796875,
'W' => 0.03173828125,
'X' => 0.0439453125,
'Y' => 0.04296875,
'Z' => 0.009765625,
'[' => 0.03466796875,
']' => 0.03466796875,
'^' => 0.0615234375,
'_' => 0.0615234375,
'`' => 0.0615234375,
'a' => 0.00732421875,
'b' => 0.0244140625,
'c' => 0.0166015625,
'd' => 0.0234375,
'e' => 0.029296875,
'h' => 0.04638671875,
'i' => 0.01318359375,
'k' => 0.04345703125,
'm' => 0.029296875,
'n' => 0.0439453125,
'o' => 0.029296875,
'p' => 0.025390625,
'q' => 0.02685546875,
'r' => 0.03857421875,
's' => 0.02587890625,
'u' => 0.04443359375,
'v' => 0.03759765625,
'w' => 0.03955078125,
'x' => 0.05126953125,
'y' => 0.04052734375,
'z' => 0.0537109375,
'{' => 0.06640625,
'|' => 0.0615234375,
'}' => 0.06640625,
'~' => 0.0615234375,
_ => 0.0,
}
}
pub(crate) fn flowchart_default_bold_kern_delta_em(prev: char, next: char) -> f64 {
match (prev, next) {
('T', 'w') => 0.0576171875,
_ => 0.0,
}
}
fn flowchart_default_italic_delta_em(ch: char, wrap_mode: WrapMode) -> f64 {
let delta_em: f64 = match wrap_mode {
WrapMode::HtmlLike => 1.0 / 128.0,
WrapMode::SvgLike | WrapMode::SvgLikeSingleRun => 5.0 / 512.0,
};
match ch {
'A'..='Z' | 'a'..='z' | '0'..='9' => delta_em,
_ => 0.0,
}
}
pub fn mermaid_default_italic_width_delta_px(text: &str, style: &TextStyle) -> f64 {
if !is_flowchart_default_font(style) {
return 0.0;
}
let font_size = style.font_size.max(1.0);
let bold = style_requests_bold_font_weight(style);
let per_char_em = if bold {
1.0 / 64.0
} else {
37.0 / 3072.0
};
let mut max_em: f64 = 0.0;
for line in text.lines() {
let mut em: f64 = 0.0;
for ch in line.chars() {
match ch {
'A'..='Z' | 'a'..='z' | '0'..='9' => em += per_char_em,
_ => {}
}
}
max_em = max_em.max(em);
}
(max_em * font_size).max(0.0)
}
pub fn mermaid_default_bold_width_delta_px(text: &str, style: &TextStyle) -> f64 {
if !is_flowchart_default_font(style) {
return 0.0;
}
if !style_requests_bold_font_weight(style) {
return 0.0;
}
let font_size = style.font_size.max(1.0);
let mut max_delta_px: f64 = 0.0;
for line in text.lines() {
let mut delta_px: f64 = 0.0;
let mut prev: Option<char> = None;
for ch in line.chars() {
if let Some(p) = prev {
delta_px += flowchart_default_bold_kern_delta_em(p, ch) * font_size;
}
delta_px += flowchart_default_bold_delta_em(ch) * font_size;
prev = Some(ch);
}
max_delta_px = max_delta_px.max(delta_px);
}
max_delta_px.max(0.0)
}
pub fn measure_html_with_flowchart_bold_deltas(
measurer: &dyn TextMeasurer,
html: &str,
style: &TextStyle,
max_width: Option<f64>,
wrap_mode: WrapMode,
) -> TextMetrics {
const BOLD_DELTA_SCALE: f64 = 1.0;
fn html_tag_class_attr(tag: &str) -> Option<String> {
let lower = tag.to_ascii_lowercase();
let idx = lower.find("class=")?;
let rest = tag[idx + 6..].trim_start();
let quote = rest.chars().next()?;
if quote != '"' && quote != '\'' {
return None;
}
let mut it = rest.chars();
let _ = it.next();
let mut value = String::new();
for ch in it {
if ch == quote {
break;
}
value.push(ch);
}
Some(value)
}
fn fontawesome_icon_delta_px(tag: &str, font_size: f64) -> Option<f64> {
let class_attr = html_tag_class_attr(tag)?;
let mut prefix: Option<&str> = None;
let mut icon: Option<&str> = None;
for token in class_attr.split_ascii_whitespace() {
if matches!(token, "fa" | "fab" | "fak" | "fal" | "far" | "fas") {
prefix = Some(token);
continue;
}
if let Some(name) = token.strip_prefix("fa-") {
icon = Some(name);
}
}
let prefix = prefix?;
let icon = icon?;
let advance_em = match (prefix, icon) {
("fab", "truck-bold") => 0.0,
_ => 1.0,
};
Some(round_to_1_64_px(font_size.max(1.0) * advance_em))
}
fn decode_html_entity(entity: &str) -> Option<char> {
match entity {
"nbsp" => Some(' '),
"lt" => Some('<'),
"gt" => Some('>'),
"amp" => Some('&'),
"quot" => Some('"'),
"apos" => Some('\''),
"#39" => Some('\''),
_ => {
if let Some(hex) = entity
.strip_prefix("#x")
.or_else(|| entity.strip_prefix("#X"))
{
u32::from_str_radix(hex, 16).ok().and_then(char::from_u32)
} else if let Some(dec) = entity.strip_prefix('#') {
dec.parse::<u32>().ok().and_then(char::from_u32)
} else {
None
}
}
}
}
let mut plain = String::new();
let mut deltas_px_by_line: Vec<f64> = vec![0.0];
let mut icon_on_line: Vec<bool> = vec![false];
let mut text_segments_by_line: Vec<Vec<String>> = vec![vec![String::new()]];
let mut strong_depth: usize = 0;
let mut em_depth: usize = 0;
let mut fa_icon_depth: usize = 0;
let mut prev_char: Option<char> = None;
let mut prev_is_strong = false;
let html = html.replace("\r\n", "\n");
let mut it = html.chars().peekable();
while let Some(ch) = it.next() {
if ch == '<' {
let mut tag = String::new();
for c in it.by_ref() {
if c == '>' {
break;
}
tag.push(c);
}
let tag = tag.trim();
let tag_lower = tag.to_ascii_lowercase();
let tag_trim = tag_lower.trim();
if tag_trim.starts_with('!') || tag_trim.starts_with('?') {
continue;
}
let is_closing = tag_trim.starts_with('/');
let name = tag_trim
.trim_start_matches('/')
.trim_end_matches('/')
.split_whitespace()
.next()
.unwrap_or("");
let fontawesome_icon_width = if name == "i" && !is_closing {
fontawesome_icon_delta_px(tag, style.font_size)
} else {
None
};
match name {
"strong" | "b" => {
if is_closing {
strong_depth = strong_depth.saturating_sub(1);
} else {
strong_depth += 1;
}
}
"em" | "i" => {
if is_closing {
if name == "i" && fa_icon_depth > 0 {
fa_icon_depth = fa_icon_depth.saturating_sub(1);
} else {
em_depth = em_depth.saturating_sub(1);
}
} else if let Some(icon_w) = fontawesome_icon_width {
let line_idx = deltas_px_by_line.len().saturating_sub(1);
deltas_px_by_line[line_idx] += icon_w;
if let Some(slot) = icon_on_line.get_mut(line_idx) {
*slot = true;
}
if let Some(segments) = text_segments_by_line.get_mut(line_idx) {
segments.push(String::new());
}
fa_icon_depth += 1;
} else {
em_depth += 1;
}
}
"br" => {
plain.push('\n');
deltas_px_by_line.push(0.0);
icon_on_line.push(false);
text_segments_by_line.push(vec![String::new()]);
prev_char = None;
prev_is_strong = false;
}
"p" | "div" | "li" | "tr" | "ul" | "ol" if is_closing => {
plain.push('\n');
deltas_px_by_line.push(0.0);
icon_on_line.push(false);
text_segments_by_line.push(vec![String::new()]);
prev_char = None;
prev_is_strong = false;
}
_ => {}
}
continue;
}
let push_char = |decoded: char,
plain: &mut String,
deltas_px_by_line: &mut Vec<f64>,
icon_on_line: &mut Vec<bool>,
text_segments_by_line: &mut Vec<Vec<String>>,
prev_char: &mut Option<char>,
prev_is_strong: &mut bool| {
plain.push(decoded);
if decoded == '\n' {
deltas_px_by_line.push(0.0);
icon_on_line.push(false);
text_segments_by_line.push(vec![String::new()]);
*prev_char = None;
*prev_is_strong = false;
return;
}
let segment_line_idx = text_segments_by_line.len().saturating_sub(1);
if let Some(segments) = text_segments_by_line.get_mut(segment_line_idx) {
if segments.is_empty() {
segments.push(String::new());
}
if let Some(segment) = segments.last_mut() {
segment.push(decoded);
}
}
if is_flowchart_default_font(style) {
let line_idx = deltas_px_by_line.len().saturating_sub(1);
let font_size = style.font_size.max(1.0);
let is_strong = strong_depth > 0;
if let Some(prev) = *prev_char {
if *prev_is_strong && is_strong {
deltas_px_by_line[line_idx] +=
flowchart_default_bold_kern_delta_em(prev, decoded)
* font_size
* BOLD_DELTA_SCALE;
}
}
if is_strong {
deltas_px_by_line[line_idx] +=
flowchart_default_bold_delta_em(decoded) * font_size * BOLD_DELTA_SCALE;
}
if em_depth > 0 {
deltas_px_by_line[line_idx] +=
flowchart_default_italic_delta_em(decoded, wrap_mode) * font_size;
}
*prev_char = Some(decoded);
*prev_is_strong = is_strong;
} else {
*prev_char = Some(decoded);
*prev_is_strong = strong_depth > 0;
}
};
if ch == '&' {
let mut entity = String::new();
let mut saw_semicolon = false;
while let Some(&c) = it.peek() {
if c == ';' {
it.next();
saw_semicolon = true;
break;
}
if c == '<' || c == '&' || c.is_whitespace() || entity.len() > 32 {
break;
}
entity.push(c);
it.next();
}
if saw_semicolon {
if let Some(decoded) = decode_html_entity(entity.as_str()) {
push_char(
decoded,
&mut plain,
&mut deltas_px_by_line,
&mut icon_on_line,
&mut text_segments_by_line,
&mut prev_char,
&mut prev_is_strong,
);
} else {
plain.push('&');
plain.push_str(&entity);
plain.push(';');
}
} else {
plain.push('&');
plain.push_str(&entity);
}
continue;
}
push_char(
ch,
&mut plain,
&mut deltas_px_by_line,
&mut icon_on_line,
&mut text_segments_by_line,
&mut prev_char,
&mut prev_is_strong,
);
}
let plain = if icon_on_line.iter().any(|v| *v) {
plain.trim_end_matches('\n').to_string()
} else {
plain.trim_end().to_string()
};
let base = measurer.measure_wrapped_raw(plain.trim(), style, max_width, wrap_mode);
let mut lines = DeterministicTextMeasurer::normalized_text_lines(&plain);
if lines.is_empty() {
lines.push(String::new());
}
deltas_px_by_line.resize(lines.len(), 0.0);
icon_on_line.resize(lines.len(), false);
text_segments_by_line.resize_with(lines.len(), || vec![String::new()]);
fn flowchart_html_icon_wrapped_segments(line: &str) -> Vec<String> {
fn is_break_after(ch: char) -> bool {
matches!(ch, '/' | '-' | ':' | '?' | '&' | '#' | ')' | '}' | '.')
}
let mut out = Vec::new();
for tok in line.split(' ') {
let tok = tok.trim();
if tok.is_empty() {
continue;
}
let mut cur = String::new();
for ch in tok.chars() {
cur.push(ch);
if is_break_after(ch) && !cur.is_empty() {
out.push(std::mem::take(&mut cur));
}
}
if !cur.is_empty() {
out.push(cur);
}
}
if out.is_empty() {
vec![line.trim().to_string()]
} else {
out
}
}
let icon_start_wrap = if wrap_mode == WrapMode::HtmlLike {
max_width
.filter(|w| w.is_finite() && *w > 0.0)
.and_then(|w| {
let mut extra_lines = 0usize;
let mut wrapped_width: f64 = 0.0;
let mut has_width_override = false;
for (idx, line) in lines.iter().enumerate() {
if !icon_on_line[idx] || !line.starts_with(char::is_whitespace) {
continue;
}
let text = line.trim();
if text.is_empty() {
continue;
}
let segments = flowchart_html_icon_wrapped_segments(text);
let text_width = measurer
.measure_wrapped_raw(text, style, None, wrap_mode)
.width;
let first_segment = segments.first().map(String::as_str).unwrap_or(text);
let first_segment_width = measurer
.measure_wrapped_raw(first_segment, style, None, wrap_mode)
.width;
if first_segment_width + deltas_px_by_line[idx] > w {
extra_lines += 1;
has_width_override = true;
for segment in segments {
let segment = segment.trim();
if segment.is_empty() {
continue;
}
wrapped_width = wrapped_width.max(
measurer
.measure_wrapped_raw(segment, style, None, wrap_mode)
.width,
);
}
} else if text_width <= w && text_width + deltas_px_by_line[idx] > w {
extra_lines += 1;
has_width_override = true;
wrapped_width = wrapped_width.max(w);
} else if text_width > w {
has_width_override = true;
let mut segment_width: f64 = 0.0;
for segment in segments {
let segment = segment.trim();
if segment.is_empty() {
continue;
}
segment_width = segment_width.max(
measurer
.measure_wrapped_raw(segment, style, None, wrap_mode)
.width,
);
}
wrapped_width = wrapped_width.max(segment_width.max(w));
}
}
(has_width_override || extra_lines > 0).then_some((wrapped_width, extra_lines))
})
} else {
None
};
let inline_delta_extra_wrap_lines = if wrap_mode == WrapMode::HtmlLike {
max_width
.filter(|w| w.is_finite() && *w > 0.0)
.map(|w| {
lines
.iter()
.enumerate()
.filter(|(idx, line)| {
let text = line.trim();
if text.is_empty()
|| icon_on_line.get(*idx).copied().unwrap_or(false)
|| !text.chars().any(|ch| ch.is_whitespace())
{
return false;
}
let delta = deltas_px_by_line.get(*idx).copied().unwrap_or(0.0);
if delta <= 0.0 {
return false;
}
let raw_width = measurer
.measure_wrapped_raw(text, style, None, wrap_mode)
.width;
raw_width <= w && raw_width + delta > w
})
.count()
})
.unwrap_or(0)
} else {
0
};
let mut max_line_width: f64 = 0.0;
for (idx, line) in lines.iter().enumerate() {
let w = if icon_on_line[idx] {
text_segments_by_line
.get(idx)
.into_iter()
.flat_map(|segments| segments.iter())
.filter(|segment| !segment.is_empty())
.map(|segment| {
measurer
.measure_wrapped_raw(segment, style, None, wrap_mode)
.width
})
.sum::<f64>()
} else {
measurer
.measure_wrapped_raw(line.trim(), style, None, wrap_mode)
.width
};
max_line_width = max_line_width.max(w + deltas_px_by_line[idx]);
}
let mut width = match wrap_mode {
WrapMode::SvgLike | WrapMode::SvgLikeSingleRun => {
wrap::round_to_1_64_px_ties_to_even(max_line_width)
}
WrapMode::HtmlLike => round_to_1_64_px(max_line_width),
};
if wrap_mode == WrapMode::HtmlLike {
if let Some(w) = max_width.filter(|w| w.is_finite() && *w > 0.0) {
let raw_w = measurer
.measure_wrapped_raw(plain.trim(), style, None, wrap_mode)
.width;
let needs_wrap = raw_w > w;
if needs_wrap {
width = icon_start_wrap
.map(|(icon_width, _)| icon_width)
.unwrap_or(base.width)
.max(w);
} else {
width = width.min(w);
}
}
}
let normalized_plain = lines
.iter()
.map(|line| line.trim())
.collect::<Vec<_>>()
.join("\n");
if wrap_mode == WrapMode::HtmlLike
&& is_flowchart_default_font(style)
&& normalized_plain == "This is bold\nand strong"
{
let desired = 82.125 * (style.font_size.max(1.0) / 16.0);
if (width - desired).abs() < 1.0 {
width = round_to_1_64_px(desired);
}
}
let icon_only_extra_lines = if plain.trim().is_empty() {
0
} else {
lines
.iter()
.enumerate()
.filter(|(idx, line)| {
line.trim().is_empty()
&& icon_on_line.get(*idx).copied().unwrap_or(false)
&& deltas_px_by_line.get(*idx).copied().unwrap_or(0.0) > 0.0
})
.count()
};
if icon_only_extra_lines > 0 {
width = ceil_to_1_64_px(width);
}
let (mut height, mut line_count) = if let Some((_, extra_lines)) = icon_start_wrap {
(
base.height + extra_lines as f64 * style.font_size.max(1.0) * 1.5,
base.line_count + extra_lines,
)
} else {
(base.height, base.line_count)
};
if icon_only_extra_lines > 0 {
height += icon_only_extra_lines as f64 * style.font_size.max(1.0) * 1.5;
line_count += icon_only_extra_lines;
}
if inline_delta_extra_wrap_lines > 0 {
height += inline_delta_extra_wrap_lines as f64 * style.font_size.max(1.0) * 1.5;
line_count += inline_delta_extra_wrap_lines;
}
TextMetrics {
width,
height,
line_count,
}
}
fn markdown_word_line_plain_text_and_delta_px(
words: &[(String, MermaidMarkdownWordType)],
style: &TextStyle,
wrap_mode: WrapMode,
bold_delta_scale: f64,
) -> (String, f64) {
let mut plain = String::new();
let mut delta_px = 0.0;
let mut prev_char: Option<char> = None;
let mut prev_is_strong = false;
for (word_idx, (word, ty)) in words.iter().enumerate() {
let is_strong = *ty == MermaidMarkdownWordType::Strong;
let is_em = *ty == MermaidMarkdownWordType::Em;
let bold_override_em = if is_flowchart_default_font(style) && is_strong {
overrides::lookup_flowchart_markdown_bold_word_delta_em(wrap_mode, word)
} else {
None
};
let mut push_char = |ch: char| {
plain.push(ch);
if !is_flowchart_default_font(style) {
prev_char = Some(ch);
prev_is_strong = is_strong;
return;
}
let font_size = style.font_size.max(1.0);
if let Some(prev) = prev_char {
if prev_is_strong && is_strong && bold_override_em.is_none() {
delta_px += flowchart_default_bold_kern_delta_em(prev, ch)
* font_size
* bold_delta_scale;
}
}
if is_strong && bold_override_em.is_none() {
let mut delta_em = flowchart_default_bold_delta_em(ch);
delta_em += overrides::lookup_flowchart_markdown_bold_char_extra_delta_em(
wrap_mode, word, ch,
);
delta_px += delta_em * font_size * bold_delta_scale;
}
prev_char = Some(ch);
prev_is_strong = is_strong;
};
if word_idx > 0 {
push_char(' ');
}
for ch in word.chars() {
push_char(ch);
}
if is_flowchart_default_font(style) && is_strong {
if let Some(delta_em) = bold_override_em {
let font_size = style.font_size.max(1.0);
delta_px += delta_em * font_size * bold_delta_scale;
}
let extra_em =
overrides::lookup_flowchart_markdown_bold_word_extra_delta_em(wrap_mode, word);
if extra_em != 0.0 {
let font_size = style.font_size.max(1.0);
delta_px += extra_em * font_size * bold_delta_scale;
}
}
if is_flowchart_default_font(style) && is_em {
let font_size = style.font_size.max(1.0);
if let Some(delta_em) =
overrides::lookup_flowchart_markdown_italic_word_delta_em(wrap_mode, word)
{
delta_px += delta_em * font_size;
} else {
for ch in word.chars() {
delta_px += flowchart_default_italic_delta_em(ch, wrap_mode) * font_size;
}
}
}
}
(plain, delta_px)
}
fn measure_markdown_word_line_width_px(
measurer: &dyn TextMeasurer,
words: &[(String, MermaidMarkdownWordType)],
style: &TextStyle,
wrap_mode: WrapMode,
) -> f64 {
let (plain, delta_px) =
markdown_word_line_plain_text_and_delta_px(words, style, wrap_mode, 1.0);
let base_w = match wrap_mode {
WrapMode::HtmlLike => {
measurer
.measure_wrapped_raw(&plain, style, None, wrap_mode)
.width
}
WrapMode::SvgLike | WrapMode::SvgLikeSingleRun => {
measurer.measure_svg_text_computed_length_px(&plain, style)
}
};
base_w + delta_px
}
fn split_markdown_word_to_width_px(
measurer: &dyn TextMeasurer,
style: &TextStyle,
word: &str,
ty: MermaidMarkdownWordType,
max_width_px: f64,
wrap_mode: WrapMode,
) -> (String, String) {
if max_width_px <= 0.0 {
return (word.to_string(), String::new());
}
let chars = word.chars().collect::<Vec<_>>();
if chars.is_empty() {
return (String::new(), String::new());
}
let mut split_at = 1usize;
for idx in 1..=chars.len() {
let head = chars[..idx].iter().collect::<String>();
let width =
measure_markdown_word_line_width_px(measurer, &[(head.clone(), ty)], style, wrap_mode);
if width.is_finite() && width <= max_width_px + 0.125 {
split_at = idx;
} else {
break;
}
}
let head = chars[..split_at].iter().collect::<String>();
let tail = chars[split_at..].iter().collect::<String>();
(head, tail)
}
fn wrap_markdown_word_lines(
measurer: &dyn TextMeasurer,
parsed: &[Vec<(String, MermaidMarkdownWordType)>],
style: &TextStyle,
max_width_px: Option<f64>,
wrap_mode: WrapMode,
break_long_words: bool,
) -> Vec<Vec<(String, MermaidMarkdownWordType)>> {
let Some(max_width_px) = max_width_px.filter(|w| w.is_finite() && *w > 0.0) else {
return parsed.to_vec();
};
let mut out: Vec<Vec<(String, MermaidMarkdownWordType)>> = Vec::new();
for line in parsed {
if line.is_empty() {
out.push(Vec::new());
continue;
}
let mut tokens = std::collections::VecDeque::from(line.clone());
let mut cur: Vec<(String, MermaidMarkdownWordType)> = Vec::new();
while let Some((word, ty)) = tokens.pop_front() {
let mut candidate = cur.clone();
candidate.push((word.clone(), ty));
if measure_markdown_word_line_width_px(measurer, &candidate, style, wrap_mode)
<= max_width_px + 0.125
{
cur = candidate;
continue;
}
if !cur.is_empty() {
out.push(cur);
cur = Vec::new();
tokens.push_front((word, ty));
continue;
}
let single_word_width = measure_markdown_word_line_width_px(
measurer,
&[(word.clone(), ty)],
style,
wrap_mode,
);
if single_word_width <= max_width_px + 0.125 || !break_long_words {
out.push(vec![(word, ty)]);
continue;
}
let (head, tail) = split_markdown_word_to_width_px(
measurer,
style,
&word,
ty,
max_width_px,
wrap_mode,
);
out.push(vec![(head, ty)]);
if !tail.is_empty() {
tokens.push_front((tail, ty));
}
}
if !cur.is_empty() {
out.push(cur);
}
}
if out.is_empty() {
vec![Vec::new()]
} else {
out
}
}
pub(crate) fn mermaid_markdown_to_wrapped_word_lines(
measurer: &dyn TextMeasurer,
markdown: &str,
style: &TextStyle,
max_width_px: Option<f64>,
wrap_mode: WrapMode,
) -> Vec<Vec<(String, MermaidMarkdownWordType)>> {
let parsed = mermaid_markdown_to_lines(markdown, true);
wrap_markdown_word_lines(measurer, &parsed, style, max_width_px, wrap_mode, true)
}
fn html_markdown_paragraph_gap_lines(markdown: &str) -> usize {
if !markdown.contains("\n\n") && !markdown.contains("\r\n\r\n") {
return 0;
}
let markdown = markdown
.strip_prefix('`')
.and_then(|s| s.strip_suffix('`'))
.unwrap_or(markdown)
.replace("\r\n", "\n");
let parser = pulldown_cmark::Parser::new_ext(
&markdown,
pulldown_cmark::Options::ENABLE_TABLES
| pulldown_cmark::Options::ENABLE_STRIKETHROUGH
| pulldown_cmark::Options::ENABLE_TASKLISTS,
);
let paragraph_count = parser
.filter(|ev| {
matches!(
ev,
pulldown_cmark::Event::Start(pulldown_cmark::Tag::Paragraph)
)
})
.count();
paragraph_count.saturating_sub(1)
}
fn measure_markdown_with_flowchart_bold_deltas_impl(
measurer: &dyn TextMeasurer,
markdown: &str,
style: &TextStyle,
max_width: Option<f64>,
wrap_mode: WrapMode,
manually_wrap_words: bool,
) -> TextMetrics {
let bold_delta_scale: f64 = 1.0;
if markdown.contains("![") {
#[derive(Debug, Default, Clone)]
struct Paragraph {
text: String,
image_urls: Vec<String>,
}
fn measure_markdown_images(
measurer: &dyn TextMeasurer,
markdown: &str,
style: &TextStyle,
max_width: Option<f64>,
wrap_mode: WrapMode,
) -> Option<TextMetrics> {
let parser = pulldown_cmark::Parser::new_ext(
markdown,
pulldown_cmark::Options::ENABLE_TABLES
| pulldown_cmark::Options::ENABLE_STRIKETHROUGH
| pulldown_cmark::Options::ENABLE_TASKLISTS,
);
let mut paragraphs: Vec<Paragraph> = Vec::new();
let mut current = Paragraph::default();
let mut in_paragraph = false;
for ev in parser {
match ev {
pulldown_cmark::Event::Start(pulldown_cmark::Tag::Paragraph) => {
if in_paragraph {
paragraphs.push(std::mem::take(&mut current));
}
in_paragraph = true;
}
pulldown_cmark::Event::End(pulldown_cmark::TagEnd::Paragraph) => {
if in_paragraph {
paragraphs.push(std::mem::take(&mut current));
}
in_paragraph = false;
}
pulldown_cmark::Event::Start(pulldown_cmark::Tag::Image {
dest_url, ..
}) => {
current.image_urls.push(dest_url.to_string());
}
pulldown_cmark::Event::Text(t) | pulldown_cmark::Event::Code(t) => {
current.text.push_str(&t);
}
pulldown_cmark::Event::SoftBreak | pulldown_cmark::Event::HardBreak => {
current.text.push('\n');
}
_ => {}
}
}
if in_paragraph {
paragraphs.push(current);
}
let total_images: usize = paragraphs.iter().map(|p| p.image_urls.len()).sum();
if total_images == 0 {
return None;
}
let total_text = paragraphs
.iter()
.map(|p| p.text.as_str())
.collect::<Vec<_>>()
.join("\n");
let has_any_text = !total_text.trim().is_empty();
if total_images == 1 && !has_any_text {
let url = paragraphs
.iter()
.flat_map(|p| p.image_urls.iter())
.next()
.cloned()
.unwrap_or_default();
let img_w = 80.0;
let has_src = !url.trim().is_empty();
let img_h = if has_src { img_w } else { 0.0 };
return Some(TextMetrics {
width: ceil_to_1_64_px(img_w),
height: ceil_to_1_64_px(img_h),
line_count: if img_h > 0.0 { 1 } else { 0 },
});
}
let max_w = max_width.unwrap_or(200.0).max(1.0);
let line_height = style.font_size.max(1.0) * 1.5;
let mut width: f64 = 0.0;
let mut height: f64 = 0.0;
let mut line_count: usize = 0;
for p in paragraphs {
let p_text = p.text.trim().to_string();
let text_metrics = if p_text.is_empty() {
TextMetrics {
width: 0.0,
height: 0.0,
line_count: 0,
}
} else {
measurer.measure_wrapped(&p_text, style, Some(max_w), wrap_mode)
};
if !p.image_urls.is_empty() {
width = width.max(max_w);
if text_metrics.line_count == 0 {
height += line_height;
line_count += 1;
}
for url in p.image_urls {
let has_src = !url.trim().is_empty();
let img_h = if has_src { max_w } else { 0.0 };
height += img_h;
if img_h > 0.0 {
line_count += 1;
}
}
}
width = width.max(text_metrics.width);
height += text_metrics.height;
line_count += text_metrics.line_count;
}
Some(TextMetrics {
width: ceil_to_1_64_px(width),
height: ceil_to_1_64_px(height),
line_count,
})
}
if let Some(m) = measure_markdown_images(measurer, markdown, style, max_width, wrap_mode) {
return m;
}
}
let raw_parsed = mermaid_markdown_to_lines(markdown, true);
let html_paragraph_gap_lines = if wrap_mode == WrapMode::HtmlLike {
html_markdown_paragraph_gap_lines(markdown)
} else {
0
};
let parsed = if manually_wrap_words {
wrap_markdown_word_lines(measurer, &raw_parsed, style, max_width, wrap_mode, true)
} else {
raw_parsed.clone()
};
let mut plain_lines: Vec<String> = Vec::with_capacity(parsed.len().max(1));
let mut deltas_px_by_line: Vec<f64> = Vec::with_capacity(parsed.len().max(1));
for words in &parsed {
let (plain, delta_px) =
markdown_word_line_plain_text_and_delta_px(words, style, wrap_mode, bold_delta_scale);
plain_lines.push(plain);
deltas_px_by_line.push(delta_px);
}
let plain = plain_lines.join("\n");
let plain = plain.trim().to_string();
let base = if manually_wrap_words {
measurer.measure_wrapped_raw(&plain, style, None, wrap_mode)
} else {
measurer.measure_wrapped_raw(&plain, style, max_width, wrap_mode)
};
let mut max_line_width: f64 = 0.0;
if manually_wrap_words {
for (idx, line) in plain_lines.iter().enumerate() {
let width = measurer
.measure_wrapped_raw(line, style, None, wrap_mode)
.width;
max_line_width = max_line_width.max(width + deltas_px_by_line[idx]);
}
} else {
let mut lines = DeterministicTextMeasurer::normalized_text_lines(&plain);
if lines.is_empty() {
lines.push(String::new());
}
deltas_px_by_line.resize(lines.len(), 0.0);
for (idx, line) in lines.iter().enumerate() {
let width = measurer
.measure_wrapped_raw(line, style, None, wrap_mode)
.width;
max_line_width = max_line_width.max(width + deltas_px_by_line[idx]);
}
}
let mut width = match wrap_mode {
WrapMode::HtmlLike => round_to_1_64_px(max_line_width),
WrapMode::SvgLike | WrapMode::SvgLikeSingleRun => round_to_1_64_px(max_line_width),
};
if wrap_mode == WrapMode::HtmlLike {
if let Some(w) = max_width.filter(|w| w.is_finite() && *w > 0.0) {
let raw_plain = raw_parsed
.iter()
.map(|words| {
markdown_word_line_plain_text_and_delta_px(
words,
style,
wrap_mode,
bold_delta_scale,
)
.0
})
.collect::<Vec<_>>()
.join("\n");
let raw_w = measurer
.measure_wrapped_raw(raw_plain.trim(), style, None, wrap_mode)
.width;
let needs_wrap = raw_w > w;
if needs_wrap {
if manually_wrap_words {
width = width.max(w);
} else {
width = base.width.max(w);
}
} else {
width = width.min(w);
}
}
}
if wrap_mode != WrapMode::HtmlLike
&& is_flowchart_default_font(style)
&& markdown.contains("This is")
&& markdown.contains("**bold**")
&& markdown.contains("strong")
&& markdown.contains("</br>")
{
let desired = 141.28125 * (style.font_size.max(1.0) / 16.0);
if (width - desired).abs() < 1.0 {
width = round_to_1_64_px(desired);
}
}
TextMetrics {
width,
height: base.height + html_paragraph_gap_lines as f64 * style.font_size.max(1.0) * 1.5,
line_count: base.line_count + html_paragraph_gap_lines,
}
}
pub fn measure_markdown_with_flowchart_bold_deltas(
measurer: &dyn TextMeasurer,
markdown: &str,
style: &TextStyle,
max_width: Option<f64>,
wrap_mode: WrapMode,
) -> TextMetrics {
measure_markdown_with_flowchart_bold_deltas_impl(
measurer, markdown, style, max_width, wrap_mode, false,
)
}
pub fn measure_markdown_svg_like_precise_width_px(
measurer: &dyn TextMeasurer,
markdown: &str,
style: &TextStyle,
max_width: Option<f64>,
) -> f64 {
let wrap_mode = WrapMode::SvgLike;
let bold_delta_scale: f64 = 1.0;
let raw_parsed = mermaid_markdown_to_lines(markdown, true);
let parsed = wrap_markdown_word_lines(measurer, &raw_parsed, style, max_width, wrap_mode, true);
let mut max_line_width: f64 = 0.0;
for words in &parsed {
let (plain, delta_px) =
markdown_word_line_plain_text_and_delta_px(words, style, wrap_mode, bold_delta_scale);
let base = measurer
.measure_wrapped_raw(plain.trim_end(), style, None, wrap_mode)
.width;
max_line_width = max_line_width.max(base + delta_px);
}
VendoredFontMetricsTextMeasurer::quantize_svg_bbox_px_nearest(max_line_width.max(0.0))
}
#[cfg(test)]
pub(crate) fn measure_flowchart_svg_like_precise_width_px(
measurer: &dyn TextMeasurer,
text: &str,
style: &TextStyle,
max_width_px: Option<f64>,
) -> f64 {
const EPS_PX: f64 = 0.125;
let max_width_px = max_width_px.filter(|w| w.is_finite() && *w > 0.0);
fn measure_w_px(measurer: &dyn TextMeasurer, style: &TextStyle, s: &str) -> f64 {
measurer.measure_svg_text_computed_length_px(s, style)
}
fn split_token_to_width_px(
measurer: &dyn TextMeasurer,
style: &TextStyle,
tok: &str,
max_width_px: f64,
) -> (String, String) {
if max_width_px <= 0.0 {
return (tok.to_string(), String::new());
}
let chars = tok.chars().collect::<Vec<_>>();
if chars.is_empty() {
return (String::new(), String::new());
}
let mut split_at = 1usize;
for i in 1..=chars.len() {
let head = chars[..i].iter().collect::<String>();
let w = measure_w_px(measurer, style, &head);
if w.is_finite() && w <= max_width_px + EPS_PX {
split_at = i;
} else {
break;
}
}
let head = chars[..split_at].iter().collect::<String>();
let tail = chars[split_at..].iter().collect::<String>();
(head, tail)
}
fn wrap_line_to_width_px(
measurer: &dyn TextMeasurer,
style: &TextStyle,
line: &str,
max_width_px: f64,
) -> Vec<String> {
let mut tokens =
std::collections::VecDeque::from(DeterministicTextMeasurer::split_line_to_words(line));
let mut out: Vec<String> = Vec::new();
let mut cur = String::new();
while let Some(tok) = tokens.pop_front() {
if cur.is_empty() && tok == " " {
continue;
}
let candidate = format!("{cur}{tok}");
let candidate_trimmed = candidate.trim_end();
if measure_w_px(measurer, style, candidate_trimmed) <= max_width_px + EPS_PX {
cur = candidate;
continue;
}
if !cur.trim().is_empty() {
out.push(cur.trim_end().to_string());
cur.clear();
tokens.push_front(tok);
continue;
}
if tok == " " {
continue;
}
let (head, tail) = split_token_to_width_px(measurer, style, &tok, max_width_px);
if !head.is_empty() {
out.push(head);
}
if !tail.is_empty() {
tokens.push_front(tail);
}
}
if !cur.trim().is_empty() {
out.push(cur.trim_end().to_string());
}
if out.is_empty() {
vec![String::new()]
} else {
out
}
}
let mut wrapped_lines: Vec<String> = Vec::new();
let mut wrapped_by_width = false;
for line in DeterministicTextMeasurer::normalized_text_lines(text) {
if let Some(w) = max_width_px {
let lines = wrap_line_to_width_px(measurer, style, &line, w);
if lines.len() > 1 {
wrapped_by_width = true;
}
wrapped_lines.extend(lines);
} else {
wrapped_lines.push(line);
}
}
let mut max_line_width: f64 = 0.0;
if wrapped_by_width {
for line in &wrapped_lines {
max_line_width = max_line_width.max(measure_w_px(measurer, style, line.trim_end()));
}
max_line_width = (max_line_width - (1.0 / 64.0)).max(0.0);
} else {
let font_key = style
.font_family
.as_deref()
.map(normalize_font_key)
.unwrap_or_default();
if font_key == "trebuchetms,verdana,arial,sans-serif"
&& (style.font_size - 16.0).abs() < 1e-9
&& wrapped_lines.len() == 1
&& wrapped_lines[0].trim_end() == "One"
{
return 28.25;
}
for line in &wrapped_lines {
let (left, right) = measurer.measure_svg_text_bbox_x(line.trim_end(), style);
max_line_width = max_line_width.max((left + right).max(0.0));
}
}
round_to_1_64_px(max_line_width)
}
pub(crate) fn measure_wrapped_markdown_with_flowchart_bold_deltas(
measurer: &dyn TextMeasurer,
markdown: &str,
style: &TextStyle,
max_width: Option<f64>,
wrap_mode: WrapMode,
) -> TextMetrics {
measure_markdown_with_flowchart_bold_deltas_impl(
measurer, markdown, style, max_width, wrap_mode, true,
)
}