543 lines
24 KiB
Rust
543 lines
24 KiB
Rust
//! 统一字体发现、文字整形、测量与抗锯齿字形绘制。
|
||
//! 测量与绘制使用同一字体系统和样式,避免布局宽度与屏幕文字不一致;中文与其他字符通过字体回退处理。
|
||
//! 整形和字形缓存有数量/字节预算;极端放大采用有界字形 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), "");
|
||
}
|
||
}
|