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;