//! 统一字体发现、文字整形、测量与抗锯齿字形绘制。 //! 测量与绘制使用同一字体系统和样式,避免布局宽度与屏幕文字不一致;中文与其他字符通过字体回退处理。 //! 整形和字形缓存有数量/字节预算;极端放大采用有界字形 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, } /// 已整形 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, // 字形栅格缓存与整段 Buffer 缓存分开维护,draw_clipped 根据位图条数/字节量整批清理。 swash: SwashCache, // 文本、浮点样式和换行宽度完整匹配才复用 Buffer;整批清理后不保留逐 VM 的历史缓存。 buffers: HashMap, // 累计缓存文本的 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) -> 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 { // 惰性创建全进程引擎;Mutex 同时保护字体库、整形缓存与栅格缓存,测量和绘制不能并发修改它。 static ENGINE: OnceLock> = 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, 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, family: &str) -> anyhow::Result { // 引擎和 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), ""); } }