dsh-cait 1.1.3 → 1.1.7

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
@@ -239,6 +239,88 @@ function paragraphText(paragraph) {
239
239
  return out.replace(/\n{3,}/g, '\n\n').trim()
240
240
  }
241
241
 
242
+ function contentPartRank(name) {
243
+ if (name === 'word/document.xml') return 0
244
+ if (/^word\/header\d*\.xml$/i.test(name)) return 1
245
+ if (/^word\/footer\d*\.xml$/i.test(name)) return 2
246
+ if (name === 'word/footnotes.xml') return 3
247
+ if (name === 'word/endnotes.xml') return 4
248
+ return 9
249
+ }
250
+
251
+ function contentPartNames(entries) {
252
+ return entries
253
+ .map((entry) => entry.name)
254
+ .filter((name) => contentPartRank(name) < 9)
255
+ .sort((left, right) => {
256
+ const rank = contentPartRank(left) - contentPartRank(right)
257
+ return rank || left.localeCompare(right)
258
+ })
259
+ }
260
+
261
+ /**
262
+ * Collect every paragraph in one document part, including table cells, in
263
+ * document order. `docxParagraph` is the ordinal within that part, which is
264
+ * what the exporter patches; table/row/cell are informative metadata.
265
+ */
266
+ function collectPartParagraphs(xml, partName) {
267
+ const pattern = /<w:tbl\b[^>]*>|<\/w:tbl>|<w:tr\b[^>]*>|<\/w:tr>|<w:tc\b[^>]*>|<\/w:tc>|<w:p\b[^>]*>[\s\S]*?<\/w:p>/gi
268
+ const paragraphs = []
269
+ let tableDepth = 0
270
+ let tableIndex = -1
271
+ let tableCount = 0
272
+ let rowIndex = -1
273
+ let cellIndex = -1
274
+ let ordinal = 0
275
+ let match
276
+
277
+ while ((match = pattern.exec(xml)) !== null) {
278
+ const tag = match[0]
279
+ if (/^<w:tbl\b/i.test(tag)) {
280
+ if (tableDepth === 0) {
281
+ tableIndex = tableCount
282
+ tableCount += 1
283
+ rowIndex = -1
284
+ cellIndex = -1
285
+ }
286
+ tableDepth += 1
287
+ continue
288
+ }
289
+ if (/^<\/w:tbl>/i.test(tag)) {
290
+ tableDepth = Math.max(0, tableDepth - 1)
291
+ continue
292
+ }
293
+ if (/^<w:tr\b/i.test(tag)) {
294
+ if (tableDepth > 0) {
295
+ rowIndex += 1
296
+ cellIndex = -1
297
+ }
298
+ continue
299
+ }
300
+ if (/^<\/w:tr>/i.test(tag)) continue
301
+ if (/^<w:tc\b/i.test(tag)) {
302
+ if (tableDepth > 0) cellIndex += 1
303
+ continue
304
+ }
305
+ if (/^<\/w:tc>/i.test(tag)) continue
306
+
307
+ const inner = tag.replace(/^<w:p\b[^>]*>/i, '').replace(/<\/w:p>\s*$/i, '')
308
+ const text = paragraphText(inner)
309
+ if (text) {
310
+ const meta = { docxPart: partName, docxParagraph: ordinal }
311
+ if (tableDepth > 0) {
312
+ meta.table = tableIndex
313
+ meta.row = Math.max(0, rowIndex)
314
+ meta.cell = Math.max(0, cellIndex)
315
+ }
316
+ paragraphs.push({ text, meta })
317
+ }
318
+ ordinal += 1
319
+ }
320
+
321
+ return paragraphs
322
+ }
323
+
242
324
  /**
243
325
  * Like parseDocx, but also returns the zero-based body paragraph ordinal for
244
326
  * every non-empty paragraph. The ordinal counts all `<w:p>` elements outside
@@ -248,33 +330,35 @@ export function parseDocxDetailed(buffer) {
248
330
  if (!Buffer.isBuffer(buffer) || buffer.length < 4) throw new Error('DOCX 内容为空')
249
331
  if (buffer[0] !== 0x50 || buffer[1] !== 0x4b) throw new Error('DOCX 文件头不是 ZIP')
250
332
 
251
- const xml = readZipEntry(buffer, 'word/document.xml')
252
- if (!xml) throw new Error('DOCX 中缺少 word/document.xml')
333
+ const entries = readZipEntries(buffer)
334
+ const documentEntry = entries.find((entry) => entry.name === 'word/document.xml')
335
+ if (!documentEntry) throw new Error('DOCX 中缺少 word/document.xml')
253
336
 
254
- const document = xml.toString('utf8')
255
- const body = document.match(/<w:body\b[^>]*>([\s\S]*?)<\/w:body>/i)?.[1] ?? document
256
- const content = withoutTables(body)
337
+ const parts = contentPartNames(entries)
257
338
  const paragraphs = []
258
- const pattern = /<w:p\b[^>]*>([\s\S]*?)<\/w:p>/gi
259
- let match
260
- let paragraphIndex = 0
261
-
262
- while ((match = pattern.exec(content)) !== null) {
263
- const text = paragraphText(match[1])
264
- if (text) paragraphs.push({ text, index: paragraphIndex })
265
- paragraphIndex += 1
339
+ for (const name of parts) {
340
+ const entry = entries.find((item) => item.name === name)
341
+ if (!entry) continue
342
+ const found = collectPartParagraphs(entry.data.toString('utf8'), name)
343
+ for (const paragraph of found) {
344
+ paragraphs.push({ ...paragraph, index: paragraphs.length })
345
+ }
266
346
  }
267
347
 
268
348
  if (!paragraphs.length) {
269
- const fallback = content.replace(/<w:tab\b[^>]*\/>/g, '\t').replace(/<w:br\b[^>]*\/>/g, '\n')
349
+ const fallback = documentEntry.data
350
+ .toString('utf8')
351
+ .replace(/<w:tab\b[^>]*\/>/g, '\t')
352
+ .replace(/<w:br\b[^>]*\/>/g, '\n')
270
353
  const text = decodeXml(fallback.replace(/<[^>]*>/g, '')).replace(/\n{3,}/g, '\n\n').trim()
271
- if (text) paragraphs.push({ text, index: 0 })
354
+ if (text) paragraphs.push({ text, index: 0, meta: { docxPart: 'word/document.xml', docxParagraph: 0 } })
272
355
  }
273
356
 
274
357
  if (!paragraphs.length) throw new Error('DOCX 中未找到可翻译的文本')
275
358
  return {
276
359
  text: paragraphs.map((paragraph) => paragraph.text).join('\n\n'),
277
360
  paragraphs,
361
+ formatMeta: { docxParts: parts },
278
362
  }
279
363
  }
280
364
 
@@ -282,9 +366,9 @@ export function parseDocx(buffer) {
282
366
  return parseDocxDetailed(buffer).text
283
367
  }
284
368
 
285
- /** Read `word/document.xml` as text; used by the export tests. */
286
- export function readDocxXml(buffer) {
287
- const xml = readZipEntry(buffer, 'word/document.xml')
369
+ /** Read one DOCX part as text; used by the export tests. */
370
+ export function readDocxXml(buffer, partName = 'word/document.xml') {
371
+ const xml = readZipEntry(buffer, partName)
288
372
  return xml ? xml.toString('utf8') : null
289
373
  }
290
374
 
@@ -355,19 +439,33 @@ function patchDocumentXml(xml, replacements) {
355
439
  return `${xml.slice(0, start)}${patchBody(body, replacements)}${xml.slice(start + body.length)}`
356
440
  }
357
441
 
442
+ function patchPartXml(xml, replacements) {
443
+ let ordinal = 0
444
+ return xml.replace(/<w:p\b[^>]*>[\s\S]*?<\/w:p>/gi, (paragraph) => {
445
+ const current = ordinal
446
+ ordinal += 1
447
+ if (!replacements.has(current)) return paragraph
448
+ return patchParagraph(paragraph, replacements.get(current))
449
+ })
450
+ }
451
+
358
452
  /** Patch translated text into the source package, preserving its formatting. */
359
- export function buildDocx(template, replacements) {
453
+ export function buildDocx(template, replacements, partReplacements = null) {
360
454
  const entries = readZipEntries(template)
361
455
  const documentEntry = entries.find((entry) => entry.name === 'word/document.xml')
362
456
  if (!documentEntry) throw new Error('DOCX 中缺少 word/document.xml')
363
- const xml = documentEntry.data.toString('utf8')
364
- const patched = patchDocumentXml(xml, replacements)
457
+ const byPart = partReplacements instanceof Map
458
+ ? partReplacements
459
+ : new Map([['word/document.xml', replacements]])
365
460
  return writeZip(
366
- entries.map((entry) =>
367
- entry.name === 'word/document.xml'
368
- ? { name: entry.name, data: Buffer.from(patched, 'utf8') }
369
- : entry,
370
- ),
461
+ entries.map((entry) => {
462
+ if (!byPart.has(entry.name)) return entry
463
+ const xml = entry.data.toString('utf8')
464
+ const patched = partReplacements instanceof Map
465
+ ? patchPartXml(xml, byPart.get(entry.name))
466
+ : patchDocumentXml(xml, replacements)
467
+ return { name: entry.name, data: Buffer.from(patched, 'utf8') }
468
+ }),
371
469
  )
372
470
  }
373
471
 
@@ -389,4 +487,35 @@ export function buildDocxFromText(paragraphs) {
389
487
  ])
390
488
  }
