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

609 lines
31 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)]
/// 界面比例字体与源码等宽字体的选择,不直接指定某个必然安装的系统字体。
// TextStyle 携带字体选择,TextEngine::prepare_width 将其解析为实际字体族;Custom ID 由 register_font 返回。
pub enum TextFamily {
Interface,
Code,
/// A registered family loaded through the generic font-resource API.
Custom(u32),
}
#[derive(Clone, Copy, Debug)]
/// 字号、行高和字体类别,测量与绘制必须使用兼容样式。
pub struct TextStyle {
// 目标坐标空间中的字体像素尺寸;scaled 同时缩放它与 line_height,以保持排版比例。
pub font_size: f32,
// 基线之间的布局高度,随同 font_size 缩放;空文本的测量也至少保留这一行高度。
pub line_height: f32,
// 字体发现策略或 register_font 返回的进程级 Custom ID,不是某个 VM 的托管字体对象身份。
pub family: TextFamily,
}
impl TextStyle {
/// 生成界面文字的基准字号与行高,size 是协议使用的字号倍率。
// 布局默认文本与宿主 UI 使用,协议等级乘基础 14/20 得到字号及行高;构造本身不验证等级范围。
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 使用的等宽风格基础尺寸。
// 侧栏、Console 和源码预览使用的等宽样式,实际字体由引擎启动时发现。
pub fn code() -> Self {
Self {
font_size: 13.0,
line_height: 18.0,
family: TextFamily::Code,
}
}
/// 同比缩放字号与行高,保持设备比例变化后的排版比例一致。
// display_list 的设备比例、页面缩放和 draw_clipped 的字形 LOD 都用同一缩放操作。
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 {
// 缓存拥有文本副本,命中比较全部文本内容;不会借用源文件或 VM 字符串的生命周期。
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 {
// 全进程共享的系统/自定义字体库;Mutex 同时串行保护发现、注册、整形与栅格缓存访问。
fonts: FontSystem,
// 已注册字体族名与内容哈希,数组下标就是 TextFamily::Custom ID,最多保留 32 个族。
custom: Vec<(String, u64)>,
// 启动时选中的等宽族名;None 时使用 FontSystem 的 Monospace 策略并保留字符回退。
code_family: Option<String>,
// 字形栅格缓存与整段 Buffer 缓存分开维护,draw_clipped 根据位图条数/字节量整批清理。
swash: SwashCache,
// 文本、浮点样式和换行宽度完整匹配才复用 Buffer;整批清理后不保留逐 VM 的历史缓存。
buffers: HashMap<TextKey, PreparedText>,
// 累计缓存文本的 UTF-8 字节数,只衡量源文本预算,并非 Buffer/字形的总内存。
source_bytes: usize,
}
impl TextEngine {
/// 扫描系统字体,选择可用的界面/等宽字体偏好,并建立空整形和栅格缓存。
// engine 的 OnceLock 首次使用时调用,加载系统字体只做一次,测量与绘制随后共享此实例。
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",
]);
// 保留等宽族名供 Code 使用;不存在偏好族时回退 FontSystem 的 Monospace。
let mono = choose(&["Menlo", "Consolas", "DejaVu Sans Mono", "Liberation Mono"]);
// 只有找到已安装的偏好族才替换字体库默认 sans-serif 配置。
if let Some(name) = sans {
fonts.db_mut().set_sans_serif_family(name);
}
// 代码族配置与保存的 code_family 保持一致,具体字符缺失仍由整形系统做字体回退。
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,
}
}
/// 对不指定换行宽度的文本复用统一整形准备过程。
// measure 的无换行入口,实际整形和缓存逻辑统一委托 prepare_width,避免另建自然宽度算法。
fn prepare(&mut self, value: &str, style: TextStyle) -> TextKey {
self.prepare_width(value, style, None)
}
/// 为文本、字体样式和可选换行宽度建立整形缓存,超过缓存预算先清理旧结果。
// measure_wrapped 和 draw_clipped 共用;调用方已持引擎锁,返回键可直接用于访问 buffers 中的结果。
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),
};
// 完全相同的文本、样式与宽度复用已整形 Buffer,返回前不再次累计 source_bytes。
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;
}
// Custom 直接用注册 ID 查族名,因此调用方应只使用 register_font 返回的有效 ID。
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),
};
// 先建立对应字体尺寸/行高的 Buffer,再设置换行宽度、文本和对齐,最后执行整形。
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()))
}
/// 返回不受换行宽度约束时的字体整形尺寸。
// layout 的原生文本测量、宿主控件尺寸及 fit 的宽度比较共用此入口,不设置自动换行宽度。
pub fn measure(value: &str, style: TextStyle) -> TextSize {
let mut engine = engine().lock().expect("text engine lock");
// prepare 确保当前缓存里存在此键,借用的 size 可复制返回而不把 Buffer 或锁带给调用方。
let key = engine.prepare(value, style);
engine.buffers[&key].size
}
/// 按可用宽度整形并测量多行高度,供布局与实际段落绘制共用。
// layout 在内容宽度约束下调用,完整段落高度用于布局;屏幕 clip 不影响测量宽度或行数。
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.
/// 限制字形分辨率并只采样可见像素;保留文字布局比例,同时避免极端相机缩放产生巨型位图。
// software_renderer 的普通/换行文字路径调用,clip 和回调坐标都相对文字原点,最终平移与混合由 Canvas 负责。
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。
// LOD 限制的是中间字形位图分辨率;输出坐标仍按请求尺寸采样,既不是把页面缩小,也不改变调用方布局盒的尺寸。
// 字体、行高和换行宽度一起缩小,后续用 1/lod 恢复目标几何比例。
let lod = (512. / style.font_size).min(1.);
let mut engine = engine().lock().expect("text engine lock");
// 用同一 lod 缩放字号、行高和可选换行宽度,在低分辨率整形后以倒 lod 恢复输出空间。
let key = engine.prepare_width(value, style.scaled(lod), width.map(|width| width * lod));
// 拆借可变字段以便在保留 Buffer 借用时访问字体和 Swash 缓存,不复制整形结果。
let TextEngine {
fonts,
swash,
buffers,
..
} = &mut *engine;
// 以下遍历只读已整形 Buffer,同时分开借用 fonts/swash 生成字形,不克隆整段布局。
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 {
// 将字形布局转换为可缓存的物理位置/键,run.line_y 提供该行基线偏移。
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;
// 粗筛完全远离 clip 的字形可省下位图生成;通过粗筛后仍用实际 placement 做精确可见范围。
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 跳过,不参与绘制。
// get_image 可能命中或新生成;只给新条目累加数据字节,避免重复命中把预算重复计费。
let cached = swash.image_cache.contains_key(&physical.cache_key);
let Some(image) = swash.get_image(fonts, physical.cache_key) else {
continue;
};
// 生成成功才增加位图数据长度;无可用图像的字形在上面 None 分支直接跳过。
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));
// u/v 是位图内像素下标,先计算单像素索引;不同 content 分支再决定每像素字节数。
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,
),
// 彩色字形每像素占 RGBA 四字节,保留位图 RGB 而非普通文字的 base RGB。
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.
/// 按真实字体测量截断到给定宽度,以字素簇边界拼接省略号,避免切开组合字符。
// 侧栏和工具的单行展示调用,结果只用于预览;不会改源文本、AST 范围或编辑器存储。
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 {
// 在 8 KiB 内选择最后一个字素起点作为切片边界,超长首字素可能让预览正文变空。
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();
// 二分上下界是完整字素数量,不是 UTF-8 字节数;真正切片位置由 ends 转换。
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.
/// 注册显式字体族并返回稳定 ID;同名但内容哈希不同,或超过族数量预算时拒绝注册。
// graphics 的字体资源 API 读取有限字体数据后调用,返回可放入绘制样式的稳定族 ID。
pub fn register_font(bytes: Vec<u8>, family: &str) -> anyhow::Result<TextFamily> {
// 引擎和 32 族配额属于整个进程,关闭或替换某个 VM 不会清此注册表;资源句柄只保存返回的 Custom ID。
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");
// 同名同哈希复用原 ID,同名不同内容直接报错,不替换已经发布给样式的字体身份。
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");
// 保存数据库副本用于请求族名验证失败时回滚;成功注册才写 custom 和清整形缓存。
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");
}
// 尾部追加保持既有 ID 不变;字体库变化后旧 Buffer 可能引用旧选择,因此清空整形结果。
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。
// 统计实际输出回调数,坐标断言验证只遍历 32×32 可见区域,不按巨型字形面积扫描。
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);
// 绘制结束已释放全局锁,再获取它检查真实字形缓存键中的字号,而非只看局部 lod 变量。
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);
// 检查真实缓存键和 Buffer 高度,除了测量结果还验证宽度确实参与缓存区分。
let mut engine = engine().lock().unwrap();
// 文本与样式相同,仅 Some(width) 不同,键及排版高度都应不同。
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,确保系统回退实际找到了字形。
// 界面与代码字体各检查一次中文回退,缺少环境 CJK 字体时该测试会暴露缺字,不能只凭非零宽度判断。
for style in [TextStyle::ui(1), TextStyle::code()] {
let key = engine.prepare("中文字体", style);
// 直接查看整形 glyph_id,ID=0 表示缺字;中文用例期望四个字符各有可用字形。
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), "");
}
}