gbdocs
v0.1.2
Published
挂逼文档:独立 .docx 编辑器(内核移植自 guabi office/docx 编解码 + 自研 contenteditable 编辑器内核,UI 用原生 HTML 重写),只依赖 gbooxml。
Maintainers
Readme
gbdocs
独立 .docx 编辑器:OOXML docx 编解码内核 + 自研 contenteditable 编辑器内核 + 原生 HTML 界面。
没有 React/Vue,没有 ProseMirror/TipTap,没有 CKEditor。整个包只依赖 gbooxml 做 ZIP 与 XML,其余全部自持:.docx 双向编解码、以 DOM 为文档模型的编辑器内核、分页/页眉页脚/脚注尾注/批注/修订,以及菜单、工具栏、查找、颜色面板这一整套 UI。
三层结构
| 层 | 入口 | 职责 |
| --- | --- | --- |
| 编解码 | gbdocs/kernel/docx/codec/* | .docx 字节 ⇄ 文档模型 ⇄ HTML |
| 编辑器内核 | gbdocs/surface/editor-surface | EditorSurface 承载 EditorCore:命令、状态快照、历史、表格、批注、修订 |
| 完整应用 | gbdocs(根入口) | WordApp:把上面两层装配成一个可用的 Word 界面 |
只想做一个在线文档编辑器 → 用 WordApp;已有自己的界面、只要编辑内核 → 用 EditorSurface;只要读写文件、不要编辑器 → 用 codec。
安装
npm install gbdocs先把它跑起来
npm start # node serve.mjs,默认 http://localhost:5273/serve.mjs 只是薄封装,静态服务器本体是 guabiutils/dev-server(三端共用同一份),端口可 node serve.mjs 8080 或用环境变量 GBDOCS_PORT。打开根路径即为完整编辑器,控制台里有 window.gbdocs 指向 WordApp 实例,可直接调 await window.gbdocs.selfTest() 做一次全链路自检。
页面本身只有 index.html,它加载四个样式表(顺序有意义):tokens.css → editor-content.css → word-panel.css → app.css。自己装配页面时请全部引入,否则正文排版和面板都会走形。
index.html 里有一条 importmap,把裸标识符 gbooxml / guabiutils 映射到 /vendor/*,而 serve.mjs 把 /vendor/gbooxml/*、/vendor/guabiutils/* 指向同级目录的源码。这意味着这个自带 demo 只在仓库的 monorepo 布局下能跑;从 npm 单独安装后要用 demo 页面,请自行把 gbooxml 映射到 node_modules/gbooxml/src/index.js。
嵌入到自己的页面
import { WordApp } from 'gbdocs';
import 'gbdocs/styles/tokens.css';
import 'gbdocs/styles/editor-content.css';
import 'gbdocs/styles/word-panel.css';
import 'gbdocs/styles/app.css';
const app = new WordApp(document.getElementById('app'));
await app.start();构造函数只有一个参数:一个已经挂在文档里的 DOM 元素,编辑器 UI 会 appendChild 进去。没有 options 对象,配置都在实例方法上:
app.setEditMode('readonly'); // 'edit' | 'suggest' | 'readonly'
app.setZoom(120); // 40–300,会持久化到 localStorage
app.setView('commentsPanel', true);
await app.saveAs(); // 另存为 .docx(优先 File System Access API,回落下载)
app.print();
app.destroy(); // 停观察器与定时器,但不会把 DOM 从挂载点摘掉destroy() 只解除副作用(ResizeObserver、window resize、分页与自动保存定时器),不会移除它自己插入的 DOM;要彻底回收请连挂载点子树一起删。
只读写文件(codec)
import { parseDocx } from 'gbdocs/kernel/docx/codec/docx';
import { buildDocxVerified } from 'gbdocs/kernel/docx/codec/roundtrip';
import { docToMarkdown } from 'gbdocs/kernel/docx/codec/markdown';
import { lossText } from 'gbdocs/kernel/docx/codec/losses';
const doc = await parseDocx(bytes); // Uint8Array | ArrayBuffer → Promise<Document>
console.log(lossText(doc.losses)); // 这次打开丢掉了哪些原文特性
const back = await buildDocxVerified(doc); // Promise<Uint8Array>
const md = docToMarkdown(doc); // 同步返回字符串文档模型就是 codec 与编辑器之间唯一的接口:
{
html: '<p>正文</p>', // 正文(编辑器的工作内容)
sections: [{
type: '', // 分节类型
page: { size: 'A4', orientation: 'portrait',
margins: { top: 25.4, right: 25.4, bottom: 25.4, left: 25.4 },
columns: 1, colGap: 8, colSeparator: false, headerTop: 0, footerBottom: 0 },
hfMode: 'all', header: '', footer: '', headerEven: '', footerEven: '', headerFirst: '', footerFirst: '',
pageNumbers: { enabled: false, align: 'center', format: 'arabic', start: 1 },
watermark: { data: '', color: '#AEB5C0', size: 120, opacity: 0.3 },
}],
comments: [], footnotes: [], endnotes: [],
props: null, // 标题/作者等文档属性
defaultTabStop: 720,
carry: [], // 整包透传的分节(主题、术语表、customXml…)
losses: [], // 只有读出来的 doc 上有
}WordApp.currentDoc() 产出的就是这个形状,applyDoc(model) 接受它(传 null 得到空白文档)。
保存自检
buildDocx() 直接生成字节;buildDocxVerified() 生成后会立刻读回来比对内容摘要,任何不一致都抛 保存自检未通过… 并拒绝这次保存。应用内所有保存路径走的都是 buildDocxVerified。想强行写出未经自检的结果(比如调试),才用 buildDocx。
保住与丢失
往返会保住:段落对齐/缩进/行距、H1–H6、加粗斜体下划删除线、颜色字号字体高亮、等宽代码、超链接、有序无序列表、表格(列宽、单元格底纹、横纵合并)、内联图片、分隔线、引用块、分页符、页眉页脚与页码、纸张尺寸方向页边距分栏、批注、修订(含作者与时间)、脚注尾注、文档属性,以及主题等整包透传内容。
会丢的主要是:文本框/形状、域代码、SmartArt。丢失项在读入时就被清点进 doc.losses,用 lossText() 可以直接展示给用户。
旧版二进制 .doc 会在文件头就被拒绝:旧版二进制 .doc 不支持,请先另存为 .docx。
只用编辑内核
import { EditorSurface } from 'gbdocs/surface/editor-surface';
const surface = new EditorSurface({
mount: document.getElementById('editor'),
modelValue: '<p>正文</p>',
placeholder: '开始输入',
editable: true,
backdrop: (host) => { /* 自己画纸张、页眉页脚带 */ },
});
const editor = surface.editor; // EditorCore
editor.exec('toggleBold');
editor.state(); // 一次快照,供工具栏刷新
editor.run(() => editor.exec('insertText', 'hi'), { merge: 'insertText' });
await surface.insertImages(fileList);
surface.destroy();这里的 文档模型就是那段 contenteditable DOM:进出只有 normalizeBody / serializeBody 两道规范化,对应 editor.getHTML() / editor.setHTML(html)。exec(name, ...args) 的命令名在 COMMANDS 注册表里,未知名返回 false;can(name, ...args) 用来置灰按钮。常用命令:undo、redo、toggleBold/Italic/Underline/Strike/Code、setColor、setFontSize、setFontFamily、setLink、clearFormat、setHeading(level)、toggleBlockquote、toggleBulletList、insertPageBreak、insertTable({ rows, cols, withHeaderRow })、tableAction(action, counts)、setComment(key)、insertNote(spec)、resolveRevisions(accept)、convertBlock(kind, el)。
导入路径
深层入口走通配 ./src/*.js,子路径不带 .js:
import { parseDocx } from 'gbdocs/kernel/docx/codec/docx'; // ✔ → src/kernel/docx/codec/docx.js
import { EditorCore } from 'gbdocs/kernel/editor/engine/editor';
import { useKeyboard } from 'gbdocs/kernel/keyboard';
import { FONT_SIZES } from 'gbdocs/kernel/textStyles';
import { notify } from 'gbdocs/kernel/notify';写成 gbdocs/kernel/docx/codec/docx.js 会解析成 docx.js.js 而报 ERR_MODULE_NOT_FOUND。
环境说明
读方向(parseDocx)在纯 Node 可跑 —— 它只用 gbooxml 的纯 JS ZIP/XML。
写方向(buildDocx / buildDocxVerified)和整套 UI 需要 DOM:HTML → 块模型的转换用 DOMParser,界面层还用到 ResizeObserver、localStorage、showOpenFilePicker。在 Node 里跑写入请自备 jsdom,本包的测试就是这么做的:
import { JSDOM } from 'jsdom';
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost/' });
Object.assign(globalThis, {
window: dom.window, document: dom.window.document,
DOMParser: dom.window.DOMParser, XMLSerializer: dom.window.XMLSerializer,
Node: dom.window.Node, NodeFilter: dom.window.NodeFilter,
Element: dom.window.Element, HTMLElement: dom.window.HTMLElement,
DocumentFragment: dom.window.DocumentFragment,
});
const { parseDocx } = await import('gbdocs/kernel/docx/codec/docx');注意要用动态 import:编解码模块在被加载时就会触碰 DOM 全局,必须在上面的赋值之后再导入。
jsdom 只是本包测试的 devDependency,不在运行时依赖里 —— 浏览器场景无需任何 polyfill。
已知坑
- 批注键在写出时会被重编号成数字 id:读到的
data-cmt="c1"存回去可能变成data-cmt="0",别把自己的业务 id 直接绑在这个属性上。 new WordApp(mount)的挂载元素必须已在文档中,构造期间就会测量布局;先append再new。WordApp不对外派发任何事件,它只消费编辑器内核的事件总线。要做联动,取app.editor后app.editor.on('state' | 'selection', fn)。- 偏好(缩放、面板开关)持久化在
localStorage['gbdocs.prefs'],SSR 环境请在客户端实例化。 - 自带 demo 的
/vendor/gbooxml依赖仓库同级目录布局,从 npm 单独安装需自己改 importmap。
许可
MIT
