阅读视图

发现新文章,点击刷新页面。

JitWord 2.3: 墨定,行远

今天,我们宣布推出 JitWord AI文档 2.3版本。

图片

在持续两年的研究和技术难点攻克下,我们取得了如下成果:

  • 实现了高效的Word在线协同编辑能力
  • 实现了高效的Excel在线协同编辑能力
  • 实现了业内领先的Docx/PDF高精度导入导出能力
  • 实现了Office办公套件的嵌入和预览(Word,PDF, PPT, Excel)
  • 实现了国产化环境兼容适配(本地部署安全可靠)
  • 支持多端适配和编辑(PC,移动, IPad等设备)
  • 兼容市面上所有主流AI模型,并研发设计了AI Native组件,全面打造智慧办公场景
  • 多模态能力(图文/音视频/思维导图/图表/电子签名等)
  • 实现了高性能文档渲染引擎(支持50W字超大文档渲染,目前还在持续优化)
  • 实现了复杂的数学公式渲染引擎(支持导出为word可编辑的公式)

 当然我们的目标是全面对齐 Office,并基于AI Native 的设计理念,打造国产化的 AI Office 解决方案。同时我们是开源了一个基础版的SDK(v1.0版本),供大家直接本地调用:

图片我们的目标是为全球的科研人员、企业和组织赋能,帮助他们利用我们的前沿解决方案和AI能力构建安全可靠,符合企业自身需求的创新协同AI办公解决方案。

github地址: github.com/MrXujiang/j…

体演示地址:jitword.com

接下来我就和大家分享一下 JitWord 2.3 版本带来的新功能。

一、电子签名功能:数字化时代的"最后一公里"

去年我们团队在做用户调研时,遇到一个令人意外的场景。

某建筑设计公司的项目经理李哥向我们吐槽:"我们用你们的 jitword 写方案、改图纸备注都很爽,但每到签合同环节,就得全部打印出来,手写签字,再扫描回传。一套流程下来,半天没了,纸摞得比字典还厚。"

这番话让我们愣住了。

在 All-in-Digital 的今天,我们实现了云端协作、AI辅助写作、多人实时编辑,却在最原始的"确认"环节卡了壳。

电子签名这个看似简单的功能,成了文档数字化流程中的"断点"。

更让我们震惊的是数据:据我们抽样调研,73%的企业用户仍在使用"打印-签字-扫描"的传统模式处理合同和确认文件,平均每周浪费4.6小时在这类机械操作上。

这不是技术问题,这是体验设计的失职。

那一刻我们决定:JitWord 2.3 必须解决这个"最后一公里"问题。而且,不能做成简单的图片贴入,要让它真正可用、好用、让人愿意用

于是我们研发并上线了电子签名组件:

图片

大家可以在 jitword 编辑器的插入分类下使用电子签名,插入到文档的效果如下:

图片

我们在用户体验和界面设计上做了大量的优化,保证用户能以最好的体验使用这个功能。

大家可以在文档的任何位置插入电子签名,并且能一键导出为PDF和Docx文件,直接用于合同等场景的打印流程:

图片

二、分栏布局:回归文档的"阅读本质"

图片

如果说电子签名解决的是"出口"问题,那么分栏功能解决的就是"呈现"问题。

2.1 为什么传统文档编辑器"不好看"?

长期以来,Web文档编辑器有个通病:它们更像是"网页"而不是"文档"。单栏通顶的布局适合屏幕阅读,但一旦需要打印成册、制作手册、设计简报,就显得笨拙不堪。

我们观察到一个趋势:越来越多的用户把jitword当作轻量级排版工具使用。市场部门做产品手册,教研组编试卷,律师团队整理证据目录...他们不需要InDesign的专业,但Word的分栏功能又总让他们在Web和桌面软件之间来回切换。

2.2 技术实现:浏览器里的"排版引擎"

分栏功能看似简单,在Web技术栈里却是个硬骨头。

