Twemoji 使用总结
Twemoji 使用总结
定制页面需要支持特定的表情包(twemoji)需要实现的功能:
- 使用 Twemoji 库来渲染 Emoji 字符(本质上就是将 Emoji 字符替换为对应的图片,twemoji 提供了对应的 SVG 图片);
- 要求输入的 Emoji 字符必须是 twemoji 库支持的,否则不进行渲染(特意搜了一下才知道,手机上的 emoji 图形并不是开源的);
- 限制输入的长度(需要限制视觉上的长度,这边发现单个表情最长的有 15 个字符);
主要依赖于 Intl.Segmenter 类和 twemoji 库。
Intl.Segmenter类用于计算视觉上的长度;- 这个类的功能太实用了!去年其实就有类似的需求,但一直没找到合适的解决方案,但这个类在 ES2022 中就引入了。
twemoji库用于渲染 Emoji 字符。- 这个是客户要求使用的表情包的库,提供了表情对应 SVG 格式的图片,比较适合高清打印。
示例代码
获取视觉上的长度及截断处理:
const GRAPHEME_SEGMENTER = new Intl.Segmenter('en', { granularity: 'grapheme' });
/**
* 计算字符串的字素(grapheme)长度
* @param {string} str 原字符串
* @returns {number} 字素长度
*/
function getGraphemeLength(str) {
if (!str) return 0;
const segments = GRAPHEME_SEGMENTER.segment(str);
let count = 0;
for (const _ of segments) {
count++;
}
return count;
}
/**
* 按字素(grapheme)截取字符串
* @param {string} str 原字符串
* @param {number} limit 最大允许长度
* @returns {string} 截取后的字符串
*/
function sliceByGrapheme(str, limit) {
if (!str || limit <= 0) return "";
let result = "";
let currentLength = 0;
const segments = GRAPHEME_SEGMENTER.segment(str);
for (const { segment } of segments) {
// 达到长度上限,直接跳出
if (currentLength >= limit) break;
result += segment;
currentLength++;
}
return result;
}
分割字符串和转换 emoji 为图片 URL 的方法:
/**
* 将字符串解析为文字块和 Emoji 块的数组
* 规则:
* 1. 连续的普通文字合并为一个 type: 'text'
* 2. twemoji 支持的每个表情为一个 type: 'emoji'
* 3. 不支持的表情直接移除
* * @param {string} text 输入字符串
* @returns {Array<{type: 'text'|'emoji', content: string}>}
*/
function parseTextWithEmoji(text) {
if (!text) return [];
const result = [];
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const segments = segmenter.segment(text);
let textBuffer = ""; // 用于收集连续的普通文字
// 内部工具函数:将 buffer 中的文字压入结果数组
const flushTextBuffer = () => {
if (textBuffer.length > 0) {
result.push({ type: 'text', content: textBuffer });
textBuffer = "";
}
};
for (const { segment } of segments) {
// 检查是否为 Emoji 字符
const isEmoji = /\p{Extended_Pictographic}/u.test(segment);
if (isEmoji) {
// 如果是表情,先处理掉之前积攒的文字
if (twemoji.test(segment)) {
flushTextBuffer();
result.push({ type: 'emoji', content: segment });
} else {
// 这里由于不渲染不支持的 Emoji,所以不累积到 buffer 里
// textBuffer += segment;
}
} else {
textBuffer += segment;
}
}
// 循环结束后,如果 buffer 里还有文字,最后清空一次
flushTextBuffer();
return result;
}
/**
* 获取表情图片 URL
* @param {string} emoji 表情字符
* @returns {Promise<string>} 表情图片的 URL
*/
async getEmojiImage(emoji) {
return new Promise((resolve, reject) => {
// 使用 twemoji 解析表情为 HTML 代码
const emojiSvg = twemoji.parse(emoji, {
folder: 'svg',
ext: '.svg',
// 自定义 base URL,指向项目的静态资源目录
// 对应的 SVG 图片在官方仓库里都有,可以下载下来传到自己的 OSS 上
// base: 'https://example.com/static/twemoji/'
});
// 从 SVG 字符串中提取 src
const srcMatch = emojiSvg.match(/src="([^"]+)"/);
if (!srcMatch) {
reject(new Error('无法解析表情'));
return;
}
// 直接返回 SVG URL
resolve(srcMatch[1]);
});
},
画布渲染使用的是 fabric.js 库,将上面获取到的图片 URL 渲染到画布上就可以了。文字和 Emoji 混合时,拆分为多个文字块和 Emoji 块,分别渲染到画布上。
开发时遇到的问题
1、导出定制区域时 Emoji 图片消失的问题
使用 canvas.toDataURL 导出画布中间的定制区域(定制区域只是整个画布中间的一小部分)时,画布中可以正常显示,但是导出的图片中 Emoji 图片消失了。
刚开始以为是是不是表情图片的位置计算有偏差,但是对比画布修改前后的渲染图片,发现 Emoji 图片的位置是一致的,而且相对于整个画布的位置也没有变化。
如果导出整个画布,这些 Emoji 倒是可以正常导出的。
最后发现还是新添加的带表情的文本组的坐标导致的。往组中添加文本或表情图片元素时,使用的是相对坐标(现对于 group 的坐标),而不是绝对坐标。在导出时,fabric.js 会根据坐标判断是否需要渲染该元素,而不是根据元素的实际渲染位置。由于混合文本组中的元素的坐标(相对左边)不在导出的范围内,导致在导出时这些元素被过滤掉了。
解决方法:在导出定制区域时,先将组的坐标设置为绝对坐标,再执行导出。
调用元素的 setCoords() 方法就可以将元素的坐标更新为绝对坐标。
具体的调用时机可以根据业务场景来定,可以在导出前调用,也可以在添加元素时调用。
// 遍历 canvas 中的所有 group,调用 setCoords 方法
canvas.forEachObject((obj) => {
if (obj.type === 'mixed') {
obj.setCoords();
obj.forEachObject(obj => obj.setCoords())
}
});
2、提交画布 JSON 数据时请求体过大的问题
由于后端需要保存画布的信息以供后续使用,所以使用 canvas.toJSON 导出画布的 JSON 字符串并发送请求到后端保存。但在某些服务商的小程序的 WebView 中,提交时报了请求体过大的错误。貌似是内嵌的浏览器限制了 POST 请求体的大小(直接通过浏览器打开时是可以正常提交的)。
调查了下 JSON 的内容,发现 SVG 元素中的 src 属性中保存了整个 SVG 图片的 base64 编码,而不是图片的 URL。
解决方法:移除 src 属性,并增加一些自定义属性保存 SVG 图片的信息以供后续使用。
移除 src 属性貌似并没有影响 SVG 图片的渲染效果。
emojiObj.isSvg = true;
emojiObj.svgSrc = emojiSvgUrl;
delete emojiObj.src;
delete emojiObj._src;