Java 库 univer-lib:让 Univer Sheets 与 xlsx 无损双向转换

Univer 是越来越火的开源在线表格 SDK,但前端用户常需要把 Excel 文件导进来编辑、再导出回 xlsx。
univer-lib 是一个纯 Java 实现的转换库,专门解决 Univer IWorkbookData ↔ Excel xlsx 双向高保真 round-trip 的问题。
本文介绍它的能力边界、集成方式和典型代码样例。


一、为什么需要它

Univer Sheets 在浏览器里用 IWorkbookData(一份庞大的 JSON
快照)表达整个工作簿:单元格值、样式、富文本、合并区、冻结、批注、条件格式、图表、透视表……应有尽有。

Excel xlsx ≠ Univer JSON

  • xlsx 是基于 OOXML 的压缩包,结构规范但缺少 Univer 一些专有字段(比如 resourcesappVersionpd padding、overline 等);
  • 直接用 Apache POI 写转换器要踩很多坑:样式 64K 上限、共享公式 master 位置、富文本 run 拆分、Drawing 锚点……
  • 没有现成的 Java 库能做到 lossless round-trip

univer-lib 在 POI 之上做了一层封装,引入 OPC sidecar 机制:把 xlsx 无法承载的 Univer 字段写入自定义 OPC 分区
/univer/metadata.json外部 Excel 打开依然合法,再次读回 Univer 时字段不丢

二、功能矩阵

下面这些转换器(converter)都已实现并通过单元 + round-trip 测试覆盖:

模块对应 Converterxlsx 侧Univer 侧
单元格值 / 公式CellConverterPOI CellICellData.v / f / si
样式 + 去重StyleConverterXSSFCellStyleIStyleData(hash 去重,避免 64K 上限)
富文本RichTextConverterXSSFRichTextStringIDocumentData(多 run + 段落)
共享公式SharedFormulaRegistrysi 主格右下Univer f + si 协议
行/列属性WorksheetConverter行高、列宽、隐藏IRowData / IColumnData
合并 / 冻结 / 网格 / RTL / 缩放WorksheetConvertersheet 属性mergeData / freeze / showGridlines …
批注CommentConverterXSSFCommentSHEET_NOTE_PLUGIN resource
条件格式ConditionalFormattingConverterCF rulesSHEET_CONDITIONAL_FORMATTING_PLUGIN
数据验证DataValidationConverterXSSFDataValidationSHEET_DATA_VALIDATION_PLUGIN
定义名称DefinedNameConverterXSSFNameIWorkbookData.definedNames
自动筛选FilterConverterXSSFAutoFilterSHEET_FILTER_PLUGIN
表格(Table)TableConverterXSSFTableSHEET_TABLE_PLUGIN
图片PictureConverterDrawingPatriarch + 图片关系SHEET_DRAWING_PLUGIN
形状 / 图表(drawing 扩展)AdvancedDrawingConverterXSSFShape / XSSFChartSHEET_DRAWING_PLUGIN 扩展
透视表PivotTableConverterXSSFPivotTableSHEET_PIVOT_TABLE_PLUGIN

这 15 个 converter 覆盖了 Excel 95% 以上的常用业务场景。多 sheet、sheetOrder 排序、隐藏 sheet、tab 颜色都同步保留。

已知限制

  • 公式不计算(仅做字符串 round-trip + cached value)
  • 长度换算(px ↔ pt ↔ char)为近似值,可能 ±1 px 偏差
  • 不支持 xlsm(带宏)和 xlsb(二进制)
  • strictMode 暂为预留开关,未在所有不支持特性处抛出异常

三、集成方式

3.1 Maven 依赖

<dependency>
  <groupId>io.github.autoffice</groupId>
  <artifactId>univer-lib</artifactId>
  <version>1.0.0</version>
</dependency>

JDK 8+ 即可,没有 Spring 依赖,可在任何 Java 项目(普通 main、Servlet、Spring Boot 2.x/3.x、Quarkus、CLI 工具)中使用。

3.2 单一入口

整个库的对外 API 只有 io.github.autoffice.univer.UniverXlsx 这一个门面类,方法都是静态的:

public final class UniverXlsx {
    public static IWorkbookData read(InputStream in);
    public static IWorkbookData read(Path path);
    public static IWorkbookData read(InputStream in, UniverXlsxOptions opts);

    public static void write(IWorkbookData wb, OutputStream out);
    public static void write(IWorkbookData wb, Path path);
    public static void write(IWorkbookData wb, OutputStream out, UniverXlsxOptions opts);
}

底层细节(POI、OPC sidecar、Jackson 配置)全部内部封装,调用方只面对 IWorkbookData POJO。

四、代码样例

4.1 读:xlsx → IWorkbookData

import io.github.autoffice.univer.UniverXlsx;
import io.github.autoffice.univer.model.IWorkbookData;
import io.github.autoffice.univer.model.ICellData;
import java.nio.file.Paths;
import java.util.Map;

IWorkbookData wb = UniverXlsx.read(Paths.get("input.xlsx"));

// 取第一个 sheet 的 A1 单元格值
String sheetId = wb.getSheetOrder().get(0);
Map<Integer, Map<Integer, ICellData>> cells = wb.getSheets().get(sheetId).getCellData();
Object a1 = cells.get(0).get(0).getV();
System.out.println("A1 = " + a1);

4.2 写:构造 IWorkbookData → xlsx

import io.github.autoffice.univer.UniverXlsx;
import io.github.autoffice.univer.model.*;
import java.nio.file.Paths;
import java.util.*;