浏览器的流式布局天生是单栏的,要实现像LaTeX那样的专业分栏,需要重写文本流算法。我们团队花了两个月时间,基于CSS Columns规范做了深度定制:

  • 智能断栏:避免标题孤行、段落割裂,确保阅读连贯性
  • 图文混排:图片跨栏、文字环绕的像素级精准控制
  • 动态平衡:根据内容长度自动调整栏高,告别"最后一栏空荡荡"
  • 打印还原:屏幕所见即打印所得,解决Web文档打印走样的顽疾

特别值得一提的是分栏协同编辑的难点。当两个用户同时编辑不同分栏的内容时,光标定位、选区计算、冲突合并的复杂度呈指数级上升。

图片

我们重构了底层的 CRDT 算法,确保分栏场景下的协同体验与单栏一样流畅。

2.3 应用场景:从"能用"到"好用"的跃迁

现在,jitword 的分栏功能已经成为一些用户的"秘密武器":

  • 教育行业:老师用双栏排版制作试卷,左栏题干右栏答题区,直接导出印刷
  • 法律行业:律师用三栏整理证据清单,证据编号、内容摘要、页码索引一目了然
  • 市场营销:运营用混栏设计制作产品白皮书,图文穿插,专业度不输设计公司

三、表格多人协同:复杂数据的"共舞"方案

图片

3.1 被低估的协同场景

表格,是文档中最复杂的数据结构,也是协同编辑的"雷区"。

传统方案要么采用"锁定整表"的保守策略(一个人改,其他人看),要么"自由混战"(最后保存的人覆盖一切)。前者效率低下,后者数据灾难。

在 jitword 2.3中,我们实现了单元格级细粒度协同——这是技术架构上的重大突破。

3.2 技术架构:从"文档"到"数据"的视角转换

要实现真正的表格协同,必须改变底层思维:把表格不再视为"文档的一部分",而是视为嵌入式数据库

我们的技术方案包含三个层次:

第一,结构层解耦。  表格的每个单元格都是独立的数据对象,拥有唯一的CRDT(无冲突复制数据类型)标识。这意味着A用户在改A1单元格,B用户在改B2单元格,两者完全隔离,互不阻塞。

第二,冲突层智能。  当两人同时修改同一单元格时,系统不是简单"后覆盖前",而是基于语义合并策略:如果是数值,做算术合并;如果是文本,做差异对比;如果是公式,重新计算依赖链。冲突解决过程可视化呈现,用户可选择接受哪个版本或手动合并。

第三,感知层细腻。  我们设计了"单元格 occupancy"机制:当某人正在编辑某单元格,该单元格边缘会显示其头像呼吸灯,其他人点击时会收到友好提示"某某正在编辑,是否加入协作?"。这种"软阻塞"既避免了冲突,又保留了灵活性。

3.3 真实场景:一场没有"等等我"的会议

想象一下这个场景:周五下午,财务、销售、运营三个部门负责人要赶在下班前确认Q3预算表。以前,他们需要:

  1. 各自填好Excel分表
  2. 发给财务汇总
  3. 发现数据对不上,群里@来@去
  4. 修改,再发,再核对...
  5. 三小时后,终于搞定

现在,大家在 jitword 中打开同一张表格,各自在自己负责的栏目实时填写,公式自动计算,批注即时可见,有疑问直接@相关人在单元格内讨论。20分钟,预算表确认完毕,直接签名定稿。

这不是未来场景,这是 jitword 2.3 用户的日常。

四、价值重构:我们到底在做什么?

写到这里,我想停下来回答一个根本问题:jitword 2.3的这三个功能,到底创造了什么价值?

效率价值:时间的复利

电子签名节省的"打印-签字-扫描"流程,按每次30分钟、每周3次计算,一年就是78小时,相当于10个工作日;

分栏功能节省的软件切换和格式调整时间;

表格协同节省的汇总核对时间...这些碎片时间积累起来,是组织效率的复利增长。

体验价值:心流的守护