391
489
 
490
+ export function buildBilingualDocx(segments, { includeStatus = false, title = '' } = {}) {
491
+ const columns = includeStatus ? 3 : 2
492
+ const width = Math.floor(9000 / columns)
493
+ const cell = (text, shaded = false) => {
494
+ const paragraphs = String(text ?? '')
495
+ .split(/\r?\n/)
496
+ .map((line) => `<w:p><w:r><w:t xml:space="preserve">${escapeXml(line)}</w:t></w:r></w:p>`)
497
+ .join('')
498
+ const shade = shaded ? '<w:shd w:val="clear" w:color="auto" w:fill="F2F2F2"/>' : ''
499
+ return `<w:tc><w:tcPr><w:tcW w:w="${width}" w:type="dxa"/>${shade}</w:tcPr>${paragraphs}</w:tc>`
500
+ }
501
+ const header = `<w:tr><w:trPr><w:tblHeader/></w:trPr>${cell('Source', true)}${cell('Target', true)}${includeStatus ? cell('Status', true) : ''}</w:tr>`
502
+ const body = segments
503
+ .map(
504
+ (segment) =>
505
+ `<w:tr>${cell(segment.source)}${cell(segment.target || '')}${includeStatus ? cell(segment.status || '') : ''}</w:tr>`,
506
+ )
507
+ .join('')
508
+ const titleXml = title
509
+ ? `<w:p><w:r><w:rPr><w:b/><w:sz w:val="28"/></w:rPr><w:t xml:space="preserve">${escapeXml(title)}</w:t></w:r></w:p>`
510
+ : ''
511
+ const document = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>${titleXml}<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/><w:tblBorders><w:top w:val="single" w:sz="4"/><w:left w:val="single" w:sz="4"/><w:bottom w:val="single" w:sz="4"/><w:right w:val="single" w:sz="4"/><w:insideH w:val="single" w:sz="4"/><w:insideV w:val="single" w:sz="4"/></w:tblBorders></w:tblPr><w:tblGrid>${Array.from({ length: columns }, () => `<w:gridCol w:w="${width}"/>`).join('')}</w:tblGrid>${header}${body}</w:tbl><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>`
512
+ 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>`
513
+ 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>`
514
+ return writeZip([
515
+ { name: '[Content_Types].xml', data: Buffer.from(contentTypes, 'utf8') },
516
+ { name: '_rels/.rels', data: Buffer.from(rels, 'utf8') },
517
+ { name: 'word/document.xml', data: Buffer.from(document, 'utf8') },
518
+ ])
519
+ }
520
+
392
521
  export { readZipEntries, writeZip }
package/lib/formats.js CHANGED
@@ -577,6 +577,9 @@ function parseXliffUnit(body, attrs, format, tokens) {
577
577
  const targetMatch = body.match(/<(?:[\w.-]+:)?target\b[^>]*>([\s\S]*?)<\/(?:[\w.-]+:)?target>/i)
578
578
  ?? body.match(/<(?:[\w.-]+:)?target\b[^>]*\/>/i)
579
579
  const target = targetMatch ? extractInline(targetMatch[1] ?? '').text : ''
580
+ const targetTag = body.match(/<(?:[\w.-]+:)?target\b([^>]*)>/i)
581
+ const targetState = targetTag?.[1] ? readXmlAttr(targetTag[1], 'state') : ''
582
+ const caitUid = readXmlAttr(attrs, 'x-cait-uid')
580
583
  tokens.push(...source.tokens)
581
584
  return {
582
585
  source: source.text,
@@ -585,6 +588,8 @@ function parseXliffUnit(body, attrs, format, tokens) {
585
588
  meta: {
586
589
  format,
587
590
  xliffId: readXmlAttr(attrs, 'id'),
591
+ ...(caitUid ? { caitUid } : {}),
592
+ ...(targetState ? { targetState } : {}),
588
593
  ...(source.tokens.length ? { inline: source.tokens } : {}),
589
594
  },
590
595
  }
@@ -661,6 +666,38 @@ export function buildXliff(doc) {
661
666
  ].join('\n')
662
667
  }
663
668
 
669
+ export function buildXliffReview(doc) {
670
+ const units = doc.segments.map((segment, index) => {
671
+ const id = String(segment.uid || segment.meta?.xliffId || index + 1)
672
+ const tokens = Array.isArray(segment.meta?.inline) ? segment.meta.inline : []
673
+ const target = completeInline(segment.target || '', tokens)
674
+ const state = segment.confirmedAt ? 'final' : segment.target ? 'translated' : 'new'
675
+ const note = segment.confirmedAt
676
+ ? 'confirmed'
677
+ : segment.origin
678
+ ? `origin: ${segment.origin}`
679
+ : ''
680
+ return [
681
+ ` <trans-unit id="${escapeXml(id)}" x-cait-uid="${escapeXml(segment.uid || id)}">`,
682
+ ` <source xml:lang="${escapeXml(doc.sourceLang)}">${renderInline(segment.source, tokens)}</source>`,
683
+ ` <target xml:lang="${escapeXml(doc.targetLang)}" state="${state}">${renderInline(target, tokens)}</target>`,
684
+ ...(note ? [` <note from="cait">${escapeXml(note)}</note>`] : []),
685
+ ' </trans-unit>',
686
+ ].join('\n')
687
+ })
688
+ return [
689
+ '<?xml version="1.0" encoding="UTF-8"?>',
690
+ '<xliff version="1.2">',
691
+ ` <file source-language="${escapeXml(doc.sourceLang)}" target-language="${escapeXml(doc.targetLang)}" datatype="plaintext" original="${escapeXml(doc.name)}">`,
692
+ ' <body>',
693
+ ...units,
694
+ ' </body>',
695
+ ' </file>',
696
+ '</xliff>',
697
+ '',
698
+ ].join('\n')
699
+ }
700
+
664
701
  export function buildXliff2(doc) {
665
702
  const units = doc.segments.map((segment, index) => {
666
703
  const id = String(segment.meta?.xliffId || index + 1)
package/lib/glossary.js CHANGED
@@ -3,13 +3,16 @@ import { DEFAULT_GLOSSARY_ID, DEFAULT_GLOSSARY_NAME, MAX_SEGMENTS, canonicalLang
3
3
  import { normalizeText } from './similarity.js'
4
4
 
5
5
  export const glossaryMethods = {
6
- findGlossaryHits(text, sourceLang) {
6
+ findGlossaryHits(text, sourceLang, documentId) {
7
7
  const haystack = String(text ?? '')
8
8
  if (!haystack) return []
9
9
  const lower = haystack.toLowerCase()
10
10
  const lang = sourceLang ? canonicalLanguage(sourceLang) : ''
11
11
  const hits = []
12
- const mountedGlossary = new Set(this.mounts.glossaryIds)
12
+ const scopedDoc = documentId ? this.requireDocument(documentId) : null
13
+ const mountedGlossary = new Set(
14
+ scopedDoc ? this.effectiveGlossaryIds(scopedDoc) : this.mounts.glossaryIds,
15
+ )
13
16
 
14
17
  for (const entry of this.glossary) {
15
18
  if (!mountedGlossary.has(entry.glossaryId || DEFAULT_GLOSSARY_ID)) continue
@@ -29,6 +32,8 @@ export const glossaryMethods = {
29
32
  term: entry.source,
30
33
  target: entry.target,
31
34
  note: entry.note || '',
35
+ forbidden: Boolean(entry.forbidden),
36
+ preferred: Boolean(entry.preferred),
32
37
  count,
33
38
  })
34
39
  }
@@ -36,7 +41,7 @@ export const glossaryMethods = {
36
41
  return hits.sort((a, b) => b.term.length - a.term.length)
37
42
  }
38
43
  ,
39
- addGlossary({ source, target, note, sourceLang, caseSensitive, glossaryId } = {}) {
44
+ addGlossary({ source, target, note, sourceLang, caseSensitive, forbidden, preferred, glossaryId } = {}) {
40
45
  const term = cleanText(source, '术语')
41
46
  const translation = cleanText(target, '术语译文')
42
47
  if (!term || !translation) throw new CatError('EMPTY_TERM', '术语和译文都不能为空。')
@@ -53,6 +58,8 @@ export const glossaryMethods = {
53
58
  existing.target = translation
54
59
  existing.note = String(note ?? '').trim()
55
60
  existing.caseSensitive = Boolean(caseSensitive)
61
+ existing.forbidden = Boolean(forbidden)
62
+ existing.preferred = Boolean(preferred)
56
63
  existing.updatedAt = now()
57
64
  this.touch()
58
65
  this.persist()
@@ -66,6 +73,8 @@ export const glossaryMethods = {
66
73
  note: String(note ?? '').trim(),
67
74
  sourceLang: lang,
68
75
  caseSensitive: Boolean(caseSensitive),
76
+ forbidden: Boolean(forbidden),
77
+ preferred: Boolean(preferred),
69
78
  createdAt: now(),
70
79
  updatedAt: now(),
71
80
  }
@@ -75,7 +84,7 @@ export const glossaryMethods = {
75
84
  return { entry: { ...entry }, created: true }
76
85
  }
77
86
  ,
78
- updateGlossary({ id, source, target, note, caseSensitive } = {}) {
87
+ updateGlossary({ id, source, target, note, caseSensitive, forbidden, preferred } = {}) {
79
88
  const entry = this.glossary.find((item) => item.id === id)
80
89
  if (!entry) throw new CatError('TERM_NOT_FOUND', '术语不存在。', 404)
81
90
  // The library page manages every library, mounted or not; only the target
@@ -93,6 +102,9 @@ export const glossaryMethods = {
93
102
  }
94
103
  if (note !== undefined) entry.note = String(note ?? '').trim()
95
104
  if (caseSensitive !== undefined) entry.caseSensitive = Boolean(caseSensitive)
105
+ if (forbidden !== undefined) entry.forbidden = Boolean(forbidden)
106
+ if (preferred !== undefined) entry.preferred = Boolean(preferred)
107
+ if (entry.forbidden) entry.preferred = false
96
108
  entry.updatedAt = now()
97
109
  this.touch()
98
110
  this.persist()
@@ -129,6 +141,8 @@ export const glossaryMethods = {
129
141
  if (existing) {
130
142
  existing.target = String(item.target).trim()
131
143
  existing.note = String(item.note ?? '').trim()
144
+ existing.forbidden = Boolean(item.forbidden)
145
+ existing.preferred = Boolean(item.preferred) && !existing.forbidden
132
146
  existing.updatedAt = now()
133
147
  updated += 1
134
148
  } else {
@@ -140,6 +154,8 @@ export const glossaryMethods = {
140
154
  note: String(item.note ?? '').trim(),
141
155
  sourceLang: lang,
142
156
  caseSensitive: Boolean(item.caseSensitive),
157
+ forbidden: Boolean(item.forbidden),
158
+ preferred: Boolean(item.preferred) && !item.forbidden,
143
159
  createdAt: now(),
144
160
  updatedAt: now(),
145
161
  })