dsh-cait 1.0.3 → 1.0.4
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/CHANGELOG.md +367 -352
- package/LICENSE +201 -201
- package/README.ja.md +4 -3
- package/README.md +424 -423
- package/README.zh-CN.md +391 -390
- package/client/components.js +2377 -2377
- package/client/helpers.js +103 -103
- package/client/locales.js +730 -730
- package/client/rpc.js +93 -93
- package/client/styles.js +258 -258
- package/client.js +3556 -3556
- package/icon.svg +8 -8
- package/index.js +45 -45
- package/lib/api.js +223 -223
- package/lib/docx.js +389 -389
- package/lib/errors.js +8 -8
- package/lib/formats.js +917 -917
- package/lib/glossary.js +178 -178
- package/lib/import-export.js +605 -605
- package/lib/libraries.js +183 -183
- package/lib/markdown.js +28 -28
- package/lib/mt.js +152 -152
- package/lib/persistence.js +321 -321
- package/lib/pptx.js +89 -89
- package/lib/segmenter.js +84 -84
- package/lib/similarity.js +113 -113
- package/lib/store-utils.js +339 -339
- package/lib/store.js +734 -734
- package/lib/tm.js +160 -160
- package/lib/tmx.js +85 -85
- package/lib/tools.js +472 -472
- package/lib/xlsx.js +218 -218
- package/lib/xml.js +31 -31
- package/locale/en.json +11 -11
- package/locale/zh.json +11 -11
- package/package.json +83 -83
- package/scripts/build-client.mjs +13 -13
package/lib/docx.js
CHANGED
|
@@ -1,389 +1,389 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Dependency-free DOCX reader/writer.
|
|
3
|
-
*
|
|
4
|
-
* A .docx is a ZIP whose `word/document.xml` holds the body. Import keeps the
|
|
5
|
-
* original package as a template; export patches only the text nodes of the
|
|
6
|
-
* paragraphs that produced CAIT segments, so paragraph, run, style, numbering
|
|
7
|
-
* and embedded objects from the source file stay intact.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { deflateRawSync, inflateRawSync } from 'node:zlib'
|
|
11
|
-
import { decodeXml, escapeXml } from './xml.js'
|
|
12
|
-
|
|
13
|
-
const EOCD = 0x06054b50
|
|
14
|
-
const CENTRAL = 0x02014b50
|
|
15
|
-
const LOCAL = 0x04034b50
|
|
16
|
-
|
|
17
|
-
export const DOCX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
|
18
|
-
|
|
19
|
-
function findEndOfCentralDirectory(buffer) {
|
|
20
|
-
const minimum = Math.max(0, buffer.length - 65557)
|
|
21
|
-
for (let offset = buffer.length - 22; offset >= minimum; offset -= 1) {
|
|
22
|
-
if (buffer.readUInt32LE(offset) === EOCD) return offset
|
|
23
|
-
}
|
|
24
|
-
return -1
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function readZipEntry(buffer, wanted) {
|
|
28
|
-
const eocd = findEndOfCentralDirectory(buffer)
|
|
29
|
-
if (eocd < 0) throw new Error('DOCX 文件不是有效的 ZIP 容器')
|
|
30
|
-
|
|
31
|
-
const entries = buffer.readUInt16LE(eocd + 10)
|
|
32
|
-
let offset = buffer.readUInt32LE(eocd + 16)
|
|
33
|
-
|
|
34
|
-
for (let index = 0; index < entries; index += 1) {
|
|
35
|
-
if (offset + 46 > buffer.length || buffer.readUInt32LE(offset) !== CENTRAL) break
|
|
36
|
-
|
|
37
|
-
const method = buffer.readUInt16LE(offset + 10)
|
|
38
|
-
const compressedSize = buffer.readUInt32LE(offset + 20)
|
|
39
|
-
const nameLength = buffer.readUInt16LE(offset + 28)
|
|
40
|
-
const extraLength = buffer.readUInt16LE(offset + 30)
|
|
41
|
-
const commentLength = buffer.readUInt16LE(offset + 32)
|
|
42
|
-
const localOffset = buffer.readUInt32LE(offset + 42)
|
|
43
|
-
const name = buffer.subarray(offset + 46, offset + 46 + nameLength).toString('utf8')
|
|
44
|
-
|
|
45
|
-
if (name === wanted) {
|
|
46
|
-
if (buffer.readUInt32LE(localOffset) !== LOCAL) throw new Error('DOCX ZIP 本地头损坏')
|
|
47
|
-
const localNameLength = buffer.readUInt16LE(localOffset + 26)
|
|
48
|
-
const localExtraLength = buffer.readUInt16LE(localOffset + 28)
|
|
49
|
-
const start = localOffset + 30 + localNameLength + localExtraLength
|
|
50
|
-
const data = buffer.subarray(start, start + compressedSize)
|
|
51
|
-
if (method === 0) return Buffer.from(data)
|
|
52
|
-
if (method === 8) return inflateRawSync(data)
|
|
53
|
-
throw new Error(`DOCX 使用了不支持的压缩方式 (${method})`)
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
offset += 46 + nameLength + extraLength + commentLength
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
return null
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function readZipEntries(buffer) {
|
|
63
|
-
const eocd = findEndOfCentralDirectory(buffer)
|
|
64
|
-
if (eocd < 0) throw new Error('DOCX 文件不是有效的 ZIP 容器')
|
|
65
|
-
const count = buffer.readUInt16LE(eocd + 10)
|
|
66
|
-
let offset = buffer.readUInt32LE(eocd + 16)
|
|
67
|
-
const entries = []
|
|
68
|
-
|
|
69
|
-
for (let index = 0; index < count; index += 1) {
|
|
70
|
-
if (offset + 46 > buffer.length || buffer.readUInt32LE(offset) !== CENTRAL) {
|
|
71
|
-
throw new Error('DOCX ZIP 中央目录损坏')
|
|
72
|
-
}
|
|
73
|
-
const method = buffer.readUInt16LE(offset + 10)
|
|
74
|
-
const compressedSize = buffer.readUInt32LE(offset + 20)
|
|
75
|
-
const nameLength = buffer.readUInt16LE(offset + 28)
|
|
76
|
-
const extraLength = buffer.readUInt16LE(offset + 30)
|
|
77
|
-
const commentLength = buffer.readUInt16LE(offset + 32)
|
|
78
|
-
const localOffset = buffer.readUInt32LE(offset + 42)
|
|
79
|
-
const name = buffer.subarray(offset + 46, offset + 46 + nameLength).toString('utf8')
|
|
80
|
-
|
|
81
|
-
if (buffer.readUInt32LE(localOffset) !== LOCAL) throw new Error('DOCX ZIP 本地头损坏')
|
|
82
|
-
const localNameLength = buffer.readUInt16LE(localOffset + 26)
|
|
83
|
-
const localExtraLength = buffer.readUInt16LE(localOffset + 28)
|
|
84
|
-
const start = localOffset + 30 + localNameLength + localExtraLength
|
|
85
|
-
const compressed = buffer.subarray(start, start + compressedSize)
|
|
86
|
-
let data
|
|
87
|
-
if (method === 0) data = Buffer.from(compressed)
|
|
88
|
-
else if (method === 8) data = inflateRawSync(compressed)
|
|
89
|
-
else throw new Error(`DOCX 使用了不支持的压缩方式 (${method})`)
|
|
90
|
-
|
|
91
|
-
entries.push({ name, data })
|
|
92
|
-
offset += 46 + nameLength + extraLength + commentLength
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
return entries
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
const CRC_TABLE = (() => {
|
|
99
|
-
const table = new Array(256)
|
|
100
|
-
for (let index = 0; index < 256; index += 1) {
|
|
101
|
-
let value = index
|
|
102
|
-
for (let bit = 0; bit < 8; bit += 1) {
|
|
103
|
-
value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1
|
|
104
|
-
}
|
|
105
|
-
table[index] = value >>> 0
|
|
106
|
-
}
|
|
107
|
-
return table
|
|
108
|
-
})()
|
|
109
|
-
|
|
110
|
-
function crc32(buffer) {
|
|
111
|
-
let crc = 0xffffffff
|
|
112
|
-
for (let index = 0; index < buffer.length; index += 1) {
|
|
113
|
-
crc = CRC_TABLE[(crc ^ buffer[index]) & 0xff] ^ (crc >>> 8)
|
|
114
|
-
}
|
|
115
|
-
return (crc ^ -1) >>> 0
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
function dosDateTime(date = new Date()) {
|
|
119
|
-
const year = Math.max(1980, date.getFullYear())
|
|
120
|
-
const time =
|
|
121
|
-
((date.getHours() & 0x1f) << 11) |
|
|
122
|
-
((date.getMinutes() & 0x3f) << 5) |
|
|
123
|
-
Math.floor(date.getSeconds() / 2)
|
|
124
|
-
const day = ((year - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate()
|
|
125
|
-
return { time, date: day }
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
function writeZip(entries) {
|
|
129
|
-
const { time, date } = dosDateTime()
|
|
130
|
-
const localChunks = []
|
|
131
|
-
const centralChunks = []
|
|
132
|
-
let offset = 0
|
|
133
|
-
|
|
134
|
-
for (const entry of entries) {
|
|
135
|
-
const name = Buffer.from(entry.name, 'utf8')
|
|
136
|
-
const data = Buffer.isBuffer(entry.data) ? entry.data : Buffer.from(entry.data)
|
|
137
|
-
const compressed = deflateRawSync(data)
|
|
138
|
-
const crc = crc32(data)
|
|
139
|
-
|
|
140
|
-
const local = Buffer.alloc(30)
|
|
141
|
-
local.writeUInt32LE(LOCAL, 0)
|
|
142
|
-
local.writeUInt16LE(20, 4)
|
|
143
|
-
local.writeUInt16LE(0x0800, 6)
|
|
144
|
-
local.writeUInt16LE(8, 8)
|
|
145
|
-
local.writeUInt16LE(time, 10)
|
|
146
|
-
local.writeUInt16LE(date, 12)
|
|
147
|
-
local.writeUInt32LE(crc, 14)
|
|
148
|
-
local.writeUInt32LE(compressed.length, 18)
|
|
149
|
-
local.writeUInt32LE(data.length, 22)
|
|
150
|
-
local.writeUInt16LE(name.length, 26)
|
|
151
|
-
local.writeUInt16LE(0, 28)
|
|
152
|
-
localChunks.push(local, name, compressed)
|
|
153
|
-
|
|
154
|
-
const central = Buffer.alloc(46)
|
|
155
|
-
central.writeUInt32LE(CENTRAL, 0)
|
|
156
|
-
central.writeUInt16LE(20, 4)
|
|
157
|
-
central.writeUInt16LE(20, 6)
|
|
158
|
-
central.writeUInt16LE(0x0800, 8)
|
|
159
|
-
central.writeUInt16LE(8, 10)
|
|
160
|
-
central.writeUInt16LE(time, 12)
|
|
161
|
-
central.writeUInt16LE(date, 14)
|
|
162
|
-
central.writeUInt32LE(crc, 16)
|
|
163
|
-
central.writeUInt32LE(compressed.length, 20)
|
|
164
|
-
central.writeUInt32LE(data.length, 24)
|
|
165
|
-
central.writeUInt16LE(name.length, 28)
|
|
166
|
-
central.writeUInt16LE(0, 30)
|
|
167
|
-
central.writeUInt16LE(0, 32)
|
|
168
|
-
central.writeUInt16LE(0, 34)
|
|
169
|
-
central.writeUInt16LE(0, 36)
|
|
170
|
-
central.writeUInt32LE(0, 38)
|
|
171
|
-
central.writeUInt32LE(offset, 42)
|
|
172
|
-
centralChunks.push(central, name)
|
|
173
|
-
|
|
174
|
-
offset += local.length + name.length + compressed.length
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
const centralSize = centralChunks.reduce((sum, chunk) => sum + chunk.length, 0)
|
|
178
|
-
const end = Buffer.alloc(22)
|
|
179
|
-
end.writeUInt32LE(EOCD, 0)
|
|
180
|
-
end.writeUInt16LE(0, 4)
|
|
181
|
-
end.writeUInt16LE(0, 6)
|
|
182
|
-
end.writeUInt16LE(entries.length, 8)
|
|
183
|
-
end.writeUInt16LE(entries.length, 10)
|
|
184
|
-
end.writeUInt32LE(centralSize, 12)
|
|
185
|
-
end.writeUInt32LE(offset, 16)
|
|
186
|
-
end.writeUInt16LE(0, 20)
|
|
187
|
-
|
|
188
|
-
return Buffer.concat([...localChunks, ...centralChunks, end])
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/**
|
|
192
|
-
* Remove complete `<w:tbl>` blocks before looking for paragraphs. Table cell
|
|
193
|
-
* text is stored in ordinary `<w:p>` elements, so matching the whole document
|
|
194
|
-
* would silently turn table rows into body paragraphs.
|
|
195
|
-
*/
|
|
196
|
-
function withoutTables(xml) {
|
|
197
|
-
const parts = []
|
|
198
|
-
let depth = 0
|
|
199
|
-
let keepFrom = 0
|
|
200
|
-
let closed = true
|
|
201
|
-
const tags = /<(\/?)w:tbl\b[^>]*>/gi
|
|
202
|
-
let tag
|
|
203
|
-
|
|
204
|
-
while ((tag = tags.exec(xml)) !== null) {
|
|
205
|
-
if (tag[1] === '/') {
|
|
206
|
-
if (depth > 0) depth -= 1
|
|
207
|
-
if (depth === 0) {
|
|
208
|
-
keepFrom = tags.lastIndex
|
|
209
|
-
closed = true
|
|
210
|
-
}
|
|
211
|
-
} else {
|
|
212
|
-
if (depth === 0) {
|
|
213
|
-
parts.push(xml.slice(keepFrom, tag.index))
|
|
214
|
-
keepFrom = tags.lastIndex
|
|
215
|
-
closed = false
|
|
216
|
-
}
|
|
217
|
-
depth += 1
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
// An unclosed table is malformed; keep only the text before it.
|
|
222
|
-
if (closed) parts.push(xml.slice(keepFrom))
|
|
223
|
-
return parts.join('')
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
function paragraphText(paragraph) {
|
|
227
|
-
let out = ''
|
|
228
|
-
const tokens = /<w:t(?:\s[^>]*)?>([\s\S]*?)<\/w:t>|<w:tab\b[^>]*\/>|<w:br\b[^>]*\/>|<w:cr\b[^>]*\/>|<w:noBreakHyphen\b[^>]*\/>/g
|
|
229
|
-
let token
|
|
230
|
-
while ((token = tokens.exec(paragraph)) !== null) {
|
|
231
|
-
if (token[1] !== undefined) out += decodeXml(token[1])
|
|
232
|
-
else if (token[0].startsWith('<w:tab')) out += '\t'
|
|
233
|
-
else if (token[0].startsWith('<w:noBreakHyphen')) out += '-'
|
|
234
|
-
else out += '\n'
|
|
235
|
-
}
|
|
236
|
-
return out.replace(/\n{3,}/g, '\n\n').trim()
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
/**
|
|
240
|
-
* Like parseDocx, but also returns the zero-based body paragraph ordinal for
|
|
241
|
-
* every non-empty paragraph. The ordinal counts all `<w:p>` elements outside
|
|
242
|
-
* tables, including empty paragraphs, so it matches the patch traversal.
|
|
243
|
-
*/
|
|
244
|
-
export function parseDocxDetailed(buffer) {
|
|
245
|
-
if (!Buffer.isBuffer(buffer) || buffer.length < 4) throw new Error('DOCX 内容为空')
|
|
246
|
-
if (buffer[0] !== 0x50 || buffer[1] !== 0x4b) throw new Error('DOCX 文件头不是 ZIP')
|
|
247
|
-
|
|
248
|
-
const xml = readZipEntry(buffer, 'word/document.xml')
|
|
249
|
-
if (!xml) throw new Error('DOCX 中缺少 word/document.xml')
|
|
250
|
-
|
|
251
|
-
const document = xml.toString('utf8')
|
|
252
|
-
const body = document.match(/<w:body\b[^>]*>([\s\S]*?)<\/w:body>/i)?.[1] ?? document
|
|
253
|
-
const content = withoutTables(body)
|
|
254
|
-
const paragraphs = []
|
|
255
|
-
const pattern = /<w:p\b[^>]*>([\s\S]*?)<\/w:p>/gi
|
|
256
|
-
let match
|
|
257
|
-
let paragraphIndex = 0
|
|
258
|
-
|
|
259
|
-
while ((match = pattern.exec(content)) !== null) {
|
|
260
|
-
const text = paragraphText(match[1])
|
|
261
|
-
if (text) paragraphs.push({ text, index: paragraphIndex })
|
|
262
|
-
paragraphIndex += 1
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
if (!paragraphs.length) {
|
|
266
|
-
const fallback = content.replace(/<w:tab\b[^>]*\/>/g, '\t').replace(/<w:br\b[^>]*\/>/g, '\n')
|
|
267
|
-
const text = decodeXml(fallback.replace(/<[^>]*>/g, '')).replace(/\n{3,}/g, '\n\n').trim()
|
|
268
|
-
if (text) paragraphs.push({ text, index: 0 })
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
if (!paragraphs.length) throw new Error('DOCX 中未找到可翻译的文本')
|
|
272
|
-
return {
|
|
273
|
-
text: paragraphs.map((paragraph) => paragraph.text).join('\n\n'),
|
|
274
|
-
paragraphs,
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
export function parseDocx(buffer) {
|
|
279
|
-
return parseDocxDetailed(buffer).text
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
/** Read `word/document.xml` as text; used by the export tests. */
|
|
283
|
-
export function readDocxXml(buffer) {
|
|
284
|
-
const xml = readZipEntry(buffer, 'word/document.xml')
|
|
285
|
-
return xml ? xml.toString('utf8') : null
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
function patchParagraph(paragraph, text) {
|
|
289
|
-
const token = /<w:t(?:\s[^>]*)?>([\s\S]*?)<\/w:t>/g
|
|
290
|
-
const matches = [...paragraph.matchAll(token)]
|
|
291
|
-
const chunks = String(text).split(/\r?\n/)
|
|
292
|
-
const content = chunks
|
|
293
|
-
.map((chunk, index) => {
|
|
294
|
-
const run = `<w:t xml:space="preserve">${escapeXml(chunk)}</w:t>`
|
|
295
|
-
return index === 0 ? run : `<w:br/>${run}`
|
|
296
|
-
})
|
|
297
|
-
.join('')
|
|
298
|
-
|
|
299
|
-
if (!matches.length) {
|
|
300
|
-
return paragraph.replace(/<\/w:p>\s*$/, `<w:r>${content}</w:r></w:p>`)
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
let first = true
|
|
304
|
-
return paragraph.replace(token, () => {
|
|
305
|
-
if (first) {
|
|
306
|
-
first = false
|
|
307
|
-
return content
|
|
308
|
-
}
|
|
309
|
-
return '<w:t xml:space="preserve"></w:t>'
|
|
310
|
-
})
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
function patchBody(body, replacements) {
|
|
314
|
-
const pattern = /<w:tbl\b[^>]*>|<\/w:tbl>|<w:p\b[^>]*>[\s\S]*?<\/w:p>/gi
|
|
315
|
-
const parts = []
|
|
316
|
-
let depth = 0
|
|
317
|
-
let paragraphIndex = 0
|
|
318
|
-
let cursor = 0
|
|
319
|
-
let match
|
|
320
|
-
|
|
321
|
-
while ((match = pattern.exec(body)) !== null) {
|
|
322
|
-
const tag = match[0]
|
|
323
|
-
if (/^<w:tbl\b/i.test(tag)) {
|
|
324
|
-
depth += 1
|
|
325
|
-
continue
|
|
326
|
-
}
|
|
327
|
-
if (/^<\/w:tbl>/i.test(tag)) {
|
|
328
|
-
if (depth > 0) depth -= 1
|
|
329
|
-
continue
|
|
330
|
-
}
|
|
331
|
-
if (depth > 0) continue
|
|
332
|
-
|
|
333
|
-
const replacement = replacements.get(paragraphIndex)
|
|
334
|
-
if (replacement !== undefined) {
|
|
335
|
-
parts.push(body.slice(cursor, match.index))
|
|
336
|
-
parts.push(patchParagraph(tag, replacement))
|
|
337
|
-
cursor = match.index + tag.length
|
|
338
|
-
}
|
|
339
|
-
paragraphIndex += 1
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
parts.push(body.slice(cursor))
|
|
343
|
-
return parts.join('')
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
function patchDocumentXml(xml, replacements) {
|
|
347
|
-
const bodyMatch = xml.match(/<w:body\b[^>]*>([\s\S]*?)<\/w:body>/i)
|
|
348
|
-
if (!bodyMatch) throw new Error('DOCX 中缺少 w:body')
|
|
349
|
-
const body = bodyMatch[1]
|
|
350
|
-
const openTagEnd = bodyMatch[0].indexOf('>') + 1
|
|
351
|
-
const start = bodyMatch.index + openTagEnd
|
|
352
|
-
return `${xml.slice(0, start)}${patchBody(body, replacements)}${xml.slice(start + body.length)}`
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
/** Patch translated text into the source package, preserving its formatting. */
|
|
356
|
-
export function buildDocx(template, replacements) {
|
|
357
|
-
const entries = readZipEntries(template)
|
|
358
|
-
const documentEntry = entries.find((entry) => entry.name === 'word/document.xml')
|
|
359
|
-
if (!documentEntry) throw new Error('DOCX 中缺少 word/document.xml')
|
|
360
|
-
const xml = documentEntry.data.toString('utf8')
|
|
361
|
-
const patched = patchDocumentXml(xml, replacements)
|
|
362
|
-
return writeZip(
|
|
363
|
-
entries.map((entry) =>
|
|
364
|
-
entry.name === 'word/document.xml'
|
|
365
|
-
? { name: entry.name, data: Buffer.from(patched, 'utf8') }
|
|
366
|
-
: entry,
|
|
367
|
-
),
|
|
368
|
-
)
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
/** Minimal valid DOCX for text-only documents (no source template available). */
|
|
372
|
-
export function buildDocxFromText(paragraphs) {
|
|
373
|
-
const body = paragraphs
|
|
374
|
-
.map(
|
|
375
|
-
(paragraph) =>
|
|
376
|
-
`<w:p><w:r><w:t xml:space="preserve">${escapeXml(paragraph)}</w:t></w:r></w:p>`,
|
|
377
|
-
)
|
|
378
|
-
.join('')
|
|
379
|
-
const document = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>${body}<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440"/></w:sectPr></w:body></w:document>`
|
|
380
|
-
const contentTypes = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>`
|
|
381
|
-
const rels = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>`
|
|
382
|
-
return writeZip([
|
|
383
|
-
{ name: '[Content_Types].xml', data: Buffer.from(contentTypes, 'utf8') },
|
|
384
|
-
{ name: '_rels/.rels', data: Buffer.from(rels, 'utf8') },
|
|
385
|
-
{ name: 'word/document.xml', data: Buffer.from(document, 'utf8') },
|
|
386
|
-
])
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
export { readZipEntries, writeZip }
|
|
1
|
+
/**
|
|
2
|
+
* Dependency-free DOCX reader/writer.
|
|
3
|
+
*
|
|
4
|
+
* A .docx is a ZIP whose `word/document.xml` holds the body. Import keeps the
|
|
5
|
+
* original package as a template; export patches only the text nodes of the
|
|
6
|
+
* paragraphs that produced CAIT segments, so paragraph, run, style, numbering
|
|
7
|
+
* and embedded objects from the source file stay intact.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { deflateRawSync, inflateRawSync } from 'node:zlib'
|
|
11
|
+
import { decodeXml, escapeXml } from './xml.js'
|
|
12
|
+
|
|
13
|
+
const EOCD = 0x06054b50
|
|
14
|
+
const CENTRAL = 0x02014b50
|
|
15
|
+
const LOCAL = 0x04034b50
|
|
16
|
+
|
|
17
|
+
export const DOCX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
|
18
|
+
|
|
19
|
+
function findEndOfCentralDirectory(buffer) {
|
|
20
|
+
const minimum = Math.max(0, buffer.length - 65557)
|
|
21
|
+
for (let offset = buffer.length - 22; offset >= minimum; offset -= 1) {
|
|
22
|
+
if (buffer.readUInt32LE(offset) === EOCD) return offset
|
|
23
|
+
}
|
|
24
|
+
return -1
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function readZipEntry(buffer, wanted) {
|
|
28
|
+
const eocd = findEndOfCentralDirectory(buffer)
|
|
29
|
+
if (eocd < 0) throw new Error('DOCX 文件不是有效的 ZIP 容器')
|
|
30
|
+
|
|
31
|
+
const entries = buffer.readUInt16LE(eocd + 10)
|
|
32
|
+
let offset = buffer.readUInt32LE(eocd + 16)
|
|
33
|
+
|
|
34
|
+
for (let index = 0; index < entries; index += 1) {
|
|
35
|
+
if (offset + 46 > buffer.length || buffer.readUInt32LE(offset) !== CENTRAL) break
|
|
36
|
+
|
|
37
|
+
const method = buffer.readUInt16LE(offset + 10)
|
|
38
|
+
const compressedSize = buffer.readUInt32LE(offset + 20)
|
|
39
|
+
const nameLength = buffer.readUInt16LE(offset + 28)
|
|
40
|
+
const extraLength = buffer.readUInt16LE(offset + 30)
|
|
41
|
+
const commentLength = buffer.readUInt16LE(offset + 32)
|
|
42
|
+
const localOffset = buffer.readUInt32LE(offset + 42)
|
|
43
|
+
const name = buffer.subarray(offset + 46, offset + 46 + nameLength).toString('utf8')
|
|
44
|
+
|
|
45
|
+
if (name === wanted) {
|
|
46
|
+
if (buffer.readUInt32LE(localOffset) !== LOCAL) throw new Error('DOCX ZIP 本地头损坏')
|
|
47
|
+
const localNameLength = buffer.readUInt16LE(localOffset + 26)
|
|
48
|
+
const localExtraLength = buffer.readUInt16LE(localOffset + 28)
|
|
49
|
+
const start = localOffset + 30 + localNameLength + localExtraLength
|
|
50
|
+
const data = buffer.subarray(start, start + compressedSize)
|
|
51
|
+
if (method === 0) return Buffer.from(data)
|
|
52
|
+
if (method === 8) return inflateRawSync(data)
|
|
53
|
+
throw new Error(`DOCX 使用了不支持的压缩方式 (${method})`)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
offset += 46 + nameLength + extraLength + commentLength
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function readZipEntries(buffer) {
|
|
63
|
+
const eocd = findEndOfCentralDirectory(buffer)
|
|
64
|
+
if (eocd < 0) throw new Error('DOCX 文件不是有效的 ZIP 容器')
|
|
65
|
+
const count = buffer.readUInt16LE(eocd + 10)
|
|
66
|
+
let offset = buffer.readUInt32LE(eocd + 16)
|
|
67
|
+
const entries = []
|
|
68
|
+
|
|
69
|
+
for (let index = 0; index < count; index += 1) {
|
|
70
|
+
if (offset + 46 > buffer.length || buffer.readUInt32LE(offset) !== CENTRAL) {
|
|
71
|
+
throw new Error('DOCX ZIP 中央目录损坏')
|
|
72
|
+
}
|
|
73
|
+
const method = buffer.readUInt16LE(offset + 10)
|
|
74
|
+
const compressedSize = buffer.readUInt32LE(offset + 20)
|
|
75
|
+
const nameLength = buffer.readUInt16LE(offset + 28)
|
|
76
|
+
const extraLength = buffer.readUInt16LE(offset + 30)
|
|
77
|
+
const commentLength = buffer.readUInt16LE(offset + 32)
|
|
78
|
+
const localOffset = buffer.readUInt32LE(offset + 42)
|
|
79
|
+
const name = buffer.subarray(offset + 46, offset + 46 + nameLength).toString('utf8')
|
|
80
|
+
|
|
81
|
+
if (buffer.readUInt32LE(localOffset) !== LOCAL) throw new Error('DOCX ZIP 本地头损坏')
|
|
82
|
+
const localNameLength = buffer.readUInt16LE(localOffset + 26)
|
|
83
|
+
const localExtraLength = buffer.readUInt16LE(localOffset + 28)
|
|
84
|
+
const start = localOffset + 30 + localNameLength + localExtraLength
|
|
85
|
+
const compressed = buffer.subarray(start, start + compressedSize)
|
|
86
|
+
let data
|
|
87
|
+
if (method === 0) data = Buffer.from(compressed)
|
|
88
|
+
else if (method === 8) data = inflateRawSync(compressed)
|
|
89
|
+
else throw new Error(`DOCX 使用了不支持的压缩方式 (${method})`)
|
|
90
|
+
|
|
91
|
+
entries.push({ name, data })
|
|
92
|
+
offset += 46 + nameLength + extraLength + commentLength
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return entries
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const CRC_TABLE = (() => {
|
|
99
|
+
const table = new Array(256)
|
|
100
|
+
for (let index = 0; index < 256; index += 1) {
|
|
101
|
+
let value = index
|
|
102
|
+
for (let bit = 0; bit < 8; bit += 1) {
|
|
103
|
+
value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1
|
|
104
|
+
}
|
|
105
|
+
table[index] = value >>> 0
|
|
106
|
+
}
|
|
107
|
+
return table
|
|
108
|
+
})()
|
|
109
|
+
|
|
110
|
+
function crc32(buffer) {
|
|
111
|
+
let crc = 0xffffffff
|
|
112
|
+
for (let index = 0; index < buffer.length; index += 1) {
|
|
113
|
+
crc = CRC_TABLE[(crc ^ buffer[index]) & 0xff] ^ (crc >>> 8)
|
|
114
|
+
}
|
|
115
|
+
return (crc ^ -1) >>> 0
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function dosDateTime(date = new Date()) {
|
|
119
|
+
const year = Math.max(1980, date.getFullYear())
|
|
120
|
+
const time =
|
|
121
|
+
((date.getHours() & 0x1f) << 11) |
|
|
122
|
+
((date.getMinutes() & 0x3f) << 5) |
|
|
123
|
+
Math.floor(date.getSeconds() / 2)
|
|
124
|
+
const day = ((year - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate()
|
|
125
|
+
return { time, date: day }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function writeZip(entries) {
|
|
129
|
+
const { time, date } = dosDateTime()
|
|
130
|
+
const localChunks = []
|
|
131
|
+
const centralChunks = []
|
|
132
|
+
let offset = 0
|
|
133
|
+
|
|
134
|
+
for (const entry of entries) {
|
|
135
|
+
const name = Buffer.from(entry.name, 'utf8')
|
|
136
|
+
const data = Buffer.isBuffer(entry.data) ? entry.data : Buffer.from(entry.data)
|
|
137
|
+
const compressed = deflateRawSync(data)
|
|
138
|
+
const crc = crc32(data)
|
|
139
|
+
|
|
140
|
+
const local = Buffer.alloc(30)
|
|
141
|
+
local.writeUInt32LE(LOCAL, 0)
|
|
142
|
+
local.writeUInt16LE(20, 4)
|
|
143
|
+
local.writeUInt16LE(0x0800, 6)
|
|
144
|
+
local.writeUInt16LE(8, 8)
|
|
145
|
+
local.writeUInt16LE(time, 10)
|
|
146
|
+
local.writeUInt16LE(date, 12)
|
|
147
|
+
local.writeUInt32LE(crc, 14)
|
|
148
|
+
local.writeUInt32LE(compressed.length, 18)
|
|
149
|
+
local.writeUInt32LE(data.length, 22)
|
|
150
|
+
local.writeUInt16LE(name.length, 26)
|
|
151
|
+
local.writeUInt16LE(0, 28)
|
|
152
|
+
localChunks.push(local, name, compressed)
|
|
153
|
+
|
|
154
|
+
const central = Buffer.alloc(46)
|
|
155
|
+
central.writeUInt32LE(CENTRAL, 0)
|
|
156
|
+
central.writeUInt16LE(20, 4)
|
|
157
|
+
central.writeUInt16LE(20, 6)
|
|
158
|
+
central.writeUInt16LE(0x0800, 8)
|
|
159
|
+
central.writeUInt16LE(8, 10)
|
|
160
|
+
central.writeUInt16LE(time, 12)
|
|
161
|
+
central.writeUInt16LE(date, 14)
|
|
162
|
+
central.writeUInt32LE(crc, 16)
|
|
163
|
+
central.writeUInt32LE(compressed.length, 20)
|
|
164
|
+
central.writeUInt32LE(data.length, 24)
|
|
165
|
+
central.writeUInt16LE(name.length, 28)
|
|
166
|
+
central.writeUInt16LE(0, 30)
|
|
167
|
+
central.writeUInt16LE(0, 32)
|
|
168
|
+
central.writeUInt16LE(0, 34)
|
|
169
|
+
central.writeUInt16LE(0, 36)
|
|
170
|
+
central.writeUInt32LE(0, 38)
|
|
171
|
+
central.writeUInt32LE(offset, 42)
|
|
172
|
+
centralChunks.push(central, name)
|
|
173
|
+
|
|
174
|
+
offset += local.length + name.length + compressed.length
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const centralSize = centralChunks.reduce((sum, chunk) => sum + chunk.length, 0)
|
|
178
|
+
const end = Buffer.alloc(22)
|
|
179
|
+
end.writeUInt32LE(EOCD, 0)
|
|
180
|
+
end.writeUInt16LE(0, 4)
|
|
181
|
+
end.writeUInt16LE(0, 6)
|
|
182
|
+
end.writeUInt16LE(entries.length, 8)
|
|
183
|
+
end.writeUInt16LE(entries.length, 10)
|
|
184
|
+
end.writeUInt32LE(centralSize, 12)
|
|
185
|
+
end.writeUInt32LE(offset, 16)
|
|
186
|
+
end.writeUInt16LE(0, 20)
|
|
187
|
+
|
|
188
|
+
return Buffer.concat([...localChunks, ...centralChunks, end])
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Remove complete `<w:tbl>` blocks before looking for paragraphs. Table cell
|
|
193
|
+
* text is stored in ordinary `<w:p>` elements, so matching the whole document
|
|
194
|
+
* would silently turn table rows into body paragraphs.
|
|
195
|
+
*/
|
|
196
|
+
function withoutTables(xml) {
|
|
197
|
+
const parts = []
|
|
198
|
+
let depth = 0
|
|
199
|
+
let keepFrom = 0
|
|
200
|
+
let closed = true
|
|
201
|
+
const tags = /<(\/?)w:tbl\b[^>]*>/gi
|
|
202
|
+
let tag
|
|
203
|
+
|
|
204
|
+
while ((tag = tags.exec(xml)) !== null) {
|
|
205
|
+
if (tag[1] === '/') {
|
|
206
|
+
if (depth > 0) depth -= 1
|
|
207
|
+
if (depth === 0) {
|
|
208
|
+
keepFrom = tags.lastIndex
|
|
209
|
+
closed = true
|
|
210
|
+
}
|
|
211
|
+
} else {
|
|
212
|
+
if (depth === 0) {
|
|
213
|
+
parts.push(xml.slice(keepFrom, tag.index))
|
|
214
|
+
keepFrom = tags.lastIndex
|
|
215
|
+
closed = false
|
|
216
|
+
}
|
|
217
|
+
depth += 1
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// An unclosed table is malformed; keep only the text before it.
|
|
222
|
+
if (closed) parts.push(xml.slice(keepFrom))
|
|
223
|
+
return parts.join('')
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function paragraphText(paragraph) {
|
|
227
|
+
let out = ''
|
|
228
|
+
const tokens = /<w:t(?:\s[^>]*)?>([\s\S]*?)<\/w:t>|<w:tab\b[^>]*\/>|<w:br\b[^>]*\/>|<w:cr\b[^>]*\/>|<w:noBreakHyphen\b[^>]*\/>/g
|
|
229
|
+
let token
|
|
230
|
+
while ((token = tokens.exec(paragraph)) !== null) {
|
|
231
|
+
if (token[1] !== undefined) out += decodeXml(token[1])
|
|
232
|
+
else if (token[0].startsWith('<w:tab')) out += '\t'
|
|
233
|
+
else if (token[0].startsWith('<w:noBreakHyphen')) out += '-'
|
|
234
|
+
else out += '\n'
|
|
235
|
+
}
|
|
236
|
+
return out.replace(/\n{3,}/g, '\n\n').trim()
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Like parseDocx, but also returns the zero-based body paragraph ordinal for
|
|
241
|
+
* every non-empty paragraph. The ordinal counts all `<w:p>` elements outside
|
|
242
|
+
* tables, including empty paragraphs, so it matches the patch traversal.
|
|
243
|
+
*/
|
|
244
|
+
export function parseDocxDetailed(buffer) {
|
|
245
|
+
if (!Buffer.isBuffer(buffer) || buffer.length < 4) throw new Error('DOCX 内容为空')
|
|
246
|
+
if (buffer[0] !== 0x50 || buffer[1] !== 0x4b) throw new Error('DOCX 文件头不是 ZIP')
|
|
247
|
+
|
|
248
|
+
const xml = readZipEntry(buffer, 'word/document.xml')
|
|
249
|
+
if (!xml) throw new Error('DOCX 中缺少 word/document.xml')
|
|
250
|
+
|
|
251
|
+
const document = xml.toString('utf8')
|
|
252
|
+
const body = document.match(/<w:body\b[^>]*>([\s\S]*?)<\/w:body>/i)?.[1] ?? document
|
|
253
|
+
const content = withoutTables(body)
|
|
254
|
+
const paragraphs = []
|
|
255
|
+
const pattern = /<w:p\b[^>]*>([\s\S]*?)<\/w:p>/gi
|
|
256
|
+
let match
|
|
257
|
+
let paragraphIndex = 0
|
|
258
|
+
|
|
259
|
+
while ((match = pattern.exec(content)) !== null) {
|
|
260
|
+
const text = paragraphText(match[1])
|
|
261
|
+
if (text) paragraphs.push({ text, index: paragraphIndex })
|
|
262
|
+
paragraphIndex += 1
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (!paragraphs.length) {
|
|
266
|
+
const fallback = content.replace(/<w:tab\b[^>]*\/>/g, '\t').replace(/<w:br\b[^>]*\/>/g, '\n')
|
|
267
|
+
const text = decodeXml(fallback.replace(/<[^>]*>/g, '')).replace(/\n{3,}/g, '\n\n').trim()
|
|
268
|
+
if (text) paragraphs.push({ text, index: 0 })
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (!paragraphs.length) throw new Error('DOCX 中未找到可翻译的文本')
|
|
272
|
+
return {
|
|
273
|
+
text: paragraphs.map((paragraph) => paragraph.text).join('\n\n'),
|
|
274
|
+
paragraphs,
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function parseDocx(buffer) {
|
|
279
|
+
return parseDocxDetailed(buffer).text
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Read `word/document.xml` as text; used by the export tests. */
|
|
283
|
+
export function readDocxXml(buffer) {
|
|
284
|
+
const xml = readZipEntry(buffer, 'word/document.xml')
|
|
285
|
+
return xml ? xml.toString('utf8') : null
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function patchParagraph(paragraph, text) {
|
|
289
|
+
const token = /<w:t(?:\s[^>]*)?>([\s\S]*?)<\/w:t>/g
|
|
290
|
+
const matches = [...paragraph.matchAll(token)]
|
|
291
|
+
const chunks = String(text).split(/\r?\n/)
|
|
292
|
+
const content = chunks
|
|
293
|
+
.map((chunk, index) => {
|
|
294
|
+
const run = `<w:t xml:space="preserve">${escapeXml(chunk)}</w:t>`
|
|
295
|
+
return index === 0 ? run : `<w:br/>${run}`
|
|
296
|
+
})
|
|
297
|
+
.join('')
|
|
298
|
+
|
|
299
|
+
if (!matches.length) {
|
|
300
|
+
return paragraph.replace(/<\/w:p>\s*$/, `<w:r>${content}</w:r></w:p>`)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
let first = true
|
|
304
|
+
return paragraph.replace(token, () => {
|
|
305
|
+
if (first) {
|
|
306
|
+
first = false
|
|
307
|
+
return content
|
|
308
|
+
}
|
|
309
|
+
return '<w:t xml:space="preserve"></w:t>'
|
|
310
|
+
})
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function patchBody(body, replacements) {
|
|
314
|
+
const pattern = /<w:tbl\b[^>]*>|<\/w:tbl>|<w:p\b[^>]*>[\s\S]*?<\/w:p>/gi
|
|
315
|
+
const parts = []
|
|
316
|
+
let depth = 0
|
|
317
|
+
let paragraphIndex = 0
|
|
318
|
+
let cursor = 0
|
|
319
|
+
let match
|
|
320
|
+
|
|
321
|
+
while ((match = pattern.exec(body)) !== null) {
|
|
322
|
+
const tag = match[0]
|
|
323
|
+
if (/^<w:tbl\b/i.test(tag)) {
|
|
324
|
+
depth += 1
|
|
325
|
+
continue
|
|
326
|
+
}
|
|
327
|
+
if (/^<\/w:tbl>/i.test(tag)) {
|
|
328
|
+
if (depth > 0) depth -= 1
|
|
329
|
+
continue
|
|
330
|
+
}
|
|
331
|
+
if (depth > 0) continue
|
|
332
|
+
|
|
333
|
+
const replacement = replacements.get(paragraphIndex)
|
|
334
|
+
if (replacement !== undefined) {
|
|
335
|
+
parts.push(body.slice(cursor, match.index))
|
|
336
|
+
parts.push(patchParagraph(tag, replacement))
|
|
337
|
+
cursor = match.index + tag.length
|
|
338
|
+
}
|
|
339
|
+
paragraphIndex += 1
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
parts.push(body.slice(cursor))
|
|
343
|
+
return parts.join('')
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function patchDocumentXml(xml, replacements) {
|
|
347
|
+
const bodyMatch = xml.match(/<w:body\b[^>]*>([\s\S]*?)<\/w:body>/i)
|
|
348
|
+
if (!bodyMatch) throw new Error('DOCX 中缺少 w:body')
|
|
349
|
+
const body = bodyMatch[1]
|
|
350
|
+
const openTagEnd = bodyMatch[0].indexOf('>') + 1
|
|
351
|
+
const start = bodyMatch.index + openTagEnd
|
|
352
|
+
return `${xml.slice(0, start)}${patchBody(body, replacements)}${xml.slice(start + body.length)}`
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Patch translated text into the source package, preserving its formatting. */
|
|
356
|
+
export function buildDocx(template, replacements) {
|
|
357
|
+
const entries = readZipEntries(template)
|
|
358
|
+
const documentEntry = entries.find((entry) => entry.name === 'word/document.xml')
|
|
359
|
+
if (!documentEntry) throw new Error('DOCX 中缺少 word/document.xml')
|
|
360
|
+
const xml = documentEntry.data.toString('utf8')
|
|
361
|
+
const patched = patchDocumentXml(xml, replacements)
|
|
362
|
+
return writeZip(
|
|
363
|
+
entries.map((entry) =>
|
|
364
|
+
entry.name === 'word/document.xml'
|
|
365
|
+
? { name: entry.name, data: Buffer.from(patched, 'utf8') }
|
|
366
|
+
: entry,
|
|
367
|
+
),
|
|
368
|
+
)
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Minimal valid DOCX for text-only documents (no source template available). */
|
|
372
|
+
export function buildDocxFromText(paragraphs) {
|
|
373
|
+
const body = paragraphs
|
|
374
|
+
.map(
|
|
375
|
+
(paragraph) =>
|
|
376
|
+
`<w:p><w:r><w:t xml:space="preserve">${escapeXml(paragraph)}</w:t></w:r></w:p>`,
|
|
377
|
+
)
|
|
378
|
+
.join('')
|
|
379
|
+
const document = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>${body}<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440"/></w:sectPr></w:body></w:document>`
|
|
380
|
+
const contentTypes = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>`
|
|
381
|
+
const rels = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>`
|
|
382
|
+
return writeZip([
|
|
383
|
+
{ name: '[Content_Types].xml', data: Buffer.from(contentTypes, 'utf8') },
|
|
384
|
+
{ name: '_rels/.rels', data: Buffer.from(rels, 'utf8') },
|
|
385
|
+
{ name: 'word/document.xml', data: Buffer.from(document, 'utf8') },
|
|
386
|
+
])
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export { readZipEntries, writeZip }
|