更重要的是认知成本的降低。当工具不再打断你的工作流——不需要为了签个字打开另一个系统,不需要为了排个版导出到另一个软件,不需要为了合个表发无数封邮件——你就能保持专注,进入心流状态。这种"不卡顿"的体验,是数字化办公的稀缺品。

信任价值:数字的确定性

电子签名的法律效力、协同编辑的版本可追溯、分栏排版的所见即所得,共同构建了一种数字确定性

在远程办公常态化的今天,这种确定性是团队协作的基石。我们知道谁在什么时候做了什么修改,我们知道这份文件被谁确认过,我们知道打印出来和屏幕上看到的一样——这些"知道",就是信任。

写在最后

我们团队的一个共识:最好的技术,是让人感受不到技术的存在,却能感受到人的温度。

电子签名的笔迹,是承诺的温度;分栏排版的精致,是专业的温度;表格协同的流畅,是协作的温度。

JitWord 2.3 不是功能的堆砌,是我们对"文档应该是什么样"的持续思考。在这个AI重构一切的时代,我们选择先做好人与文档、人与人之间的连接

如果大家也厌倦了工具的割裂、流程的繁琐、协作的摩擦,欢迎体验 jitword 。我们相信,好的工具,会让你重新爱上工作本身。


关于JitWord

JitWord 是面向企业的下一代协同AI文档平台,致力于让文档创作更智能、协作更流畅、知识更有序。

2.3版本现已全面上线,访问官网即可体验电子签名、分栏排版、表格协同等全新功能。

pxcharts Ultra V2.3更新:多维表一键导出 PDF,渲染兼容性拉满!

最近粉丝咨询最多的问题莫过于 pxcharts 多维表是否能导出PDF的能力了。

图片

说实话,我回避了很久。浏览器打印引擎差异大,中文渲染、分页断行、复杂表格适配...每个都是坑。

直到上个月,一个做财务的朋友跟我吐槽:月底导报表,调格式调到凌晨2点。我决定,这功能必须上。

于是在1周的设计和研究下,终于实现了多维表导出PDF的功能。

演示如下:

图片

导出后的PDF文件预览效果:

图片

演示地址:pxcharts.com

开源版:github.com/MrXujiang/p…

接下来和大家分享一下详细的功能技术实现。

Pxcharts多维表导出PDF功能技术实现

支持将表格数据导出为 PDF 格式,便于用户打印、存档和分享,核心需求包括:

  • 保持表格结构和样式
  • 支持分页(避免行被截断)
  • 支持封面页(统计信息)
  • 状态标签着色
  • 横向/纵向布局可选

技术选型

为了实现这个方案,我们的核心依赖如下:

依赖 版本 用途
jspdf latest 生成 PDF 文件
html2canvas latest 将 HTML 渲染为 Canvas 图像

选型理由

为什么选择 html2canvas + jsPDF?原因如下:

  1. 纯前端实现无需后端服务,保护数据隐私
  2. 样式可控通过 CSS 精确控制 PDF 外观
  3. 兼容性好支持现代浏览器
  4. 生态成熟社区活跃,文档完善

为什么不直接用 jsPDF 的表格 API?

  • jsPDF 的 autoTable 插件对复杂样式支持有限
  • 自定义样式(状态标签着色、交替行背景)实现困难
  • html2canvas 可以复用现有的 HTML/CSS 样式

实现架构

整体流程我这里设计如下:

表格数据
    ↓
生成 HTML(按页)
    ↓
html2canvas 渲染为 Canvas
    ↓
Canvas 转 PNG 图像
    ↓
jsPDF 写入 PDF(每页一张图)
    ↓
下载 PDF 文件

分页策略

关键问题:如何避免表格行在分页时被截断?

我的解决方案:按行预分页

  1. 估算每行高度(约 36px)
  2. 计算每页可容纳行数:rowsPerPage = floor((pageHeight - headerHeight) / rowHeight)
  3. 按行数切分数据,每页独立渲染
  4. 每页都包含表头,方便阅读
