@4399ywkf/editor 0.5.0 → 0.6.0
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.md +85 -10
- package/dist/PptxView-ZH4FZKNO.js +3 -0
- package/dist/PptxView-ZH4FZKNO.js.map +1 -0
- package/dist/chunk-32WQX3NR.js +37 -0
- package/dist/chunk-32WQX3NR.js.map +1 -0
- package/dist/chunk-5XG4YMAB.js +149 -0
- package/dist/chunk-5XG4YMAB.js.map +1 -0
- package/dist/csv.d.ts +83 -0
- package/dist/csv.js +153 -0
- package/dist/csv.js.map +1 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -1
- package/dist/pptx.d.ts +36 -0
- package/dist/pptx.js +3 -0
- package/dist/pptx.js.map +1 -0
- package/dist/types-C37MAt8o.d.ts +178 -0
- package/dist/xlsx.d.ts +12 -185
- package/dist/xlsx.js +3 -28
- package/dist/xlsx.js.map +1 -1
- package/package.json +14 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @4399ywkf/editor
|
|
2
2
|
|
|
3
|
-
4399 多格式文档编辑器。一个包覆盖 **doc / sheet / pdf**,引擎按需下载。
|
|
3
|
+
4399 多格式文档编辑器。一个包覆盖 **doc / sheet / pdf / pptx**,引擎按需下载。
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
6
|
pnpm add @4399ywkf/editor
|
|
@@ -20,13 +20,13 @@ import "@4399ywkf/editor/styles"
|
|
|
20
20
|
| **doc**(含云文档) | tiptap | ✅ | ✅ | ✅ | 内置 |
|
|
21
21
|
| **sheet** | Univer OSS 核心 | ✅ | ✅ | ✕ | 可选 peer `@univerjs/presets` |
|
|
22
22
|
| **pdf** | pdf.js | ✕ 只读 | ✕ | ✕ | 可选 peer `pdfjs-dist` |
|
|
23
|
-
| **pptx** |
|
|
23
|
+
| **pptx** | pptx-preview | ✕ 只读 | ✕ | ✕ | 可选 peer `pptx-preview` |
|
|
24
24
|
|
|
25
25
|
云文档与本地 doc 是**同一套 tiptap**,不做区分。
|
|
26
26
|
|
|
27
27
|
## 为什么引擎是懒加载的
|
|
28
28
|
|
|
29
|
-
主入口只有 **3KB**(格式路由),tiptap 的 698KB 在独立 chunk 里,Univer / pdf.js
|
|
29
|
+
主入口只有 **3KB**(格式路由),tiptap 的 698KB 在独立 chunk 里,Univer / pdf.js / pptx-preview 都是可选 peer。
|
|
30
30
|
`import "@4399ywkf/editor"` 不会把任何引擎拉进你的主包 —— 只有真的渲染到某种格式时才下载。
|
|
31
31
|
|
|
32
32
|
Univer sheets 核心 gzip 后 871KB;无条件打进主包会直接毁掉阅读侧首屏。
|
|
@@ -37,11 +37,56 @@ Univer sheets 核心 gzip 后 871KB;无条件打进主包会直接毁掉阅读
|
|
|
37
37
|
import { NotionEditor, ArticleViewer } from "@4399ywkf/editor/doc"
|
|
38
38
|
import { ImportDocx, ExportDocx } from "@4399ywkf/editor/docx"
|
|
39
39
|
import { downloadXlsx } from "@4399ywkf/editor/xlsx"
|
|
40
|
+
import { downloadCsv } from "@4399ywkf/editor/csv"
|
|
40
41
|
import { SheetView } from "@4399ywkf/editor/sheet"
|
|
41
42
|
import { PdfView } from "@4399ywkf/editor/pdf"
|
|
43
|
+
import { PptxView, repairPptx } from "@4399ywkf/editor/pptx"
|
|
42
44
|
import "@4399ywkf/editor/styles"
|
|
43
45
|
```
|
|
44
46
|
|
|
47
|
+
## pptx 只读预览
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
pnpm add pptx-preview # 可选 peer,不装就落到「暂不支持」兜底
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
```tsx
|
|
54
|
+
<DocumentView format="pptx" source={file} onError={(e) => toast(e.message)} />
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
**定位是「看清这页讲什么」,不是所见即所得。** 申报也照此写死:
|
|
58
|
+
`editable: false`、`aiOperable: false`。已知差距,接之前请先认下:
|
|
59
|
+
|
|
60
|
+
- **图表渲染不出数据** —— 轴画得出来,柱子/折线是空的,系列名和标题退化成
|
|
61
|
+
「系列1 / 图表标题」。该库打包了 ECharts,却没把 `c:cat` / `c:val` 接上去
|
|
62
|
+
- 缺字体时替换成系统字体,观感和原稿不一致
|
|
63
|
+
- 内部偶有 SVG 路径算出 NaN,个别形状画不出来
|
|
64
|
+
|
|
65
|
+
只认 OOXML:`.pptx` `.ppsx` `.potx` `.pptm` `.ppsm` `.potm`。
|
|
66
|
+
`.ppt` / `.pps` / `.pot` 是二进制复合文档、不是 zip,该库解不了,
|
|
67
|
+
所以**不申报** —— 让它落到「暂不支持」比装作能开要好。
|
|
68
|
+
|
|
69
|
+
### 为什么要先修一道
|
|
70
|
+
|
|
71
|
+
真实文件里大半份的 `[Content_Types].xml` 声明了 zip 中并不存在的 slideMaster。
|
|
72
|
+
PowerPoint 自己不介意,`pptx-preview` 会**整份静默失败**:不抛错、不报警,
|
|
73
|
+
`slideCount` 就是 0,面板一片空白。
|
|
74
|
+
|
|
75
|
+
17 份真实文件实测:**裸接只有 5 份能打开,内置 `repairPptx` 后 17 份全开**。
|
|
76
|
+
`PptxView` 已经自动做了这一道;直接用 `pptx-preview` 的宿主请自己先过:
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
import { repairPptx } from "@4399ywkf/editor/pptx"
|
|
80
|
+
const { bytes, removed } = repairPptx(new Uint8Array(await file.arrayBuffer()))
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
只在真有幽灵声明时才解压重打包(用已有的 fflate,不引 JSZip),
|
|
84
|
+
文件没问题就原样返回入参。17 份文件逐条目做过 sha256 比对:
|
|
85
|
+
除 `[Content_Types].xml` 外**字节全等**。
|
|
86
|
+
|
|
87
|
+
引擎选型与这道修复都对齐腾讯 WorkBuddy 内置的 pptx 预览。要更高保真,
|
|
88
|
+
用同名 `registerFormat` 覆盖本通道即可(见下)。
|
|
89
|
+
|
|
45
90
|
## 表格通道配置
|
|
46
91
|
|
|
47
92
|
Univer 的 UI 是 `univer-` 前缀的原子类,没有稳定选择器 —— **别用 CSS 硬藏工具栏**。
|
|
@@ -125,6 +170,33 @@ const { bytes, warnings, stats } = xlsxFromSnapshot(snapshot)
|
|
|
125
170
|
由 Excel 打开时重算 —— 这时会自动加上 `fullCalcOnLoad`,否则不重算的消费方
|
|
126
171
|
(网页预览、pandas)会看到一片空白。
|
|
127
172
|
|
|
173
|
+
## csv 导出
|
|
174
|
+
|
|
175
|
+
单独子路径,**一个依赖都不需要**(xlsx 那边要 fflate 打 zip),产物 6.9KB:
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
import { downloadCsv, exportCsv } from "@4399ywkf/editor/csv"
|
|
179
|
+
|
|
180
|
+
const { warnings } = downloadCsv(univerAPI, "明细.csv")
|
|
181
|
+
if (warnings.length) toast(warnings.join("\n"))
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
**csv 是单表格式,工作簿是多表的** —— 只导一张,其余的在 `warnings` 里逐个点名:
|
|
185
|
+
|
|
186
|
+
> csv 是单表格式,只导出了「明细」。未导出的 1 张表:「汇总」
|
|
187
|
+
|
|
188
|
+
传 `univerAPI` 时导**真正的活动表**;只传快照时导第一张 —— 快照里根本没有
|
|
189
|
+
「活动表」这个字段,只有运行时实例问得出来。要指定就用 `options.sheet`(表 id 或表名)。
|
|
190
|
+
|
|
191
|
+
默认加 **UTF-8 BOM**:不加的话 Excel 会按本地编码(简中系统是 GBK)解析,中文全是乱码。
|
|
192
|
+
转义按 RFC 4180,含分隔符/引号/换行的字段加引号、内部引号翻倍。
|
|
193
|
+
`delimiter: "\t"` 就是 tsv。
|
|
194
|
+
|
|
195
|
+
其余降级同样会点名:合并单元格被拆开、富文本压平、公式写成算好的值
|
|
196
|
+
(`formulas: "formula"` 可改成写公式原文)。**日期不做转换** —— 它在 Univer 里是
|
|
197
|
+
「序列号 + 数字格式」,csv 里会写成 `45678`。格式码有转义、区域前缀、还有和 `mm`
|
|
198
|
+
同形的分钟,猜错日期比原样给出序列号更糟,所以只警告不转换。
|
|
199
|
+
|
|
128
200
|
## docx 导入导出
|
|
129
201
|
|
|
130
202
|
API 形态对齐 `@tiptap-pro/extension-import-docx` / `-export-docx`,但**在本地解析**,
|
|
@@ -153,20 +225,23 @@ editor.chain().exportDocx({ onCompleteExport: (blob) => save(blob) }).run()
|
|
|
153
225
|
|
|
154
226
|
## 注册自有格式
|
|
155
227
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
228
|
+
内置通道覆盖不了、或保真度不够时,宿主可以用同名 `registerFormat` 直接覆盖。
|
|
229
|
+
|
|
230
|
+
pptx **编辑**就属于这种:Univer Slides 官方标注「请勿用于生产环境」,开源世界没有
|
|
231
|
+
第二个可用的前端 pptx 编辑引擎;真要做只有 OnlyOffice / Collabora 那一档
|
|
232
|
+
(AGPL,需法务口径 + 独立部署)。所以内置的只有只读预览,要更高保真自己接:
|
|
159
233
|
|
|
160
234
|
```ts
|
|
161
235
|
import { registerFormat } from "@4399ywkf/editor"
|
|
162
236
|
|
|
237
|
+
// 同名注册会覆盖内置通道
|
|
163
238
|
registerFormat({
|
|
164
239
|
format: "pptx",
|
|
165
240
|
label: "演示文稿",
|
|
166
|
-
extensions: [".ppt", ".pptx"],
|
|
241
|
+
extensions: [".ppt", ".pptx"], // 自家引擎能开二进制 .ppt 就一并申报
|
|
167
242
|
capabilities: {
|
|
168
|
-
editable:
|
|
169
|
-
importExtensions: [], exportFormats: [],
|
|
243
|
+
editable: true, aiOperable: false, collaborative: false,
|
|
244
|
+
importExtensions: [".ppt", ".pptx"], exportFormats: ["pptx"],
|
|
170
245
|
},
|
|
171
246
|
load: () => import("./MyOnlyOfficeSlideView"),
|
|
172
247
|
})
|
|
@@ -193,6 +268,6 @@ schema 不一致时一端产生的节点到另一端会被 y-prosemirror 当非
|
|
|
193
268
|
```bash
|
|
194
269
|
pnpm install
|
|
195
270
|
pnpm build # tsup + 样式编译
|
|
196
|
-
pnpm test #
|
|
271
|
+
pnpm test # 203 个用例
|
|
197
272
|
pnpm typecheck
|
|
198
273
|
```
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"names":[],"mappings":"","file":"PptxView-ZH4FZKNO.js"}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// src/xlsx/snapshot.ts
|
|
2
|
+
function snapshotFromUniver(univerAPI) {
|
|
3
|
+
const api = univerAPI;
|
|
4
|
+
const book = api?.getActiveWorkbook?.();
|
|
5
|
+
if (!book) throw new Error("拿不到当前工作簿:univerAPI 无效,或表格尚未就绪");
|
|
6
|
+
const snapshot = book.getSnapshot?.() ?? book.save?.();
|
|
7
|
+
if (!snapshot || typeof snapshot !== "object") {
|
|
8
|
+
throw new Error("当前 Univer 版本没有 getSnapshot() / save(),无法取快照");
|
|
9
|
+
}
|
|
10
|
+
return snapshot;
|
|
11
|
+
}
|
|
12
|
+
function toSnapshot(source) {
|
|
13
|
+
const maybe = source;
|
|
14
|
+
if (maybe && typeof maybe === "object" && ("sheets" in maybe || "sheetOrder" in maybe))
|
|
15
|
+
return maybe;
|
|
16
|
+
return snapshotFromUniver(source);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// src/xlsx/cell.ts
|
|
20
|
+
function numericKeys(o) {
|
|
21
|
+
if (!o) return [];
|
|
22
|
+
return Object.keys(o).map(Number).filter((n) => Number.isInteger(n) && n >= 0).sort((a, b) => a - b);
|
|
23
|
+
}
|
|
24
|
+
function richTextToPlain(cell) {
|
|
25
|
+
const stream = cell.p?.body?.dataStream;
|
|
26
|
+
if (typeof stream !== "string") return void 0;
|
|
27
|
+
return stream.replace(/\r\n$/, "").replace(/\r/g, "\n");
|
|
28
|
+
}
|
|
29
|
+
function looksLikeDateFormat(pattern) {
|
|
30
|
+
if (!pattern) return false;
|
|
31
|
+
const bare = pattern.replace(/"[^"]*"/g, "").replace(/\\./g, "");
|
|
32
|
+
return /[yYdD]/.test(bare) || /h/i.test(bare) || /\[\$-/.test(pattern);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export { looksLikeDateFormat, numericKeys, richTextToPlain, snapshotFromUniver, toSnapshot };
|
|
36
|
+
//# sourceMappingURL=chunk-32WQX3NR.js.map
|
|
37
|
+
//# sourceMappingURL=chunk-32WQX3NR.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/xlsx/snapshot.ts","../src/xlsx/cell.ts"],"names":[],"mappings":";AASO,SAAS,mBAAmB,SAAA,EAAkC;AACnE,EAAA,MAAM,GAAA,GAAM,SAAA;AAGZ,EAAA,MAAM,IAAA,GAAO,KAAK,iBAAA,IAAoB;AACtC,EAAA,IAAI,CAAC,IAAA,EAAM,MAAM,IAAI,MAAM,+BAA+B,CAAA;AAC1D,EAAA,MAAM,QAAA,GAAW,IAAA,CAAK,WAAA,IAAc,IAAK,KAAK,IAAA,IAAO;AACrD,EAAA,IAAI,CAAC,QAAA,IAAY,OAAO,QAAA,KAAa,QAAA,EAAU;AAC7C,IAAA,MAAM,IAAI,MAAM,6CAA6C,CAAA;AAAA,EAC/D;AACA,EAAA,OAAO,QAAA;AACT;AAGO,SAAS,WAAW,MAAA,EAA8C;AACvE,EAAA,MAAM,KAAA,GAAQ,MAAA;AACd,EAAA,IAAI,SAAS,OAAO,KAAA,KAAU,QAAA,KAAa,QAAA,IAAY,SAAS,YAAA,IAAgB,KAAA,CAAA;AAC9E,IAAA,OAAO,KAAA;AACT,EAAA,OAAO,mBAAmB,MAAM,CAAA;AAClC;;;ACjBO,SAAS,YAAY,CAAA,EAAkD;AAC5E,EAAA,IAAI,CAAC,CAAA,EAAG,OAAO,EAAC;AAChB,EAAA,OAAO,MAAA,CAAO,KAAK,CAAC,CAAA,CACjB,IAAI,MAAM,CAAA,CACV,MAAA,CAAO,CAAC,CAAA,KAAM,MAAA,CAAO,UAAU,CAAC,CAAA,IAAK,KAAK,CAAC,CAAA,CAC3C,KAAK,CAAC,CAAA,EAAG,CAAA,KAAM,CAAA,GAAI,CAAC,CAAA;AACzB;AAGO,SAAS,gBAAgB,IAAA,EAAoC;AAClE,EAAA,MAAM,MAAA,GAAS,IAAA,CAAK,CAAA,EAAG,IAAA,EAAM,UAAA;AAC7B,EAAA,IAAI,OAAO,MAAA,KAAW,QAAA,EAAU,OAAO,MAAA;AACvC,EAAA,OAAO,OAAO,OAAA,CAAQ,OAAA,EAAS,EAAE,CAAA,CAAE,OAAA,CAAQ,OAAO,IAAI,CAAA;AACxD;AASO,SAAS,oBAAoB,OAAA,EAA6C;AAC/E,EAAA,IAAI,CAAC,SAAS,OAAO,KAAA;AAErB,EAAA,MAAM,IAAA,GAAO,QAAQ,OAAA,CAAQ,UAAA,EAAY,EAAE,CAAA,CAAE,OAAA,CAAQ,QAAQ,EAAE,CAAA;AAC/D,EAAA,OAAO,QAAA,CAAS,IAAA,CAAK,IAAI,CAAA,IAAK,IAAA,CAAK,KAAK,IAAI,CAAA,IAAK,OAAA,CAAQ,IAAA,CAAK,OAAO,CAAA;AACvE","file":"chunk-32WQX3NR.js","sourcesContent":["/** 从 Univer 实例取快照。xlsx 与 csv 两条导出路径共用。 */\n\nimport type { WorkbookData } from \"./types.ts\"\n\n/**\n * 参数类型是 `unknown` 而不是 `FUniver`:`@univerjs/presets` 是可选 peer,\n * 写成具体类型会让没装它的项目 typecheck 直接红。运行时按 facade 的两个\n * 方法名依次试探(`getSnapshot` 是新名字,`save` 是旧的)。\n */\nexport function snapshotFromUniver(univerAPI: unknown): WorkbookData {\n const api = univerAPI as {\n getActiveWorkbook?: () => { getSnapshot?: () => unknown; save?: () => unknown } | null\n } | null\n const book = api?.getActiveWorkbook?.()\n if (!book) throw new Error(\"拿不到当前工作簿:univerAPI 无效,或表格尚未就绪\")\n const snapshot = book.getSnapshot?.() ?? book.save?.()\n if (!snapshot || typeof snapshot !== \"object\") {\n throw new Error(\"当前 Univer 版本没有 getSnapshot() / save(),无法取快照\")\n }\n return snapshot as WorkbookData\n}\n\n/** 快照或 Univer 实例都收 —— 调用方不必自己判断手里是什么 */\nexport function toSnapshot(source: WorkbookData | unknown): WorkbookData {\n const maybe = source as WorkbookData\n if (maybe && typeof maybe === \"object\" && (\"sheets\" in maybe || \"sheetOrder\" in maybe))\n return maybe\n return snapshotFromUniver(source)\n}\n","/** 读 Univer 快照时 xlsx 与 csv 两条导出路径共用的小工具。 */\n\nimport type { CellData } from \"./types.ts\"\n\n/**\n * 稀疏矩阵的行号/列号。\n *\n * Univer 的 `cellData` 是 `{ [row]: { [col]: cell } }` 的普通对象,键是字符串。\n * 直接 `Object.keys()` 会得到 `[\"10\", \"2\"]` 这种字典序,必须转数字再排 ——\n * 否则导出的行序就是乱的。\n */\nexport function numericKeys(o: Record<number, unknown> | undefined): number[] {\n if (!o) return []\n return Object.keys(o)\n .map(Number)\n .filter((n) => Number.isInteger(n) && n >= 0)\n .sort((a, b) => a - b)\n}\n\n/** 富文本只取纯文本。Univer 的 dataStream 用 \\r 断段、结尾还带一个 \\r\\n */\nexport function richTextToPlain(cell: CellData): string | undefined {\n const stream = cell.p?.body?.dataStream\n if (typeof stream !== \"string\") return undefined\n return stream.replace(/\\r\\n$/, \"\").replace(/\\r/g, \"\\n\")\n}\n\n/**\n * 这个单元格的数字格式是不是日期/时间。\n *\n * 只做**粗判**,用来给 csv 提示「这里会导出成序列号」,不用来做转换 ——\n * Excel 的格式码有转义(`\\d`、`\"年\"`)、有区域前缀(`[$-409]`)、\n * 还有和 `mm` 同形的分钟,真解析是个坑。猜错日期比原样给出序列号更糟。\n */\nexport function looksLikeDateFormat(pattern: string | undefined | null): boolean {\n if (!pattern) return false\n // 去掉引号里的字面量和反斜杠转义,剩下的才是真正的格式符\n const bare = pattern.replace(/\"[^\"]*\"/g, \"\").replace(/\\\\./g, \"\")\n return /[yYdD]/.test(bare) || /h/i.test(bare) || /\\[\\$-/.test(pattern)\n}\n"]}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { useRef, useState, useEffect } from 'react';
|
|
2
|
+
import { unzipSync, zipSync } from 'fflate';
|
|
3
|
+
import { jsx } from 'react/jsx-runtime';
|
|
4
|
+
|
|
5
|
+
// src/views/PptxView.tsx
|
|
6
|
+
var CONTENT_TYPES = "[Content_Types].xml";
|
|
7
|
+
var MASTER_ENTRY = /^ppt\/slideMasters\/slideMaster(\d+)\.xml$/;
|
|
8
|
+
var MASTER_DECL = /PartName="\/ppt\/slideMasters\/slideMaster(\d+)\.xml"/g;
|
|
9
|
+
function repairPptx(input) {
|
|
10
|
+
let files;
|
|
11
|
+
try {
|
|
12
|
+
files = unzipSync(input);
|
|
13
|
+
} catch (e) {
|
|
14
|
+
return { bytes: input, removed: 0, skipped: `不是有效的 zip:${e.message}` };
|
|
15
|
+
}
|
|
16
|
+
const contentTypes = files[CONTENT_TYPES];
|
|
17
|
+
if (!contentTypes) return { bytes: input, removed: 0, skipped: `缺少 ${CONTENT_TYPES}` };
|
|
18
|
+
const actual = /* @__PURE__ */ new Set();
|
|
19
|
+
for (const name of Object.keys(files)) {
|
|
20
|
+
const m = MASTER_ENTRY.exec(name);
|
|
21
|
+
if (m) actual.add(m[1]);
|
|
22
|
+
}
|
|
23
|
+
const xml = new TextDecoder().decode(contentTypes);
|
|
24
|
+
const ghosts = [...xml.matchAll(MASTER_DECL)].map((m) => m[1]).filter((n) => !actual.has(n));
|
|
25
|
+
if (!ghosts.length) return { bytes: input, removed: 0 };
|
|
26
|
+
let patched = xml;
|
|
27
|
+
for (const n of new Set(ghosts)) {
|
|
28
|
+
patched = patched.replace(
|
|
29
|
+
new RegExp(`<Override[^>]+PartName="/ppt/slideMasters/slideMaster${n}\\.xml"[^>]*>\\s*`, "g"),
|
|
30
|
+
""
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
if (!patched.trim()) return { bytes: input, removed: 0, skipped: "替换后内容为空" };
|
|
34
|
+
files[CONTENT_TYPES] = new TextEncoder().encode(patched);
|
|
35
|
+
try {
|
|
36
|
+
return { bytes: zipSync(files, { level: 6 }), removed: new Set(ghosts).size };
|
|
37
|
+
} catch (e) {
|
|
38
|
+
return { bytes: input, removed: 0, skipped: `重新打包失败:${e.message}` };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
var BASE_WIDTH = 960;
|
|
42
|
+
function PptxView({
|
|
43
|
+
source,
|
|
44
|
+
content,
|
|
45
|
+
className,
|
|
46
|
+
onReady,
|
|
47
|
+
onError
|
|
48
|
+
}) {
|
|
49
|
+
const hostRef = useRef(null);
|
|
50
|
+
const wrapperRef = useRef(null);
|
|
51
|
+
const [state, setState] = useState("loading");
|
|
52
|
+
const [slides, setSlides] = useState(0);
|
|
53
|
+
const latest = useRef({ onReady, onError });
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
latest.current = { onReady, onError };
|
|
56
|
+
});
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
let cancelled = false;
|
|
59
|
+
const host = hostRef.current;
|
|
60
|
+
if (!host || !source && !content) return;
|
|
61
|
+
let previewer = null;
|
|
62
|
+
setState("loading");
|
|
63
|
+
setSlides(0);
|
|
64
|
+
(async () => {
|
|
65
|
+
const { onReady: ready, onError: fail } = latest.current;
|
|
66
|
+
try {
|
|
67
|
+
const [{ init }, bytes] = await Promise.all([
|
|
68
|
+
import('pptx-preview'),
|
|
69
|
+
toBytes(source, content)
|
|
70
|
+
]);
|
|
71
|
+
if (cancelled) return;
|
|
72
|
+
const { bytes: fixed, removed, skipped } = repairPptx(bytes);
|
|
73
|
+
if (skipped) console.warn(`[PptxView] 跳过修复:${skipped}`);
|
|
74
|
+
else if (removed) console.info(`[PptxView] 已移除 ${removed} 条无效的 slideMaster 声明`);
|
|
75
|
+
host.innerHTML = "";
|
|
76
|
+
previewer = init(host, { width: BASE_WIDTH });
|
|
77
|
+
await previewer.preview(
|
|
78
|
+
toArrayBuffer(fixed)
|
|
79
|
+
);
|
|
80
|
+
if (cancelled) return;
|
|
81
|
+
const count = previewer.slideCount ?? 0;
|
|
82
|
+
setSlides(count);
|
|
83
|
+
if (!count) {
|
|
84
|
+
setState("error");
|
|
85
|
+
fail?.(new Error("演示文稿解析失败:没有可渲染的幻灯片"));
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
setState("ready");
|
|
89
|
+
ready?.(previewer);
|
|
90
|
+
} catch (e) {
|
|
91
|
+
if (cancelled) return;
|
|
92
|
+
const err = e instanceof Error ? e : new Error(String(e));
|
|
93
|
+
setState("error");
|
|
94
|
+
fail?.(
|
|
95
|
+
new Error(
|
|
96
|
+
`演示文稿预览失败:${err.message}。请确认已安装可选依赖 pptx-preview(pnpm add pptx-preview)`
|
|
97
|
+
)
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
})();
|
|
101
|
+
return () => {
|
|
102
|
+
cancelled = true;
|
|
103
|
+
previewer?.destroy?.();
|
|
104
|
+
if (host) host.innerHTML = "";
|
|
105
|
+
};
|
|
106
|
+
}, [source, content]);
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
const wrapper = wrapperRef.current;
|
|
109
|
+
const host = hostRef.current;
|
|
110
|
+
if (!wrapper || !host) return;
|
|
111
|
+
const update = () => {
|
|
112
|
+
const width = wrapper.clientWidth;
|
|
113
|
+
host.style.zoom = width && width < BASE_WIDTH ? String(width / BASE_WIDTH) : "";
|
|
114
|
+
};
|
|
115
|
+
update();
|
|
116
|
+
const observer = new ResizeObserver(update);
|
|
117
|
+
observer.observe(wrapper);
|
|
118
|
+
return () => observer.disconnect();
|
|
119
|
+
}, []);
|
|
120
|
+
return /* @__PURE__ */ jsx(
|
|
121
|
+
"div",
|
|
122
|
+
{
|
|
123
|
+
ref: wrapperRef,
|
|
124
|
+
className,
|
|
125
|
+
"data-pptx-state": state,
|
|
126
|
+
"data-pptx-slides": slides,
|
|
127
|
+
style: { width: "100%", height: "100%", overflow: "auto" },
|
|
128
|
+
children: /* @__PURE__ */ jsx("div", { ref: hostRef, style: { width: BASE_WIDTH } })
|
|
129
|
+
}
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
async function toBytes(source, content) {
|
|
133
|
+
if (content instanceof Uint8Array) return content;
|
|
134
|
+
if (content instanceof ArrayBuffer) return new Uint8Array(content);
|
|
135
|
+
if (source instanceof Blob) return new Uint8Array(await source.arrayBuffer());
|
|
136
|
+
if (typeof source === "string") {
|
|
137
|
+
const res = await fetch(source);
|
|
138
|
+
if (!res.ok) throw new Error(`拉取失败 HTTP ${res.status}`);
|
|
139
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
140
|
+
}
|
|
141
|
+
throw new Error("需要 source(URL / File / Blob)或 content(ArrayBuffer)");
|
|
142
|
+
}
|
|
143
|
+
function toArrayBuffer(bytes) {
|
|
144
|
+
return bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength ? bytes.buffer : bytes.slice().buffer;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export { PptxView, repairPptx };
|
|
148
|
+
//# sourceMappingURL=chunk-5XG4YMAB.js.map
|
|
149
|
+
//# sourceMappingURL=chunk-5XG4YMAB.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/pptx-repair.ts","../src/views/PptxView.tsx"],"names":[],"mappings":"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","file":"chunk-5XG4YMAB.js","sourcesContent":["/**\n * pptx 的「幽灵 slideMaster」修复。\n *\n * 很多 pptx 的 `[Content_Types].xml` 里声明了 zip 中**并不存在**的\n * `ppt/slideMasters/slideMasterN.xml`。PowerPoint 自己不介意,但 `pptx-preview`\n * 会因为解析不到这些部件而整份失败 —— 表现是 `slideCount === 0`、面板一片空白,\n * 不抛错、不报警。\n *\n * 这不是边缘情况:在 17 份真实文件上实测,**12 份中招**(AI 生成的和\n * PowerPoint 16 存出来的都有)。裸接这个库只有 5/17 能打开,加上本修复后 17/17。\n *\n * 做法与腾讯 WorkBuddy 内置 pptx 预览里的 `checkPPTXFile` 一致:删掉指向缺失\n * 部件的 `<Override>` 再重新打包。只处理 slideMaster —— 其它部件缺失时该库\n * 能自己降级,动它反而可能改坏。\n */\n\nimport { unzipSync, zipSync } from \"fflate\"\n\nconst CONTENT_TYPES = \"[Content_Types].xml\"\nconst MASTER_ENTRY = /^ppt\\/slideMasters\\/slideMaster(\\d+)\\.xml$/\nconst MASTER_DECL = /PartName=\"\\/ppt\\/slideMasters\\/slideMaster(\\d+)\\.xml\"/g\n\nexport interface RepairPptxResult {\n /** 修好的字节;没问题时**原样返回入参**,不做无谓的解压重打包 */\n bytes: Uint8Array\n /** 删掉了几条幽灵声明。0 表示这份文件本来就是好的 */\n removed: number\n /** 修复过程中放弃的原因(文件不是 zip、没有 [Content_Types].xml 等) */\n skipped?: string\n}\n\n/**\n * @param input pptx 原始字节\n * @returns 可交给 `pptx-preview` 的字节。**任何异常都退回原字节** ——\n * 修不了也不该让预览失败,让引擎自己去报它的错。\n */\nexport function repairPptx(input: Uint8Array): RepairPptxResult {\n let files: Record<string, Uint8Array>\n try {\n files = unzipSync(input)\n } catch (e) {\n return { bytes: input, removed: 0, skipped: `不是有效的 zip:${(e as Error).message}` }\n }\n\n const contentTypes = files[CONTENT_TYPES]\n if (!contentTypes) return { bytes: input, removed: 0, skipped: `缺少 ${CONTENT_TYPES}` }\n\n const actual = new Set<string>()\n for (const name of Object.keys(files)) {\n const m = MASTER_ENTRY.exec(name)\n if (m) actual.add(m[1] as string)\n }\n\n const xml = new TextDecoder().decode(contentTypes)\n const ghosts = [...xml.matchAll(MASTER_DECL)]\n .map((m) => m[1] as string)\n .filter((n) => !actual.has(n))\n if (!ghosts.length) return { bytes: input, removed: 0 }\n\n let patched = xml\n for (const n of new Set(ghosts)) {\n patched = patched.replace(\n new RegExp(`<Override[^>]+PartName=\"/ppt/slideMasters/slideMaster${n}\\\\.xml\"[^>]*>\\\\s*`, \"g\"),\n \"\",\n )\n }\n // 替换后为空说明正则吃掉了不该吃的,宁可不修\n if (!patched.trim()) return { bytes: input, removed: 0, skipped: \"替换后内容为空\" }\n\n files[CONTENT_TYPES] = new TextEncoder().encode(patched)\n try {\n return { bytes: zipSync(files, { level: 6 }), removed: new Set(ghosts).size }\n } catch (e) {\n return { bytes: input, removed: 0, skipped: `重新打包失败:${(e as Error).message}` }\n }\n}\n","import { useEffect, useRef, useState } from \"react\"\nimport { repairPptx } from \"../pptx-repair\"\nimport type { DocumentViewProps } from \"../registry\"\n\n/**\n * pptx 通道:`pptx-preview` 只读预览。\n *\n * **只做「看清这页讲什么」,不是所见即所得。** 已知差距:\n * - **图表渲染不出数据** —— 轴画得出来,柱子/折线是空的,系列名与标题退化成默认值。\n * 该库打包了 ECharts 却没把 `c:cat` / `c:val` 接上去\n * - 原字体缺失时替换成系统字体,观感和原稿不一致\n * - 内部偶有 SVG 路径算出 NaN 的报错,个别形状会画不出来\n *\n * 编辑不做:开源世界没有可用的前端 pptx 编辑引擎(Univer Slides 官方标注\n * 「请勿用于生产环境」),真要做只有 OnlyOffice / Collabora 那一档\n * (AGPL,需法务口径 + 独立部署)。宿主可以用 `registerFormat` 覆盖本通道。\n *\n * 引擎选型与「幽灵 slideMaster」修复都对齐腾讯 WorkBuddy 的内置 pptx 预览。\n */\n\n/** 该库以此宽度布局,缩放靠 CSS zoom。和 WorkBuddy 取值一致 */\nconst BASE_WIDTH = 960\n\nexport default function PptxView({\n source,\n content,\n className,\n onReady,\n onError,\n}: DocumentViewProps) {\n const hostRef = useRef<HTMLDivElement>(null)\n const wrapperRef = useRef<HTMLDivElement>(null)\n const [state, setState] = useState<\"loading\" | \"ready\" | \"error\">(\"loading\")\n const [slides, setSlides] = useState(0)\n\n // 同 SheetView:回调多是内联箭头函数,进依赖数组会让父组件每渲染一次就重解析一遍\n const latest = useRef({ onReady, onError })\n useEffect(() => {\n latest.current = { onReady, onError }\n })\n\n useEffect(() => {\n let cancelled = false\n const host = hostRef.current\n if (!host || (!source && !content)) return\n let previewer: { destroy?: () => void } | null = null\n\n setState(\"loading\")\n setSlides(0)\n ;(async () => {\n const { onReady: ready, onError: fail } = latest.current\n try {\n // 动态 import:这个库连着 ECharts 一起 gzip 后 400KB+,\n // 不该因为「包里支持 pptx」就让所有人付这笔钱\n const [{ init }, bytes] = await Promise.all([\n import(\"pptx-preview\"),\n toBytes(source, content),\n ])\n if (cancelled) return\n\n // 先修再喂。不修的话真实文件里大半会静默变成 0 页空白\n const { bytes: fixed, removed, skipped } = repairPptx(bytes)\n if (skipped) console.warn(`[PptxView] 跳过修复:${skipped}`)\n else if (removed) console.info(`[PptxView] 已移除 ${removed} 条无效的 slideMaster 声明`)\n\n host.innerHTML = \"\"\n // 只传 width:不传 mode 时该库按整列堆叠渲染,正是预览面板要的\n previewer = (init as (el: HTMLElement, o: object) => never)(host, { width: BASE_WIDTH })\n await (previewer as unknown as { preview: (b: ArrayBuffer) => Promise<unknown> }).preview(\n toArrayBuffer(fixed),\n )\n if (cancelled) return\n\n const count = (previewer as unknown as { slideCount?: number }).slideCount ?? 0\n setSlides(count)\n if (!count) {\n // 该库解析失败时不抛错,只是什么都不画 —— 不显式判空就会是一片白板\n setState(\"error\")\n fail?.(new Error(\"演示文稿解析失败:没有可渲染的幻灯片\"))\n return\n }\n setState(\"ready\")\n ready?.(previewer)\n } catch (e) {\n if (cancelled) return\n const err = e instanceof Error ? e : new Error(String(e))\n setState(\"error\")\n fail?.(\n new Error(\n `演示文稿预览失败:${err.message}。` +\n `请确认已安装可选依赖 pptx-preview(pnpm add pptx-preview)`,\n ),\n )\n }\n })()\n\n return () => {\n cancelled = true\n previewer?.destroy?.()\n if (host) host.innerHTML = \"\"\n }\n }, [source, content])\n\n // 容器比 960 窄时整体缩放。用 zoom 而不是 transform:\n // transform 不改变布局盒,缩完外层还会留着原高度的空白\n useEffect(() => {\n const wrapper = wrapperRef.current\n const host = hostRef.current\n if (!wrapper || !host) return\n const update = () => {\n const width = wrapper.clientWidth\n host.style.zoom = width && width < BASE_WIDTH ? String(width / BASE_WIDTH) : \"\"\n }\n update()\n const observer = new ResizeObserver(update)\n observer.observe(wrapper)\n return () => observer.disconnect()\n }, [])\n\n return (\n <div\n ref={wrapperRef}\n className={className}\n data-pptx-state={state}\n data-pptx-slides={slides}\n style={{ width: \"100%\", height: \"100%\", overflow: \"auto\" }}\n >\n <div ref={hostRef} style={{ width: BASE_WIDTH }} />\n </div>\n )\n}\n\nasync function toBytes(\n source: DocumentViewProps[\"source\"],\n content: DocumentViewProps[\"content\"],\n): Promise<Uint8Array> {\n if (content instanceof Uint8Array) return content\n if (content instanceof ArrayBuffer) return new Uint8Array(content)\n if (source instanceof Blob) return new Uint8Array(await source.arrayBuffer())\n if (typeof source === \"string\") {\n const res = await fetch(source)\n if (!res.ok) throw new Error(`拉取失败 HTTP ${res.status}`)\n return new Uint8Array(await res.arrayBuffer())\n }\n throw new Error(\"需要 source(URL / File / Blob)或 content(ArrayBuffer)\")\n}\n\n/** fflate 可能返回带 offset 的视图,直接取 .buffer 会把整块底层内存交出去 */\nfunction toArrayBuffer(bytes: Uint8Array): ArrayBuffer {\n return bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength\n ? (bytes.buffer as ArrayBuffer)\n : (bytes.slice().buffer as ArrayBuffer)\n}\n"]}
|
package/dist/csv.d.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { W as WorkbookData } from './types-C37MAt8o.js';
|
|
2
|
+
export { C as CellData, a as WorksheetData } from './types-C37MAt8o.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Univer 快照 → CSV。
|
|
6
|
+
*
|
|
7
|
+
* **csv 是单表格式,工作簿是多表的**,这是产品决定不是技术决定。本实现的口径:
|
|
8
|
+
* 导**一张**表,其余的在 `warnings` 里**逐个点名**,和 xlsx 导出「如实报告丢了什么」
|
|
9
|
+
* 保持一致 —— 静悄悄只给一张表,用户往往到对账时才发现少了东西。
|
|
10
|
+
*
|
|
11
|
+
* 「哪张是活动表」只有运行时的 Univer 实例知道,**快照里根本没有这个字段**。
|
|
12
|
+
* 所以:传 `univerAPI` 时取真正的活动表;只传快照时取 `sheetOrder` 的第一张,
|
|
13
|
+
* 并且照样把这件事写进 warnings。要指定就用 `options.sheet`(表 id 或表名)。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
interface CsvOptions {
|
|
17
|
+
/** 导哪张表:工作表 id 或表名。不传则用活动表(拿不到就用第一张) */
|
|
18
|
+
sheet?: string;
|
|
19
|
+
/** 分隔符,默认 `,`。用 `\t` 就是 tsv */
|
|
20
|
+
delimiter?: string;
|
|
21
|
+
/** 行分隔,默认 `\r\n`(RFC 4180 规定,Excel 也最稳) */
|
|
22
|
+
newline?: string;
|
|
23
|
+
/**
|
|
24
|
+
* 是否加 UTF-8 BOM,默认 **true**。
|
|
25
|
+
* 不加的话 Excel 会按本地编码(简中系统是 GBK)解析,中文全是乱码 ——
|
|
26
|
+
* 这是 csv 导出最常见的投诉,默认就得挡掉。
|
|
27
|
+
*/
|
|
28
|
+
bom?: boolean;
|
|
29
|
+
/** 公式格写 Univer 算好的值(默认)还是写公式文本 */
|
|
30
|
+
formulas?: "value" | "formula";
|
|
31
|
+
}
|
|
32
|
+
interface CsvResult {
|
|
33
|
+
/** 文本内容,不含 BOM */
|
|
34
|
+
text: string;
|
|
35
|
+
/** 可直接落盘/上传的字节,按 `bom` 决定是否带 BOM */
|
|
36
|
+
bytes: Uint8Array;
|
|
37
|
+
/** 导出时丢掉/降级了什么。**要展示给用户** */
|
|
38
|
+
warnings: string[];
|
|
39
|
+
stats: {
|
|
40
|
+
sheetName: string;
|
|
41
|
+
rows: number;
|
|
42
|
+
columns: number;
|
|
43
|
+
cells: number;
|
|
44
|
+
/** 没被导出的工作表数 */
|
|
45
|
+
skippedSheets: number;
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
declare const CSV_MIME = "text/csv;charset=utf-8";
|
|
49
|
+
declare function csvFromSnapshot(workbook: WorkbookData, options?: CsvOptions): CsvResult;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* `@4399ywkf/editor/csv` —— 表格导出 CSV。
|
|
53
|
+
*
|
|
54
|
+
* 独立子路径而不是塞进 `./xlsx`:csv 这条路**一个依赖都不需要**
|
|
55
|
+
* (xlsx 那边要 fflate 打 zip),几 KB 就够。只要 csv 的宿主不该为 zip 买单。
|
|
56
|
+
*
|
|
57
|
+
* ```ts
|
|
58
|
+
* import { downloadCsv } from "@4399ywkf/editor/csv"
|
|
59
|
+
*
|
|
60
|
+
* const { warnings } = downloadCsv(univerAPI, "明细.csv")
|
|
61
|
+
* if (warnings.length) toast(warnings.join("\n")) // ← 里面会点名哪些表没导
|
|
62
|
+
* ```
|
|
63
|
+
*
|
|
64
|
+
* **csv 是单表格式,工作簿是多表的。** 本实现只导一张表,其余的在 `warnings`
|
|
65
|
+
* 里逐个点名。传 `univerAPI` 时导真正的活动表;只传快照时导第一张 ——
|
|
66
|
+
* 快照里根本没有「活动表」这个字段。要指定就用 `options.sheet`(表 id 或表名)。
|
|
67
|
+
*/
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Univer 实例或快照 → CSV。
|
|
71
|
+
*
|
|
72
|
+
* 传实例时会自动锁定**当前活动表** —— 这也是唯一能拿到活动表的途径。
|
|
73
|
+
*/
|
|
74
|
+
declare function exportCsv(source: WorkbookData | unknown, options?: CsvOptions): CsvResult;
|
|
75
|
+
/** Univer 实例或快照 → Blob,便于交给上传接口 */
|
|
76
|
+
declare function csvBlob(source: WorkbookData | unknown, options?: CsvOptions): Blob;
|
|
77
|
+
/**
|
|
78
|
+
* 触发浏览器下载。返回 warnings / stats —— 别丢,
|
|
79
|
+
* 「另外几张表没导出」只能从这里知道。
|
|
80
|
+
*/
|
|
81
|
+
declare function downloadCsv(source: WorkbookData | unknown, filename?: string, options?: CsvOptions): CsvResult;
|
|
82
|
+
|
|
83
|
+
export { CSV_MIME, type CsvOptions, type CsvResult, WorkbookData, csvBlob, csvFromSnapshot, downloadCsv, exportCsv };
|
package/dist/csv.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { numericKeys, richTextToPlain, looksLikeDateFormat, toSnapshot } from './chunk-32WQX3NR.js';
|
|
2
|
+
|
|
3
|
+
// src/xlsx/csv.ts
|
|
4
|
+
var CSV_MIME = "text/csv;charset=utf-8";
|
|
5
|
+
function escapeField(value, delimiter) {
|
|
6
|
+
if (!value) return "";
|
|
7
|
+
const needsQuote = value.includes(delimiter) || value.includes('"') || value.includes("\n") || value.includes("\r");
|
|
8
|
+
return needsQuote ? `"${value.replace(/"/g, '""')}"` : value;
|
|
9
|
+
}
|
|
10
|
+
function csvFromSnapshot(workbook, options = {}) {
|
|
11
|
+
const { delimiter = ",", newline = "\r\n", bom = true, formulas = "value" } = options;
|
|
12
|
+
const warnings = [];
|
|
13
|
+
const order = (workbook.sheetOrder?.length ? workbook.sheetOrder : Object.keys(workbook.sheets ?? {})).filter((id) => workbook.sheets?.[id]);
|
|
14
|
+
if (!order.length) {
|
|
15
|
+
return {
|
|
16
|
+
text: "",
|
|
17
|
+
bytes: encode("", bom),
|
|
18
|
+
warnings: ["工作簿里没有工作表"],
|
|
19
|
+
stats: { sheetName: "", rows: 0, columns: 0, cells: 0, skippedSheets: 0 }
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
const wanted = options.sheet;
|
|
23
|
+
const pickedId = wanted && (order.find((id) => id === wanted) ?? order.find((id) => workbook.sheets?.[id]?.name === wanted)) || order[0];
|
|
24
|
+
if (wanted && pickedId !== wanted && workbook.sheets?.[pickedId]?.name !== wanted) {
|
|
25
|
+
warnings.push(
|
|
26
|
+
`找不到工作表「${wanted}」,已改导「${workbook.sheets?.[pickedId]?.name ?? pickedId}」`
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
const sheet = workbook.sheets?.[pickedId];
|
|
30
|
+
const sheetName = sheet?.name ?? pickedId;
|
|
31
|
+
const skipped = order.filter((id) => id !== pickedId);
|
|
32
|
+
if (skipped.length) {
|
|
33
|
+
const names = skipped.map((id) => `「${workbook.sheets?.[id]?.name ?? id}」`);
|
|
34
|
+
warnings.push(
|
|
35
|
+
`csv 是单表格式,只导出了「${sheetName}」。未导出的 ${skipped.length} 张表:${names.join("")}`
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
const styleMap = workbook.styles ?? {};
|
|
39
|
+
const resolveStyle = (s) => {
|
|
40
|
+
if (!s) return void 0;
|
|
41
|
+
if (typeof s === "string") return styleMap[s] ?? void 0;
|
|
42
|
+
return s;
|
|
43
|
+
};
|
|
44
|
+
const cellData = sheet?.cellData ?? {};
|
|
45
|
+
const rows = numericKeys(cellData);
|
|
46
|
+
let maxCol = -1;
|
|
47
|
+
for (const r of rows) {
|
|
48
|
+
const cols = numericKeys(cellData[r]);
|
|
49
|
+
const last = cols[cols.length - 1];
|
|
50
|
+
if (last !== void 0 && last > maxCol) maxCol = last;
|
|
51
|
+
}
|
|
52
|
+
const maxRow = rows.length ? rows[rows.length - 1] : -1;
|
|
53
|
+
let cells = 0;
|
|
54
|
+
let sawDateFormat = false;
|
|
55
|
+
let sawRichText = false;
|
|
56
|
+
const lines = [];
|
|
57
|
+
for (let r = 0; r <= maxRow; r++) {
|
|
58
|
+
const row = cellData[r];
|
|
59
|
+
const fields = [];
|
|
60
|
+
for (let c = 0; c <= maxCol; c++) {
|
|
61
|
+
const cell = row?.[c];
|
|
62
|
+
const text2 = cell ? cellToText(cell) : "";
|
|
63
|
+
if (text2) cells++;
|
|
64
|
+
fields.push(escapeField(text2, delimiter));
|
|
65
|
+
}
|
|
66
|
+
lines.push(fields.join(delimiter));
|
|
67
|
+
}
|
|
68
|
+
function cellToText(cell) {
|
|
69
|
+
if (formulas === "formula" && cell.f) return cell.f;
|
|
70
|
+
const rich = richTextToPlain(cell);
|
|
71
|
+
if (rich !== void 0) {
|
|
72
|
+
sawRichText = true;
|
|
73
|
+
return rich;
|
|
74
|
+
}
|
|
75
|
+
const v = cell.v;
|
|
76
|
+
if (v === void 0 || v === null) return "";
|
|
77
|
+
if (typeof v === "boolean") return v ? "TRUE" : "FALSE";
|
|
78
|
+
if (typeof v === "number") {
|
|
79
|
+
if (looksLikeDateFormat(resolveStyle(cell.s)?.n?.pattern)) sawDateFormat = true;
|
|
80
|
+
return String(v);
|
|
81
|
+
}
|
|
82
|
+
return String(v);
|
|
83
|
+
}
|
|
84
|
+
if (sawDateFormat) {
|
|
85
|
+
warnings.push(
|
|
86
|
+
"有单元格用的是日期/时间格式,csv 里会写成 Excel 序列号(如 45678)而不是日期文本 —— csv 不带格式信息,导入方需要自己按日期列解析"
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
if (sawRichText) warnings.push("富文本单元格已压平成纯文本,段内的字体与颜色不保留");
|
|
90
|
+
if (sheet?.mergeData?.length) {
|
|
91
|
+
warnings.push(`${sheet.mergeData.length} 处合并单元格已拆开:值留在左上角,其余为空`);
|
|
92
|
+
}
|
|
93
|
+
if (formulas === "value") {
|
|
94
|
+
const hasFormula = rows.some((r) => numericKeys(cellData[r]).some((c) => cellData[r]?.[c]?.f));
|
|
95
|
+
if (hasFormula) warnings.push("公式已写成 Univer 算出的值,公式本身不保留");
|
|
96
|
+
}
|
|
97
|
+
const text = lines.join(newline);
|
|
98
|
+
return {
|
|
99
|
+
text,
|
|
100
|
+
bytes: encode(text, bom),
|
|
101
|
+
warnings,
|
|
102
|
+
stats: {
|
|
103
|
+
sheetName,
|
|
104
|
+
rows: maxRow + 1,
|
|
105
|
+
columns: maxCol + 1,
|
|
106
|
+
cells,
|
|
107
|
+
skippedSheets: skipped.length
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
function encode(text, bom) {
|
|
112
|
+
const body = new TextEncoder().encode(text);
|
|
113
|
+
if (!bom) return body;
|
|
114
|
+
const out = new Uint8Array(body.length + 3);
|
|
115
|
+
out.set([239, 187, 191]);
|
|
116
|
+
out.set(body, 3);
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
function activeSheetId(univerAPI) {
|
|
120
|
+
const book = univerAPI?.getActiveWorkbook?.();
|
|
121
|
+
const sheet = book?.getActiveSheet?.();
|
|
122
|
+
const id = sheet?.getSheetId?.();
|
|
123
|
+
return typeof id === "string" && id ? id : void 0;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// src/csv.ts
|
|
127
|
+
function exportCsv(source, options = {}) {
|
|
128
|
+
const snapshot = toSnapshot(source);
|
|
129
|
+
const sheet = options.sheet ?? (snapshot === source ? void 0 : activeSheetId(source));
|
|
130
|
+
return csvFromSnapshot(snapshot, { ...options, sheet });
|
|
131
|
+
}
|
|
132
|
+
function csvBlob(source, options) {
|
|
133
|
+
return new Blob([exportCsv(source, options).bytes], { type: CSV_MIME });
|
|
134
|
+
}
|
|
135
|
+
function downloadCsv(source, filename = "表格.csv", options) {
|
|
136
|
+
const result = exportCsv(source, options);
|
|
137
|
+
if (typeof document === "undefined") {
|
|
138
|
+
throw new Error("downloadCsv 需要浏览器环境,服务端请改用 exportCsv 自己落盘");
|
|
139
|
+
}
|
|
140
|
+
const url = URL.createObjectURL(new Blob([result.bytes], { type: CSV_MIME }));
|
|
141
|
+
const a = document.createElement("a");
|
|
142
|
+
a.href = url;
|
|
143
|
+
a.download = filename.endsWith(".csv") ? filename : `${filename}.csv`;
|
|
144
|
+
document.body.appendChild(a);
|
|
145
|
+
a.click();
|
|
146
|
+
a.remove();
|
|
147
|
+
setTimeout(() => URL.revokeObjectURL(url), 1e4);
|
|
148
|
+
return result;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export { CSV_MIME, csvBlob, csvFromSnapshot, downloadCsv, exportCsv };
|
|
152
|
+
//# sourceMappingURL=csv.js.map
|
|
153
|
+
//# sourceMappingURL=csv.js.map
|