@spacecorexdev/core 0.0.4 → 0.0.5
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/package.json +2 -36
- package/src/api/_base.ts +243 -0
- package/src/api/index.ts +17 -0
- package/src/api/project.ts +1345 -0
- package/src/api/user.ts +1174 -0
- package/src/contract.ts +50 -0
- package/src/hooks/index.ts +2 -0
- package/{dist/hooks/index.js → src/hooks/useLongPress.ts} +53 -49
- package/src/hooks/useOnlineStatus.ts +22 -0
- package/src/index.ts +14 -0
- package/src/types/id.ts +2 -0
- package/src/types/index.ts +5 -0
- package/src/types/project/base.ts +63 -0
- package/src/types/project/contract.ts +79 -0
- package/src/types/project/contractStatus.tsx +15 -0
- package/src/types/project/index.ts +5 -0
- package/src/types/project/member.ts +46 -0
- package/src/types/project/progress.ts +43 -0
- package/src/types/projectRole.ts +17 -0
- package/src/types/theme.ts +1 -0
- package/src/types/user/index.ts +1 -0
- package/src/types/user/role.ts +7 -0
- package/src/ui/CommentTimeline/index.tsx +330 -0
- package/src/ui/FileManager/FileContextMenu.tsx +628 -0
- package/src/ui/FileManager/FileGridView.tsx +529 -0
- package/src/ui/FileManager/FileTableView.tsx +405 -0
- package/src/ui/FileManager/FileToolbar.tsx +119 -0
- package/src/ui/FileManager/FileVersionHistoryModal.tsx +175 -0
- package/src/ui/FileManager/MoveCopyToProjectModal.tsx +119 -0
- package/src/ui/FileManager/ShareToWechatModal.tsx +419 -0
- package/src/ui/FileManager/hooks/useFileManagerActions.ts +2532 -0
- package/src/ui/FileManager/hooks/useFileManagerState.ts +419 -0
- package/src/ui/FileManager/index.tsx +580 -0
- package/src/ui/FileManager/projectFileManagerApi.ts +57 -0
- package/src/ui/FileManager/types.ts +184 -0
- package/src/ui/FileManager/utils/fileDeltaWorker.ts +193 -0
- package/src/ui/FileManager/utils/fileTree.ts +178 -0
- package/src/ui/FileManager/utils/fileUpload.ts +714 -0
- package/src/ui/FileManager/utils/fileUtils.ts +65 -0
- package/src/ui/InvoiceRecordsList/index.tsx +813 -0
- package/src/ui/MyFileSelectorModal.tsx +261 -0
- package/src/ui/ProjectSelector/index.tsx +472 -0
- package/src/ui/ProjectSelector/projectCache.ts +40 -0
- package/src/ui/RoleSelector/index.tsx +473 -0
- package/src/ui/RoleSelector/roleCache.ts +40 -0
- package/src/ui/UIAlert.tsx +399 -0
- package/src/ui/UIAlertExample.tsx +129 -0
- package/src/ui/UIConfirm.tsx +335 -0
- package/src/ui/UIConfirmExample.tsx +165 -0
- package/src/ui/UIDatePicker.tsx +146 -0
- package/src/ui/UIDateRangePicker.tsx +38 -0
- package/src/ui/UIFilePreview/ExcelEditor/index.tsx +617 -0
- package/src/ui/UIFilePreview/ExcelEditor/types.ts +24 -0
- package/src/ui/UIFilePreview/ExportDropdown.css +85 -0
- package/src/ui/UIFilePreview/ExportDropdown.tsx +109 -0
- package/src/ui/UIFilePreview/IndexedDBUtils.ts +564 -0
- package/src/ui/UIFilePreview/MarkdownEditor/exportMarkdownPdf.ts +576 -0
- package/src/ui/UIFilePreview/MarkdownEditor/index.tsx +677 -0
- package/src/ui/UIFilePreview/MarkdownEditor/markdownEditor.css +1005 -0
- package/src/ui/UIFilePreview/MarkdownEditor/types.ts +15 -0
- package/src/ui/UIFilePreview/PPTXEditor/index.tsx +602 -0
- package/src/ui/UIFilePreview/RichDocEditor/exportRDocMarkdown.ts +77 -0
- package/src/ui/UIFilePreview/RichDocEditor/exportRDocPdf.ts +374 -0
- package/src/ui/UIFilePreview/RichDocEditor/index.tsx +730 -0
- package/src/ui/UIFilePreview/RichDocEditor/richdoc-editor.css +59 -0
- package/src/ui/UIFilePreview/UIFilePreview.tsx +611 -0
- package/src/ui/UIFilePreview/WordEditor/index.tsx +3933 -0
- package/src/ui/UIFilePreview/WordEditor/style.ts +1213 -0
- package/src/ui/UIFilePreview/WordEditor/word-editor.css +999 -0
- package/src/ui/UIFilePreview/filePreview.css +193 -0
- package/src/ui/UIFilePreview/filetypes/archive.tsx +66 -0
- package/src/ui/UIFilePreview/filetypes/cad.tsx +287 -0
- package/src/ui/UIFilePreview/filetypes/excel.tsx +355 -0
- package/src/ui/UIFilePreview/filetypes/image.tsx +20 -0
- package/src/ui/UIFilePreview/filetypes/imageLoader.ts +63 -0
- package/src/ui/UIFilePreview/filetypes/pdf.tsx +156 -0
- package/src/ui/UIFilePreview/filetypes/ppt.tsx +535 -0
- package/src/ui/UIFilePreview/filetypes/pptExportPdf.ts +523 -0
- package/src/ui/UIFilePreview/filetypes/video.tsx +482 -0
- package/src/ui/UIFilePreview/filetypes/word.tsx +245 -0
- package/src/ui/UIFilePreview/filetypes/wordExportMarkdown.ts +86 -0
- package/src/ui/UIFilePreview/filetypes/wordExportPdf.tsx +427 -0
- package/src/ui/UIFilePreview/index.ts +35 -0
- package/src/ui/UIFilePreview/pdfPagination.ts +143 -0
- package/src/ui/UIFilePreview/previewUtils.tsx +531 -0
- package/src/ui/UIFileUpload.tsx +382 -0
- package/src/ui/UIImageUpload.tsx +195 -0
- package/src/ui/UILoading.tsx +255 -0
- package/src/ui/UILoadingExample.tsx +139 -0
- package/src/ui/UISelect.tsx +105 -0
- package/src/ui/UIToast.tsx +381 -0
- package/src/ui/UIToastExample.tsx +69 -0
- package/src/ui/UIToastSingle.tsx +279 -0
- package/src/ui/UIViewToggle.tsx +64 -0
- package/src/ui/UserSelector/index.tsx +583 -0
- package/src/ui/index.ts +84 -0
- package/src/ui/upload/image.ts +135 -0
- package/src/ui/upload/index.ts +73 -0
- package/src/ui/upload/submitFile.ts +106 -0
- package/src/ui/upload/submitForm.ts +124 -0
- package/src/ui/upload/upload.err.ts +15 -0
- package/src/ui/upload/upload.ts +250 -0
- package/src/utils/CacheDepartmentInfo.ts +372 -0
- package/src/utils/CacheFileContent.ts +315 -0
- package/src/utils/CachePermissionInfo.ts +235 -0
- package/src/utils/CacheRoleInfo.ts +303 -0
- package/src/utils/CacheUserInfo.ts +818 -0
- package/src/utils/CacheViewToggleInfo.ts +182 -0
- package/src/utils/MessageContentUtils.ts +268 -0
- package/src/utils/clipboard.ts +70 -0
- package/src/utils/companyEmail.ts +19 -0
- package/src/utils/forbidDebugger.ts +59 -0
- package/src/utils/formatters.ts +94 -0
- package/src/utils/htmlUtils.tsx +59 -0
- package/src/utils/index.ts +18 -0
- package/src/utils/keyboard.ts +114 -0
- package/src/utils/mammothApi.ts +16 -0
- package/src/utils/polyfills.ts +928 -0
- package/src/utils/regex.ts +14 -0
- package/src/utils/storage.ts +95 -0
- package/src/utils/workerBootstrap.ts +87 -0
- package/dist/api/index.js +0 -1553
- package/dist/contract.js +0 -5
- package/dist/index.js +0 -4492
- package/dist/types/index.js +0 -49
- package/dist/ui/index.css +0 -1876
- package/dist/ui/index.js +0 -22245
- package/dist/utils/index.js +0 -4296
- /package/{dist → src}/styles/ant-modal.css +0 -0
- /package/{dist → src}/styles/ant-spin.css +0 -0
- /package/{dist → src}/styles/ant-table.css +0 -0
- /package/{dist → src}/styles/index.css +0 -0
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { createRoot } from 'react-dom/client';
|
|
3
|
+
import { FileViewer } from '@file-viewer/react';
|
|
4
|
+
import { wordRenderer } from '@file-viewer/renderer-word';
|
|
5
|
+
import { withPolyfillWorkerBootstrap } from '../../../utils/workerBootstrap';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Word(docx/doc)导出 PDF:离屏挂载 file-viewer 分页渲染 → SVG foreignObject 逐页
|
|
9
|
+
* 光栅化 → jsPDF 直接下载(不经过浏览器打印对话框)。
|
|
10
|
+
*
|
|
11
|
+
* 为什么用 foreignObject 而不是 html2canvas:
|
|
12
|
+
* html2canvas 自行重算行盒堆叠,当文档使用「固定行距 < 字体自然高度」时,相邻行
|
|
13
|
+
* 文字会被叠压(浏览器是 overflow visible 完整绘制,紧贴但不重叠);且对
|
|
14
|
+
* border-collapse 表格边框绘制有硬伤。foreignObject 把 DOM 交还给浏览器自身渲染,
|
|
15
|
+
* 行距、两端对齐、加粗、表格边框均与屏幕预览像素级一致,从根本上消除这类差异。
|
|
16
|
+
*
|
|
17
|
+
* 关键点:
|
|
18
|
+
* 1. docx.visualPagination:true 让渲染器按 Word 原生分页产出独立 .docx-page-frame,
|
|
19
|
+
* 每个 frame 内一个固定尺寸的 section.docx(inline width/height 以 pt 写明纸张),
|
|
20
|
+
* 一 frame 对应 PDF 一页;
|
|
21
|
+
* 2. 分页视图中 section 为 absolute + translateX(-50%) scale(k),光栅化前先重置为
|
|
22
|
+
* 静态布局,确保量到纸张原始 CSS 像素尺寸;
|
|
23
|
+
* 3. 把当前文档样式表整体收进 SVG(DOM 保留 class),比逐元素内联计算样式的 SVG
|
|
24
|
+
* 小得多,且伪元素 / CSS 变量 / 字体栈更完整;
|
|
25
|
+
* 4. data URL SVG 出于安全限制无法加载其中的 blob: 图片,序列化前统一把 <img>
|
|
26
|
+
* 转成 data: URL 内联。
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const DOCX_MIME_TYPE = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
|
|
30
|
+
/** 光栅化倍率:文字在 PDF 中仍清晰,单页 canvas 约 2448×3168,低于浏览器尺寸上限 */
|
|
31
|
+
const SCALE = 3;
|
|
32
|
+
/** 分页渲染最长等待时间(ms) */
|
|
33
|
+
const RENDER_TIMEOUT = 90000;
|
|
34
|
+
|
|
35
|
+
export interface ExportWordPdfOptions {
|
|
36
|
+
/** docx/doc 文件(编辑器侧应传入含最新修改的 save() 产物) */
|
|
37
|
+
file: Blob;
|
|
38
|
+
/** 文件名(.docx/.doc 后缀会被替换为 .pdf) */
|
|
39
|
+
fileName: string;
|
|
40
|
+
/** 逐页进度回调(当前页 / 总页数) */
|
|
41
|
+
onProgress?: (page: number, total: number) => void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Word 预览页使用的 FileViewer 配置(连续滚动视图、隐藏内置工具栏) */
|
|
45
|
+
export const buildWordViewerOptions = () => ({
|
|
46
|
+
locale: 'zh-CN' as const,
|
|
47
|
+
rendererMode: 'replace' as const,
|
|
48
|
+
styleIsolation: 'none' as const,
|
|
49
|
+
// FileViewer 的 renderers 类型与各 renderer 包的独立版本类型不完全对齐,原预览页同样用 as any
|
|
50
|
+
renderers: [wordRenderer] as any,
|
|
51
|
+
theme: 'light' as const,
|
|
52
|
+
toolbar: {
|
|
53
|
+
position: 'bottom-right' as const,
|
|
54
|
+
download: false,
|
|
55
|
+
print: false,
|
|
56
|
+
exportHtml: false,
|
|
57
|
+
},
|
|
58
|
+
docx: {
|
|
59
|
+
// ?v= 用于 worker 补丁更新后强制浏览器重新拉取(importScripts 走 HTTP 缓存)
|
|
60
|
+
// docgrid5:docGrid 行距 + 制表位页码 + 分页边界 + 页眉页脚锚定
|
|
61
|
+
// + 浮动对象 align 定位 + 文本框 spAutoFit 自适应
|
|
62
|
+
workerUrl: withPolyfillWorkerBootstrap('/vendor/docx/docx.worker.js?v=docgrid5'),
|
|
63
|
+
workerJsZipUrl: '/vendor/docx/jszip.min.js',
|
|
64
|
+
// 在线预览与导出 PDF 统一使用 Word 原生分页(一 frame 一页)
|
|
65
|
+
visualPagination: true,
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
/** 导出 PDF 使用的配置:与预览一致,均为分页视图(一 frame 一页) */
|
|
70
|
+
const buildWordPdfViewerOptions = () => buildWordViewerOptions();
|
|
71
|
+
|
|
72
|
+
/** 分页视图里 section 是 absolute + translateX(-50%) scale(k),重置为无偏移无缩放布局。
|
|
73
|
+
* 注意必须用 relative 而非 static:页眉/页脚是 position:absolute(见 worker 补丁),
|
|
74
|
+
* 其包含块是最近定位祖先;section 变 static 后包含块会跳到外层宿主(宽度不同),
|
|
75
|
+
* 导致内联计算样式时 left/right/百分比全部按错误宽度解析,克隆光栅化后页脚偏移。 */
|
|
76
|
+
const resetSectionLayout = (sec: HTMLElement) => {
|
|
77
|
+
sec.style.setProperty('position', 'relative', 'important');
|
|
78
|
+
sec.style.setProperty('left', 'auto', 'important');
|
|
79
|
+
sec.style.setProperty('top', 'auto', 'important');
|
|
80
|
+
sec.style.setProperty('transform', 'none', 'important');
|
|
81
|
+
sec.style.setProperty('margin', '0', 'important');
|
|
82
|
+
sec.style.setProperty('box-shadow', 'none', 'important');
|
|
83
|
+
sec.style.setProperty('outline', 'none', 'important');
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/** 把 clone 内的 blob:/远程 <img> 统一转成 data: URL(SVG data URL 内无法加载它们) */
|
|
87
|
+
const inlineCloneImages = async (root: HTMLElement): Promise<void> => {
|
|
88
|
+
await Promise.all(
|
|
89
|
+
Array.from(root.querySelectorAll('img')).map(async (img) => {
|
|
90
|
+
const src = img.getAttribute('src') || '';
|
|
91
|
+
if (!src || src.startsWith('data:')) return;
|
|
92
|
+
try {
|
|
93
|
+
const blob = await fetch(src).then((r) => r.blob());
|
|
94
|
+
const dataUrl = await new Promise<string>((resolve, reject) => {
|
|
95
|
+
const fr = new FileReader();
|
|
96
|
+
fr.onload = () => resolve(fr.result as string);
|
|
97
|
+
fr.onerror = reject;
|
|
98
|
+
fr.readAsDataURL(blob);
|
|
99
|
+
});
|
|
100
|
+
img.setAttribute('src', dataUrl);
|
|
101
|
+
} catch {
|
|
102
|
+
// 单张图片失败不阻断整体导出
|
|
103
|
+
}
|
|
104
|
+
}),
|
|
105
|
+
);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const CSS_URL_RE = /url\(\s*(['"]?)([^'")]+)\1\s*\)/g;
|
|
109
|
+
|
|
110
|
+
/** 不内联的计算样式:动画/过渡(光栅化瞬间不应运行) */
|
|
111
|
+
const SKIP_PROP_RE = /^(animation|transition)(-|$)/;
|
|
112
|
+
|
|
113
|
+
const readAllCss = (cs: CSSStyleDeclaration, skip?: string): string => {
|
|
114
|
+
let css = '';
|
|
115
|
+
for (let i = 0; i < cs.length; i++) {
|
|
116
|
+
const prop = cs[i];
|
|
117
|
+
if (prop === skip || SKIP_PROP_RE.test(prop)) continue;
|
|
118
|
+
const val = cs.getPropertyValue(prop);
|
|
119
|
+
if (val) css += `${prop}:${val};`;
|
|
120
|
+
}
|
|
121
|
+
return css;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/** 把 blob:/http(s):/相对路径 的 CSS url() 引用统一内联为 data URL(SVG 作为 <img> 加载时禁止外链) */
|
|
125
|
+
const inlineCssUrls = async (cssTexts: string[]): Promise<Map<string, string>> => {
|
|
126
|
+
const urls = new Set<string>();
|
|
127
|
+
cssTexts.forEach((text) => {
|
|
128
|
+
let m: RegExpExecArray | null;
|
|
129
|
+
CSS_URL_RE.lastIndex = 0;
|
|
130
|
+
while ((m = CSS_URL_RE.exec(text))) {
|
|
131
|
+
const u = m[2];
|
|
132
|
+
if (u && !u.startsWith('data:')) urls.add(u);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
const map = new Map<string, string>();
|
|
136
|
+
await Promise.all(
|
|
137
|
+
Array.from(urls).map(async (u) => {
|
|
138
|
+
try {
|
|
139
|
+
const blob = await fetch(u).then((r) => r.blob());
|
|
140
|
+
const dataUrl = await new Promise<string>((resolve, reject) => {
|
|
141
|
+
const fr = new FileReader();
|
|
142
|
+
fr.onload = () => resolve(fr.result as string);
|
|
143
|
+
fr.onerror = reject;
|
|
144
|
+
fr.readAsDataURL(blob);
|
|
145
|
+
});
|
|
146
|
+
map.set(u, dataUrl);
|
|
147
|
+
} catch {
|
|
148
|
+
// 单个资源失败不阻断(保留原引用时仅该背景缺失)
|
|
149
|
+
}
|
|
150
|
+
}),
|
|
151
|
+
);
|
|
152
|
+
return map;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const replaceCssUrls = (cssText: string, map: Map<string, string>): string =>
|
|
156
|
+
cssText.replace(CSS_URL_RE, (full, _quote: string, u: string) => {
|
|
157
|
+
const dataUrl = map.get(u);
|
|
158
|
+
return dataUrl ? `url("${dataUrl}")` : full;
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* 把 src 子树的全部计算样式逐元素内联到 dst,并返回伪元素(列表符号/TOC 前导点
|
|
163
|
+
* 由 ::before 承载)所需的 CSS 文本。
|
|
164
|
+
*
|
|
165
|
+
* 为什么「全量内联」而非白名单:渲染器把关键布局放在样式表/内联混合处,例如
|
|
166
|
+
* section.docx 是样式表定义的 display:flex; flex-flow:column nowrap,文本框是内联
|
|
167
|
+
* flex 列布局——漏掉任何一个 flex/grid 属性,克隆到独立 SVG 后布局就会崩(实测
|
|
168
|
+
* 漏 flex-direction 时 section 退化为 row,后续流内兄弟被横向推到纸外裁切)。
|
|
169
|
+
* 全量计算样式随克隆走,与源元素渲染必然一致;同时:
|
|
170
|
+
* - 所有 CSS url()(页面背景 blob:、列表符号等)统一转 data URL;
|
|
171
|
+
* - CSS 自定义属性(--docx-*,含符号图片变量)也在枚举内,var() 在克隆中可解析;
|
|
172
|
+
* - computed 值由 XMLSerializer 自动 XML 转义,无需手工处理特殊字符。
|
|
173
|
+
*/
|
|
174
|
+
const inlineStyles = async (src: Element, dst: Element): Promise<string> => {
|
|
175
|
+
interface Pending {
|
|
176
|
+
el: Element;
|
|
177
|
+
css: string;
|
|
178
|
+
}
|
|
179
|
+
const pending: Pending[] = [];
|
|
180
|
+
const pseudoBodies: string[] = [];
|
|
181
|
+
const pseudoRules: string[] = [];
|
|
182
|
+
let pseudoSeq = 0;
|
|
183
|
+
const win = src.ownerDocument.defaultView ?? window;
|
|
184
|
+
|
|
185
|
+
const collectPseudo = (s: Element, d: Element, which: '::before' | '::after') => {
|
|
186
|
+
const cs = win.getComputedStyle(s, which);
|
|
187
|
+
const content = cs.getPropertyValue('content');
|
|
188
|
+
if (!content || content === 'none' || content === 'normal') return;
|
|
189
|
+
const key = which.slice(2); // before / after
|
|
190
|
+
const id = `wpe-${++pseudoSeq}`;
|
|
191
|
+
d.setAttribute(`data-pseudo-${key}`, id);
|
|
192
|
+
const body = `content:${content};${readAllCss(cs, 'content')}`;
|
|
193
|
+
pseudoBodies.push(body);
|
|
194
|
+
pseudoRules.push(`[data-pseudo-${key}="${id}"]${which}{${body}}`);
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const collect = (s: Element, d: Element) => {
|
|
198
|
+
pending.push({ el: d, css: readAllCss(win.getComputedStyle(s)) });
|
|
199
|
+
collectPseudo(s, d, '::before');
|
|
200
|
+
collectPseudo(s, d, '::after');
|
|
201
|
+
const sk = s.children;
|
|
202
|
+
const dk = d.children;
|
|
203
|
+
for (let i = 0; i < sk.length; i++) collect(sk[i], dk[i]);
|
|
204
|
+
};
|
|
205
|
+
collect(src, dst);
|
|
206
|
+
|
|
207
|
+
const urlMap = await inlineCssUrls([
|
|
208
|
+
...pending.map((p) => p.css),
|
|
209
|
+
...pseudoBodies,
|
|
210
|
+
]);
|
|
211
|
+
pending.forEach(({ el, css }) => {
|
|
212
|
+
el.setAttribute('style', replaceCssUrls(css, urlMap));
|
|
213
|
+
});
|
|
214
|
+
return pseudoRules
|
|
215
|
+
.map((rule) => replaceCssUrls(rule, urlMap))
|
|
216
|
+
.join('\n');
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* 用 SVG foreignObject 把一个分页 section 光栅化为 canvas。
|
|
221
|
+
* 排版完全由浏览器完成,行距 / 两端对齐 / 加粗 / 表格边框与屏幕预览一致。
|
|
222
|
+
*/
|
|
223
|
+
const captureSection = async (section: HTMLElement): Promise<HTMLCanvasElement> => {
|
|
224
|
+
// 静态化原 section 以便量取纸张原始 CSS 像素尺寸(离屏宿主,不影响用户视图)
|
|
225
|
+
resetSectionLayout(section);
|
|
226
|
+
const rect = section.getBoundingClientRect();
|
|
227
|
+
const w = Math.max(1, Math.ceil(rect.width));
|
|
228
|
+
const h = Math.max(1, Math.ceil(rect.height));
|
|
229
|
+
|
|
230
|
+
const clone = section.cloneNode(true) as HTMLElement;
|
|
231
|
+
clone.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml');
|
|
232
|
+
await inlineCloneImages(clone);
|
|
233
|
+
// 全量内联计算样式(基于已静态化的原 section,含 CSS 背景图与列表符号内联),根节点保持静态
|
|
234
|
+
const pseudoCss = await inlineStyles(section, clone);
|
|
235
|
+
resetSectionLayout(clone);
|
|
236
|
+
|
|
237
|
+
const xhtml = new XMLSerializer().serializeToString(clone);
|
|
238
|
+
// 伪元素规则用 CDATA 包裹(content 文本可能含特殊字符)
|
|
239
|
+
const pseudoStyle = pseudoCss ? `<style><![CDATA[${pseudoCss.replace(/\]\]>/g, ']] >')}]]></style>` : '';
|
|
240
|
+
const svg =
|
|
241
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="${w * SCALE}" height="${h * SCALE}">` +
|
|
242
|
+
`<g transform="scale(${SCALE})">` +
|
|
243
|
+
`<foreignObject x="0" y="0" width="${w}" height="${h}">` +
|
|
244
|
+
`<body xmlns="http://www.w3.org/1999/xhtml" style="margin:0;padding:0;background:#ffffff">` +
|
|
245
|
+
`${pseudoStyle}${xhtml}</body>` +
|
|
246
|
+
`</foreignObject></g></svg>`;
|
|
247
|
+
|
|
248
|
+
// 必须用 data URL 而非 Blob URL:Blob URL 加载的 SVG 会被浏览器视为跨 origin,
|
|
249
|
+
// drawImage 后污染 canvas 导致 toDataURL 抛 SecurityError;data URL 不污染。
|
|
250
|
+
const url = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
|
|
251
|
+
const img = new Image();
|
|
252
|
+
await new Promise<void>((resolve, reject) => {
|
|
253
|
+
img.onload = () => resolve();
|
|
254
|
+
img.onerror = () => {
|
|
255
|
+
// 诊断:XML 解析错误 + 残留外链(SVG 作为 <img> 加载时禁止任何 blob:/http 外链)
|
|
256
|
+
const parts: string[] = [];
|
|
257
|
+
try {
|
|
258
|
+
const parsed = new DOMParser().parseFromString(svg, 'image/svg+xml');
|
|
259
|
+
const err = parsed.querySelector('parsererror');
|
|
260
|
+
if (err) parts.push(`XML错误: ${(err.textContent || '').slice(0, 150)}`);
|
|
261
|
+
} catch { /* 忽略诊断异常 */ }
|
|
262
|
+
const blobRefs = (svg.match(/blob:/g) || []).length;
|
|
263
|
+
const httpRefs = (svg.match(/url\(\s*['"]?https?:/g) || []).length;
|
|
264
|
+
if (blobRefs || httpRefs) parts.push(`残留外链 blob:${blobRefs} http:${httpRefs}`);
|
|
265
|
+
parts.push(`SVG大小:${(svg.length / 1024).toFixed(0)}KB`);
|
|
266
|
+
reject(new Error(`页面光栅化失败(${parts.join(';')})`));
|
|
267
|
+
};
|
|
268
|
+
img.src = url;
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
const canvas = document.createElement('canvas');
|
|
272
|
+
canvas.width = w * SCALE;
|
|
273
|
+
canvas.height = h * SCALE;
|
|
274
|
+
const ctx = canvas.getContext('2d');
|
|
275
|
+
if (!ctx) throw new Error('无法创建绘图上下文');
|
|
276
|
+
ctx.fillStyle = '#ffffff';
|
|
277
|
+
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
278
|
+
ctx.drawImage(img, 0, 0);
|
|
279
|
+
return canvas;
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
/** 等待分页渲染完成:frame 数稳定(600ms 内不再增长)且每页 section 已有尺寸 */
|
|
283
|
+
const waitForPagedFrames = (host: HTMLElement): Promise<HTMLElement[]> =>
|
|
284
|
+
new Promise((resolve, reject) => {
|
|
285
|
+
const start = Date.now();
|
|
286
|
+
let lastCount = -1;
|
|
287
|
+
let stableSince = 0;
|
|
288
|
+
const timer = window.setInterval(() => {
|
|
289
|
+
const frames = Array.from(host.querySelectorAll<HTMLElement>('.docx-page-frame'));
|
|
290
|
+
const ready = frames.filter((frame) => {
|
|
291
|
+
const sec = frame.querySelector<HTMLElement>('section.docx');
|
|
292
|
+
return sec && sec.getBoundingClientRect().height > 100;
|
|
293
|
+
});
|
|
294
|
+
if (ready.length >= 1 && ready.length === lastCount) {
|
|
295
|
+
if (!stableSince) stableSince = Date.now();
|
|
296
|
+
// 页数稳定 600ms 视为渲染完成(最后一页可能稍慢)
|
|
297
|
+
if (Date.now() - stableSince >= 600) {
|
|
298
|
+
window.clearInterval(timer);
|
|
299
|
+
resolve(frames);
|
|
300
|
+
}
|
|
301
|
+
} else {
|
|
302
|
+
stableSince = 0;
|
|
303
|
+
}
|
|
304
|
+
lastCount = ready.length;
|
|
305
|
+
if (Date.now() - start > RENDER_TIMEOUT) {
|
|
306
|
+
window.clearInterval(timer);
|
|
307
|
+
reject(new Error('Word 文档渲染超时'));
|
|
308
|
+
}
|
|
309
|
+
}, 150);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
/** 离屏挂载分页视图,返回卸载函数 */
|
|
313
|
+
const mountPagedViewer = (file: File, fileName: string): {
|
|
314
|
+
host: HTMLElement;
|
|
315
|
+
unmount: () => void;
|
|
316
|
+
} => {
|
|
317
|
+
const host = document.createElement('div');
|
|
318
|
+
host.setAttribute('data-word-export-root', 'true');
|
|
319
|
+
Object.assign(host.style, {
|
|
320
|
+
position: 'fixed',
|
|
321
|
+
top: '0',
|
|
322
|
+
left: '-100000px',
|
|
323
|
+
width: '1400px',
|
|
324
|
+
background: '#ffffff',
|
|
325
|
+
pointerEvents: 'none',
|
|
326
|
+
} as Partial<CSSStyleDeclaration>);
|
|
327
|
+
document.body.appendChild(host);
|
|
328
|
+
|
|
329
|
+
const root = createRoot(host);
|
|
330
|
+
root.render(<FileViewer file={file} name={fileName} options={buildWordPdfViewerOptions()} />);
|
|
331
|
+
|
|
332
|
+
return {
|
|
333
|
+
host,
|
|
334
|
+
unmount: () => {
|
|
335
|
+
root.unmount();
|
|
336
|
+
host.remove();
|
|
337
|
+
},
|
|
338
|
+
};
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* 取纸张的原始尺寸(pt)。渲染器在 section inline style 上以 pt 写明 Word 纸张大小
|
|
343
|
+
* (如 612pt×792pt),不受视觉 scale(k) 影响;单位异常时退回 CSS 像素换算。
|
|
344
|
+
*/
|
|
345
|
+
const getSectionPaperSizePt = (section: HTMLElement): { wPt: number; hPt: number } => {
|
|
346
|
+
const parse = (value: string, rectPx: number): number => {
|
|
347
|
+
const pt = /^([\d.]+)pt$/i.exec(value);
|
|
348
|
+
if (pt) return parseFloat(pt[1]);
|
|
349
|
+
const px = /^([\d.]+)px$/i.exec(value);
|
|
350
|
+
if (px) return parseFloat(px[1]) * 0.75;
|
|
351
|
+
return rectPx * 0.75;
|
|
352
|
+
};
|
|
353
|
+
const rect = section.getBoundingClientRect();
|
|
354
|
+
return {
|
|
355
|
+
wPt: parse(section.style.width || '', rect.width),
|
|
356
|
+
hPt: parse(section.style.height || '', rect.height),
|
|
357
|
+
};
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Word 文档导出 PDF(SVG foreignObject 逐页光栅化 → jsPDF 直接下载)。
|
|
362
|
+
* @returns 生成的 PDF 文件名
|
|
363
|
+
*/
|
|
364
|
+
export const exportWordPdf = async ({
|
|
365
|
+
file,
|
|
366
|
+
fileName,
|
|
367
|
+
onProgress,
|
|
368
|
+
}: ExportWordPdfOptions): Promise<string> => {
|
|
369
|
+
const baseName = /\.(docx|docm|dotx|dotm|doc|dot)$/i.test(fileName)
|
|
370
|
+
? fileName.replace(/\.(docx|docm|dotx|dotm|doc|dot)$/i, '')
|
|
371
|
+
: fileName;
|
|
372
|
+
const docxName = `${baseName}.docx`;
|
|
373
|
+
const pdfName = `${baseName}.pdf`;
|
|
374
|
+
const docxFile = file instanceof File ? file : new File([file], docxName, { type: DOCX_MIME_TYPE });
|
|
375
|
+
|
|
376
|
+
// jsPDF 仅在点击导出时加载(vite 会把它归入 vendor-md-pdf-export chunk)
|
|
377
|
+
const { jsPDF } = await import('jspdf');
|
|
378
|
+
|
|
379
|
+
const { host, unmount } = mountPagedViewer(docxFile, docxName);
|
|
380
|
+
try {
|
|
381
|
+
const frames = await waitForPagedFrames(host);
|
|
382
|
+
// 双 raf:等字体、图片解码与最后布局提交
|
|
383
|
+
await new Promise<void>((resolve) =>
|
|
384
|
+
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
const sections = frames
|
|
388
|
+
.map((frame) => frame.querySelector<HTMLElement>('section.docx'))
|
|
389
|
+
.filter((sec): sec is HTMLElement => Boolean(sec));
|
|
390
|
+
if (sections.length === 0) {
|
|
391
|
+
throw new Error('未获取到可导出的文档页面');
|
|
392
|
+
}
|
|
393
|
+
const total = sections.length;
|
|
394
|
+
|
|
395
|
+
// PDF 页面尺寸按 Word 纸张原始 pt 尺寸(section inline width/height),
|
|
396
|
+
// 与光栅化内容(静态化后恢复原始尺寸)严格一致,图片铺满整页(Word 页面本身含页边距)。
|
|
397
|
+
const firstSize = getSectionPaperSizePt(sections[0]);
|
|
398
|
+
const pdf = new jsPDF({
|
|
399
|
+
orientation: firstSize.wPt > firstSize.hPt ? 'landscape' : 'portrait',
|
|
400
|
+
unit: 'pt',
|
|
401
|
+
format: [firstSize.wPt, firstSize.hPt],
|
|
402
|
+
compress: true,
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
for (let i = 0; i < total; i++) {
|
|
406
|
+
onProgress?.(i + 1, total);
|
|
407
|
+
const section = sections[i];
|
|
408
|
+
const { wPt, hPt } = getSectionPaperSizePt(section);
|
|
409
|
+
|
|
410
|
+
const canvas = await captureSection(section);
|
|
411
|
+
const dataUrl = canvas.toDataURL('image/png');
|
|
412
|
+
if (i > 0) {
|
|
413
|
+
pdf.addPage([wPt, hPt], wPt > hPt ? 'landscape' : 'portrait');
|
|
414
|
+
}
|
|
415
|
+
pdf.addImage(dataUrl, 'PNG', 0, 0, wPt, hPt, undefined, 'FAST');
|
|
416
|
+
// 及时释放 canvas 位图内存(多页大文档)
|
|
417
|
+
canvas.width = 0;
|
|
418
|
+
canvas.height = 0;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
pdf.save(pdfName);
|
|
422
|
+
return pdfName;
|
|
423
|
+
} finally {
|
|
424
|
+
// 卸载离屏宿主,避免污染日常页面
|
|
425
|
+
unmount();
|
|
426
|
+
}
|
|
427
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
|
|
2
|
+
/**
|
|
3
|
+
* UIFilePreview 使用示例
|
|
4
|
+
*
|
|
5
|
+
* @example 获取文件预览类型
|
|
6
|
+
* const previewType = getFilePreviewType('document.pdf'); // 'pdf'
|
|
7
|
+
*
|
|
8
|
+
* @example 获取文件完整URL
|
|
9
|
+
* const fileUrl = getFileUrl('/files/document.pdf'); // 'https://example.com/files/document.pdf'
|
|
10
|
+
*
|
|
11
|
+
* @example 获取文件图标
|
|
12
|
+
* const icon = getFileIcon({ fileName: 'report.pdf', fileType: 'application/pdf', size: 24 });
|
|
13
|
+
*
|
|
14
|
+
* @example 下载文件
|
|
15
|
+
* await downloadFile('/files/report.pdf');
|
|
16
|
+
*
|
|
17
|
+
* @example 获取文件Buffer(带进度回调)
|
|
18
|
+
* const abortController = new AbortController();
|
|
19
|
+
* const result = await fetchFileBuffer('/files/report.pdf', abortController, (percent) => {
|
|
20
|
+
* console.log(`下载进度: ${percent}%`);
|
|
21
|
+
* });
|
|
22
|
+
* const { buffer, headers, status } = result;
|
|
23
|
+
*/
|
|
24
|
+
import { UIFilePreview } from './UIFilePreview';
|
|
25
|
+
import { getFileUrl, getFilePreviewType, downloadFile, fetchFileBuffer, getFileIcon } from './previewUtils';
|
|
26
|
+
|
|
27
|
+
export { UIFilePreview };
|
|
28
|
+
|
|
29
|
+
export const uiFilePreview = {
|
|
30
|
+
downloadFile,
|
|
31
|
+
fetchFileBuffer,
|
|
32
|
+
getFileIcon,
|
|
33
|
+
getFileUrl,
|
|
34
|
+
getFilePreviewType,
|
|
35
|
+
};
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PDF 导出的分页计算(Markdown 导出与块文档导出共用)。
|
|
3
|
+
*
|
|
4
|
+
* 分页优先落在内容块边界上;当单个块放不下时退到"行级"边界(文本行、表格行、图片等),
|
|
5
|
+
* 保证不会把一行文字从中间切开——这是 html2canvas 按 y 坐标硬切最容易出现的问题。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface PdfBlockBound {
|
|
9
|
+
/** 块在容器内的纵向起点 */
|
|
10
|
+
top: number;
|
|
11
|
+
/** 块在容器内的纵向终点 */
|
|
12
|
+
bottom: number;
|
|
13
|
+
/** 块内部可用的切分位置(相对容器),按升序排列 */
|
|
14
|
+
lineTops: number[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface PdfPageSlice {
|
|
18
|
+
y: number;
|
|
19
|
+
height: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** 收集块内部的行起始位置:文本按行盒取,替换元素(图片/表格行等)按自身矩形取 */
|
|
23
|
+
const collectInnerTops = (
|
|
24
|
+
block: Element,
|
|
25
|
+
originTop: number,
|
|
26
|
+
min: number,
|
|
27
|
+
max: number,
|
|
28
|
+
): number[] => {
|
|
29
|
+
const tops = new Set<number>();
|
|
30
|
+
|
|
31
|
+
const push = (top: number, bottom: number) => {
|
|
32
|
+
if (!Number.isFinite(top) || bottom - top <= 0.5) return;
|
|
33
|
+
// 只保留块内部的换行点:以块自身首尾为边界,避免把一个单行块切成两页
|
|
34
|
+
if (top <= min + 1 || top >= max - 1) return;
|
|
35
|
+
tops.add(Math.round(top * 2) / 2);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT);
|
|
40
|
+
let node = walker.nextNode();
|
|
41
|
+
while (node) {
|
|
42
|
+
const value = node.nodeValue;
|
|
43
|
+
if (value && value.trim()) {
|
|
44
|
+
const range = document.createRange();
|
|
45
|
+
range.selectNodeContents(node);
|
|
46
|
+
Array.from(range.getClientRects()).forEach((rect) =>
|
|
47
|
+
push(rect.top - originTop, rect.bottom - originTop),
|
|
48
|
+
);
|
|
49
|
+
if (typeof range.detach === 'function') {
|
|
50
|
+
range.detach();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
node = walker.nextNode();
|
|
54
|
+
}
|
|
55
|
+
} catch {
|
|
56
|
+
// 取不到行盒时退回块边界分页
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
block.querySelectorAll('img, hr, tr, canvas, svg, video, figure').forEach((el) => {
|
|
60
|
+
const rect = el.getBoundingClientRect();
|
|
61
|
+
push(rect.top - originTop, rect.bottom - originTop);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
return Array.from(tops).sort((a, b) => a - b);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** 计算各内容块在容器内的纵向区间与可切分位置 */
|
|
68
|
+
export const measureBlocks = (root: HTMLElement, blocks: Element[]): PdfBlockBound[] => {
|
|
69
|
+
const rootTop = root.getBoundingClientRect().top;
|
|
70
|
+
|
|
71
|
+
return blocks
|
|
72
|
+
.map((block) => {
|
|
73
|
+
const rect = block.getBoundingClientRect();
|
|
74
|
+
const top = rect.top - rootTop;
|
|
75
|
+
const bottom = rect.bottom - rootTop;
|
|
76
|
+
return {
|
|
77
|
+
top,
|
|
78
|
+
bottom,
|
|
79
|
+
lineTops: collectInnerTops(block, rootTop, top, bottom),
|
|
80
|
+
};
|
|
81
|
+
})
|
|
82
|
+
.filter((bound) => bound.bottom - bound.top > 0.5);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/** 在 (pageStart, limit] 区间内找出最靠后的行边界 */
|
|
86
|
+
const pickCut = (block: PdfBlockBound, pageStart: number, limit: number): number | null => {
|
|
87
|
+
let cut: number | null = null;
|
|
88
|
+
for (const top of block.lineTops) {
|
|
89
|
+
if (top > limit) break;
|
|
90
|
+
if (top > pageStart + 1) cut = top;
|
|
91
|
+
}
|
|
92
|
+
return cut;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 按块/行边界把内容划分为若干页。
|
|
97
|
+
* 返回的每一段都是 [y, y + height],互不重叠,且不会切断文本行。
|
|
98
|
+
*/
|
|
99
|
+
export const buildPageSlices = (bounds: PdfBlockBound[], pageHeight: number): PdfPageSlice[] => {
|
|
100
|
+
const slices: PdfPageSlice[] = [];
|
|
101
|
+
if (bounds.length === 0) return slices;
|
|
102
|
+
|
|
103
|
+
let pageStart = Math.max(0, bounds[0].top);
|
|
104
|
+
let pageEnd = pageStart;
|
|
105
|
+
|
|
106
|
+
for (const block of bounds) {
|
|
107
|
+
let top = block.top;
|
|
108
|
+
|
|
109
|
+
while (block.bottom - pageStart > pageHeight) {
|
|
110
|
+
const limit = pageStart + pageHeight;
|
|
111
|
+
const cut = pickCut(block, pageStart, limit);
|
|
112
|
+
|
|
113
|
+
// 1. 本页能容纳该块的开头若干行:在最后一行之前断页
|
|
114
|
+
if (cut !== null) {
|
|
115
|
+
slices.push({ y: pageStart, height: cut - pageStart });
|
|
116
|
+
pageStart = cut;
|
|
117
|
+
top = Math.max(top, cut);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// 2. 该块在本页还没开始、且第一行也放不下:整块移到下一页
|
|
122
|
+
if (top > pageStart + 0.5) {
|
|
123
|
+
slices.push({ y: pageStart, height: top - pageStart });
|
|
124
|
+
pageStart = top;
|
|
125
|
+
top = block.top;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 3. 单块高于整页且没有可用行边界(大图/大表格等):只能按页高切分
|
|
130
|
+
slices.push({ y: pageStart, height: pageHeight });
|
|
131
|
+
pageStart += pageHeight;
|
|
132
|
+
top = pageStart;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
pageEnd = Math.max(pageEnd, block.bottom);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (pageEnd > pageStart) {
|
|
139
|
+
slices.push({ y: pageStart, height: pageEnd - pageStart });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return slices;
|
|
143
|
+
};
|