const estimateRowHeight36// 每行大约 36px
const headerHeight60// 表头高度
const pageContentHeightPx = Math.round(contentHeight / scale)
const rowsPerPage = Math.floor((pageContentHeightPx - headerHeight) / estimateRowHeight)

// 分页
for (let i0; i < records.length; i += rowsPerPage) {
const pageRecords = records.slice(i, i + rowsPerPage)
  pages.push(renderDataPage(pageRecords, i))
}

核心代码解析

1. 动态导入(SSR 兼容):

const [{ default: jsPDF }, { default: html2canvas }] = awaitPromise.all([
import("jspdf"),
import("html2canvas"),
])

原因jspdf 和 html2canvas 依赖浏览器 API(如 documentwindow),在 Next.js SSR 阶段会报错。使用动态导入确保只在客户端执行。

2. 页面尺寸计算:

const pageDimensions = {
a4: { width: 595, height: 842 },  // pt 单位
a3: { width: 842, height: 1191 },
}

const pdfWidth = orientation === "landscape"
  ? pageDimensions[pageSize].height
  : pageDimensions[pageSize].width

注意:jsPDF 使用 pt(点)作为单位,1pt = 1/72 英寸。

3. HTML 生成

数据页结构这里我预设如下

<divstyle="width:1122px;padding:32px;box-sizing:border-box;background:#fff">
<tablestyle="width:100%;border-collapse:collapse">
<thead><!-- 表头 --></thead>
<tbody><!-- 数据行 --></tbody>
</table>
</div>

关键样式

  • width:1122px固定 canvas 宽度(A4 横向像素)
  • border-collapse:collapse合并表格边框
  • white-space:nowrap防止文本换行

4. Canvas 渲染

const canvasawaithtml2canvas(element, {
scale2,              // 2倍缩放,提高清晰度
useCORStrue,         // 允许跨域图片
allowTainttrue,      // 允许污染 canvas
backgroundColor"#ffffff",
loggingfalse,
})

参数说明

参数 说明
scale: 2 2倍分辨率,PDF 更清晰
useCORS 处理跨域图片(如附件预览图)
allowTaint 允许 canvas 被污染(某些图片需要)

5. PDF 写入

const imgData = canvas.toDataURL("image/png"1.0)
const imgWidth = contentWidth
const imgHeight = (canvas.height * imgWidth) / canvas.width

pdf.addImage(imgData, "PNG", margin, margin, imgWidth, imgHeight)

图像格式选择

  • PNG无损,清晰度高,适合文字
  • JPEG有损压缩,文件小,但不适合文字

样式处理技巧

状态标签着色这里我做了一层数据映射,方便精准还原样式:

constcolorMap: Record<stringstring> = {
"已完成""#dcfce7;color:#16a34a",
"进行中""#dbeafe;color:#2563eb",
"待开始""#fef3c7;color:#d97706",
"已停滞""#f3f4f6;color:#6b7280",
"重要紧急""#fee2e2;color:#dc2626",
}

交替行背景我采用的逻辑判断来动态渲染:

<tr style="background:${idx % 2 === 0 ? "#fff" : "#f8fafc"}">

如果文本出现截断换行,用canvas很难处理,这里我采用如下方案截断处理:

// 方案1:省略号截断(适合固定宽度列)
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:160px">

// 方案2:完全显示(适合自动宽度列)
<spanstyle="white-space:nowrap">

当然还有很多细节的处理,这里就不一一介绍了。我们可以基于这个方案,继续扩展出如下场景:

  1. 水印支持添加企业 Logo 或水印
  2. 页码在页脚添加 "第 X 页 / 共 Y 页"
  3. 图表嵌入将图表大屏的图表嵌入 PDF
  4. 批量导出支持同时导出多个表格

今天就分享到这,后续我们还会持续迭代和更新,打造最强大的多维表格和文档协同系统。

演示地址:pxcharts.com

开源版:github.com/MrXujiang/p…

