分类: 技术分享

  • 如何使用Office图片压缩功能

    本教程适用:怡氧桌面端

    功能简介

    在怡氧桌面端 v2.5.0 中,针对用户文档中图片体积过大导致的协作加载缓慢问题,新增“图片压缩”功能。该功能可智能压缩文档内图片体积(支持JPEG/PNG格式),可减少50%-80%的图片占用空间,同时保持人眼可接受的清晰度。

    方法/步骤

    第一步:打开文档

    1. 启动怡氧桌面端,打开需要压缩图片的文档
    2. 确保文档中包含需要压缩的图片(目前仅支持JPG/PNG格式)

    第二步:启用图片压缩

    1. 选中图片,可按住 ctrl 键多选图片
    2. 点击右侧工具栏的【图像设置】,在功能面板中选择【压缩图片】
    3. 或右键点击图片,选择菜单中的【压缩图片】

    第三步:设置压缩参数

    在弹出窗口中,顶部可选择:

    • 压缩文档中所有图片
    • 压缩所选图片

    如果文档需要压缩的图片很多,请直接选择【压缩文档中所有图片】

    压缩选项中可以选择:9最佳、8高质量、5质量、3低质量

    第四步:执行压缩

    确认后点击 「确定」,等待进度条完成(大文档约需10-30秒)

    常见问题

    Q:压缩后图片会模糊吗?

    A:不会模糊,图片仍保持原有的分辨率

    Q:能否单独恢复某张图片?

    A:无法恢复,操作前请务必备份文档

    Q:支持压缩PDF中的图片吗?

    A:当前仅支持Office三件套文档类型

    提示:示例文档原尺寸 9.32MB,选择默认的“9最佳”,压缩后的文档大小为2.11MB,减少了77.3%的空间

  • 怡氧桌面端支持 WPS 单元格图片(DISPIMG)公式解析

    背景介绍

    想象一下,你在制作一份电子表格,需要在某些单元格中放入产品图片。在 Excel 中,你只能把图片放在表格的”上层”,就像在表格上贴了一张贴纸。这样的图片是独立的,当你移动或复制单元格时,图片并不会跟着走。

    WPS 为了解决这个问题,创造性地提出了 DISPIMG 公式。这个方案就像是把图片”嵌入”到了单元格中,让图片真正成为单元格的一部分。这样,无论你怎么移动或复制单元格,图片都会跟着走。

    实现机制

    WPS 的单元格图片功能通过三个关键文件相互配合来实现:

    1. 工作表引用(sheet1.xml)- 图片的”索引卡”

    想象你在图书馆查找一本书,首先会查看索引卡片。这里的 DISPIMG 公式就像是一张索引卡,记录了”这个单元格要显示哪张图片”的信息。

    在工作表中,通过 DISPIMG 函数来引用单元格图片:

    <sheetData>
        <row r="1" ht="14.25">
            <c r="C1" t="str">
                <f>DISPIMG("ID_A16C8CE6E39B4F25934D584A1C41E1E1",1)</f>
                <v>=DISPIMG("ID_A16C8CE6E39B4F25934D584A1C41E1E1",1)</v>
            </c>
        </row>
    </sheetData>

    其中, DISPIMG 函数接受两个参数:

    • 图片的唯一标识符(ID)
    • 图片的展示方式(模式参数)

    2. 图片定义(cellimages.xml)- 图片的”档案卡”

    每张图片都有一个详细的”档案卡”(存储在 cellimages.xml 中),记录了这张图片的所有重要信息:

    • 图片的唯一编号(就像身份证号)
    • 图片的显示方式(如何摆放、多大尺寸等)
    • 图片实际存储位置的线索

    特别值得注意的是,WPS 在设计这个”档案卡”时,采用了与 Office 文档相同的标准格式(OOXML)。这不仅确保了其他软件能理解这些信息,更重要的是,这样的设计让开发者可以复用处理传统 Office 浮动图片的代码逻辑,大大减少了重复开发的工作量。

    <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
    <etc:cellImages
        xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing"
        xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
        xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
        xmlns:etc="http://www.wps.cn/officeDocument/2017/etCustomData">
    
        <etc:cellImage>
            <xdr:pic>
                <!-- 1. 图片标识信息 -->
                <xdr:nvPicPr>
                    <xdr:cNvPr id="1773908100" name="ID_A16C8CE6E39B4F25934D584A1C41E1E1"/>
                    <xdr:cNvPicPr>
                        <a:picLocks noChangeAspect="1"/>
                    </xdr:cNvPicPr>
                </xdr:nvPicPr>
    
                <!-- 2. 图片资源引用 -->
                <xdr:blipFill>
                    <a:blip r:embed="rId1"/>
                    <a:stretch/>
                </xdr:blipFill>
    
                <!-- 3. 图片显示属性 -->
                <xdr:spPr bwMode="auto">
                    <a:xfrm>
                        <a:off x="0" y="0"/>
                        <a:ext cx="59436000" cy="39623999"/>
                    </a:xfrm>
                    <a:prstGeom prst="rect">
                        <a:avLst/>
                    </a:prstGeom>
                </xdr:spPr>
            </xdr:pic>
        </etc:cellImage>
    </etc:cellImages>

    3. 资源映射(_rels/cellimages.xml.rels)

    最后,还需要一个清单(cellimages.xml.rels)来记录每张图片实际存放的位置,就像图书馆中的架位表,告诉你具体去哪个书架找这本书。

    <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
    <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
        <Relationship
            Id="rId1"
            Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"
            Target="media/image1.png"/>
        <Relationship
            Id="rId2"
            Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"
            Target="media/image2.png"/>
    </Relationships>

    引用链路说明

    1. 工作表到图片定义的关联

    • 工作表中的 DISPIMG 函数通过图片 ID 引用图片
    • 图片 ID 对应 cellimages.xml 中 xdr:cNvPr 的 name 属性
    • 这确保了每个单元格能准确找到其对应的图片定义

    2. 图片定义到实际资源的关联

    • cellimages.xml 中通过 r:embed 属性引用图片资源
    • r:embed 的值(如 “rId1″)对应 cellimages.xml.rels 中的 Id
    • cellimages.xml.rels 将这个 Id 映射到实际的图片文件路径

    3. 资源存储结构

    xl/
    ├── _rels/
    │   └── cellimages.xml.rels
    ├── media/
    │   ├── image1.png
    │   └── image2.png
    └── worksheets/
        └── sheet1.xml
    └── cellimages.xml

    为什么要这样设计?

    这种设计的巧妙之处在于:

    1. 模块化:每个部分职责明确,就像图书馆中的索引系统、档案系统和实际书架是分开管理的。这样便于维护和更新。
    2. 标准化:采用通用的 OOXML 标准,就像使用通用的图书分类法,确保其他软件也能读懂这些信息。
    3. 灵活性:图片可以方便地随单元格移动,就像书籍可以轻松地在书架间调整位置,而索引系统会自动更新。
    4. 高效性:通过 ID 和引用关系,系统可以快速定位到需要的图片,就像图书馆的编码系统让查找书籍变得简单高效。

    这种设计让整个功能既实用又可靠,就像一个运作良好的图书管理系统,每个部分都各司其职,又紧密配合。

    前端渲染实现

    在前端实现中,需要特别处理 DISPIMG 公式的渲染逻辑:

    1. 公式识别
    • 在非编辑模式下,首先识别单元格内容是否为 DISPIMG 公式
    • 解析公式参数,获取图片 ID 和显示模式
    1. 图片渲染
    • 根据图片 ID 获取对应的图片资源
    • 使用 Canvas API 进行图片绘制
    • 通过 canvas.clip() API 限制绘制区域在当前单元格范围内,避免图片溢出到其他单元格
    • 使用 drawImage() API 将图片绘制到画布上

    示例代码:

    function renderCellImage(ctx, cell, image) {
      // 保存当前画布状态
      ctx.save();
    
      // 创建裁剪路径(限制在单元格范围内)
      ctx.beginPath();
      ctx.rect(cell.x, cell.y, cell.width, cell.height);
      ctx.clip();
    
      // 绘制图片
      ctx.drawImage(image, cell.x, cell.y, cell.width, cell.height);
    
      // 恢复画布状态
      ctx.restore();
    }

    结论

    WPS 的单元格图片功能通过巧妙的文件组织和引用机制,实现了图片与单元格的绑定。这种实现方式不仅保持了与标准 Office Open XML 的兼容性,还提供了灵活的图片管理和显示控制能力。理解这种实现机制对于开发类似功能或进行文件格式兼容都有重要的参考价值。

  • 怡氧桌面端 Office 文档中的图片压缩实现

    引言

    在当今数字化办公环境中,Office 文档已成为企业日常协作的重要载体。随着高清图片的广泛应用,文档中的图片不仅丰富了表达内容,提升了文档的可读性和专业性,但同时也带来了一系列技术挑战:

    • 存储负担:大量高清图片会显著增加文档体积,占用企业宝贵的存储资源
    • 单张高清图片可能达到数 MB 大小
    • 一份文档动辄包含数十张图片
    • 企业级存储成本居高不下
    • 传输效率:过大的文件会影响文档的上传下载速度
    • 网络带宽受限时下载缓慢
    • 移动办公场景下体验更差
    • 多人同时访问时服务器压力大
    • 协作体验:文档加载缓慢会影响多人协作效率
    • 打开大文档耗时长
    • 页面渲染卡顿
    • 实时协作延迟高
    • 影响团队工作效率
    • 版本管理:大体积文档的版本控制也面临挑战
    • 占用更多版本存储空间
    • 历史版本对比耗时增加
    • 文档备份成本提高

    为了优化这些问题,我们在怡氧桌面端中开发了智能图片压缩功能。该功能可以在保证图片视觉质量的前提下,有效降低文档体积,提升协作效率。本文将从技术选型、压缩策略、性能优化等多个维度,详细介绍这一功能的设计思路和实现细节。

    技术方案概述

    我们的技术选型主要基于以下考虑:

    1. Sharp:选择 Sharp 作为核心图像处理库
    • 基于 libvips 实现,C++ 底层保证高性能
    • 内存占用低,适合批量处理
    • 支持主流图片格式,压缩效果优异
    1. Electron Utility Process
    • 将 CPU 密集型的压缩任务隔离到独立进程
    • 避免阻塞主进程,保证界面响应流畅
    1. p-limit
    • 智能控制并发任务数量
    • 优化系统资源利用

    压缩策略详解

    在怡氧桌面端中,我们主要处理 JPEG 和 PNG 这两种最常见的图片格式。针对不同的格式,我们采用了不同的压缩策略。

    JPEG 压缩策略

    JPEG 格式主要用于照片等色彩丰富的图像,我们采用以下策略:

    1. 质量控制
    • 默认质量值设置为 75
    • 通过 estimateQuality 函数评估原图质量
    • 压缩后的质量不超过原图质量
    1. 优化选项
    • 启用 mozjpeg 优化
    • 保留重要的元数据信息
    • 移除不必要的颜色配置文件

    示例代码:

    async function compressJPEG(buffer: Buffer, qualityFactor: number) {
      const originalQuality = await estimateQuality(buffer);
      const targetQuality = Math.min(75, originalQuality);
    
      return sharp(buffer)
        .jpeg({
          quality: Math.round(targetQuality * qualityFactor),
          mozjpeg: true,
        })
        .toBuffer();
    }

    为什么要评估原图质量?

    在图片压缩过程中,评估原图质量是一个非常重要的步骤,主要有以下几个原因:

    1. 避免过度压缩
    • 如果原图已经经过压缩(比如质量值为 60),再使用更高的质量值(如 75)进行压缩反而会增加文件大小
    • 通过评估原图质量,我们可以确保压缩后的质量不会超过原图,避免不必要的文件体积增加
    1. 智能质量控制
    • JPEG 图片的质量值范围是 0-100
    • 通过分析量化表(Quantization Tables),我们可以估算出原图的压缩质量
    • 这样可以根据原图质量动态调整压缩参数,而不是简单地使用固定值
    1. 保持图片质量平衡
    • 对于已经高度压缩的图片,继续压缩可能会导致明显的质量损失
    • 通过评估原图质量,我们可以在文件大小和视觉质量之间找到更好的平衡点

    质量评估的实现原理

    我们通过分析 JPEG 文件的量化表来估算图片质量:

    1. 基准量化表
    • 使用 JPEG 标准中定义的基准亮度量化表(质量值 50)
    1. 比较算法
    • 提取图片中的量化表
    • 将实际量化表与基准表进行比较
    • 计算缩放因子并使用经验公式估算质量值
    1. 质量计算
    • 当缩放因子小于 1 时,表示质量低于 50
    • 当缩放因子大于 1 时,表示质量高于 50
    • 最终结果被限制在 0-100 范围内

    这种方法让我们能够智能地控制压缩过程,避免压缩后文件变大的问题。

    PNG 压缩策略

    PNG 格式常用于需要透明度的图像和图标,压缩策略如下:

    • 使用最高压缩级别(level 9)
    • 启用调色板模式

    为什么使用调色板模式?

    调色板模式(Palette Mode)是 PNG 格式中一种重要的优化技术,具有以下优势:

    1. 数据存储优化
    • 传统 PNG 使用 RGB/RGBA 模式,每个像素需要 3-4 个字节
    • 调色板模式将颜色信息存储在一个查找表中
    • 每个像素只需要一个索引值(通常是 1 个字节)来引用颜色表
    1. 适用场景
    • 对于颜色数量有限的图像(如图标、Logo)效果最佳
    • 当图像中重复颜色较多时,可显著减小文件大小
    • 特别适合扁平化设计的 UI 元素
    1. 压缩效率
    • 减少了像素数据的存储空间
    • 提高了 DEFLATE 算法的压缩效率
    • 可以在保持视觉质量的同时大幅减小文件体积
    1. 智能转换
    • Sharp 库会自动评估是否适合使用调色板模式
    • 如果颜色过多,会自动降级到标准 RGB/RGBA 模式
    • 确保最终输出的图像质量

    通用优化策略

    对于所有图片格式,我们都采用以下通用策略:

    1. 阈值控制
    • 设置最小压缩阈值(50KB)
    • 只在压缩效果显著时才保存
    • 避免过度压缩
    1. 性能优化
    • 使用 Utility Process 处理压缩任务
    • 根据 CPU 核心数控制并发
    • 批量处理时分批执行

    并发控制实现

    在处理大量图片时,我们使用 p-limit 库来控制并发任务数量。具体实现如下:

    1. 动态并发数设置
    // 根据 CPU 核心数确定并发数
    const concurrency = Math.max(os.cpus().length - 1, 1);
    const limit = pLimit(concurrency);
    1. 任务队列管理
    // 将所有图片压缩任务映射为限制并发的 Promise
    const tasks = images.map((image) =>
      limit(async () => {
        try {
          const imageBuffer = await readFile(image);
          // ... 压缩处理逻辑 ...
    
          completed++;
          // 更新进度
          const progressPercentage = Math.round((completed / totalImages) * 100);
          process.parentPort.postMessage({
            type: 'loading',
            payload: {
              progress: `${progressPercentage}%`,
            },
          });
    
          return sizeReduction;
        } catch (error) {
          console.error(`Error processing ${image}:`, error);
          return 0;
        }
      })
    );

    总结

    总的来说,我们在怡氧桌面端里做了个挺实用的功能 – Office 文档图片压缩。这个功能直接帮用户解决了文档太大、打开慢的问题,让大家协作起来更顺畅了。

    参考链接

    1. Sharp – High performance Node.js image processing
    2. Electron Utility Process – 实用程序进程
    3. p-limit – Run multiple promise-returning & async functions with limited concurrency
  • “内存共享”与“Transferable Objects”

    在之前的优化工作中,我们将 x2t 移植到 WASM,并尝试在 JS 和 WASM 之间共享内存,以期减少数据拷贝,提升性能。然而,我们在实践中遇到了一些问题,特别是 **Segmentation Fault** 错误,这促使我们对 WASM 的内存模型以及 JS 和 WASM 共享内存的机制进行了更深入的研究。

    WASM 的线性内存模型

    WASM 实例拥有一个线性内存(Linear Memory),它本质上是一个 `ArrayBuffer`,可以被 WASM 代码和 JS 代码同时访问。 这个线性内存是 WASM 程序存储数据的主要场所。

    • **内存页(Memory Page):** WASM 的线性内存被划分为大小固定的内存页,每页通常为 64KB。
    • **内存增长:** WASM 程序可以通过 `memory.grow` 指令增加线性内存的大小,每次增加一页。线性内存的最大大小受限于 WebAssembly.Memory 实例的配置,通常为 4GB (65536 页)。
    • **地址空间:** WASM 代码通过线性地址(Linear Address)访问内存,线性地址是相对于线性内存起始位置的偏移量。

    JS 如何访问 WASM 内存

    JS 可以通过 `WebAssembly.Memory` 实例的 `buffer` 属性获取线性内存的 `ArrayBuffer` 对象。 拿到 `ArrayBuffer` 后,JS 就可以像操作普通数组一样读写 WASM 的内存了。

    const wasmMemory = new WebAssembly.Memory({ initial: 10 }); // 初始 10 页
    const buffer = wasmMemory.buffer; // 获取 ArrayBuffer
    const uint8Array = new Uint8Array(buffer); // 创建 TypedArray 视图
    
    // JS 写入数据到 WASM 内存
    uint8Array[0] = 42;
    
    // 从 WASM 内存读取数据
    const value = uint8Array[0];
    console.log(value); // 输出 42

    JS 与 WASM 共享内存的挑战

    虽然 JS 可以直接访问 WASM 的线性内存,但共享内存也带来了一些挑战:

    1. **内存对齐(Memory Alignment):**
      • 不同类型的数据需要按照特定的字节对齐。例如,32 位整数通常需要 4 字节对齐。
      • 如果 JS 和 WASM 以不同的方式访问未对齐的内存,可能会导致性能下降,甚至出现错误。
      • **解决方案:** 确保 JS 和 WASM 使用相同的内存对齐方式。可以使用 `DataView` 对象进行精确的内存访问。
    2. **数据类型转换(Data Type Conversion):**
      • JS 和 WASM 使用不同的数据类型表示方式。 例如,JS 的数字是 64 位浮点数,而 WASM 可以使用 32 位整数、64 位整数等多种类型。
      • 在 JS 和 WASM 之间传递数据时,需要进行类型转换。
      • **解决方案:** 使用 `TypedArray` (例如 `Int32Array`, `Float64Array`) 在 JS 中创建与 WASM 内存布局匹配的视图。
    3. **竞争条件(Race Conditions):**
      • 如果 JS 和 WASM 同时访问同一块内存,可能会出现竞争条件,导致数据不一致。
      • **解决方案:** 使用 `Atomics` API 进行同步。 `Atomics` API 提供了一些原子操作,例如原子加、原子减、原子比较和交换等,可以保证并发访问的安全性。
    4. **Segmentation Fault 的原因及解决:**
      • 我们遇到的 Segmentation Fault 错误,可能由以下原因导致:
        • **越界访问:** JS 或 WASM 访问了线性内存范围之外的地址。这可能是由于计算错误或内存增长未同步导致的。
        • **非法地址:** 访问了未分配或受保护的内存地址。
        • **数据竞争:** 尽管使用了 Atomics API,但在某些复杂的并发场景下,仍然可能存在竞争条件。
      • **解决方法:**
        • **仔细检查内存访问逻辑:** 确保 JS 和 WASM 的内存访问都在线性内存的有效范围内。
        • **使用调试工具:** 利用 WASM 调试工具 (例如 Chrome DevTools 的 WebAssembly Inspector) 跟踪内存访问,找出错误的根源。
        • **更细粒度的同步:** 考虑使用更细粒度的 Atomics 操作,或者采用其他的同步机制 (例如互斥锁) 来保护共享内存。
        • **避免复杂的数据结构共享:** 尽量避免在 JS 和 WASM 之间共享复杂的数据结构。如果必须共享,可以使用序列化/反序列化来传递数据。

    使用 Atomics API 进行同步的注意事项

    虽然 `Atomics` API 可以用来同步 JS 和 WASM 的内存访问,但也需要注意以下几点:

    • `Atomics.wait` 只能在 WebWorker 中调用,不能在主线程中使用。
    • `Atomics` 操作仍然有一定的性能开销,过度使用可能会影响性能。
    • 在复杂的并发场景下,需要仔细设计同步策略,避免死锁等问题。

    什么是 Transferable Objects?

    Transferable Objects 是一种特殊的对象,它们在传递时不会进行数据复制,而是直接将对象的底层内存的所有权从一个上下文转移到另一个上下文。 这意味着传递后,原始上下文将无法再访问该对象,而接收上下文则拥有该对象的完整控制权。

    常见的 Transferable Objects 包括:

    • `ArrayBuffer`
    • `MessagePort`
    • `ImageBitmap`
    • `OffscreenCanvas`

    **Transferable Objects 的优势:**

    1. **零拷贝(Zero-Copy):** 这是 Transferable Objects 最大的优势。 由于没有数据复制,传递速度非常快,尤其适合传递大型数据块。
    2. **避免内存竞争:** 由于所有权转移,只有一个上下文可以访问该对象,因此可以避免内存竞争和数据不一致的问题。 这简化了并发编程,无需使用复杂的同步机制。

    **Transferable Objects 的劣势:**

    1. **所有权转移:** 这是 Transferable Objects 的一个限制。 传递后,原始上下文将无法再访问该对象。 如果需要同时在多个上下文中使用同一份数据,Transferable Objects 就不适用了。
    2. **对象类型限制:** 只有特定类型的对象才能作为 Transferable Objects 传递。 常见的对象类型 (例如普通 JavaScript 对象) 不支持 Transferable Objects。
    3. **单向传递:** Transferable Objects 通常用于单向传递数据。 如果需要在两个上下文之间频繁地交换数据,效率可能不高。

    **Transferable Objects 方案与内存共享方案的对比:**

    特性Transferable Objects内存共享 (SharedArrayBuffer)
    数据拷贝零拷贝需要拷贝 (初始拷贝), 后续修改共享
    并发安全天然安全,无需同步机制需要使用 Atomics API 进行同步,增加了复杂性
    对象类型仅支持特定类型 (ArrayBuffer, MessagePort, ImageBitmap, OffscreenCanvas)支持任意类型,但需要序列化/反序列化
    使用场景适用于单向传递大型数据块的场景,例如:从 WebWorker 向主线程传递处理后的图像数据适用于多个线程/上下文需要同时访问和修改同一份数据的场景,例如:多人协作编辑文档
    复杂性简单复杂,需要处理内存对齐、数据类型转换、竞争条件等问题
    适用性适合 WASM X2T 的场景,X2T 在 WebWorker 中处理文件后,将 `Editor.bin` 数据通过 Transferable Objects 传递给主线程,主线程接收数据后,WebWorker 就不再需要这份数据了。这样可以避免数据拷贝,提高性能。不太适合 WASM X2T 的场景。X2T 的主要需求是将处理结果传递给主线程,而不是共享数据。如果使用内存共享,需要在 JS 和 WASM 之间进行复杂的同步,反而会增加复杂性。
    与 WASM 结合非常适合: WASM 在 WebWorker 中处理数据后,可以使用 Transferable Objects 将结果传递给主线程,无需任何数据拷贝。可以结合: JS 和 WASM 可以共享线性内存,但需要小心处理内存对齐、数据类型转换和竞争条件。
    WebWorker 支持良好良好

    **结论:**

    • Transferable Objects 是一种简单而高效的数据传递方式,适用于单向传递大型数据块的场景。
    • 内存共享 (SharedArrayBuffer) 适用于多个线程/上下文需要同时访问和修改同一份数据的场景,但实现起来更加复杂。

    **在 WASM X2T 场景中,Transferable Objects 可能是更好的选择。** 因为 X2T 的主要需求是将处理结果传递给主线程,而不是共享数据。 使用 Transferable Objects 可以避免数据拷贝,提高性能,并且简化并发编程的复杂性。

  • 怡氧文件打开速度优化:一次步步深入的探索

    作为一名关注用户体验的工程师,我一直对“怡氧”打开文件的速度有所关注。提升打开速度,改善用户体验,是我们一直在努力的方向。本文将分享我们团队在优化文件打开速度过程中的一些探索和尝试。

    问题分析:性能瓶颈在哪里?

    经过一番分析,我们发现文件打开的耗时主要集中在以下几个阶段:

    1. **JS SDK 加载:** 庞大的 `sdkjs` 文件在加载和解析时会消耗一定时间。
    2. **字体文件加载:** 渲染文字需要加载多个字体文件,部分字体文件体积较大,加载需要时间。
    3. **X2T 解析:** 调用 `x2t` 将文件解析为 `Editor.bin`,再将文件加载到 JS 环境中。

    其中,`x2t` 是一个关键环节,涉及到文件格式转换,是文档打开流程中不可或缺的一步。

    方案探索:各种尝试与思考

    针对以上瓶颈,我们团队集思广益,尝试了一些优化方案,其中一些方案也带来了一些新的思考。

    • **字体文件缓存 + Memmap?**我们曾考虑过缓存字体文件,然后使用 `memmap` 直接加载。`memmap` 可以将文件映射到内存,减少重复读取。为了实现这个方案,我们还研究了 Electron 的 `nodeIntegration` API,尝试在渲染线程调用 Node.js 环境。
      https://www.electronjs.org/docs/latest/api/browser-window#new-browserwindowoptions nodeIntegration
      测试用的 npm 库 https://www.npmjs.com/package/mmap-io 然而,在实际尝试中,我们发现 `memmap` 存在一些问题:Page Fault Remap 时可能会阻塞渲染线程,导致界面出现卡顿。

      考虑到 Electron 的版本升级,以及 V8 引擎对外部内存访问的限制,我们最终放弃了这个方案。
    • **V8 Heap Snapshot?**我们也曾考虑过利用 V8 Heap Snapshot 加速加载。但很快我们意识到,Heap Snapshot 只是内存快照,本质上是数据文件,并不能直接用于代码执行。JavaScript 代码仍然需要经过解析和编译才能执行,这个过程无法省略。
      Heap Snapshot 不是可执行代码: Heap Snapshot 是 V8 引擎内存状态的快照,它本质上是一个数据文件,包含了对象、类型、引用关系等信息。它不是可以直接执行的 JavaScript 代码或者编译后的机器码。编译是必须的: JavaScript 引擎必须先将 JavaScript 代码解析成抽象语法树 (AST),然后再将 AST 编译成机器码才能执行。 这个编译过程是不可避免的。 Heap Snapshot 只能在代码执行后生成。
      因此,这个方案也未能达到预期的效果。

    新思路:WASM 的应用

    在探索过程中,我们逐渐将目光转向 WASM:**将 `x2t` 运行在 WASM 中!**

    这样做具有以下潜在优势:

    1. **提高文件解析效率:** WASM 具有接近原生代码的执行效率,可以提升 `x2t` 的解析速度。
    2. **增强安全性:** WASM 运行在沙箱环境中,可以降低恶意文件对后端造成影响的风险。

    令人欣喜的是,我们发现 GitHub 上已经有开发者完成了将 `x2t` 编译为 WASM 的工作:https://github.com/cryptpad/onlyoffice-x2t-wasm

    借助 WASM,我们可以将原来通过 Node.js `child_process.spawn` 调用可执行程序的过程转移到渲染线程。并且,我们还可以考虑直接通过内存将 `Editor.bin` 内容传递给渲染进程,减少文件读写的步骤。

    技术调研:JS 与 WASM 的数据交互

    确定 WASM 的方向后,我们需要解决一个重要问题:如何在 JS 和 WASM 之间高效地交换数据?

    我们向 AI 助手咨询了 JS 和 WASM 数据交换的多种方式,得到了以下方案:

    // 1. Direct numeric parameter passing (fastest, but limited to numbers)
    // 2. Linear Memory Access (high performance for bulk data)
    // 3. SharedArrayBuffer (zero-copy, but requires cross-origin isolation)
    // 4. Structured Data Exchange (for complex objects)
    // 5. Transferable Objects (e.g., ArrayBuffer)
    // 6. WebAssembly.Table for function references
    // 7. Streaming SIMD Data
    // 8. Asyncify for Async Operations
    // 9. Web Workers with SharedArrayBuffer
    // 10. Atomics for Synchronization

    为了找到适合我们的方案,我们进行了性能测试。

    性能测试:数据驱动决策

    我们利用工具辅助,编写了一系列测试程序,针对浏览器相关 API (WASM Shared Memory, Atomics, SharedArrayBuffer) 进行了较为详细的性能测试。

    测试结果显示:

    • 在 JS 渲染线程和 WebWorker 中,性能差异不明显,但大块数据的处理可能会阻塞 UI。
    • WASM 需要使用 `WebAssembly.Memory` 实例化对象,`SharedArrayBuffer` 对象不能直接传递给 WASM 使用。
    • 不使用 `WebAssembly.Memory` 实例化 Memory 对象,而是直接通过 WASM 实例化的对象访问 WASM 中的内存是可行的。
    • `WebAssembly.Memory` 存在内存大小限制。
    • JS 和 WASM 两个交替操作相同位置的内存时,可能会出现问题,需要用 Atomics API 进行同步。但在某些情况下,即便使用了 Atomics API,WASM 读写数据仍然可能出现问题。

    我们还对 WASM 读写文件的性能进行了测试和优化,并取得了一定的进展:

    数据量优化前 (ms)优化后 (ms)
    64M11805.45134.99
    256M46676.62576.21
    512M94539.741264.35
    1G188446.262676.79

    此外,我们还测试了 `Transferable Objects` 的性能,结果表明,在 WebWorker 中使用 `Transferable Objects` 传递大文件,能够带来明显的性能提升。

    场景耗时 (ms)
    IPC1788
    WebWorker357.599
    WebWorkerTransfer145.4

    在数据传递方面,我们对比了内存共享和 Transferable Objects 两种方案。 内存共享需要复杂的同步机制,而 Transferable Objects 则提供了一种“所有权转移”的零拷贝传递方式。 考虑到 WASM X2T 的场景是将处理结果传递给主线程,而不是共享数据,我们最终选择了 Transferable Objects 方案。 这样可以避免数据拷贝,提高性能,并且简化并发编程的复杂性。详细说明请参考这篇文章

    方案选择:WebWorker + WASM X2T

    综合考虑测试结果,我们选择了以下方案:

    1. 在渲染进程中通过 WebWorker 执行 WASM X2T。
    2. 利用 Electron 的 `nodeIntegrationInWorker` 在 Worker 中使用 Node.js 的 `fs` 模块处理文件。
    3. 使用 Transferable Objects 在主线程和 WebWorker 之间传递数据。

    字体文件处理:自定义协议

    值得一提的是,目前版本的“怡氧”已经在使用 WASM 处理字体源文件了。加载方式是通过自定义协议,可以实现类似直接磁盘 IO 的效果。

    总结与展望

    目前,我们已经完成了文件打开速度优化方案的设计和技术验证。方案能否达到预期效果,还需要在实际开发完成后进行进一步测试。

    这次优化过程充满了挑战,我们也在不断探索和学习。在后续的开发中,我们也会继续关注性能,持续优化用户体验。

    如果您在文件处理优化方面有经验,欢迎在评论区分享您的见解!

  • 解决 Emoji 渲染难题:Unicode Range 与 JavaScript 的完美结合

    Emoji,这些生动形象的表情符号,已经成为我们日常沟通中不可或缺的一部分。然而,在某些特定的技术场景下,例如在自研的字符渲染引擎中,要正确渲染 Emoji 却并非易事。本文将深入探讨 Emoji 的 Unicode Range,以及如何在 JavaScript 中正确处理 Emoji 的 Unicode,最终实现 Emoji 的完美渲染。

    Emoji 与 Unicode Range

    Unicode 是一种国际标准字符集,它为世界上几乎所有的字符都分配了一个唯一的数字编码,也就是码点。Emoji 也不例外,它们被分配在 Unicode 的多个码点范围内,通常位于补充平面(Supplementary Multilingual Plane,SMP)中,这意味着它们的码点值大于 `0xFFFF`

    常见的 Emoji Unicode Range 包括:

    • **Emoticons (1F600–1F64F):** 包含各种面部表情,如笑脸、哭脸、惊讶等等。
    • **Symbols & Pictographs (1F300–1F5FF):** 包含各种符号和象形文字,如食物、交通工具、地点等等。
    • **Transport & Map Symbols (1F680–1F6FF):** 包含交通工具和地图相关的符号。
    • **Miscellaneous Symbols and Pictographs (1F900–1F9FF):** 包含各种杂项符号和象形文字。

    由于 Emoji 的码点值可能超出基本多文种平面(Basic Multilingual Plane,BMP),因此传统的字符处理方法可能会遇到问题。

    两种渲染模式下的 Emoji 挑战

    在我们的场景中,存在两种字符渲染模式:

    1. **基于字体文件的字符渲染:** 这种模式依赖于字体文件来渲染字符,包括 Emoji。如果缺少 Emoji 字体文件,则无法渲染 Emoji。即使提供了 Emoji 字体,如何正确识别和处理 Emoji 的 Unicode 也是一个挑战。
    2. **极速渲染模式(Canvas.fillText):** 这种模式直接使用 Canvas 的 `fillText` 方法来绘制字符。在这种模式下,只要能正确处理字符的 Unicode,就可以相对简单地实现 Emoji 渲染。

    无论哪种模式,正确处理 Emoji 的 Unicode 都是关键。

    传统方法的问题:charCodeAt 和 fromCharCode 的局限性

    在过去的代码中,我们使用 `String.charCodeAt` 方法和 `String.fromCharCode` 方法来处理 Unicode 和还原字符。然而,这两种方法存在一个很大的局限性:它们只能处理码点值在 `0x0000``0xFFFF` 之间的字符,无法正确处理 Emoji 的 Unicode。

    例如,对于一个码点值为 `0x1F600` 的 Emoji,`charCodeAt` 方法只能返回 `0xD83D``fromCharCode` 方法也只能处理小于 `0xFFFF` 的值。这导致 Emoji 无法正确识别和渲染。

    解决方案:拥抱 codePointAt 和 fromCodePoint

    为了解决这个问题,我们需要使用 `String.codePointAt` 方法和 `String.fromCodePoint` 方法来替代 `String.charCodeAt` 方法和 `String.fromCharCode` 方法。

    • **`String.codePointAt(index)`:** 返回字符串中指定索引位置字符的 Unicode 码点值。即使字符的码点值大于 `0xFFFF`,也能正确返回。
    • **`String.fromCodePoint(codePoint)`:** 使用指定的 Unicode 码点值创建一个字符串。

    通过使用这两个方法,我们可以正确地获取和还原 Emoji 的 Unicode 码点值,从而为 Emoji 的正确渲染奠定基础。

    // 获取 Emoji 的 Unicode 码点
    const emoji = '😀';
    const codePoint = emoji.codePointAt(0); // 128512 (0x1F600)
    
    // 使用 Unicode 码点还原 Emoji
    const restoredEmoji = String.fromCodePoint(codePoint); // "😀"

    字符遍历与字形测量

    在使用 `codePointAt``fromCodePoint` 后,我们还需要注意一个问题:由于 Emoji 的码点值可能大于 `0xFFFF`,这意味着一个 Emoji 字符可能占用两个 JavaScript 字符的位置(UTF-16 编码)。

    因此,在之前逐个字符进行测量和填充的逻辑中,我们需要识别一个字符是否占用两个单位的字符,然后进行相应的处理。

    例如,我们需要判断 `codePointAt` 返回的码点值是否大于 `0xFFFF`,如果大于,则需要跳过下一个字符,因为它实际上是当前 Emoji 的一部分。

    const text = 'Hello 😀 World';
    for (let i = 0; i < text.length; i++) {
      const codePoint = text.codePointAt(i);
      console.log(`Character at index ${i}: ${String.fromCodePoint(codePoint)}, Code Point: ${codePoint}`);
    
      if (codePoint > 0xFFFF) {
        i++; // 跳过下一个字符
      }
    
      // 进行字符测量和填充
      // ...
    }

    Combined Emoji 的挑战

    除了单个 Emoji 之外,还存在一种叫做 Combined Emoji 的特殊类型。这些 Emoji 由多个 Unicode 字符组合而成,例如肤色修饰符、性别符号等等。

    Combined Emoji 的渲染是一个更加复杂的问题,因为我们需要正确地识别和组合这些字符,才能渲染出正确的 Emoji。这可能需要对整个渲染逻辑进行重构,将字符串作为一个整体进行测量和渲染,而不是逐个字符进行处理。

    然而,如何正确地分割字符串,以识别 Combined Emoji 的边界,又是一个非常棘手的问题。这可能需要借助复杂的 Unicode 规范和正则表达式来实现。

    总结与展望

    通过使用 `String.codePointAt``String.fromCodePoint` 方法,我们可以有效地解决 Emoji 的 Unicode 处理问题,为 Emoji 的正确渲染奠定基础。然而,Combined Emoji 的渲染仍然是一个挑战,需要进一步的研究和实践。

    未来,我们可以考虑以下方向:

    • **Unicode 规范的深入研究:** 更加深入地了解 Unicode 规范,特别是关于 Emoji 和 Combined Emoji 的部分。
    • **正则表达式的应用:** 使用正则表达式来识别和分割 Combined Emoji。
    • **渲染引擎的重构:** 将字符串作为一个整体进行测量和渲染,而不是逐个字符进行处理。

    希望本文能够帮助你更好地理解 Emoji 的 Unicode Range,以及如何在 JavaScript 中正确处理 Emoji 的 Unicode,最终实现 Emoji 的完美渲染。

  • 解析 EO2Suite:增强怡氧文档编辑器的浏览器插件

    EO2Suite 是一个功能丰富的浏览器插件,专为怡氧(EO2)文档编辑器设计。它通过优化下载管理、用户界面和文件处理等方面,显著提升了用户的文档编辑体验。

    现在,你可以简单地下载这个插件,然后在浏览器中安装它。

    我们提供了一个示例文件,当你安装完插件后,可以尝试下载该示例文件,体验在浏览器中畅快地编辑office的感觉。

    Popup 界面

    EO2Suite 的 popup 界面为用户提供了直观的操作入口。通过点击浏览器工具栏上的插件图标,用户可以打开这个弹出窗口。界面设计简洁明了,主要包括文件管理、规则配置和设置选项等功能模块。

    我们内置了一些规则,用于识别和拦截常用的 Office 文件类型,如 PPT、Word 和 Excel 表格。这些规则确保插件能够自动处理用户在浏览器中下载的 Office 文件,并为其创建在线编辑器实例。用户也可以自己修改或者添加任意数量的正则表达式。

    网络请求拦截

    EO2Suite 通过拦截特定的网络请求,实现了对文件的自动管理和编辑器的创建。插件根据用户设置的规则监控浏览器发出的网络请求,并对符合条件的请求进行拦截和处理。

    在线编辑器创建

    在拦截到符合条件的网络请求后,EO2Suite 会自动为用户创建一个在线文档编辑器实例。插件通过上传文件数据并调用服务器 API,获取编辑器的访问链接,并在新的浏览器标签页中打开该链接,加载文档编辑器的用户界面。

    服务器发送事件 (SSE) 的应用

    SSE 是 EO2Suite 中的一个关键技术,负责实时监控文件上传的状态。以下是其具体实现和作用:

    • 实时更新机制:SSE 允许服务器向客户端推送实时更新。EO2Suite 使用 SSE 来监听文件上传完成事件,以便在上传完成后立即更新本地存储的数据。这确保了用户在编辑器中始终使用最新的文件版本。
    • 实现细节:在插件的 SSE 接口实现中,服务器为每个上传的文件生成一个唯一的事件流。当文件上传完成时,服务器会通过事件流通知客户端。客户端接收到通知后,会更新本地的文件数据,并关闭事件流以释放资源。

  • 怡氧 字体 Fallback 机制:从乱码到优雅显示

    在日常办公中,我们经常需要在不同的设备、不同的系统中打开文档。为了保证文档在各种环境下都能呈现出作者希望的效果,字体扮演着至关重要的角色。然而,在线文档预览场景下,字体渲染却面临着诸多挑战。今天,就让我们一起深入探讨 怡氧 在字体 fallback 机制上所做的努力,以及我们如何在此基础上进行优化,让在线文档预览更加完美。

    在线字体渲染的难题

    在线文档预览,与本地应用最大的不同在于,我们无法直接访问用户的本地字体文件。这导致了两个核心问题:

    1. **字体资源有限:** 我们无法像本地应用一样,依赖用户系统安装的字体。为了显示效果,我们需要从服务器下载字体文件。
    2. **字体版权限制:** 商业字体通常需要授权才能使用,我们不能直接将这些付费字体放在服务器上供用户下载。因此,我们只能选择使用免费字体。

    这两个难题使得在线文档的字体 fallback 变得尤为重要。如果用户设置的字体在我们的字体列表中不存在,就需要找到一个合适的替代字体。如果 fallback 逻辑不佳,就会导致文档显示错乱,严重影响用户体验。

    怡氧 的字体 fallback 机制:一个精细的“寻字”之旅

    怡氧 在这方面做出了很多努力。他们并没有简单粗暴地选择一个默认字体,而是建立了一套复杂的 fallback 机制,其核心思路可以概括为“惩罚值计算”+“Unicode 索引”。

    1. 字体字典:信息完备的“字体地图”

    首先,怡氧 维护了一个庞大的字体字典,其中包含了:

    • **字体名称:** 例如:Arial、Times New Roman 等。
    • **Unicode 范围:** 描述该字体支持的 Unicode 字符范围。
    • **字体文件大小:** 用于评估下载成本。

    这个字体字典相当于一个详细的“字体地图”,帮助 怡氧 快速定位可用的字体资源。

    2. 惩罚值计算:优中选优的替代策略

    当用户设置的字体不在字典中时,怡氧 会根据字典中的信息,计算每个候选字体的“惩罚值”。这个惩罚值综合考虑了以下因素:

    • **字体名称的相似度:** 例如,如果用户设置的字体名为 “Arial Bold”,那么字典中名为 “Arial” 的字体的惩罚值会相对较低。
    • **Unicode 范围的匹配程度:** 如果文档中包含大量中文字符,那么支持中文的字体的惩罚值会相对较低。
    • **字体文件大小:** 体积较小的字体,下载速度更快,因此惩罚值会相对较低。

    最终,怡氧 会选择惩罚值最小的字体作为 fallback 字体,并将其下载到本地。这样可以尽量保证文档的显示效果与用户的期望接近。

    3. Unicode 索引:最后的“救命稻草”

    然而,即便通过惩罚值计算选择了 fallback 字体,也可能出现目标字体没有用户文档中特定字符的情况。这时,怡氧 会启用最后的 fallback 机制:Unicode 索引。

    怡氧 事先会生成一个 Unicode 索引,将整个 Unicode 空间划分成小段,每一小段都对应到一个包含了此段的字体,这个字体是锁支持的列表中文件大小最小的一个。这可以确保任何字符都能在字体集中找到对应的字形,从而最大程度地避免显示为“方块”的乱码情况。

    我们的优化:针对中文的“特殊关照”

    怡氧 的字体 fallback 机制已经相当完善,但在中文显示方面,仍然存在一定的优化空间。考虑到中文字体的复杂性和多样性,我们基于 怡氧 的机制进行了改进:

    1. **引入开源中文字体:** 我们增加了思源黑体、思源宋体等高质量的开源中文字体,这些字体覆盖的 Unicode 范围更广,能够更好地显示中文内容。
    2. **硬编码常用字体 fallback:** 我们硬编码了一些常用的中文字体,例如微软雅黑、宋体、黑体、平方等,固定 fallback 到思源黑体或者思源宋体。这样,当用户设置这些常见字体时,可以确保 fallback 到最适合的中文字体。

    通过这些优化,我们在很大程度上解决了中文文档乱码的问题,让中文文档的显示更加准确和美观。

    总结

    字体 fallback 是一个复杂而又至关重要的技术环节,它直接影响着用户体验。怡氧 通过其精密的字体字典、惩罚值计算和 Unicode 索引,构建了一套健壮的 fallback 机制。而我们在此基础上,针对中文进行了优化,进一步提升了在线文档预览的体验。

    当然,字体显示永远是一个持续优化的过程。随着技术的发展,我们也将不断探索更好的解决方案,让在线文档的字体显示更加完美。

    希望这篇文章能帮助你更好地理解 怡氧 的字体 fallback 机制。如果你有任何问题或者建议,欢迎在评论区留言讨论。

  • 怡氧文档极速加载背后的秘密:文字渲染引擎的优化之路

    大家好,我是一位在代码世界里摸爬滚打多年的程序员。今天想和大家聊聊 怡氧 文档加载速度优化背后的故事,特别是我们如何对文字渲染引擎进行大刀阔斧的改进,从而实现“秒开文档”的体验。

    传统渲染:字体加载的沉重负担

    我们都知道,怡氧 作为一个功能强大的 Office 套件,它需要处理各种复杂的文档。而文档的呈现,文字是其中最重要的组成部分。在 怡氧 早期版本中,我们的文字渲染引擎依赖于强大的 [FreeType](https://freetype.org/) 库。

    [FreeType](https://freetype.org/) 的工作原理大致是这样的:

    1. **字体加载**:当打开一个文档时,它会解析文档中使用的字体信息,并尝试加载相应的字体文件。字体文件少则几 MB,多则几十 MB,这无疑给文档的加载带来了不小的负担,尤其是在网络状况不佳或者设备性能有限的情况下,等待字体加载完成会让人感到不耐烦。
    2. **字形获取**:加载字体后,[FreeType](https://freetype.org/) 会根据 Unicode 编码查询字体文件中字符对应的字形信息(也就是我们看到的文字形状的轮廓),并计算出渲染所需的数据。
    3. **画布绘制**:最后,结合文字样式和字号等信息,通过 `canvas.drawImage` API 将字形渲染到页面上。

    这种模式的优点是渲染效果精确,能完美呈现字体设计师的意图,但缺点也显而易见:**字体加载耗时长,是影响文档打开速度的主要瓶颈之一**

    另辟蹊径:HTML Canvas 的崛起

    为了解决这个问题,我们开始深入研究 HTML5 canvas 的能力。我们发现 `canvas.measureText` API 可以获取某个字体中字符的文本度量信息(TextMetrics),包括字符宽度、高度、基线偏移等。**这为我们提供了一个完全不依赖字体文件,就能渲染文字的全新思路**

    我们的优化方案是:

    1. **极速模式**:我们引入了“极速模式”。在这种模式下,我们不再加载字体文件,而是利用 `canvas.measureText` API 获取字符的文本度量信息,然后使用 `canvas.fillText` API 将文字直接渲染到页面上。这种方法省去了加载字体文件的大量时间,显著提升了文档的打开速度。**我们可以把这个过程想象成这样:** 以前我们需要从书架上找到一本书(字体文件),然后翻到对应的页码(字形信息),再根据书上的内容在纸上画出文字。现在我们只需要知道每个字的“占位”大小(TextMetrics),就可以直接在纸上写字,是不是快多了?
    2. **精确模式**:为了兼容一些特殊情况,我们保留了传统的“精确模式”。当文档中包含艺术字等需要通过字形图才能实现的效果时,我们会自动切换到“精确模式”。同时,如果用户在“极速模式”下插入了艺术字,也会自动切换回“精确模式”,保证所有内容都能正确显示。

    **两种模式,相辅相成,平衡了速度与精度。**

    优化带来的显著效果

    经过这样的改造,文档的打开速度得到了质的飞跃,尤其是在网络状况不佳或者设备性能有限的情况下,提升效果更加明显。用户再也不用长时间等待文档加载了,可以立即开始阅读和编辑。

    总结与展望

    这次文字渲染引擎的优化,是我们不断追求卓越、精益求精的一个缩影。我们相信,通过持续的技术创新,我们可以为用户带来更好的产品体验。未来,我们还会继续探索新的技术,进一步提升文档处理效率,让 怡氧 成为您工作学习的最佳伙伴。

    希望今天的分享对你有所启发。如果你对 怡氧 技术细节感兴趣,欢迎关注我的博客,我们下期再见!