Officia.Editor — 在线编辑
能力摘要(AI 友好):
OfficiaEditor把文档交给浏览器端编辑器再把结果写回文件。 Words 侧open(docx/doc)→officia-doc/1JSON、save(json, DOCX|PDF)写回; Cells 侧openWorkbook(xlsx/xls)/recalc/saveWorkbook是一条 JSON in → JSON out 的闭环。 前端产物officia-editor.js打在同一个 jar 的META-INF/resources/下随包分发。 没有 Document Server、没有独立进程、没有新端口——客户只多引一个 jar。
别的在线编辑方案里,Document Server 承担的「转换 / 保存回调」,在这里就是两次普通方法调用。
引入
在线编辑随聚合包 officia-all 一起分发,不需要额外的 Maven 坐标—— 引入方式与其它能力完全一致,见快速开始。
版本要求
Officia.Editor 是 1.1.3 之后新增的模块。用 1.1.3 或更早的 officia-all 会 import 不到 plus.ruoyi.officia.editor.OfficiaEditor,也取不到 /officia-editor/officia-editor.js。请对照更新日志确认你用的版本已包含本模块。
打开:文档 → JSON
import plus.ruoyi.officia.editor.OfficiaEditor;
import plus.ruoyi.officia.editor.EditorDocument;
import plus.ruoyi.officia.editor.EditorFormat;
EditorDocument doc = OfficiaEditor.open(docxBytes); // docx / doc 按内容嗅探,不看扩展名
String json = doc.getJson(); // officia-doc/1 契约,直接回给前端
doc.getSourceFormat(); // "docx" 或 "doc"
doc.getBlockCount(); // 顶层块数(不是页数——页数要排版后才知道)
doc.getWarnings(); // 解析告警;先 hasWarnings() 判一下这份 JSON 里除了内容,还带着逐码位的字体前进宽度——所以前端不必再向服务端要任何东西 就能自己排版渲染。
两端排版一致是工程目标,不是数学保证
浏览器里的排版与 officia 服务端的排版力求一致,编辑器的「检查」面板会把两个页数并排显示, 让差异随时可见。要看服务端排出来的真实效果,用 OfficiaEditor.toImages(json) 逐页出图比对。
保存:JSON → docx / PDF
byte[] docx = OfficiaEditor.save(json, EditorFormat.DOCX); // 语义 docx,可被 Word 再打开继续编辑
byte[] pdf = OfficiaEditor.save(json, EditorFormat.PDF); // 与 OfficiaWords.toPdf 同一条排版渲染链EditorFormat 只有 DOCX 与 PDF 两个常量。工作簿不走 save,见下一节。
工作簿:打开 / 重算 / 保存
String wbJson = OfficiaEditor.openWorkbook(excelBytes); // xlsx 或 xls,officia-workbook/1 契约
String next = OfficiaEditor.recalc(wbJson); // 重算全部公式格
byte[] xlsx = OfficiaEditor.saveWorkbook(next); // 写回 xlsx打开再保存,公式不丢
Excel 往下拖出来的共享公式,读入时展开成每格完整的公式;数组公式(Ctrl+Shift+Enter 输入的) 带着数组标记(契约字段 fa)进出,recalc 按数组算、存回去仍是数组公式。 早先的实现对这两类有静默丢失:共享公式的跟随格存回去只剩数字,数组公式存回去成了普通公式。
定义名称(=SUM(销量) 里的 销量)读进契约根级字段 names,recalc 按它展开计算,插删行列时名称里的引用跟着改,存回 xlsx 原样还在;编辑器里没有新建 / 修改名称的界面(「名称管理器」是琥珀态)。
表格(Excel「插入 → 表格」建的那种)随契约字段 sheets[].tables 往返,结构化引用(表1[金额])照常算;表头格就是列名,编辑器里只许改格式、不许改内容;插删行列时表格跟着走,会拆掉表头、删光数据行、只删掉表格一部分列的删除被拒绝;编辑器里不能新建表格(「表格区域」是琥珀态)。
图片、形状、图表原样往返:绘图部件连同它引用的图片、图表放在契约根级字段 parts(base64), 每张表的锚点在 sheets[].drawing。存回 xlsx 时只改锚点,其余字节不动;插删行列时锚点按 Excel「对象位置」的三种设置走 (随单元格改变位置和大小 / 只改位置 / 大小和位置均固定),图表的数据源引用跟着改写。 编辑器网格里不画它们,也不能新插或修改(「图片 / 形状 / 图表」是琥珀态)——以形状做导航页的表在网格里看是空白,存回去都在。
parts 只在保存时带
parts 可达几 MB。recalc 不读它,调用时去掉(JSON.stringify({...wb, parts: undefined})); saveWorkbook 必须带上,漏了会报「绘图部件不存在」(或「……指向的部件不存在」),而不是悄悄把图丢掉。
打开时停在哪跟文件走:打开就切到文件里记的那张表、滚到记的位置、选中记的选区(与 Excel 打开时一样), 契约里是 activeSheet 与各表的 topLeft / active / sel。在编辑器里切表、点格子、滚动时编辑器把位置写回这几个字段, 存回的 xlsx 在 Excel 里打开就停在编辑器里最后的位置;这些写回不进撤销栈,也不算「有未保存的改动」。
插入行 / 列照相邻格式来(与 Excel、WPS 默认的「与上面格式相同」一致):在一张带框线、底色的表中间插一行, 新行带上一行的行高、行样式与每一格的格式;插一列,新列带左一列的列宽与每一格的格式。只带格式,不带值与公式; 插在第 1 行 / A 列之前时前面没有东西,新行列是空白的;隐藏不继承。插在表格中间的新行不会自动填计算列公式(Excel 会), 也没有「与下面格式相同 / 清除格式」这类插入选项。
放不下的文字照 Excel 画:勾了「自动换行」的格按格宽折成几行(中文逐字、英文按词,标点不放行首); 没勾的长文字铺到右边空着的格上(右对齐往左、居中往两边),遇到有内容的格就在那里裁掉;数字放不下一律显示 ###。 行高不会自动加高:折出来的行比行高多时放不下的部分被裁掉。xlsx→PDF 按同一口径铺与裁(PDF 没有冻结线,文字照样铺过去); 常规格式的数字例外——xlsx→PDF 会先减小数位、改科学计数,都放不下才 ###(见 Cells)。
富文本(一格里分段设了不同格式的文字)原样往返:契约里是单元格的 rt(各段 t 文字、f 这一段自己的字体, 拼起来等于 s),编辑器按段画。编辑器是纯文本输入——改了一个字,这一格的分段格式就作废成单元格样式; 点进去不改字直接回车则留着;整格点「加粗」「改颜色」会落到每一段上(与 Excel 一致)。 上标、下标(「0.35M³」的「3」)在字体里是 va(SUPERSCRIPT / SUBSCRIPT),单元格字体与段字体都可以有,编辑器照画、存回不丢; 工具栏没有上标 / 下标按钮(Excel 也没有,它在「设置单元格格式」里)。
条件格式、数据验证、超链接、打印设置、批注原样往返:工作表里编辑器不建模的元素——条件格式、数据验证(下拉框)、 超链接、打印设置(纸张 / 方向 / 页边距 / 缩放打印)、页眉页脚、分页符、工作表保护、筛选、批注——按原文放在 sheets[].raw,它们引用的外部地址、打印机设置、批注部件在 sheets[].rels 与 parts 里;工作簿级的 1904 日期系统、 迭代计算、结构保护在 wbRaw,条件格式用到的差异格式在 styleRaw。存回 xlsx 原样带回,插删行列时其中的区域与公式、 批注位置跟着改,所在的格被删掉就一起去掉。编辑器不画高亮、没有下拉框,也不能新建或修改 (「筛选 / 超链接 / 批注 / 数据验证」是琥珀态;属性面板的「页面」页只读显示文件里的打印设置)。
批注能看:有批注的格子右上角有红三角,鼠标移上去弹出批注框,文件里设了「常显」的一直显示; 框的位置、底色、边框、各段文字的加粗和颜色都照文件,插删行列后跟着格子走。批注不能新建、修改或删除; 批注文字若用了主题色,按黑色显示。
这些不跟着剪切粘贴走,透视表不带回
条件格式、数据验证、批注只在插删行列时跟着改;剪切粘贴、排序搬动单元格时它们留在原来的位置(Excel 会跟着格走)。 透视表、切片器、外部链接不带回——它们跨多个部件互相引用,只带一半 Excel 会报修复,所以存回的文件里没有它们。 raw / rels / wbRaw / styleRaw 前端只管原样交回;保存时服务端逐条校验,改坏了会明确报错。
颜色与视图设置原样往返:主题色、调色板色读进来时服务端已算成 AARRGGBB,前端直接画;原始写法在 colorRef / fgRef / bgRef,颜色没改过时按原写法存回,主题部件(根级 theme)与自带调色板(palette)原样写回, 换主题后颜色照样联动。工作表的网格线、行号列标、零值显示、缩放、标签色、隐藏状态(showGrid / showHeaders / showZeros / zoom / tabColor / state)同样往返:隐藏的表不进标签栏、存回仍隐藏、不出 PDF;关了网格线、行号列标或零值的表照此画。 「视图」页的「网格线」「行列标题」两个复选框写的就是当前表的 showGrid / showHeaders,存回文件生效(与 Excel 一致)。 .xls 打开时这三个开关同样读得出。
「网格线」写的是工作表
与 Excel 一致,工具栏的「网格线」改的是当前工作表的 showGrid,会存进文件。复选框要跟着当前表走: 在装载后、onActiveSheetChange 与 onChange 里各调一次 OfficiaEditor.syncCellsRibbon(ribbon, editor)。
xls 进、xlsx 出
openWorkbook 也收 .xls(Excel 97-2003 二进制,未加密),读出来是同一份 officia-workbook/1 JSON,顶层多一个 "src": "xls"。 但写侧只有 xlsx——saveWorkbook 存回来的一定是 xlsx,这是一次实实在在的格式转换。 请拿 src 字段告诉用户一声,别默默替人改文件格式。
设了打开密码的 xlsx 不是 ZIP 而是 CFB(MS-OFFCRYPTO),会明确报 「这份文件设了打开密码……请在 Excel 里打开、输入密码后另存为一份不带密码的副本」。
recalc 用的就是 Officia.Cells 那一个公式引擎
浏览器端没有另写一套 JS 求值器。编辑器里看到的数字与 OfficiaCells.toPdf(xlsx) 算出来的因此必然一致——「预览和导出对不上」这类最难自证的问题在结构上被排除了。 算不出来的格写成错误格,错误码与 Excel 一致(#N/A、#DIV/0!、#REF!、#NUM!、#NAME?,其余 #VALUE!),不让整张表失败;失败原因附在返回 JSON 的根级可选字段 recalcErrors([{sheet, ref, message}],无失败时不出现)。循环引用由求值栈检出并落成错误格,不会栈溢出。 引擎算不了的公式例外——引擎不支持的函数(如 RANDBETWEEN、GETPIVOTDATA)、三维引用、外部工作簿引用、普通公式里引擎还算不了的数组写法(如 INDEX(区域,0,1) 取整列),以及引用了本工作簿里没有的表的公式(跨表引用、整列整行引用、数组公式本身照常重算):带缓存值的保留 Excel 存下的结果、不重算,引用它的公式照常用这个值算,消息以「引擎不支持此公式写法,保留原缓存值」开头;没缓存值的才写 #VALUE!。
saveWorkbook 不会自动重算
什么时候重算是调用方的节奏问题(前端多为改一格 debounce 调一次 recalc)。 保存时再算一遍,会让「存下来的数字」与「用户刚在屏幕上确认过的数字」出自两次不同的求值。 需要落盘前兜底就自己串一下:saveWorkbook(recalc(json))。
前端:officia-editor.js
产物打在 jar 的 META-INF/resources/officia-editor/officia-editor.js——Servlet 3.0 规范约定的 静态资源目录,Spring Boot 等 Servlet 3.0+ 容器会自动把它暴露出来,你不需要拷贝任何文件:
<script src="/officia-editor/officia-editor.js"></script>脚本加载后挂一个全局 OfficiaEditor(OfficiaEditor.version 可读版本号)。 不是 Servlet 容器时,自己把 /officia-editor/* 映射到 classpath 的 /META-INF/resources/officia-editor/* 即可。
挂一个能打字的文档
const view = OfficiaEditor.mountEditable(host, json, {
zoom: 1,
onChange: (state) => refresh(state), // 每次内容或选区变化回调,供宿主刷新工具栏
});
view.pageCount; // 前端排版页数
view.fontMisses; // 字体度量缺口;>0 说明下发的字体表不全
view.json(); // 当前文档 JSON —— 存盘时把它交回 Java 侧
view.focus(); view.undo(); view.redo(); view.destroy();OfficiaEditor.mount(...) 是只读渲染,mountEditable 才可编辑。
功能区、属性面板、网格
// 功能区:六个标签的真工具栏,命令派发交给库里的命令表
const ribbon = OfficiaEditor.mountRibbon(host, {
surface: 'words', // 或 'cells'
onCommand: (e) => OfficiaEditor.runWordsCommand(view, e), // Cells 侧用 runCellsCommand(editor, e)
});
// 外壳:右侧属性面板 + 底部状态栏
const shell = OfficiaEditor.mountWordsShell(panelHost, statusHost, { view, onZoom });
// 工作簿网格
const editor = OfficiaEditor.Cells.mountWorkbook(host, workbookJson, {
onCellCommitted: (e) => { if (editor.hasFormula) scheduleRecalc(); }, // 一格提交后
onChange: () => { if (editor.hasFormula) scheduleRecalc(); }, // 撤销 / 重做 / 清空 / 拖列宽后
});
editor.applyRecalc(next); // 把服务端算好的结果并回来,选区/滚动都保留样式取 OfficiaEditor.RIBBON_CSS 与 OfficiaEditor.SHELL_CSS。 Cells 侧的全部对外面收在 OfficiaEditor.Cells.* 命名空间下——Words 与 Cells 是两份并列的契约, 两边都有「合并单元格」「设列宽」,说的却是不同的东西,摊到一起必然撞名。
公式结果要向服务端要
浏览器端没有公式引擎(那样就与 OfficiaCells 的 85 函数引擎成了两套), 所以公式格的值必须由服务端 recalc 算出来再并回网格:
editor.hasFormula—— 这份表里有没有公式,没有就不必白跑一趟往返editor.staleFormulaCount—— 有公式但还没算出结果的格数;打开一份没有缓存值的 工作簿时 > 0,此时应先算一次(Excel 打开这类文件也是自己重算)onCellCommitted—— 一格提交后回调,宿主据此决定要不要重算。 注意不是「提交的是公式才算」:改一个常量会牵动引用它的公式onChange—— 网格里的操作改了工作簿之后回调:提交、Delete 清空、Ctrl+Z / Ctrl+Y、拖列宽行高。 撤销影响哪些格说不准,所以不带参数;只挂onCellCommitted的话,按 Ctrl+Z 后引用它的公式会停在旧值editor.applyRecalc(next)—— 只把计算结果并回来,选区、滚动位置、正在编辑的那一格全保留; 整份换掉等于重建视图,每输一个公式就跳回原点。重算请求体去掉parts(见上),parts一直留在编辑器手里
格内输入按 Excel 的规矩识别:在「常规」格里敲 1,000、50%、2023-03-15、12:30, 存的是数值(1000、0.5、日期序列值、一天的小数),并顺手配上 #,##0 / 0% / yyyy-mm-dd / h:mm 格式——所以 SUM 算得到它们、排序按数值排。已设过数字格式的格只存值、不改格式。 开头敲一个英文单引号强制存文本('00123 存 00123,引号本身不存), 这类格在公式栏里也带着引号显示,原样回车不会被改成数字。
网格的操作按 Excel 的习惯:
- 复制 / 剪切 / 粘贴(Ctrl+C / X / V):表内粘贴带值、公式与格式,复制时公式的相对引用按位移改写, 剪切是「搬」、公式不改写;从 Excel / 网页粘进来的制表符文本逐格按上面的输入规则识别。 系统剪贴板里写的是显示文本,可直接粘到别处。暂不带合并区域;剪切后别处指向源区域的公式不跟着改指向
- Delete 清除整个选区的内容,格式保留
- 删除行 / 删除列(「插入」页):指向被删格的公式变
#REF!,跨过的区域收缩,合并区、行高、冻结随之调整 - 点列标 / 行号选整列 / 整行(可拖动、可 Shift 扩选),左上角全选;整行整列上设格式记在行列样式上,不逐格铺满
- 键盘:Shift+方向键扩选,Ctrl+方向键跳到数据边缘,Home / Ctrl+Home / Ctrl+End,PageUp / PageDown
runWordsCommand 返回 false 不是异常
false 表示这次点击没有改动文档:光标不在可编辑处、选项值解析不出来, 或这个控件按设计该由宿主处理(缩放、视图开关、插图要弹文件框、超链接要弹框问 URL、书签要弹框问名字、脚注要弹框问注释内容)。
宿主收到参数后调库里的命令:插图用 new OfficiaEditor.InsertImage(...), 超链接用 OfficiaEditor.setHyperlink(range, url)——后者连 Word 的超链接观感 (蓝 0563C1 + 下划线)一起套,与读 docx 时解析器给的那套一致; 不传 URL 即取消链接,观感一并撤掉。书签用 OfficiaEditor.insertBookmark(range, name)—— 它与超链接是一对,内链 setHyperlink(r, undefined, name) 指向的正是它建的名字。
书签在画布上按设计不可见
Word 默认也不显示书签。别拿「画面变没变」当它做没做成的判据—— 判据是 IR 里多了锚点、存回 docx 是 w:bookmarkStart/End、内链能指到它。
OfficiaEditor.WORDS_UNWIRED / CELLS_UNWIRED 是一张 Map<控件名, 原因>, 可以把原因直接显示给用户,而不是静默吞掉一次点击。 :::
属性面板与状态栏
| 界面 | 属性面板标签 |
|---|---|
| Words | 段落 · 表格 · 页面 · 检查 |
| Cells | 单元格 · 工作表 · 页面 · 检查 |
面板自己不存任何值,每个字段都声明「怎么从文档读」「怎么写回一条命令」, 所以「光标移到别的段落还留着上一段的值」这种失真在结构上不可能发生。 「检查」页显示往返 diff、只读透传对象数、字体度量缺口,以及编辑器页数与 officia 排版页数是否相等。
方法一览
| 方法 | 说明 |
|---|---|
open(docx)(byte[] / InputStream / File) | 文档 → officia-doc/1 JSON(EditorDocument) |
open(docx, options) | 带转换选项(字体目录等) |
save(json, format) | JSON → docx / PDF 字节 |
save(json, format, options) | 带转换选项(仅 PDF 用得上) |
save(json, format, out)(OutputStream / File) | 直写流或文件;不关闭调用方传入的流 |
toImages(json[, options]) | 每页一张 PNG,按页序 → List<byte[]> |
openWorkbook(xlsx/xls) | 工作簿 → officia-workbook/1 JSON;xls 源会多一个 "src":"xls" |
recalc(workbookJson) | 重算全部公式格,返回同契约 JSON |
saveWorkbook(workbookJson) | 工作簿 JSON → xlsx 字节 |
能力边界
工具栏上的每一个控件都有一个机检过的状态,这就是能力承诺本身:
| 状态 | 含义 | Words | Cells |
|---|---|---|---|
| 正常(可点) | 模型里有这个东西,且保存回目标格式时语义仍在 | 66 | 51 |
| 受限(只读) | 只读透传 / 不属于文档内容 / 可编辑但导出时降级 | 1 | 11 |
| 不可用(置灰) | 模型层就没有这个东西 | 11 | 7 |
| 合计 | 78 | 69 |
本轮明确不做:
- 实时协同(多人同改一份)——必然要服务与长连接。命令层已设计成可序列化操作日志,为将来留门。
- 修订 / 批注 / 脚注的编辑(打开保留、存回不丢,但改不了)。
- 浮动对象拖拽、图文环绕的编辑(只读)。
- 演示文稿(pptx)在线编辑——pptx 仍可用 Officia.Slides 转 PDF。
- 前端引入任何 UI 框架(零依赖对前端产物同样适用)。
一个容易被问到的字节差异
save(json, PDF) 与 OfficiaWords.toPdf(docx) 走同一条排版渲染链,从同一份内容出发必然产出相同字节。 但 toPdf(docx) 会额外把 docx 内嵌的字体叠加进字体表,而 save 只拿得到 JSON、拿不到原始 docx 包, 用的是基础字体表——所以对内嵌了字体的文档,两者字节可以不同。这是信息量差异,不是缺陷。
常见问题
- 要不要另外部署一个文档服务? 不要。
open/save是两次普通方法调用,没有新进程、没有新端口。 officia-editor.js从哪里下载? 不用下载,它随 jar 分发;Servlet 3.0+ 容器会自动把/officia-editor/officia-editor.js暴露出来。- 能离线用吗? 能。
officia-editor.js不依赖任何 URL,给它 JSON 字符串就能跑,file://也行; Electron / 桌面壳 / 纯静态页都能嵌。 - 存成 PDF 后中文变方块? 与其它模块同一个原因与解法,见 字体替换诊断。
- 输出带水印、被限页? 那是未授权的评估态,见授权与加载。