@courtifyai/docx-render 1.0.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.
@@ -0,0 +1,379 @@
1
+ /**
2
+ * 嵌入字体加载器
3
+ * 从 DOCX 文件中加载嵌入字体,支持字体解密和 CSS @font-face 注入
4
+ */
5
+
6
+ import JSZip from 'jszip'
7
+ import {
8
+ IFontTable,
9
+ IFontDeclaration,
10
+ IEmbedFontRef,
11
+ ILoadedEmbedFont,
12
+ IRelationship,
13
+ TEmbedFontType,
14
+ RELATIONSHIP_TYPES,
15
+ } from '../types'
16
+
17
+ /**
18
+ * 嵌入字体加载器配置
19
+ */
20
+ export interface IFontLoaderOptions {
21
+ /** 是否自动注入 CSS @font-face */
22
+ injectStyles?: boolean
23
+ /** 自定义样式容器(默认为 document.head) */
24
+ styleContainer?: HTMLElement
25
+ /** 字体加载超时时间(毫秒) */
26
+ timeout?: number
27
+ }
28
+
29
+ const DEFAULT_OPTIONS: IFontLoaderOptions = {
30
+ injectStyles: true,
31
+ timeout: 10000,
32
+ }
33
+
34
+ /**
35
+ * 加载嵌入字体
36
+ * @param zip DOCX 文件的 JSZip 实例
37
+ * @param fontTable 字体表
38
+ * @param fontRels fontTable.xml 的关系文件内容(可选)
39
+ * @param options 配置选项
40
+ * @returns 已加载的嵌入字体数组
41
+ */
42
+ export async function loadEmbeddedFonts(
43
+ zip: JSZip,
44
+ fontTable: IFontTable,
45
+ fontRels: IRelationship[],
46
+ options: IFontLoaderOptions = {}
47
+ ): Promise<ILoadedEmbedFont[]> {
48
+ const opts = { ...DEFAULT_OPTIONS, ...options }
49
+ const loadedFonts: ILoadedEmbedFont[] = []
50
+
51
+ // 遍历所有有嵌入字体的字体声明
52
+ for (const font of fontTable.fonts) {
53
+ if (font.embedFontRefs.length === 0) continue
54
+
55
+ for (const embedRef of font.embedFontRefs) {
56
+ try {
57
+ const loadedFont = await loadSingleEmbeddedFont(
58
+ zip,
59
+ font,
60
+ embedRef,
61
+ fontRels,
62
+ opts.timeout || DEFAULT_OPTIONS.timeout!
63
+ )
64
+
65
+ if (loadedFont) {
66
+ loadedFonts.push(loadedFont)
67
+ }
68
+ } catch (e) {
69
+ console.warn(`加载嵌入字体失败: ${font.name} (${embedRef.type})`, e)
70
+ }
71
+ }
72
+ }
73
+
74
+ // 注入 CSS @font-face
75
+ if (opts.injectStyles && loadedFonts.length > 0) {
76
+ injectFontFaceStyles(loadedFonts, opts.styleContainer)
77
+ }
78
+
79
+ return loadedFonts
80
+ }
81
+
82
+ /**
83
+ * 加载单个嵌入字体
84
+ */
85
+ async function loadSingleEmbeddedFont(
86
+ zip: JSZip,
87
+ font: IFontDeclaration,
88
+ embedRef: IEmbedFontRef,
89
+ fontRels: IRelationship[],
90
+ timeout: number
91
+ ): Promise<ILoadedEmbedFont | null> {
92
+ // 根据关系 ID 查找字体文件路径
93
+ const rel = fontRels.find(r => r.id === embedRef.id)
94
+ if (!rel) {
95
+ console.warn(`找不到嵌入字体关系: ${embedRef.id}`)
96
+ return null
97
+ }
98
+
99
+ // 构建字体文件路径
100
+ const fontPath = `word/${rel.target}`
101
+ const fontFile = zip.file(fontPath)
102
+
103
+ if (!fontFile) {
104
+ console.warn(`找不到嵌入字体文件: ${fontPath}`)
105
+ return null
106
+ }
107
+
108
+ // 读取字体文件
109
+ const fontData = await Promise.race([
110
+ fontFile.async('arraybuffer'),
111
+ new Promise<never>((_, reject) =>
112
+ setTimeout(() => reject(new Error('字体加载超时')), timeout)
113
+ ),
114
+ ])
115
+
116
+ // 如果有密钥,需要解密字体
117
+ let decryptedData = fontData
118
+ if (embedRef.key) {
119
+ decryptedData = decryptEmbeddedFont(fontData, embedRef.key)
120
+ }
121
+
122
+ // 检测字体格式
123
+ const format = detectFontFormat(new Uint8Array(decryptedData))
124
+
125
+ // 转换为 Data URL
126
+ const mimeType = getFontMimeType(format)
127
+ const blob = new Blob([decryptedData], { type: mimeType })
128
+ const dataUrl = await blobToDataUrl(blob)
129
+
130
+ return {
131
+ fontName: font.name,
132
+ type: embedRef.type,
133
+ dataUrl,
134
+ format,
135
+ }
136
+ }
137
+
138
+ /**
139
+ * 解密嵌入字体
140
+ * OOXML 嵌入字体使用 XOR 加密,密钥为 32 位 GUID
141
+ * @param data 加密的字体数据
142
+ * @param keyString 密钥字符串(格式:{xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx})
143
+ * @returns 解密后的字体数据
144
+ */
145
+ function decryptEmbeddedFont(data: ArrayBuffer, keyString: string): ArrayBuffer {
146
+ // 解析密钥 GUID(移除花括号和连字符)
147
+ const keyHex = keyString.replace(/[{}-]/g, '')
148
+ if (keyHex.length !== 32) {
149
+ console.warn('无效的字体密钥格式:', keyString)
150
+ return data
151
+ }
152
+
153
+ // 将 GUID 转换为 16 字节密钥数组
154
+ // 注意:GUID 的字节顺序需要特殊处理
155
+ const key = new Uint8Array(16)
156
+
157
+ // 前三组需要反转字节顺序(小端序)
158
+ // 第一组:8 个字符 = 4 字节,反转
159
+ for (let i = 0; i < 4; i++) {
160
+ key[3 - i] = parseInt(keyHex.substr(i * 2, 2), 16)
161
+ }
162
+ // 第二组:4 个字符 = 2 字节,反转
163
+ for (let i = 0; i < 2; i++) {
164
+ key[5 - i] = parseInt(keyHex.substr(8 + i * 2, 2), 16)
165
+ }
166
+ // 第三组:4 个字符 = 2 字节,反转
167
+ for (let i = 0; i < 2; i++) {
168
+ key[7 - i] = parseInt(keyHex.substr(12 + i * 2, 2), 16)
169
+ }
170
+ // 后两组保持原顺序
171
+ for (let i = 0; i < 8; i++) {
172
+ key[8 + i] = parseInt(keyHex.substr(16 + i * 2, 2), 16)
173
+ }
174
+
175
+ // XOR 解密:每 16 字节与密钥进行 XOR
176
+ const input = new Uint8Array(data)
177
+ const output = new Uint8Array(input.length)
178
+
179
+ // 只解密前 32 字节(OOXML 规范)
180
+ const decryptLength = Math.min(32, input.length)
181
+
182
+ for (let i = 0; i < decryptLength; i++) {
183
+ output[i] = input[i] ^ key[i % 16]
184
+ }
185
+
186
+ // 剩余部分保持不变
187
+ for (let i = decryptLength; i < input.length; i++) {
188
+ output[i] = input[i]
189
+ }
190
+
191
+ return output.buffer
192
+ }
193
+
194
+ /**
195
+ * 检测字体格式
196
+ */
197
+ function detectFontFormat(data: Uint8Array): 'opentype' | 'truetype' | 'embedded-opentype' {
198
+ if (data.length < 4) {
199
+ return 'truetype'
200
+ }
201
+
202
+ // OpenType (CFF) 签名: 'OTTO'
203
+ if (data[0] === 0x4F && data[1] === 0x54 && data[2] === 0x54 && data[3] === 0x4F) {
204
+ return 'opentype'
205
+ }
206
+
207
+ // TrueType 签名: 0x00010000 或 'true'
208
+ if ((data[0] === 0x00 && data[1] === 0x01 && data[2] === 0x00 && data[3] === 0x00) ||
209
+ (data[0] === 0x74 && data[1] === 0x72 && data[2] === 0x75 && data[3] === 0x65)) {
210
+ return 'truetype'
211
+ }
212
+
213
+ // EOT 签名
214
+ if (data[0] === 0x00 && data[1] === 0x00 && data[2] === 0x01) {
215
+ return 'embedded-opentype'
216
+ }
217
+
218
+ // 默认假设为 TrueType
219
+ return 'truetype'
220
+ }
221
+
222
+ /**
223
+ * 获取字体 MIME 类型
224
+ */
225
+ function getFontMimeType(format: string): string {
226
+ switch (format) {
227
+ case 'opentype':
228
+ return 'font/otf'
229
+ case 'truetype':
230
+ return 'font/ttf'
231
+ case 'embedded-opentype':
232
+ return 'application/vnd.ms-fontobject'
233
+ default:
234
+ return 'font/ttf'
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Blob 转 Data URL
240
+ */
241
+ function blobToDataUrl(blob: Blob): Promise<string> {
242
+ return new Promise((resolve, reject) => {
243
+ const reader = new FileReader()
244
+ reader.onloadend = () => resolve(reader.result as string)
245
+ reader.onerror = reject
246
+ reader.readAsDataURL(blob)
247
+ })
248
+ }
249
+
250
+ /**
251
+ * 注入 CSS @font-face 样式
252
+ */
253
+ function injectFontFaceStyles(
254
+ fonts: ILoadedEmbedFont[],
255
+ container?: HTMLElement
256
+ ): void {
257
+ const styleEl = document.createElement('style')
258
+ styleEl.setAttribute('data-docx-fonts', 'true')
259
+
260
+ const cssRules = fonts.map(font => buildFontFaceRule(font)).join('\n')
261
+ styleEl.textContent = cssRules
262
+
263
+ const target = container || document.head
264
+
265
+ // 移除旧的字体样式
266
+ const oldStyle = target.querySelector('style[data-docx-fonts]')
267
+ if (oldStyle) {
268
+ oldStyle.remove()
269
+ }
270
+
271
+ target.appendChild(styleEl)
272
+ }
273
+
274
+ /**
275
+ * 构建单个 @font-face 规则
276
+ */
277
+ function buildFontFaceRule(font: ILoadedEmbedFont): string {
278
+ const fontWeight = getFontWeight(font.type)
279
+ const fontStyle = getFontStyle(font.type)
280
+ const format = getFontFormatString(font.format)
281
+
282
+ return `@font-face {
283
+ font-family: "${escapeFontName(font.fontName)}";
284
+ src: url("${font.dataUrl}") format("${format}");
285
+ font-weight: ${fontWeight};
286
+ font-style: ${fontStyle};
287
+ font-display: swap;
288
+ }`
289
+ }
290
+
291
+ /**
292
+ * 获取字体粗细
293
+ */
294
+ function getFontWeight(type: TEmbedFontType): string {
295
+ switch (type) {
296
+ case 'bold':
297
+ case 'boldItalic':
298
+ return '700'
299
+ default:
300
+ return '400'
301
+ }
302
+ }
303
+
304
+ /**
305
+ * 获取字体样式
306
+ */
307
+ function getFontStyle(type: TEmbedFontType): string {
308
+ switch (type) {
309
+ case 'italic':
310
+ case 'boldItalic':
311
+ return 'italic'
312
+ default:
313
+ return 'normal'
314
+ }
315
+ }
316
+
317
+ /**
318
+ * 获取 CSS font format 字符串
319
+ */
320
+ function getFontFormatString(format: string): string {
321
+ switch (format) {
322
+ case 'opentype':
323
+ return 'opentype'
324
+ case 'truetype':
325
+ return 'truetype'
326
+ case 'embedded-opentype':
327
+ return 'embedded-opentype'
328
+ default:
329
+ return 'truetype'
330
+ }
331
+ }
332
+
333
+ /**
334
+ * 转义字体名称中的特殊字符
335
+ */
336
+ function escapeFontName(name: string): string {
337
+ return name.replace(/"/g, '\\"')
338
+ }
339
+
340
+ /**
341
+ * 清理已注入的字体样式
342
+ * @param container 样式容器(默认为 document.head)
343
+ */
344
+ export function cleanupFontStyles(container?: HTMLElement): void {
345
+ const target = container || document.head
346
+ const oldStyle = target.querySelector('style[data-docx-fonts]')
347
+ if (oldStyle) {
348
+ oldStyle.remove()
349
+ }
350
+ }
351
+
352
+ /**
353
+ * 解析 fontTable.xml.rels 文件获取字体关系
354
+ */
355
+ export function parseFontRelationships(xmlContent: string): IRelationship[] {
356
+ const parser = new DOMParser()
357
+ const doc = parser.parseFromString(xmlContent, 'application/xml')
358
+ const root = doc.documentElement
359
+
360
+ const relationships: IRelationship[] = []
361
+ const relElements = root.getElementsByTagName('Relationship')
362
+
363
+ for (let i = 0; i < relElements.length; i++) {
364
+ const el = relElements[i]
365
+ const type = el.getAttribute('Type') || ''
366
+
367
+ // 只处理字体类型的关系
368
+ if (type === RELATIONSHIP_TYPES.FONT) {
369
+ relationships.push({
370
+ id: el.getAttribute('Id') || '',
371
+ type,
372
+ target: el.getAttribute('Target') || '',
373
+ targetMode: el.getAttribute('TargetMode') || undefined,
374
+ })
375
+ }
376
+ }
377
+
378
+ return relationships
379
+ }
@@ -0,0 +1,258 @@
1
+ /**
2
+ * 字体表解析器
3
+ * 解析 word/fontTable.xml,提取字体声明、嵌入字体引用和字体替换映射
4
+ */
5
+
6
+ import { xmlParser, parseXmlString } from '../parser/xml-parser'
7
+ import {
8
+ IFontTable,
9
+ IFontDeclaration,
10
+ IEmbedFontRef,
11
+ IFontSignature,
12
+ TEmbedFontType,
13
+ } from '../types'
14
+
15
+ /**
16
+ * 嵌入字体元素名到类型的映射
17
+ */
18
+ const EMBED_FONT_TYPE_MAP: Record<string, TEmbedFontType> = {
19
+ embedRegular: 'regular',
20
+ embedBold: 'bold',
21
+ embedItalic: 'italic',
22
+ embedBoldItalic: 'boldItalic',
23
+ }
24
+
25
+ /**
26
+ * 解析字体表 XML
27
+ * @param xmlContent fontTable.xml 的内容
28
+ * @returns 字体表对象
29
+ */
30
+ export function parseFontTable(xmlContent: string): IFontTable {
31
+ const doc = parseXmlString(xmlContent)
32
+ const root = doc.documentElement
33
+
34
+ const fonts = parseFonts(root)
35
+ const fontMap = new Map(fonts.map(f => [f.name, f]))
36
+ const substitutionMap = buildSubstitutionMap(fonts)
37
+
38
+ return {
39
+ fonts,
40
+ fontMap,
41
+ substitutionMap,
42
+ }
43
+ }
44
+
45
+ /**
46
+ * 解析所有字体声明
47
+ */
48
+ function parseFonts(root: Element): IFontDeclaration[] {
49
+ return xmlParser.elements(root, 'font').map(el => parseFont(el))
50
+ }
51
+
52
+ /**
53
+ * 解析单个字体声明
54
+ */
55
+ function parseFont(elem: Element): IFontDeclaration {
56
+ const result: IFontDeclaration = {
57
+ name: xmlParser.attr(elem, 'name') || '',
58
+ embedFontRefs: [],
59
+ }
60
+
61
+ for (const el of xmlParser.elements(elem)) {
62
+ switch (el.localName) {
63
+ case 'family':
64
+ result.family = xmlParser.attr(el, 'val')
65
+ break
66
+
67
+ case 'altName':
68
+ result.altName = xmlParser.attr(el, 'val')
69
+ break
70
+
71
+ case 'charset':
72
+ result.charset = xmlParser.attr(el, 'val')
73
+ break
74
+
75
+ case 'panose1':
76
+ result.panose1 = xmlParser.attr(el, 'val')
77
+ break
78
+
79
+ case 'sig':
80
+ result.sig = parseFontSignature(el)
81
+ break
82
+
83
+ case 'embedRegular':
84
+ case 'embedBold':
85
+ case 'embedItalic':
86
+ case 'embedBoldItalic':
87
+ const embedRef = parseEmbedFontRef(el)
88
+ if (embedRef) {
89
+ result.embedFontRefs.push(embedRef)
90
+ }
91
+ break
92
+ }
93
+ }
94
+
95
+ return result
96
+ }
97
+
98
+ /**
99
+ * 解析字体签名
100
+ */
101
+ function parseFontSignature(elem: Element): IFontSignature {
102
+ return {
103
+ usb0: xmlParser.attr(elem, 'usb0'),
104
+ usb1: xmlParser.attr(elem, 'usb1'),
105
+ usb2: xmlParser.attr(elem, 'usb2'),
106
+ usb3: xmlParser.attr(elem, 'usb3'),
107
+ csb0: xmlParser.attr(elem, 'csb0'),
108
+ csb1: xmlParser.attr(elem, 'csb1'),
109
+ }
110
+ }
111
+
112
+ /**
113
+ * 解析嵌入字体引用
114
+ */
115
+ function parseEmbedFontRef(elem: Element): IEmbedFontRef | null {
116
+ const id = xmlParser.attr(elem, 'id')
117
+ if (!id) return null
118
+
119
+ const type = EMBED_FONT_TYPE_MAP[elem.localName]
120
+ if (!type) return null
121
+
122
+ return {
123
+ id,
124
+ key: xmlParser.attr(elem, 'fontKey'),
125
+ subsetted: xmlParser.boolAttr(elem, 'subsetted'),
126
+ type,
127
+ }
128
+ }
129
+
130
+ /**
131
+ * 构建字体替换映射
132
+ * 从字体声明中提取 altName,建立原字体名到替代字体名的映射
133
+ */
134
+ function buildSubstitutionMap(fonts: IFontDeclaration[]): Map<string, string> {
135
+ const map = new Map<string, string>()
136
+
137
+ for (const font of fonts) {
138
+ if (font.altName) {
139
+ map.set(font.name, font.altName)
140
+ }
141
+ }
142
+
143
+ return map
144
+ }
145
+
146
+ /**
147
+ * 获取字体的替代字体名
148
+ * @param fontTable 字体表
149
+ * @param fontName 原字体名
150
+ * @returns 替代字体名,如果没有替代则返回原字体名
151
+ */
152
+ export function getSubstituteFontName(fontTable: IFontTable, fontName: string): string {
153
+ return fontTable.substitutionMap.get(fontName) || fontName
154
+ }
155
+
156
+ /**
157
+ * 获取字体声明
158
+ * @param fontTable 字体表
159
+ * @param fontName 字体名
160
+ * @returns 字体声明,如果不存在则返回 undefined
161
+ */
162
+ export function getFontDeclaration(fontTable: IFontTable, fontName: string): IFontDeclaration | undefined {
163
+ return fontTable.fontMap.get(fontName)
164
+ }
165
+
166
+ /**
167
+ * 检查字体是否有嵌入字体
168
+ * @param fontTable 字体表
169
+ * @param fontName 字体名
170
+ * @returns 是否有嵌入字体
171
+ */
172
+ export function hasEmbeddedFont(fontTable: IFontTable, fontName: string): boolean {
173
+ const font = fontTable.fontMap.get(fontName)
174
+ return font ? font.embedFontRefs.length > 0 : false
175
+ }
176
+
177
+ /**
178
+ * 获取字体的嵌入字体引用
179
+ * @param fontTable 字体表
180
+ * @param fontName 字体名
181
+ * @param type 字体类型(可选,不指定则返回所有类型)
182
+ * @returns 嵌入字体引用数组
183
+ */
184
+ export function getEmbedFontRefs(
185
+ fontTable: IFontTable,
186
+ fontName: string,
187
+ type?: TEmbedFontType
188
+ ): IEmbedFontRef[] {
189
+ const font = fontTable.fontMap.get(fontName)
190
+ if (!font) return []
191
+
192
+ if (type) {
193
+ return font.embedFontRefs.filter(ref => ref.type === type)
194
+ }
195
+
196
+ return font.embedFontRefs
197
+ }
198
+
199
+ /**
200
+ * 生成 CSS font-family 字符串
201
+ * 根据字体声明生成包含替代字体的 font-family
202
+ * @param fontTable 字体表
203
+ * @param fontName 字体名
204
+ * @returns CSS font-family 字符串
205
+ */
206
+ export function buildFontFamily(fontTable: IFontTable, fontName: string): string {
207
+ const fonts: string[] = []
208
+ const font = fontTable.fontMap.get(fontName)
209
+
210
+ // 添加原字体名
211
+ fonts.push(quoteFontName(fontName))
212
+
213
+ // 添加替代字体
214
+ if (font?.altName) {
215
+ fonts.push(quoteFontName(font.altName))
216
+ }
217
+
218
+ // 根据字体家族添加通用字体
219
+ if (font?.family) {
220
+ const genericFont = getGenericFontFamily(font.family)
221
+ if (genericFont) {
222
+ fonts.push(genericFont)
223
+ }
224
+ }
225
+
226
+ return fonts.join(', ')
227
+ }
228
+
229
+ /**
230
+ * 为字体名添加引号(如果需要)
231
+ */
232
+ function quoteFontName(name: string): string {
233
+ // 如果字体名包含空格或特殊字符,需要加引号
234
+ if (/[\s,'"()]/.test(name)) {
235
+ return `"${name.replace(/"/g, '\\"')}"`
236
+ }
237
+ return name
238
+ }
239
+
240
+ /**
241
+ * 根据 OOXML 字体家族获取 CSS 通用字体家族
242
+ */
243
+ function getGenericFontFamily(family: string): string | null {
244
+ switch (family.toLowerCase()) {
245
+ case 'roman':
246
+ return 'serif'
247
+ case 'swiss':
248
+ return 'sans-serif'
249
+ case 'modern':
250
+ return 'monospace'
251
+ case 'script':
252
+ return 'cursive'
253
+ case 'decorative':
254
+ return 'fantasy'
255
+ default:
256
+ return null
257
+ }
258
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * 字体表模块
3
+ * 提供字体表解析、嵌入字体加载和字体替换映射功能
4
+ */
5
+
6
+ // 导出解析器
7
+ export {
8
+ parseFontTable,
9
+ getSubstituteFontName,
10
+ getFontDeclaration,
11
+ hasEmbeddedFont,
12
+ getEmbedFontRefs,
13
+ buildFontFamily,
14
+ } from './font-parser'
15
+
16
+ // 导出加载器
17
+ export {
18
+ loadEmbeddedFonts,
19
+ cleanupFontStyles,
20
+ parseFontRelationships,
21
+ type IFontLoaderOptions,
22
+ } from './font-loader'