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/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 }