#![allow(dead_code)]
use std::ops::Range;
use pulldown_cmark::{Alignment, Event, Tag, TagEnd};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use super::model::{
code_body_text, html_body_text, html_body_text_in, AlertKind, Block, BlockKind, Doc,
HtmlTableCell, Task,
};
use super::{
alert_bar, alert_header_line, code_header, decorate_headings_and_extras, details_bar,
details_marker_line, gutter_span, highlight_body, html_cell_to_markdown, image_loading_line,
image_placeholder_lines, image_text_fallback, inline_math_reservation_style, is_mermaid_info,
math_placeholder_lines, math_raw_lines, math_url, mermaid_diagram_col, mermaid_fence_url,
mermaid_placeholder_lines, next_details_open, normalize_cell, pad_to_width,
parse_cell_segments, prefix_link_icons, render_html_block, render_mermaid_block,
render_table_cells, scan_inline_math, task_prefix_state, BlockAligns, CellAttrs, CellImage,
CellSeg, CodeStyle, ColAlign, ImagePlacement, ImageSlot, KonomaStyles, MathPart, MathSlot,
MermaidSlot, SourceRun, TableCells,
};
pub(crate) struct RenderOut {
pub lines: Vec<Line<'static>>,
pub images: Vec<ImagePlacement>,
pub unsupported: Vec<&'static str>,
pub code_blocks: Vec<String>,
pub tasks: Vec<(char, usize)>,
}
#[derive(Clone, Copy)]
struct BlockCtx {
math_here: bool,
extract_here: bool,
details_interactive: bool,
list_depth: usize,
}
#[allow(clippy::too_many_arguments)] pub(crate) fn render_doc(
doc: &Doc<'_>,
src: &str,
width: u16,
code: CodeStyle,
theme: &str,
icons: bool,
tasks: &[char],
slot_of: &dyn Fn(&str, Option<u16>) -> ImageSlot,
mermaid_slot: &dyn Fn(&str) -> MermaidSlot,
mermaid_caption: &str,
alerts: bool,
math_slot: &dyn Fn(&str, bool) -> MathSlot,
math_on: bool,
) -> RenderOut {
render_doc_aligned(
doc,
src,
width,
code,
theme,
icons,
tasks,
slot_of,
mermaid_slot,
mermaid_caption,
alerts,
math_slot,
math_on,
BlockAligns::default(),
)
}
#[allow(clippy::too_many_arguments)] pub(crate) fn render_doc_aligned(
doc: &Doc<'_>,
src: &str,
width: u16,
code: CodeStyle,
theme: &str,
icons: bool,
tasks: &[char],
slot_of: &dyn Fn(&str, Option<u16>) -> ImageSlot,
mermaid_slot: &dyn Fn(&str) -> MermaidSlot,
mermaid_caption: &str,
alerts: bool,
math_slot: &dyn Fn(&str, bool) -> MathSlot,
math_on: bool,
aligns: BlockAligns,
) -> RenderOut {
let styles = KonomaStyles { code_bg: code.bg };
let math = math_on.then_some(MathCtx {
slot: math_slot,
width,
});
let mermaid = MermaidCtx {
slot: mermaid_slot,
caption: mermaid_caption,
width,
fences_on: !matches!(mermaid_slot(""), MermaidSlot::Text),
ord: 0,
};
let ctx = BlockCtx {
math_here: math.is_some(),
extract_here: true,
details_interactive: true,
list_depth: 0,
};
let mut w = Writer {
lines: Vec::new(),
inline_styles: Vec::new(),
link: None,
styles,
pending_block_gap: false,
math,
mermaid,
slot_of,
images: Vec::new(),
code_blocks: Vec::new(),
task_marks: Vec::new(),
after_math: false,
fresh_boundary: false,
pending_para_start: None,
heading_rule_shift: 0,
code,
theme: theme.to_string(),
width,
icons,
tasks,
alerts,
aligns,
};
render_metadata_block(&mut w, doc);
let mut unsupported: Vec<&'static str> = Vec::new();
for block in &doc.blocks {
match &block.kind {
BlockKind::Heading {
level,
inline,
id,
classes,
attrs,
} => {
let meta = HeadingMeta {
id: id.clone(),
classes: classes.clone(),
attrs: attrs.clone(),
};
render_heading(&mut w, doc, src, *level, inline.clone(), &meta);
}
BlockKind::Paragraph { inline } => {
render_paragraph_dispatch(
&mut w,
doc,
src,
inline.clone(),
ctx.math_here,
ctx.extract_here,
block.src.start,
);
}
BlockKind::ThematicBreak => render_rule(&mut w),
BlockKind::List { start, .. } => match contains_unsupported(&block.children, false) {
Some(bad) => unsupported.push(bad),
None => render_list(&mut w, doc, src, *start, &block.children, ctx),
},
BlockKind::Quote { alert: None, .. } => {
match contains_unsupported(&block.children, true) {
Some(bad) => unsupported.push(bad),
None => render_quote(&mut w, doc, src, &block.children, ctx),
}
}
BlockKind::Quote { alert: Some(_), .. } if !w.alerts => {
match contains_unsupported(&block.children, true) {
Some(bad) => unsupported.push(bad),
None => render_quote(&mut w, doc, src, &block.children, ctx),
}
}
BlockKind::Quote {
alert: Some(kind),
alert_title,
} => match contains_unsupported(&block.children, true) {
Some(bad) => unsupported.push(bad),
None => render_alert_from_model(
&mut w,
doc,
src,
*kind,
alert_title,
&block.children,
block.src.start,
),
},
BlockKind::CodeBlock {
lang, body_spans, ..
} => render_code_block_dispatch(
&mut w,
src,
lang.as_deref(),
body_spans,
ctx.extract_here,
),
BlockKind::Details {
open_attr,
summary,
glued_body,
} => match contains_unsupported(&block.children, false) {
Some(bad) => unsupported.push(bad),
None => render_details_from_model(
&mut w,
doc,
src,
*open_attr,
summary,
&block.children,
glued_body.clone(),
ctx,
),
},
BlockKind::ListItem { .. } => unsupported.push("ListItem"),
BlockKind::Table { aligns, rows } => render_table_from_model(&mut w, src, aligns, rows),
BlockKind::Html { body_spans, .. } => {
render_html_block_from_model(&mut w, src, body_spans)
}
BlockKind::HtmlTable { body_spans, rows } => {
render_html_table_from_model(&mut w, src, body_spans, rows)
}
}
}
let lines = decorate_headings_and_extras(w.lines, width, icons, tasks);
RenderOut {
lines,
images: w.images,
unsupported,
code_blocks: w.code_blocks,
tasks: w.task_marks,
}
}
#[allow(clippy::only_used_in_recursion)]
fn contains_unsupported(blocks: &[Block], in_quote: bool) -> Option<&'static str> {
for b in blocks {
match &b.kind {
BlockKind::Table { .. } | BlockKind::Html { .. } | BlockKind::HtmlTable { .. } => {}
BlockKind::Heading { .. }
| BlockKind::Paragraph { .. }
| BlockKind::ThematicBreak
| BlockKind::CodeBlock { .. } => {}
BlockKind::ListItem { .. } | BlockKind::List { .. } | BlockKind::Details { .. } => {
if let Some(bad) = contains_unsupported(&b.children, in_quote) {
return Some(bad);
}
}
BlockKind::Quote { .. } => {
if let Some(bad) = contains_unsupported(&b.children, true) {
return Some(bad);
}
}
}
}
None
}
struct Writer<'m> {
lines: Vec<Line<'static>>,
inline_styles: Vec<Style>,
link: Option<String>,
styles: KonomaStyles,
pending_block_gap: bool,
math: Option<MathCtx<'m>>,
mermaid: MermaidCtx<'m>,
slot_of: &'m dyn Fn(&str, Option<u16>) -> ImageSlot,
images: Vec<ImagePlacement>,
code_blocks: Vec<String>,
task_marks: Vec<(char, usize)>,
after_math: bool,
fresh_boundary: bool,
pending_para_start: Option<bool>,
code: CodeStyle,
theme: String,
width: u16,
icons: bool,
tasks: &'m [char],
alerts: bool,
aligns: BlockAligns,
heading_rule_shift: usize,
}
impl<'m> Writer<'m> {
fn cur_style(&self) -> Style {
self.inline_styles.last().copied().unwrap_or_default()
}
fn emit_row(&mut self, line: Line<'static>) {
self.emit_row_prefixed(line, None, None);
}
fn emit_row_prefixed(
&mut self,
line: Line<'static>,
prefix: Option<&Span<'static>>,
style: Option<Style>,
) {
self.after_math = false;
self.fresh_boundary = false;
let mut line = match style {
Some(s) => line.patch_style(s),
None => line,
};
if let Some(p) = prefix {
line.spans.insert(0, Span::raw(" "));
line.spans.insert(0, p.clone());
}
self.lines.push(line);
}
fn append_span(&mut self, span: Span<'static>) {
match self.lines.last_mut() {
Some(l) => l.push_span(span),
None => self.emit_row(Line::from(vec![span])),
}
}
fn emit_blank_row(&mut self) {
self.emit_row(Line::default());
}
fn open_pending_para_start(&mut self) {
if let Some(gap_before) = self.pending_para_start.take() {
if gap_before {
self.emit_blank_row();
}
self.emit_blank_row();
self.pending_block_gap = false;
}
}
fn drop_pending_para_start(&mut self) {
self.pending_para_start = None;
}
fn push_inline_style(&mut self, style: Style) {
self.inline_styles.push(self.cur_style().patch(style));
}
fn pop_inline_style(&mut self) {
self.inline_styles.pop();
}
fn write_text(&mut self, text: &str) {
for (i, line) in text.lines().enumerate() {
if i > 0 {
self.emit_blank_row();
}
let style = self.cur_style();
self.append_span(Span::styled(line.to_string(), style));
}
self.pending_block_gap = false;
}
fn write_code_span(&mut self, code: &str) {
self.append_span(Span::styled(code.to_string(), self.styles.code()));
}
fn join_with_space(&mut self) {
self.append_span(Span::raw(" "));
}
fn end_row(&mut self) {
self.emit_blank_row();
}
fn enter_link(&mut self, url: String) {
self.link = Some(url);
}
fn leave_link(&mut self) {
if let Some(link) = self.link.take() {
self.append_span(Span::raw(" ("));
self.append_span(Span::styled(link, self.styles.link()));
self.append_span(Span::raw(")"));
}
}
fn write_task_marker(&mut self, checked: bool) {
let marker = if checked { 'x' } else { ' ' };
let marker_span = Span::raw(format!("[{marker}] "));
if let Some(line) = self.lines.last_mut() {
if let Some(first_span) = line.spans.first_mut() {
let content = first_span.content.to_mut();
if content.ends_with("- ") {
let len = content.len();
content.truncate(len - 2);
content.push_str("- [");
content.push(marker);
content.push_str("] ");
return;
}
}
let idx = line.spans.len().min(1);
line.spans.insert(idx, marker_span);
} else {
self.append_span(marker_span);
}
}
}
fn walk_inline<'a>(
events: &mut impl Iterator<Item = Event<'a>>,
w: &mut Writer<'_>,
stop: Option<TagEnd>,
) {
while let Some(ev) = events.next() {
match ev {
Event::End(e) if Some(e) == stop => return,
Event::Start(tag) => start_inline_tag(events, w, tag),
Event::Text(t) => w.write_text(&t),
Event::Code(c) => w.write_code_span(&c),
Event::SoftBreak => w.join_with_space(),
Event::HardBreak => w.end_row(),
_ => {}
}
}
}
fn start_inline_tag<'a>(
events: &mut impl Iterator<Item = Event<'a>>,
w: &mut Writer<'_>,
tag: Tag<'a>,
) {
match tag {
Tag::Emphasis => wrap_styled(
events,
w,
TagEnd::Emphasis,
Style::new().add_modifier(Modifier::ITALIC),
),
Tag::Strong => wrap_styled(
events,
w,
TagEnd::Strong,
Style::new().add_modifier(Modifier::BOLD),
),
Tag::Strikethrough => wrap_styled(
events,
w,
TagEnd::Strikethrough,
Style::new().add_modifier(Modifier::CROSSED_OUT),
),
Tag::Subscript => wrap_styled(
events,
w,
TagEnd::Subscript,
Style::new().add_modifier(Modifier::DIM | Modifier::ITALIC),
),
Tag::Superscript => wrap_styled(
events,
w,
TagEnd::Superscript,
Style::new().add_modifier(Modifier::DIM | Modifier::ITALIC),
),
Tag::Link { dest_url, .. } => {
w.enter_link(dest_url.into_string());
walk_inline(events, w, Some(TagEnd::Link));
w.leave_link();
}
Tag::Image { .. } => walk_inline(events, w, Some(TagEnd::Image)),
other => skip_balanced(events, other.to_end()),
}
}
fn wrap_styled<'a>(
events: &mut impl Iterator<Item = Event<'a>>,
w: &mut Writer<'_>,
stop: TagEnd,
style: Style,
) {
w.push_inline_style(style);
walk_inline(events, w, Some(stop));
w.pop_inline_style();
}
fn skip_balanced<'a>(events: &mut impl Iterator<Item = Event<'a>>, end: TagEnd) {
while let Some(ev) = events.next() {
match ev {
Event::Start(t) => skip_balanced(events, t.to_end()),
Event::End(e) if e == end => return,
_ => {}
}
}
}
struct HeadingMeta {
id: Option<String>,
classes: Vec<String>,
attrs: Vec<(String, Option<String>)>,
}
impl HeadingMeta {
fn to_suffix(&self) -> Option<String> {
let mut parts = Vec::new();
if let Some(id) = &self.id {
parts.push(format!("#{id}"));
}
for class in &self.classes {
parts.push(format!(".{class}"));
}
for (key, value) in &self.attrs {
match value {
Some(v) => parts.push(format!("{key}={v}")),
None => parts.push(key.clone()),
}
}
if parts.is_empty() {
None
} else {
Some(format!(" {{{}}}", parts.join(" ")))
}
}
}
fn events_iter<'a, 'e>(
events: &'e [(Event<'a>, Range<usize>)],
) -> impl Iterator<Item = Event<'a>> + 'e {
events.iter().map(|(ev, _)| ev.clone())
}
fn render_heading(
w: &mut Writer<'_>,
doc: &Doc<'_>,
src: &str,
level: u8,
inline: Range<usize>,
meta: &HeadingMeta,
) {
if w.pending_block_gap {
w.emit_blank_row();
}
let heading_style = w.styles.heading(level);
let hashes = format!("{} ", "#".repeat(level as usize));
w.emit_row(Line::styled(hashes, heading_style));
w.pending_block_gap = false;
let (text_range, images) = match heading_trailing_images(doc, src, &inline) {
Some((text_range, images)) => (text_range, images),
None => (inline, Vec::new()),
};
walk_inline(&mut events_iter(&doc.events[text_range]), w, None);
if let Some(suffix) = meta.to_suffix() {
w.append_span(Span::styled(suffix, w.styles.heading_meta()));
}
if level <= 2 {
w.heading_rule_shift += 1;
}
w.pending_block_gap = true;
render_image_group(w, &images);
}
fn render_paragraph(w: &mut Writer<'_>, doc: &Doc<'_>, inline: Range<usize>) {
if w.pending_block_gap {
w.emit_blank_row();
}
w.emit_blank_row();
w.pending_block_gap = false;
walk_inline(&mut events_iter(&doc.events[inline]), w, None);
w.pending_block_gap = true;
}
fn render_metadata_block(w: &mut Writer<'_>, doc: &Doc<'_>) {
let Some(start) = doc
.events
.iter()
.position(|(ev, _)| matches!(ev, Event::Start(Tag::MetadataBlock(_))))
else {
return;
};
if w.pending_block_gap {
w.emit_blank_row();
}
let style = w.styles.metadata_block();
w.emit_row_prefixed(Line::from("---"), None, Some(style));
w.emit_row_prefixed(Line::default(), None, Some(style));
for ev in events_iter(&doc.events[start + 1..]) {
match ev {
Event::End(TagEnd::MetadataBlock(_)) => break,
Event::Text(t) => {
for (i, line) in t.lines().enumerate() {
if i > 0 {
w.emit_row_prefixed(Line::default(), None, Some(style));
}
let span = Span::styled(line.to_string(), w.cur_style());
match w.lines.last_mut() {
Some(l) => l.push_span(span),
None => w.emit_row_prefixed(Line::from(vec![span]), None, Some(style)),
}
}
w.pending_block_gap = false;
}
Event::SoftBreak => w.emit_row_prefixed(Line::default(), None, Some(style)),
_ => {}
}
}
w.emit_row_prefixed(Line::from("---"), None, Some(style));
w.pending_block_gap = true;
}
struct MathCtx<'a> {
slot: &'a dyn Fn(&str, bool) -> MathSlot,
width: u16,
}
struct MermaidCtx<'a> {
slot: &'a dyn Fn(&str) -> MermaidSlot,
caption: &'a str,
width: u16,
fences_on: bool,
ord: usize,
}
fn top_level_segment_bounds(events: &[(Event<'_>, Range<usize>)]) -> Vec<(usize, usize)> {
let mut out = Vec::new();
let mut start = 0usize;
let mut depth = 0i32;
for (i, (ev, _)) in events.iter().enumerate() {
match ev {
Event::Start(_) => depth += 1,
Event::End(_) => depth -= 1,
Event::SoftBreak | Event::HardBreak if depth == 0 => {
out.push((start, i));
start = i + 1;
}
_ => {}
}
}
out.push((start, events.len()));
out
}
fn split_top_level_breaks<'e, 'd>(
events: &'e [(Event<'d>, Range<usize>)],
) -> Vec<&'e [(Event<'d>, Range<usize>)]> {
top_level_segment_bounds(events)
.into_iter()
.map(|(s, e)| &events[s..e])
.collect()
}
fn segment_as_block_image(
src: &str,
seg: &[(Event<'_>, Range<usize>)],
) -> Option<(String, String)> {
if seg.is_empty() {
return None;
}
let inner = match seg.first() {
Some((Event::Start(Tag::Link { .. }), _)) => {
if !matches!(seg.last(), Some((Event::End(TagEnd::Link), _))) {
return None;
}
&seg[1..seg.len() - 1]
}
_ => seg,
};
if inner.is_empty() {
return None;
}
let image_starts = inner
.iter()
.filter(|(ev, _)| matches!(ev, Event::Start(Tag::Image { .. })))
.count();
if image_starts > 0 {
if image_starts != 1
|| !matches!(inner.first(), Some((Event::Start(Tag::Image { .. }), _)))
|| !matches!(inner.last(), Some((Event::End(TagEnd::Image), _)))
{
return None;
}
let Some((Event::Start(Tag::Image { dest_url, .. }), _)) = inner.first() else {
return None;
};
let url = dest_url.to_string();
if url.is_empty() {
return None;
}
let alt = image_alt_text(&inner[1..inner.len() - 1]);
return Some((alt, url));
}
if !inner
.iter()
.all(|(ev, _)| matches!(ev, Event::InlineHtml(_) | Event::Html(_)))
{
return None;
}
let start = inner.first()?.1.start;
let end = inner.last()?.1.end;
super::extract_block_image(&src[start..end])
}
type ImageUnits<'e, 'd> = Vec<&'e [(Event<'d>, Range<usize>)]>;
fn split_top_level_image_units<'e, 'd>(
src: &str,
seg: &'e [(Event<'d>, Range<usize>)],
) -> Option<ImageUnits<'e, 'd>> {
let mut out = Vec::new();
let mut i = 0usize;
while i < seg.len() {
if let Event::Text(t) = &seg[i].0 {
if t.trim().is_empty() {
i += 1;
continue;
}
return None; }
if let Event::Html(_) | Event::InlineHtml(_) = &seg[i].0 {
if render_html_block(&src[seg[i].1.clone()]).is_empty() {
i += 1;
continue;
}
return None;
}
let wants_end = match &seg[i].0 {
Event::Start(Tag::Link { .. }) => TagEnd::Link,
Event::Start(Tag::Image { .. }) => TagEnd::Image,
_ => return None, };
let mut depth = 0i32;
let mut j = i + 1;
let end_idx = loop {
if j >= seg.len() {
return None; }
match &seg[j].0 {
Event::Start(_) => depth += 1,
Event::End(e) if *e == wants_end && depth == 0 => break j,
Event::End(_) => depth -= 1,
_ => {}
}
j += 1;
};
out.push(&seg[i..=end_idx]);
i = end_idx + 1;
}
if out.is_empty() {
None
} else {
Some(out)
}
}
fn segment_as_block_images(
src: &str,
seg: &[(Event<'_>, Range<usize>)],
) -> Option<Vec<(String, String)>> {
if seg.is_empty() {
return Some(Vec::new());
}
if let Some(units) = split_top_level_image_units(src, seg) {
let mut out = Vec::with_capacity(units.len());
for unit in units {
out.push(segment_as_block_image(src, unit)?);
}
return Some(out);
}
segment_as_block_image(src, seg).map(|img| vec![img])
}
fn paragraph_as_block_images(
doc: &Doc<'_>,
src: &str,
inline: &Range<usize>,
) -> Option<Vec<(String, String)>> {
let events = &doc.events[inline.clone()];
if events.is_empty() {
return None;
}
let mut out = Vec::new();
for seg in split_top_level_breaks(events) {
out.extend(segment_as_block_images(src, seg)?);
}
Some(out)
}
type TextPrefixAndTrailingImages = (Range<usize>, Vec<(String, String)>);
type HeadingItems = Vec<(usize, usize, Option<(String, String)>)>;
fn heading_trailing_images(
doc: &Doc<'_>,
src: &str,
inline: &Range<usize>,
) -> Option<TextPrefixAndTrailingImages> {
let events = &doc.events[inline.clone()];
if events.is_empty() {
return None;
}
let mut items: HeadingItems = Vec::new();
let mut i = 0usize;
while i < events.len() {
match &events[i].0 {
Event::Text(t) if t.trim().is_empty() => i += 1,
Event::SoftBreak | Event::HardBreak => i += 1,
Event::Html(_) | Event::InlineHtml(_)
if render_html_block(&src[events[i].1.clone()]).is_empty() =>
{
i += 1;
}
Event::Start(Tag::Link { .. }) | Event::Start(Tag::Image { .. }) => {
let wants_end = if matches!(events[i].0, Event::Start(Tag::Link { .. })) {
TagEnd::Link
} else {
TagEnd::Image
};
let mut depth = 0i32;
let mut j = i + 1;
let end_idx = loop {
if j >= events.len() {
break events.len(); }
match &events[j].0 {
Event::Start(_) => depth += 1,
Event::End(e) if *e == wants_end && depth == 0 => break j + 1,
Event::End(_) => depth -= 1,
_ => {}
}
j += 1;
};
let img = segment_as_block_image(src, &events[i..end_idx]);
items.push((i, end_idx, img));
i = end_idx;
}
_ => {
items.push((i, i + 1, None));
i += 1;
}
}
}
let mut split = items.len();
while split > 0 && items[split - 1].2.is_some() {
split -= 1;
}
if split == 0 || split == items.len() {
return None;
}
let images: Vec<(String, String)> = items[split..]
.iter()
.map(|(_, _, img)| {
img.clone()
.expect("every item past `split` is an image by construction")
})
.collect();
let text_end_idx = items[split].0;
let text_range = inline.start..(inline.start + text_end_idx);
Some((text_range, images))
}
fn paragraph_trailing_images(
doc: &Doc<'_>,
src: &str,
inline: &Range<usize>,
) -> Option<TextPrefixAndTrailingImages> {
let events = &doc.events[inline.clone()];
let bounds = top_level_segment_bounds(events);
if bounds.len() < 2 {
return None; }
let mut images = Vec::new();
let mut trailing = 0usize;
for &(s, e) in bounds.iter().rev() {
match segment_as_block_images(src, &events[s..e]) {
Some(imgs) => {
images.extend(imgs.into_iter().rev());
trailing += 1;
}
None => break,
}
}
if trailing == 0 || trailing == bounds.len() {
return None;
}
images.reverse(); let text_end_rel = bounds[bounds.len() - trailing - 1].1;
let text_range = inline.start..(inline.start + text_end_rel);
Some((text_range, images))
}
type LeadingImagesAndTextSuffix = (Vec<(String, String)>, Range<usize>);
fn paragraph_leading_images(
doc: &Doc<'_>,
src: &str,
inline: &Range<usize>,
) -> Option<LeadingImagesAndTextSuffix> {
let events = &doc.events[inline.clone()];
let bounds = top_level_segment_bounds(events);
if bounds.len() < 2 {
return None; }
let mut images = Vec::new();
let mut leading = 0usize;
for &(s, e) in &bounds {
match segment_as_block_images(src, &events[s..e]) {
Some(imgs) => {
images.extend(imgs); leading += 1;
}
None => break,
}
}
if leading == 0 || leading == bounds.len() {
return None;
}
let text_start_rel = bounds[leading].0;
let text_range = (inline.start + text_start_rel)..inline.end;
Some((images, text_range))
}
fn image_alt_text(events: &[(Event<'_>, Range<usize>)]) -> String {
let mut s = String::new();
for (ev, _) in events {
match ev {
Event::Text(t) => s.push_str(t),
Event::Code(c) => s.push_str(c),
Event::SoftBreak | Event::HardBreak => s.push(' '),
_ => {}
}
}
s
}
fn try_render_paragraph_as_image(
w: &mut Writer<'_>,
doc: &Doc<'_>,
src: &str,
inline: &Range<usize>,
extract_eligible: bool,
) -> bool {
if !extract_eligible {
return false;
}
let Some(images) = paragraph_as_block_images(doc, src, inline) else {
return false;
};
render_image_group(w, &images);
true
}
fn render_image_group(w: &mut Writer<'_>, images: &[(String, String)]) {
if images.is_empty() {
return;
}
let mut group: Vec<(String, String, u16, u16)> = Vec::new();
for (alt, url) in images {
match (w.slot_of)(url, None) {
ImageSlot::Inline { cols, rows } => {
let would_be =
row_group_width(&group) + if group.is_empty() { 0 } else { 1 } + cols as usize;
if !group.is_empty() && would_be > w.width as usize {
flush_row_group(w, &mut group);
}
group.push((alt.clone(), url.clone(), cols, rows));
}
ImageSlot::Loading => {
flush_row_group(w, &mut group);
w.lines.extend(image_loading_line(alt, url, w.width));
}
ImageSlot::Unavailable => {
flush_row_group(w, &mut group);
w.lines.extend(image_text_fallback(alt, url, w.width));
}
}
}
flush_row_group(w, &mut group);
w.pending_block_gap = false;
w.after_math = true;
w.fresh_boundary = true;
}
fn row_group_width(group: &[(String, String, u16, u16)]) -> usize {
if group.is_empty() {
return 0;
}
let cols_sum: usize = group.iter().map(|(_, _, cols, _)| *cols as usize).sum();
cols_sum + (group.len() - 1)
}
fn flush_row_group(w: &mut Writer<'_>, group: &mut Vec<(String, String, u16, u16)>) {
if group.is_empty() {
return;
}
let width = w.width;
let rows = group
.iter()
.map(|(_, _, _, rows)| *rows)
.max()
.unwrap_or(1)
.max(1);
let total = row_group_width(group) as u16;
let start_col = w.aligns.image.offset(width, total);
let placement_line = w.lines.len() + w.heading_rule_shift;
let mut items: Vec<(u16, u16, &str)> = Vec::with_capacity(group.len());
let mut col = start_col;
for (alt, url, cols, item_rows) in group.iter() {
items.push((col, *cols, alt.as_str()));
w.images.push(ImagePlacement {
url: url.clone(),
alt: alt.clone(),
line: placement_line,
col,
cols: *cols,
rows: *item_rows,
fence_ord: None,
});
col += cols + 1; }
w.lines.extend(image_placeholder_lines(&items, rows));
group.clear();
}
fn render_paragraph_dispatch(
w: &mut Writer<'_>,
doc: &Doc<'_>,
src: &str,
inline: Range<usize>,
math_here: bool,
extract_here: bool,
block_start: usize,
) {
if try_render_paragraph_as_image(w, doc, src, &inline, extract_here) {
return;
}
if extract_here {
if let Some((text_range, images)) = paragraph_trailing_images(doc, src, &inline) {
if math_here && w.math.is_some() {
render_paragraph_math(w, doc, src, text_range, block_start);
} else {
render_paragraph(w, doc, text_range);
}
render_image_group(w, &images);
return;
}
if let Some((images, text_range)) = paragraph_leading_images(doc, src, &inline) {
if w.pending_block_gap {
w.emit_blank_row();
}
w.pending_block_gap = false;
render_image_group(w, &images);
if math_here && w.math.is_some() {
render_paragraph_math(w, doc, src, text_range, block_start);
} else {
render_paragraph(w, doc, text_range);
}
return;
}
}
if math_here && w.math.is_some() {
render_paragraph_math(w, doc, src, inline, block_start);
} else {
render_paragraph(w, doc, inline);
}
}
fn render_paragraph_math(
w: &mut Writer<'_>,
doc: &Doc<'_>,
src: &str,
inline: Range<usize>,
block_start: usize,
) {
w.pending_para_start = Some(w.pending_block_gap);
walk_inline_math(
&mut events_iter_ranged(&doc.events[inline]),
w,
src,
block_start,
);
if !w.after_math {
w.pending_block_gap = true;
}
w.pending_para_start = None;
}
fn events_iter_ranged<'a, 'e>(
events: &'e [(Event<'a>, Range<usize>)],
) -> impl Iterator<Item = (Event<'a>, Range<usize>)> + 'e {
events.iter().cloned()
}
fn walk_inline_math<'a>(
events: &mut impl Iterator<Item = (Event<'a>, Range<usize>)>,
w: &mut Writer<'_>,
src: &str,
block_start: usize,
) {
let mut prev_end: Option<usize> = Some(block_start);
let mut pending: Option<pulldown_cmark::CowStr<'a>> = None;
while let Some((ev, range)) = events.next() {
let gap = prev_end.map(|p| &src[p..range.start]);
prev_end = Some(range.end);
match ev {
Event::Text(t) => {
if let Some((closer, body_start)) = multiline_math_opener(src, &range) {
render_multiline_display_math(
w,
events,
pending.take(),
t,
body_start,
closer,
src,
&mut prev_end,
);
continue;
}
if let Some((display, closer)) = backslash_math_opener(gap, &t) {
render_backslash_math(
w,
events,
pending.take(),
t,
range.clone(),
display,
closer,
src,
&mut prev_end,
);
continue;
}
if dollar_math_still_open(&t) {
render_dollar_math_tail(
w,
events,
pending.take(),
t,
range.clone(),
src,
&mut prev_end,
);
continue;
}
if let Some(p) = pending.take() {
render_text_with_math(w, &p, false);
}
pending = Some(t);
}
Event::Code(c) => {
if let Some(p) = pending.take() {
render_text_with_math(w, &p, false);
}
ensure_fresh_after_math(w);
w.write_code_span(&c);
}
Event::SoftBreak => {
if let Some(p) = pending.take() {
render_text_with_math(w, &p, false);
}
ensure_fresh_after_math(w);
w.join_with_space();
}
Event::HardBreak => {
if let Some(p) = pending.take() {
render_text_with_math(w, &p, false);
}
ensure_fresh_after_math(w);
w.end_row();
}
Event::Start(tag) => {
if let Some(p) = pending.take() {
render_text_with_math(w, &p, false);
}
ensure_fresh_after_math(w);
let mut inner = events.by_ref().map(|(ev, r)| {
prev_end = Some(r.end);
ev
});
start_inline_tag(&mut inner, w, tag);
}
_ => {
if let Some(p) = pending.take() {
render_text_with_math(w, &p, false);
}
}
}
}
if let Some(p) = pending.take() {
render_text_with_math(w, &p, false);
}
}
fn line_bounds(src: &str, range: &Range<usize>) -> Range<usize> {
let start = src[..range.start].rfind('\n').map_or(0, |i| i + 1);
let end = src[range.end..]
.find('\n')
.map_or(src.len(), |i| range.end + i);
start..end
}
fn multiline_math_opener(src: &str, range: &Range<usize>) -> Option<(&'static str, usize)> {
let bounds = line_bounds(src, range);
let closer = match src[bounds.clone()].trim() {
"$$" => "$$",
"\\[" => "\\]",
_ => return None,
};
let body_start = if bounds.end < src.len() {
bounds.end + 1
} else {
bounds.end
};
Some((closer, body_start))
}
#[allow(clippy::too_many_arguments)] fn render_multiline_display_math<'a>(
w: &mut Writer<'_>,
events: &mut impl Iterator<Item = (Event<'a>, Range<usize>)>,
pending: Option<pulldown_cmark::CowStr<'a>>,
first_text: pulldown_cmark::CowStr<'a>,
body_start: usize,
closer: &str,
src: &str,
prev_end: &mut Option<usize>,
) {
let mut fallback: Vec<Event<'a>> = vec![Event::Text(first_text)];
let mut depth: usize = 0;
loop {
let Some((ev, range)) = events.next() else {
if let Some(p) = pending {
render_text_with_math(w, &p, false);
}
replay_events(w, fallback);
return;
};
*prev_end = Some(range.end);
let bounds = line_bounds(src, &range);
let is_closer_line =
depth == 0 && matches!(&ev, Event::Text(_)) && src[bounds.clone()].trim() == closer;
match &ev {
Event::Start(_) => depth += 1,
Event::End(_) => depth -= 1,
_ => {}
}
if is_closer_line {
let body = src[body_start.min(bounds.start)..bounds.start].trim();
if let Some(p) = pending {
render_text_with_math(w, &p, true);
}
let slot = resolve_math_slot(w, body, true);
render_math_slot(w, body, true, slot);
return;
}
fallback.push(ev);
}
}
fn backslash_math_opener(gap: Option<&str>, t: &str) -> Option<(bool, char)> {
if gap != Some("\\") {
return None;
}
match t.chars().next()? {
'[' => Some((true, ']')),
'(' => Some((false, ')')),
_ => None,
}
}
#[allow(clippy::too_many_arguments)] fn render_backslash_math<'a>(
w: &mut Writer<'_>,
events: &mut impl Iterator<Item = (Event<'a>, Range<usize>)>,
pending: Option<pulldown_cmark::CowStr<'a>>,
first_text: pulldown_cmark::CowStr<'a>,
first_range: Range<usize>,
display: bool,
closer: char,
src: &str,
prev_end: &mut Option<usize>,
) {
let opener = if display { '[' } else { '(' };
let content_start = first_range.start + opener.len_utf8();
let mut fallback: Vec<Event<'a>> = vec![Event::Text(first_text)];
let mut depth: i32 = 0;
loop {
let Some((ev, range)) = events.next() else {
if let Some(p) = pending {
render_text_with_math(w, &p, false);
}
replay_events(w, fallback);
return;
};
let gap = if depth == 0 {
prev_end.map(|p| &src[p..range.start])
} else {
None
};
*prev_end = Some(range.end);
match &ev {
Event::Start(_) => depth += 1,
Event::End(_) => depth -= 1,
_ => {}
}
let is_break = depth == 0 && matches!(ev, Event::SoftBreak | Event::HardBreak);
if depth == 0 {
if let Event::Text(t) = &ev {
if gap == Some("\\") && t.starts_with(closer) {
let content = &src[content_start..range.start - 1];
let trimmed = content.trim();
let pending_has_its_own_math = pending
.as_deref()
.is_some_and(text_contains_its_own_scannable_math);
if pending_has_its_own_math {
if let Some(p) = pending {
render_text_with_math(w, &p, true);
}
let slot = resolve_math_slot(w, trimmed, display);
render_math_slot(w, trimmed, display, slot);
} else {
let slot = resolve_math_slot(w, trimmed, display);
let will_be_inline =
!display && matches!(slot, Some(MathSlot::Image { rows: 1, .. }));
if let Some(p) = pending {
render_text_with_math(w, &p, !will_be_inline);
}
render_math_slot(w, trimmed, display, slot);
}
let rest = &t[closer.len_utf8()..];
if !rest.is_empty() {
render_text_with_math(w, rest, false);
}
return;
}
}
}
fallback.push(ev);
if is_break {
if let Some(p) = pending {
render_text_with_math(w, &p, false);
}
replay_events(w, fallback);
return;
}
}
}
fn replay_events<'a>(w: &mut Writer<'_>, events: Vec<Event<'a>>) {
let mut it = events.into_iter();
while let Some(ev) = it.next() {
match ev {
Event::Text(t) => render_text_with_math(w, &t, false),
Event::Code(c) => {
ensure_fresh_after_math(w);
w.write_code_span(&c);
}
Event::SoftBreak => {
ensure_fresh_after_math(w);
w.join_with_space();
}
Event::HardBreak => {
ensure_fresh_after_math(w);
w.end_row();
}
Event::Start(tag) => {
ensure_fresh_after_math(w);
start_inline_tag(&mut it, w, tag);
}
_ => {}
}
}
}
fn dollar_math_still_open(t: &str) -> bool {
let mut parts = Vec::new();
let mut buf = String::new();
let mut mask = Vec::new();
scan_inline_math(t, &mut parts, &mut buf, &mut mask);
buf.contains('$')
}
#[allow(clippy::too_many_arguments)] fn render_dollar_math_tail<'a>(
w: &mut Writer<'_>,
events: &mut impl Iterator<Item = (Event<'a>, Range<usize>)>,
pending: Option<pulldown_cmark::CowStr<'a>>,
first_text: pulldown_cmark::CowStr<'a>,
first_range: Range<usize>,
src: &str,
prev_end: &mut Option<usize>,
) {
let mut end = first_range.end;
let mut fallback: Vec<Event<'a>> = vec![Event::Text(first_text)];
let mut depth: usize = 0;
loop {
let raw = &src[first_range.start..end];
let mut parts: Vec<MathPart> = Vec::new();
let mut buf = String::new();
let mut mask: Vec<bool> = Vec::new();
scan_inline_math(raw, &mut parts, &mut buf, &mut mask);
if depth == 0 && !buf.contains('$') {
if !buf.is_empty() {
mask.push(false);
parts.push(MathPart::Text(SourceRun::new(buf, mask)));
}
let pending_has_its_own_math = pending
.as_deref()
.is_some_and(text_contains_its_own_scannable_math);
let (slots, inline_at) = if pending_has_its_own_math {
let old_trailing_lift = matches!(parts.first(), Some(MathPart::Math { .. }));
if let Some(p) = pending {
render_text_with_math(w, &p, old_trailing_lift);
}
resolve_parts(w, &parts)
} else {
let (slots, inline_at) = resolve_parts(w, &parts);
let trailing_lift = matches!(inline_at.first(), Some(Some(false)));
if let Some(p) = pending {
render_text_with_math(w, &p, trailing_lift);
}
(slots, inline_at)
};
render_math_parts(w, parts, slots, inline_at, false);
return;
}
let Some((ev, range)) = events.next() else {
if let Some(p) = pending {
render_text_with_math(w, &p, false);
}
replay_events(w, fallback);
return;
};
*prev_end = Some(range.end);
end = end.max(range.end);
let is_break = matches!(ev, Event::SoftBreak | Event::HardBreak);
match &ev {
Event::Start(_) => depth += 1,
Event::End(_) => depth -= 1,
_ => {}
}
fallback.push(ev);
if is_break && depth == 0 {
if let Some(p) = pending {
render_text_with_math(w, &p, false);
}
replay_events(w, fallback);
return;
}
}
}
fn ensure_fresh_after_math(w: &mut Writer<'_>) {
w.open_pending_para_start();
if w.after_math {
if w.pending_block_gap {
w.emit_blank_row();
}
w.emit_blank_row();
w.pending_block_gap = false;
w.after_math = false;
}
}
fn render_text_with_math(w: &mut Writer<'_>, text: &str, trailing_lift: bool) {
let lines: Vec<&str> = text.lines().collect();
let line_count = lines.len();
for (i, line) in lines.into_iter().enumerate() {
if i > 0 {
w.emit_blank_row();
}
let mut parts: Vec<MathPart> = Vec::new();
let mut buf = String::new();
let mut mask: Vec<bool> = Vec::new();
scan_inline_math(line, &mut parts, &mut buf, &mut mask);
if !buf.is_empty() {
mask.push(false);
parts.push(MathPart::Text(SourceRun::new(buf, mask)));
}
let is_last_line = i + 1 == line_count;
let (slots, inline_at) = resolve_parts(w, &parts);
render_math_parts(w, parts, slots, inline_at, trailing_lift && is_last_line);
}
}
fn resolve_math_slot(w: &Writer<'_>, latex: &str, display: bool) -> Option<MathSlot> {
w.math.as_ref().map(|m| (m.slot)(latex, display))
}
fn text_contains_its_own_scannable_math(text: &str) -> bool {
text.lines().any(|line| {
let mut parts = Vec::new();
let mut buf = String::new();
let mut mask = Vec::new();
scan_inline_math(line, &mut parts, &mut buf, &mut mask);
parts
.iter()
.any(|part| matches!(part, MathPart::Math { .. }))
})
}
fn resolve_parts(w: &Writer<'_>, parts: &[MathPart]) -> (Vec<Option<MathSlot>>, Vec<Option<bool>>) {
let slots: Vec<Option<MathSlot>> = parts
.iter()
.map(|p| match p {
MathPart::Math { latex, display } => resolve_math_slot(w, latex, *display),
MathPart::Text(_) => None,
})
.collect();
let inline_at: Vec<Option<bool>> = parts
.iter()
.zip(slots.iter())
.map(|(p, s)| match p {
MathPart::Math { display, .. } => {
Some(!*display && matches!(s, Some(MathSlot::Image { rows: 1, .. })))
}
MathPart::Text(_) => None,
})
.collect();
(slots, inline_at)
}
fn render_math_parts(
w: &mut Writer<'_>,
parts: Vec<MathPart>,
slots: Vec<Option<MathSlot>>,
inline_at: Vec<Option<bool>>,
trailing_lift: bool,
) {
let n = parts.len();
for (idx, part) in parts.into_iter().enumerate() {
match part {
MathPart::Text(run) => {
let mut t = run.text().to_string();
if idx > 0 && inline_at[idx - 1] != Some(true) {
t = t.trim_start_matches([' ', '\t']).to_string();
}
let trim_end = if idx + 1 < n {
inline_at[idx + 1] != Some(true)
} else {
trailing_lift
};
if trim_end {
t = t.trim_end_matches([' ', '\t']).to_string();
}
if t.is_empty() {
continue;
}
ensure_fresh_after_math(w);
let style = w.cur_style();
w.append_span(Span::styled(t, style));
}
MathPart::Math { latex, display } => {
render_math_slot(w, &latex, display, slots[idx].clone());
}
}
}
}
fn render_math_slot(w: &mut Writer<'_>, latex: &str, display: bool, slot: Option<MathSlot>) {
let Some(math) = w.math.as_ref() else {
w.drop_pending_para_start();
return;
};
let width = math.width;
let Some(slot) = slot else {
w.drop_pending_para_start();
return;
};
if let MathSlot::Image { cols, rows } = slot {
if !display && rows == 1 {
render_inline_math(w, latex, cols);
return;
}
}
w.drop_pending_para_start();
match slot {
MathSlot::Image { cols, rows } => {
let placement_line = w.lines.len() + w.heading_rule_shift;
w.lines
.extend(math_placeholder_lines(cols, rows, width, display));
let col = if display {
width.saturating_sub(cols) / 2
} else {
0
};
w.images.push(ImagePlacement {
url: math_url(latex, display),
alt: "math".into(),
line: placement_line,
col,
cols,
rows,
fence_ord: None,
});
}
MathSlot::Loading => {
w.lines
.extend(image_loading_line("math", "equation", width));
}
MathSlot::Raw => {
w.lines.extend(math_raw_lines(latex, display));
}
}
w.pending_block_gap = false;
w.after_math = true;
}
fn render_inline_math(w: &mut Writer<'_>, latex: &str, cols: u16) {
ensure_fresh_after_math(w);
let width = w.width;
let mut col = w
.lines
.last()
.and_then(|l| u16::try_from(l.width()).ok())
.unwrap_or(u16::MAX);
if col > 0 && col.saturating_add(cols) > width {
w.lines.push(Line::default());
col = 0;
}
let placement_line = w.lines.len().saturating_sub(1) + w.heading_rule_shift;
w.append_span(Span::styled(
" ".repeat(cols as usize),
inline_math_reservation_style(),
));
w.images.push(ImagePlacement {
url: math_url(latex, false),
alt: "math".into(),
line: placement_line,
col,
cols,
rows: 1,
fence_ord: None,
});
}
fn render_rule(w: &mut Writer<'_>) {
if w.pending_block_gap {
w.emit_blank_row();
}
w.emit_row(Line::from("---"));
w.pending_block_gap = true;
}
fn render_code_block_dispatch(
w: &mut Writer<'_>,
src: &str,
lang: Option<&str>,
body_spans: &[Range<usize>],
extract_eligible: bool,
) {
if extract_eligible && w.mermaid.fences_on && is_mermaid_info(lang.unwrap_or("")) {
render_mermaid_slot(w, src, body_spans);
} else {
render_code_block(w, src, lang, body_spans);
}
}
fn render_code_block(
w: &mut Writer<'_>,
src: &str,
lang: Option<&str>,
body_spans: &[Range<usize>],
) {
if !w.lines.is_empty() && !w.fresh_boundary {
w.emit_blank_row();
}
let content_w = w.width as usize;
let lang_trimmed = lang.unwrap_or("").trim();
let label = if lang_trimmed.is_empty() {
"code"
} else {
lang_trimmed
};
w.emit_row(code_header(label, content_w, w.code));
let body_text = code_body_text(body_spans, src);
w.code_blocks.push(body_text.clone());
let body_lines: Vec<String> = if body_spans.is_empty() {
Vec::new()
} else {
body_text.split('\n').map(str::to_string).collect()
};
let theme = w.theme.clone();
for line in highlight_body(
&body_lines,
lang_trimmed,
content_w,
w.code.bg,
&theme,
w.code.tab_width,
w.code.wrap,
) {
w.emit_row(line);
}
w.emit_row(pad_to_width(
vec![gutter_span(w.code.bg)],
content_w,
w.code.bg,
));
w.pending_block_gap = true;
}
fn render_mermaid_slot(w: &mut Writer<'_>, src: &str, body_spans: &[Range<usize>]) {
let ord = w.mermaid.ord;
w.mermaid.ord += 1;
let code = code_body_text(body_spans, src);
let width = w.mermaid.width;
if code.trim().is_empty() {
w.lines.extend(render_mermaid_block(&code, width));
w.pending_block_gap = false;
w.after_math = true;
w.fresh_boundary = true;
return;
}
let hashed = format!("{code}\n");
match (w.mermaid.slot)(&hashed) {
MermaidSlot::Image { cols, rows } => {
let url = mermaid_fence_url(&hashed);
let col = mermaid_diagram_col(w.aligns.image, width, cols);
let mut ls = mermaid_placeholder_lines(col, rows, width, w.mermaid.caption);
w.lines.push(ls.remove(0));
let placement_line = w.lines.len() + w.heading_rule_shift;
w.images.push(ImagePlacement {
url,
alt: "mermaid".into(),
line: placement_line,
col,
cols,
rows,
fence_ord: Some(ord),
});
w.lines.extend(ls);
}
MermaidSlot::Loading => w
.lines
.extend(image_loading_line("mermaid", "diagram", width)),
MermaidSlot::Text => w.lines.extend(render_mermaid_block(&code, width)),
}
w.pending_block_gap = false;
w.after_math = true;
w.fresh_boundary = true;
}
fn render_table_from_model(
w: &mut Writer<'_>,
src: &str,
aligns: &[Alignment],
rows: &[Vec<Range<usize>>],
) {
let cells: Vec<Vec<Vec<CellSeg>>> = rows
.iter()
.map(|row| {
row.iter()
.map(|range| {
let mut segs = parse_cell_segments(&normalize_cell(&src[range.clone()]));
prefix_link_icons(&mut segs, w.icons);
segs
})
.collect()
})
.collect();
let col_aligns: Vec<ColAlign> = aligns
.iter()
.map(|a| match a {
Alignment::Center => ColAlign::Center,
Alignment::Right => ColAlign::Right,
Alignment::None | Alignment::Left => ColAlign::Left,
})
.collect();
let header_rows = if rows.is_empty() { 0 } else { 1 };
let table = TableCells {
rows: cells,
header_rows,
aligns: col_aligns,
cell_attrs: Vec::new(),
};
let content_w = w.width as usize;
emit_table(w, &table, content_w as u16);
w.pending_block_gap = false;
w.after_math = true;
w.fresh_boundary = true;
}
fn emit_table(w: &mut Writer<'_>, table: &TableCells, width: u16) {
let base = w.lines.len() + w.heading_rule_shift;
let (lines, cell_images, drawn_width) = render_table_cells(table, width, w.slot_of);
let shift = w.aligns.table.offset(width, drawn_width);
for line in lines {
let line = if shift == 0 {
line
} else {
let mut line = line;
line.spans.insert(0, Span::raw(" ".repeat(shift as usize)));
line
};
w.emit_row(line);
}
for CellImage {
url,
alt,
row,
col,
cols,
rows,
} in cell_images
{
w.images.push(ImagePlacement {
url,
alt,
line: base + row,
col: col + shift,
cols,
rows,
fence_ord: None,
});
}
}
fn render_html_table_from_model(
w: &mut Writer<'_>,
src: &str,
body_spans: &[Range<usize>],
rows: &[Vec<HtmlTableCell>],
) {
let cells: Vec<Vec<Vec<CellSeg>>> = rows
.iter()
.map(|row| {
row.iter()
.map(|cell| {
let raw = html_body_text_in(body_spans, src, &cell.inner);
let mut segs =
parse_cell_segments(&normalize_cell(&html_cell_to_markdown(&raw)));
prefix_link_icons(&mut segs, w.icons);
segs
})
.collect()
})
.collect();
let cell_attrs: Vec<Vec<CellAttrs>> = rows
.iter()
.map(|row| {
row.iter()
.map(|cell| CellAttrs {
align: cell.align.map(|a| match a {
Alignment::Center => ColAlign::Center,
Alignment::Right => ColAlign::Right,
Alignment::None | Alignment::Left => ColAlign::Left,
}),
header: cell.header,
})
.collect()
})
.collect();
let header_rows = rows
.iter()
.take_while(|r| !r.is_empty() && r.iter().all(|c| c.header))
.count();
let table = TableCells {
rows: cells,
header_rows,
aligns: Vec::new(),
cell_attrs,
};
emit_table(w, &table, w.width);
w.pending_block_gap = false;
w.after_math = true;
w.fresh_boundary = true;
}
fn render_html_block_from_model(w: &mut Writer<'_>, src: &str, body_spans: &[Range<usize>]) {
let raw = html_body_text(body_spans, src);
let mut buf = String::new();
let mut pending_images: Vec<(String, String)> = Vec::new();
let flush_text = |w: &mut Writer<'_>, buf: &mut String| {
if !buf.is_empty() {
for l in render_html_block(buf) {
w.emit_row(l);
}
buf.clear();
}
};
for line in raw.split_inclusive('\n') {
let bare = line.strip_suffix('\n').unwrap_or(line);
match super::extract_block_image(bare) {
Some((alt, url)) => {
flush_text(w, &mut buf);
pending_images.push((alt, url));
}
None => {
render_image_group(w, &pending_images);
pending_images.clear();
buf.push_str(line);
}
}
}
render_image_group(w, &pending_images);
flush_text(w, &mut buf);
w.pending_block_gap = false;
w.after_math = true;
w.fresh_boundary = true;
}
fn render_block(block: &Block, w: &mut Writer<'_>, doc: &Doc<'_>, src: &str, ctx: BlockCtx) {
match &block.kind {
BlockKind::Heading {
level,
inline,
id,
classes,
attrs,
} => {
let meta = HeadingMeta {
id: id.clone(),
classes: classes.clone(),
attrs: attrs.clone(),
};
render_heading(w, doc, src, *level, inline.clone(), &meta);
}
BlockKind::Paragraph { inline } => {
if is_real_paragraph(src, block) {
render_paragraph_dispatch(
w,
doc,
src,
inline.clone(),
ctx.math_here,
ctx.extract_here,
block.src.start,
);
} else {
render_bare_paragraph(
w,
doc,
src,
inline.clone(),
ctx.math_here,
ctx.extract_here,
block.src.start,
);
}
}
BlockKind::ThematicBreak => render_rule(w),
BlockKind::List { start, .. } => render_list(w, doc, src, *start, &block.children, ctx),
BlockKind::Quote { alert: None, .. } => render_quote(w, doc, src, &block.children, ctx),
BlockKind::Quote { alert: Some(_), .. } if !w.alerts => {
render_quote(w, doc, src, &block.children, ctx)
}
BlockKind::Quote {
alert: Some(kind),
alert_title,
} => render_alert_from_model(
w,
doc,
src,
*kind,
alert_title,
&block.children,
block.src.start,
),
BlockKind::CodeBlock {
lang, body_spans, ..
} => render_code_block_dispatch(w, src, lang.as_deref(), body_spans, ctx.extract_here),
BlockKind::Details {
open_attr,
summary,
glued_body,
} => render_details_from_model(
w,
doc,
src,
*open_attr,
summary,
&block.children,
glued_body.clone(),
ctx,
),
BlockKind::Table { aligns, rows } => render_table_from_model(w, src, aligns, rows),
BlockKind::Html { body_spans, .. } => render_html_block_from_model(w, src, body_spans),
BlockKind::HtmlTable { body_spans, rows } => {
render_html_table_from_model(w, src, body_spans, rows)
}
BlockKind::ListItem { .. } => {}
}
}
fn render_list(
w: &mut Writer<'_>,
doc: &Doc<'_>,
src: &str,
start: Option<u64>,
items: &[Block],
ctx: BlockCtx,
) {
if ctx.list_depth == 0 && w.pending_block_gap {
w.emit_blank_row();
}
let list_ctx = BlockCtx {
list_depth: ctx.list_depth + 1,
..ctx
};
let mut counter = start;
for item in items {
if let BlockKind::ListItem { task } = &item.kind {
render_item(
w,
doc,
src,
*task,
&item.children,
list_ctx,
item.src.start,
&mut counter,
);
}
}
if !w.after_math {
w.pending_block_gap = true;
}
}
#[allow(clippy::too_many_arguments)] fn render_item(
w: &mut Writer<'_>,
doc: &Doc<'_>,
src: &str,
task: Option<Task>,
children: &[Block],
ctx: BlockCtx,
item_src_start: usize,
counter: &mut Option<u64>,
) {
w.emit_row(Line::default());
push_item_marker(w, ctx.list_depth, counter);
w.pending_block_gap = false;
let loose_first = children.first().is_some_and(|b| is_real_paragraph(src, b));
let custom_task = if task.is_none() {
let first_line_end = src[item_src_start..]
.find('\n')
.map_or(src.len(), |off| item_src_start + off);
let first_line = &src[item_src_start..first_line_end];
task_prefix_state(first_line, w.tasks)
} else {
None
};
let checkbox_shares_the_marker_row = task.is_some() || custom_task.is_some();
if loose_first && !checkbox_shares_the_marker_row {
w.emit_blank_row();
w.pending_block_gap = false;
}
match task {
Some(t) => {
w.write_task_marker(matches!(t.state, 'x' | 'X'));
if counter.is_none() {
w.task_marks.push((t.state, t.state_at));
}
}
None => {
if let Some((state, off)) = custom_task {
w.task_marks.push((state, item_src_start + off));
}
}
}
let mut rest = children;
if let Some(first) = children.first() {
if let BlockKind::Paragraph { inline } = &first.kind {
if ctx.extract_here && paragraph_as_block_images(doc, src, inline).is_some() {
render_block(first, w, doc, src, ctx);
} else {
if ctx.math_here {
walk_inline_math(
&mut events_iter_ranged(&doc.events[inline.clone()]),
w,
src,
first.src.start,
);
} else {
walk_inline(&mut events_iter(&doc.events[inline.clone()]), w, None);
}
w.pending_block_gap =
loose_first && !checkbox_shares_the_marker_row && !w.after_math;
}
} else {
render_block(first, w, doc, src, ctx);
}
rest = &children[1..];
}
for child in rest {
render_block(child, w, doc, src, ctx);
}
}
fn is_real_paragraph(src: &str, block: &Block) -> bool {
matches!(&block.kind, BlockKind::Paragraph { .. }) && src[block.src.clone()].ends_with('\n')
}
fn render_bare_paragraph(
w: &mut Writer<'_>,
doc: &Doc<'_>,
src: &str,
inline: Range<usize>,
math_here: bool,
extract_here: bool,
block_start: usize,
) {
if w.pending_block_gap {
w.emit_blank_row();
w.pending_block_gap = false;
}
if try_render_paragraph_as_image(w, doc, src, &inline, extract_here) {
return;
}
if extract_here {
if let Some((text_range, images)) = paragraph_trailing_images(doc, src, &inline) {
if math_here {
walk_inline_math(
&mut events_iter_ranged(&doc.events[text_range]),
w,
src,
block_start,
);
} else {
walk_inline(&mut events_iter(&doc.events[text_range]), w, None);
}
render_image_group(w, &images);
return;
}
if let Some((images, text_range)) = paragraph_leading_images(doc, src, &inline) {
render_image_group(w, &images);
if math_here {
walk_inline_math(
&mut events_iter_ranged(&doc.events[text_range]),
w,
src,
block_start,
);
} else {
walk_inline(&mut events_iter(&doc.events[text_range]), w, None);
}
return;
}
}
if math_here {
walk_inline_math(
&mut events_iter_ranged(&doc.events[inline]),
w,
src,
block_start,
);
} else {
walk_inline(&mut events_iter(&doc.events[inline]), w, None);
}
}
fn push_item_marker(w: &mut Writer<'_>, depth: usize, counter: &mut Option<u64>) {
let width = depth.saturating_mul(4).saturating_sub(3);
let span = match counter {
None => Span::raw(" ".repeat(width - 1) + "- "),
Some(index) => {
*index += 1;
Span::styled(
format!("{:width$}. ", *index - 1),
Style::new().fg(Color::LightBlue),
)
}
};
w.append_span(span);
}
fn render_quote(w: &mut Writer<'_>, doc: &Doc<'_>, src: &str, children: &[Block], ctx: BlockCtx) {
if w.pending_block_gap {
w.emit_blank_row();
w.pending_block_gap = false;
}
let mut inner = Writer {
lines: Vec::new(),
inline_styles: Vec::new(),
link: None,
styles: w.styles,
pending_block_gap: false,
math: None,
mermaid: MermaidCtx {
slot: w.mermaid.slot,
caption: w.mermaid.caption,
width: w.width.saturating_sub(2),
fences_on: w.mermaid.fences_on,
ord: 0,
},
slot_of: w.slot_of,
images: Vec::new(),
code_blocks: Vec::new(),
task_marks: Vec::new(),
after_math: false,
fresh_boundary: false,
pending_para_start: None,
heading_rule_shift: 0,
code: w.code,
theme: w.theme.clone(),
width: w.width.saturating_sub(2),
icons: w.icons,
tasks: w.tasks,
alerts: w.alerts,
aligns: w.aligns,
};
let child_ctx = BlockCtx {
math_here: false,
extract_here: false,
details_interactive: ctx.details_interactive,
list_depth: 0,
};
for child in children {
render_block(child, &mut inner, doc, src, child_ctx);
}
let decorated =
decorate_headings_and_extras(inner.lines, inner.width, inner.icons, inner.tasks);
let prefix = Span::raw(">");
let style = w.styles.blockquote();
let base = w.lines.len() + w.heading_rule_shift;
for line in decorated {
w.emit_row_prefixed(line, Some(&prefix), Some(style));
}
w.images
.extend(rebase_nested_images(inner.images, base, QUOTE_PREFIX_COLS));
w.code_blocks.extend(inner.code_blocks);
w.task_marks.extend(inner.task_marks);
w.pending_block_gap = true;
}
const QUOTE_PREFIX_COLS: u16 = 2;
const BAR_PREFIX_COLS: u16 = 2;
fn rebase_nested_images(
images: Vec<ImagePlacement>,
base: usize,
prefix_cols: u16,
) -> impl Iterator<Item = ImagePlacement> {
images.into_iter().map(move |mut p| {
p.line += base;
p.col += prefix_cols;
p
})
}
fn render_alert_from_model(
w: &mut Writer<'_>,
doc: &Doc<'_>,
src: &str,
kind: AlertKind,
title: &str,
children: &[Block],
quote_src_start: usize,
) {
w.emit_row(alert_header_line(kind, title, w.icons));
let header_end = line_after(src, quote_src_start);
let bar = alert_bar(kind.color());
match glued_alert_body(children, header_end) {
Some(glued_range) => {
let base = glued_range.start;
let (body_src, line_map) = dequote_alert_body(&src[glued_range]);
let nested = Doc::parse(&body_src);
let marks_before = w.task_marks.len();
render_bar_prefixed_body(w, &nested, &body_src, &nested.blocks, bar);
for mark in &mut w.task_marks[marks_before..] {
mark.1 = base + remap_glued_alert_offset(mark.1, &line_map);
}
}
None => {
let trimmed = trim_leading_header(children, doc, header_end);
render_bar_prefixed_body(w, doc, src, &trimmed, bar);
}
}
w.pending_block_gap = false;
w.fresh_boundary = true;
}
fn glued_alert_body(children: &[Block], header_end: usize) -> Option<Range<usize>> {
let b = children.iter().find(|b| b.src.end > header_end)?;
if b.src.start >= header_end {
return None; }
if !matches!(b.kind, BlockKind::Paragraph { .. }) {
return None; }
Some(
header_end
..children
.last()
.expect("b came from this slice, so it is non-empty")
.src
.end,
)
}
fn dequote_alert_body(raw: &str) -> (String, Vec<(usize, usize)>) {
let mut out = String::with_capacity(raw.len());
let mut line_map = Vec::new();
let mut raw_pos = 0usize;
for line in raw.split_inclusive('\n') {
let (text, term) = match line.strip_suffix("\r\n") {
Some(t) => (t, "\r\n"),
None => match line.strip_suffix('\n') {
Some(t) => (t, "\n"),
None => (line, ""), },
};
let stripped = super::strip_blockquote(text);
let marker_len = text.len() - stripped.len();
line_map.push((out.len(), raw_pos + marker_len));
out.push_str(&stripped);
out.push_str(term);
raw_pos += line.len();
}
(out, line_map)
}
fn remap_glued_alert_offset(body_offset: usize, line_map: &[(usize, usize)]) -> usize {
let idx = line_map
.partition_point(|&(body_start, _)| body_start <= body_offset)
.saturating_sub(1);
let (body_start, raw_start) = line_map[idx];
raw_start + (body_offset - body_start)
}
fn line_after(src: &str, start: usize) -> usize {
match src[start..].find('\n') {
Some(off) => start + off + 1,
None => src.len(),
}
}
fn trim_leading_header(children: &[Block], doc: &Doc<'_>, header_end: usize) -> Vec<Block> {
for (i, b) in children.iter().enumerate() {
if b.src.end <= header_end {
continue; }
if b.src.start >= header_end {
return children[i..].to_vec(); }
let mut out = Vec::with_capacity(children.len() - i);
if let BlockKind::Paragraph { inline } = &b.kind {
let mut start = inline.start;
while start < inline.end {
let (ev, r) = &doc.events[start];
if r.start < header_end || matches!(ev, Event::SoftBreak | Event::HardBreak) {
start += 1;
} else {
break;
}
}
out.push(Block {
kind: BlockKind::Paragraph {
inline: start..inline.end,
},
src: header_end..b.src.end,
children: Vec::new(),
});
} else {
out.push(b.clone());
}
out.extend_from_slice(&children[i + 1..]);
return out;
}
Vec::new()
}
#[allow(clippy::too_many_arguments)] fn render_details_from_model(
w: &mut Writer<'_>,
doc: &Doc<'_>,
src: &str,
open_attr: bool,
summary: &str,
children: &[Block],
glued_body: Option<Range<usize>>,
ctx: BlockCtx,
) {
let open = if ctx.details_interactive {
next_details_open(open_attr)
} else {
open_attr
};
w.emit_row(details_marker_line(open, summary, ctx.details_interactive));
if open {
if !children.is_empty() {
render_bar_prefixed_body(w, doc, src, children, details_bar());
} else if let Some(range) = glued_body {
let body_src = src[range].to_string();
let nested = Doc::parse(&body_src);
render_bar_prefixed_body(w, &nested, &body_src, &nested.blocks, details_bar());
}
}
w.pending_block_gap = false;
w.fresh_boundary = true;
}
fn render_bar_prefixed_body(
w: &mut Writer<'_>,
doc: &Doc<'_>,
src: &str,
children: &[Block],
bar: Span<'static>,
) {
let mut inner = Writer {
lines: Vec::new(),
inline_styles: Vec::new(),
link: None,
styles: w.styles,
pending_block_gap: false,
math: None,
mermaid: MermaidCtx {
slot: w.mermaid.slot,
caption: w.mermaid.caption,
width: w.width.saturating_sub(2),
fences_on: w.mermaid.fences_on,
ord: 0,
},
slot_of: w.slot_of,
images: Vec::new(),
code_blocks: Vec::new(),
task_marks: Vec::new(),
after_math: false,
fresh_boundary: false,
pending_para_start: None,
heading_rule_shift: 0,
code: w.code,
theme: w.theme.clone(),
width: w.width.saturating_sub(2),
icons: w.icons,
tasks: w.tasks,
alerts: w.alerts,
aligns: w.aligns,
};
let body_ctx = BlockCtx {
math_here: false,
extract_here: false,
details_interactive: false,
list_depth: 0,
};
for child in children {
render_block(child, &mut inner, doc, src, body_ctx);
}
let decorated =
decorate_headings_and_extras(inner.lines, inner.width, inner.icons, inner.tasks);
let base = w.lines.len() + w.heading_rule_shift;
for line in decorated {
let style = line.style;
let mut spans = vec![bar.clone()];
spans.extend(line.spans);
w.emit_row(Line::from(spans).style(style));
}
w.images
.extend(rebase_nested_images(inner.images, base, BAR_PREFIX_COLS));
w.code_blocks.extend(inner.code_blocks);
w.task_marks.extend(inner.task_marks);
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::style::Stylize;
fn no_images(_: &str, _: Option<u16>) -> ImageSlot {
ImageSlot::Unavailable
}
fn no_mermaid(_: &str) -> MermaidSlot {
MermaidSlot::Text
}
fn fresh_writer() -> Writer<'static> {
Writer {
lines: Vec::new(),
inline_styles: Vec::new(),
link: None,
styles: KonomaStyles::default(),
pending_block_gap: false,
math: None,
mermaid: MermaidCtx {
slot: &no_mermaid,
caption: "Enter: full screen",
width: 80,
fences_on: false,
ord: 0,
},
slot_of: &no_images,
images: Vec::new(),
code_blocks: Vec::new(),
task_marks: Vec::new(),
after_math: false,
fresh_boundary: false,
pending_para_start: None,
heading_rule_shift: 0,
code: CodeStyle::default(),
theme: String::new(),
width: 80,
icons: false,
tasks: super::super::DEFAULT_TASK_STATES,
alerts: true,
aligns: BlockAligns::default(),
}
}
fn fresh_ctx() -> BlockCtx {
BlockCtx {
math_here: false,
extract_here: true,
details_interactive: true,
list_depth: 0,
}
}
#[test]
fn write_task_marker_on_a_fresh_empty_line_does_not_panic() {
let mut w = fresh_writer();
w.lines.push(Line::default());
w.write_task_marker(false);
assert_eq!(w.lines.len(), 1);
assert_eq!(
w.lines[0].spans.len(),
1,
"the marker becomes the line's only span"
);
assert_eq!(w.lines[0].spans[0].content.as_ref(), "[ ] ");
}
#[test]
fn escape_handling_drops_the_backslash_from_every_event_range() {
let src = "`\\[x\\]` and \\[y\\]\n";
let doc = Doc::parse(src);
let events: Vec<(Event<'_>, Range<usize>)> = doc.events;
assert_eq!(events.len(), 6, "events: {events:#?}");
assert!(
matches!(&events[1].0, Event::Code(c) if c.as_ref() == "\\[x\\]"),
"a code span's own delimiters keep their content literal — events: {events:#?}"
);
assert_eq!(events[1].1, 0..7);
assert_eq!(&src[events[1].1.clone()], "`\\[x\\]`");
assert!(matches!(&events[2].0, Event::Text(t) if t.as_ref() == " and "));
assert_eq!(events[2].1, 7..12);
assert!(matches!(&events[3].0, Event::Text(t) if t.as_ref() == "[y"));
assert_eq!(
events[3].1,
13..15,
"the opening backslash (position 12) is not part of this event's own range at all"
);
assert_eq!(&src[12..13], "\\");
assert!(matches!(&events[4].0, Event::Text(t) if t.as_ref() == "]"));
assert_eq!(
events[4].1,
16..17,
"the closing backslash (position 15) is not part of this event's own range either"
);
assert_eq!(&src[15..16], "\\");
}
#[test]
fn escaped_bracket_with_no_matching_closer_has_no_gap_before_its_own_closing_bracket() {
let src = "This is \\[not a link](nope).\n";
let doc = Doc::parse(src);
let events: Vec<(Event<'_>, Range<usize>)> = doc.events;
assert_eq!(events.len(), 6, "events: {events:#?}");
assert!(matches!(&events[1].0, Event::Text(t) if t.as_ref() == "This is "));
assert_eq!(events[1].1, 0..8);
assert!(matches!(&events[2].0, Event::Text(t) if t.as_ref() == "[not a link"));
assert_eq!(
events[2].1,
9..20,
"the escaping backslash (position 8) is dropped from the range, same as the math case"
);
assert_eq!(&src[8..9], "\\");
assert!(matches!(&events[3].0, Event::Text(t) if t.as_ref() == "]"));
assert_eq!(
events[3].1,
20..21,
"no gap before the closing bracket — it is not itself escaped, so this can never look like a closer"
);
assert_eq!(&src[20..20], "", "zero-width gap");
}
#[test]
fn write_task_marker_on_a_line_with_one_existing_span_inserts_after_it() {
let mut w = fresh_writer();
w.lines.push(Line::from(vec![Span::raw("1. ")]));
w.write_task_marker(true);
assert_eq!(w.lines.len(), 1);
let spans: Vec<&str> = w.lines[0]
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
assert_eq!(spans, vec!["1. ", "[x] "]);
}
#[test]
fn loose_task_item_renders_without_panicking_through_render_item() {
let src = "- [ ] outer\n\n - [ ] nested at matching indent\n";
let doc = Doc::parse(src);
assert!(
contains_unsupported(&doc.blocks[0].children, false).is_none(),
"the LooseTask exclusion should be gone"
);
let mut w = fresh_writer();
let BlockKind::List { .. } = &doc.blocks[0].kind else {
panic!("expected a top-level list");
};
let BlockKind::ListItem { task } = &doc.blocks[0].children[0].kind else {
panic!("expected the outer list item");
};
let mut counter = None;
render_item(
&mut w,
&doc,
src,
*task,
&doc.blocks[0].children[0].children,
BlockCtx {
list_depth: 1,
..fresh_ctx()
},
doc.blocks[0].children[0].src.start,
&mut counter,
);
let rendered: Vec<String> = w
.lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.collect();
assert!(
rendered.iter().any(|l| l.contains("[ ] outer")),
"rendered lines: {rendered:?}"
);
assert!(
rendered.iter().any(|l| l.contains("[ ] nested")),
"rendered lines: {rendered:?}"
);
}
#[test]
fn math_lift_that_is_a_paragraphs_entire_content_gets_no_leading_blank_line() {
let code = CodeStyle::default();
let math_slot = |_: &str, _: bool| MathSlot::Raw;
let width = 80;
let src = "$$y$$\n";
let doc = Doc::parse(src);
let out = render_doc(
&doc,
src,
width,
code,
"TwoDark",
false,
&[' ', 'x'],
&no_images,
&no_mermaid,
"Enter: full screen",
true,
&math_slot,
true,
);
assert!(out.unsupported.is_empty(), "src: {src:?}");
assert_eq!(
out.lines,
vec![Line::from(Span::from("$$ y $$").dim())],
"a paragraph whose entire content is lifted math must render to exactly its own \
placeholder line — no leading blank row ahead of it: {:?}",
out.lines
);
let src = " $$x$$\n\n$$y$$\n";
let doc = Doc::parse(src);
let out = render_doc(
&doc,
src,
width,
code,
"TwoDark",
false,
&[' ', 'x'],
&no_images,
&no_mermaid,
"Enter: full screen",
true,
&math_slot,
true,
);
assert!(out.unsupported.is_empty(), "src: {src:?}");
assert_eq!(
out.lines.len(),
4,
"expected exactly [header, body, padding, math] — no orphaned blank line(s) between the \
code block's own padding row and the math placeholder: {:?}",
out.lines
);
assert_eq!(
out.lines.last(),
Some(&Line::from(Span::from("$$ y $$").dim())),
"the math placeholder must be the render's own last line: {:?}",
out.lines
);
}
#[test]
fn backslash_math_opener_detects_a_paragraphs_own_first_event() {
let code = CodeStyle::default();
let math_slot = |_: &str, _: bool| MathSlot::Raw;
let width = 80;
for (src, expected_dim) in [("\\(y\\)\n", "$y$"), ("\\[y\\]\n", "$$ y $$")] {
let doc = Doc::parse(src);
let out = render_doc(
&doc,
src,
width,
code,
"TwoDark",
false,
&[' ', 'x'],
&no_images,
&no_mermaid,
"Enter: full screen",
true,
&math_slot,
true,
);
assert!(out.unsupported.is_empty(), "src: {src:?}");
assert_eq!(
out.lines,
vec![Line::from(Span::from(expected_dim).dim())],
"backslash-delimited math that is a paragraph's own very first event must still be \
detected and lifted, not fall through as literal text — src: {src:?}, lines: {:?}",
out.lines
);
}
}
#[test]
fn alert_header_bar_icon_label_and_recursive_body_render_from_the_model() {
crate::preview::markdown::set_details_open(Vec::new());
let code = CodeStyle::default();
let math_slot = |_: &str, _: bool| MathSlot::Raw;
let src = "> [!TIP] Hey\n> - a\n> - b\n";
let doc = Doc::parse(src);
let out = render_doc(
&doc,
src,
80,
code,
"TwoDark",
true, &[' ', 'x'],
&no_images,
&no_mermaid,
"Enter: full screen",
true,
&math_slot,
false,
);
assert!(
out.unsupported.is_empty(),
"src: {src:?}, unsupported: {:?}",
out.unsupported
);
let color = Color::Green; assert_eq!(
out.lines.first(),
Some(&Line::from(vec![
Span::styled("▌ ".to_string(), Style::new().fg(color)),
Span::styled("\u{f0eb} ".to_string(), Style::new().fg(color)),
Span::styled(
"Tip — Hey".to_string(),
Style::new().fg(color).add_modifier(Modifier::BOLD)
),
])),
"header line (bar/icon/color/label+title): {:?}",
out.lines
);
assert_eq!(
out.lines.len(),
3,
"header + two recursively rendered list-item body lines: {:?}",
out.lines
);
let bar = Span::styled("▌ ".to_string(), Style::new().fg(color));
for (i, want_tail) in [(1, "- a"), (2, "- b")] {
assert_eq!(
out.lines[i].spans.first(),
Some(&bar),
"line {i} does not open with the alert's own bar: {:?}",
out.lines[i]
);
let joined: String = out.lines[i]
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
assert_eq!(
joined,
format!("▌ {want_tail}"),
"line {i}: the body's own list item was not recursively rendered: {:?}",
out.lines[i]
);
}
}
#[test]
fn details_marker_style_bar_and_recursive_body_render_from_the_model() {
crate::preview::markdown::set_details_open(Vec::new());
let code = CodeStyle::default();
let math_slot = |_: &str, _: bool| MathSlot::Raw;
let src = "<details open>\n<summary>More</summary>\n\n- x\n- y\n\n</details>\n";
let doc = Doc::parse(src);
let out = render_doc(
&doc,
src,
80,
code,
"TwoDark",
false,
&[' ', 'x'],
&no_images,
&no_mermaid,
"Enter: full screen",
true,
&math_slot,
false,
);
assert!(
out.unsupported.is_empty(),
"src: {src:?}, unsupported: {:?}",
out.unsupported
);
let sentinel_style = Style::new()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD | Modifier::ITALIC);
assert_eq!(
out.lines.first(),
Some(&Line::from(vec![
Span::styled("▾ ".to_string(), sentinel_style),
Span::styled(
"More".to_string(),
Style::new().add_modifier(Modifier::BOLD)
),
])),
"marker line (arrow/sentinel style/summary label): {:?}",
out.lines
);
assert_eq!(
out.lines.len(),
3,
"marker + two recursively rendered list-item body lines: {:?}",
out.lines
);
let bar = Span::styled("▏ ".to_string(), Style::new().fg(Color::Rgb(90, 98, 120)));
for (i, want_tail) in [(1, "- x"), (2, "- y")] {
assert_eq!(
out.lines[i].spans.first(),
Some(&bar),
"line {i} does not open with the details block's own bar: {:?}",
out.lines[i]
);
let joined: String = out.lines[i]
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
assert_eq!(
joined,
format!("▏ {want_tail}"),
"line {i}: the body's own list item was not recursively rendered: {:?}",
out.lines[i]
);
}
}
fn html_table_lines(src: &str, width: u16) -> Vec<String> {
crate::preview::markdown::set_details_open(Vec::new());
let math_slot = |_: &str, _: bool| MathSlot::Raw;
let doc = Doc::parse(src);
let out = render_doc(
&doc,
src,
width,
CodeStyle::default(),
"TwoDark",
false,
&[' ', 'x'],
&no_images,
&no_mermaid,
"Enter: full screen",
true,
&math_slot,
false,
);
assert!(
out.unsupported.is_empty(),
"unsupported: {:?}",
out.unsupported
);
out.lines
.iter()
.map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
.collect()
}
fn column_rule_widths(lines: &[String]) -> Vec<usize> {
let top = lines
.iter()
.find(|l| l.starts_with('┌'))
.unwrap_or_else(|| panic!("no table was drawn at all: {lines:?}"));
top.trim_start_matches('┌')
.trim_end_matches('┐')
.split('┬')
.map(|seg| seg.chars().count())
.collect()
}
#[test]
fn every_column_is_at_least_one_cell_wide_however_empty_its_cells_are() {
for (why, src, want) in [
(
"an empty cell in the first column",
"<table>\n<tr><td></td><td>b</td></tr>\n</table>\n",
vec!["┌───┬───┐", "│ │ b │", "└───┴───┘"],
),
(
"every cell in the table empty",
"<table>\n<tr><td></td><td></td></tr>\n</table>\n",
vec!["┌───┬───┐", "│ │ │", "└───┴───┘"],
),
(
"an empty column between two that have content",
"<table>\n<tr><td>a</td><td></td><td>c</td></tr>\n</table>\n",
vec!["┌───┬───┬───┐", "│ a │ │ c │", "└───┴───┴───┘"],
),
(
"a lone empty header cell",
"<table>\n<tr><th></th></tr>\n</table>\n",
vec!["┌───┐", "│ │", "├───┤", "└───┘"],
),
(
"the same, through the GFM table path",
"| a | | c |\n|---|---|---|\n| 1 | | 3 |\n",
vec![
"┌───┬───┬───┐",
"│ a │ │ c │",
"├───┼───┼───┤",
"│ 1 │ │ 3 │",
"└───┴───┴───┘",
],
),
(
"a GFM table with nothing in it at all",
"| |\n|---|\n| |\n",
vec!["┌───┐", "│ │", "├───┤", "│ │", "└───┘"],
),
] {
let lines = html_table_lines(src, 40);
assert_eq!(lines, want, "{why}");
assert!(
column_rule_widths(&lines).iter().all(|&w| w >= 3),
"{why}: 内容 1 桁 + 左右のパディングに満たない列がある: {:?}",
column_rule_widths(&lines)
);
}
}
#[test]
fn a_ragged_table_draws_as_many_columns_as_its_widest_row_has() {
for (why, src, want) in [
(
"the widest row first",
"<table>\n<tr><td>a</td><td>b</td><td>c</td></tr>\n<tr><td>d</td></tr>\n</table>\n",
vec!["┌───┬───┬───┐", "│ a │ b │ c │", "│ d │ │ │", "└───┴───┴───┘"],
),
(
"the widest row last",
"<table>\n<tr><td>d</td></tr>\n<tr><td>a</td><td>b</td><td>c</td></tr>\n</table>\n",
vec!["┌───┬───┬───┐", "│ d │ │ │", "│ a │ b │ c │", "└───┴───┴───┘"],
),
(
"a colspan cell, drawn as one plain cell in a row of its own",
"<table>\n<tr><td colspan=\"2\">spanning</td></tr>\n<tr><td>a</td><td>b</td></tr>\n</table>\n",
vec![
"┌──────────┬───┐",
"│ spanning │ │",
"│ a │ b │",
"└──────────┴───┘",
],
),
(
"a body row shorter than the header row, through the GFM path",
"| a | b | c |\n|---|---|---|\n| d |\n",
vec![
"┌───┬───┬───┐",
"│ a │ b │ c │",
"├───┼───┼───┤",
"│ d │ │ │",
"└───┴───┴───┘",
],
),
] {
let lines = html_table_lines(src, 40);
assert_eq!(lines, want, "{why}");
}
}
#[test]
fn html_table_renders_a_box_drawn_grid() {
let src = "<table>\n <tr>\n <td><img src=\"a.png\" alt=\"A\"></td>\n <td><img src=\"b.png\" alt=\"B\"></td>\n </tr>\n <tr>\n <td align=\"center\"><b>One</b></td>\n <td align=\"center\"><b>Two</b></td>\n </tr>\n</table>\n";
let lines = html_table_lines(src, 60);
assert_eq!(lines.len(), 4, "top rule, two rows, bottom rule: {lines:?}");
assert!(
lines[0].starts_with('┌') && lines[0].ends_with('┐'),
"{lines:?}"
);
assert!(
lines[3].starts_with('└') && lines[3].ends_with('┘'),
"{lines:?}"
);
assert!(
lines[1].contains("🖼 A") && lines[1].contains("🖼 B"),
"{lines:?}"
);
assert!(
lines[2].contains("One") && lines[2].contains("Two"),
"{lines:?}"
);
assert!(
!lines
.iter()
.any(|l| l.contains("<td") || l.contains("<img")),
"no raw tag survives: {lines:?}"
);
}
#[test]
fn html_table_cell_align_beats_the_column_one_cell_at_a_time() {
let src = "<table>\n<tr><td align=\"left\">x</td></tr>\n<tr><td align=\"center\">x</td></tr>\n<tr><td align=\"right\">x</td></tr>\n<tr><td>wwwwwwwww</td></tr>\n</table>\n";
let lines = html_table_lines(src, 60);
assert_eq!(lines[1], "│ x │", "left: {lines:?}");
assert_eq!(lines[2], "│ x │", "center: {lines:?}");
assert_eq!(lines[3], "│ x │", "right: {lines:?}");
assert_eq!(
lines[4], "│ wwwwwwwww │",
"unspecified falls back to the column: {lines:?}"
);
}
#[test]
fn html_table_leading_th_rows_get_one_header_rule() {
let one = html_table_lines(
"<table>\n<tr><th>H</th></tr>\n<tr><td>a</td></tr>\n<tr><td>b</td></tr>\n</table>\n",
40,
);
assert!(
one[2].starts_with('├'),
"rule under the single header row: {one:?}"
);
let two = html_table_lines(
"<table>\n<tr><th>H</th></tr>\n<tr><th>I</th></tr>\n<tr><td>a</td></tr>\n</table>\n",
40,
);
assert!(
two[3].starts_with('├'),
"one rule, under the second header row: {two:?}"
);
assert_eq!(
two.iter().filter(|l| l.starts_with('├')).count(),
1,
"exactly one header rule: {two:?}"
);
}
#[test]
fn html_table_stray_th_is_styled_without_a_header_rule() {
let src =
"<table>\n<tr><td>a</td><th>b</th></tr>\n<tr><td>c</td><td>d</td></tr>\n</table>\n";
crate::preview::markdown::set_details_open(Vec::new());
let math_slot = |_: &str, _: bool| MathSlot::Raw;
let doc = Doc::parse(src);
let out = render_doc(
&doc,
src,
40,
CodeStyle::default(),
"TwoDark",
false,
&[' ', 'x'],
&no_images,
&no_mermaid,
"Enter: full screen",
true,
&math_slot,
false,
);
let text: Vec<String> = out
.lines
.iter()
.map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
.collect();
assert!(
!text.iter().any(|l| l.starts_with('├')),
"a stray <th> draws no header rule: {text:?}"
);
let bold_texts: Vec<String> = out.lines[1]
.spans
.iter()
.filter(|s| s.style.add_modifier.contains(Modifier::BOLD))
.map(|s| s.content.to_string())
.collect();
assert!(
bold_texts.iter().any(|t| t == "b"),
"the <th> cell itself is bold: {bold_texts:?}"
);
assert!(
!bold_texts.iter().any(|t| t == "a"),
"its <td> neighbour is not: {bold_texts:?}"
);
}
#[test]
fn html_table_inside_a_quote_draws_inside_the_quote_bar() {
let src = "> <table>\n> <tr><td>\n> first\n> second\n> </td></tr>\n> </table>\n";
let lines = html_table_lines(src, 60);
assert!(
lines.iter().all(|l| l.starts_with("> ")),
"every line stays inside the quote: {lines:?}"
);
assert!(
lines.iter().any(|l| l.contains("first second")),
"the multi-line cell reads without quote markers: {lines:?}"
);
assert!(
!lines.iter().any(|l| l[2..].contains('>')),
"no stray quote marker inside a cell: {lines:?}"
);
}
#[test]
fn a_gfm_table_still_takes_its_alignment_from_the_delimiter_row() {
let lines = html_table_lines("| a | b |\n|:--|--:|\n| x | y |\n| wwwww | wwwww |\n", 60);
assert_eq!(
lines[3], "│ x │ y │",
"left / right columns: {lines:?}"
);
}
#[test]
fn details_closed_state_hides_its_body() {
crate::preview::markdown::set_details_open(Vec::new());
let code = CodeStyle::default();
let math_slot = |_: &str, _: bool| MathSlot::Raw;
let src = "<details>\n<summary>S</summary>\n\nSECRET\n\n</details>\n";
let doc = Doc::parse(src);
let out = render_doc(
&doc,
src,
80,
code,
"TwoDark",
false,
&[' ', 'x'],
&no_images,
&no_mermaid,
"Enter: full screen",
true,
&math_slot,
false,
);
assert!(
out.unsupported.is_empty(),
"src: {src:?}, unsupported: {:?}",
out.unsupported
);
assert_eq!(
out.lines.len(),
1,
"a closed details block draws only its own marker line: {:?}",
out.lines
);
let joined: String = out.lines[0]
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
assert!(
!joined.contains("SECRET"),
"closed details leaked its body: {joined:?}"
);
}
#[test]
fn details_nested_inside_an_alert_does_not_consume_the_top_level_ordinal() {
crate::preview::markdown::set_details_open(vec![false]);
let code = CodeStyle::default();
let math_slot = |_: &str, _: bool| MathSlot::Raw;
let src = "> [!NOTE]\n> <details open>\n> <summary>Nested</summary>\n>\n> shown\n>\n> </details>\n\n\
<details open>\n<summary>Real</summary>\n\nSECRET\n\n</details>\n";
let doc = Doc::parse(src);
let out = render_doc(
&doc,
src,
80,
code,
"TwoDark",
false,
&[' ', 'x'],
&no_images,
&no_mermaid,
"Enter: full screen",
true,
&math_slot,
false,
);
assert!(
out.unsupported.is_empty(),
"src: {src:?}, unsupported: {:?}",
out.unsupported
);
let joined_all: Vec<String> = out
.lines
.iter()
.map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
.collect();
assert!(
joined_all.iter().any(|l| l.contains("shown")),
"the alert-nested details is rendered statically, honoring its own `open` attribute \
directly (never consuming an ordinal slot) — it must still show its body: {joined_all:?}"
);
assert!(
!joined_all.iter().any(|l| l.contains("SECRET")),
"the one real top-level details block must land on ordinal slot 0 (seeded closed) — \
if the alert-nested one had wrongly consumed that slot instead, this block would fall \
back to its own `open` attribute (`true`) and leak SECRET: {joined_all:?}"
);
}
use super::super::TABLE_BORDER_FG;
#[test]
fn table_renders_borders_header_style_and_column_alignment_from_the_model() {
let src = "| a | bb | ccc |\n|:---|:---:|---:|\n| 1 | 2 | 3 |\n";
let doc = Doc::parse(src);
let code = CodeStyle::default();
let math_slot = |_: &str, _: bool| MathSlot::Raw;
let out = render_doc(
&doc,
src,
40,
code,
"TwoDark",
false,
&[' ', 'x'],
&no_images,
&no_mermaid,
"Enter: full screen",
true,
&math_slot,
false,
);
assert!(
out.unsupported.is_empty(),
"src: {src:?}, unsupported: {:?}",
out.unsupported
);
let border = Style::new().fg(TABLE_BORDER_FG);
let head = Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD);
assert_eq!(
out.lines,
vec![
Line::from(Span::styled("┌───┬────┬─────┐", border)),
Line::from(vec![
Span::styled("│", border),
Span::styled(" ", head),
Span::styled("a", head),
Span::styled(" ", head),
Span::styled("│", border),
Span::styled(" ", head),
Span::styled("bb", head),
Span::styled(" ", head),
Span::styled("│", border),
Span::styled(" ", head),
Span::styled("ccc", head),
Span::styled(" ", head),
Span::styled("│", border),
]),
Line::from(Span::styled("├───┼────┼─────┤", border)),
Line::from(vec![
Span::styled("│", border),
Span::raw(" "),
Span::raw("1"),
Span::raw(" "),
Span::styled("│", border),
Span::raw(" "), Span::raw("2"),
Span::raw(" "), Span::styled("│", border),
Span::raw(" "), Span::raw("3"),
Span::raw(" "),
Span::styled("│", border),
]),
Line::from(Span::styled("└───┴────┴─────┘", border)),
],
"table structure (borders/header style/alignment) did not match: {:?}",
out.lines
);
}
#[test]
fn table_nested_in_a_list_item_renders_correctly() {
let src = "- item\n\n | a | b |\n |---|---|\n | 1 | 2 |\n";
let doc = Doc::parse(src);
let code = CodeStyle::default();
let math_slot = |_: &str, _: bool| MathSlot::Raw;
let out = render_doc(
&doc,
src,
40,
code,
"TwoDark",
false,
&[' ', 'x'],
&no_images,
&no_mermaid,
"Enter: full screen",
true,
&math_slot,
false,
);
assert!(
out.unsupported.is_empty(),
"src: {src:?}, unsupported: {:?}",
out.unsupported
);
let has_border = out
.lines
.iter()
.any(|l| l.spans.iter().any(|s| s.content.as_ref() == "┌───┬───┐"));
assert!(
has_border,
"expected a real, box-drawn table: {:?}",
out.lines
);
let has_row = out.lines.iter().any(|l| {
let joined: String = l.spans.iter().map(|s| s.content.as_ref()).collect();
joined.contains('1') && joined.contains('2')
});
assert!(
has_row,
"expected the table's own data row: {:?}",
out.lines
);
}
#[test]
fn quote_nested_table_and_html_are_neither_reported_unsupported() {
let table_src = "> | a | b |\n> |---|---|\n> | 1 | 2 |\n";
let doc = Doc::parse(table_src);
assert_eq!(
contains_unsupported(&doc.blocks[0].children, true),
None,
"src: {table_src:?}"
);
let html_src = "> <div>x</div>\n> more\n";
let doc = Doc::parse(html_src);
assert_eq!(
contains_unsupported(&doc.blocks[0].children, true),
None,
"src: {html_src:?}"
);
}
#[test]
fn html_block_nested_inside_a_quote_renders_its_content_instead_of_vanishing() {
let src = "> <div align=\"center\">\n> HTML-INSIDE-QUOTE\n> </div>\n";
let doc = Doc::parse(src);
let code = CodeStyle::default();
let math_slot = |_: &str, _: bool| MathSlot::Raw;
let out = render_doc(
&doc,
src,
40,
code,
"TwoDark",
false,
&[' ', 'x'],
&no_images,
&no_mermaid,
"Enter: full screen",
true,
&math_slot,
false,
);
assert!(
out.unsupported.is_empty(),
"src: {src:?}, unsupported: {:?}",
out.unsupported
);
let rendered: Vec<String> = out
.lines
.iter()
.map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
.collect();
assert_eq!(
rendered,
vec!["> HTML-INSIDE-QUOTE".to_string(), "> ".to_string()],
"the quote-nested HTML block did not render cleanly (no doubled `>` marker, no dropped \
content): {rendered:#?}"
);
}
#[test]
fn html_img_tags_with_no_intervening_tag_lines_pack_onto_one_row() {
let src = "<div>\n<img src=\"a.svg\" alt=\"a\">\n<img src=\"b.svg\" alt=\"b\">\n\
<img src=\"c.svg\" alt=\"c\">\n</div>\n";
let doc = Doc::parse(src);
let code = CodeStyle::default();
let math_slot = |_: &str, _: bool| MathSlot::Raw;
let slot_of = |_: &str, _: Option<u16>| ImageSlot::Inline { cols: 5, rows: 1 };
let out = render_doc(
&doc,
src,
40,
code,
"TwoDark",
false,
&[' ', 'x'],
&slot_of,
&no_mermaid,
"Enter: full screen",
true,
&math_slot,
false,
);
assert!(
out.unsupported.is_empty(),
"src: {src:?}, unsupported: {:?}",
out.unsupported
);
let lines: Vec<usize> = out.images.iter().map(|p| p.line).collect();
assert_eq!(
lines,
vec![0, 0, 0],
"three bare <img> lines with nothing but blank tag-only content between them must pack \
onto one shared row via the same `render_image_group` a Markdown badge row uses — not \
three stacked rows: {:?}",
out.images
);
let cols: Vec<u16> = out.images.iter().map(|p| p.col).collect();
assert_eq!(
cols,
vec![11, 17, 23],
"cols must increase strictly left to right, same as the Markdown-only badge-row tests: \
{:?}",
out.images
);
}
#[test]
fn html_block_strips_tags_drops_comments_and_decodes_entities_from_the_model() {
let src = "<div class=\"x\">\n<!-- hidden -->\nHello <b>world</b> & friends\n</div>\n";
let doc = Doc::parse(src);
let code = CodeStyle::default();
let math_slot = |_: &str, _: bool| MathSlot::Raw;
let out = render_doc(
&doc,
src,
40,
code,
"TwoDark",
false,
&[' ', 'x'],
&no_images,
&no_mermaid,
"Enter: full screen",
true,
&math_slot,
false,
);
assert!(
out.unsupported.is_empty(),
"src: {src:?}, unsupported: {:?}",
out.unsupported
);
assert_eq!(
out.lines,
vec![Line::from("Hello world & friends"), Line::from("")],
"tag-stripped/comment-dropped/entity-decoded output did not match: {:?}",
out.lines
);
}
#[test]
fn standalone_block_image_renders_and_is_not_gated_by_math_on() {
let src = "\n";
let doc = Doc::parse(src);
let code = CodeStyle::default();
let math_slot = |_: &str, _: bool| MathSlot::Raw;
let slot_of = |_: &str, _: Option<u16>| ImageSlot::Inline { cols: 7, rows: 3 };
let out = render_doc(
&doc,
src,
40,
code,
"TwoDark",
false,
&[' ', 'x'],
&slot_of,
&no_mermaid,
"Enter: full screen",
true,
&math_slot,
false, );
assert!(
out.unsupported.is_empty(),
"src: {src:?}, unsupported: {:?}",
out.unsupported
);
assert_eq!(
out.images,
vec![ImagePlacement {
url: "cat.png".to_string(),
alt: "a cat".to_string(),
line: 0,
col: 16, cols: 7,
rows: 3,
fence_ord: None,
}],
"images: {:?}",
out.images
);
assert_eq!(
out.lines.len(),
3,
"3 reserved rows for rows: 3: {:?}",
out.lines
);
}
#[test]
fn block_image_placement_line_accounts_for_heading_decoration_shift() {
let src = "# One\n\n## Two\n\npara\n\n\n";
let doc = Doc::parse(src);
let code = CodeStyle::default();
let math_slot = |_: &str, _: bool| MathSlot::Raw;
let slot_of = |_: &str, _: Option<u16>| ImageSlot::Inline { cols: 4, rows: 2 };
let out = render_doc(
&doc,
src,
40,
code,
"TwoDark",
false,
&[' ', 'x'],
&slot_of,
&no_mermaid,
"Enter: full screen",
true,
&math_slot,
false,
);
assert!(
out.unsupported.is_empty(),
"src: {src:?}, unsupported: {:?}",
out.unsupported
);
assert_eq!(out.images.len(), 1, "images: {:?}", out.images);
let placement = &out.images[0];
let at_placement = out.lines.get(placement.line).map(|l| {
l.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
});
assert_eq!(
at_placement.as_deref().map(str::trim),
Some("🖼 a"),
"ImagePlacement.line ({}) must index the image's own reserved row in the fully \
decorated `out.lines` — two headings (one H1, one H2) precede it, each contributing its \
own rule line: {:?}",
placement.line,
out.lines
);
assert_eq!(
out.lines.get(placement.line - 1),
Some(&Line::from("para")),
"the line right before the image placement must be \"para\", the real paragraph \
immediately preceding it in the source — a wrong shift would land here instead: {:?}",
out.lines
);
}
#[test]
fn consecutive_badge_lines_extract_every_image_not_just_the_first() {
let src = "[](https://ci.example/build)\n\
[](https://docs.example)\n";
let doc = Doc::parse(src);
let code = CodeStyle::default();
let math_slot = |_: &str, _: bool| MathSlot::Raw;
let slot_of = |_: &str, _: Option<u16>| ImageSlot::Inline { cols: 5, rows: 1 };
let out = render_doc(
&doc,
src,
40,
code,
"TwoDark",
false,
&[' ', 'x'],
&slot_of,
&no_mermaid,
"Enter: full screen",
true,
&math_slot,
false,
);
assert!(
out.unsupported.is_empty(),
"src: {src:?}, unsupported: {:?}",
out.unsupported
);
assert_eq!(
out.images,
vec![
ImagePlacement {
url: "build.svg".to_string(),
alt: "Build Status".to_string(),
line: 0,
col: 14, cols: 5,
rows: 1,
fence_ord: None,
},
ImagePlacement {
url: "docs.svg".to_string(),
alt: "Docs".to_string(),
line: 0,
col: 20, cols: 5,
rows: 1,
fence_ord: None,
},
],
"both badges must extract as separate placements, each with its own URL, packed onto the \
same row left to right — not one merged placement missing the second badge's own URL, \
and not stacked one row below the other: {:?}",
out.images
);
}
#[test]
fn two_images_sharing_one_line_segment_both_extract_as_separate_placements() {
let src = "[](h1) [](h2)\n";
let doc = Doc::parse(src);
let code = CodeStyle::default();
let math_slot = |_: &str, _: bool| MathSlot::Raw;
let slot_of = |_: &str, _: Option<u16>| ImageSlot::Inline { cols: 5, rows: 1 };
let out = render_doc(
&doc,
src,
40,
code,
"TwoDark",
false,
&[' ', 'x'],
&slot_of,
&no_mermaid,
"Enter: full screen",
true,
&math_slot,
false,
);
assert!(
out.unsupported.is_empty(),
"src: {src:?}, unsupported: {:?}",
out.unsupported
);
assert_eq!(
out.images,
vec![
ImagePlacement {
url: "u1".to_string(),
alt: "a".to_string(),
line: 0,
col: 14, cols: 5,
rows: 1,
fence_ord: None,
},
ImagePlacement {
url: "u2".to_string(),
alt: "b".to_string(),
line: 0,
col: 20, cols: 5,
rows: 1,
fence_ord: None,
},
],
"both badges sharing one physical line must extract as separate placements, each with \
its own URL, packed onto the same row left to right — not rejected outright, and not \
merged into one with the second badge's own information dropped: {:?}",
out.images
);
}
#[test]
fn three_badges_on_one_line_pack_onto_one_row_left_to_right() {
let src = "[](u1) [](u2) [](u3)\n";
let doc = Doc::parse(src);
let code = CodeStyle::default();
let math_slot = |_: &str, _: bool| MathSlot::Raw;
let slot_of = |_: &str, _: Option<u16>| ImageSlot::Inline { cols: 5, rows: 1 };
let out = render_doc(
&doc,
src,
40,
code,
"TwoDark",
false,
&[' ', 'x'],
&slot_of,
&no_mermaid,
"Enter: full screen",
true,
&math_slot,
false,
);
assert!(
out.unsupported.is_empty(),
"src: {src:?}, unsupported: {:?}",
out.unsupported
);
assert_eq!(
out.images,
vec![
ImagePlacement {
url: "x.svg".to_string(),
alt: "a".to_string(),
line: 0,
col: 11, cols: 5,
rows: 1,
fence_ord: None,
},
ImagePlacement {
url: "y.svg".to_string(),
alt: "b".to_string(),
line: 0,
col: 17, cols: 5,
rows: 1,
fence_ord: None,
},
ImagePlacement {
url: "z.svg".to_string(),
alt: "c".to_string(),
line: 0,
col: 23, cols: 5,
rows: 1,
fence_ord: None,
},
],
"three badges sharing one physical source line must pack onto one shared row, left to \
right — not stack one per row: {:?}",
out.images
);
assert_eq!(
out.lines.len(),
1,
"one shared row for all three badges (rows: 1 each) — three stacked rows would be a \
regression to the pre-row-packing bug: {:?}",
out.lines
);
}
#[test]
fn three_badges_on_separate_lines_still_pack_onto_one_row() {
let src = "[](u1)\n[](u2)\n[](u3)\n";
let doc = Doc::parse(src);
let code = CodeStyle::default();
let math_slot = |_: &str, _: bool| MathSlot::Raw;
let slot_of = |_: &str, _: Option<u16>| ImageSlot::Inline { cols: 5, rows: 1 };
let out = render_doc(
&doc,
src,
40,
code,
"TwoDark",
false,
&[' ', 'x'],
&slot_of,
&no_mermaid,
"Enter: full screen",
true,
&math_slot,
false,
);
assert!(
out.unsupported.is_empty(),
"src: {src:?}, unsupported: {:?}",
out.unsupported
);
let lines: Vec<usize> = out.images.iter().map(|p| p.line).collect();
assert_eq!(
lines,
vec![0, 0, 0],
"three badges on separate source lines, joined lazily with no blank line, must still \
pack onto one shared rendered row (the README badge-row idiom): {:?}",
out.images
);
let cols: Vec<u16> = out.images.iter().map(|p| p.col).collect();
assert_eq!(
cols,
vec![11, 17, 23],
"cols must increase strictly left to right by cols(5) + gap(1) each step: {:?}",
out.images
);
}
#[test]
fn a_row_group_that_does_not_fit_wraps_to_a_new_line() {
let src = "[](u1) [](u2) [](u3) [](u4)\n";
let doc = Doc::parse(src);
let code = CodeStyle::default();
let math_slot = |_: &str, _: bool| MathSlot::Raw;
let slot_of = |_: &str, _: Option<u16>| ImageSlot::Inline { cols: 5, rows: 1 };
let out = render_doc(
&doc,
src,
13,
code,
"TwoDark",
false,
&[' ', 'x'],
&slot_of,
&no_mermaid,
"Enter: full screen",
true,
&math_slot,
false,
);
assert!(
out.unsupported.is_empty(),
"src: {src:?}, unsupported: {:?}",
out.unsupported
);
let lines: Vec<usize> = out.images.iter().map(|p| p.line).collect();
assert_eq!(
lines,
vec![0, 0, 1, 1],
"at width 13, two 5-cell images (11 <= 13) share row 0; the third no longer fits \
(11 + 1 + 5 = 17 > 13) and wraps to row 1 along with the fourth: {:?}",
out.images
);
let cols: Vec<u16> = out.images.iter().map(|p| p.col).collect();
assert_eq!(
cols,
vec![1, 7, 1, 7],
"each row group is independently centered — both rows hold two 5-cell images with a \
1-cell gap, so both rows compute the identical (13 - 11) / 2 = 1 start column: {:?}",
out.images
);
assert_eq!(
out.lines.len(),
2,
"two reserved rows (one per row group, rows: 1 each) — a group that fit everything onto \
one row, or overflowed past width without wrapping, would both show up here as a wrong \
row count: {:?}",
out.lines
);
}
#[test]
fn sentence_directly_followed_by_a_standalone_image_line_still_extracts_the_image() {
let src = "Some intro sentence.\n\n";
let doc = Doc::parse(src);
let code = CodeStyle::default();
let math_slot = |_: &str, _: bool| MathSlot::Raw;
let slot_of = |_: &str, _: Option<u16>| ImageSlot::Inline { cols: 7, rows: 3 };
let out = render_doc(
&doc,
src,
40,
code,
"TwoDark",
false,
&[' ', 'x'],
&slot_of,
&no_mermaid,
"Enter: full screen",
true,
&math_slot,
false,
);
assert!(
out.unsupported.is_empty(),
"src: {src:?}, unsupported: {:?}",
out.unsupported
);
assert_eq!(
out.images,
vec![ImagePlacement {
url: "cat.png".to_string(),
alt: "a cat".to_string(),
line: 1,
col: 16, cols: 7,
rows: 3,
fence_ord: None,
}],
"the trailing image must still extract, with its own real URL, not fall back to \
URL-less inline alt-text: {:?}",
out.images
);
assert_eq!(
out.lines.first(),
Some(&Line::from("Some intro sentence.")),
"the leading sentence's own text must survive intact, on its own line: {:?}",
out.lines
);
}
#[test]
fn mermaid_fence_ordinal_and_placement_render_from_the_model() {
let src = "```mermaid\nA\n```\n\n```\nplain\n```\n\n```mermaid\nB\n```\n";
let doc = Doc::parse(src);
let code = CodeStyle::default();
let math_slot = |_: &str, _: bool| MathSlot::Raw;
let mermaid_slot = |_: &str| MermaidSlot::Image { cols: 6, rows: 2 };
let out = render_doc(
&doc,
src,
40,
code,
"TwoDark",
false,
&[' ', 'x'],
&no_images,
&mermaid_slot,
"mermaid",
true,
&math_slot,
false,
);
assert!(
out.unsupported.is_empty(),
"src: {src:?}, unsupported: {:?}",
out.unsupported
);
assert_eq!(
out.images.iter().map(|p| p.fence_ord).collect::<Vec<_>>(),
vec![Some(0), Some(1)],
"two mermaid fences, ordinal 0 then 1 (the plain fence between them must not consume a \
slot): {:?}",
out.images
);
assert_eq!(
out.images
.iter()
.map(|p| (p.cols, p.rows))
.collect::<Vec<_>>(),
vec![(6, 2), (6, 2)],
"images: {:?}",
out.images
);
let has_plain_code = out
.lines
.iter()
.any(|l| l.spans.iter().any(|s| s.content.as_ref() == "plain"));
assert!(
has_plain_code,
"the ordinary fence between the two mermaid ones must still render as plain, \
highlighted code: {:?}",
out.lines
);
}
#[test]
fn mermaid_fence_url_matches_production_for_a_top_level_fence() {
for src in ["```mermaid\nA-->B\n```\n", "```mermaid\nA-->B\n\n\n```\n"] {
let prod_fences = crate::preview::markdown::collect_mermaid_fences(src);
assert_eq!(
prod_fences.len(),
1,
"src: {src:?}, fences: {prod_fences:?}"
);
let want = crate::preview::markdown::mermaid_fence_url(&prod_fences[0]);
let doc = Doc::parse(src);
let code = CodeStyle::default();
let math_slot = |_: &str, _: bool| MathSlot::Raw;
let mermaid_slot = |_: &str| MermaidSlot::Image { cols: 6, rows: 2 };
let out = render_doc(
&doc,
src,
40,
code,
"TwoDark",
false,
&[' ', 'x'],
&no_images,
&mermaid_slot,
"mermaid",
true,
&math_slot,
false,
);
assert!(
out.unsupported.is_empty(),
"src: {src:?}, unsupported: {:?}",
out.unsupported
);
assert_eq!(
out.images.len(),
1,
"src: {src:?}, images: {:?}",
out.images
);
assert_eq!(
out.images[0].url, want,
"src: {src:?} — render_doc's own mermaid URL ({:?}) must match production's \
mermaid_fence_url(&fence) ({want:?}) exactly, byte for byte",
out.images[0].url
);
}
}
#[test]
fn math_url_matches_production_for_the_same_expression() {
for (src, display) in [
("inline $x^2$ math\n", false),
("$$\\int_0^1 x dx$$\n", true),
("$$a\\,b$$\n", true),
("$$a\\_b$$\n", true),
("$a\\%b$\n", false),
("\\(a\\,b\\)\n", false),
("\\[a\\,b\\]\n", true),
] {
let prod = crate::preview::markdown::collect_math_exprs(src);
assert_eq!(prod.len(), 1, "src: {src:?}, exprs: {prod:?}");
let (latex, prod_display) = &prod[0];
assert_eq!(*prod_display, display, "src: {src:?}, exprs: {prod:?}");
let want = crate::preview::markdown::math_url(latex, *prod_display);
let doc = Doc::parse(src);
let code = CodeStyle::default();
let math_slot = |_: &str, _: bool| MathSlot::Image { cols: 6, rows: 2 };
let out = render_doc(
&doc,
src,
40,
code,
"TwoDark",
false,
&[' ', 'x'],
&no_images,
&no_mermaid,
"mermaid",
true,
&math_slot,
true,
);
assert!(
out.unsupported.is_empty(),
"src: {src:?}, unsupported: {:?}",
out.unsupported
);
assert_eq!(
out.images.len(),
1,
"src: {src:?}, images: {:?}",
out.images
);
assert_eq!(
out.images[0].url, want,
"src: {src:?} — render_doc's own math URL ({:?}) must match production's \
math_url(latex, display) ({want:?}) exactly, byte for byte",
out.images[0].url
);
}
}
#[test]
fn escaped_dollar_math_lifts_instead_of_rendering_as_two_literal_fragments() {
let src = "$$a\\,b$$\n";
let doc = Doc::parse(src);
let code = CodeStyle::default();
let math_slot = |_: &str, _: bool| MathSlot::Raw;
let out = render_doc(
&doc,
src,
80,
code,
"TwoDark",
false,
&[' ', 'x'],
&no_images,
&no_mermaid,
"mermaid",
true,
&math_slot,
true,
);
assert!(out.unsupported.is_empty(), "src: {src:?}");
assert_eq!(
out.lines,
vec![Line::from(Span::from("$$ a\\,b $$").dim())],
"an escape-split `$$…$$` must still lift as one placeholder, not fall through as two \
literal text fragments (`\"$$a\"`/`\",b$$\"`) around the event boundary the escape causes: \
{:?}",
out.lines
);
}
#[test]
fn escaped_backslash_math_content_keeps_the_escapes_own_backslash() {
for (src, want_display) in [("\\(a\\,b\\)\n", false), ("\\[a\\,b\\]\n", true)] {
let prod = crate::preview::markdown::collect_math_exprs(src);
assert_eq!(
prod,
vec![("a\\,b".to_string(), want_display)],
"src: {src:?}"
);
let doc = Doc::parse(src);
let code = CodeStyle::default();
let math_slot = |_: &str, _: bool| MathSlot::Raw;
let out = render_doc(
&doc,
src,
80,
code,
"TwoDark",
false,
&[' ', 'x'],
&no_images,
&no_mermaid,
"mermaid",
true,
&math_slot,
true,
);
assert!(out.unsupported.is_empty(), "src: {src:?}");
let want_dim = if want_display {
"$$ a\\,b $$"
} else {
"$a\\,b$"
};
assert_eq!(
out.lines,
vec![Line::from(Span::from(want_dim).dim())],
"src: {src:?} — the lifted expression's own content must keep the internal escape's \
backslash, matching collect_math_exprs's own raw-source extraction exactly: {:?}",
out.lines
);
}
}
#[test]
fn a_dollar_that_never_closes_still_renders_literally_even_with_unrelated_escapes_nearby() {
for src in [
"It costs $50 for the widget.\n",
"cost \\$5 and \\$10 for \\*not italic\\*.\n",
] {
let prod = crate::preview::markdown::collect_math_exprs(src);
assert!(prod.is_empty(), "src: {src:?}, exprs: {prod:?}");
let doc = Doc::parse(src);
let code = CodeStyle::default();
let math_slot = |_: &str, _: bool| MathSlot::Raw;
let out = render_doc(
&doc,
src,
80,
code,
"TwoDark",
false,
&[' ', 'x'],
&no_images,
&no_mermaid,
"mermaid",
true,
&math_slot,
true,
);
assert!(out.unsupported.is_empty(), "src: {src:?}");
assert!(
out.images.is_empty(),
"src: {src:?}, images: {:?}",
out.images
);
let rendered: String = out
.lines
.iter()
.flat_map(|l| l.spans.iter())
.map(|s| s.content.as_ref())
.collect();
assert!(
!rendered.contains('\\'),
"src: {src:?} — no backslash should ever reach the screen for an escape that is not \
part of a real lift, whether the escape sits *inside* the never-closing dollar span's \
own reach or somewhere else entirely: {rendered:?}"
);
}
}
#[test]
fn dollar_that_never_closes_does_not_panic_when_unrelated_markup_follows() {
for (src, want) in [
(
"It costs $50 **bold** text.\n",
vec![Line::from_iter([
Span::from("It costs $50 "),
Span::from("bold").bold(),
Span::from(" text."),
])],
),
(
"It costs $50 *italic\nbreak* more.\n",
vec![Line::from_iter([
Span::from("It costs $50 "),
Span::from("italic").italic(),
Span::from(" "),
Span::from("break").italic(),
Span::from(" more."),
])],
),
(
"It costs $50 [a link](url) more.\n",
vec![Line::from_iter([
Span::from("It costs $50 "),
Span::from("a link"),
Span::from(" ("),
Span::from("url").blue().underlined(),
Span::from(")"),
Span::from(" more."),
])],
),
] {
let prod = crate::preview::markdown::collect_math_exprs(src);
assert!(prod.is_empty(), "src: {src:?}, exprs: {prod:?}");
let doc = Doc::parse(src);
let code = CodeStyle::default();
let math_slot = |_: &str, _: bool| MathSlot::Raw;
let out = render_doc(
&doc,
src,
80,
code,
"TwoDark",
false,
&[' ', 'x'],
&no_images,
&no_mermaid,
"mermaid",
true,
&math_slot,
true,
);
assert!(out.unsupported.is_empty(), "src: {src:?}");
assert!(
out.images.is_empty(),
"src: {src:?}, images: {:?}",
out.images
);
assert_eq!(
out.lines, want,
"src: {src:?} — must render exactly as if the never-closing `$` had never triggered \
`render_dollar_math_tail` at all: {:?}",
out.lines
);
}
}
#[test]
fn backslash_math_that_never_closes_does_not_panic_when_a_link_follows() {
for (src, want) in [
(
"See \\([a link](http://x)) here.\n",
vec![Line::from_iter([
Span::from("See "),
Span::from("("),
Span::from("a link"),
Span::from(" ("),
Span::from("http://x").blue().underlined(),
Span::from(")"),
Span::from(") here."),
])],
),
(
"See \\[[a link](http://x)] here.\n",
vec![Line::from_iter([
Span::from("See "),
Span::from("["),
Span::from("a link"),
Span::from(" ("),
Span::from("http://x").blue().underlined(),
Span::from(")"),
Span::from("]"),
Span::from(" here."),
])],
),
(
"\\([a **bold** link](http://x)) works.\n",
vec![Line::from_iter([
Span::from("("),
Span::from("a "),
Span::from("bold").bold(),
Span::from(" link"),
Span::from(" ("),
Span::from("http://x").blue().underlined(),
Span::from(")"),
Span::from(") works."),
])],
),
] {
let doc = Doc::parse(src);
let code = CodeStyle::default();
let math_slot = |_: &str, _: bool| MathSlot::Raw;
let out = render_doc(
&doc,
src,
80,
code,
"TwoDark",
false,
&[' ', 'x'],
&no_images,
&no_mermaid,
"mermaid",
true,
&math_slot,
true,
);
assert!(out.unsupported.is_empty(), "src: {src:?}");
assert!(
out.images.is_empty(),
"src: {src:?}, images: {:?}",
out.images
);
assert_eq!(
out.lines, want,
"src: {src:?} — must render exactly as if `backslash_math_opener` had never matched \
at all: {:?}",
out.lines
);
}
}
#[test]
fn dollar_math_resolves_across_an_intervening_code_span_or_nested_markup() {
for src in ["$$a\\,b `x` c\\_d$$\n", "$$a\\,b **c** d\\_e$$\n"] {
let prod = crate::preview::markdown::collect_math_exprs(src);
assert_eq!(prod.len(), 1, "src: {src:?}, exprs: {prod:?}");
let (latex, display) = &prod[0];
let want_url = crate::preview::markdown::math_url(latex, *display);
let doc = Doc::parse(src);
let code = CodeStyle::default();
let math_slot = |_: &str, _: bool| MathSlot::Image { cols: 6, rows: 2 };
let out = render_doc(
&doc,
src,
80,
code,
"TwoDark",
false,
&[' ', 'x'],
&no_images,
&no_mermaid,
"mermaid",
true,
&math_slot,
true,
);
assert!(out.unsupported.is_empty(), "src: {src:?}");
assert_eq!(
out.images.len(),
1,
"src: {src:?}, images: {:?}",
out.images
);
assert_eq!(
out.images[0].url, want_url,
"src: {src:?} — must resolve to production's own math_url exactly, byte for byte, \
even though a code span/nested markup construct sits between the escape and the real \
closer: {:?}",
out.images[0].url
);
}
}
#[test]
fn loose_task_list_collapses_checkbox_onto_the_marker_row_like_production_does() {
let src = "- [ ] one\n\n- [ ] two\n\n- [ ] three\n";
let out = render(src, true, false);
assert_eq!(
lines_text(&out),
vec!["- [ ] one", "- [ ] two", "- [ ] three"]
);
let checkbox_style = super::super::task_marker_style();
for line in &out.lines {
let checkbox = line
.spans
.iter()
.find(|s| s.style == checkbox_style)
.unwrap_or_else(|| panic!("no task_marker_style() span on line {line:?}"));
assert_eq!(checkbox.content.as_ref(), "[ ] ");
}
assert_eq!(out.tasks.len(), 3, "tasks: {:?}", out.tasks);
for (state, off) in &out.tasks {
assert_eq!(*state, ' ');
assert_eq!(
&src[*off..*off + 1],
" ",
"byte {off} must be the state char itself"
);
}
}
#[test]
fn loose_task_list_checked_and_custom_states_also_collapse_onto_the_marker_row() {
let src = "- [x] done\n\n- [/] custom\n";
let doc = Doc::parse(src);
let code = CodeStyle::default();
let math_slot = |_: &str, _: bool| MathSlot::Raw;
let out = render_doc(
&doc,
src,
80,
code,
"TwoDark",
false,
&[' ', 'x', '/'],
&no_images,
&no_mermaid,
"Enter: full screen",
true,
&math_slot,
false,
);
let texts = lines_text(&out);
assert_eq!(texts, vec!["- [x] done", "- [/] custom"]);
assert_eq!(
out.tasks,
vec![('x', 3), ('/', 15)],
"tasks: {:?}",
out.tasks
);
}
#[test]
fn tight_task_list_is_unaffected_by_the_loose_task_fix() {
let out = render("- [ ] one\n- [ ] two\n", true, false);
assert_eq!(lines_text(&out), vec!["- [ ] one", "- [ ] two"]);
}
#[test]
fn loose_list_with_no_task_still_gets_its_own_separate_row() {
let out = render("- one\n\n- two\n", true, false);
assert_eq!(lines_text(&out), vec!["- ", "one", "- ", "two"]);
}
#[test]
fn checkbox_inside_a_plain_quote_is_decorated_and_recorded() {
let out = render("> - [ ] quoted task\n", true, false);
assert_eq!(lines_text(&out), vec!["> - [ ] quoted task"]);
let checkbox_style = super::super::task_marker_style();
let checkbox = out.lines[0]
.spans
.iter()
.find(|s| s.style == checkbox_style)
.expect("the checkbox span must carry the Tab-focus sentinel style");
assert_eq!(checkbox.content.as_ref(), "[ ] ");
assert_eq!(out.tasks, vec![(' ', 5)], "tasks: {:?}", out.tasks);
}
#[test]
fn nested_quotes_keep_the_same_prefix_shape_as_before() {
let out = render("> > nested\n", true, false);
assert_eq!(lines_text(&out), vec!["> > nested"]);
}
#[test]
fn checkbox_inside_a_doubly_nested_quote_is_also_decorated() {
let out = render("> > - [ ] x\n", true, false);
assert_eq!(lines_text(&out), vec!["> > - [ ] x"]);
let checkbox_style = super::super::task_marker_style();
assert!(
out.lines[0].spans.iter().any(|s| s.style == checkbox_style),
"lines: {:?}",
out.lines[0]
);
}
#[test]
fn heading_inside_a_plain_quote_now_gets_the_full_width_rule_too() {
let out = render("> # H\n", true, false);
let texts = lines_text(&out);
assert_eq!(texts.len(), 2, "lines: {texts:?}");
assert!(texts[0].starts_with("> "), "heading line: {:?}", texts[0]);
assert!(texts[0].contains('H'), "heading line: {:?}", texts[0]);
assert!(
texts[1].starts_with("> ─") || texts[1].starts_with('>'),
"the full-width rule under an H1/H2 must also be quote-prefixed: {texts:?}"
);
}
#[test]
fn code_block_inside_a_plain_quote_still_fits_the_requested_width() {
let src = "> ```rust\n> fn a() {}\n> ```\n";
let doc = Doc::parse(src);
let code = CodeStyle {
bg: Some(Color::Rgb(10, 10, 10)),
..CodeStyle::default()
};
let math_slot = |_: &str, _: bool| MathSlot::Raw;
let width = 40u16;
let out = render_doc(
&doc,
src,
width,
code,
"TwoDark",
false,
&[' ', 'x'],
&no_images,
&no_mermaid,
"Enter: full screen",
true,
&math_slot,
false,
);
for l in &out.lines {
let cols: usize = l
.spans
.iter()
.map(|s| unicode_width::UnicodeWidthStr::width(s.content.as_ref()))
.sum();
assert!(
cols <= width as usize,
"line {cols} cols wide, requested width was {width}: {:?}",
line_text(l)
);
}
}
#[test]
fn a_quoted_list_inside_a_list_item_starts_its_nesting_over() {
for (why, src, want) in [
(
"the quote sits in a one-level list",
"- item\n > - a\n > - b\n",
vec!["- item", "> - a", "> - b"],
),
(
"the quote sits in an ordered list",
"1. item\n > - a\n",
vec!["1. item", "> - a"],
),
(
"the quote sits two list levels deep",
"- one\n - two\n > - a\n",
vec!["- one", " - two", "> - a"],
),
(
"an ordered list inside the quote",
"- item\n > 1. a\n > 2. b\n",
vec!["- item", "> 1. a", "> 2. b"],
),
(
"a doubly nested quote inside a list item",
"- item\n > > - a\n",
vec!["- item", "> > - a"],
),
(
"a list nested inside the quoted list",
"- item\n > - a\n > - deep\n",
vec!["- item", "> - a", "> - deep"],
),
(
"the same quote with no list around it at all",
"> - a\n",
vec!["> - a"],
),
] {
let out = render(src, true, false);
assert_eq!(lines_text(&out), want, "{why}");
}
}
#[test]
fn a_table_in_a_pane_narrower_than_its_own_frame_keeps_one_column_per_column() {
for ncol in 1..=4usize {
let head: Vec<String> = (0..ncol).map(|c| format!("aaaa{c}")).collect();
let src = format!(
"| {} |\n|{}|\n| {} |\n",
head.join(" | "),
vec!["---"; ncol].join("|"),
head.join(" | ")
);
let want = 1 + 4 * ncol;
for width in 1..=(want as u16) {
let lines = html_table_lines(&src, width);
let box_lines: Vec<&String> = lines
.iter()
.filter(|l| {
l.starts_with('┌')
|| l.starts_with('│')
|| l.starts_with('├')
|| l.starts_with('└')
})
.collect();
assert!(
!box_lines.is_empty(),
"ncol={ncol} width={width}: 表が描かれていない: {lines:?}"
);
for l in box_lines {
assert_eq!(
unicode_width::UnicodeWidthStr::width(l.as_str()),
want,
"ncol={ncol} width={width}: 列が1桁に潰れていない: |{l}|"
);
}
}
}
}
#[test]
fn three_columns_in_a_twelve_column_pane_draw_a_thirteen_column_box() {
let lines = html_table_lines("| aaaa | bbbb | cccc |\n|---|---|---|\n| 1 | 2 | 3 |\n", 12);
assert_eq!(
lines
.iter()
.map(|l| unicode_width::UnicodeWidthStr::width(l.as_str()))
.collect::<Vec<_>>(),
vec![13; 8],
"{lines:?}"
);
}
fn render(src: &str, alerts: bool, math_on: bool) -> RenderOut {
let doc = Doc::parse(src);
let code = CodeStyle::default();
let math_slot = |_: &str, _: bool| MathSlot::Raw;
render_doc(
&doc,
src,
80,
code,
"TwoDark",
false,
&[' ', 'x'],
&no_images,
&no_mermaid,
"Enter: full screen",
alerts,
&math_slot,
math_on,
)
}
fn render_with_images(
src: &str,
alerts: bool,
math_on: bool,
cols: u16,
rows: u16,
) -> RenderOut {
let doc = Doc::parse(src);
let code = CodeStyle::default();
let math_slot = |_: &str, _: bool| MathSlot::Raw;
let slot_of = |_: &str, _: Option<u16>| ImageSlot::Inline { cols, rows };
render_doc(
&doc,
src,
80,
code,
"TwoDark",
false,
&[' ', 'x'],
&slot_of,
&no_mermaid,
"Enter: full screen",
alerts,
&math_slot,
math_on,
)
}
fn line_text(l: &Line<'_>) -> String {
l.spans.iter().map(|s| s.content.as_ref()).collect()
}
fn lines_text(out: &RenderOut) -> Vec<String> {
out.lines.iter().map(line_text).collect()
}
#[test]
fn a_nested_list_never_opens_with_a_blank_row_however_loose_its_parent_is() {
for (why, src, want) in [
(
"a nested list right after a loose item's own paragraph",
"- a\n\n - b\n",
vec!["- ", "a", " - b"],
),
(
"a nested list after a second paragraph in the same item",
"- a\n\n para\n\n - b\n",
vec!["- ", "a", "", "para", " - b"],
),
(
"three levels, every one of them loose",
"- a\n\n - b\n\n - c\n",
vec!["- ", "a", " - ", "b", " - c"],
),
(
"an ordered list nested in a loose ordered item",
"1. a\n\n 1. b\n",
vec!["1. ", "a", " 1. b"],
),
(
"a nested list whose item carries a task marker",
"- item\n\n - [ ] looks like a task\n",
vec!["- ", "item", " - [ ] looks like a task"],
),
(
"the same shape inside a block quote",
"> - a\n>\n> - b\n",
vec!["> - ", "> a", "> - b"],
),
(
"counter-case: a top-level list still gets its blank row",
"before.\n\n- a\n- b\n",
vec!["before.", "", "- a", "- b"],
),
] {
assert_eq!(lines_text(&render(src, true, false)), want, "{why}");
}
}
#[test]
fn an_html_table_is_separated_from_what_follows_exactly_like_a_gfm_table() {
fn tail(src: &str) -> Vec<String> {
let lines = lines_text(&render(src, true, false));
let bottom = lines
.iter()
.position(|l| l.starts_with('└'))
.unwrap_or_else(|| panic!("no table was drawn at all for {src:?}: {lines:?}"));
lines[bottom + 1..].to_vec()
}
for follower in [
"para\n",
"# H\n",
"- item\n",
"1. item\n",
"```\nfence\n```\n",
"> quote\n",
"---\n",
"<table><tr><td>z</td></tr></table>\n",
"| q |\n|---|\n| r |\n",
"", ] {
let html = tail(&format!("<table><tr><td>a</td></tr></table>\n\n{follower}"));
let gfm = tail(&format!("| a |\n|---|\n| x |\n\n{follower}"));
assert_eq!(
html, gfm,
"HTML 表と GFM 表で、後に続く {follower:?} との間の空け方が違う"
);
}
}
fn has_table_divider(out: &RenderOut) -> bool {
out.lines
.iter()
.any(|l| l.spans.iter().any(|s| s.content.contains('├')))
}
#[test]
fn list_with_a_quote_nested_table_renders_a_real_table() {
let src = "- item\n > | a | b |\n > |---|---|\n > | 1 | 2 |\n";
let out = render(src, true, false);
assert!(
out.unsupported.is_empty(),
"unsupported: {:?}",
out.unsupported
);
let has_border = out
.lines
.iter()
.any(|l| l.spans.iter().any(|s| s.content.contains('┌')));
assert!(
has_border,
"expected a real, box-drawn table: {:?}",
out.lines
);
assert!(
has_table_divider(&out),
"expected the header/body divider rule (proof the delimiter row itself parsed \
correctly, not as a bogus extra data row): {:?}",
out.lines
);
}
#[test]
fn details_with_a_quote_nested_table_renders_a_real_table() {
let src = "<details open>\n<summary>s</summary>\n\n\
> | a | b |\n> |---|---|\n> | 1 | 2 |\n\n\
</details>\n";
let out = render(src, true, false);
assert!(
out.unsupported.is_empty(),
"unsupported: {:?}",
out.unsupported
);
let has_border = out
.lines
.iter()
.any(|l| l.spans.iter().any(|s| s.content.contains('┌')));
assert!(
has_border,
"expected a real, box-drawn table: {:?}",
out.lines
);
assert!(
has_table_divider(&out),
"expected the header/body divider rule (proof the delimiter row itself parsed \
correctly, not as a bogus extra data row): {:?}",
out.lines
);
}
#[test]
fn alert_headed_quote_with_alerts_off_and_a_nested_table_renders_a_real_table() {
let src = "> [!NOTE]\n> | a | b |\n> |---|---|\n> | 1 | 2 |\n";
let out = render(src, false, false);
assert!(
out.unsupported.is_empty(),
"unsupported: {:?}",
out.unsupported
);
let has_border = out
.lines
.iter()
.any(|l| l.spans.iter().any(|s| s.content.contains('┌')));
assert!(
has_border,
"expected a real, box-drawn table: {:?}",
out.lines
);
assert!(
has_table_divider(&out),
"expected the header/body divider rule (proof the delimiter row itself parsed \
correctly, not as a bogus extra data row): {:?}",
out.lines
);
}
#[test]
fn alert_headed_quote_with_alerts_on_and_a_nested_table_renders_a_real_table() {
let src = "> [!NOTE]\n> | a | b |\n> |---|---|\n> | 1 | 2 |\n";
let out = render(src, true, false);
assert!(
out.unsupported.is_empty(),
"unsupported: {:?}",
out.unsupported
);
let has_border = out
.lines
.iter()
.any(|l| l.spans.iter().any(|s| s.content.contains('┌')));
assert!(
has_border,
"expected a real, box-drawn table: {:?}",
out.lines
);
assert!(
has_table_divider(&out),
"expected the header/body divider rule (proof the delimiter row itself parsed \
correctly, not as a bogus extra data row): {:?}",
out.lines
);
}
#[test]
fn alert_headed_quote_with_a_table_glued_to_the_header_no_blank_line_renders_a_real_table() {
let src = "> [!IMPORTANT]\n> Crate | Note\n> ------|------\n> ndk | x\n";
let out = render(src, true, false);
assert!(
out.unsupported.is_empty(),
"unsupported: {:?}",
out.unsupported
);
let has_border = out
.lines
.iter()
.any(|l| l.spans.iter().any(|s| s.content.contains('┌')));
assert!(
has_border,
"expected a real, box-drawn table even though the header line glued into the same \
paragraph as the table's own raw text: {:?}",
out.lines
);
assert!(
has_table_divider(&out),
"expected the header/body divider rule (proof the delimiter row itself parsed \
correctly, not as a bogus extra data row): {:?}",
out.lines
);
}
#[test]
fn a_top_level_list_item_that_could_never_really_happen_is_reported_unsupported_not_a_panic() {
let doc = Doc {
events: Vec::new(),
blocks: vec![Block {
kind: BlockKind::ListItem { task: None },
src: 0..0,
children: Vec::new(),
}],
};
let code = CodeStyle::default();
let math_slot = |_: &str, _: bool| MathSlot::Raw;
let out = render_doc(
&doc,
"",
80,
code,
"TwoDark",
false,
&[' ', 'x'],
&no_images,
&no_mermaid,
"Enter: full screen",
true,
&math_slot,
false,
);
assert_eq!(out.unsupported, vec!["ListItem"]);
}
#[test]
fn append_span_on_a_totally_empty_writer_opens_the_first_line_itself() {
let mut w = fresh_writer();
assert!(w.lines.is_empty());
w.append_span(Span::raw("hi"));
assert_eq!(w.lines, vec![Line::from(vec![Span::raw("hi")])]);
}
#[test]
fn text_with_an_embedded_newline_starts_a_fresh_line_for_the_second_half() {
let mut w = fresh_writer();
w.write_text("line1\nline2");
assert_eq!(
lines_text(&RenderOut {
lines: w.lines.clone(),
images: Vec::new(),
unsupported: Vec::new(),
code_blocks: Vec::new(),
tasks: Vec::new(),
}),
vec!["line1", "line2"]
);
}
#[test]
fn write_task_marker_with_no_lines_at_all_falls_back_to_append_span() {
let mut w = fresh_writer();
assert!(w.lines.is_empty());
w.write_task_marker(true);
assert_eq!(w.lines.len(), 1);
assert_eq!(w.lines[0].spans.len(), 1);
assert_eq!(w.lines[0].spans[0].content.as_ref(), "[x] ");
}
#[test]
fn superscript_renders_through_the_real_tag_dispatch() {
let out = render("a ^super^ b\n", true, false);
assert_eq!(lines_text(&out), vec!["a super b"]);
let span = out.lines[0]
.spans
.iter()
.find(|s| s.content.as_ref() == "super")
.expect("superscript span");
assert!(span.style.add_modifier.contains(Modifier::DIM));
assert!(span.style.add_modifier.contains(Modifier::ITALIC));
}
#[test]
fn subscript_renders_through_the_real_tag_dispatch() {
let out = render("a ~sub~ b\n", true, false);
assert_eq!(lines_text(&out), vec!["a sub b"]);
let span = out.lines[0]
.spans
.iter()
.find(|s| s.content.as_ref() == "sub")
.expect("subscript span");
assert!(span.style.add_modifier.contains(Modifier::DIM));
assert!(span.style.add_modifier.contains(Modifier::ITALIC));
}
#[test]
fn strikethrough_span_carries_the_crossed_out_modifier() {
let out = render("This was ~~deleted~~ text.\n", true, false);
let span = out.lines[0]
.spans
.iter()
.find(|s| s.content.as_ref() == "deleted")
.expect("strikethrough span");
assert!(span.style.add_modifier.contains(Modifier::CROSSED_OUT));
}
#[test]
fn ordered_list_marker_is_styled_light_blue() {
let out = render("1. first\n2. second\n", true, false);
let marker = out.lines[0].spans.first().expect("marker span");
assert!(
marker.content.as_ref().contains('1'),
"marker: {:?}",
marker.content
);
assert_eq!(marker.style.fg, Some(Color::LightBlue));
}
#[test]
fn code_block_with_no_language_labels_itself_code() {
let out = render("```\nplain\n```\n", true, false);
let header = out
.lines
.iter()
.find(|l| l.spans.iter().any(super::super::is_code_header_span))
.expect("a header line");
assert!(
line_text(header).contains("code"),
"header: {:?}",
line_text(header)
);
}
#[test]
fn multiline_math_opener_maps_backslash_bracket_to_its_own_closer() {
let (closer, _) = multiline_math_opener("\\[\n", &(0..2)).expect("opener recognized");
assert_eq!(closer, "\\]");
}
#[test]
fn render_rule_pushes_the_literal_dashes_line() {
let mut w = fresh_writer();
render_rule(&mut w);
assert_eq!(w.lines, vec![Line::from("---")]);
}
#[test]
fn empty_mermaid_fence_uses_the_text_fallback_without_consulting_the_slot() {
let src = "```mermaid\n```\n";
let doc = Doc::parse(src);
let code = CodeStyle::default();
let math_slot = |_: &str, _: bool| MathSlot::Raw;
let mermaid_slot = |s: &str| -> MermaidSlot {
if s.is_empty() {
MermaidSlot::Image { cols: 1, rows: 1 } } else {
panic!("the empty-fence-body branch must never consult the slot: {s:?}")
}
};
let out = render_doc(
&doc,
src,
80,
code,
"TwoDark",
false,
&[' ', 'x'],
&no_images,
&mermaid_slot,
"Enter: full screen",
true,
&math_slot,
false,
);
assert!(out.images.is_empty(), "no placement for an empty fence");
}
#[test]
fn an_unexpected_inline_start_tag_is_skipped_balanced_not_rendered() {
let mut w = fresh_writer();
let events: Vec<Event<'_>> = vec![
Event::Start(Tag::List(None)),
Event::Text("skip me".into()),
Event::Start(Tag::Emphasis),
Event::Text("nested".into()),
Event::End(TagEnd::Emphasis),
Event::End(TagEnd::List(false)),
Event::Text("after".into()),
];
walk_inline(&mut events.into_iter(), &mut w, None);
assert_eq!(w.lines, vec![Line::from(vec![Span::raw("after")])]);
}
#[test]
fn skip_balanced_on_an_unbalanced_stream_just_runs_out_without_panicking() {
let events: Vec<Event<'_>> = vec![
Event::Start(Tag::Emphasis),
Event::Text("never closes".into()),
];
skip_balanced(&mut events.into_iter(), TagEnd::List(false));
}
#[test]
fn heading_attribute_with_no_value_renders_the_bare_key() {
let out = render("## Heading {#id .cls lang}\n", true, false);
assert!(
lines_text(&out)[0].contains("lang"),
"lines: {:?}",
lines_text(&out)
);
assert!(
!lines_text(&out)[0].contains("lang="),
"no value should be appended"
);
}
#[test]
fn segment_as_block_image_on_an_empty_segment_is_none() {
assert_eq!(segment_as_block_image("", &[]), None);
}
#[test]
fn segment_as_block_image_where_a_link_wraps_nothing_is_none() {
let link_tag = Tag::Link {
link_type: pulldown_cmark::LinkType::Inline,
dest_url: "h".into(),
title: "".into(),
id: "".into(),
};
let events: Vec<(Event<'_>, Range<usize>)> = vec![
(Event::Start(link_tag), 0..1),
(Event::End(TagEnd::Link), 1..2),
];
assert_eq!(segment_as_block_image("xx", &events), None);
}
#[test]
fn standalone_image_with_an_empty_url_is_not_extracted_as_a_block_image() {
let out = render("![a]()\n", true, false);
assert!(out.images.is_empty());
assert_eq!(lines_text(&out), vec!["a"]);
}
#[test]
fn image_next_to_non_comment_html_is_not_folded_into_a_badge_row() {
let out = render(" <![CDATA[a>b]]>\n", true, false);
assert!(
out.images.is_empty(),
"html with real visible content should block badge-row extraction: {:?}",
out.images
);
}
#[test]
fn split_top_level_image_units_on_an_unbalanced_image_is_none() {
let image_tag = Tag::Image {
link_type: pulldown_cmark::LinkType::Inline,
dest_url: "u".into(),
title: "".into(),
id: "".into(),
};
let events: Vec<(Event<'_>, Range<usize>)> = vec![
(Event::Start(image_tag), 0..3),
(Event::Text("a".into()), 1..2),
];
assert_eq!(split_top_level_image_units("src", &events), None);
}
#[test]
fn paragraph_as_block_images_on_an_empty_range_is_none() {
let doc = Doc::parse("");
assert_eq!(paragraph_as_block_images(&doc, "", &(0..0)), None);
}
#[test]
fn heading_trailing_images_on_an_empty_range_is_none() {
let doc = Doc::parse("");
assert_eq!(heading_trailing_images(&doc, "", &(0..0)), None);
}
#[test]
fn heading_badge_followed_by_a_comment_still_peels_as_a_trailing_image() {
let out = render_with_images(
"## Heading  <!-- c -->\n",
true,
false,
5,
2,
);
assert_eq!(out.images.len(), 1, "images: {:?}", out.images);
assert_eq!(out.images[0].url, "u.png");
assert_eq!(
lines_text(&out)[0],
"Heading ",
"the comment and the badge must not survive as literal title text: {:?}",
lines_text(&out)
);
}
#[test]
fn heading_badge_followed_by_non_comment_html_is_not_peeled_as_trailing() {
let out = render_with_images(
"## Heading  <![CDATA[a>b]]>\n",
true,
false,
5,
2,
);
assert!(
out.images.is_empty(),
"the CDATA content disqualifies the badge from the trailing run: {:?}",
out.images
);
}
#[test]
fn heading_trailing_images_html_guard_false_branch_is_reached_directly() {
let src = "<![CDATA[a>b]]>";
assert!(
!super::super::render_html_block(src).is_empty(),
"fixture must actually disqualify — otherwise this test proves nothing"
);
let events: Vec<(Event<'_>, Range<usize>)> =
vec![(Event::InlineHtml(src.into()), 0..src.len())];
let doc = Doc {
events,
blocks: Vec::new(),
};
assert_eq!(heading_trailing_images(&doc, src, &(0..1)), None);
}
#[test]
fn heading_trailing_images_on_an_unbalanced_heading_gives_up_without_panicking() {
let image_tag = Tag::Image {
link_type: pulldown_cmark::LinkType::Inline,
dest_url: "u".into(),
title: "".into(),
id: "".into(),
};
let events: Vec<(Event<'_>, Range<usize>)> = vec![
(Event::Start(image_tag), 0..5),
(Event::Text("ab".into()), 1..3),
];
let doc = Doc {
events,
blocks: Vec::new(),
};
assert_eq!(heading_trailing_images(&doc, "abcde", &(0..2)), None);
}
#[test]
fn image_alt_text_joins_code_spans_and_hard_breaks_as_plain_text() {
let out = render_with_images("## Heading \n", true, false, 5, 2);
assert_eq!(out.images.len(), 1, "images: {:?}", out.images);
assert_eq!(out.images[0].alt, "code alt");
let out2 = render("\n", true, false);
assert!(
out2.images.is_empty(),
"no live slot in this call — Unavailable fallback"
);
assert!(
lines_text(&out2)[0].contains("line1 line2"),
"hard break inside alt text should become one literal space: {:?}",
lines_text(&out2)
);
}
#[test]
fn image_alt_text_drops_nested_style_tags() {
let out = render_with_images("\n", true, false, 5, 2);
assert_eq!(out.images.len(), 1, "images: {:?}", out.images);
assert_eq!(out.images[0].alt, "bold alt");
}
#[test]
fn paragraph_with_math_and_a_trailing_image_lifts_the_math_first() {
let out = render_with_images("$x$ intro\n\n", true, true, 5, 2);
assert_eq!(out.images.len(), 1, "images: {:?}", out.images);
assert_eq!(out.images[0].url, "u.png");
assert!(
lines_text(&out).iter().any(|l| l.contains('$')),
"the math must have been lifted (a dimmed raw placeholder), not left as plain text: {:?}",
lines_text(&out)
);
}
#[test]
fn heading_then_a_leading_image_paragraph_gets_its_own_blank_line_and_plain_text() {
let out = render_with_images("# T\n\n\noutro\n", false, false, 5, 2);
assert_eq!(out.images.len(), 1, "images: {:?}", out.images);
let texts = lines_text(&out);
assert_eq!(texts.last().map(String::as_str), Some("outro"));
let rule_idx = texts.iter().position(|l| l.starts_with('━')).unwrap();
assert_eq!(texts[rule_idx + 1], "", "lines: {texts:?}");
}
#[test]
fn tight_list_bare_paragraph_that_is_only_an_image_returns_early() {
let out = render_with_images("- a\n # h\n \n", true, true, 5, 2);
assert_eq!(out.images.len(), 1, "images: {:?}", out.images);
assert_eq!(out.images[0].url, "u.png");
}
#[test]
fn tight_list_first_child_that_is_only_an_image_becomes_a_real_placement() {
let out = render_with_images("- \n", true, true, 5, 2);
assert_eq!(out.images.len(), 1, "images: {:?}", out.images);
assert_eq!(out.images[0].url, "u.png");
assert_eq!(out.images[0].alt, "alt");
}
#[test]
fn two_tight_list_items_each_only_an_image_both_become_real_placements() {
let out = render_with_images("- \n- \n", true, true, 5, 2);
assert_eq!(out.images.len(), 2, "images: {:?}", out.images);
assert_eq!(out.images[0].url, "x.png");
assert_eq!(out.images[1].url, "y.png");
}
#[test]
fn loose_list_first_child_that_is_only_an_image_becomes_a_real_placement() {
let out = render_with_images("- \n\n- b\n", true, true, 5, 2);
assert_eq!(out.images.len(), 1, "images: {:?}", out.images);
assert_eq!(out.images[0].url, "u.png");
}
#[test]
fn tight_list_bare_paragraph_trailing_image_with_math_on_uses_the_math_walk() {
let out = render_with_images("- a\n # h\n intro\n \n", true, true, 5, 2);
assert_eq!(out.images.len(), 1, "images: {:?}", out.images);
assert!(lines_text(&out).iter().any(|l| l == "intro"));
}
#[test]
fn tight_list_bare_paragraph_leading_image_with_math_on_uses_the_math_walk() {
let out = render_with_images("- a\n # h\n \n outro\n", true, true, 5, 2);
assert_eq!(out.images.len(), 1, "images: {:?}", out.images);
assert!(lines_text(&out).iter().any(|l| l == "outro"));
}
#[test]
fn tight_list_bare_paragraph_trailing_image_with_math_off_uses_the_plain_walk() {
let out = render_with_images(
"- a\n # h\n intro\n \n",
true,
false,
5,
2,
);
assert_eq!(out.images.len(), 1, "images: {:?}", out.images);
assert!(lines_text(&out).iter().any(|l| l == "intro"));
}
#[test]
fn tight_list_bare_paragraph_leading_image_with_math_off_uses_the_plain_walk() {
let out = render_with_images(
"- a\n # h\n \n outro\n",
true,
false,
5,
2,
);
assert_eq!(out.images.len(), 1, "images: {:?}", out.images);
assert!(lines_text(&out).iter().any(|l| l == "outro"));
}
#[test]
fn a_standalone_image_with_no_live_slot_degrades_to_the_text_fallback() {
let out = render("\n", true, false);
assert!(out.images.is_empty());
assert_eq!(lines_text(&out).len(), 1);
assert!(
lines_text(&out)[0].contains("alt") && lines_text(&out)[0].contains("u.png"),
"lines: {:?}",
lines_text(&out)
);
}
#[test]
fn multiline_math_opener_at_the_very_end_of_input_with_no_trailing_newline() {
let src = "abc\n$$";
let range = 4..6; assert_eq!(&src[range.clone()], "$$");
let (closer, body_start) = multiline_math_opener(src, &range).expect("opener recognized");
assert_eq!(closer, "$$");
assert_eq!(
body_start,
src.len(),
"no `+ 1`: there is no byte after this line at all"
);
}
#[test]
fn render_multiline_display_math_ran_out_of_events_flushes_a_pending_text_first() {
let mut w = fresh_writer();
let mut events = std::iter::empty();
let mut prev_end = Some(0usize);
render_multiline_display_math(
&mut w,
&mut events,
Some(pulldown_cmark::CowStr::from("lead ")),
pulldown_cmark::CowStr::from("$$"),
2,
"$$",
"$$\n",
&mut prev_end,
);
assert_eq!(
lines_text(&RenderOut {
lines: w.lines,
images: Vec::new(),
unsupported: Vec::new(),
code_blocks: Vec::new(),
tasks: Vec::new(),
}),
vec!["lead $$"],
"both the flushed pending text and the un-closed opener replay onto one line"
);
}
#[test]
fn render_multiline_display_math_closer_found_flushes_a_pending_text_first() {
let mut w = fresh_writer();
let src = "$$\nbody\n$$\n";
let events: Vec<(Event<'_>, Range<usize>)> = vec![
(Event::Text("body".into()), 3..7),
(Event::Text("$$".into()), 8..10),
];
let mut it = events.into_iter();
let mut prev_end = Some(0usize);
render_multiline_display_math(
&mut w,
&mut it,
Some(pulldown_cmark::CowStr::from("lead ")),
pulldown_cmark::CowStr::from("$$"),
3,
"$$",
src,
&mut prev_end,
);
assert_eq!(
w.lines,
vec![Line::from(vec![Span::raw("lead")])],
"the pending text must flush before the (here, no-op) math slot renders"
);
}
#[test]
fn backslash_math_opener_that_never_closes_replays_everything_it_buffered() {
let out = render("lead \\(a `code` <b> more \nend\n", true, true);
assert!(
out.images.is_empty(),
"nothing here should have been recognized as math: {:?}",
out.images
);
let texts = lines_text(&out);
assert_eq!(texts.len(), 2, "lines: {texts:?}");
assert_eq!(texts[0], "lead (a code more");
assert_eq!(texts[1], "end");
}
#[test]
fn dollar_math_that_resolves_across_a_nested_strong_span_flushes_a_pending_text_first() {
let out = render("lead \\*$a **b** c$ tail\n", true, true);
let texts = lines_text(&out);
assert_eq!(texts.len(), 3, "lines: {texts:?}");
assert_eq!(
texts[0], "lead *",
"the pending text must have flushed onto its own line"
);
assert!(texts[1].contains("$a **b** c$"), "lines: {texts:?}");
assert_eq!(texts[2], "tail");
}
#[test]
fn dollar_math_that_never_closes_gives_up_at_the_next_soft_break() {
let out = render("lead \\*$50 more\nend\n", true, true);
assert!(
out.images.is_empty(),
"nothing should have been lifted: {:?}",
out.images
);
let texts = lines_text(&out);
assert_eq!(texts, vec!["lead *$50 more end"]);
}
#[test]
fn render_math_slot_with_no_math_context_at_all_renders_nothing() {
let mut w = fresh_writer();
render_math_slot(&mut w, "x", false, None);
assert!(w.lines.is_empty());
}
#[test]
fn render_text_with_math_on_an_embedded_newline_starts_a_fresh_line() {
let mut w = fresh_writer();
render_text_with_math(&mut w, "line1\nline2", false);
assert_eq!(
lines_text(&RenderOut {
lines: w.lines,
images: Vec::new(),
unsupported: Vec::new(),
code_blocks: Vec::new(),
tasks: Vec::new(),
}),
vec!["line1", "line2"]
);
}
#[test]
fn ensure_fresh_after_math_with_needs_newline_and_after_math_both_set_pushes_two_blanks() {
let mut w = fresh_writer();
w.lines.push(Line::from("prior"));
w.pending_block_gap = true;
w.after_math = true;
w.pending_para_start = None;
ensure_fresh_after_math(&mut w);
assert_eq!(
w.lines,
vec![Line::from("prior"), Line::default(), Line::default()],
"the inner (pending_block_gap) push and the unconditional second push both fired"
);
assert!(!w.after_math, "the flag must be consumed");
assert!(!w.pending_block_gap);
}
fn render_math(src: &str, width: u16, math_slot: &dyn Fn(&str, bool) -> MathSlot) -> RenderOut {
let doc = Doc::parse(src);
let code = CodeStyle::default();
render_doc(
&doc,
src,
width,
code,
"TwoDark",
false,
&[' ', 'x'],
&no_images,
&no_mermaid,
"Enter: full screen",
true,
math_slot,
true,
)
}
#[test]
fn small_inline_math_stays_on_the_same_line_and_records_its_exact_cell() {
let math_slot = |_: &str, _: bool| MathSlot::Image { cols: 3, rows: 1 };
let out = render_math("before $x$ after\n", 80, &math_slot);
assert_eq!(
out.lines.len(),
1,
"no line lift at all: {:?}",
lines_text(&out)
);
assert_eq!(out.images.len(), 1);
let p = &out.images[0];
assert_eq!(p.line, 0);
assert_eq!(p.cols, 3);
assert_eq!(p.rows, 1);
assert_eq!(p.col, 7, "\"before \" is 7 cells wide");
assert_eq!(
out.lines[0].width(),
7 + 3 + 6,
"\"before \" + the 3 reserved cells + \" after\" — the single space on each side of \
the source's own $x$ must survive, not be trimmed away: {:?}",
out.lines[0]
);
}
#[test]
fn inline_math_at_the_start_and_end_of_a_line() {
let math_slot = |_: &str, _: bool| MathSlot::Image { cols: 4, rows: 1 };
let start = render_math("$x$ leads\n", 80, &math_slot);
assert_eq!(start.images.len(), 1);
assert_eq!(
start.images[0].col, 0,
"math at the very start of the paragraph"
);
assert_eq!(start.images[0].line, 0);
let end = render_math("trails $x$\n", 80, &math_slot);
assert_eq!(end.images.len(), 1);
assert_eq!(end.images[0].col, 7);
assert_eq!(end.images[0].line, 0);
assert_eq!(
end.lines[0].width(),
7 + 4,
"nothing trails the math itself — the line ends right at its own reserved cells"
);
}
#[test]
fn two_inline_math_expressions_on_the_same_line_each_get_their_own_placement() {
let math_slot = |latex: &str, _: bool| match latex {
"a" => MathSlot::Image { cols: 2, rows: 1 },
"b" => MathSlot::Image { cols: 5, rows: 1 },
_ => MathSlot::Raw,
};
let out = render_math("one $a$ two $b$ three\n", 80, &math_slot);
assert_eq!(out.lines.len(), 1, "{:?}", lines_text(&out));
assert_eq!(out.images.len(), 2);
let (pa, pb) = (&out.images[0], &out.images[1]);
assert_eq!((pa.line, pa.col, pa.cols, pa.rows), (0, 4, 2, 1));
assert_eq!((pb.line, pb.col, pb.cols, pb.rows), (0, 11, 5, 1));
}
#[test]
fn inline_math_after_cjk_text_measures_display_width_not_char_count() {
let math_slot = |_: &str, _: bool| MathSlot::Image { cols: 3, rows: 1 };
let out = render_math("日本語 $x$ 文章\n", 80, &math_slot);
assert_eq!(out.images.len(), 1);
assert_eq!(
out.images[0].col, 7,
"3 CJK chars (6 cells) + 1 space = 7 cells"
);
}
#[test]
fn inline_math_that_does_not_fit_the_remaining_width_wraps_to_a_fresh_line() {
let math_slot = |_: &str, _: bool| MathSlot::Image { cols: 3, rows: 1 };
let out = render_math("already here $x$ tail\n", 15, &math_slot);
assert_eq!(out.images.len(), 1);
let p = &out.images[0];
assert_eq!(
p.col, 0,
"wrapped to a fresh line, not squeezed in at column 13"
);
assert_eq!(
p.line, 1,
"the wrap actually produced a second logical line"
);
assert_eq!(
out.lines.len(),
2,
"a plain word-wrap, not a paragraph boundary: exactly one extra line, no blank \
separator row: {:?}",
lines_text(&out)
);
assert!(out.lines[0].width() as u16 <= 15, "{:?}", out.lines[0]);
}
#[test]
fn inline_math_that_exactly_fits_the_remaining_width_does_not_wrap() {
let math_slot = |_: &str, _: bool| MathSlot::Image { cols: 3, rows: 1 };
let out = render_math("already here $x$ tail\n", 16, &math_slot);
assert_eq!(out.lines.len(), 1, "{:?}", lines_text(&out));
assert_eq!(out.images.len(), 1);
assert_eq!(out.images[0].line, 0);
assert_eq!(out.images[0].col, 13, "\"already here \" is 13 cells wide");
}
#[test]
fn a_multi_row_inline_math_slot_still_lifts_onto_its_own_line() {
let math_slot = |_: &str, _: bool| MathSlot::Image { cols: 6, rows: 2 };
let out = render_math("before $x$ after\n", 80, &math_slot);
assert_eq!(out.lines.len(), 1 + 2 + 1, "{:?}", lines_text(&out));
assert_eq!(out.images.len(), 1);
assert_eq!(out.images[0].cols, 6);
assert_eq!(out.images[0].rows, 2);
assert_eq!(
out.images[0].col, 0,
"inline math's own lift is left-aligned, not centered"
);
}
#[test]
fn dollar_math_across_a_nested_strong_span_preserves_surrounding_space_when_placed_inline() {
let math_slot = |_: &str, _: bool| MathSlot::Image { cols: 5, rows: 1 };
let out = render_math("lead $a **b** c$ tail\n", 80, &math_slot);
assert_eq!(out.lines.len(), 1, "{:?}", lines_text(&out));
assert_eq!(out.images.len(), 1);
let p = &out.images[0];
assert_eq!(p.rows, 1);
assert_eq!(
p.col, 5,
"\"lead \" is 5 cells wide — its own trailing space must survive"
);
assert_eq!(
out.lines[0].width(),
5 + 5 + 5,
"\"lead \" + the 5 reserved cells + \" tail\" (5 cells): {:?}",
out.lines[0]
);
}
#[test]
fn dollar_math_across_a_nested_strong_span_that_lifts_still_trims_the_space_before_it() {
let math_slot = |_: &str, _: bool| MathSlot::Image { cols: 6, rows: 2 };
let out = render_math("lead $a **b** c$ tail\n", 80, &math_slot);
assert_eq!(
out.lines.len(),
1 + 2 + 1,
"\"lead\"/\"tail\" land on their own lines around the 2-row lift: {:?}",
lines_text(&out)
);
assert_eq!(out.images.len(), 1);
assert_eq!(out.images[0].rows, 2);
}
#[test]
fn dollar_math_tail_with_a_genuinely_separate_pending_preserves_its_own_trailing_space_when_inline(
) {
let mut w = fresh_writer();
let math_slot = |_: &str, _: bool| MathSlot::Image { cols: 5, rows: 1 };
w.math = Some(MathCtx {
slot: &math_slot,
width: 80,
});
let src = "lead &$a **b** c$ tail\n";
let events: Vec<(Event<'_>, Range<usize>)> = vec![
(Event::Start(Tag::Strong), 13..18),
(Event::Text("b".into()), 15..16),
(Event::End(TagEnd::Strong), 13..18),
(Event::Text(" c$ tail".into()), 18..26),
];
let mut it = events.into_iter();
let mut prev_end = Some(10usize);
render_dollar_math_tail(
&mut w,
&mut it,
Some(pulldown_cmark::CowStr::from("synthetic ")),
pulldown_cmark::CowStr::from("$a "),
10..13,
src,
&mut prev_end,
);
assert_eq!(
lines_text(&RenderOut {
lines: w.lines.clone(),
images: Vec::new(),
unsupported: Vec::new(),
code_blocks: Vec::new(),
tasks: Vec::new(),
}),
vec!["synthetic tail"],
"\"synthetic \" (10 cells) keeps its own trailing space — the math is inline, not a \
lift: {:?}",
w.lines
);
assert_eq!(w.images.len(), 1);
assert_eq!(w.images[0].rows, 1);
assert_eq!(
w.images[0].col, 10,
"\"synthetic \" is 10 cells wide — its own trailing space must survive: {:?}",
w.images[0]
);
}
#[test]
fn dollar_math_tail_with_a_genuinely_separate_pending_that_lifts_still_trims_its_own_trailing_space(
) {
let mut w = fresh_writer();
let math_slot = |_: &str, _: bool| MathSlot::Image { cols: 6, rows: 2 };
w.math = Some(MathCtx {
slot: &math_slot,
width: 80,
});
let src = "lead &$a **b** c$ tail\n";
let events: Vec<(Event<'_>, Range<usize>)> = vec![
(Event::Start(Tag::Strong), 13..18),
(Event::Text("b".into()), 15..16),
(Event::End(TagEnd::Strong), 13..18),
(Event::Text(" c$ tail".into()), 18..26),
];
let mut it = events.into_iter();
let mut prev_end = Some(10usize);
render_dollar_math_tail(
&mut w,
&mut it,
Some(pulldown_cmark::CowStr::from("synthetic ")),
pulldown_cmark::CowStr::from("$a "),
10..13,
src,
&mut prev_end,
);
assert_eq!(
lines_text(&RenderOut {
lines: w.lines.clone(),
images: Vec::new(),
unsupported: Vec::new(),
code_blocks: Vec::new(),
tasks: Vec::new(),
}),
vec!["synthetic", "", "", "tail"],
"\"synthetic\" has lost its own trailing space (a lift): {:?}",
w.lines
);
assert_eq!(w.images.len(), 1);
assert_eq!(w.images[0].rows, 2);
}
#[test]
fn display_math_with_rows_one_still_lifts_and_centers_never_treated_as_inline() {
let math_slot = |_: &str, display: bool| {
assert!(display, "this document only ever asks for display math");
MathSlot::Image { cols: 4, rows: 1 }
};
let out = render_math("$$x$$\n", 80, &math_slot);
assert_eq!(
out.lines.len(),
1,
"a single reserved placeholder line, not text folded back into the running line: {:?}",
lines_text(&out)
);
assert_eq!(out.images.len(), 1);
let p = &out.images[0];
assert_eq!(p.rows, 1);
assert_eq!(p.cols, 4);
assert_eq!(
p.col,
(80 - 4) / 2,
"display math is centered, not left-aligned at column 0"
);
}
#[test]
fn inline_math_slot_is_never_reached_inside_a_blockquote() {
let math_slot = |_: &str, _: bool| MathSlot::Image { cols: 3, rows: 1 };
let out = render_math("> before $x$ after\n", 80, &math_slot);
assert!(
out.images.is_empty(),
"math inside a quote is never extracted at all"
);
assert!(
lines_text(&out).iter().any(|l| l.contains("$x$")),
"the literal source text stays untouched: {:?}",
lines_text(&out)
);
}
#[test]
fn backslash_math_preserves_surrounding_space_exactly_like_dollar_math_when_placed_inline() {
let math_slot = |_: &str, _: bool| MathSlot::Image { cols: 5, rows: 1 };
let dollar = render_math("前のテキスト $E = mc^2$ 後ろのテキスト\n", 80, &math_slot);
let backslash = render_math(
"前のテキスト \\(E = mc^2\\) 後ろのテキスト\n",
80,
&math_slot,
);
assert_eq!(dollar.lines.len(), 1, "{:?}", lines_text(&dollar));
assert_eq!(
backslash.lines.len(),
1,
"\\(…\\) must stay on one line exactly like $…$: {:?}",
lines_text(&backslash)
);
assert_eq!(dollar.images.len(), 1);
assert_eq!(backslash.images.len(), 1);
assert_eq!(
backslash.images[0].col, 13,
"\\(…\\): {:?}",
backslash.images[0]
);
assert_eq!(
(
dollar.images[0].col,
dollar.images[0].cols,
dollar.lines[0].width()
),
(
backslash.images[0].col,
backslash.images[0].cols,
backslash.lines[0].width()
),
"$…$ (left) and \\(…\\) (right) must render identically:\n $…$: {:?}\n \\(…\\): {:?}",
dollar.lines[0],
backslash.lines[0]
);
}
#[test]
fn backslash_math_that_still_lifts_keeps_trimming_the_space_before_it() {
let math_slot = |_: &str, _: bool| MathSlot::Image { cols: 6, rows: 2 };
let out = render_math(
"前のテキスト \\(E = mc^2\\) 後ろのテキスト\n",
80,
&math_slot,
);
assert_eq!(
out.lines.len(),
1 + 2 + 1,
"\"前のテキスト\"/\"後ろのテキスト\" land on their own lines around the 2-row lift: {:?}",
lines_text(&out)
);
assert_eq!(out.images.len(), 1);
assert_eq!(out.images[0].rows, 2);
}
#[test]
fn backslash_math_right_after_dollar_math_in_the_same_event_keeps_the_old_trim_behavior() {
let math_slot = |_: &str, _: bool| MathSlot::Image { cols: 5, rows: 1 };
let out = render_math("lead $x$ mid \\(y\\) tail\n", 80, &math_slot);
assert_eq!(out.images.len(), 2, "{:?}", out.images);
}
#[test]
fn alert_headed_quote_nested_inside_a_list_item_with_alerts_off_renders_as_a_plain_quote() {
let out = render("- item\n\n > [!NOTE]\n > text\n", false, false);
assert!(out.unsupported.is_empty());
let joined = lines_text(&out).join("\n");
assert!(joined.contains("[!NOTE]"), "lines: {:?}", lines_text(&out));
assert!(
!joined.contains('▌'),
"no alert bar — this must be a plain quote: {joined:?}"
);
}
#[test]
fn render_block_on_a_bare_list_item_that_could_never_really_happen_renders_nothing() {
let doc = Doc::parse("");
let mut w = fresh_writer();
let block = Block {
kind: BlockKind::ListItem { task: None },
src: 0..0,
children: Vec::new(),
};
render_block(&block, &mut w, &doc, "", fresh_ctx());
assert!(w.lines.is_empty());
}
#[test]
fn alert_header_with_no_trailing_newline_at_all_does_not_panic() {
let out = render("> [!NOTE]", true, false);
assert!(out.unsupported.is_empty());
assert!(
lines_text(&out)[0].contains("Note"),
"lines: {:?}",
lines_text(&out)
);
}
#[test]
fn trim_leading_header_keeps_a_straddling_non_paragraph_block_unmodified() {
let doc = Doc::parse("");
let children = vec![Block {
kind: BlockKind::ThematicBreak,
src: 0..10,
children: Vec::new(),
}];
let out = trim_leading_header(&children, &doc, 5);
assert_eq!(out.len(), 1);
assert!(matches!(out[0].kind, BlockKind::ThematicBreak));
assert_eq!(
out[0].src,
0..10,
"a non-Paragraph straddler is kept byte-for-byte unchanged"
);
}
#[test]
fn trim_leading_header_on_no_children_at_all_is_empty() {
let doc = Doc::parse("");
assert_eq!(trim_leading_header(&[], &doc, 0), Vec::<Block>::new());
}
#[test]
fn render_doc_never_panics_on_adversarial_input() {
let cases: Vec<String> = vec![
"> ".repeat(10) + "deep quote\n",
"- ".repeat(10) + "deep list\n",
{
let mut s = String::new();
for _ in 0..10 {
s.push_str("- a\n");
}
s
},
"```rust\nfn a() {}\n".to_string(),
"> unclosed quote with no closing anything".to_string(),
"**unclosed bold".to_string(),
"*unclosed italic".to_string(),
"~~unclosed strike".to_string(),
"[unclosed link(".to_string(),
"![unclosed image(".to_string(),
"> [!NOTE] unclosed alert body".to_string(),
"<details>\n<summary>unclosed".to_string(),
"<details>\n<summary>s</summary>\nbody with no close at all\n".to_string(),
"$$unclosed display math".to_string(),
"\\(unclosed backslash math".to_string(),
"$unclosed dollar".to_string(),
"x".repeat(300_000),
"\n".repeat(5_000),
"# 見出し ✅🇯🇵\u{200B}\u{0301}\n".to_string(),
"これは*強調*と**太字**と`コード`です。\n".to_string(),
"- 項目1\n- 項目2\u{200D}\u{FE0F}\n".to_string(),
"> 引用文\u{0301}です\n".to_string(),
"\n".to_string(),
"```rust\nlet 変数 = \"文字列\u{200B}\";\n```\n".to_string(),
"line one\r\nline two\r\n\r\n# heading\r\n".to_string(),
"\u{FEFF}# heading with a BOM\n".to_string(),
"text with a \u{0007} bell and a \u{001B} escape\n".to_string(),
"\tindented with a real tab\nnot indented\n".to_string(),
"```\n\tfenced code with a tab\n```\n".to_string(),
"\\*あ \\_い \\[う \\]え \\(お \\)か \\$き \\\\く\n".to_string(),
"\\*🎉 \\_🎉 \\[🎉 \\]🎉 \\(🎉 \\)🎉 \\$🎉 \\\\🎉\n".to_string(),
"\\あ \\🎉 \\\u{200B}\n".to_string(),
"It costs $50 **bold** text.\n".to_string(),
"- It costs $50 **bold** text.\n".to_string(),
"> It costs $50 **bold** text.\n".to_string(),
"See \\([#519](https://example.com/pull/519))\n".to_string(),
"| a | b |\n|---|---|\n| | 日本語 |\n```\ncode\n```\n".to_string(),
"- [ ] first\n".to_string(),
"- [x] first\n".to_string(),
"- [/] custom\n".to_string(),
"# H *em* **st** ~~s~~ `c` [l](u)  ^sup^ ~sub~ \\* end\n".to_string(),
];
let code_wrap_true = CodeStyle {
wrap: true,
..CodeStyle::default()
};
let code_wrap_false = CodeStyle {
wrap: false,
..CodeStyle::default()
};
let math_slot = |_: &str, _: bool| MathSlot::Raw;
let slot_of = |_: &str, _: Option<u16>| ImageSlot::Inline { cols: 4, rows: 2 };
for src in &cases {
let doc = Doc::parse(src);
for width in [1u16, 2, 3, 10, 80, 4000] {
for code in [code_wrap_true, code_wrap_false] {
let _ = render_doc(
&doc,
src,
width,
code,
"TwoDark",
true,
&[' ', 'x', '/'],
&slot_of,
&no_mermaid,
"Enter: full screen",
true,
&math_slot,
true,
);
}
}
}
}
#[test]
fn render_doc_never_panics_across_extreme_tab_widths() {
let src = "```rust\n\tfn a() {\n\t\tb();\n\t}\n```\n";
let doc = Doc::parse(src);
let math_slot = |_: &str, _: bool| MathSlot::Raw;
for tab_width in [0usize, 1, 100] {
let code = CodeStyle {
tab_width,
..CodeStyle::default()
};
let _ = render_doc(
&doc,
src,
80,
code,
"TwoDark",
false,
&[' ', 'x'],
&no_images,
&no_mermaid,
"Enter: full screen",
true,
&math_slot,
false,
);
}
}
#[test]
fn render_doc_never_panics_on_deeply_nested_inline_emphasis() {
let mut src = String::new();
for i in 0..10 {
src.push_str(if i % 2 == 0 { "*" } else { "_" });
}
src.push_str("center");
for i in (0..10).rev() {
src.push_str(if i % 2 == 0 { "*" } else { "_" });
}
src.push('\n');
let out = render(&src, true, true);
assert!(!out.lines.is_empty());
}
const FRONT_MATTER_SRC: &str = concat!(
"---\n",
"title: My Doc\n",
"author: Me\n",
"---\n",
"body text\n",
"\n",
"# Heading\n",
"\n",
"more text\n",
);
#[test]
fn render_doc_front_matter_is_no_longer_unsupported() {
let doc = Doc::parse(FRONT_MATTER_SRC);
let math_slot = |_: &str, _: bool| MathSlot::Raw;
let out = render_doc(
&doc,
FRONT_MATTER_SRC,
80,
CodeStyle::default(),
"TwoDark",
false,
&[' ', 'x'],
&no_images,
&no_mermaid,
"Enter: full screen",
true,
&math_slot,
true,
);
assert!(
out.unsupported.is_empty(),
"a leading metadata block should render directly, not fall back: {:?}",
out.unsupported
);
assert!(
out.lines.iter().any(|l| l
.spans
.iter()
.any(|s| s.content.as_ref() == "title: My Doc")),
"front matter content missing from render_doc's own output: {:?}",
out.lines
);
}
}
#[cfg(test)]
mod cell_image_tests {
use super::*;
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
const NAT_COLS: u16 = 40;
fn slot(url: &str, max_cols: Option<u16>) -> ImageSlot {
if url.starts_with("https://") {
return ImageSlot::Loading;
}
if url.contains("missing") {
return ImageSlot::Unavailable;
}
let cols = max_cols.unwrap_or(NAT_COLS).clamp(1, NAT_COLS);
ImageSlot::Inline {
cols,
rows: (cols as u32).div_ceil(5) as u16,
}
}
fn render(src: &str, width: u16) -> RenderOut {
render_with(src, width, &slot)
}
fn render_with(
src: &str,
width: u16,
slot_of: &dyn Fn(&str, Option<u16>) -> ImageSlot,
) -> RenderOut {
let math_slot = |_: &str, _: bool| MathSlot::Raw;
let doc = Doc::parse(src);
let out = render_doc(
&doc,
src,
width,
CodeStyle::default(),
"TwoDark",
false,
&[' ', 'x'],
slot_of,
&|_: &str| MermaidSlot::Text,
"Enter: full screen",
true,
&math_slot,
false,
);
assert!(
out.unsupported.is_empty(),
"unsupported: {:?}",
out.unsupported
);
out
}
fn sized_slot(url: &str, max_cols: Option<u16>) -> ImageSlot {
if url.contains("missing") {
return ImageSlot::Unavailable;
}
let nat: u16 = url
.strip_prefix('w')
.and_then(|s| s.split('.').next())
.and_then(|s| s.parse().ok())
.unwrap_or(NAT_COLS)
.max(1);
let cols = max_cols.unwrap_or(nat).clamp(1, nat);
ImageSlot::Inline {
cols,
rows: (cols as u32).div_ceil(5) as u16,
}
}
fn texts(out: &RenderOut) -> Vec<String> {
out.lines
.iter()
.map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
.collect()
}
fn slice_cols(s: &str, from: usize, len: usize) -> String {
let mut out = String::new();
let mut col = 0usize;
for ch in s.chars() {
let w = UnicodeWidthChar::width(ch).unwrap_or(0);
if col >= from && col + w <= from + len {
out.push(ch);
}
col += w;
}
out
}
fn assert_reserved(out: &RenderOut) {
let lines = texts(out);
assert!(!out.images.is_empty(), "配置が 1 件も無い: {lines:?}");
for p in &out.images {
let (col, cols, rows) = (p.col as usize, p.cols as usize, p.rows as usize);
assert!(
p.line + rows <= lines.len(),
"予約行が行数を超えている {p:?}: {lines:?}"
);
let top = &lines[p.line];
assert!(
slice_cols(top, col, cols).starts_with('🖼'),
"予約矩形の左上に 🖼 ラベルが無い {p:?}: |{top}|"
);
for r in 0..rows {
let line = &lines[p.line + r];
assert!(
UnicodeWidthStr::width(line.as_str()) >= col + cols,
"行が予約矩形より狭い {p:?}: |{line}|"
);
assert!(
line.starts_with('│') && line.ends_with('│'),
"予約行の罫線が壊れている {p:?}: |{line}|"
);
if r > 0 {
assert_eq!(
slice_cols(line, col, cols),
" ".repeat(cols),
"予約矩形の 2 行目以降は空白でなければならない {p:?}: |{line}|"
);
}
}
}
}
fn assert_rectangular(out: &RenderOut) {
let lines: Vec<String> = texts(out)
.into_iter()
.filter(|l| l.starts_with('│') || l.starts_with('┌') || l.starts_with('└'))
.collect();
let widths: Vec<usize> = lines
.iter()
.map(|l| UnicodeWidthStr::width(l.as_str()))
.collect();
assert!(
widths.windows(2).all(|w| w[0] == w[1]),
"表の行幅が揃っていない {widths:?}: {lines:?}"
);
}
#[test]
fn a_gfm_cell_image_reserves_a_rectangle_and_records_a_placement() {
let src = "| name | image |\n|---|---|\n| png |  |\n";
let out = render(src, 70);
assert_eq!(out.images.len(), 1, "{:?}", out.images);
let p = &out.images[0];
assert_eq!(p.url, "a.png", "セル画像の URL がそのまま渡る");
assert_eq!(p.alt, "alt text");
assert_eq!(p.fence_ord, None, "セル画像は mermaid フェンスではない");
assert_eq!((p.cols, p.rows), (NAT_COLS, 8), "自然サイズのまま入る");
assert_reserved(&out);
assert_rectangular(&out);
}
#[test]
fn an_html_cell_image_reserves_a_rectangle_and_records_a_placement() {
let src = "<table>\n <tr><td><img src=\"a.png\" alt=\"A\"></td></tr>\n</table>\n";
let out = render(src, 70);
assert_eq!(out.images.len(), 1, "{:?}", out.images);
assert_eq!(out.images[0].url, "a.png");
assert_eq!(out.images[0].alt, "A");
assert_reserved(&out);
assert_rectangular(&out);
}
#[test]
fn two_image_cells_in_one_row_sit_side_by_side_on_the_same_rows() {
let src = "<table>\n <tr>\n <td><img src=\"tree.png\" alt=\"Tree\"></td>\n <td><img src=\"graph.png\" alt=\"Graph\"></td>\n </tr>\n <tr><td align=\"center\">Tree view</td><td align=\"center\">Git graph</td></tr>\n</table>\n";
let out = render(src, 90);
assert_eq!(out.images.len(), 2, "{:?}", out.images);
let (a, b) = (&out.images[0], &out.images[1]);
assert_eq!(a.line, b.line, "同じ行に並ぶ: {:?}", out.images);
assert!(
a.col + a.cols <= b.col,
"2 枚の矩形が重なっている: {:?}",
out.images
);
assert_reserved(&out);
assert_rectangular(&out);
let lines = texts(&out);
let caption = lines
.iter()
.find(|l| l.contains("Tree view"))
.unwrap_or_else(|| panic!("キャプション行が無い: {lines:?}"));
assert!(
caption.contains("Git graph"),
"キャプション行が 1 行に揃っていない: |{caption}|"
);
}
#[test]
fn an_image_cell_and_a_text_cell_share_the_rows_the_taller_one_needs() {
let src = "| a | b |\n|---|---|\n|  | just text |\n";
let out = render(src, 70);
assert_eq!(out.images.len(), 1, "{:?}", out.images);
let p = &out.images[0];
let lines = texts(&out);
assert!(
lines[p.line].contains("just text"),
"テキストセルが行の先頭に無い: |{}|",
lines[p.line]
);
assert_reserved(&out);
assert_rectangular(&out);
}
#[test]
fn an_image_and_text_in_one_cell_stack_vertically_image_first() {
let src =
"<table>\n <tr><td><img src=\"a.png\" alt=\"A\"> caption here</td></tr>\n</table>\n";
let out = render(src, 70);
assert_eq!(out.images.len(), 1, "{:?}", out.images);
let p = &out.images[0];
let lines = texts(&out);
let caption_row = lines
.iter()
.position(|l| l.contains("caption here"))
.unwrap_or_else(|| panic!("キャプションが描かれていない: {lines:?}"));
assert!(
caption_row >= p.line + p.rows as usize,
"キャプションが予約矩形の中に入り込んでいる (行 {caption_row}, 矩形 {p:?})"
);
assert_reserved(&out);
assert_rectangular(&out);
}
#[test]
fn a_shaved_column_places_the_image_at_the_final_width_not_the_natural_one() {
let src = "| a | b |\n|---|---|\n|  | a fairly long stretch of text |\n";
let out = render(src, 40);
assert_eq!(out.images.len(), 1, "{:?}", out.images);
let p = &out.images[0];
assert!(p.cols < NAT_COLS, "狭い幅なのに自然幅のまま: {p:?}");
assert_eq!(
p.rows,
(p.cols as u32).div_ceil(5) as u16,
"再フィットで縦横比が保たれていない: {p:?}"
);
assert_reserved(&out);
assert_rectangular(&out);
}
#[test]
fn an_image_is_re_fit_only_when_it_is_wider_than_its_column() {
let cases = [
(
"the column is sized by the image itself, so it fits exactly",
"| i |\n|---|\n|  |\n",
70u16,
false,
),
(
"a header wider than the image leaves the column with room to spare",
"| a header much wider than the picture |\n|---|\n|  |\n",
70,
false,
),
(
"the pane squeezes the column below the image's natural width",
"| i |\n|---|\n|  |\n",
20,
true,
),
];
for (why, src, width, must_refit) in cases {
let asked = std::cell::RefCell::new(Vec::<Option<u16>>::new());
let recording = |url: &str, max_cols: Option<u16>| {
asked.borrow_mut().push(max_cols);
sized_slot(url, max_cols)
};
let out = render_with(src, width, &recording);
let refits: Vec<u16> = asked.borrow().iter().flatten().copied().collect();
assert_eq!(
!refits.is_empty(),
must_refit,
"{why}: 再フィットの問い合わせ {refits:?} が想定と違う"
);
assert_eq!(out.images.len(), 1, "{why}: {:?}", out.images);
assert_reserved(&out);
assert_rectangular(&out);
let once = |url: &str, max_cols: Option<u16>| match max_cols {
None => sized_slot(url, None),
Some(_) => ImageSlot::Unavailable,
};
let out = render_with(src, width, &once);
assert_eq!(
out.images.len(),
usize::from(!must_refit),
"{why}: 2 回目に答えない lookup での配置数が想定と違う: {:?}",
out.images
);
}
}
#[test]
fn a_cell_image_never_pushes_the_table_past_the_pane_width() {
for width in [20u16, 30, 45, 70, 120] {
let src = "| a | b |\n|---|---|\n|  |  |\n";
let out = render(src, width);
for line in texts(&out) {
assert!(
UnicodeWidthStr::width(line.as_str()) <= width as usize,
"幅 {width} を超えた行がある: |{line}|"
);
}
assert_reserved(&out);
assert_rectangular(&out);
}
}
#[test]
fn a_centered_cell_image_is_reported_at_the_column_it_was_centered_to() {
let src = "<table>\n <tr><td align=\"center\"><img src=\"a.png\" alt=\"A\"></td></tr>\n <tr><td align=\"center\">a caption that runs quite a lot wider than the image</td></tr>\n</table>\n";
let out = render(src, 80);
assert_eq!(out.images.len(), 1, "{:?}", out.images);
let p = &out.images[0];
assert!(
p.col > 2,
"中央寄せの余白が桁に反映されていない (左端は 2): {p:?}"
);
assert_reserved(&out);
assert_rectangular(&out);
}
#[test]
fn a_center_aligned_gfm_column_centers_its_cell_image_too() {
let src = "| shot |\n|:---:|\n|  |\n| a caption that runs quite a lot wider than the image |\n";
let out = render(src, 80);
assert_eq!(out.images.len(), 1, "{:?}", out.images);
assert!(out.images[0].col > 2, "{:?}", out.images);
assert_reserved(&out);
assert_rectangular(&out);
}
#[test]
fn a_missing_image_keeps_its_label_and_records_no_placement() {
let src = "| a |\n|---|\n|  |\n";
let out = render(src, 70);
assert!(
out.images.is_empty(),
"描けない画像に矩形を予約している: {:?}",
out.images
);
let lines = texts(&out);
assert!(
lines.iter().any(|l| l.contains("🖼 gone")),
"ラベルへの退行が消えている: {lines:?}"
);
}
#[test]
fn a_remote_image_still_fetching_keeps_its_label_and_records_no_placement() {
let src = "| a |\n|---|\n|  |\n";
let out = render(src, 70);
assert!(out.images.is_empty(), "{:?}", out.images);
let lines = texts(&out);
assert!(
lines.iter().any(|l| l.contains("🖼 badge")),
"取得前のラベルが出ていない: {lines:?}"
);
}
#[test]
fn a_table_with_nothing_drawable_renders_exactly_as_it_did_before() {
let src = "| a | b |\n|---|---|\n|  | text |\n";
let with_slot = texts(&render(src, 70));
let math_slot = |_: &str, _: bool| MathSlot::Raw;
let doc = Doc::parse(src);
let plain = render_doc(
&doc,
src,
70,
CodeStyle::default(),
"TwoDark",
false,
&[' ', 'x'],
&|_: &str, _: Option<u16>| ImageSlot::Unavailable,
&|_: &str| MermaidSlot::Text,
"Enter: full screen",
true,
&math_slot,
false,
);
assert_eq!(
with_slot,
texts(&plain),
"描けない画像しか無い表の描画が変わっている"
);
}
#[test]
fn a_link_wrapped_badge_stays_a_link_and_reserves_nothing() {
let src = "| ci |\n|---|\n| [](https://ci.example.com) |\n";
let out = render(src, 70);
assert!(
out.images.is_empty(),
"バッジのリンクを画像として予約している: {:?}",
out.images
);
let lines = texts(&out);
assert!(
lines.iter().any(|l| l.contains("🖼 build")),
"バッジのラベルが消えている: {lines:?}"
);
}
#[test]
fn an_svg_cell_image_is_drawn_like_any_other() {
let src = "| logo |\n|---|\n|  |\n";
let out = render(src, 70);
assert_eq!(out.images.len(), 1, "{:?}", out.images);
assert_eq!(out.images[0].url, "logo.svg");
assert_reserved(&out);
}
#[test]
fn the_same_image_in_two_cells_gets_one_placement_each() {
let src = "| a | b |\n|---|---|\n|  |  |\n";
let out = render(src, 90);
assert_eq!(out.images.len(), 2, "{:?}", out.images);
assert_eq!(out.images[0].url, out.images[1].url);
assert_ne!(
out.images[0].col, out.images[1].col,
"同じ画像の 2 枚が同じ桁に重なっている: {:?}",
out.images
);
assert_eq!(
(out.images[0].cols, out.images[0].rows),
(out.images[1].cols, out.images[1].rows),
"等幅の列に入った同じ画像のサイズが食い違っている: {:?}",
out.images
);
assert_reserved(&out);
}
#[test]
fn a_header_cell_image_is_drawn_too() {
let src = "|  | b |\n|---|---|\n| x | y |\n";
let out = render(src, 70);
assert_eq!(out.images.len(), 1, "{:?}", out.images);
assert_reserved(&out);
assert_rectangular(&out);
let lines = texts(&out);
assert!(
lines.iter().any(|l| l.starts_with('├')),
"ヘッダ罫線が消えている: {lines:?}"
);
}
#[test]
fn a_one_by_one_image_only_table_is_all_rectangle() {
let src = "<table><tr><td><img src=\"only.png\" alt=\"Only\"></td></tr></table>\n";
let out = render(src, 70);
assert_eq!(out.images.len(), 1, "{:?}", out.images);
assert_eq!(out.images[0].rows, 8, "{:?}", out.images);
assert_reserved(&out);
assert_rectangular(&out);
}
#[test]
fn a_table_inside_details_places_its_cell_image_where_it_was_drawn() {
crate::preview::markdown::set_details_open(vec![true]);
let src = "intro\n\n<details open>\n<summary>S</summary>\n\n| a |\n|---|\n|  |\n\n</details>\n";
let out = render(src, 70);
crate::preview::markdown::set_details_open(Vec::new());
assert_eq!(out.images.len(), 1, "{:?}", out.images);
let p = &out.images[0];
assert!(p.line > 1, "文書先頭に貼り付いている: {p:?}");
let lines = texts(&out);
let top = &lines[p.line];
assert!(
slice_cols(top, p.col as usize, p.cols as usize).starts_with('🖼'),
"入れ子の桁補正が効いていない {p:?}: |{top}|"
);
for r in 1..p.rows as usize {
let line = &lines[p.line + r];
assert_eq!(
slice_cols(line, p.col as usize, p.cols as usize),
" ".repeat(p.cols as usize),
"入れ子の予約矩形が空白でない {p:?}: |{line}|"
);
}
}
#[test]
fn a_table_inside_a_quote_places_its_cell_image_where_it_was_drawn() {
let src = "intro\n\n> | a |\n> |---|\n> |  |\n";
let out = render(src, 70);
assert_eq!(out.images.len(), 1, "{:?}", out.images);
let p = &out.images[0];
assert!(p.line > 1, "文書先頭に貼り付いている: {p:?}");
let lines = texts(&out);
let top = &lines[p.line];
assert!(top.starts_with('>'), "引用の中の表になっていない: |{top}|");
assert!(
slice_cols(top, p.col as usize, p.cols as usize).starts_with('🖼'),
"引用の桁補正が効いていない {p:?}: |{top}|"
);
for r in 1..p.rows as usize {
let line = &lines[p.line + r];
assert_eq!(
slice_cols(line, p.col as usize, p.cols as usize),
" ".repeat(p.cols as usize),
"引用の予約矩形が空白でない {p:?}: |{line}|"
);
}
}
#[test]
fn a_cell_image_after_headings_accounts_for_the_rules_decoration_inserts() {
let src = "# One\n\n## Two\n\n| a |\n|---|\n|  |\n";
let out = render(src, 70);
assert_eq!(out.images.len(), 1, "{:?}", out.images);
assert_reserved(&out);
}
#[test]
fn a_reserved_rectangles_label_is_cut_to_the_rectangle_not_to_the_alt_text() {
for (why, src, width) in [
(
"long alt, and a neighbour long enough to shave the column",
"|  | some fairly long prose that eats the width |\n|---|---|\n| x | y |\n",
60u16,
),
(
"the same, in a much narrower pane",
"|  | some fairly long prose that eats the width |\n|---|---|\n| x | y |\n",
40,
),
(
"a CJK alt, whose characters are two columns each",
"|  | some fairly long prose that eats the width |\n|---|---|\n| x | y |\n",
50,
),
(
"an HTML cell, which reaches the identical rectangle through the other parser",
"<table>\n<tr><td><img src=\"a.png\" alt=\"a very long alternative text indeed\"></td><td>some fairly long prose that eats the width</td></tr>\n</table>\n",
60,
),
(
"two long-alt image cells at once, so both labels have to be cut",
"|  |  |\n|---|---|\n| x | y |\n",
50,
),
] {
let out = render(src, width);
assert!(!out.images.is_empty(), "{why}: 画像が配置されていない");
for p in &out.images {
let label_w =
UnicodeWidthStr::width(super::super::cell_image_label(&p.alt).as_str());
assert!(
label_w > p.cols as usize,
"{why}: ラベル({label_w}桁)が矩形({}桁)より広くない=この形では切り詰めを検査できない",
p.cols
);
}
for line in texts(&out) {
assert!(
UnicodeWidthStr::width(line.as_str()) <= width as usize,
"{why}: 幅 {width} を超えた行がある: |{line}|"
);
}
assert_reserved(&out);
assert_rectangular(&out);
}
}
#[test]
fn text_and_images_in_one_cell_keep_their_source_order() {
for (why, src, needle, above, below) in [
(
"text then image",
"| Shot:  |\n|---|\n| y |\n",
"Shot:",
0usize,
1usize,
),
(
"image then text",
"|  caption |\n|---|\n| y |\n",
"caption",
1,
0,
),
(
"text, image, text",
"| lead  tail |\n|---|\n| y |\n",
"lead",
0,
1,
),
(
"image, text, image",
"|  middle  |\n|---|\n| y |\n",
"middle",
1,
1,
),
(
"the HTML spelling of text-then-image",
"<table>\n<tr><td>Shot: <img src=\"a.png\" alt=\"x\"></td></tr>\n</table>\n",
"Shot:",
0,
1,
),
] {
let out = render(src, 70);
let lines = texts(&out);
let row = lines
.iter()
.position(|l| l.contains(needle))
.unwrap_or_else(|| panic!("{why}: 「{needle}」が描かれていない: {lines:?}"));
let n_above = out
.images
.iter()
.filter(|p| p.line + p.rows as usize <= row)
.count();
let n_below = out.images.iter().filter(|p| p.line > row).count();
assert_eq!(
(n_above, n_below),
(above, below),
"{why}: 「{needle}」(行 {row}) の上下にある矩形の数が違う: {:?}\n{lines:?}",
out.images
);
}
}
#[test]
fn a_wrapped_text_run_before_an_image_keeps_all_of_its_rows_above_it() {
let src =
"| one two three four five six seven eight nine ten  |\n|---|\n| y |\n";
let out = render(src, 40);
assert_eq!(out.images.len(), 1, "{:?}", out.images);
let p = &out.images[0];
let lines = texts(&out);
let rows: Vec<usize> = lines
.iter()
.enumerate()
.filter(|(_, l)| l.contains("one") || l.contains("ten"))
.map(|(i, _)| i)
.collect();
assert!(rows.len() >= 2, "折り返していない: {lines:?}");
for r in rows {
assert!(
r < p.line,
"折り返した本文の一部が矩形の下に落ちた (行 {r}, 矩形 {p:?}): {lines:?}"
);
}
assert_reserved(&out);
assert_rectangular(&out);
}
fn box_width(out: &RenderOut) -> usize {
let top = texts(out)
.into_iter()
.find(|l| l.trim_start().starts_with('┌'))
.expect("表が描かれていない");
UnicodeWidthStr::width(top.trim_start())
}
#[test]
fn a_cells_width_is_the_wider_band_not_the_sum_of_its_bands() {
for (why, both, wider_alone, width) in [
(
"the caption is narrower than the picture",
"|  a caption | b |\n|---|---|\n| 1 | 2 |\n",
"|  | b |\n|---|---|\n| 1 | 2 |\n",
90u16,
),
(
"a CJK caption, still narrower than the picture",
"|  図の説明 | b |\n|---|---|\n| 1 | 2 |\n",
"|  | b |\n|---|---|\n| 1 | 2 |\n",
90,
),
(
"the caption is the wider band",
"| a caption that is a good deal wider than the picture it explains | b |\n|---|---|\n| 1 | 2 |\n",
"| a caption that is a good deal wider than the picture it explains | b |\n|---|---|\n| 1 | 2 |\n",
90,
),
(
"the HTML spelling",
"<table>\n<tr><td><img src=\"a.png\" alt=\"x\"> a caption</td><td>b</td></tr>\n</table>\n",
"<table>\n<tr><td><img src=\"a.png\" alt=\"x\"></td><td>b</td></tr>\n</table>\n",
90,
),
] {
let out = render(both, width);
let reference = render(wider_alone, width);
assert_eq!(
box_width(&out),
box_width(&reference),
"{why}: 帯の和で測って列が膨らんでいる\n{:?}\n{:?}",
texts(&out),
texts(&reference)
);
assert_rectangular(&out);
}
}
#[test]
fn the_widest_band_leaves_no_dead_space_in_its_column() {
let src = "|  a caption | b |\n|---|---|\n| 1 | 2 |\n";
let out = render(src, 90);
assert_eq!(out.images.len(), 1, "{:?}", out.images);
let p = &out.images[0];
let lines = texts(&out);
let top = &lines[p.line];
assert_eq!(
slice_cols(top, p.col as usize + p.cols as usize, 2),
" │",
"矩形の右に死んだ余白がある {p:?}: |{top}|"
);
assert_eq!(
slice_cols(top, p.col as usize - 2, 2),
"│ ",
"矩形の左に死んだ余白がある {p:?}: |{top}|"
);
}
#[test]
fn a_cell_with_several_images_is_measured_by_the_widest_of_them() {
for (why, src, want) in [
(
"widest first",
"|   | z |\n|---|---|\n| 1 | 2 |\n",
vec![20u16, 10],
),
(
"widest last",
"|   | z |\n|---|---|\n| 1 | 2 |\n",
vec![10, 20],
),
(
"widest in the middle of three",
"|    | z |\n|---|---|\n| 1 | 2 |\n",
vec![10, 30, 20],
),
(
"three, widest first",
"|    | z |\n|---|---|\n| 1 | 2 |\n",
vec![30, 10, 20],
),
(
"an undrawable one between two drawable ones",
"|    | z |\n|---|---|\n| 1 | 2 |\n",
vec![20, 10],
),
(
"the HTML spelling, widest first",
"<table>\n<tr><td><img src=\"w20.png\" alt=\"a\"> <img src=\"w10.png\" alt=\"b\"></td><td>z</td></tr>\n</table>\n",
vec![20, 10],
),
] {
let out = render_with(src, 90, &sized_slot);
let got: Vec<u16> = out.images.iter().map(|p| p.cols).collect();
assert_eq!(
got, want,
"{why}: どれかの絵が黙って縮んでいる: {:?}\n{:?}",
out.images,
texts(&out)
);
for w in out.images.windows(2) {
assert!(
w[0].line + w[0].rows as usize <= w[1].line,
"{why}: 同じセルの矩形どうしが重なっている: {:?}",
out.images
);
}
assert_reserved(&out);
assert_rectangular(&out);
}
}
#[test]
fn several_images_in_a_cell_size_the_column_like_the_widest_one_alone() {
let several = render_with(
"|    | z |\n|---|---|\n| 1 | 2 |\n",
90,
&sized_slot,
);
let widest_alone = render_with(
"|  | z |\n|---|---|\n| 1 | 2 |\n",
90,
&sized_slot,
);
assert_eq!(
box_width(&several),
box_width(&widest_alone),
"複数枚のセルの列幅が、いちばん広い1枚だけの場合と違う\n{:?}\n{:?}",
texts(&several),
texts(&widest_alone)
);
}
}