JitWord Office预览引擎:如何用Vue3+Node.js打造丝滑的PDF/Excel/PPT嵌入方案

ps:老规矩,先上地址,github地址:jitword sdk

最近很多用户反馈了需要支持Office预览功能,于是我们加班加点,在Jitword 协同AI文档上支持了一键预览Office文件的功能:

image.png

目前 jitword 已全面支持如下文件类型的解析预览:

  • Markdown文件
  • Docx文件
  • PDF文件
  • Excel文件
  • PPT文件
  • JSON文件
  • HTML文件

接下来我会详细和大家分享一下功能和技术实现,给大家提供一个技术参考。

往期精彩:

拒绝重复造轮子?我们偏偏花365天,用Vue3写了款AI协同的Word编辑器

项目背景:为什么我们要造这个轮子?

image.png

作为一个协同文档项目,JitWord一直在探索轻量级的办公解决方案。最近社区反复提出"Office预览"需求,但是我们面临一个选择:

方案 优点 缺点
OnlyOffice/Collabora 功能完整,支持编辑 部署重(2GB+镜像),加载慢(3-5s),样式难定制
微软/谷歌预览API 接入简单 数据出境,自定义域名受限,免费额度有限
自研预览引擎 轻量、可控、体验统一 开发成本高,需持续维护

我们的决策:自研轻量级预览引擎,专注"预览+文档编排"场景。

下面分享一下我们的技术方案。

架构设计:三层解耦模型

┌─────────────────────────────────────────┐
│           协同层 (Collaboration)         │
│    批注Canvas + 用户体系 + 实时同步        │
├─────────────────────────────────────────┤
│           嵌入层 (Embedding)             │
│    Vue3组件 + 响应式布局 + 主题同步        │
├─────────────────────────────────────────┤
│           解析层 (Parsing)               │
│    PDF.js / SheetJS / PPTX解析器         │
└─────────────────────────────────────────┘

核心技术实现

PDF预览:PDF.js深度优化

问题:原版PDF.js加载大文件时卡顿,内存占用高。

优化方案

// pdf-loader.js
import * as pdfjsLib from 'pdfjs-dist';

class PDFPreviewEngine {
  constructor(container, options = {}) {
    this.container = container;
    this.pdfDoc = null;
    this.scale = options.scale || 1.5;
    this.chunkSize = options.chunkSize || 256 * 1024; // 256KB分片
  }

  async load(url) {
    // 分片加载:只加载可视区域附近的页面
    const loadingTask = pdfjsLib.getDocument({
      url,
      rangeChunkSize: this.chunkSize,
      disableAutoFetch: true, // 关键:禁用自动全量加载
    });

    this.pdfDoc = await loadingTask.promise;
    return this.renderVisiblePages();
  }

  async renderVisiblePages() {
    const viewportHeight = this.container.clientHeight;
    const pages = [];
    
    // 只渲染可视区域 + 上下各缓冲1页
    for (let i = 1; i <= this.pdfDoc.numPages; i++) {
      const page = await this.pdfDoc.getPage(i);
      const viewport = page.getViewport({ scale: this.scale });
      
      // 虚拟列表逻辑:计算页面是否在视口内
      if (this.isPageInViewport(i, viewport.height)) {
        pages.push(this.renderPage(page, viewport));
      }
    }
    
    return Promise.all(pages);
  }

  renderPage(page, viewport) {
    const canvas = document.createElement('canvas');
    const context = canvas.getContext('2d');
    canvas.height = viewport.height;
    canvas.width = viewport.width;

    return page.render({
      canvasContext: context,
      viewport: viewport
    }).promise.then(() => canvas);
  }
}

关键优化点

  1. disableAutoFetch: true:禁用PDF.js的自动全量加载
  2. rangeChunkSize:设置分片大小,配合HTTP Range请求
  3. 虚拟列表渲染:只渲染可视区域,100MB+PDF也能流畅滚动

Excel预览:SheetJS + 自研渲染器

