From be2e9d76b717ebcb05eea677be79956bcb1abe17 Mon Sep 17 00:00:00 2001 From: kuddev Date: Sun, 19 Jul 2026 10:36:22 +0800 Subject: [PATCH] feat(math): bound layout cache and glyph rasterization --- nebula_app/src/math/cache.rs | 236 ++++++++++++++++++++++++++++++ nebula_app/src/math/mod.rs | 3 + nebula_app/src/math/rasterizer.rs | 198 +++++++++++++++++++++++++ 3 files changed, 437 insertions(+) create mode 100644 nebula_app/src/math/cache.rs create mode 100644 nebula_app/src/math/rasterizer.rs diff --git a/nebula_app/src/math/cache.rs b/nebula_app/src/math/cache.rs new file mode 100644 index 0000000..49f4ab8 --- /dev/null +++ b/nebula_app/src/math/cache.rs @@ -0,0 +1,236 @@ +//! 固定字节预算的公式布局 LRU。 + +use std::collections::HashMap; +use std::mem::size_of; + +use ahash::RandomState; + +use super::layout::{MathGlyphOp, MathLayout, MathRuleOp}; +use super::{MathError, MathErrorKind}; + +pub(crate) const LAYOUT_CACHE_BUDGET: usize = 4 * 1024 * 1024; + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub(crate) struct FormulaCacheKey { + /// 文档生命周期内稳定的公式编号;缓存随 DocView 一起销毁,因而无需复制公式源码。 + pub(crate) formula_id: u64, + pub(crate) pixel_size_bits: u32, + pub(crate) pixels_per_point_bits: u32, + pub(crate) display: bool, +} + +impl FormulaCacheKey { + pub(crate) fn new( + formula_id: u64, + pixel_size: f32, + pixels_per_point: f32, + display: bool, + ) -> Self { + Self { + formula_id, + pixel_size_bits: pixel_size.to_bits(), + pixels_per_point_bits: pixels_per_point.to_bits(), + display, + } + } +} + +struct CacheEntry { + key: FormulaCacheKey, + layout: MathLayout, + charge: usize, +} + +#[derive(Default)] +struct CacheNode { + entry: Option, + previous: Option, + next: Option, + next_free: Option, +} + +pub(crate) struct MathLayoutCache { + budget: usize, + used: usize, + entries: HashMap, + nodes: Vec, + most_recent: Option, + least_recent: Option, + free: Option, +} + +impl Default for MathLayoutCache { + fn default() -> Self { + Self::with_budget(LAYOUT_CACHE_BUDGET) + } +} + +impl MathLayoutCache { + fn with_budget(budget: usize) -> Self { + Self { + budget, + used: 0, + entries: HashMap::with_hasher(RandomState::default()), + nodes: Vec::new(), + most_recent: None, + least_recent: None, + free: None, + } + } + + pub(crate) fn get(&mut self, key: FormulaCacheKey) -> Option<&MathLayout> { + let index = *self.entries.get(&key)?; + self.touch(index); + self.nodes[index].entry.as_ref().map(|entry| &entry.layout) + } + + pub(crate) fn get_or_insert_with( + &mut self, + key: FormulaCacheKey, + build: impl FnOnce() -> Result, + ) -> Result<&MathLayout, MathError> { + if let Some(index) = self.entries.get(&key).copied() { + self.touch(index); + return self.nodes[index] + .entry + .as_ref() + .map(|entry| &entry.layout) + .ok_or_else(|| MathError::new(MathErrorKind::Parse, 0)); + } + + let layout = build()?; + let charge = layout_charge(&layout); + if charge > self.budget { + return Err(MathError::new(MathErrorKind::OpLimit, 0)); + } + while self.used.saturating_add(charge) > self.budget { + self.evict_least_recent()?; + } + + let index = if let Some(index) = self.free { + self.free = self.nodes[index].next_free.take(); + index + } else { + self.nodes.push(CacheNode::default()); + self.nodes.len() - 1 + }; + self.nodes[index].entry = Some(CacheEntry { key, layout, charge }); + self.used += charge; + self.entries.insert(key, index); + self.attach_most_recent(index); + Ok(&self.nodes[index].entry.as_ref().expect("inserted cache node").layout) + } + + #[cfg(test)] + fn len(&self) -> usize { + self.entries.len() + } + + #[cfg(test)] + fn used_bytes(&self) -> usize { + self.used + } + + fn touch(&mut self, index: usize) { + if self.most_recent == Some(index) { + return; + } + self.detach(index); + self.attach_most_recent(index); + } + + fn detach(&mut self, index: usize) { + let previous = self.nodes[index].previous.take(); + let next = self.nodes[index].next.take(); + if let Some(previous) = previous { + self.nodes[previous].next = next; + } else if self.most_recent == Some(index) { + self.most_recent = next; + } + if let Some(next) = next { + self.nodes[next].previous = previous; + } else if self.least_recent == Some(index) { + self.least_recent = previous; + } + } + + fn attach_most_recent(&mut self, index: usize) { + let old_head = self.most_recent; + self.nodes[index].previous = None; + self.nodes[index].next = old_head; + if let Some(old_head) = old_head { + self.nodes[old_head].previous = Some(index); + } else { + self.least_recent = Some(index); + } + self.most_recent = Some(index); + } + + fn evict_least_recent(&mut self) -> Result<(), MathError> { + let index = self.least_recent.ok_or_else(|| MathError::new(MathErrorKind::OpLimit, 0))?; + self.detach(index); + let entry = self.nodes[index] + .entry + .take() + .ok_or_else(|| MathError::new(MathErrorKind::Parse, 0))?; + self.entries.remove(&entry.key); + self.used = self.used.saturating_sub(entry.charge); + self.nodes[index].next_free = self.free; + self.free = Some(index); + Ok(()) + } +} + +fn layout_charge(layout: &MathLayout) -> usize { + // 将容器、索引节点与哈希桶的近似固定开销也计入预算,避免大量空公式绕过限制。 + const INDEX_OVERHEAD: usize = size_of::() + + size_of::<(FormulaCacheKey, usize)>() * 2 + + size_of::(); + INDEX_OVERHEAD + .saturating_add(layout.glyphs.capacity() * size_of::()) + .saturating_add(layout.rules.capacity() * size_of::()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::math::layout::{MathGlyphOp, MathMetrics}; + + fn key(id: u64) -> FormulaCacheKey { + FormulaCacheKey::new(id, 18.0, 1.0, false) + } + + fn layout(glyphs: usize) -> MathLayout { + MathLayout { + metrics: MathMetrics::default(), + glyphs: vec![MathGlyphOp::default(); glyphs], + rules: Vec::new(), + } + } + + #[test] + fn lru_stays_within_its_byte_budget_and_reuses_nodes() { + let one_charge = layout_charge(&layout(8)); + let mut cache = MathLayoutCache::with_budget(one_charge * 2); + cache.get_or_insert_with(key(1), || Ok(layout(8))).unwrap(); + cache.get_or_insert_with(key(2), || Ok(layout(8))).unwrap(); + assert!(cache.get(key(1)).is_some()); + cache.get_or_insert_with(key(3), || Ok(layout(8))).unwrap(); + + assert_eq!(cache.len(), 2); + assert!(cache.get(key(1)).is_some()); + assert!(cache.get(key(2)).is_none()); + assert!(cache.get(key(3)).is_some()); + assert!(cache.used_bytes() <= one_charge * 2); + assert_eq!(cache.nodes.len(), 2); + } + + #[test] + fn cache_hit_does_not_rebuild_layout() { + let mut cache = MathLayoutCache::default(); + cache.get_or_insert_with(key(7), || Ok(layout(1))).unwrap(); + let hit = + cache.get_or_insert_with(key(7), || panic!("cache hit must not call builder")).unwrap(); + assert_eq!(hit.glyphs.len(), 1); + } +} diff --git a/nebula_app/src/math/mod.rs b/nebula_app/src/math/mod.rs index 14fdbe8..ab1f0e5 100644 --- a/nebula_app/src/math/mod.rs +++ b/nebula_app/src/math/mod.rs @@ -2,10 +2,12 @@ //! //! 该模块不依赖窗口、OpenGL 或主题类型,保证同一份布局可被不同渲染后端复用。 +pub(crate) mod cache; pub(crate) mod font; pub(crate) mod ir; pub(crate) mod layout; pub(crate) mod parser; +pub(crate) mod rasterizer; pub(crate) mod spacing; pub(crate) mod validate; @@ -47,6 +49,7 @@ pub(crate) enum MathErrorKind { OpLimit, Parse, MissingGlyph, + GlyphTooLarge, Font, } diff --git a/nebula_app/src/math/rasterizer.rs b/nebula_app/src/math/rasterizer.rs new file mode 100644 index 0000000..ddbab42 --- /dev/null +++ b/nebula_app/src/math/rasterizer.rs @@ -0,0 +1,198 @@ +//! 固定数学字体 glyph ID 的紧边界 CPU 栅格化。 + +use ab_glyph_rasterizer::{Point, Rasterizer, point}; +use ttf_parser::{GlyphId, OutlineBuilder}; + +use super::font::MathFont; +use super::{MathError, MathErrorKind}; + +const GLYPH_PADDING: f32 = 1.0; +const MAX_GLYPH_DIMENSION: u32 = 512; + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub(crate) struct RasterizedMathGlyph { + pub(crate) width: u16, + pub(crate) height: u16, + /// glyph 原点到位图左边界的像素偏移。 + pub(crate) left: i16, + /// glyph 基线到位图顶边界的像素偏移,向上为正。 + pub(crate) top: i16, + pub(crate) rgba: Vec, +} + +#[derive(Clone, Copy, Debug)] +pub(crate) struct MathGlyphRasterizer { + font: MathFont, +} + +impl MathGlyphRasterizer { + pub(crate) fn new() -> Result { + let font = MathFont::load().map_err(|_| MathError::new(MathErrorKind::Font, 0))?; + Ok(Self { font }) + } + + pub(crate) fn rasterize( + &self, + glyph_id: u16, + pixel_size: f32, + ) -> Result { + if !pixel_size.is_finite() || pixel_size <= 0.0 { + return Err(MathError::new(MathErrorKind::Font, 0)); + } + let glyph = GlyphId(glyph_id); + let metrics = self + .font + .glyph_metrics(glyph, pixel_size) + .map_err(|_| MathError::new(MathErrorKind::MissingGlyph, 0))?; + let left = (metrics.x_min - GLYPH_PADDING).floor(); + let right = (metrics.x_max + GLYPH_PADDING).ceil(); + let top = (metrics.height + GLYPH_PADDING).ceil(); + let bottom = (-metrics.depth - GLYPH_PADDING).floor(); + let width = (right - left).max(0.0) as u32; + let height = (top - bottom).max(0.0) as u32; + if width == 0 || height == 0 { + return Ok(RasterizedMathGlyph::default()); + } + if width > MAX_GLYPH_DIMENSION || height > MAX_GLYPH_DIMENSION { + return Err(MathError::new(MathErrorKind::GlyphTooLarge, 0)); + } + let left_i16 = checked_i16(left)?; + let top_i16 = checked_i16(top)?; + let mut outline = + RasterOutline::new(width as usize, height as usize, pixel_size, left, top, self.font)?; + let bounds = self + .font + .outline(glyph, &mut outline) + .map_err(|_| MathError::new(MathErrorKind::Font, 0))?; + if bounds.is_none() { + return Err(MathError::new(MathErrorKind::MissingGlyph, 0)); + } + + let pixel_count = (width as usize) + .checked_mul(height as usize) + .ok_or_else(|| MathError::new(MathErrorKind::GlyphTooLarge, 0))?; + let mut rgba = vec![0u8; pixel_count * 4]; + outline.rasterizer.for_each_pixel(|index, alpha| { + let offset = index * 4; + rgba[offset] = 255; + rgba[offset + 1] = 255; + rgba[offset + 2] = 255; + rgba[offset + 3] = (alpha.clamp(0.0, 1.0) * 255.0).round() as u8; + }); + Ok(RasterizedMathGlyph { + width: width as u16, + height: height as u16, + left: left_i16, + top: top_i16, + rgba, + }) + } +} + +fn checked_i16(value: f32) -> Result { + if value < i16::MIN as f32 || value > i16::MAX as f32 { + Err(MathError::new(MathErrorKind::GlyphTooLarge, 0)) + } else { + Ok(value as i16) + } +} + +struct RasterOutline { + rasterizer: Rasterizer, + scale: f32, + left: f32, + top: f32, + current: Point, + contour_start: Point, +} + +impl RasterOutline { + fn new( + width: usize, + height: usize, + pixel_size: f32, + left: f32, + top: f32, + font: MathFont, + ) -> Result { + let units_per_em = + font.units_per_em().map_err(|_| MathError::new(MathErrorKind::Font, 0))?; + Ok(Self { + rasterizer: Rasterizer::new(width, height), + scale: pixel_size / units_per_em as f32, + left, + top, + current: point(0.0, 0.0), + contour_start: point(0.0, 0.0), + }) + } + + fn transform(&self, x: f32, y: f32) -> Point { + point(x * self.scale - self.left, self.top - y * self.scale) + } +} + +impl OutlineBuilder for RasterOutline { + fn move_to(&mut self, x: f32, y: f32) { + let point = self.transform(x, y); + self.current = point; + self.contour_start = point; + } + + fn line_to(&mut self, x: f32, y: f32) { + let next = self.transform(x, y); + self.rasterizer.draw_line(self.current, next); + self.current = next; + } + + fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) { + let control = self.transform(x1, y1); + let next = self.transform(x, y); + self.rasterizer.draw_quad(self.current, control, next); + self.current = next; + } + + fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) { + let control_1 = self.transform(x1, y1); + let control_2 = self.transform(x2, y2); + let next = self.transform(x, y); + self.rasterizer.draw_cubic(self.current, control_1, control_2, next); + self.current = next; + } + + fn close(&mut self) { + self.rasterizer.draw_line(self.current, self.contour_start); + self.current = self.contour_start; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn indexed_math_glyph_rasterizes_to_bounded_rgba() { + let font = MathFont::load().unwrap(); + let glyph = font.glyph_id('∫').unwrap(); + let rasterizer = MathGlyphRasterizer::new().unwrap(); + let image = rasterizer.rasterize(glyph.0, 32.0).unwrap(); + assert!(image.width > 0 && image.height > 0); + assert_eq!(image.rgba.len(), image.width as usize * image.height as usize * 4); + assert!(image.rgba.chunks_exact(4).any(|pixel| pixel[3] > 0)); + assert!(image.rgba.chunks_exact(4).all(|pixel| pixel[..3] == [255, 255, 255])); + } + + #[test] + fn rasterization_is_deterministic_and_rejects_oversized_requests() { + let font = MathFont::load().unwrap(); + let glyph = font.glyph_id('x').unwrap(); + let rasterizer = MathGlyphRasterizer::new().unwrap(); + let first = rasterizer.rasterize(glyph.0, 18.0).unwrap(); + let second = rasterizer.rasterize(glyph.0, 18.0).unwrap(); + assert_eq!(first, second); + assert_eq!( + rasterizer.rasterize(glyph.0, 4096.0).unwrap_err().kind, + MathErrorKind::GlyphTooLarge + ); + } +}