use crate::geometry::Rect;
use super::length::swap_lr;
use super::run::{BlockLayout, RichTextRun};
use crate::color::Color;
use crate::style_vocab::{Palette, ThemeColor};
#[derive(Debug, Clone, PartialEq)]
pub struct BlockPaint {
pub outer_rect: Rect,
pub background: Option<Color>,
pub border: Option<BlockBorder>,
pub corner_radius: f32,
}
#[derive(Debug, Clone, PartialEq)]
pub struct BlockBorder {
pub color: Color,
pub widths_px: [f32; 4],
pub linetype_pt: Option<std::sync::Arc<[crate::scales::value::LinetypeStep]>>,
}
impl BlockBorder {
pub fn is_uniform(&self) -> bool {
let w0 = self.widths_px[0];
self.widths_px.iter().all(|&w| (w - w0).abs() < 1e-3)
}
}
pub(crate) fn compute_block_paints(run: &RichTextRun) -> Vec<BlockPaint> {
let blocks = run.blocks.borrow();
let mut paints: Vec<BlockPaint> = Vec::new();
let palette = &run.palette;
let dpi = run.dpi;
let px = |pt: f64| (pt * dpi / 72.0) as f32;
for container in &run.containers {
if !has_paint(&container.style.background, &container.style.border_color) {
continue;
}
let leaves: Vec<&BlockLayout> = blocks
.iter()
.filter(|bl| {
bl.text_range.start >= container.range.start
&& bl.text_range.end <= container.range.end
})
.collect();
if leaves.is_empty() {
continue;
}
let mut x0 = f32::INFINITY;
let mut y0 = f32::INFINITY;
let mut x1 = f32::NEG_INFINITY;
let mut y1 = f32::NEG_INFINITY;
for bl in &leaves {
let r = bl.outer_rect();
x0 = x0.min(r.x0 as f32);
y0 = y0.min(r.y0 as f32);
x1 = x1.max(r.x1 as f32);
y1 = y1.max(r.y1 as f32);
}
let is_rtl = leaves.first().map(|bl| bl.is_rtl).unwrap_or(false);
let pad = swap_lr(container.style.padding_pt, is_rtl);
x0 -= px(pad[3]);
x1 += px(pad[1]);
y0 -= px(pad[0]);
y1 += px(pad[2]);
let outer_rect = Rect::new(x0 as f64, y0 as f64, x1 as f64, y1 as f64);
let bg = container
.style
.background
.as_ref()
.map(|c| c.resolve(palette));
let border = border_for(
&container.style.border_color,
container.style.border_width_pt,
container.style.border_type.as_deref(),
palette,
dpi,
is_rtl,
);
let corner_radius = px(container.style.border_radius_pt);
if bg.is_none() && border.is_none() {
continue;
}
paints.push(BlockPaint {
outer_rect,
background: bg,
border,
corner_radius,
});
}
for bl in blocks.iter() {
let d = &bl.style;
if !has_paint(&d.background, &d.border_color) {
continue;
}
let outer_rect = bl.outer_rect();
let bg = d.background.as_ref().map(|c| c.resolve(palette));
let border = border_for(
&d.border_color,
d.border_width_pt,
d.border_type.as_deref(),
palette,
dpi,
bl.is_rtl,
);
let corner_radius = px(d.border_radius_pt);
if bg.is_none() && border.is_none() {
continue;
}
paints.push(BlockPaint {
outer_rect,
background: bg,
border,
corner_radius,
});
}
paints
}
fn has_paint(bg: &Option<ThemeColor>, border: &Option<ThemeColor>) -> bool {
bg.is_some() || border.is_some()
}
fn border_for(
color: &Option<ThemeColor>,
width_pt: [f64; 4],
dash_pattern: Option<&[crate::scales::value::LinetypeStep]>,
palette: &Palette,
dpi: f64,
is_rtl: bool,
) -> Option<BlockBorder> {
let c = color.as_ref()?;
let w = swap_lr(width_pt, is_rtl);
let widths_px = [
(w[0] * dpi / 72.0) as f32,
(w[1] * dpi / 72.0) as f32,
(w[2] * dpi / 72.0) as f32,
(w[3] * dpi / 72.0) as f32,
];
if widths_px.iter().all(|&w| w <= 0.0) {
return None;
}
let linetype_pt = dash_pattern.map(|steps| std::sync::Arc::from(steps.to_vec()));
Some(BlockBorder {
color: c.resolve(palette),
widths_px,
linetype_pt,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::color::Color;
use crate::style_vocab::ThemeColor;
use crate::text::rich::length::{pt, RichMargin};
use crate::text::rich::style::StyleDelta;
use crate::text::rich::{RichTextRun, RichTextStyleSheet};
use crate::text::TextStyle;
fn palette() -> Palette {
Palette::new(
Color::from_rgba8(255, 255, 255, 255),
Color::from_rgba8(0, 0, 0, 255),
Color::from_rgba8(51, 105, 232, 255),
)
}
fn base_style() -> TextStyle {
TextStyle::new(14.0)
}
fn shape(sheet: &RichTextStyleSheet, src: &str) -> RichTextRun {
RichTextRun::new(
src,
&base_style(),
Color::from_rgba8(0, 0, 0, 255),
sheet,
&palette(),
96.0,
)
}
#[test]
fn code_block_produces_background_paint() {
let run = shape(&RichTextStyleSheet::new(), "```\nlet x = 1;\n```");
let paints = run.block_paints();
assert!(
paints.iter().any(|p| p.background.is_some()),
"code_block should produce a background paint"
);
}
#[test]
fn paragraph_without_paint_produces_no_paint() {
let run = shape(&RichTextStyleSheet::new(), "plain paragraph");
let paints = run.block_paints();
assert!(paints.is_empty(), "got {paints:?}");
}
#[test]
fn container_paint_excludes_own_margin_includes_padding() {
let mut sheet = RichTextStyleSheet::empty();
sheet.set(
"block_quote",
StyleDelta {
padding: Some(RichMargin::all(pt(10.0))),
margin: Some(RichMargin::all(pt(20.0))),
background: Some(ThemeColor::Fixed(Color::from_rgba8(200, 200, 200, 255))),
..StyleDelta::empty()
},
);
let run = shape(&sheet, "before\n\n> hello world\n\nafter");
let paints = run.block_paints();
let bq = paints
.iter()
.find(|p| p.background.is_some())
.expect("expected bordered/filled blockquote paint");
let paint_h = bq.outer_rect.height();
let total_h = run.natural_height();
let text_h = total_h - paint_h;
let expected_margin_gap = 2.0 * 20.0 * 96.0 / 72.0;
assert!(
text_h >= expected_margin_gap * 0.9,
"run should be taller than paint by ~2×margin plus two lines (paint={paint_h}, total={total_h}, expected gap ≈ {expected_margin_gap})",
);
}
#[test]
fn container_margin_at_the_document_edge_leaves_no_gap() {
let mut sheet = RichTextStyleSheet::empty();
sheet.set(
"block_quote",
StyleDelta {
padding: Some(RichMargin::all(pt(10.0))),
margin: Some(RichMargin::all(pt(20.0))),
background: Some(ThemeColor::Fixed(Color::from_rgba8(200, 200, 200, 255))),
..StyleDelta::empty()
},
);
let run = shape(&sheet, "> hello world");
let paints = run.block_paints();
let bq = paints
.iter()
.find(|p| p.background.is_some())
.expect("expected bordered/filled blockquote paint");
let gap = run.natural_height() - bq.outer_rect.height();
assert!(
gap.abs() < 0.01,
"document-edge margins should collapse out of the box (gap={gap})"
);
}
#[test]
fn blockquote_paint_wraps_its_content() {
let quoted = shape(&RichTextStyleSheet::new(), "> hello world");
let paints = quoted.block_paints();
let bq = paints.iter().find(|p| p.border.is_some());
assert!(bq.is_some(), "blockquote should produce a bordered paint");
}
#[test]
fn border_only_block_produces_stroke_paint() {
let mut sheet = RichTextStyleSheet::empty();
sheet.set(
"paragraph",
StyleDelta {
border_color: Some(ThemeColor::Ink),
border_width: Some(RichMargin::all(pt(1.0))),
..StyleDelta::empty()
},
);
let run = shape(&sheet, "content");
let paints = run.block_paints();
assert_eq!(paints.len(), 1);
assert!(paints[0].border.is_some());
assert!(paints[0].background.is_none());
}
#[test]
fn zero_width_border_collapses_to_no_paint() {
let mut sheet = RichTextStyleSheet::empty();
sheet.set(
"paragraph",
StyleDelta {
border_color: Some(ThemeColor::Ink),
border_width: Some(RichMargin::all(pt(0.0))),
..StyleDelta::empty()
},
);
let run = shape(&sheet, "content");
assert!(run.block_paints().is_empty());
}
#[test]
fn left_only_border_produces_left_edge_paint() {
let mut sheet = RichTextStyleSheet::empty();
sheet.set(
"paragraph",
StyleDelta {
border_color: Some(ThemeColor::Ink),
border_width: Some(RichMargin::new(pt(0.0), pt(0.0), pt(0.0), pt(4.0))),
..StyleDelta::empty()
},
);
let run = shape(&sheet, "content");
let paints = run.block_paints();
assert_eq!(paints.len(), 1);
let border = paints[0].border.as_ref().expect("expected a border");
assert!(
!border.is_uniform(),
"border should be per-side, not uniform"
);
assert!(border.widths_px[0].abs() < 0.1, "top should be 0");
assert!(border.widths_px[1].abs() < 0.1, "right should be 0");
assert!(border.widths_px[2].abs() < 0.1, "bottom should be 0");
assert!(border.widths_px[3] > 3.0, "left should be ~4pt in px");
}
#[test]
fn own_padding_inflates_outer_rect() {
let mut sheet = RichTextStyleSheet::empty();
sheet.set(
"paragraph",
StyleDelta {
background: Some(ThemeColor::Fixed(Color::from_rgba8(200, 200, 200, 255))),
padding: Some(RichMargin::all(pt(10.0))),
..StyleDelta::empty()
},
);
let run = shape(&sheet, "hello world");
let paints = run.block_paints();
assert_eq!(paints.len(), 1);
let p = &paints[0];
assert!(
p.outer_rect.x0.abs() < 0.5,
"expected outer.x0 ≈ 0 (padding pulls rect back to origin), got {}",
p.outer_rect.x0
);
assert!(
p.outer_rect.y0.abs() < 0.5,
"expected outer.y0 ≈ 0, got {}",
p.outer_rect.y0
);
}
}