问题:SheetJS解析后如何高效渲染?如何保留公式计算?

方案架构

Excel文件 (.xlsx)
    ↓
SheetJS解析 → Workbook对象
    ↓
数据转换层 (Data Transformer)
    ↓
Vue3表格组件 (Virtual Table)

核心代码

// excel-parser.js
import XLSX from 'xlsx';

class ExcelPreviewEngine {
  parse(buffer) {
    const workbook = XLSX.read(buffer, { 
      type: 'array',
      cellFormula: true,      // 保留公式
      cellNF: true,           // 保留数字格式
      cellStyles: true        // 保留样式
    });
    
    return this.transformWorkbook(workbook);
  }

  transformWorkbook(workbook) {
    return workbook.SheetNames.map(name => {
      const worksheet = workbook.Sheets[name];
      const data = XLSX.utils.sheet_to_json(worksheet, { header: 1 });
      
      return {
        name,
        data,
        merges: this.parseMerges(worksheet['!merges']), // 合并单元格
        formulas: this.extractFormulas(worksheet),      // 公式映射
        colWidths: worksheet['!cols']?.map(c => c.wpx) || []
      };
    });
  }

  extractFormulas(worksheet) {
    const formulas = {};
    for (const [cell, value] of Object.entries(worksheet)) {
      if (value && value.f) { // value.f 是公式字符串
        formulas[cell] = value.f;
      }
    }
    return formulas;
  }
}

前端渲染组件(Vue3 + 虚拟滚动):

<!-- ExcelPreview.vue -->
<template>
  <div class="excel-preview" ref="container">
    <div class="sheet-tabs">
      <button 
        v-for="sheet in sheets" 
        :key="sheet.name"
        :class="{ active: currentSheet === sheet.name }"
        @click="switchSheet(sheet.name)"
      >
        {{ sheet.name }}
      </button>
    </div>
    
    <VirtualTable
      :data="currentData"
      :formulas="currentFormulas"
      :col-widths="currentColWidths"
      :row-height="28"
      @cell-click="handleCellClick"
    />
  </div>
</template>

<script setup>
import { ref, computed } from 'vue';
import VirtualTable from './VirtualTable.vue';
import { evaluateFormula } from './formula-engine'; // 自研公式计算引擎

const props = defineProps({
  workbook: Object
});

const currentSheet = ref(props.workbook[0]?.name);
const currentData = computed(() => {
  const sheet = props.workbook.find(s => s.name === currentSheet.value);
  return sheet?.data || [];
});

// 公式实时计算
const computedValues = computed(() => {
  const result = {};
  const formulas = props.workbook.find(s => s.name === currentSheet.value)?.formulas || {};
  
  for (const [cell, formula] of Object.entries(formulas)) {
    try {
      result[cell] = evaluateFormula(formula, currentData.value);
    } catch (e) {
      result[cell] = '#ERROR';
    }
  }
  
  return result;
});
</script>

公式计算引擎(简化版):

// formula-engine.js
export function evaluateFormula(formula, data) {
  // 移除开头的=
  const expr = formula.replace(/^=/, '');
  
  // 单元格引用解析:A1 → data[0][0]
  const cellRef = expr.match(/([A-Z]+)(\d+)/g);
  if (!cellRef) return evaluateExpression(expr);
  
  let evalExpr = expr;
  for (const ref of cellRef) {
    const { col, row } = parseCellRef(ref);
    const value = data[row - 1]?.[col] || 0;
    evalExpr = evalExpr.replace(ref, value);
  }
  
  return evaluateExpression(evalExpr);
}

// 支持常用函数
const FUNCTIONS = {
  SUM: (args) => args.reduce((a, b) => Number(a) + Number(b), 0),
  AVERAGE: (args) => FUNCTIONS.SUM(args) / args.length,
  MAX: (args) => Math.max(...args),
  MIN: (args) => Math.min(...args),
  // ... 200+函数实现
};

PPT预览:XML解析 + Vue3幻灯片组件

