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

188 lines
10 KiB
Rust

//! Width-independent word preparation and variable-width line filling. The
//! exclusion algorithm owns geometry; this module only consumes text in order.
//! graphics 的 Prepare 在这里生成可复用词/字素图像,Flow 每行提供避让后的区间,由 row 按文本顺序消费。
//! 字形资源不随列宽重新生成;Cursor 属于每次 Flow,放不下的内容留待后续区间或下一行。
use crate::{display_list::Color, geometry::Rect, render::image::Image, text::TextStyle};
use std::{collections::HashMap, sync::Arc};
use unicode_segmentation::UnicodeSegmentation;
#[derive(Debug)]
/// 一段文字的不可变墨迹资源;整词和单字素都用同一结构,graphics::flow 按 Placement 引用它生成图片命令。
pub struct Ink {
// Image::text 产生的 RGBA 图片;Arc 允许重复文本和多次 Flow 共享像素,不为每个摆放位置复制。
pub image: Arc<Image>,
// 图片相对文字原点的逻辑矩形,包含负方向字形留白;不能只按 advance 宽度当作图片框。
pub rect: Rect,
// 字体测量的逻辑 advance,排版推进使用它;图片像素宽度还包含 scale 与留白。
pub width: f32,
}
#[derive(Debug)]
/// Unicode 换行机会之间的文字段,不只代表英文词;同时保留整段墨迹和按字素拆分的后备墨迹。
pub struct Word {
// 能完整容纳时使用整段图像,保留整段整形的字距与连字效果。
pub ink: Arc<Ink>,
// 按扩展字素簇生成的资源,避免拆开组合音标或 emoji;拆分模式的宽度来自逐字素独立测量。
pub letters: Vec<Arc<Ink>>,
// 该段末尾空白的逻辑测量宽度,放完正文后推进 x;换行符不参与这份空白测量。
pub space: f32,
// 原段以换行符结尾,消费完该段就结束当前 row,即使本行还有其他可用区间。
pub newline: bool,
}
#[derive(Debug)]
/// 与列宽无关的预排资源;graphics 的 TextResource 持有 Arc<Prepared>,每次 Flow 单独创建游标。
pub struct Prepared {
pub words: Vec<Word>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
/// 下一个待消费段及段内字素位置;Default 从 (0,0) 开始,跨一行的多个区间和后续行持续推进。
pub struct Cursor {
pub word: usize,
// 为零表示尚未拆分/位于段首,非零表示前面字素已放置,不能再回头画整词。
pub letter: usize,
}
/// row 追加的纯几何结果,graphics::flow 随后转为 PaintCommand;不在排版循环内分配托管命令对象。
pub struct Placement {
pub word: usize,
// None 指整词墨迹,Some 指该词某一字素;不能把整词位置再当成已经拆分的单字素。
pub letter: Option<usize>,
// 本次 Flow 坐标空间中的文字逻辑原点;实际图片命令还会叠加 Ink.rect 的留白偏移。
pub x: f32,
pub y: f32,
}
impl Prepared {
/// graphics 的 Prepare 分支在校验文本长度、样式和设备比例后调用;这里再限制本份预排图片累计像素。
pub fn new(
value: &str,
style: TextStyle,
scale: f32,
color: Color,
) -> Result<Self, &'static str> {
// 缓存只在这一份 Prepared 构造期间存在,按文本复用整词/字素资源;同文样式固定,无需额外样式键。
let mut cache: HashMap<String, Arc<Ink>> = HashMap::new();
// 累计唯一墨迹图片面积,u64 乘法避免在统计前溢出;重复文本命中不重复计费。
let mut pixels = 0_u64;
// 整段和字素走同一图片构造闭包,共享资源检查与去重,不另外维护两套缓存。
let mut ink = |text: &str| -> Result<Arc<Ink>, &'static str> {
// 重复内容直接克隆 Arc;文本相同的段与字素也能共享同一张图。
if let Some(ink) = cache.get(text) {
return Ok(ink.clone());
}
// 先生成图像、逻辑留白框及 advance,再核算累计像素;单图自身预算由 Image::text 检查。
let (image, rect, width) = Image::text(text, style, scale, color)?;
pixels += u64::from(image.width) * u64::from(image.height);
// 超过本份预排的八百万级像素上限即返回错误,不发布部分 Prepared;本次生成图片也计入。
if pixels > 8_388_608 {
return Err("prepared text exceeds 8M pixel cache budget");
}
// 图片只封装一次,缓存和 Word 分别通过 Arc 保留;闭包的临时 HashMap 退出后资源仍由结果持有。
let ink = Arc::new(Ink { image, rect, width });
cache.insert(text.to_owned(), ink.clone());
Ok(ink)
};
let mut words = Vec::new();
// 保留段末换行符,使换行意图不会因分段丢失;空输入不产生任何 Word。
for para in value.split_inclusive('\n') {
// Unicode 换行迭代器给出字节结束位置,start 跟随推进以逐段切取 UTF-8 安全文本。
let mut start = 0;
// 使用每个换行机会作为可整段放置的单元;机会类型本身未参与判断,显式换行另由 newline 字段保留。
for (end, _) in unicode_linebreak::linebreaks(para) {
let part = &para[start..end];
// 把尾部空白与可见正文分开,正文生成墨迹,末尾空白只作为布局 advance。
let body = part.trim_end_matches(char::is_whitespace);
// 去除 CR/LF 后测尾部空白;没有把换行当成一行可绘制的空格宽度。
let space =
crate::text::measure(part[body.len()..].trim_matches(['\n', '\r']), style)
.width;
// 同时预生成整词和各字素;后续列宽或避让区间变化只改摆放,不再次调用字体栅格化。
words.push(Word {
ink: ink(body)?,
letters: body
.graphemes(true)
.map(&mut ink)
.collect::<Result<Vec<_>, _>>()?,
space,
newline: part.ends_with('\n'),
});
start = end;
}
}
Ok(Self { words })
}
/// The same cursor crosses left/right fragments. Narrow gaps never discard
/// a word; a word wider than every remaining fragment falls back to graphemes.
/// graphics::flow 对每条行带调用,ranges 由障碍几何计算并按从左到右给出;y 是整行文字原点纵坐标。
pub fn row(
&self,
cursor: &mut Cursor,
ranges: &[(f32, f32)],
y: f32,
out: &mut Vec<Placement>,
) {
// 同一游标穿过一行所有碎片,ri 用于查看当前及后续区间原始宽度,防止过早拆词。
for (ri, &(left, right)) in ranges.iter().enumerate() {
// 进入新区间从左边界开始,上一碎片的横向剩余空间不累计到这里。
let mut x = left;
// 仍有未消费文本才尝试摆放;每个成功分支推进词或字素,放不下则 break 到下一区间。
while let Some(word) = self.words.get(cursor.word) {
// 空正文不生成 Placement,也不推进其 space;若是空白换行段则只消费它并结束当前行。
if word.letters.is_empty() {
cursor.word += 1;
cursor.letter = 0;
// 显式换行结束整个 row,不只是当前碎片;下次调用从下一个词继续。
if word.newline {
return;
}
continue;
}
// 取当前及后续区间的完整最大宽度,不是仅当前剩余 right-x;用来判断整词能否留给稍后碎片/行。
let remaining_max = ranges[ri..].iter().map(|(l, r)| r - l).fold(0., f32::max);
// 只有尚未拆分且当前余宽容得下整词才走整词模式,0.001 容差吸收浮点边界误差。
if cursor.letter == 0 && word.ink.width <= right - x + 0.001 {
out.push(Placement {
word: cursor.word,
letter: None,
x,
y,
});
// 推进正文宽度和尾空白,再跳到下一词;尾空白本身不会产生图片命令。
x += word.ink.width + word.space;
cursor.word += 1;
if word.newline {
return;
}
} else if cursor.letter == 0 && word.ink.width <= remaining_max + 0.001 {
// 整词有望放入某个完整剩余区间时先保留它,结束当前碎片尝试,不因当前余宽小就拆成字素。
break;
} else {
// 词过宽或已经拆分才逐字素继续;这个分支保留跨碎片/跨行的 letter 下标。
let letter = &word.letters[cursor.letter];
// 连一个完整字素也放不下时不丢字,转到后续区间;若所有行都放不下,由外层高度预算结束 Flow。
if letter.width > right - x + 0.001 {
break;
}
out.push(Placement {
word: cursor.word,
letter: Some(cursor.letter),
x,
y,
});
// 成功放置一个字素后才递增游标,同一段的后续字素从新 x 继续。
x += letter.width;
cursor.letter += 1;
// 最后字素放完才消费尾空白并推进词;复位 letter,让下一词仍有整词放置机会。
if cursor.letter == word.letters.len() {
cursor.word += 1;
cursor.letter = 0;
x += word.space;
if word.newline {
return;
}
}
}
}
}
}
}