大家好,我是前端西瓜哥。
之前我们通过字体解析库,拿到了文字的字形路径数据,实现了 手动换行。
但是对于换行,只有手动换行还是不够的。
在文字排版中,我们希望可以给定一个区域宽度,让输入的单行文本超过这个宽度,需要对这行文字进行 "软换行",将文本拆分成多行。

这个能力就是 "自动换行"。
我的开源图形编辑器项目目前已支持文字自动换行了,欢迎体验。
下面来探究自动换行要如何实现。
排版计算改造
我们之前实现过支持手动换行的文字排版。
思路就是基于文本的换行符 \n 将文本拆分成多个单行文本,然后生成它们的 glyph 字形,基于 glyph 的宽度更新排版的 x、y。
现在要自动换行了,所以要再提供一个 maxWidth 表示最大宽度。
在拿到单行文本 glyph 字形的基础上,遍历累加计算总宽度,当宽度超过 maxWidth 的情况下,补上一个 "软换行",即额外添加 \n 空 glyph 进行占位。
注意这是排版的上 "补充",并不会修改原文本内容。
ts
let x = 0;
const y = 0;
let i = 0;
for (; i < originGlyphs.length; i++) {
const glyph = originGlyphs[i];
let width = glyph.advanceWidth ?? 0;
// 超过最大宽度,添加一个
const isSoftWrapAdd = i > 0 && x + width > maxWidth;
if (isSoftWrapAdd) {
glyphs.push({
position: { x, y: y },
width: 0,
commands: '',
logicIndex: i,
});
// 到下一行了,更新 glyphLines,重置当前行 glyphs
x = 0;
glyphLines.push(glyphs);
glyphs = [];
}
// ...
glyphs.push({
position: { x: x, y: y },
width: width,
commands: glyph.path.toPathData(100),
logicIndex: i, // 逻辑索引
});
x += width;
}
最后得到的 glyphs 不再是一维数组,而是变成二维数组,因为可能会返回多行的信息。
另外, 这里额外新增一个 logicIndex 属性,表达 glyph 对应文本的字符串位置。因为现在不再是原来的一一对应关系了。
选区模型方案更换
这里出现了一个问题:"软换行" 的额外添加,导致 "逻辑索引"(offset,字符串下标) 和 "可视索引"(position,光标位置) 无法匹配上。
在自动换行(soft wrap)的场景下,换行点的前后两个视觉位置,在文档模型中对应的是同一个 index。例如:
bash
|The quick brown fox jum| <-- 视觉第一行
|ps over the lazy dog | <-- 视觉第二行
假设 jum 后面的位置是 index 24,那么 "第一行末尾" 和 "第二行开头" 都是 index 24,但视觉上是不同的光标位置。

