feat(math): bound layout cache and glyph rasterization

This commit is contained in:
kuddev committed 2026-07-19 16:50:22 +08:00
1 parent 7f648f9567
commit be2e9d76b7
3 files changed
+437

No files matched your search

+236
View File
@@ -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<CacheEntry>,
previous: Option<usize>,
next: Option<usize>,
next_free: Option<usize>,
}
pub(crate) struct MathLayoutCache {
budget: usize,
used: usize,
entries: HashMap<FormulaCacheKey, usize, RandomState>,
nodes: Vec<CacheNode>,
most_recent: Option<usize>,
least_recent: Option<usize>,
free: Option<usize>,
}
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<MathLayout, MathError>,
) -> 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::<CacheNode>()
+ size_of::<(FormulaCacheKey, usize)>() * 2
+ size_of::<CacheEntry>();
INDEX_OVERHEAD
.saturating_add(layout.glyphs.capacity() * size_of::<MathGlyphOp>())
.saturating_add(layout.rules.capacity() * size_of::<MathRuleOp>())
}
#[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);
}
}
+3
View File
@@ -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,
}
+198
View File
@@ -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<u8>,
}
#[derive(Clone, Copy, Debug)]
pub(crate) struct MathGlyphRasterizer {
font: MathFont,
}
impl MathGlyphRasterizer {
pub(crate) fn new() -> Result<Self, MathError> {
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<RasterizedMathGlyph, MathError> {
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<i16, MathError> {
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<Self, MathError> {
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
);
}
}