Files
endorphin-tessera-gui/src/render/text.rs
T

543 lines
24 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 统一字体发现、文字整形、测量与抗锯齿字形绘制。
//! 测量与绘制使用同一字体系统和样式,避免布局宽度与屏幕文字不一致;中文与其他字符通过字体回退处理。
//! 整形和字形缓存有数量/字节预算;极端放大采用有界字形 LOD,最终只遍历裁剪区域内的像素。
//!
//! Shared font discovery, shaping, measurement and antialiased rasterization.
use std::{
collections::HashMap,
sync::{Mutex, OnceLock},
};
use cosmic_text::{Align, Attrs, Buffer, Family, FontSystem, Metrics, Shaping, SwashCache, Wrap};
use crate::display_list::Color;
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
/// 界面比例字体与源码等宽字体的选择,不直接指定某个必然安装的系统字体。
pub enum TextFamily {
Interface,
Code,
/// A registered family loaded through the generic font-resource API.
Custom(u32),
}
#[derive(Clone, Copy, Debug)]
/// 字号、行高和字体类别,测量与绘制必须使用兼容样式。
pub struct TextStyle {
pub font_size: f32,
pub line_height: f32,
pub family: TextFamily,
}
impl TextStyle {
/// 生成界面文字的基准字号与行高,size 是协议使用的字号倍率。
pub fn ui(size: u32) -> Self {
Self {
font_size: 14.0 * size as f32,
line_height: 20.0 * size as f32,
family: TextFamily::Interface,
}
}
/// 生成源码/Console 使用的等宽风格基础尺寸。
pub fn code() -> Self {
Self {
font_size: 13.0,
line_height: 18.0,
family: TextFamily::Code,
}
}
/// 同比缩放字号与行高,保持设备比例变化后的排版比例一致。
pub fn scaled(self, scale: f32) -> Self {
Self {
font_size: self.font_size * scale,
line_height: self.line_height * scale,
..self
}
}
}
#[derive(Clone, Copy, Debug)]
/// 整形后实际占用的逻辑文字尺寸。
pub struct TextSize {
pub width: f32,
pub height: f32,
}
#[derive(Clone, Hash, PartialEq, Eq)]
/// 文字整形缓存键,包含文本、样式和可选换行宽度。
struct TextKey {
value: String,
// 用浮点原始位模式做键,精确区分整形参数;不是把字号取整为 u32。
font_size: u32,
line_height: u32,
family: TextFamily,
// None 表示不换行,Some 保存宽度的浮点位模式;同一文本不同宽度必须分开缓存。
wrap_width: Option<u32>,
}
/// 已整形 Buffer 与测量结果,后续测量/绘制复用同一布局。
struct PreparedText {
buffer: Buffer,
size: TextSize,
}
/// 共享字体系统、字形缓存和整形缓存;调用通过全局锁串行访问。
struct TextEngine {
fonts: FontSystem,
custom: Vec<(String, u64)>,
code_family: Option<String>,
swash: SwashCache,
buffers: HashMap<TextKey, PreparedText>,
// 累计缓存文本的 UTF-8 字节数,只衡量源文本预算,并非 Buffer/字形的总内存。
source_bytes: usize,
}
impl TextEngine {
/// 扫描系统字体,选择可用的界面/等宽字体偏好,并建立空整形和栅格缓存。
fn new() -> Self {
let mut fonts = FontSystem::new();
// Select installed, readable families rather than assuming an OS font
// name exists. COSMIC Text handles per-glyph fallback (including CJK).
// 按偏好顺序查询实际安装字体,找到第一个匹配名;找不到则由字体系统默认族处理。
let choose = |choices: &[&str]| {
choices.iter().find_map(|choice| {
fonts
.db()
.faces()
.any(|face| face.families.iter().any(|(name, _)| name == choice))
.then(|| (*choice).to_owned())
})
};
let sans = choose(&[
".AppleSystemUIFont",
"SF Pro Text",
"Helvetica Neue",
"Segoe UI",
"Noto Sans",
"DejaVu Sans",
"Liberation Sans",
]);
let mono = choose(&["Menlo", "Consolas", "DejaVu Sans Mono", "Liberation Mono"]);
if let Some(name) = sans {
fonts.db_mut().set_sans_serif_family(name);
}
if let Some(name) = &mono {
fonts.db_mut().set_monospace_family(name);
}
Self {
fonts,
custom: Vec::new(),
code_family: mono,
swash: SwashCache::new(),
buffers: HashMap::new(),
source_bytes: 0,
}
}
/// 对不指定换行宽度的文本复用统一整形准备过程。
fn prepare(&mut self, value: &str, style: TextStyle) -> TextKey {
self.prepare_width(value, style, None)
}
/// 为文本、字体样式和可选换行宽度建立整形缓存,超过缓存预算先清理旧结果。
fn prepare_width(&mut self, value: &str, style: TextStyle, wrap_width: Option<f32>) -> TextKey {
// 键包含全部排版输入;命中后测量与绘制都复用同一 Buffer,避免两套排版产生偏差。
let key = TextKey {
value: value.to_owned(),
font_size: style.font_size.to_bits(),
line_height: style.line_height.to_bits(),
family: style.family,
wrap_width: wrap_width.map(f32::to_bits),
};
if self.buffers.contains_key(&key) {
return key;
}
// Cache recent shaped strings, without retaining every submitted source.
// 采用整批清空而非 LRU;新增前达到条数或文本字节阈值时释放旧整形结果。
// 单个超大文本仍会被整形,source_bytes 不是对单次输入长度的硬拒绝。
if self.buffers.len() >= 256 || self.source_bytes + value.len() > 524_288 {
self.buffers.clear();
self.source_bytes = 0;
}
let family = match style.family {
TextFamily::Interface => Family::SansSerif,
TextFamily::Custom(id) => Family::Name(&self.custom[id as usize].0),
// Keep Latin source on the selected code face, and allow ordinary
// script fallback for CJK instead of searching all monospace faces.
TextFamily::Code => self
.code_family
.as_deref()
.map_or(Family::Monospace, Family::Name),
};
let mut buffer = Buffer::new(
&mut self.fonts,
Metrics::new(style.font_size, style.line_height),
);
// 有限宽度使用按词优先、字形兜底的换行策略,兼容中文和无空格长标识符;无宽度则不换行。
buffer.set_wrap(if wrap_width.is_some() {
Wrap::WordOrGlyph
} else {
Wrap::None
});
// 高度不设上限,让测量能得到所有换行后的完整内容高度,而非当前裁剪区域高度。
buffer.set_size(wrap_width, None);
buffer.set_text(
value,
&Attrs::new().family(family),
Shaping::Advanced,
Some(Align::Left),
);
// 整形采用高级脚本处理和左对齐;实际字形及回退由同一 FontSystem 决定。
buffer.shape_until_scroll(&mut self.fonts, false);
// 即使空文本没有布局 run 也保留一行高度;宽度取各行最大值,高度取最下方行底。
let mut size = TextSize {
width: 0.0,
height: style.line_height,
};
for run in buffer.layout_runs() {
size.width = size.width.max(run.line_w);
size.height = size.height.max(run.line_top + run.line_height);
}
self.source_bytes += value.len();
self.buffers
.insert(key.clone(), PreparedText { buffer, size });
key
}
}
/// 取得全局受锁保护的字体/整形引擎;共享缓存不等于可以并行修改字体状态。
fn engine() -> &'static Mutex<TextEngine> {
// 惰性创建全进程引擎;Mutex 同时保护字体库、整形缓存与栅格缓存,测量和绘制不能并发修改它。
static ENGINE: OnceLock<Mutex<TextEngine>> = OnceLock::new();
ENGINE.get_or_init(|| Mutex::new(TextEngine::new()))
}
/// 返回不受换行宽度约束时的字体整形尺寸。
pub fn measure(value: &str, style: TextStyle) -> TextSize {
let mut engine = engine().lock().expect("text engine lock");
let key = engine.prepare(value, style);
engine.buffers[&key].size
}
/// 按可用宽度整形并测量多行高度,供布局与实际段落绘制共用。
pub fn measure_wrapped(value: &str, style: TextStyle, width: f32) -> TextSize {
// 零或负可用宽度直接返回零宽与一行高,避免给整形器无效换行宽度。
if width <= 0.0 {
return TextSize {
width: 0.0,
height: style.line_height,
};
}
let mut engine = engine().lock().expect("text engine lock");
let key = engine.prepare_width(value, style, Some(width));
engine.buffers[&key].size
}
/// Rasterize only visible glyph pixels. Large camera zooms use a bounded glyph
/// bitmap and sample it at the requested size instead of allocating an enormous
/// font image. Layout and wrapping still use proportionally identical metrics.
/// 限制字形分辨率并只采样可见像素;保留文字布局比例,同时避免极端相机缩放产生巨型位图。
pub fn draw_clipped(
value: &str,
style: TextStyle,
color: Color,
width: Option<f32>,
clip: crate::geometry::Rect,
mut pixel: impl FnMut(i32, i32, Color),
) {
if clip.width <= 0.
|| clip.height <= 0.
|| !style.font_size.is_finite()
|| style.font_size <= 0.
{
return;
}
// 超大字号按比例降到最高 512 再栅格化;普通字号保持 1。
// 字体、行高和换行宽度一起缩小,后续用 1/lod 恢复目标几何比例。
let lod = (512. / style.font_size).min(1.);
let mut engine = engine().lock().expect("text engine lock");
let key = engine.prepare_width(value, style.scaled(lod), width.map(|width| width * lod));
// 拆借可变字段以便在保留 Buffer 借用时访问字体和 Swash 缓存,不复制整形结果。
let TextEngine {
fonts,
swash,
buffers,
..
} = &mut *engine;
let buffer = &buffers[&key].buffer;
// 从已有 image_cache 统计位图字节;不包含整形 Buffer,也不含字体数据库本身。
let mut cache_bytes: usize = swash
.image_cache
.values()
.flatten()
.map(|image| image.data.len())
.sum();
for run in buffer.layout_runs() {
for glyph in run.glyphs {
let physical = glyph.physical((0., run.line_y), 1.);
// Conservative font-sized guard includes bearings/overhangs. The
// exact bitmap bounds below determine the actual visible pixels.
// 先用保守包围范围排除完全不可见字形,尽量避免生成裁剪区外的位图;精确边界由实际位图决定。
let guard = glyph.font_size * 4. / lod;
let gx = physical.x as f32 / lod;
let gy = physical.y as f32 / lod;
if gx + guard < clip.x
|| gx - guard > clip.x + clip.width
|| gy + guard < clip.y
|| gy - guard > clip.y + clip.height
{
continue;
}
// 达到条数或约 32 MiB 时替换整个 SwashCache,再生成当前可见字形;预算不因缩放无限增长。
if swash.image_cache.len() >= 4096 || cache_bytes >= 33_554_432 {
*swash = SwashCache::new();
cache_bytes = 0;
}
// 只对新生成的图像增加字节统计;缓存中的生成失败项由 None 跳过,不参与绘制。
let cached = swash.image_cache.contains_key(&physical.cache_key);
let Some(image) = swash.get_image(fonts, physical.cache_key) else {
continue;
};
if !cached {
cache_bytes += image.data.len();
}
// 位图 placement 包含字形 bearing。顶部使用基线减 top,再除 lod 转回目标绘制空间。
let left = (physical.x + image.placement.left) as f32 / lod;
let top = (physical.y - image.placement.top) as f32 / lod;
let right = left + image.placement.width as f32 / lod;
let bottom = top + image.placement.height as f32 / lod;
// 可选字形颜色覆盖 RGB,但透明度始终采用调用方的整体 alpha。彩色位图分支会使用位图自己的 RGB。
let base = glyph.color_opt.map_or(color, |value| {
Color::rgba(value.r(), value.g(), value.b(), color.alpha)
});
// 循环范围取位图与 clip 交集,只遍历最终可见像素;ceil 与半像素采样共同决定整数像素覆盖。
for y in top.max(clip.y).ceil() as i32..bottom.min(clip.y + clip.height).ceil() as i32 {
for x in
left.max(clip.x).ceil() as i32..right.min(clip.x + clip.width).ceil() as i32
{
// 把目标像素中心反映射到低分辨率位图,再钳制到最后一个有效像素,避免边缘采样越界。
let u = (((x as f32 + 0.5 - left) * lod) as u32)
.min(image.placement.width.saturating_sub(1));
let v = (((y as f32 + 0.5 - top) * lod) as u32)
.min(image.placement.height.saturating_sub(1));
let index = (v * image.placement.width + u) as usize;
// 灰度 Mask 把覆盖率乘整体 alpha;Color 保留位图 RGB 并乘其 alpha。
// SubpixelMask 不适用此软件合成路径,直接跳过。
let sample = match image.content {
cosmic_text::SwashContent::Mask => Color::rgba(
base.red,
base.green,
base.blue,
(u16::from(image.data[index]) * u16::from(color.alpha) / 255) as u8,
),
cosmic_text::SwashContent::Color => {
let i = index * 4;
Color::rgba(
image.data[i],
image.data[i + 1],
image.data[i + 2],
(u16::from(image.data[i + 3]) * u16::from(color.alpha) / 255) as u8,
)
}
cosmic_text::SwashContent::SubpixelMask => continue,
};
// 只输出采样颜色,最终混合由调用方 pixel 完成;回调在引擎锁持有期间执行,不应重入文字引擎。
pixel(x, y, sample);
}
}
}
}
}
/// Fit a single displayed source line without slicing a UTF-8/grapheme cluster.
/// 按真实字体测量截断到给定宽度,以字素簇边界拼接省略号,避免切开组合字符。
pub fn fit(value: &str, width: f32, style: TextStyle) -> String {
use unicode_segmentation::UnicodeSegmentation;
// A source line may include a very large comment. Only shape a bounded
// preview for the sidebar; the stored source and its AST spans stay intact.
// 仅预览截断至约 8 KiB 字素边界,不改保存的源码。单个超长字素也不能被拆开。
let truncated = value.len() > 8192;
let value = if truncated {
let end = value
.grapheme_indices(true)
.map(|(index, _)| index)
.take_while(|index| *index <= 8192)
.last()
.unwrap_or(0);
&value[..end]
} else {
value
};
// 原文本未受预览截断且已能放下时原样返回;受截断文本即使较短也要追加省略号提示。
if !truncated && measure(value, style).width <= width {
return value.to_owned();
}
let ellipsis = "…";
if width <= 0.0 || measure(ellipsis, style).width > width {
return String::new();
}
// 保存每个完整字素簇的结束字节位置,后续切片不会拆开组合音标或连字 emoji。
let ends: Vec<_> = value
.grapheme_indices(true)
.map(|(start, part)| start + part.len())
.collect();
let mut low = 0;
let mut high = ends.len();
// 二分搜索可容纳的最长字素前缀;取上中位数使成功分支能推进 low,避免相邻边界时死循环。
while low < high {
let count = (low + high).div_ceil(2);
let end = ends[count - 1];
// 省略号也参与真实整形测量,不能简单从总宽度减去字符数估算。
let candidate = format!("{}{ellipsis}", &value[..end]);
if measure(&candidate, style).width <= width {
low = count;
} else {
high = count - 1;
}
}
// 没有任何字素能放下时只返回省略号;更早已经检查省略号本身可容纳。
let end = low.checked_sub(1).map_or(0, |index| ends[index]);
format!("{}{ellipsis}", &value[..end])
}
/// Register an explicit face for graphics resources. Repeated identical loads
/// reuse the family; conflicting content and unbounded global growth fail.
pub fn register_font(bytes: Vec<u8>, family: &str) -> anyhow::Result<TextFamily> {
use std::hash::{Hash, Hasher};
let mut hash = std::collections::hash_map::DefaultHasher::new();
bytes.hash(&mut hash);
let hash = hash.finish();
let mut e = engine().lock().expect("text engine lock");
if let Some((id, (_, old))) = e.custom.iter().enumerate().find(|(_, v)| v.0 == family) {
anyhow::ensure!(*old == hash, "font family already has different content");
return Ok(TextFamily::Custom(id as u32));
}
anyhow::ensure!(e.custom.len() < 32, "font registry exceeds 32 families");
let previous = e.fonts.db().clone();
e.fonts.db_mut().load_font_data(bytes);
if !e
.fonts
.db()
.faces()
.any(|f| f.families.iter().any(|(n, _)| n == family))
{
*e.fonts.db_mut() = previous;
anyhow::bail!("font data does not contain requested family");
}
let id = e.custom.len() as u32;
e.custom.push((family.into(), hash));
e.buffers.clear();
e.source_bytes = 0;
Ok(TextFamily::Custom(id))
}
#[cfg(test)]
mod tests {
use super::*;
use unicode_segmentation::UnicodeSegmentation;
#[test]
/// 用一万倍缩放检验绘制回调被限制在32x32裁剪内,同时检查字形缓存字号不超过512以控制中间资源。
fn extreme_zoom_rasterizes_only_the_clip_with_bounded_glyph_sizes() {
// 极端缩放测试限制实际回调在 32×32 裁剪区,同时检查缓存字号不超过 512。
let mut count = 0;
draw_clipped(
"M",
TextStyle::ui(1).scaled(10_000.),
Color::rgba(1, 2, 3, 255),
None,
crate::geometry::Rect::new(50_000., 60_000., 32., 32.),
|x, y, _| {
assert!((50_000..50_032).contains(&x));
assert!((60_000..60_032).contains(&y));
count += 1;
},
);
assert_eq!(count, 1024);
let engine = engine().lock().unwrap();
assert!(
engine
.swash
.image_cache
.keys()
.all(|key| f32::from_bits(key.font_size_bits) <= 512.)
);
}
#[test]
/// 混合中文、单词、长标识符、组合字符和emoji,检查窄宽约束分别换行/缓存,且不污染无约束自然测量。
fn width_sensitive_shaping_wraps_words_and_clusters_without_changing_intrinsic_metrics() {
let style = TextStyle::ui(1);
// 混合中文、长英文词、组合字符和 emoji,验证窄/宽换行缓存隔离且不改变无换行测量结果。
let text = "中文段落 with words and long_unbroken_identifier é 👩‍💻 中文段落";
let intrinsic = measure(text, style);
let narrow = measure_wrapped(text, style, 70.0);
let wide = measure_wrapped(text, style, 240.0);
assert!(narrow.height > wide.height);
assert!(wide.height >= style.line_height);
assert!(narrow.width <= 70.1);
assert_eq!(measure(text, style).width, intrinsic.width);
assert_eq!(measure_wrapped(text, style, 0.0).width, 0.0);
let mut engine = engine().lock().unwrap();
let narrow_key = engine.prepare_width(text, style, Some(70.0));
let wide_key = engine.prepare_width(text, style, Some(240.0));
assert!(narrow_key != wide_key);
assert!(engine.buffers[&narrow_key].size.height > engine.buffers[&wide_key].size.height);
}
#[test]
/// 比较比例字体宽窄字母、等宽字体、多行高度与缩放比例,证明尺寸来自字体整形而非按字符数估计。
fn proportional_metrics_monospace_and_multiline_height_come_from_the_fonts() {
// 用宽窄拉丁字母验证比例字体差异、等宽字体一致性、多行行高和同比例缩放。
let ui = TextStyle::ui(1);
assert!(measure("WWWW", ui).width > measure("iiii", ui).width * 2.0);
let mono = TextStyle::code();
assert!((measure("WWWW", mono).width - measure("iiii", mono).width).abs() < 0.1);
assert_eq!(measure("one\ntwo", ui).height, ui.line_height * 2.0);
let width = measure("Hello Endor", ui).width;
assert!((measure("Hello Endor", ui.scaled(1.25)).width - width * 1.25).abs() < 0.5);
}
#[test]
/// 普通和代码字体都需为四个中文字符产生非零glyph_id;依赖测试环境安装可用CJK回退字体。
fn system_fallback_shapes_chinese_without_missing_glyphs() {
let mut engine = engine().lock().unwrap();
// 两种字体风格都要求四个中文字符生成非零 glyph_id,确保系统回退实际找到了字形。
for style in [TextStyle::ui(1), TextStyle::code()] {
let key = engine.prepare("中文字体", style);
let glyphs: Vec<_> = engine.buffers[&key]
.buffer
.layout_runs()
.flat_map(|run| run.glyphs)
.collect();
assert_eq!(glyphs.len(), 4);
assert!(glyphs.iter().all(|glyph| glyph.glyph_id != 0));
assert!(engine.buffers[&key].size.width <= style.font_size * 8.0);
}
}
#[test]
/// 按实际测量宽度截断带组合字符的文本,结果需是原文字素边界前缀加省略号,且整体不超宽。
fn ellipsis_fits_actual_width_and_preserves_graphemes() {
let style = TextStyle::code();
let short_source = "let s = ui.text(\"中文字体\")";
assert_eq!(fit(short_source, 365.0, style), short_source);
let value = "e\u{301}e\u{301}中文字体e\u{301}e\u{301}";
let width = measure("e\u{301}e\u{301}中…", style).width + 0.01;
let fitted = fit(value, width, style);
// 检查结果前缀确实来自原文且结束于字素边界,而不仅是合法 UTF-8 字节边界。
let prefix = fitted.strip_suffix('…').unwrap();
assert!(value.starts_with(prefix));
assert!(
value
.grapheme_indices(true)
.any(|(index, _)| index == prefix.len())
);
assert!(measure(&fitted, style).width <= width);
assert_eq!(fit(value, 0.0, style), "");
}
}