技术选型:不渲染为图片,而是解析为可交互的组件树

// pptx-parser.js
import JSZip from 'jszip';

class PPTXParser {
  async parse(arrayBuffer) {
    const zip = await JSZip.loadAsync(arrayBuffer);
    
    // 解析核心XML
    const [contentTypes, presentation, slideMasters] = await Promise.all([
      zip.file('[Content_Types].xml').async('string'),
      zip.file('ppt/presentation.xml').async('string'),
      zip.file('ppt/slideMasters/slideMaster1.xml').async('string')
    ]);

    const parser = new DOMParser();
    const presDoc = parser.parseFromString(presentation, 'application/xml');
    
    // 提取幻灯片列表
    const slideIds = Array.from(presDoc.querySelectorAll('sldId')).map(s => s.getAttribute('id'));
    
    // 并行解析所有幻灯片
    const slides = await Promise.all(
      slideIds.map((id, index) => this.parseSlide(zip, index + 1))
    );
    
    return { slides, slideCount: slides.length };
  }

  async parseSlide(zip, slideNum) {
    const slideXml = await zip.file(`ppt/slides/slide${slideNum}.xml`).async('string');
    const doc = new DOMParser().parseFromString(slideXml, 'application/xml');
    
    // 提取形状、文本、图片
    const shapes = Array.from(doc.querySelectorAll('sp')).map(sp => ({
      type: this.getShapeType(sp),
      x: this.emuToPx(sp.querySelector('off')?.getAttribute('x')),
      y: this.emuToPx(sp.querySelector('off')?.getAttribute('y')),
      width: this.emuToPx(sp.querySelector('ext')?.getAttribute('cx')),
      height: this.emuToPx(sp.querySelector('ext')?.getAttribute('cy')),
      text: this.extractText(sp),
      style: this.extractStyle(sp)
    }));

    // 提取动画时序
    const animations = this.parseAnimations(doc);
    
    return { shapes, animations, transition: this.parseTransition(doc) };
  }

  emuToPx(emu) {
    return Math.round(parseInt(emu) / 9525); // 1px = 9525 EMU
  }
}

Vue3幻灯片渲染组件

<!-- SlideViewer.vue -->
<template>
  <div class="slide-viewer" :style="slideStyle">
    <TransitionGroup name="slide">
      <div 
        v-for="(shape, index) in currentSlide.shapes" 
        :key="index"
        class="shape"
        :style="shapeStyle(shape)"
        v-show="isShapeVisible(index)"
      >
        <TextShape v-if="shape.type === 'text'" :content="shape.text" :style="shape.style" />
        <ImageShape v-else-if="shape.type === 'image'" :src="shape.src" />
        <TableShape v-else-if="shape.type === 'table'" :data="shape.data" />
      </div>
    </TransitionGroup>
    
    <!-- 动画控制 -->
    <div class="animation-controls">
      <button @click="prevAnimation" :disabled="currentStep === 0">上一步</button>
      <span>{{ currentStep + 1 }} / {{ totalSteps }}</span>
      <button @click="nextAnimation" :disabled="currentStep >= totalSteps - 1">下一步</button>
    </div>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue';
import TextShape from './shapes/TextShape.vue';
import ImageShape from './shapes/ImageShape.vue';
import TableShape from './shapes/TableShape.vue';

const props = defineProps({
  slide: Object
});

const currentStep = ref(0);

// 根据动画时序计算可见形状
const isShapeVisible = (shapeIndex) => {
  if (!props.slide.animations) return true;
  const triggerStep = props.slide.animations[shapeIndex]?.triggerStep || 0;
  return currentStep.value >= triggerStep;
};

const nextAnimation = () => {
  if (currentStep.value < totalSteps.value - 1) {
    currentStep.value++;
  }
};

const totalSteps = computed(() => {
  if (!props.slide.animations) return 1;
  return Math.max(...props.slide.animations.map(a => a.triggerStep)) + 1;
});
</script>

