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

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

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

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

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

这个能力就是 "自动换行"。

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

github.com/F-star/suik...

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

排版计算改造

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

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

思路就是基于文本的换行符 \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,表示是否根据文本内容自适应修改宽或高。

它支持的值有:

  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 使用与文字渲染

相关推荐
_AaronWong1 小时前
Vue3+Element Plus 通用表格组件封装与使用实践
前端·javascript·vue.js
全栈老石2 小时前
手写一个无限画布 #3:如何在Canvas 层上建立事件体系
前端·javascript·canvas
晴殇i2 小时前
BroadcastChannel:浏览器原生跨标签页通信
前端·面试
DeathGhost2 小时前
分享URL地址到微信朋友圈没有缩略图?
前端·html
MrBread2 小时前
微任务链式派生阻塞渲染
前端·debug
wuhen_n2 小时前
patch算法:新旧节点的比对与更新
前端·javascript·vue.js
小岛前端2 小时前
Cloudflare 掀桌子了,Next.js 迎来重大变化,尤雨溪都说酷!
前端·vite·next.js
简离2 小时前
前端调试实战:基于 chrome://webrtc-internals/ 高效排查WebRTC问题
前端·chrome·webrtc
十里八乡有名的后俊生2 小时前
深度解析:JavaScript中的import方式 - 静态导入、动态导入与CSS处理机制
前端·javascript·面试