use cosmic_text::{Attrs, Buffer, Family, Metrics, Shaping, Style, Weight};
use crate::error::{Error, Result};
use crate::font::FontHandle;
use std::collections::HashMap;
use crate::model::{
Align, Block, BlockImage, Cell, Color, Column, Columns, Document, FontRole, ImageBorder,
ImageSource, Inline, Length, List, ListKind, Progress, Table, TextStyle,
};
use crate::theme::{RenderOptions, Theme};
const MAX_DIM: u32 = 30_000;
const MAX_AREA: u64 = 40_000_000;
const MIN_PX: f32 = 1.0;
const MAX_PX: f32 = 2_000.0;
const MAX_IMAGE_BYTES: u64 = 32 * 1024 * 1024;
fn safe_px(px: f32) -> f32 {
if px.is_finite() {
px.clamp(MIN_PX, MAX_PX)
} else {
MIN_PX
}
}
pub(crate) struct Layout {
pub items: Vec<DisplayItem>,
pub width_px: u32,
pub height_px: u32,
pub images: Vec<image::RgbaImage>,
}
pub(crate) enum DisplayItem {
Glyphs(Vec<PlacedGlyph>),
Rect { x: f32, y: f32, w: f32, h: f32, color: Color, radius: f32, layer: RectLayer },
Image { x: f32, y: f32, w: f32, h: f32, src: usize, radius: f32 },
Shadow(ShadowItem),
StrokeRect(StrokeItem),
Ellipse { cx: f32, cy: f32, rx: f32, ry: f32, width: f32, color: Color },
}
pub(crate) struct ShadowItem {
pub x: f32,
pub y: f32,
pub w: f32,
pub h: f32,
pub radius: f32,
pub blur: f32,
pub color: Color,
pub under: bool,
}
pub(crate) struct StrokeItem {
pub x: f32,
pub y: f32,
pub w: f32,
pub h: f32,
pub radius: f32,
pub width: f32,
pub color: Color,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum RectLayer {
Under,
Mid,
Over,
}
pub(crate) struct PlacedGlyph {
pub cache_key: cosmic_text::CacheKey,
pub x: i32,
pub y: i32,
pub color: Color,
pub shadow: Option<GlyphShadow>,
}
#[derive(Clone, Copy, Debug)]
pub(crate) struct GlyphShadow {
pub dx: i32,
pub dy: i32,
pub blur: f32,
pub color: Color,
}
pub(crate) fn layout_document(doc: &Document, opts: &RenderOptions) -> Result<Layout> {
let sc = opts.scale;
if opts.width <= 0.0 || sc <= 0.0 || !opts.width.is_finite() || !sc.is_finite() {
return Err(Error::Layout("宽度 / scale 必须为正且有限".into()));
}
let pad = opts.padding;
if [pad.top, pad.right, pad.bottom, pad.left].iter().any(|v| !v.is_finite() || *v < 0.0) {
return Err(Error::Layout("内边距必须为非负且有限".into()));
}
let content_w = ((opts.width - pad.left - pad.right) * sc).max(1.0);
let x_left = pad.left * sc;
let mut ctx = LayoutCtx { opts, sc, items: Vec::new(), images: Vec::new(), y: pad.top * sc };
if let Some(c) = &opts.header {
ctx.chrome_bar(c, x_left, content_w, true);
}
for (i, block) in doc.blocks.iter().enumerate() {
ctx.block(block, x_left, content_w, i == 0);
}
let mut footer_band: Option<(usize, f32)> = None;
if let Some(c) = &opts.footer {
let idx = ctx.items.len();
if let Some(band_top) = ctx.chrome_bar(c, x_left, content_w, false) {
footer_band = Some((idx, band_top));
}
}
let height_f = ctx.y + pad.bottom * sc;
if !height_f.is_finite() {
return Err(Error::Layout("内容高度非有限(检查字号 / 图宽 / 内边距)".into()));
}
if let Some((idx, band_top)) = footer_band {
let color = opts.footer.as_ref().and_then(|c| c.band).unwrap_or(opts.theme.border);
ctx.items.insert(
idx,
DisplayItem::Rect {
x: 0.0,
y: band_top,
w: opts.width * sc,
h: (height_f - band_top).max(0.0),
color,
radius: 0.0,
layer: RectLayer::Under,
},
);
}
let width_px = (opts.width * sc).round().max(1.0) as u32;
let height_px = height_f.round().max(1.0) as u32;
if width_px > MAX_DIM || height_px > MAX_DIM || width_px as u64 * height_px as u64 > MAX_AREA {
return Err(Error::Layout(format!(
"画布过大:{width_px}×{height_px}(超出 {MAX_DIM}px 单边 / {MAX_AREA} 像素上限,调小 width / scale 或拆分内容)"
)));
}
Ok(Layout { items: ctx.items, width_px, height_px, images: ctx.images })
}
struct LayoutCtx<'a> {
opts: &'a RenderOptions,
sc: f32,
items: Vec<DisplayItem>,
images: Vec<image::RgbaImage>,
y: f32,
}
impl LayoutCtx<'_> {
fn block(&mut self, b: &Block, x: f32, w: f32, first: bool) {
let base = self.opts.theme.base_size;
let sc = self.sc;
match b {
Block::Heading { level, inlines, align } => {
let k = self.opts.theme.heading_scale[(*level as usize).clamp(1, 6) - 1];
let before = if first { 0.0 } else { base * sc * 0.6 };
self.text_block(inlines, *align, base * k, true, x, w, before, base * sc * 0.3);
}
Block::Paragraph { inlines, align } => {
self.text_block(inlines, *align, base, false, x, w, 0.0, base * sc * 0.55);
}
Block::Divider => {
self.y += base * sc * 0.45;
let th = (2.0 * sc).max(1.0);
self.items.push(DisplayItem::Rect {
x,
y: self.y,
w,
h: th,
color: self.opts.theme.muted,
radius: 0.0,
layer: RectLayer::Under,
});
self.y += th + base * sc * 0.45;
}
Block::Quote(inner) => self.quote(inner, x, w),
Block::Code { lang, text } => self.code(lang.as_deref(), text, x, w),
Block::List(list) => self.list(list, x, w),
Block::Image(bi) => self.image(bi, x, w),
Block::Columns(c) => self.columns(c, x, w),
Block::Table(t) => self.table(t, x, w),
Block::Progress(p) => self.progress(p, x, w),
Block::Panel(p) => {
self.panel(p, x, w);
}
}
}
#[allow(clippy::too_many_arguments)]
fn text_block(
&mut self,
inlines: &[Inline],
align: Align,
base_logical: f32,
bold: bool,
x: f32,
w: f32,
before: f32,
after: f32,
) {
self.y += before;
let h = self.emit_text(inlines, align, base_logical, bold, x, self.y, w);
self.y += h + after;
}
#[allow(clippy::too_many_arguments)]
fn emit_text(
&mut self,
inlines: &[Inline],
align: Align,
base_logical: f32,
bold: bool,
x: f32,
y: f32,
w: f32,
) -> f32 {
let (glyphs, decos, h) = shape_text(
&self.opts.fonts,
&self.opts.theme,
inlines,
align,
base_logical,
bold,
self.sc,
w,
x,
y,
);
self.items.extend(decos);
if !glyphs.is_empty() {
self.items.push(DisplayItem::Glyphs(glyphs));
}
h
}
fn panel(&mut self, p: &crate::model::Panel, x: f32, w: f32) -> (usize, usize) {
let (base, sc) = (self.opts.theme.base_size, self.sc);
let d = &p.decor;
let pad = safe_px(d.pad.unwrap_or(base * 0.6)) * sc;
let radius = safe_px(d.radius.unwrap_or(12.0)) * sc;
let plain = d.bg.is_none() && d.border.is_none();
let bg = if plain { Some(self.opts.theme.code_bg) } else { d.bg };
let border = if plain {
Some(ImageBorder { width: 1.5, color: self.opts.theme.border })
} else {
d.border
};
self.y += base * sc * 0.35;
let top = self.y;
let insert_at = self.items.len();
self.y += pad;
for (i, b) in p.blocks.iter().enumerate() {
self.block(b, x + pad, (w - 2.0 * pad).max(1.0), i == 0);
}
let h = (self.y + pad - top).max(2.0 * pad);
let mut decor: Vec<DisplayItem> = Vec::new();
if let Some(sh) = &d.shadow {
decor.push(DisplayItem::Shadow(ShadowItem {
x: x + sh.dx * sc,
y: top + sh.dy * sc,
w,
h,
radius,
blur: (sh.blur * sc).max(0.0),
color: sh.color,
under: true,
}));
}
if let Some(c) = bg {
decor.push(DisplayItem::Rect {
x,
y: top,
w,
h,
color: c,
radius,
layer: RectLayer::Under,
});
}
if let Some(b) = border {
decor.push(DisplayItem::StrokeRect(StrokeItem {
x,
y: top,
w,
h,
radius,
width: (b.width * sc).max(1.0),
color: b.color,
}));
}
let count = decor.len();
self.items.splice(insert_at..insert_at, decor);
self.y = top + h + base * sc * 0.35;
(insert_at, count)
}
fn quote(&mut self, inner: &[Block], x: f32, w: f32) {
let (base, sc) = (self.opts.theme.base_size, self.sc);
self.y += base * sc * 0.3;
let bar_w = (4.0 * sc).max(2.0);
let gap = base * sc * 0.45;
let ix = x + bar_w + gap;
let iw = (w - bar_w - gap).max(1.0);
let y0 = self.y;
for (i, b) in inner.iter().enumerate() {
self.block(b, ix, iw, i == 0);
}
let h = (self.y - y0).max(0.0);
self.items.push(DisplayItem::Rect {
x,
y: y0,
w: bar_w,
h,
color: self.opts.theme.accent,
radius: bar_w / 2.0,
layer: RectLayer::Under,
});
self.y += base * sc * 0.3;
}
fn code(&mut self, lang: Option<&str>, text: &str, x: f32, w: f32) {
let (base, sc) = (self.opts.theme.base_size, self.sc);
self.y += base * sc * 0.4;
let pad = base * sc * 0.45;
let ix = x + pad;
let iw = (w - 2.0 * pad).max(1.0);
let y_bg = self.y;
let mut y_text = y_bg + pad;
if let Some(l) = lang.map(str::trim).filter(|l| !l.is_empty()) {
let tag = vec![Inline::Text {
text: l.to_string(),
style: TextStyle {
font: FontRole::Mono,
color: Some(self.opts.theme.muted),
size: 0.72,
..TextStyle::default()
},
}];
let th = self.emit_text(&tag, Align::Right, base, false, ix, y_bg + pad * 0.5, iw);
y_text = y_bg + pad * 0.5 + th + base * sc * 0.1;
}
let inlines = vec![Inline::Text {
text: text.to_string(),
style: TextStyle {
font: FontRole::Mono,
color: Some(self.opts.theme.code_text),
..TextStyle::default()
},
}];
let h = self.emit_text(&inlines, Align::Left, base, false, ix, y_text, iw);
let bg_h = y_text + h + pad - y_bg;
self.items.push(DisplayItem::Rect {
x,
y: y_bg,
w,
h: bg_h,
color: self.opts.theme.code_bg,
radius: 8.0 * sc,
layer: RectLayer::Under,
});
self.y = y_bg + bg_h + base * sc * 0.4;
}
fn list(&mut self, list: &List, x: f32, w: f32) {
let (base, sc) = (self.opts.theme.base_size, self.sc);
self.y += base * sc * 0.2;
let markers: Vec<Vec<Inline>> = list
.items
.iter()
.enumerate()
.map(|(idx, item)| vec![marker_inline(list, idx, item.check, &self.opts.theme)])
.collect();
let zone = markers
.iter()
.map(|m| measure_natural(&self.opts.fonts, &self.opts.theme, m, base, false, sc))
.fold(base * sc, f32::max)
+ 1.0; let gap = base * sc * 0.5;
let gutter = zone + gap;
let ix = x + gutter;
let iw = (w - gutter).max(1.0);
for (item, marker) in list.items.iter().zip(&markers) {
let y_item = self.y;
self.emit_text(marker, Align::Right, base, false, x, y_item, zone);
for (i, b) in item.blocks.iter().enumerate() {
self.block(b, ix, iw, i == 0);
}
if self.y <= y_item {
self.y = y_item + base * sc * self.opts.theme.line_height;
}
}
self.y += base * sc * 0.2;
}
fn image(&mut self, bi: &BlockImage, x: f32, w: f32) {
let (base, sc) = (self.opts.theme.base_size, self.sc);
self.y += base * sc * 0.3;
let Some(rgba) = decode_image(&bi.src, &self.opts.images) else {
let ph = vec![Inline::Text {
text: "⟨图片缺失⟩".to_string(),
style: TextStyle { color: Some(self.opts.theme.muted), ..TextStyle::default() },
}];
let h = self.emit_text(&ph, Align::Left, base, false, x, self.y, w);
self.y += h + base * sc * 0.4;
return;
};
let (iw, ih) = (rgba.width() as f32, rgba.height() as f32);
let req = match bi.width {
Some(Length::Px(p)) => p * sc,
Some(Length::Percent(pct)) => w * (pct / 100.0),
None => iw.min(w), };
let dw = if req.is_finite() { req } else { iw.min(w) }.clamp(1.0, w.max(1.0));
let dh = if iw > 0.0 { dw * ih / iw } else { dw };
let ix = match bi.align {
Align::Center => x + (w - dw) / 2.0,
Align::Right => x + (w - dw),
_ => x,
};
let src = self.images.len();
self.images.push(rgba);
let radius = if bi.decor.radius.is_finite() { (bi.decor.radius * sc).max(0.0) } else { 0.0 };
if let Some(sh) = &bi.decor.shadow {
self.items.push(DisplayItem::Shadow(ShadowItem {
x: ix + sh.dx * sc,
y: self.y + sh.dy * sc,
w: dw,
h: dh,
radius,
blur: (sh.blur * sc).max(0.0),
color: sh.color,
under: false,
}));
}
self.items.push(DisplayItem::Image { x: ix, y: self.y, w: dw, h: dh, src, radius });
self.image_overlay(&bi.decor, ix, self.y, dw, dh, radius);
self.y += dh;
if let Some(cap) = &bi.caption {
self.y += base * sc * 0.2;
let cap_w = dw.max(base * sc * 4.0).min(w.max(1.0));
let cap_x = (ix + dw / 2.0 - cap_w / 2.0).clamp(x, x + (w - cap_w).max(0.0));
let h = self.emit_text(cap, Align::Center, base * 0.85, false, cap_x, self.y, cap_w);
self.y += h;
}
self.y += base * sc * 0.4;
}
fn chrome_bar(&mut self, c: &crate::theme::PageChrome, x: f32, w: f32, top: bool) -> Option<f32> {
let (base, sc) = (self.opts.theme.base_size, self.sc);
let px = base * c.size;
let ink = c.color.unwrap_or(self.opts.theme.muted);
let hairline = (1.0 * sc).max(1.0);
let line_pad = px * sc * (self.opts.theme.line_height - 1.0) / 2.0;
if top {
let h = self.chrome_line(c, ink, px, x, w);
self.y += h + (self.opts.padding.top * sc - line_pad).max(base * sc * 0.35);
if c.rule {
self.items.push(DisplayItem::Rect {
x,
y: self.y,
w,
h: hairline,
color: self.opts.theme.border,
radius: 0.0,
layer: RectLayer::Under,
});
self.y += hairline;
}
self.y += base * sc * 0.5;
None
} else if c.band.is_some() {
self.y += base * sc * 0.6;
let band_top = self.y;
self.y += (self.opts.padding.bottom * sc - line_pad).max(base * sc * 0.5);
let h = self.chrome_line(c, ink, px, x, w);
self.y += h;
Some(band_top)
} else {
self.y += base * sc * 0.6;
if c.rule {
self.items.push(DisplayItem::Rect {
x,
y: self.y,
w,
h: hairline,
color: self.opts.theme.border,
radius: 0.0,
layer: RectLayer::Under,
});
self.y += hairline;
}
self.y += (self.opts.padding.bottom * sc - line_pad).max(base * sc * 0.35);
let h = self.chrome_line(c, ink, px, x, w);
self.y += h;
None
}
}
fn chrome_line(
&mut self,
c: &crate::theme::PageChrome,
ink: Color,
px: f32,
x: f32,
w: f32,
) -> f32 {
let fill = |src: &[Inline]| -> Vec<Inline> {
src.iter()
.cloned()
.map(|mut i| {
if let Inline::Text { style, .. } = &mut i {
if style.color.is_none() {
style.color = Some(ink);
}
}
i
})
.collect()
};
let main = fill(&c.inlines);
let h1 = self.emit_text(&main, c.align, px, false, x, self.y, w);
let h2 = match &c.trailing {
Some(t) => {
let t = fill(t);
self.emit_text(&t, Align::Right, px, false, x, self.y, w)
}
None => 0.0,
};
h1.max(h2)
}
fn image_overlay(
&mut self,
decor: &crate::model::ImageDecor,
ix: f32,
iy: f32,
dw: f32,
dh: f32,
radius: f32,
) {
let (base, sc) = (self.opts.theme.base_size, self.sc);
let line_mult = self.opts.theme.line_height;
if let Some(b) = &decor.border {
self.items.push(DisplayItem::StrokeRect(StrokeItem {
x: ix,
y: iy,
w: dw,
h: dh,
radius,
width: (b.width * sc).max(1.0),
color: b.color,
}));
}
if let Some(badge) = &decor.badge {
let px = base * badge.size; let inl = [Inline::Text {
text: badge.text.clone(),
style: TextStyle { color: Some(badge.fg), ..TextStyle::default() },
}];
let tw = measure_natural(&self.opts.fonts, &self.opts.theme, &inl, px, false, sc);
let line_h = px * sc * line_mult;
let (pad_x, pad_y) = (px * sc * 0.45, px * sc * 0.12);
let (bw, bh) = (tw + pad_x * 2.0, line_h + pad_y * 2.0);
let margin = px * sc * 0.5;
let (bx, by) = anchor_pos(badge.anchor, (ix, iy, dw, dh), (bw, bh), margin);
self.items.push(DisplayItem::Rect {
x: bx,
y: by,
w: bw,
h: bh,
color: badge.bg,
radius: bh * 0.25,
layer: RectLayer::Mid,
});
self.emit_text(&inl, Align::Left, px, false, bx + pad_x, by + pad_y, tw + 2.0);
}
if let Some(wm) = &decor.watermark {
let px = base * wm.size;
let inl = [Inline::Text {
text: wm.text.clone(),
style: TextStyle { color: Some(wm.color), ..TextStyle::default() },
}];
let tw = measure_natural(&self.opts.fonts, &self.opts.theme, &inl, px, false, sc);
let line_h = px * sc * line_mult;
let margin = px * sc * 0.5;
let (wx, wy) = anchor_pos(wm.anchor, (ix, iy, dw, dh), (tw, line_h), margin);
self.emit_text(&inl, Align::Left, px, false, wx, wy, tw + 2.0);
}
}
fn columns(&mut self, c: &Columns, x: f32, w: f32) {
let (base, sc) = (self.opts.theme.base_size, self.sc);
let cols: Vec<&Column> = c.cols.iter().filter(|col| col.weight > 0.0).collect();
if cols.is_empty() {
return;
}
self.y += base * sc * 0.3;
let gap = c.gap.map(|g| g * sc).unwrap_or(base * sc * 0.6);
let avail = (w - gap * (cols.len() - 1) as f32).max(1.0);
let total_w: f32 = cols.iter().map(|col| col.weight).sum();
let y_top = self.y;
let mut cx = x;
let mut max_h = 0.0f32;
let mut stretch: Vec<(usize, usize, f32)> = Vec::new();
for col in cols {
let cw = (avail * col.weight / total_w).max(1.0);
let offset = self.items.len();
let (items, images, y_bottom) = self.sub_layout(&col.blocks, cx, y_top, cw);
self.merge(items, images);
if let [Block::Panel(p)] = col.blocks.as_slice() {
let plain = p.decor.bg.is_none() && p.decor.border.is_none();
let n = usize::from(p.decor.shadow.is_some())
+ usize::from(plain || p.decor.bg.is_some())
+ usize::from(plain || p.decor.border.is_some());
let shadow_dy = p.decor.shadow.as_ref().map(|sh| sh.dy * sc).unwrap_or(0.0);
stretch.push((offset, n, shadow_dy));
}
max_h = max_h.max(y_bottom - y_top);
cx += cw + gap;
}
let target_bottom = y_top + max_h - base * sc * 0.35;
for (at, n, shadow_dy) in stretch {
for item in self.items.iter_mut().skip(at).take(n) {
match item {
DisplayItem::Shadow(s) => s.h = (target_bottom + shadow_dy - s.y).max(s.h),
DisplayItem::Rect { y, h, .. } => *h = (target_bottom - *y).max(*h),
DisplayItem::StrokeRect(s) => s.h = (target_bottom - s.y).max(s.h),
_ => {}
}
}
}
self.y = y_top + max_h + base * sc * 0.3;
}
fn sub_layout(
&self,
blocks: &[Block],
x: f32,
y_top: f32,
w: f32,
) -> (Vec<DisplayItem>, Vec<image::RgbaImage>, f32) {
let mut sub = LayoutCtx {
opts: self.opts,
sc: self.sc,
items: Vec::new(),
images: Vec::new(),
y: y_top,
};
for (i, b) in blocks.iter().enumerate() {
sub.block(b, x, w, i == 0);
}
(sub.items, sub.images, sub.y)
}
fn merge(&mut self, items: Vec<DisplayItem>, images: Vec<image::RgbaImage>) {
let offset = self.images.len();
self.images.extend(images);
for mut it in items {
if let DisplayItem::Image { src, .. } = &mut it {
*src += offset;
}
self.items.push(it);
}
}
fn table(&mut self, t: &Table, x: f32, w: f32) {
let (base, sc) = (self.opts.theme.base_size, self.sc);
let ncols = t
.header
.as_ref()
.map(|h| h.len())
.into_iter()
.chain(t.rows.iter().map(|r| r.len()))
.chain(std::iter::once(t.cols.len()))
.max()
.unwrap_or(0);
if ncols == 0 {
return;
}
self.y += base * sc * 0.3;
let pad = t.style.pad_x.unwrap_or(base * 0.32) * sc;
let pad_v = t.style.pad_y.unwrap_or(base * 0.26) * sc;
let widths = self.solve_widths(t, ncols, w, pad);
let table_w: f32 = widths.iter().sum();
let mut col_x = Vec::with_capacity(ncols);
let mut cx = x;
for &cw in &widths {
col_x.push(cx);
cx += cw;
}
let table_top = self.y;
let mut inner = Vec::new();
if let Some(h) = &t.header {
self.table_row(h, t, &widths, &col_x, x, table_w, true, pad, pad_v);
inner.push(self.y);
}
for row in &t.rows {
self.table_row(row, t, &widths, &col_x, x, table_w, false, pad, pad_v);
inner.push(self.y);
}
let table_bottom = self.y;
inner.pop();
let line = (1.0 * sc).max(1.0);
let border = self.opts.theme.border;
let grid = t.style.grid;
if grid.horizontal {
for &yb in &inner {
self.items.push(hrule(x, yb, table_w, line, border));
}
}
if grid.outer {
for yb in [table_top, table_bottom] {
self.items.push(hrule(x, yb, table_w, line, border));
}
}
if grid.vertical {
for &vx in col_x.iter().skip(1) {
self.items.push(vrule(vx, table_top, table_bottom, line, border));
}
}
if grid.outer {
for vx in [x, x + table_w] {
self.items.push(vrule(vx, table_top, table_bottom, line, border));
}
}
self.y += base * sc * 0.3;
}
fn progress(&mut self, p: &Progress, x: f32, w: f32) {
let (base, sc) = (self.opts.theme.base_size, self.sc);
let bw = match p.width {
Some(Length::Px(v)) => (safe_px(v) * sc).clamp(1.0, w.max(1.0)),
Some(Length::Percent(pct)) => {
let pct = if pct.is_finite() { pct.clamp(0.0, 100.0) } else { 100.0 };
(w * pct / 100.0).clamp(1.0, w.max(1.0))
}
None => w.max(1.0),
};
let bx = match p.align {
Align::Center => x + (w - bw) / 2.0,
Align::Right => x + w - bw,
Align::Left | Align::Justify => x,
};
let h = safe_px(p.height) * sc;
let r = match p.radius {
Some(v) if v.is_finite() && v > 0.0 => (v * sc).min(h / 2.0),
Some(_) => 0.0,
None => h / 2.0,
};
let value = if p.value.is_finite() { p.value.clamp(0.0, 1.0) } else { 0.0 };
self.y += base * sc * 0.3;
let track = p.track.unwrap_or(self.opts.theme.border);
self.items.push(DisplayItem::Rect {
x: bx,
y: self.y,
w: bw,
h,
color: track,
radius: r,
layer: RectLayer::Under,
});
if value > 0.0 {
let fw = (bw * value).max((2.0 * r).min(bw));
let fill = p.fill.unwrap_or(self.opts.theme.accent);
self.items.push(DisplayItem::Rect {
x: bx,
y: self.y,
w: fw,
h,
color: fill,
radius: r,
layer: RectLayer::Under,
});
}
self.y += h + base * sc * 0.55;
}
#[allow(clippy::too_many_arguments)]
fn table_row(
&mut self,
cells: &[Cell],
t: &Table,
widths: &[f32],
col_x: &[f32],
table_x: f32,
table_w: f32,
header: bool,
pad: f32,
pad_v: f32,
) {
let (base, sc) = (self.opts.theme.base_size, self.sc);
let row_top = self.y;
let y_text = row_top + pad_v;
let mut content_h = 0.0f32;
for (k, cell) in cells.iter().enumerate() {
if k >= widths.len() {
break;
}
let align = t.cols.get(k).map(|c| c.align).unwrap_or(Align::Left);
let cwidth = (widths[k] - 2.0 * pad).max(1.0);
let h = self.emit_text(&cell.inlines, align, base, header, col_x[k] + pad, y_text, cwidth);
content_h = content_h.max(h);
}
if content_h <= 0.0 {
content_h = base * sc * self.opts.theme.line_height;
}
let row_bottom = y_text + content_h + pad_v;
if header && t.style.header_fill {
self.items.push(DisplayItem::Rect {
x: table_x,
y: row_top,
w: table_w,
h: row_bottom - row_top,
color: self.opts.theme.code_bg,
radius: 0.0,
layer: RectLayer::Under,
});
}
for (k, cell) in cells.iter().enumerate() {
if k >= widths.len() {
break;
}
if let Some(bg) = cell.bg {
self.items.push(DisplayItem::Rect {
x: col_x[k],
y: row_top,
w: widths[k],
h: row_bottom - row_top,
color: bg,
radius: 0.0,
layer: RectLayer::Under,
});
}
}
self.y = row_bottom;
}
fn solve_widths(&self, t: &Table, ncols: usize, avail_w: f32, pad: f32) -> Vec<f32> {
let (base, sc) = (self.opts.theme.base_size, self.sc);
let min_w = 2.0 * pad + 1.0;
let mut natural = vec![0f32; ncols];
let measure = |cells: &[Cell], bold: bool, natural: &mut [f32]| {
for (k, cell) in cells.iter().enumerate() {
if k < ncols {
let nw =
measure_natural(&self.opts.fonts, &self.opts.theme, &cell.inlines, base, bold, sc);
natural[k] = natural[k].max(nw);
}
}
};
if let Some(h) = &t.header {
measure(h, true, &mut natural);
}
for row in &t.rows {
measure(row, false, &mut natural);
}
let mut widths = vec![0f32; ncols];
let mut auto = Vec::new();
let (mut auto_sum, mut fixed_sum) = (0.0f32, 0.0f32);
for (k, wid) in widths.iter_mut().enumerate() {
match t.cols.get(k).and_then(|c| c.width) {
Some(Length::Px(p)) if p.is_finite() => {
*wid = (p * sc).max(min_w);
fixed_sum += *wid;
}
Some(Length::Percent(pct)) if pct.is_finite() => {
*wid = (avail_w * pct / 100.0).clamp(min_w, avail_w.max(min_w));
fixed_sum += *wid;
}
_ => {
*wid = natural[k] + 2.0 * pad;
auto.push(k);
auto_sum += *wid;
}
}
}
let total: f32 = widths.iter().sum();
if total > avail_w && !auto.is_empty() && auto_sum > 0.0 {
let remaining = (avail_w - fixed_sum).max(min_w * auto.len() as f32);
for &k in &auto {
widths[k] = (widths[k] / auto_sum * remaining).max(min_w);
}
}
let total: f32 = widths.iter().sum();
if total > avail_w && total > 0.0 {
let factor = avail_w / total;
for wid in widths.iter_mut() {
*wid = (*wid * factor).max(1.0);
}
}
let total: f32 = widths.iter().sum();
if t.style.expand && total > 0.0 && total < avail_w {
if !auto.is_empty() {
let auto_total: f32 = auto.iter().map(|&k| widths[k]).sum();
if auto_total > 0.0 {
let extra = avail_w - total;
for &k in &auto {
widths[k] += extra * (widths[k] / auto_total);
}
}
} else {
let factor = avail_w / total;
for wid in widths.iter_mut() {
*wid *= factor;
}
}
}
widths
}
}
fn marker_inline(list: &List, idx: usize, check: Option<bool>, theme: &Theme) -> Inline {
let (text, color) = match check {
Some(true) => ("✓".to_string(), theme.accent),
Some(false) => ("□".to_string(), theme.muted),
None => match list.kind {
ListKind::Unordered => ("•".to_string(), theme.accent),
ListKind::Ordered => (format!("{}.", list.start as usize + idx), theme.accent),
},
};
Inline::Text { text, style: TextStyle { color: Some(color), ..TextStyle::default() } }
}
fn decode_image(src: &ImageSource, images: &HashMap<String, Vec<u8>>) -> Option<image::RgbaImage> {
let bytes: std::borrow::Cow<[u8]> = match src {
ImageSource::Bytes(b) => std::borrow::Cow::Borrowed(b),
ImageSource::Named(n) => std::borrow::Cow::Borrowed(images.get(n)?.as_slice()),
ImageSource::Path(p) => std::borrow::Cow::Owned(read_image_file(p)?),
};
image::load_from_memory(&bytes).ok().map(|i| i.to_rgba8())
}
fn read_image_file(p: &std::path::Path) -> Option<Vec<u8>> {
use std::io::Read;
if !std::fs::metadata(p).ok()?.is_file() {
return None;
}
let mut buf = Vec::new();
std::fs::File::open(p).ok()?.take(MAX_IMAGE_BYTES).read_to_end(&mut buf).ok()?;
Some(buf)
}
struct SpanDeco {
underline: bool,
strike: bool,
highlight: Option<Color>,
code_bg: Option<Color>,
ring: Option<RingDeco>,
dot: Option<DotDeco>,
ink: Color,
shadow: Option<GlyphShadow>,
}
struct RingDeco {
color: Color,
rx: Option<f32>,
ry: Option<f32>,
width: Option<f32>,
each: bool,
}
struct DotDeco {
color: Color,
radius: Option<f32>,
each: bool,
}
#[allow(clippy::too_many_arguments)]
fn shape_text(
fonts: &FontHandle,
theme: &Theme,
inlines: &[Inline],
align: Align,
base_logical: f32,
base_bold: bool,
sc: f32,
width: f32,
x_left: f32,
y_top: f32,
) -> (Vec<PlacedGlyph>, Vec<DisplayItem>, f32) {
let line_mult = theme.line_height;
let default_px = safe_px(base_logical * sc);
let default_attrs = base_attrs()
.family(Family::Name(&theme.font_sans))
.color(to_cosmic(theme.text))
.metrics(Metrics::new(default_px, default_px * line_mult))
.metadata(usize::MAX);
use crate::model::AsideSide;
let side_of = |i: &Inline| match i {
Inline::Text { style, .. } => style.aside,
_ => None,
};
let mut main_part: Vec<Inline> = Vec::new();
let mut asides: [Vec<Inline>; 2] = [Vec::new(), Vec::new()]; if inlines.iter().any(|i| side_of(i).is_some()) {
for i in inlines {
match side_of(i) {
Some(AsideSide::Left) => asides[0].push(i.clone()),
Some(AsideSide::Right) => asides[1].push(i.clone()),
None => main_part.push(i.clone()),
}
}
if main_part.is_empty() {
main_part = inlines.to_vec();
asides = [Vec::new(), Vec::new()];
}
} else {
main_part = inlines.to_vec();
}
let (spans, decos) = build_spans(&main_part, theme, base_logical, base_bold, sc, &default_attrs);
fonts.with_system(|fs| {
let mut buf = Buffer::new(fs, Metrics::new(default_px, default_px * line_mult));
buf.set_size(Some(width), None);
buf.set_rich_text(
spans.iter().map(|(t, a)| (*t, a.clone())),
&default_attrs,
Shaping::Advanced,
Some(align_to_cosmic(align)),
);
buf.shape_until_scroll(fs, false);
let xi = x_left.round() as i32;
let mut glyphs = Vec::new();
let mut deco_rects = Vec::new();
let mut height = 0.0f32;
let mut first_line: Option<(f32, f32)> = None; let mut last_line: Option<(f32, f32)> = None; for run in buf.layout_runs() {
let (mut lo, mut hi) = (f32::MAX, f32::MIN);
for g in run.glyphs {
let p = g.physical((0.0, 0.0), 1.0);
let color = g.color_opt.map(from_cosmic).unwrap_or(theme.text);
glyphs.push(PlacedGlyph {
cache_key: p.cache_key,
x: xi + p.x,
y: (y_top + run.line_y).round() as i32 + p.y,
color,
shadow: decos.get(g.metadata).and_then(|d| d.shadow),
});
lo = lo.min(g.x);
hi = hi.max(g.x + g.w);
}
collect_decos(&run, &decos, x_left, y_top, &mut deco_rects);
height = height.max(run.line_top + run.line_height);
if !run.glyphs.is_empty() {
first_line.get_or_insert((run.line_y, lo));
last_line = Some((run.line_y, hi));
}
}
let gap = default_px * 0.5;
for (k, aside) in asides.iter().enumerate() {
if aside.is_empty() {
continue;
}
let anchor = if k == 0 { first_line } else { last_line };
let Some((anchor_y, edge)) = anchor else { continue };
let (aspans, adecos) =
build_spans(aside, theme, base_logical, base_bold, sc, &default_attrs);
let mut abuf = Buffer::new(fs, Metrics::new(default_px, default_px * line_mult));
abuf.set_size(None, None);
abuf.set_rich_text(
aspans.iter().map(|(t, a)| (*t, a.clone())),
&default_attrs,
Shaping::Advanced,
None,
);
abuf.shape_until_scroll(fs, false);
let aw = abuf.layout_runs().map(|r| r.line_w).fold(0.0, f32::max);
let ax_left = if k == 0 { x_left + edge - gap - aw } else { x_left + edge + gap };
let axi = ax_left.round() as i32;
let Some(first_y) = abuf.layout_runs().next().map(|r| r.line_y) else { continue };
let ay_top = y_top + anchor_y - first_y;
for run in abuf.layout_runs() {
for g in run.glyphs {
let p = g.physical((0.0, 0.0), 1.0);
let color = g.color_opt.map(from_cosmic).unwrap_or(theme.text);
glyphs.push(PlacedGlyph {
cache_key: p.cache_key,
x: axi + p.x,
y: (ay_top + run.line_y).round() as i32 + p.y,
color,
shadow: adecos.get(g.metadata).and_then(|d| d.shadow),
});
}
collect_decos(&run, &adecos, ax_left, ay_top, &mut deco_rects);
}
}
(glyphs, deco_rects, height)
})
}
#[allow(clippy::type_complexity)]
fn build_spans<'a>(
inlines: &'a [Inline],
theme: &'a Theme,
base_logical: f32,
base_bold: bool,
sc: f32,
default_attrs: &Attrs<'a>,
) -> (Vec<(&'a str, Attrs<'a>)>, Vec<SpanDeco>) {
let line_mult = theme.line_height;
let mut spans: Vec<(&str, Attrs)> = Vec::new();
let mut decos: Vec<SpanDeco> = Vec::new();
for inline in inlines {
match inline {
Inline::Text { text, style } => {
let idx = decos.len();
let px = safe_px(base_logical * style.size * sc);
let fallback = if style.link { theme.accent } else { theme.text };
let ink = style.color.unwrap_or(fallback);
let mut a = base_attrs()
.family(family_of(&style.font, theme))
.color(to_cosmic(ink))
.metrics(Metrics::new(px, px * line_mult))
.metadata(idx);
let mut weight = match style.weight {
Some(w) => Weight(w),
None if base_bold => Weight::BOLD,
None => Weight::NORMAL,
};
if matches!(style.font, FontRole::Kai) {
weight = Weight(weight.0.clamp(300, 500));
}
a = a.weight(weight);
if style.italic {
a = a.style(Style::Italic);
}
for (seg, emoji) in emoji_runs(text) {
if emoji {
spans.push((seg, a.clone().family(Family::Name(&theme.font_emoji))));
} else {
spans.push((seg, a.clone()));
}
}
decos.push(SpanDeco {
underline: style.underline,
strike: style.strike,
highlight: resolve_highlight(style.highlight, theme),
code_bg: None,
ring: style.ring.map(|m| RingDeco {
color: m.color.unwrap_or(ink),
rx: m.rx.map(|v| safe_px(v) * sc),
ry: m.ry.map(|v| safe_px(v) * sc),
width: m.width.map(|v| safe_px(v) * sc),
each: m.each,
}),
dot: style.dot.map(|m| DotDeco {
color: m.color.unwrap_or(ink),
radius: m.radius.map(|v| safe_px(v) * sc),
each: m.each,
}),
ink,
shadow: style.shadow.map(|s| GlyphShadow {
dx: (s.dx * sc).round() as i32,
dy: (s.dy * sc).round() as i32,
blur: (s.blur * sc).max(0.0),
color: s.color,
}),
});
}
Inline::Code(s) => {
let idx = decos.len();
let px = safe_px(base_logical * sc);
let mut a = base_attrs()
.family(Family::Name(&theme.font_mono))
.color(to_cosmic(theme.code_text))
.metrics(Metrics::new(px, px * line_mult))
.metadata(idx);
if base_bold {
a = a.weight(Weight::BOLD);
}
spans.push((s, a));
decos.push(SpanDeco {
underline: false,
strike: false,
highlight: None,
code_bg: Some(theme.code_bg),
ring: None,
dot: None,
ink: theme.code_text,
shadow: None,
});
}
Inline::LineBreak => spans.push(("\n", default_attrs.clone())),
}
}
(spans, decos)
}
fn measure_natural(
fonts: &FontHandle,
theme: &Theme,
inlines: &[Inline],
base_logical: f32,
base_bold: bool,
sc: f32,
) -> f32 {
let px = safe_px(base_logical * sc);
let line_mult = theme.line_height;
let default_attrs = base_attrs()
.family(Family::Name(&theme.font_sans))
.metrics(Metrics::new(px, px * line_mult))
.metadata(usize::MAX);
let (spans, _) = build_spans(inlines, theme, base_logical, base_bold, sc, &default_attrs);
fonts.with_system(|fs| {
let mut buf = Buffer::new(fs, Metrics::new(px, px * line_mult));
buf.set_size(None, None); buf.set_rich_text(
spans.iter().map(|(t, a)| (*t, a.clone())),
&default_attrs,
Shaping::Advanced,
None,
);
buf.shape_until_scroll(fs, false);
buf.layout_runs().map(|r| r.line_w).fold(0.0, f32::max)
})
}
fn anchor_pos(
a: crate::model::Anchor,
frame: (f32, f32, f32, f32),
size: (f32, f32),
m: f32,
) -> (f32, f32) {
use crate::model::Anchor;
let (ix, iy, dw, dh) = frame;
let (w, h) = size;
let x = match a {
Anchor::TopLeft | Anchor::BottomLeft => ix + m,
Anchor::TopRight | Anchor::BottomRight => ix + dw - w - m,
Anchor::Center => ix + (dw - w) / 2.0,
};
let y = match a {
Anchor::TopLeft | Anchor::TopRight => iy + m,
Anchor::BottomLeft | Anchor::BottomRight => iy + dh - h - m,
Anchor::Center => iy + (dh - h) / 2.0,
};
(x.max(ix), y.max(iy))
}
fn resolve_highlight(h: Option<crate::model::Highlight>, theme: &Theme) -> Option<Color> {
use crate::model::Highlight;
match h {
Some(Highlight::Theme) => Some(theme.highlight),
Some(Highlight::Custom(c)) => Some(c),
None => None,
}
}
fn collect_decos(
run: &cosmic_text::LayoutRun,
decos: &[SpanDeco],
x_left: f32,
y_top: f32,
out: &mut Vec<DisplayItem>,
) {
let glyphs = run.glyphs;
let baseline = y_top + run.line_y;
let line_top = y_top + run.line_top;
let line_h = run.line_height;
let mut i = 0;
while i < glyphs.len() {
let m = glyphs[i].metadata;
let (mut x0, mut x1, mut fs) = (f32::MAX, f32::MIN, glyphs[i].font_size);
let seg_start = i;
let mut j = i;
while j < glyphs.len() && glyphs[j].metadata == m {
let g = &glyphs[j];
x0 = x0.min(g.x);
x1 = x1.max(g.x + g.w);
fs = g.font_size;
j += 1;
}
i = j;
let seg = &glyphs[seg_start..j];
let Some(d) = decos.get(m) else { continue };
let ax = x_left + x0;
let aw = (x1 - x0).max(0.0);
if aw <= 0.0 {
continue;
}
if let Some(c) = d.highlight {
out.push(DisplayItem::Rect {
x: ax - fs * 0.06,
y: line_top,
w: aw + fs * 0.12,
h: line_h,
color: c,
radius: fs * 0.12,
layer: RectLayer::Under,
});
}
if let Some(c) = d.code_bg {
out.push(DisplayItem::Rect {
x: ax - fs * 0.18,
y: line_top + line_h * 0.08,
w: aw + fs * 0.36,
h: line_h * 0.84,
color: c,
radius: fs * 0.22,
layer: RectLayer::Under,
});
}
if d.underline {
out.push(DisplayItem::Rect {
x: ax,
y: baseline + fs * 0.12,
w: aw,
h: (fs * 0.06).max(1.0),
color: d.ink,
radius: 0.0,
layer: RectLayer::Over,
});
}
if d.strike {
out.push(DisplayItem::Rect {
x: ax,
y: baseline - fs * 0.28,
w: aw,
h: (fs * 0.06).max(1.0),
color: d.ink,
radius: 0.0,
layer: RectLayer::Over,
});
}
let mark_boxes = |each: bool| -> Vec<(f32, f32)> {
if each {
seg.iter()
.filter(|g| {
run.text.get(g.start..g.end).is_none_or(|t| !t.trim().is_empty())
})
.map(|g| (x_left + g.x, g.w))
.collect()
} else {
vec![(ax, aw)]
}
};
if let Some(r) = &d.ring {
for (bx, bw) in mark_boxes(r.each) {
let cx = bx + bw / 2.0;
let cy = baseline - fs * 0.35;
let auto_rx = (bw / 2.0 + fs * 0.30).max(fs * 0.55);
let (rx, ry) = match (r.rx, r.ry) {
(Some(rx), Some(ry)) => (rx, ry),
(Some(rx), None) => (rx, rx),
(None, Some(ry)) => (auto_rx, ry),
(None, None) if r.each => {
let rr = auto_rx.max(fs * 0.62);
(rr, rr)
}
(None, None) => (auto_rx, fs * 0.62),
};
out.push(DisplayItem::Ellipse {
cx,
cy,
rx,
ry,
width: r.width.unwrap_or((fs * 0.07).max(1.0)),
color: r.color,
});
}
}
if let Some(p) = &d.dot {
let r = p.radius.unwrap_or((fs * 0.09).max(1.5));
for (bx, bw) in mark_boxes(p.each) {
out.push(DisplayItem::Rect {
x: bx + bw / 2.0 - r,
y: baseline + fs * 0.16,
w: r * 2.0,
h: r * 2.0,
color: p.color,
radius: r,
layer: RectLayer::Over,
});
}
}
}
}
fn emoji_runs(text: &str) -> Vec<(&str, bool)> {
use unicode_properties::{EmojiStatus, UnicodeEmoji};
let chars: Vec<(usize, char)> = text.char_indices().collect();
let mut flags = vec![false; chars.len()];
for (k, &(_, c)) in chars.iter().enumerate() {
flags[k] = matches!(
c.emoji_status(),
EmojiStatus::EmojiPresentation
| EmojiStatus::EmojiModifierBase
| EmojiStatus::EmojiPresentationAndModifierBase
| EmojiStatus::EmojiPresentationAndEmojiComponent
| EmojiStatus::EmojiPresentationAndModifierAndEmojiComponent
);
}
for (k, &(_, c)) in chars.iter().enumerate() {
if (c == '\u{FE0F}' || c == '\u{20E3}') && k > 0 {
flags[k] = true;
flags[k - 1] = true;
}
}
for (k, &(_, c)) in chars.iter().enumerate() {
if c == '\u{200D}' && k > 0 && k + 1 < chars.len() && flags[k - 1] && flags[k + 1] {
flags[k] = true;
}
}
let mut runs = Vec::new();
let mut start = 0usize;
for k in 1..chars.len() {
if flags[k] != flags[k - 1] {
runs.push((&text[chars[start].0..chars[k].0], flags[start]));
start = k;
}
}
if !chars.is_empty() {
runs.push((&text[chars[start].0..], flags[start]));
}
runs
}
fn base_attrs() -> Attrs<'static> {
Attrs::new().cache_key_flags(cosmic_text::CacheKeyFlags::DISABLE_HINTING)
}
fn family_of<'a>(role: &'a FontRole, theme: &'a Theme) -> Family<'a> {
match role {
FontRole::Sans => Family::Name(&theme.font_sans),
FontRole::Serif => Family::Name(&theme.font_serif),
FontRole::Mono => Family::Name(&theme.font_mono),
FontRole::Kai => Family::Name(&theme.font_kai),
FontRole::Named(s) => Family::Name(s),
}
}
fn align_to_cosmic(a: Align) -> cosmic_text::Align {
match a {
Align::Left => cosmic_text::Align::Left,
Align::Center => cosmic_text::Align::Center,
Align::Right => cosmic_text::Align::Right,
Align::Justify => cosmic_text::Align::Justified,
}
}
fn to_cosmic(c: Color) -> cosmic_text::Color {
cosmic_text::Color::rgba(c.r, c.g, c.b, c.a)
}
fn from_cosmic(c: cosmic_text::Color) -> Color {
Color::rgba(c.r(), c.g(), c.b(), c.a())
}
fn hrule(x: f32, y: f32, w: f32, line: f32, color: Color) -> DisplayItem {
DisplayItem::Rect { x, y: y - line / 2.0, w, h: line, color, radius: 0.0, layer: RectLayer::Under }
}
fn vrule(vx: f32, top: f32, bottom: f32, line: f32, color: Color) -> DisplayItem {
DisplayItem::Rect {
x: vx - line / 2.0,
y: top,
w: line,
h: bottom - top,
color,
radius: 0.0,
layer: RectLayer::Under,
}
}