use hephaestus::backend::vello::VelloRenderer;
use hephaestus::color::{rgb8, Color};
use hephaestus::geometry::Affine;
use hephaestus::pick::PickId;
use hephaestus::scene::SceneBuilder;
use hephaestus::style_vocab::{HAlign, Palette};
use hephaestus::text::rich::{
draw_rich_text, parse, reduce, BlockKind, BuiltRuns, InlineRun, ResolvedStyle, RichAnchor,
RichTextRun, RichTextStyleSheet,
};
use hephaestus::text::TextStyle;
use hephaestus::Renderer;
const BASE_PT: f32 = 12.0;
const KITCHEN_SINK: &str = "\
# Heading one
## Heading two
A paragraph with *italic*, _underline_, **bold**, ~~struck~~ text,
some `inline code`, a ^script^ and a ~script~, plus a
{.17 sized span} and a {#c04030 coloured} one.
- first bullet
- second bullet
- nested bullet
1. nested ordered
2. and another
- third bullet
9. nine
10. ten
> A block quote with **emphasis** inside it.
```rust
let x = 1;
```
---
:::note
A fenced div body.
:::
";
fn base() -> ResolvedStyle {
ResolvedStyle::from_base(&TextStyle::new(BASE_PT))
}
fn build(src: &str) -> BuiltRuns {
reduce(&parse(src), &RichTextStyleSheet::new(), &base())
}
fn run_covering<'a>(r: &'a BuiltRuns, needle: &str) -> &'a InlineRun {
let at = r.text.find(needle).unwrap_or_else(|| {
panic!("{needle:?} is not in the reduced text: {:?}", r.text);
});
r.inline
.iter()
.find(|run| run.range.start <= at && run.range.end > at)
.expect("a run covering the match")
}
#[test]
fn kitchen_sink_reduces_to_covering_non_overlapping_runs() {
let r = build(KITCHEN_SINK);
assert!(!r.text.is_empty());
assert_eq!(r.inline.first().expect("a first run").range.start, 0);
assert_eq!(
r.inline.last().expect("a last run").range.end,
r.text.len(),
"the last run must reach the end of the text"
);
for pair in r.inline.windows(2) {
assert!(
pair[0].range.end <= pair[1].range.start,
"runs overlap: {:?} then {:?}",
pair[0].range,
pair[1].range
);
}
}
#[test]
fn kitchen_sink_emits_every_block_kind() {
let r = build(KITCHEN_SINK);
let has = |f: &dyn Fn(&BlockKind) -> bool| r.blocks.iter().any(|b| f(&b.kind));
assert!(has(&|k| matches!(k, BlockKind::Heading(1))), "h1");
assert!(has(&|k| matches!(k, BlockKind::Heading(2))), "h2");
assert!(has(&|k| matches!(k, BlockKind::Paragraph)), "paragraph");
assert!(has(&|k| matches!(k, BlockKind::BlockQuote)), "block quote");
assert!(
has(&|k| matches!(k, BlockKind::List { ordered: false, .. })),
"unordered list"
);
assert!(
has(&|k| matches!(k, BlockKind::List { ordered: true, .. })),
"ordered list"
);
assert!(has(&|k| matches!(k, BlockKind::ListItem { .. })), "item");
assert!(has(&|k| matches!(k, BlockKind::CodeBlock { .. })), "code");
assert!(has(&|k| matches!(k, BlockKind::Rule)), "rule");
assert!(
has(&|k| matches!(k, BlockKind::Div { class } if class == "note")),
"div"
);
}
#[test]
fn inline_marks_resolve_to_their_style() {
let r = build(KITCHEN_SINK);
assert!(run_covering(&r, "italic").style.italic);
assert!(run_covering(&r, "underline").style.underline);
assert_eq!(run_covering(&r, "bold").style.weight, 700);
assert!(run_covering(&r, "struck").style.strikethrough);
assert_eq!(
run_covering(&r, "inline code").style.family.as_deref(),
Some("monospace")
);
}
#[test]
fn sized_and_coloured_spans_resolve_to_concrete_values() {
let r = build(KITCHEN_SINK);
let sized = run_covering(&r, "sized span");
assert!(
(sized.style.size_pt - 17.0).abs() < 1e-6,
"`{{.17 …}}` should be 17pt, got {}",
sized.style.size_pt
);
assert!(run_covering(&r, "coloured").style.color.is_some());
}
#[test]
fn superscript_and_subscript_shift_in_opposite_directions() {
let r = build(KITCHEN_SINK);
let up = r
.baseline_shifts
.iter()
.filter(|b| b.shift_pt > 0.0)
.count();
let down = r
.baseline_shifts
.iter()
.filter(|b| b.shift_pt < 0.0)
.count();
assert!(up >= 1, "expected a superscript shift");
assert!(down >= 1, "expected a subscript shift");
}
#[test]
fn headings_carry_larger_sizes_than_body_text() {
let r = build(KITCHEN_SINK);
let size_of = |level: u8| {
r.blocks
.iter()
.find(|b| matches!(b.kind, BlockKind::Heading(l) if l == level))
.expect("heading block")
.style
.size_pt
};
assert!(size_of(1) > size_of(2));
assert!(size_of(2) > BASE_PT as f64);
}
#[test]
fn list_markers_ride_on_their_items() {
let r = build(KITCHEN_SINK);
let markers: Vec<&str> = r
.blocks
.iter()
.filter_map(|b| match &b.kind {
BlockKind::ListItem { marker, .. } => marker.as_deref(),
_ => None,
})
.collect();
assert!(markers.contains(&"•"), "unordered items keep a bullet");
assert!(markers.contains(&"◦"), "nesting cycles the bullet set");
assert!(
markers.contains(&"1."),
"ordered items number from the list start"
);
assert!(markers.contains(&"10."), "ordinals reach double digits");
assert!(
!r.text.contains("• "),
"markers leaked into the text: {:?}",
r.text
);
}
#[test]
fn code_block_body_keeps_its_source_lines() {
let r = build(KITCHEN_SINK);
let block = r
.blocks
.iter()
.find(|b| matches!(b.kind, BlockKind::CodeBlock { .. }))
.expect("code block");
assert_eq!(r.text[block.range.clone()].trim(), "let x = 1;");
}
#[test]
fn escaped_braces_never_open_a_span() {
let r = build(r"a \{not a span\} b");
assert_eq!(r.text, "a {not a span} b");
assert_eq!(r.inline.len(), 1, "no span means one uniform run");
}
#[test]
fn image_alt_text_survives_as_plain_text() {
let r = build("before  after");
assert!(
r.text.contains("a diagram"),
"alt text should render, got {:?}",
r.text
);
}
#[test]
fn a_pathological_source_still_produces_a_document() {
for src in [
"{",
"}",
"{.red unclosed",
"{}",
"{!!! nonsense}",
":::",
":::open\nbody",
"~~~",
"***",
"",
] {
let r = build(src);
for run in &r.inline {
assert!(
run.range.end <= r.text.len(),
"{src:?} produced a bad range"
);
}
}
}
fn ink_bounds(buf: &[u8], w: u32, h: u32, bg: Color) -> Option<(u32, u32, u32, u32)> {
let [br, bgc, bb, _] = bg.components;
let target = [
(br * 255.0).round() as i16,
(bgc * 255.0).round() as i16,
(bb * 255.0).round() as i16,
];
let mut bounds: Option<(u32, u32, u32, u32)> = None;
for y in 0..h {
for x in 0..w {
let i = ((y * w + x) * 4) as usize;
let differs = (0..3).any(|c| (buf[i + c] as i16 - target[c]).abs() > 8);
if !differs {
continue;
}
bounds = Some(match bounds {
None => (x, y, x, y),
Some((x0, y0, x1, y1)) => (x0.min(x), y0.min(y), x1.max(x), y1.max(y)),
});
}
}
bounds
}
#[test]
fn a_rendered_block_puts_its_ink_inside_the_box_it_reports() {
let (w, h) = (480u32, 420u32);
let dpi = 96.0;
let bg = rgb8(255, 255, 255);
let sheet = RichTextStyleSheet::new();
let palette = Palette::default();
let style = TextStyle::new(13.0);
let run = RichTextRun::new(
"# Title\n\nA paragraph that is long enough to need more than one \
line at this column width.\n\n- alpha\n- beta",
&style,
rgb8(20, 20, 30),
&sheet,
&palette,
dpi,
);
let column = 360.0_f32;
let (origin_x, origin_y) = (48.0_f64, 32.0_f64);
let height = run.set_max_width(column, HAlign::Start) as f64;
assert!(height > 0.0, "a shaped block must have height");
let mut renderer = VelloRenderer::new().expect("vello renderer init");
{
let scene = renderer.scene();
scene.clear();
draw_rich_text(
scene,
&run,
origin_x,
origin_y,
RichAnchor::top_left(),
Affine::IDENTITY,
PickId::Skip,
);
}
let mut buf = vec![0u8; (w * h * 4) as usize];
renderer
.render_to_buffer(w, h, bg, &mut buf)
.expect("render");
let (x0, y0, x1, y1) = ink_bounds(&buf, w, h, bg).expect("the block should have drawn ink");
assert!(
(x0 as f64) > origin_x - 40.0,
"ink starts too far left: {x0}"
);
assert!(
(x1 as f64) <= origin_x + column as f64 + 4.0,
"ink overflows the wrap column: {x1}"
);
assert!(
(y0 as f64) >= origin_y - 4.0,
"ink starts above the box: {y0}"
);
assert!(
(y1 as f64) <= origin_y + height + 8.0,
"ink runs past the reported height: {y1} vs {}",
origin_y + height
);
assert!(
(y1 - y0) as f64 > 60.0,
"expected several stacked lines, got {}px of ink",
y1 - y0
);
}
#[test]
fn wrapping_narrower_makes_the_block_taller() {
let sheet = RichTextStyleSheet::new();
let palette = Palette::default();
let style = TextStyle::new(13.0);
let run = RichTextRun::new(
"A paragraph long enough that halving its column has to add lines.",
&style,
rgb8(0, 0, 0),
&sheet,
&palette,
96.0,
);
let wide = run.set_max_width(400.0, HAlign::Start);
let narrow = run.set_max_width(160.0, HAlign::Start);
assert!(narrow > wide, "{narrow} should exceed {wide}");
assert_eq!(run.set_max_width(160.0, HAlign::Start), narrow);
}