@file-viewer/doc 3.1.2 → 3.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -1,39 +1,7 @@
1
1
  # @file-viewer/doc
2
2
 
3
- `@file-viewer/doc` is the open-source legacy MS-DOC (`.doc`) parser and HTML renderer used by File Viewer. It is extracted from the public `msdoc-viewer@0.2.0` line and maintained as the `docjs` source package.
3
+ Runtime package version 3.2.1.
4
4
 
5
- ## Install
5
+ Usage and API documentation: https://github.com/flyfish-dev/docjs
6
6
 
7
- ```bash
8
- npm install @file-viewer/doc
9
- ```
10
-
11
- Historical imports from `msdoc-viewer` remain supported through the compatibility alias package.
12
-
13
- ## Usage
14
-
15
- ```ts
16
- import { parseMsDoc, renderMsDoc } from '@file-viewer/doc'
17
-
18
- const parsed = parseMsDoc(arrayBuffer)
19
- const rendered = renderMsDoc(parsed)
20
-
21
- container.innerHTML = `<style>${rendered.css}</style><div class="msdoc-root">${rendered.html}</div>`
22
- ```
23
-
24
- External links and linked images are blocked by default. Trusted integrations
25
- can opt in separately with `externalLinkPolicy: 'allow'` and
26
- `externalResourcePolicy: 'allow'`; embedded images and bookmarks keep working.
27
-
28
- The package also exports `parseMsDocToHtml`, `mountMsDoc`, `createMsDocViewer`, and `MsDocWorkerClient`.
29
-
30
- ## Scope
31
-
32
- - OLE/CFB container parsing
33
- - WordDocument, 0Table/1Table, CLX, FKP, CHPX, PAPX, STSH, and font table parsing
34
- - Paragraph, character, table, image, OLE attachment, and basic field-code output
35
- - Embedded picture extraction via MS-DOC PICF / OfficeArt BLIP records (PNG, JPEG, DIB, TIFF), with EMF/WMF → SVG conversion for browser preview
36
- - Browser-native HTML/CSS rendering without server-side conversion
37
- - Optional module worker entry at `@file-viewer/doc/worker`
38
-
39
- This package intentionally tracks the old public `msdoc-viewer@0.2.0` code line and continues to harden that public MS-DOC path. Newer private document-engine work is not used here.
7
+ The package's own license is MIT. Included third-party notices apply to their respective components.
package/README.md CHANGED
@@ -1,269 +1,7 @@
1
1
  # @file-viewer/doc
2
2
 
3
- 一个面向 **MS-DOC(`.doc` 二进制 Word)** 的 **TypeScript 严格类型** 解析与预览库。它把旧版 Word 文档解析为结构化 AST,再渲染为可直接嵌入任意项目的 `HTML + CSS`,同时提供 DOM Viewer 与 Web Worker 通道。
3
+ Runtime package version 3.2.1.
4
4
 
5
- > 本项目处理的是 **`.doc`**,不是 `.docx`。
6
- > 本包基于 npm 已公开的 `msdoc-viewer@0.2.0` 旧版源码维护,不包含新版私有文档引擎实现。
5
+ Usage and API documentation: https://github.com/flyfish-dev/docjs
7
6
 
