use crate::projection::{is_bold_item, is_italic_item, is_mono_item, is_strike_item};
use crate::types::{ProjectedLine, TextItem};
use super::paragraphs::{collapse_whitespace, dehyphenate_join};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct SpanStyle {
pub(super) bold: bool,
pub(super) italic: bool,
pub(super) mono: bool,
pub(super) strike: bool,
}
impl SpanStyle {
pub(super) fn from_item(item: &TextItem) -> Self {
SpanStyle {
bold: is_bold_item(item),
italic: is_italic_item(item),
mono: is_mono_item(item),
strike: is_strike_item(item),
}
}
pub(super) fn is_plain(self) -> bool {
!self.bold && !self.italic && !self.mono && !self.strike
}
}
pub(super) fn escape_inline(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'\\' | '*' | '_' => {
out.push('\\');
out.push(c);
}
_ => out.push(c),
}
}
out
}
fn apply_link(inner: &str, url: &str) -> String {
if url.contains([' ', '\t', '(', ')']) {
format!("[{}](<{}>)", inner, url)
} else {
format!("[{}]({})", inner, url)
}
}
fn apply_style(inner: &str, style: SpanStyle) -> String {
let styled = if style.mono {
if inner.contains('`') {
format!("`` {} ``", inner)
} else {
format!("`{}`", inner)
}
} else {
match (style.bold, style.italic) {
(true, true) => format!("***{}***", inner),
(true, false) => format!("**{}**", inner),
(false, true) => format!("*{}*", inner),
(false, false) => inner.to_string(),
}
};
if style.strike {
format!("~~{}~~", styled)
} else {
styled
}
}
pub(super) fn render_line_inline(line: &ProjectedLine) -> String {
let spans: Vec<&TextItem> = line
.spans
.iter()
.filter(|s| !s.text.trim().is_empty())
.collect();
if spans.is_empty() {
return collapse_whitespace(&line.text);
}
let mut spans = spans;
spans.sort_by(|a, b| a.x.total_cmp(&b.x));
let styles: Vec<SpanStyle> = spans.iter().map(|s| SpanStyle::from_item(s)).collect();
let links: Vec<Option<&str>> = spans.iter().map(|s| s.link.as_deref()).collect();
let uniform = styles.iter().all(|s| *s == styles[0]) && links.iter().all(|l| l.is_none());
if uniform {
let joined = collapse_whitespace(&line.text);
if joined.is_empty() {
return joined;
}
let escaped = escape_inline(&joined);
if styles[0].is_plain() {
return escaped;
}
return apply_style(&escaped, styles[0]);
}
let mut out = String::new();
let mut i = 0;
while i < spans.len() {
let style = styles[i];
let link = links[i];
let mut j = i + 1;
while j < spans.len() && styles[j] == style && links[j] == link {
j += 1;
}
let mut group_text = String::new();
for span in &spans[i..j] {
if !group_text.is_empty() && !group_text.ends_with(' ') {
group_text.push(' ');
}
group_text.push_str(span.text.trim());
}
let group_text = collapse_whitespace(&group_text);
let escaped = escape_inline(&group_text);
let mut rendered = if style.is_plain() {
escaped
} else {
apply_style(&escaped, style)
};
if let Some(url) = link {
rendered = apply_link(&rendered, url);
}
if !out.is_empty() && !out.ends_with(' ') {
out.push(' ');
}
out.push_str(&rendered);
i = j;
}
out
}
pub(super) fn render_list_item_text(line: &ProjectedLine, marker: &str, rest: &str) -> String {
if let Some(style) = line_uniform_style(line) {
let plain = collapse_whitespace(rest);
let escaped = escape_inline(&plain);
return if style.is_plain() {
escaped
} else {
apply_style(&escaped, style)
};
}
let full = render_line_inline(line);
if let Some(stripped) = strip_leading_marker_from_inline(&full, marker) {
return stripped;
}
escape_inline(&collapse_whitespace(rest))
}
fn strip_leading_marker_from_inline(s: &str, marker: &str) -> Option<String> {
for wrap in ["***", "**", "*", "`"] {
let prefix = format!("{wrap}{marker}{wrap} ");
if let Some(rest) = s.strip_prefix(&prefix) {
return Some(rest.to_string());
}
}
let prefix = format!("{marker} ");
s.strip_prefix(&prefix).map(|r| r.to_string())
}
pub(super) fn append_inline_continuation(
prev_text: &mut String,
next_raw: &str,
next_inline: &str,
) {
let next_raw = collapse_whitespace(next_raw);
dehyphenate_join(prev_text, &next_raw, next_inline);
}
pub(super) fn line_uniform_style(line: &ProjectedLine) -> Option<SpanStyle> {
if line
.spans
.iter()
.any(|s| !s.text.trim().is_empty() && s.link.is_some())
{
return None;
}
let mut iter = line
.spans
.iter()
.filter(|s| !s.text.trim().is_empty())
.map(SpanStyle::from_item);
let first = iter.next()?;
for s in iter {
if s != first {
return None;
}
}
Some(first)
}
pub(super) fn line_all_bold(line: &ProjectedLine) -> bool {
let mut saw_span = false;
for span in &line.spans {
if span.text.trim().is_empty() {
continue;
}
if is_mono_item(span) || !is_bold_item(span) {
return false;
}
saw_span = true;
}
saw_span
}
#[cfg(test)]
mod tests {
use super::super::test_helpers::styled_line;
use super::*;
#[test]
fn render_line_inline_mid_line_bold() {
let l = styled_line(
&[
("regular text with", 50.0, Some("Arial")),
("bold word", 200.0, Some("Arial-Bold")),
],
100.0,
10.0,
);
let out = render_line_inline(&l);
assert!(out.contains("regular text with"), "got: {out}");
assert!(out.contains("**bold word**"), "got: {out}");
assert!(
!out.starts_with("**"),
"mid-line shouldn't open with bold: {out}"
);
}
#[test]
fn render_line_inline_uniform_bold_uses_shortcut() {
let l = styled_line(
&[
("first", 50.0, Some("Arial-Bold")),
("second", 100.0, Some("Arial-Bold")),
],
100.0,
10.0,
);
let out = render_line_inline(&l);
assert!(out.starts_with("**") && out.ends_with("**"), "got: {out}");
assert_eq!(out.matches("**").count(), 2, "got: {out}");
}
#[test]
fn render_line_inline_escapes_emphasis_chars() {
let l = styled_line(&[("5*4=20", 50.0, Some("Arial"))], 100.0, 10.0);
let out = render_line_inline(&l);
assert_eq!(out, "5\\*4=20");
}
#[test]
fn render_line_inline_italic_then_bold() {
let l = styled_line(
&[
("italic", 50.0, Some("Arial-Italic")),
("plain", 100.0, Some("Arial")),
("bold", 150.0, Some("Arial-Bold")),
],
100.0,
10.0,
);
let out = render_line_inline(&l);
assert!(out.contains("*italic*"), "got: {out}");
assert!(out.contains("plain"), "got: {out}");
assert!(out.contains("**bold**"), "got: {out}");
}
#[test]
fn render_line_inline_wraps_link_span() {
let mut l = styled_line(
&[
("see", 50.0, Some("Arial")),
("the docs", 150.0, Some("Arial")),
],
100.0,
10.0,
);
l.spans[1].link = Some("https://example.com/docs".to_string());
let out = render_line_inline(&l);
assert!(out.contains("see"), "got: {out}");
assert!(
out.contains("[the docs](https://example.com/docs)"),
"got: {out}"
);
}
#[test]
fn render_line_inline_link_wraps_outside_emphasis() {
let mut l = styled_line(&[("cite", 50.0, Some("Arial-Italic"))], 100.0, 10.0);
l.spans[0].link = Some("https://example.com/p.pdf".to_string());
let out = render_line_inline(&l);
assert_eq!(out, "[*cite*](https://example.com/p.pdf)");
}
#[test]
fn render_line_inline_link_url_with_space_uses_angle_brackets() {
let mut l = styled_line(&[("link", 50.0, Some("Arial"))], 100.0, 10.0);
l.spans[0].link = Some("https://example.com/a b".to_string());
let out = render_line_inline(&l);
assert_eq!(out, "[link](<https://example.com/a b>)");
}
#[test]
fn render_line_inline_strike_span() {
let mut l = styled_line(
&[
("keep this", 50.0, Some("Arial")),
("removed", 150.0, Some("Arial")),
],
100.0,
10.0,
);
l.spans[1].strike = true;
let out = render_line_inline(&l);
assert!(out.contains("keep this"), "got: {out}");
assert!(out.contains("~~removed~~"), "got: {out}");
}
#[test]
fn render_line_inline_strike_composes_with_bold() {
let mut l = styled_line(&[("gone", 50.0, Some("Arial-Bold"))], 100.0, 10.0);
l.spans[0].strike = true;
let out = render_line_inline(&l);
assert_eq!(out, "~~**gone**~~");
}
#[test]
fn render_line_inline_mono_span() {
let l = styled_line(
&[
("call", 50.0, Some("Arial")),
("foo()", 100.0, Some("Courier")),
("on it", 150.0, Some("Arial")),
],
100.0,
10.0,
);
let out = render_line_inline(&l);
assert!(out.contains("`foo()`"), "got: {out}");
assert!(out.contains("call"));
assert!(out.contains("on it"));
}
}