这对我们更新选区位置信息,或是转换为逻辑索引(字符串索引位置)转换都比较麻烦。
如果我们要继续使用原来的 线性选区模型 (如 { start: 0, end: 24 }),在软换行场景,可能需要再 引入一个 affinity 概念来表达光标是在行末还是行首 ,如{ start: 0, startAffinity: 'downstream' end: 24, endAffinity: 'upstream' } 表达选区选中为第一行行首到行末。
但这种写法个人不是很喜欢,且我调研了下,这种方案在文本编辑中还是比较少。
最后我参考了 Monaco editor 的 selection 表达,使用的 行(line)和列(column)的表达。
ts
textEditor.selectionManager.setSelection({
// 基准位置
anchorLineNum: 0,
anchorColumn: 0,
// 聚焦位置
focusLineNum: 0,
focusColumn: 24,
});
选区位置更新
重要的排版计算和选区模型改造完成后,后面就是一些细节的调整了。
举个例子。
插入光标在视觉第二行的开头,此时我们 按下左方向键,对光标进行左移。
在 逻辑索引 上,其实就是将 "m" 字符删除,逻辑索引向左移动 1 个距离。但对于 可视索引,则是要向前移动 2 个距离的("m" 和软换行符)。
这个就是 "逻辑索引" 和 "可视索引" 无法匹配的问题。
解决方案是,先根据插入光标位置的可视索引,转换为逻辑索引,然后减 1,然后再求对应的可视索引。
ts
const { focusColumn, focusLineNum } = this.selection;
// 可视索引 -> 逻辑索引
const offset = textGraphics.paragraph.getOffsetAt({
lineNum: focusLineNum,
column: focusColumn,
});
// 逻辑索引减 1,然后转换为可视索引
const newPosition = textGraphics.paragraph.getPositionAt(offset - 1, 'downstream');
this.setSelection({
anchorColumn: newPosition.column,
anchorLineNum: newPosition.lineNum,
focusColumn: newPosition.column,
focusLineNum: newPosition.lineNum,
});
getPositionAt 方法的第二个参数是 affinity,downstream 希望得到靠下的 position。如下图,左移时,光标还在当前行,再左移,就跑到上一行的最后一个字符前方了。光标没有在行末出现。
getOffsetAt 实现:
ts
getOffsetAt(pos: IPosition): number {
const glyphs = this.getGlyphs();
const lineNum = Math.min(Math.max(pos.lineNum, 0), glyphs.length - 1);
const line = glyphs[lineNum];
if (line.length === 0) return0;
const column = Math.min(Math.max(pos.column, 0), line.length - 1);
return line[column].logicIndex;
}
getPositionAt 实现:
ts
getPositionAt(
offset: number,
affinity: 'upstream' | 'downstream' = 'downstream',
): IPosition {
const glyphs = this.getGlyphs();
let isFound = false;
const position: IPosition = { lineNum: 0, column: 0 };
for (let lineNum = 0; lineNum < glyphs.length; lineNum++) {
const line = glyphs[lineNum];
for (let column = 0; column < line.length; column++) {
if (line[column].logicIndex === offset) {
position.lineNum = lineNum;
position.column = column;
isFound = true;
break;
}
}
if (isFound) break;
}
// if affinity is 'downstream' and the position is not the last line,
// get the position of the next line
if (affinity === 'downstream' && position.lineNum < glyphs.length - 1) {
const nextLine = glyphs[position.lineNum + 1];
if (nextLine.length > 0 && nextLine[0].logicIndex === offset) {
position.lineNum = position.lineNum + 1;
position.column = 0;
}
}
return position;
}
Figma 文字对象
Figma 的文字对象,同时自适应宽度、固定宽度两种效果。
自适应宽度,表现为文本内容,宽高会自动调整适应文本宽高。固定宽度,则要超出的文本自动换行到下一行。
Figma 的文字对象有一个属性 textAutoResize,表示是否根据文本内容自适应修改宽或高。
它支持的值有:
-
WIDTH_AND_HEIGHT,属性面板表达为:自动宽度(Auto width),表示宽高都自适应; -
HEIGHT,属性面板表达为:自动高度(Auto height),表示宽固定,高自适应; -
NONE,属性面板表达为:固定宽高(Fixed Size),表示宽固定,高也固定(文字渲染的实际高度可超出定高);
可以通过属性面板的 Resizing 项下直接修改这个属性。

也可以拖拽控制点修改宽高。
如果当前文字是"自动宽度"策略,当用户修改其宽高属性,如果高度发生改变,会变成"固定宽高"策略。如果高度没改变,但宽度发生改变,则会变成 "自动高度策略"。

创建文字的时候,如果拖拽会产生一个矩形区域,释放时会基于该宽高创建一个使用 "固定宽高" 策略的文字对象。
如果没有发生拖拽,则创建 "自动宽度" 的文字对象。

Adobe Illustrator 文字对象
Adobe Illustrator 的文字对象,只支持自动宽高,修改宽高只会在垂直方向拉伸文字,或是改变字体大小。
另外有一个 区域文字对象,支持在特定的路径下填充文本,是一种更灵活更复杂的表达。
容器除了可以是常规的矩形,也可以是复杂的路径。
修改容器图形的宽高,文字会自动换行去自适应容器。
结尾
自动换行的核心原理,是累积当行文本的宽,当超过容器的固定宽度时,在视觉上加上一个 "软换行"。这种做法会导致逻辑位置(offset)和 可视位置(position)的不一致,在进行一些文本编辑相关操作时,需要做一些转换处理。
当然这里说的是最基础版本的自动换行,之后可以考虑分词处理,基于多个字符形成的词为一个整体进行换行,或是支持一些特殊的效果,比如超出高度的文字不做显示,或是像 Adobe Illustrator 支持不规则的容器。
我是前端西瓜哥,关注我,学习更多文字排版知识。
相关阅读,