图形编辑器开发:文字排版如何实现自动换行?

72 阅读7分钟

大家好,我是前端西瓜哥。

之前我们通过字体解析库,拿到了文字的字形路径数据,实现了 手动换行

但是对于换行,只有手动换行还是不够的。

在文字排版中,我们希望可以给定一个区域宽度,让输入的单行文本超过这个宽度,需要对这行文字进行 “软换行”,将文本拆分成多行。

图片

这个能力就是 “自动换行”。

我的开源图形编辑器项目目前已支持文字自动换行了,欢迎体验。

github.com/F-star/suik…

下面来探究自动换行要如何实现。

排版计算改造

我们之前实现过支持手动换行的文字排版。

图形编辑器:类 Figma 所见即所得文本编辑(2)

思路就是基于文本的换行符 \n 将文本拆分成多个单行文本,然后生成它们的 glyph 字形,基于 glyph 的宽度更新排版的 x、y。

现在要自动换行了,所以要再提供一个 maxWidth 表示最大宽度

在拿到单行文本 glyph 字形的基础上,遍历累加计算总宽度,当宽度超过 maxWidth 的情况下,补上一个 “软换行”,即额外添加  \n 空 glyph 进行占位。

注意这是排版的上 “补充”,并不会修改原文本内容。

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 },  
      width0,  
      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。例如:

|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)的表达

textEditor.selectionManager.setSelection({  
  // 基准位置  
  anchorLineNum0,  
  anchorColumn0,  
  // 聚焦位置  
  focusLineNum0,  
  focusColumn24,  
});

选区位置更新

重要的排版计算和选区模型改造完成后,后面就是一些细节的调整了。

举个例子。

插入光标在视觉第二行的开头,此时我们 按下左方向键,对光标进行左移

在 逻辑索引 上,其实就是将 “m” 字符删除,逻辑索引向左移动 1 个距离。但对于 可视索引,则是要向前移动 2 个距离的("m" 和软换行符)。

这个就是  “逻辑索引” 和 “可视索引” 无法匹配的问题。

解决方案是,先根据插入光标位置的可视索引,转换为逻辑索引,然后减 1,然后再求对应的可视索引。

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 实现:

getOffsetAt(pos: IPosition): number {  
const glyphs = this.getGlyphs();  
const lineNum = Math.min(Math.max(pos.lineNum0), glyphs.length1);  
const line = glyphs[lineNum];  
  
if (line.length === 0) return0;  
  
const column = Math.min(Math.max(pos.column0), line.length1);  
return line[column].logicIndex;  
}

getPositionAt 实现:

getPositionAt(  
  offsetnumber,  
  affinity'upstream' | 'downstream' = 'downstream',  
): IPosition {  
const glyphs = this.getGlyphs();  
  
let isFound = false;  
const position: IPosition = { lineNum0, column0 };  
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.length1) {  
    const nextLine = glyphs[position.lineNum1];  
    if (nextLine.length0 && nextLine[0].logicIndex === offset) {  
      position.lineNum = position.lineNum1;  
      position.column0;  
    }  
  }  
return position;  
}

Figma 文字对象

Figma 的文字对象,同时自适应宽度、固定宽度两种效果。

自适应宽度,表现为文本内容,宽高会自动调整适应文本宽高。固定宽度,则要超出的文本自动换行到下一行。

Figma 的文字对象有一个属性 textAutoResize,表示是否根据文本内容自适应修改宽或高。

它支持的值有:

  1. WIDTH_AND_HEIGHT,属性面板表达为:自动宽度(Auto width),表示宽高都自适应;

  2. HEIGHT,属性面板表达为:自动高度(Auto height),表示宽固定,高自适应;

  3. NONE,属性面板表达为:固定宽高(Fixed Size),表示宽固定,高也固定(文字渲染的实际高度可超出定高);

可以通过属性面板的 Resizing 项下直接修改这个属性。

图片

也可以拖拽控制点修改宽高。

如果当前文字是“自动宽度”策略,当用户修改其宽高属性,如果高度发生改变,会变成“固定宽高”策略。如果高度没改变,但宽度发生改变,则会变成 “自动高度策略”。

图片

创建文字的时候,如果拖拽会产生一个矩形区域,释放时会基于该宽高创建一个使用 “固定宽高” 策略的文字对象。

如果没有发生拖拽,则创建 “自动宽度” 的文字对象。

图片

Adobe Illustrator 文字对象

Adobe Illustrator 的文字对象,只支持自动宽高,修改宽高只会在垂直方向拉伸文字,或是改变字体大小。

另外有一个 区域文字对象,支持在特定的路径下填充文本,是一种更灵活更复杂的表达。

容器除了可以是常规的矩形,也可以是复杂的路径。

修改容器图形的宽高,文字会自动换行去自适应容器。

结尾

自动换行的核心原理,是累积当行文本的宽,当超过容器的固定宽度时,在视觉上加上一个 “软换行”。这种做法会导致逻辑位置(offset)和 可视位置(position)的不一致,在进行一些文本编辑相关操作时,需要做一些转换处理。

当然这里说的是最基础版本的自动换行,之后可以考虑分词处理,基于多个字符形成的词为一个整体进行换行,或是支持一些特殊的效果,比如超出高度的文字不做显示,或是像 Adobe Illustrator 支持不规则的容器。

我是前端西瓜哥,关注我,学习更多文字排版知识。


相关阅读,

图形编辑器:类 Figma 所见即所得文本编辑(2)

 图形编辑器:基于 canvas的所见即所得文本编辑

图形编辑器开发:使用 opentype.js 解析字体并渲染文本

 opentype.js 使用与文字渲染