8
- ## 特性
9
-
10
- - **整仓 TypeScript 重构**:源码、公开 API、Worker 协议、解析 AST 全部使用 TypeScript,并开启 `strict` 模式
11
- - **完整声明文件产物**:构建后输出 `dist/*.d.ts`,便于社区阅读、联想、二次开发
12
- - **纯前端方案**:不依赖 LibreOffice、COM、ActiveX 或服务器端转换
13
- - **按 MS-DOC 主链路实现**:CFB/OLE → FIB → CLX/Piece Table → FKP → PAPX/CHPX/TAPX → STSH → HTML
14
- - **可扩展架构**:把二进制读取、属性解码、样式继承、表格布局、渲染层完全拆分
15
- - **支持 Web Worker**:避免大文档解析阻塞 UI 线程
16
-
17
- ## 当前能力
18
-
19
- 已经覆盖的主要能力:
20
-
21
- - CFB/OLE 容器读取
22
- - FIB(`FibBase` / `FibRgLw` / `FibRgFcLcb`)解析
23
- - CLX / Piece Table 文本恢复
24
- - FKP(PAPX / CHPX)属性读取
25
- - STSH 样式表解析与样式继承
26
- - 字体表读取
27
- - 常见字符样式:粗体、斜体、下划线、删除线、字号、颜色、高亮、大小写、上下标、字体切换
28
- - 常见段落样式:对齐、缩进、段前后距、行距、分页控制、边框
29
- - 表格:`sprmTDefTable`、单元格宽度、横向/纵向合并、边框、垂直对齐、nowrap、fitText
30
- - 图片:按 MS-DOC PICF / OfficeArt BLIP 规范提取内嵌 PNG、JPEG、DIB、TIFF,并支持 EMF/WMF 转 SVG 预览
31
- - OLE / ObjectPool 附件提取
32
- - 域代码的基础处理(例如超链接)
33
- - 浏览器 Viewer 与 Worker Client
34
-
35
- ## 工程结构
36
-
37
- ```text
38
- src/
39
- core/
40
- binary.ts # 二进制读取器
41
- cfb.ts # CFB/OLE 容器解析
42
- inflate.ts # OfficeArt 压缩 metafile 的 zlib/deflate 解压
43
- utils.ts # 字节、编码、范围等底层工具
44
- msdoc/
45
- fib.ts # FIB 读取
46
- clx.ts # CLX / Piece Table
47
- fkp.ts # FKP 页读取
48
- sprm.ts # SPRM 操作数解码
49
- properties.ts # PAPX / CHPX / TAPX 到状态对象的归并
50
- styles.ts # STSH / 样式继承
51
- fonts.ts # 字体表
52
- officeart.ts # OfficeArt 内联 shape / BLIP 辅助解析
53
- objects.ts # PICF / OfficeArt 图片与 OLE / ObjectPool
54
- vector.ts # EMF/WMF 转 SVG
55
- parser.ts # 主解析流程,输出 AST
56
- render/
57
- html.ts # AST -> HTML/CSS
58
- types.ts # 完整类型声明与公共接口
59
- viewer.ts # DOM Viewer
60
- worker-client.ts # Worker 客户端
61
- worker.ts # Worker 入口
62
- index.ts # 公共导出
63
-
64
- demo/
65
- index.html
66
-
67
- test/
68
- smoke.mjs
69
- ```
70
-
71
- ## 安装
72
-
73
- ```bash
74
- npm install @file-viewer/doc
75
- ```
76
-
77
- 历史 `msdoc-viewer` 包名会通过兼容别名继续导出同一套 API。
78
-
79
- ## 仓库开发与构建
80
-
81
- ```bash
82
- pnpm install
83
- pnpm --filter @file-viewer/doc type-check
84
- pnpm --filter @file-viewer/doc build
85
- pnpm --filter @file-viewer/doc verify:github-34
86
- ```
87
-
88
- 构建产物输出到 `dist/`:
89
-
90
- - `dist/index.js`
91
- - `dist/index.d.ts`
92
- - `dist/worker.js`
93
- - 其余模块的 JS / d.ts
94
-
95
- ## 快速开始
96
-
97
- ### 1)直接解析并渲染
98
-
99
- ```ts
100
- import { parseMsDoc, renderMsDoc } from '@file-viewer/doc';
101
-
102
- const buffer = await file.arrayBuffer();
103
- const parsed = parseMsDoc(buffer);
104
- const rendered = renderMsDoc(parsed);
105
-
106
- document.getElementById('app')!.innerHTML = `
107
- <style>${rendered.css}</style>
108
- <div class="msdoc-root">${rendered.html}</div>
109
- `;
110
- ```
111
-
112
- 外部链接和关联图片默认阻断。确需访问可信外部资源时,应分别显式设置
113
- `externalLinkPolicy: 'allow'` 和 `externalResourcePolicy: 'allow'`;内嵌图片及书签不受影响。
114
-
115
- ### 2)使用内置 Viewer
116
-
117
- ```ts
118
- import { createMsDocViewer } from '@file-viewer/doc';
119
-
120
- const container = document.getElementById('app')!;
121
- const viewer = createMsDocViewer(container);
122
-
123
- await viewer.load(file);
124
- ```
125
-
126
- ### 3)使用 Worker 提升体验
127
-
128
- ```ts
129
- import { createMsDocViewer, MsDocWorkerClient } from '@file-viewer/doc';
130
-
131
- const workerClient = MsDocWorkerClient.create(
132
- new URL('./node_modules/@file-viewer/doc/dist/worker.js', import.meta.url)
133
- );
134
-
135
- const viewer = createMsDocViewer(document.getElementById('app')!, {
136
- workerClient,
137
- });
138
-
139
- await viewer.load(file);
140
- ```
141
-
142
- ## API 概览
143
-
144
- ### `parseMsDoc(input, options?)`
145
-
146
- 把 `.doc` 解析成结构化结果:
147
-
148
- ```ts
149
- import type { MsDocParseResult } from '@file-viewer/doc';
150
- ```
151
-
152
- 核心字段:
153
-
154
- - `warnings`: 解析期告警
155
- - `meta`: 文档元数据与统计
156
- - `fonts`: 字体表摘要
157
- - `styles`: 样式表摘要
158
- - `blocks`: 结构化块级 AST(段落、表格、附件)
159
- - `assets`: 图片 / 附件资产
160
-
161
- ### `renderMsDoc(parsed, options?)`
162
-
163
- 把 AST 渲染为:
164
-
165
- - `html`
166
- - `css`
167
- - `warnings`
168
- - `meta`
169
- - `assets`
170
- - `parsed`
171
-
172
- ### `parseMsDocToHtml(input, options?)`
173
-
174
- 一步完成解析与渲染。
175
-
176
- ### `createMsDocViewer(container, config?)`
177
-
178
- 返回一个面向浏览器的 viewer:
179
-
180
- - `load(input, options?)`
181
- - `mount(rendered)`
182
- - `clear()`
183
- - `destroy()`
184
- - `value`
185
-
186
- ### `MsDocWorkerClient`
187
-
188
- 为 Worker 提供强类型 RPC 包装:
189
-
190
- - `parse(input, options?)`
191
- - `render(parsed, options?)`
192
- - `parseToHtml(input, options?)`
193
- - `destroy()`
194
-
195
- ## 关键类型
196
-
197
- 所有公共类型都从根入口导出,例如:
198
-
199
- ```ts
200
- import type {
201
- MsDocParseResult,
202
- MsDocRenderResult,
203
- MsDocBlock,
204
- ParagraphBlock,
205
- TableBlock,
206
- MsDocAsset,
207
- MsDocParseOptions,
208
- MsDocRenderOptions,
209
- MsDocViewer,
210
- } from '@file-viewer/doc';
211
- ```
212
-
213
- 这意味着:
214
-
215
- - 社区开发者可以直接消费 `AST` 与 `asset` 数据自行渲染
216
- - 也可以在现有渲染器之外实现 Markdown、Canvas、PDF 等输出层
217
- - Worker 主线程通信协议也有完整类型约束,避免“隐式 any 消息格式”问题
218
-
219
- ## 严格类型与可维护性说明
220
-
221
- 这次重构的重点不只是把文件后缀改成 `.ts`,而是把整条主链路都显式类型化:
222
-
223
- - 二进制结构:CFB/FIB/CLX/FKP
224
- - 样式模型:字符、段落、表格状态对象
225
- - 中间 AST:段落、行内节点、表格、附件
226
- - 渲染结果:HTML/CSS/资产
227
- - Viewer / Worker 协议
228
-
229
- 同时给关键模块补充了注释,尤其是:
230
-
231
- - `BinaryReader` 的边界读取行为
232
- - Worker 请求/响应通道
233
- - parser 主流程
234
- - 样式与属性归并逻辑
235
-
236
- ## 本地验证
237
-
238
- ```bash
239
- npm run smoke
240
- ```
241
-
242
- 这个命令会:
243
-
244
- 1. 先运行 TypeScript 构建
245
- 2. 再读取 `test/test.doc`
246
- 3. 输出 `test/rendered-sample.html`
247
-
248
- 如果你要手工打开 demo:
249
-
250
- ```bash
251
- npm run build
252
- # 然后用任意静态服务器打开 demo/index.html
253
- ```
254
-
255
- ## 已知边界
256
-
257
- MS-DOC 是历史包袱很重的二进制格式,虽然主链路已经完整重构,但以下场景仍建议用更多真实样本持续压测:
258
-
259
- - 极端复杂的嵌套表格
260
- - 非常规域代码组合
261
- - 少见的 OLE 嵌入形式
262
- - 某些兼容模式下的边角 sprm 变体
263
- - 仅保存外部路径、未嵌入字节的链接图片(会给出占位提示,无法在离线环境加载本地盘路径)
264
-
265
- 这些场景并不影响当前的整体架构,后续继续扩展时可直接在现有 TS 类型体系上增量补强。
266
-
267
- ## 许可证
268
-
269
- MIT
7
+ The package's own license is MIT. Included third-party notices apply to their respective components.
@@ -2,11 +2,6 @@ import type { BinaryInput } from '../types.js';
2
2
  type ReaderMethod = ((offset: number) => number) & {
3
3
  byteWidth?: number;
4
4
  };