IWorkbookData wb = new IWorkbookData()
        .setId("demo-workbook")
        .setAppVersion("0.10.2")
        .setLocale("zhCN");

IWorksheetData sheet = new IWorksheetData().setId("s1").setName("Sheet1");

// 写两个单元格:A1 = "Hello",B1 = 42
Map<Integer, ICellData> row0 = new LinkedHashMap<>();
row0.put(0, new ICellData().setV("Hello").setT(CellValueType.STRING));
row0.put(1, new ICellData().setV(42.0).setT(CellValueType.NUMBER));
sheet.getCellData().put(0, row0);

wb.getSheets().put("s1", sheet);
wb.setSheetOrder(Collections.singletonList("s1"));

UniverXlsx.write(wb, Paths.get("output.xlsx"));

4.3 自定义选项

UniverXlsxOptions opts = UniverXlsxOptions.builder()
        .strictMode(false)     // 严格模式(预留)
        .writeSidecar(true)    // 是否写边车,默认 true。关掉后导出文件给纯 Excel 用更轻
        .prettyJson(false)     // sidecar JSON 美化
        .locale("zhCN")
        .build();

UniverXlsx.write(wb, Paths.get("output.xlsx"), opts);

4.4 Spring Boot REST 接口

最常见的场景:浏览器里 Univer 编辑器和后端做 xlsx 互转。下面是一个最简单的 controller:

@RestController
@RequestMapping("/api")
public class UniverXlsxController {

    private final ObjectMapper mapper = JsonMapper.get();  // 关键:用库提供的 mapper

    /** 上传 xlsx → 返回 IWorkbookData JSON */
    @PostMapping(value = "/import", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    public ResponseEntity<String> importXlsx(@RequestParam("file") MultipartFile file) throws Exception {
        try (InputStream in = file.getInputStream()) {
            IWorkbookData wb = UniverXlsx.read(in);
            if (wb.getId() == null) {
                wb.setId("imported-" + System.currentTimeMillis());
            }
            return ResponseEntity.ok()
                    .contentType(MediaType.APPLICATION_JSON)
                    .body(mapper.writeValueAsString(wb));
        }
    }

    /** 接收 IWorkbookData JSON → 返回 xlsx 字节 */
    @PostMapping(value = "/export", consumes = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<byte[]> exportXlsx(@RequestBody String body,
                                             @RequestParam(value = "name", defaultValue = "export") String name)
            throws Exception {
        IWorkbookData wb = mapper.readValue(body, IWorkbookData.class);

        ByteArrayOutputStream out = new ByteArrayOutputStream();
        UniverXlsx.write(wb, out);

        String fileName = URLEncoder.encode(name + ".xlsx", StandardCharsets.UTF_8.name()).replace("+", "%20");
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.parseMediaType(
                "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"));
        headers.set(HttpHeaders.CONTENT_DISPOSITION,
                "attachment; filename=\"" + fileName + "\"; filename*=UTF-8''" + fileName);
        return ResponseEntity.ok().headers(headers).body(out.toByteArray());
    }
}

⚠️ 必须用 JsonMapper.get():库内部有一个 IntegerKeyDeserializer,专门处理 cellData 嵌套 Map
的整数字符串键("0":{"0":{...}})。Spring Boot 默认的 ObjectMapper 解析这个结构会失败。

4.5 前端配合(参考)

前端拿到 /api/import 返回的 JSON,直接喂给 Univer:

const res = await fetch('/api/import', { method: 'POST', body: formData });
const json = await res.json();
univerAPI.createWorkbook(json);   // Univer 渲染

// 用户编辑完后导出
const wb = univerAPI.getActiveWorkbook();
const exportJson = wb.save();     // 拿到 IWorkbookData
const xlsxRes = await fetch('/api/export?name=demo', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(exportJson),
}); 
const blob = await xlsxRes.blob();
// 触发浏览器下载...

五、设计要点(一句话版)

  • 分层清晰io → converter → resource → model/util,POI 类型从不泄漏到 converter 包以上
  • Sidecar 模式:xlsx 无法表达的 Univer 专有字段写入 /univer/metadata.json。读时先加载 sidecar 作为基线,再用 xlsx 内容覆盖;外部
    Excel 打开仍合法
  • 样式去重StyleConverter 用 IStyleData 的 JSON hash 做缓存,避免 POI 64K cell-style 上限
  • 共享公式SharedFormulaRegistry 保证 master 落在右下角,符合 Univer 协议
  • POJO 转发兼容:所有模型继承 AbstractUniverModel,未知字段进 extras,Univer 升级新字段不会让旧库报错

六、参考链接

  • GitHub 仓库:https://github.com/autoffice/univer-lib
  • 设计文档(权威):https://github.com/autoffice/univer-lib/blob/main/docs/design.md
  • 完整 demo(Spring Boot 2.7 + Vue 3 + Univer Sheets):https://github.com/autoffice/univer-lib/tree/main/example
  • 后端 demo 源码:https://github.com/autoffice/univer-lib/tree/main/example/backend
  • 前端 demo 源码:https://github.com/autoffice/univer-lib/tree/main/example/frontend
  • example 启动说明:https://github.com/autoffice/univer-lib/blob/main/example/README.md
  • Maven Central:https://central.sonatype.com/artifact/io.github.autoffice/univer-lib

欢迎 issue 反馈和 PR,如果觉得有用别忘了点个 ⭐。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值