use std::ops::Range;
use super::length::pt;
use super::parser::{RichEvent, Selector};
use super::style::{css_color, ResolvedStyle, RichTextStyleSheet, StyleDelta};
use crate::style_vocab::ThemeColor;
#[derive(Debug, Clone, PartialEq)]
pub struct BuiltRuns {
pub text: String,
pub inline: Vec<InlineRun>,
pub baseline_shifts: Vec<BaselineRun>,
pub blocks: Vec<Block>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct InlineRun {
pub range: Range<usize>,
pub style: ResolvedStyle,
}
#[derive(Debug, Clone, PartialEq)]
pub struct BaselineRun {
pub range: Range<usize>,
pub shift_pt: f64,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Block {
pub range: Range<usize>,
pub kind: BlockKind,
pub depth: usize,
pub style: ResolvedStyle,
}
#[derive(Debug, Clone, PartialEq)]
pub enum BlockKind {
Paragraph,
Heading(u8),
BlockQuote,
List {
ordered: bool,
start: u64,
},
ListItem {
ordinal: u64,
marker: Option<String>,
},
CodeBlock {
lang: Option<String>,
},
Rule,
Div {
class: String,
},
}
pub fn reduce(events: &[RichEvent], sheet: &RichTextStyleSheet, base: &ResolvedStyle) -> BuiltRuns {
let root = match sheet.get("base") {
Some(d) => base.apply(d, base, base.size_pt),
None => base.clone(),
};
let mut r = Reducer {
text: String::new(),
inline: Vec::new(),
baseline_shifts: Vec::new(),
blocks: Vec::new(),
base_size_pt: base.size_pt,
style_stack: vec![StyleFrame {
style: root,
baseline_start: None,
}],
block_stack: Vec::new(),
list_stack: Vec::new(),
item_body_pending: false,
synthetic_paragraph_open: false,
};
for e in events {
r.consume(e, sheet);
}
r.finish()
}
struct Reducer {
text: String,
inline: Vec<InlineRun>,
baseline_shifts: Vec<BaselineRun>,
blocks: Vec<Block>,
base_size_pt: f64,
style_stack: Vec<StyleFrame>,
block_stack: Vec<BlockFrame>,
list_stack: Vec<ListFrame>,
item_body_pending: bool,
synthetic_paragraph_open: bool,
}
struct BlockFrame {
start: usize,
kind: BlockKind,
depth: usize,
style: ResolvedStyle,
}
struct StyleFrame {
style: ResolvedStyle,
baseline_start: Option<usize>,
}
struct ListFrame {
next_ordinal: u64,
ordered: bool,
}
impl Reducer {
fn consume(&mut self, event: &RichEvent, sheet: &RichTextStyleSheet) {
match event {
RichEvent::ParagraphStart => {
self.item_body_pending = false;
self.open_block(BlockKind::Paragraph, sheet, "paragraph");
}
RichEvent::ParagraphEnd => self.close_block(),
RichEvent::HeadingStart { level } => {
self.commit_pending_item_body(sheet, true);
let key = heading_key(*level);
self.open_block(BlockKind::Heading(*level), sheet, key);
}
RichEvent::HeadingEnd { .. } => self.close_block(),
RichEvent::BlockQuoteStart => {
self.commit_pending_item_body(sheet, true);
self.open_block(BlockKind::BlockQuote, sheet, "block_quote");
}
RichEvent::BlockQuoteEnd => self.close_block(),
RichEvent::ListStart { ordered, start } => self.on_list_start(*ordered, *start, sheet),
RichEvent::ListEnd => {
self.close_block();
self.list_stack.pop();
}
RichEvent::ItemStart => self.on_item_start(sheet),
RichEvent::ItemEnd => {
self.commit_pending_item_body(sheet, true);
self.close_block();
}
RichEvent::CodeBlockStart { lang } => {
self.commit_pending_item_body(sheet, true);
self.open_block(
BlockKind::CodeBlock { lang: lang.clone() },
sheet,
"code_block",
);
}
RichEvent::CodeBlockEnd => self.on_code_block_end(),
RichEvent::Rule => self.on_rule(sheet),
RichEvent::DivStart { class } => {
self.commit_pending_item_body(sheet, true);
self.open_block(
BlockKind::Div {
class: class.clone(),
},
sheet,
class,
);
}
RichEvent::DivEnd => self.close_block(),
RichEvent::EmphasisStart => self.push_inline("em", sheet),
RichEvent::EmphasisEnd => self.pop_inline(),
RichEvent::UnderlineStart => self.push_inline("underline", sheet),
RichEvent::UnderlineEnd => self.pop_inline(),
RichEvent::StrongStart => self.push_inline("strong", sheet),
RichEvent::StrongEnd => self.pop_inline(),
RichEvent::StrikethroughStart => self.push_inline("del", sheet),
RichEvent::StrikethroughEnd => self.pop_inline(),
RichEvent::SuperscriptStart => self.push_inline("sup", sheet),
RichEvent::SuperscriptEnd => self.pop_inline(),
RichEvent::SubscriptStart => self.push_inline("sub", sheet),
RichEvent::SubscriptEnd => self.pop_inline(),
RichEvent::LinkStart { .. } => self.push_inline("link", sheet),
RichEvent::LinkEnd => self.pop_inline(),
RichEvent::SpanStart { selector } => self.push_selector(selector, sheet),
RichEvent::SpanEnd => self.pop_inline(),
RichEvent::Text(t) => {
self.ensure_item_body_open(sheet);
self.push_text(t);
}
RichEvent::Code(t) => {
self.ensure_item_body_open(sheet);
self.push_inline("code", sheet);
self.push_text(t);
self.pop_inline();
}
RichEvent::InlineMath(t) | RichEvent::DisplayMath(t) => {
self.ensure_item_body_open(sheet);
self.push_text(t);
}
RichEvent::SoftBreak => {
self.ensure_item_body_open(sheet);
self.push_text(" ");
}
RichEvent::HardBreak => {
self.ensure_item_body_open(sheet);
self.push_text("\n");
}
}
}
fn on_list_start(&mut self, ordered: bool, start: u64, sheet: &RichTextStyleSheet) {
self.commit_pending_item_body(sheet, true);
let nested = !self.list_stack.is_empty();
self.list_stack.push(ListFrame {
next_ordinal: start,
ordered,
});
self.open_block(
BlockKind::List { ordered, start },
sheet,
if ordered { "list_ordered" } else { "list" },
);
if nested {
if let Some(frame) = self.block_stack.last_mut() {
frame.style.margin_pt[0] = 0.0;
frame.style.margin_pt[2] = 0.0;
}
}
}
fn on_item_start(&mut self, sheet: &RichTextStyleSheet) {
let (ordinal, ordered) = self
.list_stack
.last_mut()
.map(|f| {
let n = f.next_ordinal;
f.next_ordinal += 1;
(n, f.ordered)
})
.unwrap_or((1, false));
let bullet_depth = self
.list_stack
.iter()
.rev()
.take_while(|f| !f.ordered)
.count()
.saturating_sub(1);
let marker = compute_marker(sheet, ordered, ordinal, bullet_depth);
self.open_block(BlockKind::ListItem { ordinal, marker }, sheet, "list_item");
self.item_body_pending = true;
}
fn on_code_block_end(&mut self) {
if let Some(frame) = self.block_stack.last() {
if self.text.len() > frame.start && self.text.ends_with('\n') {
self.text.pop();
let new_len = self.text.len();
if let Some(last) = self.inline.last_mut() {
if last.range.end > new_len {
last.range.end = new_len;
if last.range.is_empty() {
self.inline.pop();
}
}
}
}
}
self.close_block();
}
fn on_rule(&mut self, sheet: &RichTextStyleSheet) {
self.commit_pending_item_body(sheet, true);
let start = self.text.len();
let style = self.resolve_class("hr", sheet);
self.blocks.push(Block {
range: start..start,
kind: BlockKind::Rule,
depth: self.container_depth(),
style,
});
self.push_paragraph_break();
}
fn ensure_item_body_open(&mut self, sheet: &RichTextStyleSheet) {
if self.item_body_pending {
self.item_body_pending = false;
self.open_block(BlockKind::Paragraph, sheet, "list_item_body");
self.synthetic_paragraph_open = true;
}
}
fn commit_pending_item_body(&mut self, sheet: &RichTextStyleSheet, close_synthetic: bool) {
if self.item_body_pending {
self.item_body_pending = false;
self.open_block(BlockKind::Paragraph, sheet, "list_item_body");
self.close_block();
} else if close_synthetic && self.synthetic_paragraph_open {
self.close_block();
self.synthetic_paragraph_open = false;
}
}
fn open_block(&mut self, kind: BlockKind, sheet: &RichTextStyleSheet, class_key: &str) {
if matches!(
kind,
BlockKind::Paragraph
| BlockKind::Heading(_)
| BlockKind::ListItem { .. }
| BlockKind::CodeBlock { .. }
) {
self.push_paragraph_break();
}
let start = self.text.len();
let style = self.resolve_class(class_key, sheet);
let depth = self.container_depth();
self.push_style(style.for_inline());
self.block_stack.push(BlockFrame {
start,
kind,
depth,
style,
});
}
fn close_block(&mut self) {
self.pop_style();
if let Some(frame) = self.block_stack.pop() {
let end = self.text.len();
self.blocks.push(Block {
range: frame.start..end,
kind: frame.kind,
depth: frame.depth,
style: frame.style,
});
}
}
fn container_depth(&self) -> usize {
self.block_stack
.iter()
.filter(|f| {
matches!(
f.kind,
BlockKind::BlockQuote
| BlockKind::List { .. }
| BlockKind::ListItem { .. }
| BlockKind::Div { .. }
)
})
.count()
}
fn push_paragraph_break(&mut self) {
if !self.text.is_empty() && !self.text.ends_with("\n\n") {
if self.text.ends_with('\n') {
self.text.push('\n');
} else {
self.text.push_str("\n\n");
}
}
}
fn push_inline(&mut self, key: &str, sheet: &RichTextStyleSheet) {
let style = self.resolve_class(key, sheet);
self.push_style(style);
}
fn pop_inline(&mut self) {
self.pop_style();
}
fn top(&self) -> &ResolvedStyle {
&self.style_stack.last().expect("cascade root").style
}
fn push_resolved(&mut self, delta: &StyleDelta) {
let n = self.style_stack.len();
let parent = &self.style_stack[n - 1].style;
let grandparent = &self.style_stack[n.saturating_sub(2)].style;
let style = parent.apply(delta, grandparent, self.base_size_pt);
self.push_style(style);
}
fn push_style(&mut self, style: ResolvedStyle) {
let baseline_start =
(style.baseline_pt != self.top().baseline_pt).then_some(self.text.len());
self.style_stack.push(StyleFrame {
style,
baseline_start,
});
}
fn pop_style(&mut self) {
if self.style_stack.len() <= 1 {
return;
}
let frame = self.style_stack.pop().expect("non-root frame");
if let Some(start) = frame.baseline_start {
let end = self.text.len();
if end > start {
self.baseline_shifts.push(BaselineRun {
range: start..end,
shift_pt: frame.style.baseline_pt,
});
}
}
}
fn resolve_class(&self, key: &str, sheet: &RichTextStyleSheet) -> ResolvedStyle {
let n = self.style_stack.len();
let parent = &self.style_stack[n - 1].style;
let grandparent = &self.style_stack[n.saturating_sub(2)].style;
match sheet.get(key) {
Some(d) => parent.apply(d, grandparent, self.base_size_pt),
None => parent.clone(),
}
}
fn push_selector(&mut self, sel: &Selector, sheet: &RichTextStyleSheet) {
let delta = match sel {
Selector::Class(name) => self.lookup_class(name, sheet).unwrap_or_else(|| {
css_color(name).map_or_else(StyleDelta::empty, |[r, g, b]| StyleDelta {
color: Some(ThemeColor::Fixed(rgb8_to_color(r, g, b))),
..StyleDelta::empty()
})
}),
Selector::HexColor([r, g, b]) => StyleDelta {
color: Some(ThemeColor::Fixed(rgb8_to_color(*r, *g, *b))),
..StyleDelta::empty()
},
Selector::Size(size_pt) => StyleDelta {
size: Some(pt(*size_pt as f64)),
..StyleDelta::empty()
},
Selector::HashName(name) => self
.lookup_class(name, sheet)
.unwrap_or_else(StyleDelta::empty),
};
self.push_resolved(&delta);
}
fn lookup_class(&self, name: &str, sheet: &RichTextStyleSheet) -> Option<StyleDelta> {
sheet.get(name).cloned()
}
fn push_text(&mut self, s: &str) {
if s.is_empty() {
return;
}
let start = self.text.len();
self.text.push_str(s);
let end = self.text.len();
let style = self.top().clone();
if let Some(last) = self.inline.last_mut() {
if last.range.end == start && last.style == style {
last.range.end = end;
return;
}
}
self.inline.push(InlineRun {
range: start..end,
style,
});
}
fn finish(mut self) -> BuiltRuns {
while self.block_stack.pop().is_some() {}
while self.style_stack.len() > 1 {
self.pop_style();
}
BuiltRuns {
text: self.text,
inline: self.inline,
baseline_shifts: self.baseline_shifts,
blocks: self.blocks,
}
}
}
fn compute_marker(
sheet: &RichTextStyleSheet,
ordered: bool,
ordinal: u64,
bullet_depth: usize,
) -> Option<String> {
if ordered {
return Some(format!("{ordinal}."));
}
match sheet.get("list_item").and_then(|d| d.bullet.as_ref()) {
Some(v) if v.is_empty() => None,
Some(v) => {
let s = &v[bullet_depth % v.len()];
if s.is_empty() {
None
} else {
Some(s.clone())
}
}
None => Some("•".to_string()),
}
}
fn heading_key(level: u8) -> &'static str {
match level {
1 => "h1",
2 => "h2",
3 => "h3",
4 => "h4",
5 => "h5",
_ => "h6",
}
}
fn rgb8_to_color(r: u8, g: u8, b: u8) -> crate::color::Color {
crate::color::Color::from_rgba8(r, g, b, 255)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::text::rich::length::{em, relative, RichMargin};
use crate::text::rich::parser::parse;
use crate::text::TextStyle;
const BASE_PT: f64 = 10.0;
fn base_style() -> ResolvedStyle {
ResolvedStyle::from_base(&TextStyle::new(BASE_PT as f32))
}
fn reduce_with(src: &str, sheet: &RichTextStyleSheet) -> BuiltRuns {
reduce(&parse(src), sheet, &base_style())
}
fn reduce_ok(src: &str) -> BuiltRuns {
reduce_with(src, &RichTextStyleSheet::new())
}
fn run_for<'a>(r: &'a BuiltRuns, text: &str) -> &'a InlineRun {
r.inline
.iter()
.find(|run| &r.text[run.range.clone()] == text)
.unwrap_or_else(|| panic!("no run for {text:?} in {:?}", r.text))
}
fn items(r: &BuiltRuns) -> Vec<&Block> {
r.blocks
.iter()
.filter(|b| matches!(b.kind, BlockKind::ListItem { .. }))
.collect()
}
fn marker_of(b: &Block) -> Option<&str> {
match &b.kind {
BlockKind::ListItem { marker, .. } => marker.as_deref(),
_ => None,
}
}
#[test]
fn plain_text_yields_one_run_at_the_base_style() {
let r = reduce_ok("hello");
assert_eq!(r.text, "hello");
assert_eq!(r.inline.len(), 1);
assert_eq!(r.inline[0].range, 0..5);
let d = &r.inline[0].style;
let base = base_style();
assert_eq!(d.weight, base.weight);
assert_eq!(d.italic, base.italic);
assert!(d.family.is_none());
assert_eq!(d.size_pt, BASE_PT);
assert!(d.color.is_none());
assert!(!d.underline);
assert!(!d.strikethrough);
assert_eq!(d.baseline_pt, 0.0);
assert!(r.baseline_shifts.is_empty());
}
#[test]
fn a_block_style_does_not_leak_its_box_onto_its_text() {
let r = reduce_ok("```\nlet x = 1;\n```");
let body = r
.inline
.iter()
.find(|run| r.text[run.range.clone()].contains("let"))
.expect("code body run");
assert!(body.style.background.is_none());
assert_eq!(body.style.padding_pt, [0.0; 4]);
assert_eq!(body.style.family.as_deref(), Some("monospace"));
}
#[test]
fn bold_run_gets_strong_weight() {
let r = reduce_ok("a **bold** c");
assert_eq!(r.inline.len(), 3, "got {:?}", r.inline);
assert_eq!(run_for(&r, "bold").style.weight, 700);
}
#[test]
fn nested_strong_and_em_overlay() {
let r = reduce_ok("***both***");
let both = run_for(&r, "both");
assert_eq!(both.style.weight, 700);
assert!(both.style.italic);
}
#[test]
fn underscore_emphasis_underlines() {
let r = reduce_ok("a _u_ b");
assert!(run_for(&r, "u").style.underline);
assert!(!run_for(&r, "u").style.italic);
}
#[test]
fn sup_produces_a_positive_baseline_shift() {
let r = reduce_ok("a ^2^ b");
assert_eq!(r.baseline_shifts.len(), 1, "got {:?}", r.baseline_shifts);
let bs = &r.baseline_shifts[0];
assert_eq!(&r.text[bs.range.clone()], "2");
assert!(bs.shift_pt > 0.0, "sup shift should be positive");
}
#[test]
fn sub_baseline_shift_is_negative() {
let r = reduce_ok("a ~2~ b");
assert_eq!(r.baseline_shifts.len(), 1);
assert!(r.baseline_shifts[0].shift_pt < 0.0);
}
#[test]
fn sup_inside_sup_stops_shrinking_but_keeps_lifting() {
let r = reduce_ok("a ^b ^c^^ d");
let outer = run_for(&r, "b ");
let inner = run_for(&r, "c");
assert!((outer.style.size_pt - inner.style.size_pt).abs() < 1e-9);
assert!(inner.style.baseline_pt > outer.style.baseline_pt);
}
#[test]
fn hex_color_span_sets_color() {
let r = reduce_ok("{#ff8800 warm}");
assert!(matches!(
run_for(&r, "warm").style.color,
Some(ThemeColor::Fixed(_))
));
}
#[test]
fn css_color_fallback_when_class_undefined() {
let r = reduce_ok("{.steelblue hi}");
assert!(
matches!(run_for(&r, "hi").style.color, Some(ThemeColor::Fixed(_))),
"expected CSS colour fallback"
);
}
#[test]
fn size_selector_sets_an_absolute_size() {
let r = reduce_ok("{.17 x}");
assert!((run_for(&r, "x").style.size_pt - 17.0).abs() < 1e-6);
}
#[test]
fn nested_selectors_overlay() {
let r = reduce_ok("{.red {.17 x}}");
let x = run_for(&r, "x");
assert!(matches!(x.style.color, Some(ThemeColor::Fixed(_))));
assert!((x.style.size_pt - 17.0).abs() < 1e-6);
}
#[test]
fn user_class_overrides_css_name() {
let mut sheet = RichTextStyleSheet::new();
sheet.set(
"red",
StyleDelta {
weight: Some(900),
..StyleDelta::empty()
},
);
let r = reduce_with("{.red word}", &sheet);
let word = run_for(&r, "word");
assert_eq!(word.style.weight, 900);
assert!(word.style.color.is_none());
}
#[test]
fn unknown_hash_selector_leaves_the_body_unstyled() {
let r = reduce_ok("{#nosuchid word}");
let word = run_for(&r, "word");
assert!(word.style.color.is_none());
assert_eq!(word.style.weight, base_style().weight);
}
#[test]
fn two_paragraphs_separated_by_double_newline() {
let r = reduce_ok("first\n\nsecond");
assert!(r.text.contains("\n\n"), "got text = {:?}", r.text);
let paragraph_count = r
.blocks
.iter()
.filter(|b| matches!(b.kind, BlockKind::Paragraph))
.count();
assert_eq!(paragraph_count, 2);
}
#[test]
fn div_block_recorded_with_class() {
let r = reduce_ok(":::warning\nbody\n:::");
let div = r
.blocks
.iter()
.find(|b| matches!(&b.kind, BlockKind::Div { class } if class == "warning"))
.expect("div block");
let body_run = run_for(&r, "body");
assert!(div.range.start <= body_run.range.start);
assert!(div.range.end >= body_run.range.end);
}
#[test]
fn heading_text_inherits_heading_style() {
let r = reduce_ok("# Big");
let big = run_for(&r, "Big");
assert_eq!(big.style.weight, 700, "h1 weight should apply to text");
assert!(
big.style.size_pt > BASE_PT * 1.5,
"h1 size should apply to text, got {}",
big.style.size_pt
);
}
#[test]
fn heading_margins_measure_against_the_headings_own_size() {
let r = reduce_ok("# Big");
let heading = r
.blocks
.iter()
.find(|b| matches!(b.kind, BlockKind::Heading(1)))
.expect("h1 block");
assert!(
(heading.style.margin_pt[0] - heading.style.size_pt).abs() < 1e-6,
"got {:?} for a {}pt heading",
heading.style.margin_pt,
heading.style.size_pt
);
}
#[test]
fn sizes_compound_through_nested_divs() {
let mut sheet = RichTextStyleSheet::new();
sheet.set(
"half",
StyleDelta {
size: Some(relative(0.5)),
..StyleDelta::empty()
},
);
let r = reduce_with(":::half\n:::half\ndeep\n:::\n:::", &sheet);
assert!((run_for(&r, "deep").style.size_pt - BASE_PT * 0.25).abs() < 1e-6);
}
#[test]
fn nested_strong_inside_heading_composes() {
let r = reduce_ok("# **bold** heading");
let heading = r
.blocks
.iter()
.find(|b| matches!(b.kind, BlockKind::Heading(1)))
.expect("h1 block");
for inline in &r.inline {
if inline.range.start >= heading.range.end || inline.range.end <= heading.range.start {
continue;
}
assert!(
inline.style.size_pt > BASE_PT * 1.5,
"run {:?} inside h1 should inherit the heading size, got {}",
&r.text[inline.range.clone()],
inline.style.size_pt
);
assert_eq!(
inline.style.weight,
700,
"run {:?} inside h1 should be bold",
&r.text[inline.range.clone()]
);
}
}
#[test]
fn code_block_body_gets_monospace_family() {
let r = reduce_ok("```\nlet x = 1;\n```");
let body = r
.inline
.iter()
.find(|run| r.text[run.range.clone()].contains("let"))
.expect("code body run");
assert_eq!(body.style.family.as_deref(), Some("monospace"));
}
#[test]
fn list_items_carry_ordinal() {
let r = reduce_ok("1. one\n2. two\n3. three");
let ords: Vec<u64> = items(&r)
.iter()
.map(|b| match b.kind {
BlockKind::ListItem { ordinal, .. } => ordinal,
_ => unreachable!(),
})
.collect();
assert_eq!(ords.len(), 3);
assert!(ords.contains(&1) && ords.contains(&2) && ords.contains(&3));
}
#[test]
fn markers_ride_on_the_item_not_in_its_text() {
let r = reduce_ok("- alpha");
let item = items(&r)[0];
assert_eq!(marker_of(item), Some("•"));
assert_eq!(
r.text[item.range.clone()].trim(),
"alpha",
"the marker must not be injected into the item's text"
);
}
#[test]
fn ordered_markers_carry_the_ordinal() {
let r = reduce_ok("1. one\n2. two");
let it = items(&r);
assert_eq!(it.len(), 2);
let mut markers: Vec<&str> = it.iter().filter_map(|b| marker_of(b)).collect();
markers.sort_unstable();
assert_eq!(markers, vec!["1.", "2."]);
}
#[test]
fn custom_bullet_replaces_default() {
let mut sheet = RichTextStyleSheet::new();
sheet.set(
"list_item",
StyleDelta {
bullet: Some(vec!["★".to_string()]),
..StyleDelta::empty()
},
);
let r = reduce_with("- one", &sheet);
assert_eq!(marker_of(items(&r)[0]), Some("★"));
}
#[test]
fn empty_bullet_vec_suppresses_marker() {
let mut sheet = RichTextStyleSheet::new();
sheet.set(
"list_item",
StyleDelta {
bullet: Some(Vec::new()),
..StyleDelta::empty()
},
);
let r = reduce_with("- naked", &sheet);
assert_eq!(marker_of(items(&r)[0]), None);
}
#[test]
fn empty_string_entry_suppresses_at_that_depth() {
let mut sheet = RichTextStyleSheet::new();
sheet.set(
"list_item",
StyleDelta {
bullet: Some(vec!["•".to_string(), String::new()]),
..StyleDelta::empty()
},
);
let r = reduce_with("- outer\n - inner", &sheet);
let it = items(&r);
assert_eq!(it.len(), 2);
assert_eq!(marker_of(it[0]), None, "depth 1 is suppressed");
assert_eq!(marker_of(it[1]), Some("•"));
}
#[test]
fn bullet_cycles_through_vector_by_depth() {
let mut sheet = RichTextStyleSheet::new();
sheet.set(
"list_item",
StyleDelta {
bullet: Some(vec!["•".to_string(), "◦".to_string()]),
..StyleDelta::empty()
},
);
let r = reduce_with("- a\n - b\n - c", &sheet);
let it = items(&r);
assert_eq!(it.len(), 3);
assert_eq!(marker_of(it[0]), Some("•"), "depth 2 cycles back");
assert_eq!(marker_of(it[1]), Some("◦"));
assert_eq!(marker_of(it[2]), Some("•"));
}
#[test]
fn an_ordered_list_restarts_the_bullet_cycle() {
let r = reduce_ok("- a\n 1. b\n - c");
let inner = items(&r)
.into_iter()
.find(|b| r.text[b.range.clone()].contains('c'))
.expect("innermost item");
assert_eq!(marker_of(inner), Some("•"));
}
#[test]
fn tight_list_items_use_list_item_body_class() {
let mut sheet = RichTextStyleSheet::new();
sheet.set(
"list_item_body",
StyleDelta {
margin: Some(RichMargin::new(pt(0.0), pt(0.0), pt(1.0), pt(0.0))),
..StyleDelta::empty()
},
);
let r = reduce_with("- a\n- b", &sheet);
let bodies: Vec<&Block> = r
.blocks
.iter()
.filter(|b| matches!(b.kind, BlockKind::Paragraph))
.collect();
assert!(bodies.len() >= 2, "expected two body paragraphs");
for body in bodies {
assert_eq!(
body.style.margin_pt,
[0.0, 0.0, 1.0, 0.0],
"tight body should carry the list_item_body margin"
);
}
}
#[test]
fn loose_list_items_use_paragraph_class() {
let r = reduce_ok("- a\n\n- b");
let bodies: Vec<&Block> = r
.blocks
.iter()
.filter(|b| matches!(b.kind, BlockKind::Paragraph))
.collect();
assert!(bodies.len() >= 2, "expected two body paragraphs");
for body in bodies {
assert!(
body.style.margin_pt[2] > 0.0,
"loose body should carry paragraph's bottom margin, got {:?}",
body.style.margin_pt
);
}
}
#[test]
fn lists_indent_their_items_through_container_padding() {
let r = reduce_ok("- a");
let list = r
.blocks
.iter()
.find(|b| matches!(b.kind, BlockKind::List { .. }))
.expect("list container");
assert!((list.style.padding_pt[3] - BASE_PT * 2.0).abs() < 1e-6);
}
#[test]
fn nested_lists_drop_the_container_margin() {
let r = reduce_ok("- a\n - b");
let lists: Vec<&Block> = r
.blocks
.iter()
.filter(|b| matches!(b.kind, BlockKind::List { .. }))
.collect();
assert_eq!(lists.len(), 2);
assert_eq!(lists[0].style.margin_pt[0], 0.0);
assert_eq!(lists[0].style.margin_pt[2], 0.0);
assert!(
lists[1].style.margin_pt[0] > 0.0,
"outer list keeps its gap"
);
}
#[test]
fn nested_ordered_lists_number_independently() {
let r = reduce_ok("1. first\n2. second\n 1. inner1\n 2. inner2\n3. third");
let ords: Vec<u64> = items(&r)
.iter()
.map(|b| match b.kind {
BlockKind::ListItem { ordinal, .. } => ordinal,
_ => unreachable!(),
})
.collect();
assert_eq!(ords.len(), 5);
assert_eq!(
ords.iter().filter(|&&n| n == 1).count(),
2,
"expected two `1`s (outer + nested first), got {ords:?}"
);
}
#[test]
fn base_selector_applies_run_wide() {
let mut sheet = RichTextStyleSheet::new();
sheet.set(
"base",
StyleDelta {
tracking: Some(50.0),
..StyleDelta::empty()
},
);
let r = reduce_with("plain", &sheet);
assert_eq!(r.inline[0].style.tracking, 50.0);
}
#[test]
fn em_lengths_inside_a_scaled_block_follow_that_block() {
let mut sheet = RichTextStyleSheet::new();
sheet.set(
"big",
StyleDelta {
size: Some(relative(2.0)),
padding: Some(RichMargin::all(em(1.0))),
..StyleDelta::empty()
},
);
let r = reduce_with(":::big\nx\n:::", &sheet);
let div = r
.blocks
.iter()
.find(|b| matches!(&b.kind, BlockKind::Div { .. }))
.expect("div");
assert!((div.style.padding_pt[3] - BASE_PT * 2.0).abs() < 1e-6);
}
#[test]
fn inline_runs_coalesce_across_soft_breaks() {
let r = reduce_ok("first\nsecond");
assert_eq!(r.text, "first second");
assert_eq!(r.inline.len(), 1, "got {:?}", r.inline);
}
#[test]
fn depth_increments_inside_nested_divs() {
let r = reduce_ok(":::outer\n:::inner\nx\n:::\n:::");
let inner = r
.blocks
.iter()
.find(|b| matches!(&b.kind, BlockKind::Div { class } if class == "inner"))
.unwrap();
let outer = r
.blocks
.iter()
.find(|b| matches!(&b.kind, BlockKind::Div { class } if class == "outer"))
.unwrap();
assert_eq!(outer.depth, 0);
assert_eq!(inner.depth, 1);
}
}