gbxlxs
v0.1.6
Published
Standalone spreadsheet (XLSX) engine with a self-contained HTML editor: read/write xlsx, workbook model ops, CSV/HTML import-export and a full spreadsheet UI.
Maintainers
Readme
gbxlxs
独立电子表格引擎:读写真正的 .xlsx、工作簿模型操作、CSV / HTML 导入导出,外加一个自包含的 HTML 表格编辑器。
它不是某个在线表格的封装,而是一层可以直接嵌进任何前端的表格内核:解析 OOXML 得到一个纯对象模型,用同步算子改这个模型,再序列化回 .xlsx,并在往返中尽量保住原文件的边角内容(图表、图片、透视表、条件格式、打印分节等)。ZIP 与 XML 这两个底层难题全部交给 gbooxml。
四层结构
| 层 | 入口 | 职责 |
| --- | --- | --- |
| 引擎 | gbxlxs/workbook | .xlsx 读写、CSV 导出、HTML 导入、数字格式、行列插删 |
| 算子 | gbxlxs/ops | 对工作簿/分节对象做同步就地修改:取值、写值、公式、样式、合并、排序、筛选、冻结、分节增删改名 |
| 会话 | gbxlxs/session | 托管一份文档:脏标记、自动序列化、失败上报、修订号握手 |
| 界面 | web/ | 原生 HTML 表格编辑器:菜单、工具带、公式栏、窗口化网格、页签、右键菜单 |
根入口 gbxlxs 把引擎/算子/会话的 91 个名字一次性全部导出,日常用 import { openXlsx, setValue } from 'gbxlxs' 就够了。
安装
npm install gbxlxs界面
web/ 是一套不打包、不依赖框架的完整表格编辑器,引擎仍走本包:
npm start # 等价于 node web/serve.mjs
# http://localhost:5275/web/包含打开 / 保存 / 另存为 .xlsx、新建、CSV 导出、自动保存(浏览器文件句柄)、深色主题、缩放(工具栏、Ctrl+滚轮以指针为锚)、冻结窗格、筛选、合并、排序、数字格式、条件格式、数据验证、批注、超链接、复选框、图片预览、拖放打开、撤销重做与剪贴板;公式在本地实时求值,输入函数名时弹出候选列表(↑↓ 选择、Enter/Tab 插入、Esc 收起),函数参数处显示签名与必填/可选说明;提供自动求和、可搜索的插入函数对话框、函数分类菜单,列宽/行高支持拖拽与双击自适应,填充柄支持复制、数字/日期序列与公式相对引用平移。index.html 通过 import map 把 gbooxml / guabiutils 指到 /vendor/*,serve.mjs(薄封装,本体在 guabiutils/dev-server)负责静态供给,无需构建。
也可以把编辑器嵌进现有前端(不依赖 Vue,导出路径为 gbxlxs/web/*):
import { SheetEditor } from 'gbxlxs/web/app/sheets-app.js';
const editor = new SheetEditor(mountEl, {
embedded: true, // 文件级动作交给宿主
host: {
getTitle: () => '销售.xlsx',
getPath: () => '文档/表格',
getStatus: () => ({ text: '已保存', kind: '' }),
getAutoSave: () => true,
setAutoSave: (on) => {},
canSave: () => true,
save: async () => true, // 宿主负责把 serializeNow() 的字节写回
saveAs: async () => true,
open: () => {},
create: () => {},
recentList: () => [],
openRecent: (doc) => {},
onChange: ({ dirty, error }) => {},
},
});
await editor.openBytes(bytes); // 或 await editor.createBlank()
const out = await editor.serializeNow();
editor.dispose();不传 host / embedded 时即独立模式,编辑器自己处理文件选择、保存与未保存确认。
导入路径
exports 声明了四个引擎入口与一个 web/ 通配;src/ 深层路径仍会被 Node 直接拒掉:
import { openXlsx } from 'gbxlxs'; // 根入口,全部 91 个名字
import { openXlsx } from 'gbxlxs/workbook'; // 只要引擎层
import { setValue } from 'gbxlxs/ops'; // 只要算子层
import { createSheetSession } from 'gbxlxs/session';
import { SheetEditor } from 'gbxlxs/web/app/sheets-app.js';
import 'gbxlxs/web/styles/sheets.css';
import version from 'gbxlxs/package.json' with { type: 'json' };import ... from 'gbxlxs/src/ops.js' 这种写法会报 ERR_PACKAGE_PATH_NOT_EXPORTED(仓库内的测试能这么写,是因为走的是文件系统路径,不是包解析)。
快速开始
读 → 改 → 存
import { openXlsx, saveXlsx, setValue } from 'gbxlxs';
const wb = await openXlsx(bytes, { readStyles: true }); // 注意:readStyles 默认是 false
const sheet = wb.sheets[0];
setValue(sheet, 0, 0, '新值'); // 同步、就地改
const out = await saveXlsx(wb); // Promise<Uint8Array>readStyles: true 是这个 API 最容易漏的一个开关:不传就不解析样式,写回去等于把原格式抹平。
从零写一个 .xlsx
import { writeXlsx } from 'gbxlxs';
const bytes = await writeXlsx({
sheets: [{
name: '销售',
rows: [
[{ value: '品名', style: { font: { bold: true } } }, { value: '金额' }],
['苹果', 12.5],
[{ value: 0.25, style: { numFmt: '0.00%' } }, { value: '注', comment: { text: '备注', author: '我' } }],
],
}],
defaultFont: { name: '微软雅黑', size: 11 },
});rows 里的单元格里联对象会被自动提升进 cells:{ value, style, formula, richText, comment, hyperlink, checkbox } 都可以直接写在格子上(超链接对象本身除外,它靠 value 上的 hyperlink 字段识别)。
saveXlsx 和 writeXlsx 是两条不同的路:saveXlsx 走保真往返,只接受 openXlsx 产出的工作簿;writeXlsx 从裸模型重新生成全部 OOXML。把一个手工拼出来的对象交给 saveXlsx 会抛 InvalidArgumentError。
导出一张表(writeTableXlsx)
只是要把「表头 + 行」导成 Excel 时不用拼工作簿模型:列定义、表头样式、冻结首行、自动筛选与列宽一次配好。
import { writeTableXlsx } from 'gbxlxs';
const bytes = await writeTableXlsx({
name: '任务',
columns: [
{ title: '名称', key: 'name', width: 40 },
{ title: '负责人', key: 'owner' },
{ title: '截止', key: 'end' },
{ title: '数量', key: 'count', numFmt: '#,##0', align: 'right' },
],
rows: [
{ name: '写文档', owner: '李娜', end: '2026-11-01', count: 3 },
['评审', '王强', '2026-11-02', 1], // 也可以给数组(按列序)
],
});
// 多张表:{ sheets: [{ name, columns, rows }, …], defaultFont, dateSystem }- 列:
title必填(表头文案,也是对象行的默认取值键);key/width/numFmt/align(center/right)/style可选。numFmt与align落到该列每个数据格上。 - 行:数组按列序、对象按
key取值;null/undefined/""落成空格;数字 / 布尔 /Date原样保留类型;{ value, style, hyperlink }内联单元格对象透传。不做类型推断("00123"仍是文本)。 - 版式:
header(默认true)出表头行(加粗 + 浅灰底 + 居中 + 下细线,headerStyle可整份替换),freezeHeader(默认true)冻结首行,autoFilter(默认true)给「表头 + 数据」加自动筛选,autoWidth(默认true)按内容算列宽(8–60 字符;给了width的列以width为准,autoWidth: false则只写显式宽度)。 - 顶层另接受
defaultFont/dateSystem/stringMode/activeSheet,原样传给writeXlsx。 - 参数不合法(空列、空标题、行里塞数组或普通对象、一行给的值比列多、
sheets与columns混用)一律抛InvalidArgumentError,不会静默写出半张表。
公式
import { setFormula, getFormula, recalculateWorkbook, insertRows } from 'gbxlxs';
setFormula(sheet, 1, 1, 'SUM(A2:A5)'); // 公式不带前导 =;第 4 个参数可传缓存值
recalculateWorkbook(wb); // 本地求值,写回每格的 formulaResult
getFormula(sheet, 1, 1); // 'SUM(A2:A5)'
sheet.rows[1][1]; // 计算结果
insertRows(sheet, 1, 1); // 在第 2 行插一行,引用自动下移引擎自带公式解析与求值:支持单元格/区域/跨表引用、定义名称、共享公式、错误值与循环检测,内置 230+ 个函数(数学、统计、文本、日期、逻辑、查找、财务、工程、信息、数据库)。recalculateWorkbook(wb, { keepCached: true }) 会在打开文件时保留无法识别函数的原缓存值,编辑时再暴露 #NAME?。行列插删、隐藏、移动这类结构算子会自动重映射 cells 的键、公式引用、合并区、图片锚点、冻结线和分页符 —— 前提是先调用过 linkWorkbookSheets(wb)(openXlsx 与 addSheet 已经替你做这一步),否则跨表引用不会被同步。
样式与数字格式
import { setStyle, setFont, applyNumfmt, getDisplayText } from 'gbxlxs';
setFont(sheet, 0, 0, { bold: true, color: '#c00000' });
setStyle(sheet, 0, 0, { alignment: { wrapText: true } });
applyNumfmt(sheet, { startRow: 1, startCol: 1, endRow: 3, endCol: 1 }, '0.00');
getDisplayText(sheet, 1, 1); // 按 numFmt 渲染出来的文本样式对象在格子之间是共享引用,绝不要就地改它;setStyle / replaceStyle / cloneCellStyle 都会先克隆再替换。
合并、排序、筛选、冻结
import { mergeRegion, sortRegion, toggleFilter, applyFilter, setFreeze, normalizeRegion } from 'gbxlxs';
const region = normalizeRegion(0, 0, 4, 3); // → { startRow, startCol, endRow, endCol }
mergeRegion(sheet, region); // 1×1 返回 false;与已有合并相交时抛错
sortRegion(sheet, region, 1, false); // 按第 2 列升序;含跨行合并时抛错
toggleFilter(sheet, region); // 建/撤自动筛选
applyFilter(sheet, 1, ['苹果', '梨']); // 按显示文本筛某一列
setFreeze(sheet, 1, 0); // 冻结首行排序的首行识别规则:该行是非空字符串,且同列数据区里存在非字符串值,或者该格 style.font.bold === true。混合类型排序为 数字/日期 < 文本 < 布尔,空值永远排最后(即使 desc)。
高级筛选(条件 / 空白 / 颜色 / 列间且或)
import { setFilterCriteria, setFilterLogic, filterCriteria, filterColorOptions, rowMatchesFilter } from 'gbxlxs';
setFilterCriteria(sheet, 1, { customFilters: { and: true, customFilters: [
{ operator: 'greaterThan', value: 100 },
{ operator: 'lessThanOrEqual', value: 500 },
] } }); // 100 < x <= 500
setFilterCriteria(sheet, 2, { filters: ['北京'], blank: true }); // 按值 + 带空白
setFilterCriteria(sheet, 3, { colorFilters: { cellFillColors: ['FFCC00'] } });
setFilterCriteria(sheet, 1, null); // 清除该列条件
setFilterLogic(sheet, 'or'); // 列间关系:任一条件满足
rowMatchesFilter(sheet, 5); // 单行是否通过(标题行恒为 true)operator 取 equal / notEqual / greaterThan / greaterThanOrEqual / lessThan / lessThanOrEqual / beginsWith / endsWith / contains / notContains;等于/不等于支持 * 与 ? 通配符。筛选隐藏的行由引擎单独记账,取消筛选不会把用户手动隐藏的行放出来。条件随 xlsx 的 customFilters / colorFilter 往返。
查找与替换
import { findInSheet, findInWorkbook, replaceInSheet, replaceInWorkbook, replaceMatch } from 'gbxlxs';
const hits = findInWorkbook(wb, '订单-\\d+', { useRegex: true, matchCase: false, inFormulas: true });
// → [{ sheetIndex, sheetName, row, col, start, end, text, cellText, inFormula }]
replaceInWorkbook(wb, '旧值', '新值', { matchEntireCell: true }); // → { count, changes }
replaceMatch(sheet, hits[0], '新值'); // 单个命中(UI「替换」按钮口径)选项:matchCase、matchEntireCell(同义 wholeCell)、useRegex、inFormulas(同义 lookin: 'formulas')、byFormat + formatStyle、range、sheetIndex。replaceIn* 是「全部替换」口径(格内多处出现一次换净),replaceMatch 只换命中处;返回的 changes 可直接交给历史栈撤销。无效的正则会抛「正则表达式无效」。
超级表结构化引用与动态数组
import { setFormula, recalculateWorkbook } from 'gbxlxs';
setFormula(sheet, 9, 1, 'SUM(销售表[金额])'); // 列、[列]:[列]、[#全部]、[#数据]
setFormula(sheet, 9, 2, '销售表[[#标题],[金额]]'); // [#标题] / [#总计]
setFormula(sheet, 2, 5, '@单价 * @数量'); // @ 与 [#此行] 取当前行
setFormula(sheet, 0, 5, 'SORT(A2:A10)'); // 数组结果自动溢出,冲突返回 #SPILL!
setFormula(sheet, 0, 8, 'LET(x, 10, x * 2)'); // LET / LAMBDA 已可求值
recalculateWorkbook(wb);表名或列名对不上返回 #NAME?,节不合法(如无标题行却要 [#标题])返回 #REF!。溢出区的值写在 spillParent 标记的格子上,重算前会被清掉,不会污染用户数据。
打印分页、线程评论与形状
import { paginateSheet, normalizePrintSetup, addThread, replyThread, threadGroups } from 'gbxlxs';
const g = { colWidths: [...], rowHeights: [...] }; // 未缩放的 96dpi 像素尺寸
const { pages, setup } = paginateSheet(sheet, g); // 按打印区域/标题/分页符/缩放切页
normalizePrintSetup(sheet); // 页面设置 → 像素与选项
addThread(sheet, wb, 0, 0, '这里需要复核', '张三'); // 线程评论(legacy comment 仍是备注)
replyThread(sheet, wb, rootId, '已改');
threadGroups(sheet, wb, 0, 0); // → [{ author, replies, done }]sheet.shapes / sheet.textBoxes 随 xlsx 往返,插删行列时锚点自动同步。paginateSheet 只算分页,渲染交给 UI(Web 编辑器的打印预览即基于此,走浏览器打印对话框另存 PDF)。
CSV 导出与 HTML 导入
import { writeCsv, fromHtml } from 'gbxlxs';
const text = writeCsv(sheet.rows, { delimiter: ',', bom: true, dateFormat: 'yyyy-mm-dd' }); // 同步返回字符串
const sheet2 = fromHtml('<table><tr><td>1</td></tr></table>'); // 返回的是「分节」,不是工作簿
const bytes = await writeXlsx({ sheets: [sheet2] });托管一份文档(会话)
import { createSheetSession, setValue } from 'gbxlxs';
const session = createSheetSession({
onChange: () => render(session.getSheets()),
onSerializeFailed: (err) => toast(err.message),
});
await session.open(bytes); // 或 await session.createBlank()
setValue(session.getSheet(0), 0, 0, 'v1');
session.markChanged(); // 触发一次去抖序列化
await session.serializeNow(); // 立即拿当前 Uint8Array会话的 markChanged() 只是递增修订号并后台序列化;真正落盘后要 session.ackFlushed(revision) 才会清脏标记。序列化失败不会丢数据 —— getLatestBytes() 继续返回上一份可用字节,getSerializeError() 保留错误,等你修好分节名再 markChanged() 自愈。
数据模型
一个工作簿长这样(字段均为真实名称):
{
sheets: [{
name: 'Sheet1',
rows: [['a', 1], ['b', 2]], // 稠密矩形,Array<Array<value>>
cells: new Map([['0,1', { value: 1, type: 'number', style: {…} }]]),
columns: [{ width: 20 }, undefined],
rowDefs: new Map([[0, { height: 24 }]]),
merges: [{ startRow: 0, startCol: 0, endRow: 0, endCol: 1 }],
freezePane: { rows: 1 },
autoFilter: { range: 'A1:D9', columns: [{ colIndex: 0, filters: ['a'] }] },
threadedComments: [{ id, personId, ref: 'A1', date, text, parentId?, done? }],
shapes: [{ type: 'rect', text, anchor: { from: { row, col }, to: { row, col } }, width, height, style }],
// 还可能有:dataValidations, conditionalRules, images, charts, tables,
// textBoxes, pageSetup, headerFooter, protection, sparklines, …
}],
persons: [{ id, displayName }],
dateSystem: '1900',
activeSheet: 0,
namedRanges: [{ name, range, scope }],
properties: { title, creator, created, modified },
}单元格的 value 可能是 null、number、string、boolean、Date,或错误文本字符串(如 #REF!)。日期序列在读入时就被转成真正的 Date;公式格存的是缓存结果而不是公式文本(公式文本在 cells 条目的 formula 上)。
rows 与 cells 的双写规则
rows 是稠密的值矩形,cells 是稀疏的细节条目表(样式、公式、富文本、批注、超链接)。写出时 cells 里的值覆盖 rows,所以:
- 读值请走
getValue/getDisplayText,它们会先查cells再回落rows; - 写值请走
setValue/setFormula,它们会同时更新两侧并清理无关字段; - 永远不要直接
sheet.rows[r][c] = x—— 旧的cells条目会把你新写的值盖回去。
API 速查
| 组 | 名字 |
| --- | --- |
| 打开/保存 | openXlsx、saveXlsx、writeXlsx、writeTableXlsx、fromHtml、writeCsv |
| 读格 | getEntry、getValue、getDisplayText、getFormula、getStyle、keyOf、isProtected |
| 写格 | setValue、setFormula、setStyle、setFont、setCellExtra、replaceStyle、clearStyle、parseInput |
| 快照/撤销 | captureCell、applyCell、captureRegion |
| 行列结构 | insertRows、deleteRows、hideRows、insertColumns、deleteColumns、hideColumns、unhideAll、setFreeze |
| 分节 | addSheet、removeSheetAt、renameSheetAt、linkWorkbookSheets |
| 区域 | normalizeRegion、regionA1、cellHasContent、currentRegion、regionsOverlap、mergeAt、mergeRegion、unmergeRegion |
| 排序/筛选 | compareKey、sortRegion、toggleFilter、applyFilter、clearFilter、filterOptions、filterColorOptions、filterCriteria、setFilterCriteria、setFilterLogic、filterLogic、rowMatchesFilter、hasFilter |
| 查找替换 | findInSheet、findInWorkbook、replaceMatch、replaceInSheet、replaceInWorkbook |
| 打印分页 | paginateSheet、normalizePrintSetup |
| 格式 | formatValue、inferType、cloneCellStyle、shiftedPattern、applyNumfmt、currentNumfmt、applyBorder |
| 引用工具 | parseCellRef、cellRef、colToLetter、letterToCol、parseRange、isInRange、replaceA1Refs |
| 校验常量 | FORBIDDEN_SHEET_NAME_CHARS、MAX_SHEET_NAME_LENGTH、RESERVED_SHEET_NAME |
openXlsx 的完整选项面:sheets(数组按序号/名称挑,或 ({ name, index, hidden, veryHidden }, i) => boolean)、readStyles、range、maxRows、maxTotalCells、sparse、dateSystem、password、maxSpinCount、maxInputBytes、maxDecompressedBytes、onWarning。onWarning 收到 { code, message, sheet, row, col },实际出现过的 code 有 malformed-cell-ref、unresolved-shared-string、unresolved-style、unresolved-hyperlink。
校验与上限
分节名由 workbook/validate.js 统一把关,全部抛 InvalidArgumentError:非空、≤ 31 字符、不含 [ ] : * ? / \、不以撇号 ' 开头或结尾、不等于保留名 history(大小写不敏感)、同簿内不重名(大小写不敏感)。renameSheetAt 用的是同一套规则、中文错误文案。
硬上限:行索引 ≤ 1048575、列索引 ≤ 16383(即 A1:XFD1048576)、单元格总数 2×10⁷、cells Map 条目 2²⁴、数字格式串长度 ≤ 255。
环境说明
浏览器可直接用,src/ 内没有任何 node: 导入,也不用 Buffer、fs、process。Node 侧 engines: >=18 只是为了 structuredClone 与 WebCrypto(敏捷加密),以及 node --test 跑测试。
错误类不可导入:WorkbookError / InvalidArgumentError / EncryptedFileError / DecryptionError / ParseError 都不在导出面上,只能 try/catch 后按 err.name 与 err.message 判断。引擎层(validate.js)文案是英文,算子层(ops.js)文案是中文。
已知坑
readStyles默认false,忘传就丢样式。saveXlsx依赖openXlsx挂在工作簿上的一个不可枚举、不可写、不可配置的Symbol往返状态;展开或克隆工作簿会丢掉它,之后只能改用writeXlsx。xl/calcchain.xml在保存时被丢弃(Excel 会自行重算),这是刻意的。hideColumns会用{}懒填充sheet.columns,所以columns可能很长且带空洞。- 筛选隐藏行记录在模块级
WeakMap里而不是模型上;unhideAll()会清掉它,因此先unhideAll()再clearFilter(),否则手动隐藏会被当成筛选隐藏。 - 用
options.sheets过滤打开会真的删掉分节,于是activeSheet下标与跨表位置相对原文件发生偏移。 - 文本→类型推断请走
inferType,别自己Number();日期串退化成文本后,General 格式会把它显示成带时间的 ISO 串。 - 富文本在写出时优先于公式,所以
setFormula会主动删掉richText;反过来只删formula而不调clearFormulaMeta,会让下一条公式背上array/shared/dynamic标记。 - 想输入公式请用
parseInput(text):=1+1返回{ formula: '1+1', value: null },其余走inferType。