嵌入层:与文档流的完美融合

核心挑战:如何让Office预览组件像<img>标签一样自然嵌入文档?

解决方案contenteditable + Shadow DOM隔离

// embed-manager.js
class OfficeEmbedManager {
  constructor(editor) {
    this.editor = editor; // 富文本编辑器实例
    this.embeds = new Map();
  }

  insertEmbed(type, fileUrl, position) {
    // 生成唯一ID
    const embedId = `embed-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
    
    // 在编辑器中插入占位符
    const placeholder = document.createElement('div');
    placeholder.className = 'office-embed-placeholder';
    placeholder.dataset.embedId = embedId;
    placeholder.dataset.type = type;
    placeholder.contentEditable = false; // 关键:防止编辑器干扰
    
    // 使用Shadow DOM隔离样式
    const shadow = placeholder.attachShadow({ mode: 'open' });
    
    // 根据类型渲染对应组件
    const app = createApp(getPreviewComponent(type), {
      src: fileUrl,
      onReady: (api) => this.embeds.set(embedId, api)
    });
    
    app.mount(shadow);
    
    // 插入到编辑器指定位置
    this.editor.insertNodeAt(position, placeholder);
    
    return embedId;
  }

  // 协同批注:将坐标映射到Office内容
  addAnnotation(embedId, x, y, content) {
    const embed = this.embeds.get(embedId);
    if (!embed) return;
    
    // 将屏幕坐标转换为文档相对坐标
    const rect = embed.getBoundingClientRect();
    const relativeX = (x - rect.left) / rect.width;
    const relativeY = (y - rect.top) / rect.height;
    
    // 根据类型做语义化定位
    const location = embed.resolveLocation(relativeX, relativeY);
    
    return {
      embedId,
      location, // 如:{ type: 'cell', ref: 'B5' } 或 { type: 'page', num: 3 }
      content,
      timestamp: Date.now()
    };
  }
}

性能数据与优化技巧

加载性能对比

文件类型 文件大小 OnlyOffice 我们的方案 提升
PDF 50MB 4.2s 0.8s 5.2x
Excel 10MB (10万行) 3.8s 1.1s 3.5x
PPT 20MB (50页) 5.1s 1.5s 3.4x

关键优化技巧

1. Web Worker卸载解析

// excel-worker.js
self.onmessage = async (e) => {
  const { buffer, sheetName } = e.data;
  
  // 在Worker线程解析,不阻塞主线程
  const workbook = XLSX.read(buffer, { type: 'array' });
  const sheet = workbook.Sheets[sheetName];
  const data = XLSX.utils.sheet_to_json(sheet, { header: 1 });
  
  self.postMessage({ data, formulas: extractFormulas(sheet) });
};

2. 虚拟滚动(Excel大数据)

<VirtualList
  :items="flattenedData"
  :item-height="28"
  :buffer="5"
  v-slot="{ item, index }"
>
  <TableRow :cells="item" :row-index="index" />
</VirtualList>

3. 图片懒加载(PDF/PPT)

const imageObserver = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      const img = entry.target;
      img.src = img.dataset.src; // 真正加载
      imageObserver.unobserve(img);
    }
  });
});

我们提供了一个开源SDK版本,大家可以轻松集成到项目里使用:

github:github.com/MrXujiang/j…

总结与展望

这套方案的核心价值在于轻量与可控

  • 轻量:前端包体积<500KB,无需重型服务器
  • 可控:源码支持二次开发,模块化解耦设计
  • 协同:与文档系统深度集成,而非孤立的预览窗口

未来规划

  1. WebAssembly加速:将公式计算用Rust重写,编译为WASM
  2. Rag知识库:支持文档即知识的Rag动态知识库功能
  3. AI增强:PDF自动摘要、Excel智能分析

如果大家有好的方案,欢迎随时交流反馈~

往期精彩:

拒绝重复造轮子?我们偏偏花365天,用Vue3写了款AI协同的Word编辑器

❌