5
- /**
6
- * Lightweight little-endian binary reader used across the MS-DOC parser.
7
- * The implementation intentionally returns neutral defaults for out-of-range
8
- * reads so upper layers can stay resilient to slightly malformed documents.
9
- */
10
5
  export declare class BinaryReader {
11
6
  readonly bytes: Uint8Array;
12
7
  readonly view: DataView;
@@ -1,11 +1,6 @@
1
1
  import { textDecoder } from './utils.js';
2
2
  const utf16leDecoder = textDecoder('utf-16le');
3
3
  const latin1Decoder = typeof TextDecoder !== 'undefined' ? textDecoder('windows-1252') : textDecoder('latin1');
4
- /**
5
- * Lightweight little-endian binary reader used across the MS-DOC parser.
6
- * The implementation intentionally returns neutral defaults for out-of-range
7
- * reads so upper layers can stay resilient to slightly malformed documents.
8
- */
9
4
  export class BinaryReader {
10
5
  bytes;
11
6
  view;
@@ -1,7 +1,2 @@
1
1
  import type { BinaryInput, ParsedCFB } from '../types.js';
2
- /**
3
- * Parses the CFB/OLE container that wraps a legacy `.doc` file.
4
- * The returned object exposes stream helpers so higher layers can focus on
5
- * Word-specific structures instead of low-level sector navigation.
6
- */
7
2
  export declare function parseCFB(input: BinaryInput, _options?: Record<string, unknown>): ParsedCFB;
package/dist/core/cfb.js CHANGED
@@ -105,11 +105,6 @@ function buildPathMap(entries) {
105
105
  function normalizeName(name) {
106
106
  return name.replace(/\u0000+$/, '');
107
107
  }
108
- /**
109
- * Parses the CFB/OLE container that wraps a legacy `.doc` file.
110
- * The returned object exposes stream helpers so higher layers can focus on
111
- * Word-specific structures instead of low-level sector navigation.
112
- */
113
108
  export function parseCFB(input, _options = {}) {
114
109
  const bytes = toUint8Array(input);
115
110
  const reader = new BinaryReader(bytes);
@@ -1,9 +1 @@
1
- /**
2
- * Small RFC1950/zlib + RFC1951/DEFLATE inflater.
3
- *
4
- * OfficeArt stores EMF/WMF BLIPs behind OfficeArtMetafileHeader.compression=0x00
5
- * using the zlib wrapper defined by RFC1950. Keeping this inflater dependency-free
6
- * preserves the browser-only build while still allowing the synchronous MS-DOC
7
- * parser to expose browser-displayable SVG assets.
8
- */
9
1
  export declare function inflateZlib(bytes: Uint8Array, expectedLength?: number): Uint8Array | null;
@@ -1,11 +1,3 @@
1
- /**
2
- * Small RFC1950/zlib + RFC1951/DEFLATE inflater.
3
- *
4
- * OfficeArt stores EMF/WMF BLIPs behind OfficeArtMetafileHeader.compression=0x00
5
- * using the zlib wrapper defined by RFC1950. Keeping this inflater dependency-free
6
- * preserves the browser-only build while still allowing the synchronous MS-DOC
7
- * parser to expose browser-displayable SVG assets.
8
- */
9
1
  class InflateError extends Error {
10
2
  name = 'InflateError';
11
3
  }
@@ -81,8 +73,6 @@ class ByteSink {
81
73
  toUint8Array(expectedLength) {
82
74
  const out = this.buffer.slice(0, this.length);
83
75
  if (expectedLength != null && expectedLength >= 0 && out.length !== expectedLength) {
84
- // The OfficeArt header is advisory in some legacy files. Do not fail the
85
- // parse because a producer rounded the saved size differently.
86
76
  return out;
87
77
  }
88
78
  return out;
@@ -42,7 +42,7 @@ export const JC_MAP = {
42
42
  };
43
43
  export const VERTICAL_ALIGN_MAP = {
44
44
  0: 'top',
45
- 1: 'center',
45
+ 1: 'middle',
46
46
  2: 'bottom',
47
47
  };
48
48
  export const HIGHLIGHT_COLORS = {
package/dist/msdoc/fkp.js CHANGED
@@ -170,7 +170,6 @@ export function readPapxProperties(wordBytes, offset, dataBytes) {
170
170
  bodyStart = offset + 2;
171
171
  }
172
172
  else {
173
- // PapxInFkp.cb counts 2-byte words; the payload occupies (2 * cb) - 1 bytes.
174
173
  bodySize = cb * 2 - 1;
175
174
  bodyStart = offset + 1;
176
175
  }
@@ -216,8 +216,6 @@ function extractParagraphs(html) {
216
216
  }
217
217
  cursor = tagEnd + 1;
218
218
  }
219
- // Malformed HTML saved by old Word versions is common. Preserve its visible
220
- // tail as a paragraph without restarting a regex search at every opening tag.
221
219
  if (active) {
222
220
  appendParagraph(active.attributes, html.slice(active.contentStart));
223
221
  }
@@ -12,18 +12,7 @@ export declare function detectImageSegment(bytes: Uint8Array): {
12
12
  start: number;
13
13
  end: number;
14
14
  } | null;
15
- /**
16
- * OfficeArt records all start with the common 8-byte OfficeArtRecordHeader.
17
- * Parsing this explicitly lets us walk inline shape containers instead of guessing
18
- * image payloads by magic bytes, which was the root cause of the broken image output.
19
- */
20
15
  export declare function parseOfficeArtRecordHeader(bytes: Uint8Array, offset: number): OfficeArtRecordHeader | null;
21
- /**
22
- * Resolves a picture character (U+0001 + sprmCPicLocation) to an HTML-friendly asset.
23
- * The happy path is: PICF -> optional linked picture name -> OfficeArtInlineSpContainer
24
- * -> OfficeArtBlip*. When no structured BLIP can be found we still fall back to a
25
- * signature scan so slightly malformed files remain usable.
26
- */
27
16
  export declare function extractPictureAsset(dataStreamBytes: Uint8Array, pictureOffset: number | null | undefined, options?: MsDocParseOptions): ImageAsset | null;
28
17
  export declare function extractFbseImageAsset(drawingBytes: Uint8Array, offset: number, header?: OfficeArtRecordHeader | null): ImageAsset | null;
29
18
  export declare function parseOle10Native(streamBytes: Uint8Array): {
@@ -182,9 +182,6 @@ function parsePicfHeader(bytes) {
182
182
  if (bytes.length < PICF_HEADER_SIZE)
183
183
  return null;
184
184
  const reader = new BinaryReader(bytes);
185
- // PICMID stores the intended display goal size in twips plus mx/my scaling
186
- // factors (tenths of a percent). Word uses these values for inline picture
187
- // layout, including images that live in header/footer stories.
188
185
  const dxaGoalTwips = reader.i16(28);
189
186
  const dyaGoalTwips = reader.i16(30);
190
187
  const scaleXPermille = reader.u16(32);
@@ -209,11 +206,6 @@ function parsePicfHeader(bytes) {
209
206
  displayHeightTwips,
210
207
  };
211
208
  }
212
- /**
213
- * PICFAndOfficeArtData can optionally carry a length-prefixed ANSI path right after
214
- * the 68-byte PICF header. The spec ties this to MM_SHAPEFILE, but real-world files
215
- * are not always perfectly spec-compliant, so we also accept clearly path-like data.
216
- */
217
209
  function readOptionalPictureName(pictureChunk, picf) {
218
210
  const offset = Math.min(picf.cbHeader || PICF_HEADER_SIZE, pictureChunk.length);
219
211
  if (offset >= pictureChunk.length)
@@ -231,11 +223,6 @@ function readOptionalPictureName(pictureChunk, picf) {
231
223
  }
232
224
  return { value, nextOffset: offset + 1 + length };
233
225
  }
234
- /**
235
- * OfficeArt records all start with the common 8-byte OfficeArtRecordHeader.
236
- * Parsing this explicitly lets us walk inline shape containers instead of guessing
237
- * image payloads by magic bytes, which was the root cause of the broken image output.
238
- */
239
226
  export function parseOfficeArtRecordHeader(bytes, offset) {
240
227
  if (offset < 0 || offset + 8 > bytes.length)
241
228
  return null;
@@ -300,11 +287,6 @@ function dibToBmp(dibBytes) {
300
287
  out.set(dibBytes, 14);
301
288
  return out;
302
289
  }
303
- /**
304
- * OfficeArtBStoreContainerFileBlock can wrap an OfficeArtFBSE record instead of a
305
- * BLIP atom directly. In that case the actual image payload sits in the FBSE
306
- * embeddedBlip field after the fixed 36-byte FBSE header and optional nameData.
307
- */
308
290
  function extractEmbeddedBlipFromFbse(bytes, offset, header) {
309
291
  const payloadOffset = offset + 8;
310
292
  const payloadEnd = payloadOffset + header.recLen;
@@ -448,12 +430,6 @@ function collectOfficeArtCandidates(bytes, offset, end, out) {
448
430
  cursor = next;
449
431
  }
450
432
  }
451
- /**
452
- * The `picture` field of PICFAndOfficeArtData is an OfficeArtInlineSpContainer.
453
- * We recursively walk nested OfficeArt containers until we reach actual BLIP atoms
454
- * (PNG/JPEG/DIB/TIFF/EMF/WMF/PICT) and then extract the payload according to the
455
- * BLIP record-specific layout from MS-ODRAW.
456
- */
457
433
  function findOfficeArtCandidates(pictureChunk, startOffset) {
458
434
  if (startOffset < 0 || startOffset + 8 > pictureChunk.length)
459
435
  return [];
@@ -543,12 +519,6 @@ function createImageAsset(candidate, pictureOffset, picf, linkedPath, inlineShap
543
519
  meta,
544
520
  };
545
521
  }
546
- /**
547
- * Resolves a picture character (U+0001 + sprmCPicLocation) to an HTML-friendly asset.
548
- * The happy path is: PICF -> optional linked picture name -> OfficeArtInlineSpContainer
549
- * -> OfficeArtBlip*. When no structured BLIP can be found we still fall back to a
550
- * signature scan so slightly malformed files remain usable.
551
- */
552
522
  export function extractPictureAsset(dataStreamBytes, pictureOffset, options = {}) {
553
523
  if (!dataStreamBytes || pictureOffset == null || pictureOffset < 0 || pictureOffset + PICF_HEADER_SIZE > dataStreamBytes.length)
554
524
  return null;
@@ -712,7 +682,6 @@ export function parseOle10Native(streamBytes) {
712
682
  }
713
683
  }
714
684
  catch {
715
- // try next variant
716
685
  }
717
686
  }
718
687
  return null;
@@ -1,10 +1,4 @@
1
1
  export declare function detectMimeFromBlipType(blipType: number): string | null;
2
- /**
3
- * Inline picture characters often carry a miniature OfficeArt container that
4
- * points at the BLIP used by the picture. We only need a compact subset of the
5
- * shape metadata so the higher-level parser can annotate extracted assets with
6
- * source shape ids and BLIP indices.
7
- */
8
2
  export declare function parseInlineOfficeArtShape(bytes: Uint8Array, startOffset?: number): {
9
3
  shapeId: number;
10
4
  blipIndex?: number;
@@ -125,12 +125,6 @@ function scanRecords(bytes, start, end, visitor) {
125
125
  }
126
126
  return false;
127
127
  }
128
- /**
129
- * Inline picture characters often carry a miniature OfficeArt container that
130
- * points at the BLIP used by the picture. We only need a compact subset of the
131
- * shape metadata so the higher-level parser can annotate extracted assets with
132
- * source shape ids and BLIP indices.
133
- */
134
128
  export function parseInlineOfficeArtShape(bytes, startOffset = 0) {
135
129
  let result = null;
136
130
  scanRecords(bytes, startOffset, bytes.length, (offset, header) => {
@@ -1,10 +1,5 @@
1
1
  import type { MsDocParseOptions, MsDocParseResult, ParagraphModel, TableRowBlock } from '../types.js';
2
2
  export declare function finalizeTableGrid(rows: TableRowBlock[]): void;
3
3
  export declare function normalizeRowEndOnlyTables(paragraphs: ParagraphModel[]): void;
4
- /**
5
- * Main MS-DOC entry point.
6
- * It parses the OLE container, restores text through the piece table, resolves
7
- * paragraph/character/table properties, and finally produces a normalized AST
8
- * that the HTML renderer can consume.
9
- */
4
+ export declare function buildBlocks(paragraphs: ParagraphModel[]): MsDocParseResult['blocks'];
10
5
  export declare function parseMsDoc(input: ArrayBuffer | Uint8Array | ArrayBufferView, options?: MsDocParseOptions): MsDocParseResult;
@@ -337,7 +337,6 @@ function buildParagraphModel(range, paragraphText, styles, fonts, chpxRuns, reso
337
337
  const resolveFont = (fontId) => fonts.byIndex(fontId);
338
338
  const segments = buildCharSegments(range, paragraphText, chpxRuns, styles, baseCharProps, resolveFont, chpxCursor, range.storyKind === 'textbox' || range.storyKind === 'header-textbox');
339
339
  const inlines = buildInlineNodes(segments, resolveAsset);
340
- // The terminator has its own CHPX run and is not part of paragraphText.
341
340
  const markState = range.terminator === DOC_CONTROL.paragraph
342
341
  ? buildCharSegments({ ...range, cpStart: range.cpEnd - 1 }, range.terminator, chpxRuns, styles, baseCharProps, resolveFont, { index: chpxCursor.index })[0]?.state
343
342
  : undefined;
@@ -440,10 +439,6 @@ export function finalizeTableGrid(rows) {
440
439
  }
441
440
  }
442
441
  }
443
- // HTML tables need one shared column grid even when Word stores a different
444
- // TDefTable boundary list per row (usually because cells are merged). Map
445
- // every visible cell back onto the union of physical boundaries so colspan
446
- // preserves the original geometry instead of squeezing later row cells.
447
442
  const boundaries = Array.from(new Set(rows.flatMap((row) => row.cells.flatMap((cell) => {
448
443
  const left = cell.meta?.leftBoundary;
449
444
  const right = cell.meta?.rightBoundary;
@@ -468,8 +463,6 @@ export function finalizeTableGrid(rows) {
468
463
  function resolveTableGridBorders(rows) {
469
464
  const hasOwnBorder = (borders, side) => (Object.prototype.hasOwnProperty.call(borders, side) && (() => {
470
465
  const border = borders[side];
471
- // An all-zero BRC is an empty cell-level value and still inherits the
472
- // table edge/inside border. NilBrc is the explicit "no border" value.
473
466
  return Boolean(border?.nil || border?.borderType);
474
467
  })());
475
468
  rows.forEach((row, rowIndex) => {
@@ -557,11 +550,6 @@ export function normalizeRowEndOnlyTables(paragraphs) {
557
550
  }
558
551
  if (remainingCells > 0)
559
552
  continue;
560
- // Some Word/WPS producers store all table properties only on the row-end
561
- // PAPX. Paragraph marks inside a cell then look like ordinary paragraphs.
562
- // When the immediately preceding row has the same outer grid and table
563
- // border identity, everything between the two row terminators belongs to
564
- // the current row, including leading paragraphs in its first cell.
565
553
  let previousRowEndIndex = cursor;
566
554
  while (previousRowEndIndex >= 0
567
555
  && !paragraphs[previousRowEndIndex].paraState.tableRowEnd
@@ -581,87 +569,91 @@ export function normalizeRowEndOnlyTables(paragraphs) {
581
569
  rowParagraphs.forEach((paragraph) => inheritRowEndTableState(paragraph, rowEnd));
582
570
  }
583
571
  }
584
- function buildTableBlock(tableParagraphs) {
585
- const rows = [];
586
- let pendingRow = { cells: [] };
587
- let pendingCellParagraphs = [];
588
- for (const paragraph of tableParagraphs) {
589
- pendingCellParagraphs.push(paragraph);
590
- if (paragraph.terminator === DOC_CONTROL.cellMark) {
591
- pendingRow.cells.push({
592
- id: uniqueId('cell'),
593
- paragraphs: pendingCellParagraphs.map(paragraphToBlock),
594
- meta: null,
595
- });
596
- pendingCellParagraphs = [];
597
- if (paragraph.paraState.tableRowEnd || paragraph.paraState.innerTableRowEnd) {
598
- const cellDefs = applyTableStateToCells(paragraph.tableState);
599
- while (cellDefs.length &&
600
- pendingRow.cells.length > cellDefs.length &&
601
- pendingRow.cells[pendingRow.cells.length - 1].paragraphs.every((block) => !block.text && !(block.inlines || []).length)) {
602
- pendingRow.cells.pop();
603
- }
604
- pendingRow.cells.forEach((cell, index) => {
605
- cell.meta = cellDefs[index] || { index };
606
- });
607
- const gridWidthTwips = cellDefs.length
608
- ? ((cellDefs[cellDefs.length - 1].rightBoundary || 0) - (cellDefs[0].leftBoundary || 0))
609
- : 0;
610
- rows.push({
611
- id: uniqueId('row'),
612
- cells: pendingRow.cells,
613
- state: paragraph.tableState,
614
- gridWidthTwips,
615
- });
616
- pendingRow = { cells: [] };
617
- }
618
- }
619
- }
620
- if (pendingCellParagraphs.length) {
621
- pendingRow.cells.push({ id: uniqueId('cell'), paragraphs: pendingCellParagraphs.map(paragraphToBlock), meta: null });
622
- }
623
- if (pendingRow.cells.length) {
624
- rows.push({ id: uniqueId('row'), cells: pendingRow.cells, state: tableParagraphs[0]?.tableState || tablePropsToState([]), gridWidthTwips: 0 });
625
- }
626
- finalizeTableGrid(rows);
627
- resolveTableGridBorders(rows);
628
- const gridWidthTwips = rows.find((row) => row.gridWidthTwips)?.gridWidthTwips || 0;
629
- const depth = Math.max(...tableParagraphs.map((paragraph) => getTableDepth(paragraph.paraState)), 1);
630
- return {
631
- type: 'table',
632
- id: uniqueId('table'),
633
- depth,
634
- rows,
635
- state: rows[0]?.state || tablePropsToState([]),
636
- gridWidthTwips,
637
- };
638
- }
639
- function buildBlocks(paragraphs) {
572
+ export function buildBlocks(paragraphs) {
640
573
  const blocks = [];
641
- let index = 0;
642
- while (index < paragraphs.length) {
643
- const paragraph = paragraphs[index];
574
+ const stack = [];
575
+ const finishCell = (pending) => {
576
+ pending.cells.push({
577
+ id: uniqueId('cell'),
578
+ paragraphs: pending.content.filter((block) => block.type === 'paragraph'),
579
+ blocks: pending.content,
580
+ meta: null,
581
+ });
582
+ pending.content = [];
583
+ };
584
+ const finishRow = (pending, state = pending.lastState) => {
585
+ if (pending.content.length)
586
+ finishCell(pending);
587
+ const definitions = applyTableStateToCells(state);
588
+ while (pending.cells.length < definitions.length)
589
+ finishCell(pending);
590
+ if (!pending.cells.length)
591
+ finishCell(pending);
592
+ pending.cells.forEach((cell, index) => { cell.meta = definitions[index] || { index }; });
593
+ pending.table.rows.push({
594
+ id: uniqueId('row'),
595
+ cells: pending.cells,
596
+ state,
597
+ gridWidthTwips: definitions.length
598
+ ? (definitions[definitions.length - 1].rightBoundary || 0) - (definitions[0].leftBoundary || 0)
599
+ : 0,
600
+ });
601
+ pending.cells = [];
602
+ };
603
+ const finishTable = () => {
604
+ const pending = stack.pop();
605
+ if (pending.content.length || pending.cells.length)
606
+ finishRow(pending);
607
+ const table = pending.table;
608
+ finalizeTableGrid(table.rows);
609
+ resolveTableGridBorders(table.rows);
610
+ table.state = table.rows[0]?.state || pending.lastState;
611
+ table.gridWidthTwips = table.rows.find(row => row.gridWidthTwips)?.gridWidthTwips || 0;
612
+ const parent = stack[stack.length - 1];
613
+ if (parent)
614
+ parent.content.push(table);
615
+ else
616
+ blocks.push(table);
617
+ };
618
+ for (const paragraph of paragraphs) {
644
619
  const depth = getTableDepth(paragraph.paraState);
620
+ if (stack.length && stack[0].storyKind !== paragraph.storyKind) {
621
+ while (stack.length)
622
+ finishTable();
623
+ }
624
+ while (stack.length && stack[stack.length - 1].table.depth > depth)
625
+ finishTable();
645
626
  if (depth <= 0) {
646
627
  blocks.push(paragraphToBlock(paragraph));
647
- index += 1;
648
628
  continue;
649
629
  }
650
- const tableParagraphs = [];
651
- while (index < paragraphs.length && getTableDepth(paragraphs[index].paraState) > 0) {
652
- tableParagraphs.push(paragraphs[index]);
653
- index += 1;
630
+ let pending = stack[stack.length - 1];
631
+ if (!pending || pending.table.depth < depth) {
632
+ pending = {
633
+ table: { type: 'table', id: uniqueId('table'), depth, rows: [], state: paragraph.tableState, gridWidthTwips: 0 },
634
+ cells: [], content: [], lastState: paragraph.tableState, storyKind: paragraph.storyKind,
635
+ };
636
+ stack.push(pending);
637
+ }
638
+ pending.lastState = paragraph.tableState;
639
+ const rowEnd = depth > 1 ? paragraph.paraState.innerTableRowEnd : paragraph.paraState.tableRowEnd;
640
+ if (rowEnd) {
641
+ if (paragraph.text || paragraph.inlines.length)
642
+ pending.content.push(paragraphToBlock(paragraph));
643
+ finishRow(pending, paragraph.tableState);
644
+ continue;
654
645
  }
655
- blocks.push(buildTableBlock(tableParagraphs));
646
+ pending.content.push(paragraphToBlock(paragraph));
647
+ const cellEnd = depth > 1
648
+ ? paragraph.paraState.innerTableCell
649
+ : paragraph.terminator === DOC_CONTROL.cellMark;
650
+ if (cellEnd)
651
+ finishCell(pending);
656
652
  }
653
+ while (stack.length)
654
+ finishTable();
657
655
  return blocks;
658
656
  }
659
- /**
660
- * Main MS-DOC entry point.
661
- * It parses the OLE container, restores text through the piece table, resolves
662
- * paragraph/character/table properties, and finally produces a normalized AST
663
- * that the HTML renderer can consume.
664
- */
665
657
  export function parseMsDoc(input, options = {}) {
666
658
  const warnings = [];
667
659
  const cfb = parseCFB(input, options);
@@ -155,7 +155,6 @@ export function paraPropsToState(properties) {
155
155
  break;
156
156
  case 'lineSpacing': {
157
157
  state.lineSpacing = prop.value || 0;
158
- // LSPD's second short distinguishes 1/240-line multiples from signed twips.
159
158
  const bytes = prop.operandBytes;
160
159
  const absolute = bytes && bytes.length >= 4
161
160
  ? bytes[2] === 0 && bytes[3] === 0
@@ -375,9 +374,6 @@ export function applyTableStateToCells(tableState) {
375
374
  for (const op of tableState.operations || []) {
376
375
  switch (op.name) {
377
376
  case 'insertCells': {
378
- // Word commonly emits TDefTable for legacy readers and TInsert for
379
- // readers that process sprmPTableProps. They are alternative cell
380
- // definitions, not cumulative instructions.
381
377
  if (def?.cells?.length)
382
378
  break;
383
379
  const value = op.value;
@@ -429,8 +425,6 @@ export function applyTableStateToCells(tableState) {
429
425
  rangeApply(cells, op.value.range, (cell) => {
430
426
  const value = op.value;
431
427
  cell.ftsWidth = value.ftsWidth;
432
- // ftsNil and ftsAuto carry no usable preferred width. Preserve the
433
- // TDefTable boundary width instead of collapsing the cell to zero.
434
428
  if ((value.ftsWidth === 2 || value.ftsWidth === 3) && value.width != null) {
435
429
  cell.width = value.width;
436
430
  }
@@ -155,7 +155,6 @@ function parseStyleStd(stdBytes, cbSTDBaseInFile, istd) {
155
155
  }
156
156
  }
157
157
  catch {
158
- // keep best-effort partial decoding
159
158
  }
160
159
  return {
161
160
  istd,
@@ -1104,7 +1104,6 @@ function parseEmf(bytes) {
1104
1104
  }
1105
1105
  case EMF_RECORD.EMR_EXTCREATEFONTINDIRECTW: {
1106
1106
  const index = record.u32(8);
1107
- // LOGFONTW starts at offset 12: lfHeight(+0), ..., lfFaceName(+28).
1108
1107
  const height = Math.abs(record.i32(12));
1109
1108
  const escapement = record.i32(20);
1110
1109
  const weight = record.i32(28);
@@ -1,4 +1,5 @@
1
- import type { CharState } from '../types.js';
1
+ import type { CharState, FontInfo } from '../types.js';
2
+ export declare function msDocFontFallbacks(family: string, info?: FontInfo): readonly string[];
2
3
  export declare function textFontRuns(text: string, style: CharState): Array<{
3
4
  text: string;
4
5
  fontFamily?: string;
@@ -1,5 +1,65 @@
1
1
  const EAST_ASIAN = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}\p{Script=Bopomofo}\u3000-\u303f\uff00-\uffef]/u;
2
2
  const INHERIT_FONT = /[\p{Mark}\u200c\u200d]/u;
3
+ function cjkFontFallbacks(family) {
4
+ switch (family.toLowerCase().replace(/[ _-]/g, '')) {
5
+ case '宋体':
6
+ case '新宋体':
7
+ case 'simsun':
8
+ case 'nsimsun':
9
+ return ['SimSun', 'Songti SC', 'Noto Serif CJK SC', 'serif'];
10
+ case '仿宋':
11
+ case '仿宋gb2312':
12
+ case 'fangsong':
13
+ case 'fangsonggb2312':
14
+ return ['FangSong', 'STFangsong', 'Songti SC', 'Noto Serif CJK SC', 'serif'];
15
+ case '楷体':
16
+ case '楷体gb2312':
17
+ case 'kaiti':
18
+ case 'kaitigb2312':
19
+ return ['KaiTi', 'STKaiti', 'Kaiti SC', 'Songti SC', 'Noto Serif CJK SC', 'serif'];
20
+ case '黑体':
21
+ case 'simhei':
22
+ return ['SimHei', 'Heiti SC', 'Noto Sans CJK SC', 'sans-serif'];
23
+ case '微软雅黑':
24
+ case 'microsoftyahei':
25
+ return ['Microsoft YaHei', 'PingFang SC', 'Noto Sans CJK SC', 'sans-serif'];
26
+ }
27
+ const normalized = family.normalize('NFKC').toLowerCase();
28
+ if (/fangsong|仿宋/.test(normalized))
29
+ return ['STFangsong', 'Songti SC', 'Noto Serif CJK SC', 'serif'];
30
+ if (/simsun|nsimsun|宋体|宋體|songti/.test(normalized))
31
+ return ['Songti SC', 'Songti TC', 'Noto Serif CJK SC', 'serif'];
32
+ if (/kaiti|楷体|楷體/.test(normalized))
33
+ return ['Kaiti SC', 'STKaiti', 'Noto Serif CJK SC', 'serif'];
34
+ if (/mingliu|細明體|新細明體/.test(normalized))
35
+ return ['Songti TC', 'Noto Serif CJK TC', 'serif'];
36
+ if (/mincho|明朝/.test(normalized))
37
+ return ['Hiragino Mincho ProN', 'Yu Mincho', 'Noto Serif CJK JP', 'serif'];
38
+ if (/simhei|黑体|黑體|heiti|dengxian|等线|等線|yahei/.test(normalized))
39
+ return ['PingFang SC', 'Microsoft YaHei', 'Noto Sans CJK SC', 'sans-serif'];
40
+ return undefined;
41
+ }
42
+ export function msDocFontFallbacks(family, info) {
43
+ const alternate = info?.altName && info.altName !== family ? [info.altName] : [];
44
+ const cjk = cjkFontFallbacks(family);
45
+ if (cjk)
46
+ return [...alternate, ...cjk];
47
+ const genericByFamily = {
48
+ 1: 'serif', 2: 'sans-serif', 3: 'monospace', 4: 'cursive', 5: 'fantasy',
49
+ };
50
+ const familyClass = ((info?.ffid ?? 0) >>> 4) & 7;
51
+ let generic = genericByFamily[familyClass];
52
+ if (!generic && info?.panose?.[0] === 2) {
53
+ const serif = info.panose[1];
54
+ if (info.panose[3] === 9)
55
+ generic = 'monospace';
56
+ else if (serif >= 2 && serif <= 10)
57
+ generic = 'serif';
58
+ else if (serif >= 11 && serif <= 15)
59
+ generic = 'sans-serif';
60
+ }
61
+ return [...alternate, generic ?? 'sans-serif'];
62
+ }
3
63
  export function textFontRuns(text, style) {
4
64
  if (!text)
5
65
  return [];
@@ -1,16 +1,6 @@
1
1
  import type { MsDocParseResult, MsDocRenderOptions, MsDocRenderResult } from '../types.js';
2
2
  type ExternalLinkPolicy = NonNullable<MsDocRenderOptions['externalLinkPolicy']>;
3
- /**
4
- * Normalizes a document-provided hyperlink before it reaches generated HTML.
5
- * Internal bookmarks always remain available. External navigation is opt-in
6
- * and is still restricted to browser-safe schemes and relative paths.
7
- */
8
3
  export declare function sanitizeMsDocLinkHref(href: string | undefined | null, policy?: ExternalLinkPolicy): string | null;
9
4
  export declare function defaultMsDocCss(): string;
10
- /**
11
- * Converts the parsed AST into HTML and a companion CSS string.
12
- * Keeping rendering separate from parsing makes it easier for downstream apps
13
- * to customize styles or consume the AST directly.
14
- */
15
5
  export declare function renderMsDoc(parsed: MsDocParseResult, options?: MsDocRenderOptions): MsDocRenderResult;
16
6
  export {};
@@ -1,7 +1,7 @@
1
1
  import { escapeHtml, slugify, twipsToPx } from '../core/utils.js';
2
2
  import { HIGHLIGHT_COLORS } from '../msdoc/constants.js';
3
3
  import { cssTextAlign, cssUnderline, cssVerticalAlign } from '../msdoc/properties.js';
4
- import { textFontRuns } from './fonts.js';
4
+ import { msDocFontFallbacks, textFontRuns } from './fonts.js';
5
5
  const COLOR_INDEX_MAP = {
6
6
  1: '#000000',
7
7
  2: '#0000ff',
@@ -34,11 +34,10 @@ function quoteCssString(value) {
34
34
  .replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ');
35
35
  return `'${escaped}'`;
36
36
  }
37
- /**
38
- * Normalizes a document-provided hyperlink before it reaches generated HTML.
39
- * Internal bookmarks always remain available. External navigation is opt-in
40
- * and is still restricted to browser-safe schemes and relative paths.
41
- */
37
+ function fontFamilyToCss(family, context) {
38
+ const fallbacks = msDocFontFallbacks(family, context.fonts.get(family));
39
+ return [quoteCssString(family), ...fallbacks.map((name, index) => index === fallbacks.length - 1 ? name : quoteCssString(name))].join(',');
40
+ }
42
41
  export function sanitizeMsDocLinkHref(href, policy = 'block') {
43
42
  const value = String(href ?? '')
44
43
  .replace(/[\u0000-\u0020\u007f-\u009f]/g, '')
@@ -160,7 +159,6 @@ function paragraphStyleToCss(paraState) {
160
159
  }
161
160
  else {
162
161
  const multiple = Math.abs(paraState.lineSpacing) / 240;
163
- // A font's single line includes its metrics and leading, not just a 1em box.
164
162
  style['line-height'] = multiple === 1 ? 'normal' : String(multiple);
165
163
  }
166
164
  }
@@ -203,7 +201,7 @@ function buildUnderlineStyle(underline) {
203
201
  css['text-decoration-style'] = 'solid';
204
202
  return css;
205
203
  }
206
- function inlineStyleToCss(styleState) {
204
+ function inlineStyleToCss(styleState, context) {
207
205
  const style = {};
208
206
  if (styleState.bold || styleState.boldBi)
209
207
  style['font-weight'] = '700';
@@ -215,7 +213,7 @@ function inlineStyleToCss(styleState) {
215
213
  if (styleState.fontSizeHalfPoints)
216
214
  style['font-size'] = `${styleState.fontSizeHalfPoints / 2}pt`;
217
215
  if (styleState.fontFamily)
218
- style['font-family'] = `${quoteCssString(styleState.fontFamily)},sans-serif`;
216
+ style['font-family'] = fontFamilyToCss(styleState.fontFamily, context);
219
217
  if (styleState.colorIndex && COLOR_INDEX_MAP[styleState.colorIndex])
220
218
  style.color = COLOR_INDEX_MAP[styleState.colorIndex];
221
219
  const highlightIndex = typeof styleState.highlight === 'number' ? styleState.highlight : styleState.highlight?.index;
@@ -253,10 +251,10 @@ function renderTextNode(node, context) {
253
251
  const text = escapeHtml(run.text);
254
252
  if (!run.fontFamily || run.fontFamily === node.style.fontFamily)
255
253
  return text;
256
- const font = styleObjectToCss({ 'font-family': `${quoteCssString(run.fontFamily)},sans-serif` });
254
+ const font = styleObjectToCss({ 'font-family': fontFamilyToCss(run.fontFamily, context) });
257
255
  return `<span style="${font}">${text}</span>`;
258
256
  }).join('');
259
- const inlineStyle = inlineStyleToCss(node.style);
257
+ const inlineStyle = inlineStyleToCss(node.style, context);
260
258
  inlineStyle['white-space'] = 'break-spaces';
261
259
  const revision = context.reviewMode === 'all'
262
260
  ? node.style.revisionDeleted ? 'delete' : node.style.revisionInserted ? 'insert' : null
@@ -373,7 +371,7 @@ function sanitizeAssetHref(href, context) {
373
371
  function renderImageNode(node, context) {
374
372
  const allowGeneratedVector = node.asset.meta?.vectorConverted === true;
375
373
  const src = sanitizeImageSource(node.asset.sourceUrl, context.externalResourcePolicy, allowGeneratedVector) || sanitizeImageSource(node.asset.dataUrl, context.externalResourcePolicy, allowGeneratedVector);
376
- const baseStyle = inlineStyleToCss(node.style);
374
+ const baseStyle = inlineStyleToCss(node.style, context);
377
375
  const displaySize = inlineImageDisplaySizePx(node.asset);
378
376
  if (displaySize.widthPx || displaySize.heightPx) {
379
377
  applyInlineImageDisplaySize(baseStyle, node.asset);
@@ -432,7 +430,6 @@ function reviewParagraphs(paragraphs, context) {
432
430
  const previous = result[result.length - 1];
433
431
  if (previous && previous.storyKind === paragraph.storyKind
434
432
  && hiddenRevision(previous.paragraphMark, context)) {
435
- // The surviving paragraph mark owns the merged paragraph's formatting.
436
433
  result[result.length - 1] = {
437
434
  ...paragraph,
438
435
  inlines: [...previous.inlines, ...paragraph.inlines],
@@ -508,7 +505,15 @@ function tableStyle(block) {
508
505
  else
509
506
  style.width = '100%';
510
507
  const marginLeft = twipsToPx(block.state?.leftIndent);
511
- if (marginLeft)
508
+ if (block.state?.alignment === 1) {
509
+ style['margin-inline-start'] = 'auto';
510
+ style['margin-inline-end'] = 'auto';
511
+ }
512
+ else if (block.state?.alignment === 2) {
513
+ style['margin-inline-start'] = 'auto';
514
+ style['margin-inline-end'] = '0';
515
+ }
516
+ else if (marginLeft)
512
517
  style['margin-inline-start'] = `${marginLeft}px`;
513
518
  if (block.state?.rtl)
514
519
  style.direction = 'rtl';
@@ -531,6 +536,48 @@ function tableStyle(block) {
531
536
  style['table-layout'] = 'fixed';
532
537
  return style;
533
538
  }
539
+ function renderTableColumns(block) {
540
+ const boundaries = new Set();
541
+ for (const row of block.rows)
542
+ for (const cell of row.cells) {
543
+ const left = cell.meta?.leftBoundary;
544
+ const right = cell.meta?.rightBoundary;
545
+ if (Number.isFinite(left) && Number.isFinite(right) && right > left) {
546
+ boundaries.add(left);
547
+ boundaries.add(right);
548
+ }
549
+ }
550
+ const sorted = [...boundaries].sort((a, b) => a - b);
551
+ if (sorted.length < 2)
552
+ return '';
553
+ return `<colgroup>${sorted.slice(1).map((right, i) => `<col style="width:${twipsToPx(right - sorted[i])}px">`).join('')}</colgroup>`;
554
+ }
555
+ function renderCellBody(cell, context) {
556
+ const parts = [];
557
+ let paragraphs = [];
558
+ const wrapText = (body) => cell.meta?.textFlow === 5
559
+ ? `<div class="msdoc-cell-vertical" style="writing-mode:vertical-rl;text-orientation:upright;inline-size:max-content;block-size:max-content;margin:0 auto">${body}</div>`
560
+ : body;
561
+ const flush = () => {
562
+ if (!paragraphs.length)
563
+ return;
564
+ const body = reviewParagraphs(paragraphs, context)
565
+ .map(paragraph => renderParagraphBlock(paragraph, context, { inline: true })).join('');
566
+ if (body)
567
+ parts.push(wrapText(body));
568
+ paragraphs = [];
569
+ };
570
+ for (const block of cell.blocks ?? cell.paragraphs) {
571
+ if (block.type === 'paragraph')
572
+ paragraphs.push(block);
573
+ else {
574
+ flush();
575
+ parts.push(renderTableBlock(block, context));
576
+ }
577
+ }
578
+ flush();
579
+ return parts.join('') || wrapText('<div class="msdoc-paragraph"><br></div>');
580
+ }
534
581
  function renderTableBlock(block, context) {
535
582
  const rows = block.rows.map((row) => {
536
583
  const rowHeight = row.state?.rowHeight ? twipsToPx(Math.abs(row.state.rowHeight)) : null;
@@ -544,14 +591,13 @@ function renderTableBlock(block, context) {
544
591
  if ((cell.rowspan ?? 1) > 1)
545
592
  attrs.push(` rowspan="${cell.rowspan}"`);
546
593
  const style = styleObjectToCss(cellStyle(cell));
547
- const body = reviewParagraphs(cell.paragraphs, context)
548
- .map((paragraph) => renderParagraphBlock(paragraph, context, { inline: true })).join('');
594
+ const body = renderCellBody(cell, context);
549
595
  return `<td class="msdoc-cell"${attrs.join('')}${style ? ` style="${style}"` : ''}>${body || '<div class="msdoc-paragraph"><br></div>'}</td>`;
550
596
  })
551
597
  .join('');
552
598
  return `<tr class="msdoc-row"${rowStyle}>${cells}</tr>`;
553
599
  }).join('');
554
- return `<table class="msdoc-table msdoc-table-depth-${block.depth}" style="${styleObjectToCss(tableStyle(block))}"><tbody>${rows}</tbody></table>`;
600
+ return `<table class="msdoc-table msdoc-table-depth-${block.depth}" style="${styleObjectToCss(tableStyle(block))}">${renderTableColumns(block)}<tbody>${rows}</tbody></table>`;
555
601
  }
556
602
  function renderAttachmentsBlock(block) {
557
603
  const items = block.items.map((item) => {
@@ -572,6 +618,7 @@ export function defaultMsDocCss() {
572
618
  .msdoc-line-at-least span,.msdoc-line-at-least ins,.msdoc-line-at-least del{line-height:normal}
573
619
  .msdoc-paragraph:last-child{margin-bottom:0}
574
620
  .msdoc-table{margin:12px 0;border-collapse:collapse;border-spacing:0;max-width:100%}
621
+ .msdoc-cell>.msdoc-table{margin-top:0;margin-bottom:0}
575
622
  .msdoc-cell{padding:6px 8px;vertical-align:top;word-break:break-word;overflow-wrap:anywhere}
576
623
  .msdoc-link{color:#1a73e8;text-decoration:none}
577
624
  .msdoc-link:hover{text-decoration:underline}
@@ -583,17 +630,13 @@ export function defaultMsDocCss() {
583
630
  .msdoc-page-break{display:block;height:0;border-top:1px dashed #cbd5e1;margin:16px 0}
584
631
  `;
585
632
  }
586
- /**
587
- * Converts the parsed AST into HTML and a companion CSS string.
588
- * Keeping rendering separate from parsing makes it easier for downstream apps
589
- * to customize styles or consume the AST directly.
590
- */
591
633
  export function renderMsDoc(parsed, options = {}) {
592
634
  const css = options.css ?? defaultMsDocCss();
593
635
  const context = {
594
636
  reviewMode: options.reviewMode ?? 'all',
595
637
  externalLinkPolicy: options.externalLinkPolicy ?? 'block',
596
638
  externalResourcePolicy: options.externalResourcePolicy ?? 'block',
639
+ fonts: new Map((parsed.fonts ?? []).flatMap(font => [font.name, font.altName].filter(Boolean).map(name => [name, font]))),
597
640
  };
598
641
  const parts = [];
599
642
  let paragraphs = [];
package/dist/types.d.ts CHANGED
@@ -482,7 +482,6 @@ export interface OfficeArtBlipMeta {
482
482
  blipIndex?: number;
483
483
  sourceShapeId?: number;
484
484
  }
485
- /** PICF / OfficeArt BLIP / linked-picture metadata used by the HTML renderer. */
486
485
  export interface ImageAssetMeta extends PicfSummary, OfficeArtBlipMeta {
487
486
  linkedPath?: string;
488
487
  sourceKind?: 'embedded' | 'linked' | 'fallback';
@@ -582,6 +581,7 @@ export interface ParagraphBlock {
582
581
  export interface TableCellBlock {
583
582
  id: string;
584
583
  paragraphs: ParagraphBlock[];
584
+ blocks?: Array<ParagraphBlock | TableBlock>;
585
585
  meta: TableCellMeta | null;
586
586
  colIndex?: number;
587
587
  colspan?: number;
@@ -632,11 +632,8 @@ export interface MsDocParseOptions {
632
632
  }
633
633
  export interface MsDocRenderOptions {
634
634
  css?: string;
635
- /** Preserve insertion/deletion marks by default; explicitly select final or original text. */
636
635
  reviewMode?: 'all' | 'final' | 'original';
637
- /** External links are blocked by default; internal document bookmarks remain active. */
638
636
  externalLinkPolicy?: 'allow' | 'block';
639
- /** Linked image resources are blocked by default; embedded data/blob images remain available. */
640
637
  externalResourcePolicy?: 'allow' | 'block';
641
638
  }
642
639
  export interface MsDocMeta {
package/dist/viewer.d.ts CHANGED
@@ -1,16 +1,7 @@
1
1
  import type { Config } from 'dompurify';
2
2
  import type { MsDocParseToHtmlOptions, MsDocRenderResult, MsDocViewer, MsDocViewerConfig, ViewerInput } from './types.js';
3
- /**
4
- * Shared DOC/RTF DOMPurify boundary. A per-window purifier and Trusted Types
5
- * policy avoid duplicate policy creation when several Word formats are opened
6
- * in the same host document.
7
- */
8
3
  export declare function sanitizeFileViewerDocumentMarkup(html: string, targetWindow: Window, config: Config): unknown | null;
9
4
  export declare function sanitizeMsDocHtml(html: string, targetWindow: Window): DocumentFragment;
10
5
  export declare function mountMsDoc(container: HTMLElement, rendered: MsDocRenderResult): HTMLElement;
11
6
  export declare function parseMsDocToHtml(input: ViewerInput, options?: MsDocParseToHtmlOptions): Promise<MsDocRenderResult>;
12
- /**
13
- * Small DOM-oriented helper that keeps browser integration trivial.
14
- * Apps can either use it directly or consume the lower-level parse/render APIs.
15
- */
16
7
  export declare function createMsDocViewer(container: HTMLElement, config?: MsDocViewerConfig): MsDocViewer;
package/dist/viewer.js CHANGED
@@ -32,11 +32,6 @@ const getDocumentTrustedTypesPolicy = (targetWindow) => {
32
32
  return null;
33
33
  }
34
34
  };
35
- /**
36
- * Shared DOC/RTF DOMPurify boundary. A per-window purifier and Trusted Types
37
- * policy avoid duplicate policy creation when several Word formats are opened
38
- * in the same host document.
39
- */
40
35
  export function sanitizeFileViewerDocumentMarkup(html, targetWindow, config) {
41
36
  const purifier = getDocumentPurifier(targetWindow);
42
37
  if (!purifier)
@@ -49,8 +44,6 @@ export function sanitizeFileViewerDocumentMarkup(html, targetWindow, config) {
49
44
  });
50
45
  }
51
46
  catch {
52
- // If a host CSP forbids this policy name, callers fall back to a text node
53
- // rather than mounting unsanitized markup or failing the whole preview.
54
47
  return null;
55
48
  }
56
49
  }
@@ -112,10 +105,6 @@ export async function parseMsDocToHtml(input, options = {}) {
112
105
  const parsed = parseMsDoc(buffer, options.parseOptions || {});
113
106
  return renderMsDoc(parsed, options.renderOptions || {});
114
107
  }
115
- /**
116
- * Small DOM-oriented helper that keeps browser integration trivial.
117
- * Apps can either use it directly or consume the lower-level parse/render APIs.
118
- */
119
108
  export function createMsDocViewer(container, config = {}) {
120
109
  let current = null;
121
110
  return {
@@ -1,8 +1,4 @@
1
1
  import type { MsDocParseOptions, MsDocParseResult, MsDocParseToHtmlOptions, MsDocRenderOptions, MsDocRenderResult, MsDocWorkerClientLike, WorkerLike } from './types.js';
2
- /**
3
- * Thin typed wrapper around a module worker so applications can offload
4
- * expensive binary parsing without having to manage message correlation.
5
- */
6
2
  export declare class MsDocWorkerClient implements MsDocWorkerClientLike {
7
3
  private readonly worker;
8
4
  private readonly pending;
@@ -8,10 +8,6 @@ function toArrayBuffer(input) {
8
8
  }
9
9
  throw new TypeError('Expected ArrayBuffer or TypedArray');
10
10
  }
11
- /**
12
- * Thin typed wrapper around a module worker so applications can offload
13
- * expensive binary parsing without having to manage message correlation.
14
- */
15
11
  export class MsDocWorkerClient {
16
12
  worker;
17
13
  pending = new Map();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@file-viewer/doc",
3
- "version": "3.1.2",
3
+ "version": "3.2.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Browser-native legacy MS-DOC (.doc) parser and HTML renderer extracted from the public msdoc-viewer 0.2.0 line.",
@@ -27,8 +27,7 @@
27
27
  },
28
28
  "repository": {
29
29
  "type": "git",
30
- "url": "git+https://github.com/flyfish-dev/file-viewer.git",
31
- "directory": "packages/renderers/doc"
30
+ "url": "git+https://github.com/flyfish-dev/file-viewer.git"
32
31
  },
33
32
  "homepage": "https://github.com/flyfish-dev/docjs",
34
33
  "bugs": {
@@ -56,26 +55,66 @@
56
55
  "./package.json": "./package.json"
57
56
  },
58
57
  "files": [
59
- "dist",
60
- "README.md",
58
+ "dist/core/binary.d.ts",
59
+ "dist/core/binary.js",
60
+ "dist/core/cfb.d.ts",
61
+ "dist/core/cfb.js",
62
+ "dist/core/inflate.d.ts",
63
+ "dist/core/inflate.js",
64
+ "dist/core/utils.d.ts",
65
+ "dist/core/utils.js",
66
+ "dist/index.d.ts",
67
+ "dist/index.js",
68
+ "dist/msdoc/clx.d.ts",
69
+ "dist/msdoc/clx.js",
70
+ "dist/msdoc/constants.d.ts",
71
+ "dist/msdoc/constants.js",
72
+ "dist/msdoc/fib.d.ts",
73
+ "dist/msdoc/fib.js",
74
+ "dist/msdoc/fkp.d.ts",
75
+ "dist/msdoc/fkp.js",
76
+ "dist/msdoc/fonts.d.ts",
77
+ "dist/msdoc/fonts.js",
78
+ "dist/msdoc/html-word.d.ts",
79
+ "dist/msdoc/html-word.js",
80
+ "dist/msdoc/objects.d.ts",
81
+ "dist/msdoc/objects.js",
82
+ "dist/msdoc/officeart.d.ts",
83
+ "dist/msdoc/officeart.js",
84
+ "dist/msdoc/parser.d.ts",
85
+ "dist/msdoc/parser.js",
86
+ "dist/msdoc/properties.d.ts",
87
+ "dist/msdoc/properties.js",
88
+ "dist/msdoc/sprm.d.ts",
89
+ "dist/msdoc/sprm.js",
90
+ "dist/msdoc/styles.d.ts",
91
+ "dist/msdoc/styles.js",
92
+ "dist/msdoc/vector.d.ts",
93
+ "dist/msdoc/vector.js",
94
+ "dist/render/fonts.d.ts",
95
+ "dist/render/fonts.js",
96
+ "dist/render/html.d.ts",
97
+ "dist/render/html.js",
98
+ "dist/types.d.ts",
99
+ "dist/types.js",
100
+ "dist/viewer.d.ts",
101
+ "dist/viewer.js",
102
+ "dist/worker-client.d.ts",
103
+ "dist/worker-client.js",
104
+ "dist/worker.d.ts",
105
+ "dist/worker.js",
106
+ "LICENSE",
61
107
  "README.en.md",
62
- "LICENSE"
108
+ "README.md"
63
109
  ],
64
110
  "dependencies": {
65
- "dompurify": "3.4.15"
66
- },
67
- "devDependencies": {
68
- "typescript": "^6.0.3"
111
+ "dompurify": "3.4.16"
69
112
  },
70
113
  "license": "MIT",
71
- "scripts": {
72
- "build": "tsc -b tsconfig.json",
73
- "type-check": "tsc -b tsconfig.json",
74
- "verify:table-spec": "pnpm build && node scripts/verify-table-spec.mjs",
75
- "verify:github-34": "pnpm build && node scripts/verify-github-34.mjs",
76
- "verify:github-87": "pnpm build && node scripts/verify-github-87.mjs",
77
- "verify:github-171": "pnpm build && node scripts/verify-github-171.mjs",
78
- "verify:github-192": "pnpm build && node scripts/verify-github-192.mjs",
79
- "verify:github-236": "pnpm build && node scripts/verify-github-236.mjs"
114
+ "fileViewer": {
115
+ "distribution": {
116
+ "schemaVersion": 1,
117
+ "mode": "runtime-only"
118
+ }
80
119
  }
81
- }
120
+ }