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/formats.js CHANGED
@@ -1,917 +1,917 @@
1
- /**
2
- * Dependency-free parsers/builders for the first-tier CAT exchange formats:
3
- * CSV, SRT, WebVTT, PO and a basic XLIFF 1.2 subset.
4
- *
5
- * These formats are intentionally shallow: inline markup is flattened to text,
6
- * complex subtitle styling and XLIFF extensions are not round-tripped. The
7
- * original source text is kept so the normal CAT/TM/glossary flow applies.
8
- */
9
- import { CatError } from './errors.js'
10
- import { decodeXml, escapeXml } from './xml.js'
11
-
12
- const SRT_TIME = /^(\d{1,3}):(\d{2}):(\d{2})[,.](\d{1,3})$/
13
- const VTT_TIME = /^(?:(\d{1,3}):)?(\d{1,2}):(\d{2})\.(\d{1,3})$/
14
- const INLINE_XML = /<\/?(?:bpt|ept|ph|g|x|bx|ex|mrk|sub|hi|span)\b[^>]*>/gi
15
- const INLINE_TOKEN = /\{(\d+)\}/g
16
-
17
- function formatError(code, message) {
18
- return new CatError(code, message)
19
- }
20
-
21
- function readXmlAttr(source, name) {
22
- const match = String(source ?? '').match(new RegExp(`\\b${name}\\s*=\\s*["']([^"']*)["']`, 'i'))
23
- return match ? decodeXml(match[1]) : ''
24
- }
25
-
26
- /**
27
- * Convert XLIFF inline markup to stable {n} tokens while keeping the original
28
- * XML for export. This prevents <ph>, <bpt>/<ept> and <g> placeholders from
29
- * being silently dropped when a translation is written back.
30
- */
31
- function extractInline(raw) {
32
- const tokens = []
33
- const text = String(raw ?? '').replace(INLINE_XML, (xml) => {
34
- const token = `{${tokens.length + 1}}`
35
- tokens.push({ token, xml })
36
- return token
37
- })
38
- return { text: decodeXml(text).trim(), tokens }
39
- }
40
-
41
- /** Render a tokenised string back to XML, escaping only the text portions. */
42
- function renderInline(value, tokens) {
43
- const text = String(value ?? '')
44
- const map = new Map((Array.isArray(tokens) ? tokens : []).map((item) => [item.token, item.xml]))
45
- let output = ''
46
- let last = 0
47
- for (const match of text.matchAll(INLINE_TOKEN)) {
48
- output += escapeXml(text.slice(last, match.index))
49
- output += map.get(match[0]) ?? escapeXml(match[0])
50
- last = match.index + match[0].length
51
- }
52
- output += escapeXml(text.slice(last))
53
- return output
54
- }
55
-
56
- /** Tokens present in the source but missing from the target. */
57
- export function missingInlineTokens(tokens, target) {
58
- if (!Array.isArray(tokens) || !tokens.length) return []
59
- const value = String(target ?? '')
60
- return tokens.map((item) => item.token).filter((token) => !value.includes(token))
61
- }
62
-
63
- /** Append placeholders the translator omitted, so export never silently loses them. */
64
- function completeInline(value, tokens) {
65
- let output = String(value ?? '')
66
- for (const item of Array.isArray(tokens) ? tokens : []) {
67
- if (!output.includes(item.token)) output += `${output && !/\s$/.test(output) ? ' ' : ''}${item.token}`
68
- }
69
- return output
70
- }
71
-
72
- export function parseCsvRows(text) {
73
- const input = String(text ?? '').replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n')
74
- const rows = []
75
- let row = []
76
- let field = ''
77
- let quoted = false
78
-
79
- for (let index = 0; index < input.length; index += 1) {
80
- const char = input[index]
81
- if (quoted) {
82
- if (char === '"') {
83
- if (input[index + 1] === '"') {
84
- field += '"'
85
- index += 1
86
- } else {
87
- quoted = false
88
- }
89
- } else {
90
- field += char
91
- }
92
- continue
93
- }
94
-
95
- if (char === '"' && field === '') {
96
- quoted = true
97
- } else if (char === ',') {
98
- row.push(field)
99
- field = ''
100
- } else if (char === '\n') {
101
- row.push(field)
102
- rows.push(row)
103
- row = []
104
- field = ''
105
- } else {
106
- field += char
107
- }
108
- }
109
-
110
- if (quoted) throw formatError('CSV_PARSE', 'CSV 引号未闭合。')
111
- if (field.length || row.length) {
112
- row.push(field)
113
- rows.push(row)
114
- }
115
- return rows
116
- }
117
-
118
- export function parseCsv(text, { sourceColumn, targetColumn } = {}) {
119
- const rows = parseCsvRows(text)
120
- if (!rows.length) throw formatError('CSV_PARSE', 'CSV 中没有可用的行。')
121
-
122
- const normalize = (value) => String(value ?? '').trim().toLowerCase()
123
- const sourceAliases = new Set(['source', 'src', 'original', '原文', '源文', '源文本'])
124
- const targetAliases = new Set(['target', 'tgt', 'translation', '译文', '目標', '目标', '目标文本'])
125
- const first = rows[0].map(normalize)
126
- const hasHeader = first.some((value) => sourceAliases.has(value) || targetAliases.has(value))
127
- const resolveColumn = (rawValue, aliases, fallback) => {
128
- const value = String(rawValue ?? '').trim()
129
- if (!value) return fallback
130
- if (/^\d+$/.test(value)) {
131
- const index = Number(value) - 1
132
- if (index < 0) throw formatError('CSV_PARSE', 'CSV 列号从 1 开始。')
133
- return index
134
- }
135
- const index = first.findIndex((item) => item === value.toLowerCase())
136
- if (index < 0) throw formatError('CSV_PARSE', `CSV 中找不到列“${value}”。`)
137
- return index
138
- }
139
-
140
- let sourceIndex = first.findIndex((value) => sourceAliases.has(value))
141
- let targetIndex = first.findIndex((value) => targetAliases.has(value))
142
- let start = 0
143
-
144
- if (sourceColumn !== undefined || targetColumn !== undefined) {
145
- sourceIndex = resolveColumn(sourceColumn, sourceAliases, sourceIndex >= 0 ? sourceIndex : 0)
146
- targetIndex = resolveColumn(targetColumn, targetAliases, targetIndex)
147
- start = hasHeader ? 1 : 0
148
- } else if (sourceIndex >= 0 && targetIndex >= 0) {
149
- start = 1
150
- } else {
151
- sourceIndex = 0
152
- targetIndex = rows[0].length > 1 ? 1 : -1
153
- }
154
-
155
- const maxColumns = rows.reduce((max, row) => Math.max(max, row.length), 0)
156
- if (sourceIndex < 0 || sourceIndex >= maxColumns) {
157
- throw formatError('CSV_PARSE', 'CSV 源文列不存在。')
158
- }
159
- if (targetIndex >= maxColumns) {
160
- throw formatError('CSV_PARSE', 'CSV 译文列不存在。')
161
- }
162
-
163
- const segments = []
164
- for (let index = start; index < rows.length; index += 1) {
165
- const row = rows[index]
166
- const source = String(row[sourceIndex] ?? '').trim()
167
- if (!source) continue
168
- const target = targetIndex >= 0 ? String(row[targetIndex] ?? '').trim() : ''
169
- segments.push({
170
- source,
171
- target,
172
- paragraph: segments.length,
173
- meta: { format: 'csv' },
174
- })
175
- }
176
-
177
- if (!segments.length) throw formatError('CSV_NO_UNITS', 'CSV 中没有可用的源文行。')
178
- return { format: 'csv', segments, warnings: [] }
179
- }
180
-
181
- function parseSrtTime(value) {
182
- const match = String(value).trim().match(SRT_TIME)
183
- if (!match) return null
184
- const [, hours, minutes, seconds, millis] = match
185
- return Number(hours) * 3600000 + Number(minutes) * 60000 + Number(seconds) * 1000 + Number(millis.padEnd(3, '0'))
186
- }
187
-
188
- export function formatSrtTime(milliseconds) {
189
- const total = Math.max(0, Math.round(Number(milliseconds) || 0))
190
- const hours = Math.floor(total / 3600000)
191
- const minutes = Math.floor((total % 3600000) / 60000)
192
- const seconds = Math.floor((total % 60000) / 1000)
193
- const millis = total % 1000
194
- return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')},${String(millis).padStart(3, '0')}`
195
- }
196
-
197
- function stripSubtitleMarkup(value) {
198
- const assOverrides = String(value).match(/\{\\[^}]+\}/g) ?? []
199
- return {
200
- // Semantic tags such as <i>, <b>, <font> and WebVTT <c>/<v>/<ruby> are
201
- // meaningful translation content and stay in the string verbatim.
202
- text: decodeXml(String(value).replace(/<br\s*\/?>/gi, '\n').replace(/\{\\[^}]+\}/g, '')),
203
- count: assOverrides.length,
204
- }
205
- }
206
-
207
- function parseSubtitleBlocks(text, kind) {
208
- const input = String(text ?? '').replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n').trim()
209
- if (!input) throw formatError(`${kind.toUpperCase()}_PARSE`, `${kind.toUpperCase()} 文件内容为空。`)
210
-
211
- const blocks = input.split(/\n{2,}/)
212
- const segments = []
213
- const warnings = []
214
- let markupCount = 0
215
-
216
- for (const block of blocks) {
217
- const lines = block.split('\n')
218
- if (!lines.length) continue
219
- let cursor = 0
220
- if (lines[0].trim().toUpperCase().startsWith('WEBVTT')) {
221
- lines.shift()
222
- while (lines.length && !lines[0].trim()) lines.shift()
223
- if (!lines.length) continue
224
- }
225
- if (lines[0].trim().startsWith('NOTE') || lines[0].trim().startsWith('STYLE') || lines[0].trim().startsWith('REGION')) continue
226
-
227
- let identifier = ''
228
- if (!lines[cursor].includes('-->')) {
229
- identifier = lines[cursor].trim()
230
- cursor += 1
231
- }
232
- if (!lines[cursor]?.includes('-->')) continue
233
- const timing = lines[cursor].trim()
234
- cursor += 1
235
-
236
- const timingMatch = timing.match(/^(\S+)\s*-->\s*(\S+)(.*)$/)
237
- if (!timingMatch) {
238
- throw formatError(`${kind.toUpperCase()}_PARSE`, `字幕时间轴无效: ${timing}`)
239
- }
240
- const start = kind === 'srt' ? parseSrtTime(timingMatch[1]) : parseVttTime(timingMatch[1])
241
- const end = kind === 'srt' ? parseSrtTime(timingMatch[2]) : parseVttTime(timingMatch[2])
242
- if (start === null || end === null) {
243
- throw formatError(`${kind.toUpperCase()}_PARSE`, `字幕时间轴无效: ${timing}`)
244
- }
245
- if (end <= start) {
246
- throw formatError(
247
- `${kind.toUpperCase()}_PARSE`,
248
- `字幕结束时间必须晚于开始时间: ${timing}`,
249
- )
250
- }
251
-
252
- const rawText = lines.slice(cursor).join('\n').trim()
253
- if (!rawText) continue
254
- const stripped = stripSubtitleMarkup(rawText)
255
- markupCount += stripped.count
256
- const text = stripped.text.trim()
257
- if (!text) continue
258
- segments.push({
259
- source: text,
260
- target: '',
261
- paragraph: segments.length,
262
- meta: {
263
- format: kind,
264
- start,
265
- end,
266
- settings: timingMatch[3].trim(),
267
- identifier,
268
- },
269
- })
270
- }
271
-
272
- if (!segments.length) throw formatError(`${kind.toUpperCase()}_NO_UNITS`, `${kind.toUpperCase()} 中没有可用的字幕。`)
273
- if (markupCount) {
274
- warnings.push({
275
- code: `${kind.toUpperCase()}_INLINE_MARKUP_LOST`,
276
- count: markupCount,
277
- message: `${kind.toUpperCase()} 含 ${markupCount} 个 ASS 样式覆盖标记({\\...}),导入时已移除;语义标签已保留。`,
278
- })
279
- }
280
- return { format: kind, segments, warnings }
281
- }
282
-
283
- export function parseSrt(text) {
284
- return parseSubtitleBlocks(text, 'srt')
285
- }
286
-
287
- function parseVttTime(value) {
288
- const match = String(value).trim().match(VTT_TIME)
289
- if (!match) return null
290
- const [, hours, minutes, seconds, millis] = match
291
- return Number(hours || 0) * 3600000 + Number(minutes) * 60000 + Number(seconds) * 1000 + Number(millis.padEnd(3, '0'))
292
- }
293
-
294
- export function formatVttTime(milliseconds) {
295
- const total = Math.max(0, Math.round(Number(milliseconds) || 0))
296
- const hours = Math.floor(total / 3600000)
297
- const minutes = Math.floor((total % 3600000) / 60000)
298
- const seconds = Math.floor((total % 60000) / 1000)
299
- const millis = total % 1000
300
- return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}.${String(millis).padStart(3, '0')}`
301
- }
302
-
303
- export function parseVtt(text) {
304
- return parseSubtitleBlocks(text, 'vtt')
305
- }
306
-
307
- function subtitleText(segment) {
308
- return String(segment.target || segment.source || '').trim()
309
- }
310
-
311
- function cueTimes(segment, fallbackStart) {
312
- const start = Number(segment.meta?.start)
313
- const end = Number(segment.meta?.end)
314
- if (Number.isFinite(start) && start >= 0 && Number.isFinite(end) && end > start) {
315
- return { start, end }
316
- }
317
- // Text-only documents (and any cue without usable timing metadata) start
318
- // where the previous cue ended. This avoids the old index * 2000 fallback
319
- // overlapping an explicitly timed cue that sits earlier in the document.
320
- const safeStart = Number.isFinite(start) && start >= 0 ? start : fallbackStart
321
- return { start: safeStart, end: safeStart + 2000 }
322
- }
323
-
324
- function buildCues(segments) {
325
- let cursor = 0
326
- return segments.map((segment) => {
327
- const times = cueTimes(segment, cursor)
328
- cursor = Math.max(cursor, times.end)
329
- return { segment, times }
330
- })
331
- }
332
-
333
- export function buildSrt(doc) {
334
- return buildCues(doc.segments)
335
- .map(({ segment, times }, index) => [
336
- String(index + 1),
337
- `${formatSrtTime(times.start)} --> ${formatSrtTime(times.end)}`,
338
- subtitleText(segment),
339
- '',
340
- ].join('\n'))
341
- .join('\n')
342
- }
343
-
344
- export function buildVtt(doc) {
345
- const cues = buildCues(doc.segments).map(({ segment, times }) => {
346
- const identifier = segment.meta?.identifier ? `${segment.meta.identifier}\n` : ''
347
- return `${identifier}${formatVttTime(times.start)} --> ${formatVttTime(times.end)}\n${subtitleText(segment)}\n`
348
- })
349
- return `WEBVTT\n\n${cues.join('\n')}`
350
- }
351
-
352
- function parsePoQuoted(raw) {
353
- const match = String(raw).trim().match(/^"((?:\\.|[^"\\])*)"$/)
354
- if (!match) return null
355
- return match[1].replace(/\\(n|t|r|"|\\)/g, (all, char) => {
356
- if (char === 'n') return '\n'
357
- if (char === 't') return '\t'
358
- if (char === 'r') return '\r'
359
- return char
360
- })
361
- }
362
-
363
- function poField(line) {
364
- const match = line.match(/^(msgctxt|msgid_plural|msgid|msgstr(?:\[\d+\])?)\s+(.*)$/)
365
- if (!match) return null
366
- const value = parsePoQuoted(match[2])
367
- if (value === null) return null
368
- return { name: match[1], value }
369
- }
370
-
371
- export function parsePo(text) {
372
- const input = String(text ?? '').replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n')
373
- if (!input.trim()) throw formatError('PO_PARSE', 'PO 文件内容为空。')
374
-
375
- const segments = []
376
- const obsolete = []
377
- let entry = null
378
- let current = null
379
- let pendingComments = []
380
- let obsoleteBlock = null
381
-
382
- const flushObsolete = () => {
383
- if (!obsoleteBlock?.length) return
384
- const block = obsoleteBlock.join('\n').trimEnd()
385
- if (block) obsolete.push(block)
386
- obsoleteBlock = null
387
- }
388
-
389
- const flush = () => {
390
- if (!entry) return
391
- const source = String(entry.msgid ?? '').trim()
392
- if (source) {
393
- const msgidPlural = String(entry.msgid_plural ?? '').trim()
394
- const pluralKeys = Object.keys(entry.msgstr ?? {})
395
- .filter((key) => /^\d+$/.test(key))
396
- .sort((left, right) => Number(left) - Number(right))
397
- const plurals = pluralKeys
398
- .map((key) => String(entry.msgstr[key] ?? '').trim())
399
- .filter(Boolean)
400
- const target = String(entry.msgstr?.[''] ?? plurals[0] ?? '').trim()
401
- const comments = entry.comments.filter(Boolean)
402
- segments.push({
403
- source,
404
- target,
405
- paragraph: segments.length,
406
- meta: {
407
- format: 'po',
408
- context: String(entry.msgctxt ?? '').trim(),
409
- msgidPlural,
410
- plural: Boolean(msgidPlural),
411
- ...(comments.length ? { comments } : {}),
412
- ...(plurals.length ? { plurals } : {}),
413
- },
414
- })
415
- }
416
- entry = null
417
- current = null
418
- }
419
-
420
- for (const rawLine of input.split('\n')) {
421
- const line = rawLine.trim()
422
- if (!line) {
423
- flush()
424
- flushObsolete()
425
- continue
426
- }
427
- if (line.startsWith('#~')) {
428
- if (!obsoleteBlock) {
429
- flush()
430
- obsoleteBlock = []
431
- }
432
- obsoleteBlock.push(rawLine.replace(/^#~\s?/, ''))
433
- continue
434
- }
435
- if (obsoleteBlock) {
436
- obsoleteBlock.push(rawLine)
437
- continue
438
- }
439
- if (line.startsWith('#')) {
440
- const comment = line.replace(/^#[.,:|\s]*/, '').trim()
441
- if (comment) {
442
- if (entry) entry.comments.push(comment)
443
- else pendingComments.push(comment)
444
- }
445
- continue
446
- }
447
-
448
- const field = poField(line)
449
- if (field) {
450
- entry ??= { msgstr: {}, comments: pendingComments.splice(0) }
451
- current = field.name
452
- if (current.startsWith('msgstr')) {
453
- entry.msgstr[current === 'msgstr' ? '' : current.match(/\[(\d+)\]/)?.[1] ?? '0'] = field.value
454
- } else {
455
- entry[current] = field.value
456
- }
457
- continue
458
- }
459
- if (line.startsWith('"') && current) {
460
- const value = parsePoQuoted(line)
461
- if (value === null) continue
462
- if (current.startsWith('msgstr')) {
463
- const key = current === 'msgstr' ? '' : current.match(/\[(\d+)\]/)?.[1] ?? '0'
464
- entry.msgstr[key] = `${entry.msgstr[key] ?? ''}${value}`
465
- } else {
466
- entry[current] = `${entry[current] ?? ''}${value}`
467
- }
468
- }
469
- }
470
- flush()
471
- flushObsolete()
472
-
473
- if (!segments.length) throw formatError('PO_NO_UNITS', 'PO 中没有可用的 msgid/msgstr 条目。')
474
- return { format: 'po', segments, obsolete, warnings: [] }
475
- }
476
-
477
- function poEscape(value) {
478
- return String(value ?? '')
479
- .replace(/\\/g, '\\\\')
480
- .replace(/"/g, '\\"')
481
- .replace(/\n/g, '\\n')
482
- .replace(/\t/g, '\\t')
483
- .replace(/\r/g, '\\r')
484
- }
485
-
486
- function poLine(field, value) {
487
- return `${field} "${poEscape(value)}"`
488
- }
489
-
490
- export function buildPo(doc) {
491
- const header = [
492
- 'msgid ""',
493
- 'msgstr ""',
494
- '"Content-Type: text/plain; charset=UTF-8\\n"',
495
- `"Language: ${doc.targetLang}\\n"`,
496
- '',
497
- ].join('\n')
498
-
499
- const entries = doc.segments.map((segment) => {
500
- const context = String(segment.meta?.context ?? '').trim()
501
- const plural = String(segment.meta?.msgidPlural ?? '').trim()
502
- const target = String(segment.target || segment.source || '').trim()
503
- const comments = Array.isArray(segment.meta?.comments) ? segment.meta.comments : []
504
- const lines = comments.filter(Boolean).map((comment) => `#. ${comment}`)
505
- if (context) lines.push(poLine('msgctxt', context))
506
- lines.push(poLine('msgid', segment.source))
507
- if (plural) {
508
- const forms = Array.isArray(segment.meta?.plurals) ? segment.meta.plurals : []
509
- lines.push(poLine('msgid_plural', plural))
510
- lines.push(poLine('msgstr[0]', target))
511
- for (let index = 1; index < Math.max(forms.length, 2); index += 1) {
512
- lines.push(poLine(`msgstr[${index}]`, forms[index] || target))
513
- }
514
- } else {
515
- lines.push(poLine('msgstr', target))
516
- }
517
- return lines.join('\n')
518
- })
519
-
520
- const obsolete = Array.isArray(doc.formatMeta?.poObsolete)
521
- ? doc.formatMeta.poObsolete
522
- .filter((block) => typeof block === 'string' && block.trim())
523
- .map((block) => block.split('\n').map((line) => `#~ ${line}`).join('\n'))
524
- : []
525
-
526
- const blocks = [...entries, ...obsolete].filter(Boolean)
527
- return `${header}\n${blocks.join('\n\n')}\n`
528
- }
529
-
530
- function parseXliffUnit(body, attrs, format, tokens) {
531
- const sourceMatch = body.match(/<(?:[\w.-]+:)?source\b[^>]*>([\s\S]*?)<\/(?:[\w.-]+:)?source>/i)
532
- ?? body.match(/<(?:[\w.-]+:)?source\b[^>]*\/>/i)
533
- if (!sourceMatch) return null
534
- const sourceRaw = sourceMatch[1] ?? ''
535
- const source = extractInline(sourceRaw)
536
- if (!source.text) return null
537
- const targetMatch = body.match(/<(?:[\w.-]+:)?target\b[^>]*>([\s\S]*?)<\/(?:[\w.-]+:)?target>/i)
538
- ?? body.match(/<(?:[\w.-]+:)?target\b[^>]*\/>/i)
539
- const target = targetMatch ? extractInline(targetMatch[1] ?? '').text : ''
540
- tokens.push(...source.tokens)
541
- return {
542
- source: source.text,
543
- target,
544
- paragraph: 0,
545
- meta: {
546
- format,
547
- xliffId: readXmlAttr(attrs, 'id'),
548
- ...(source.tokens.length ? { inline: source.tokens } : {}),
549
- },
550
- }
551
- }
552
-
553
- function parseXliff2(input, rootAttrs) {
554
- const sourceLang = readXmlAttr(rootAttrs, 'srcLang') || readXmlAttr(rootAttrs, 'source-language')
555
- const targetLang = readXmlAttr(rootAttrs, 'trgLang') || readXmlAttr(rootAttrs, 'target-language')
556
- const units = []
557
- const inlineTokens = []
558
- const unitRe = /<(?:[\w.-]+:)?unit\b([^>]*)>([\s\S]*?)<\/(?:[\w.-]+:)?unit>/gi
559
- let match
560
- while ((match = unitRe.exec(input))) {
561
- const unit = parseXliffUnit(match[2], match[1], 'xliff2', inlineTokens)
562
- if (!unit) continue
563
- unit.paragraph = units.length
564
- units.push(unit)
565
- }
566
- if (!units.length) throw formatError('XLIFF_NO_UNITS', 'XLIFF 中没有可用的 unit/segment。')
567
- return { format: 'xliff2', sourceLang, targetLang, segments: units, warnings: [] }
568
- }
569
-
570
- export function parseXliff(text) {
571
- const input = String(text ?? '').replace(/^\uFEFF/, '')
572
- if (!input.trim()) throw formatError('XLIFF_PARSE', 'XLIFF 文件内容为空。')
573
-
574
- const rootTag = input.match(/<((?:[\w.-]+:)?xliff)\b([^>]*)>/i)
575
- const rootAttrs = rootTag?.[2] ?? ''
576
- const version = readXmlAttr(rootAttrs, 'version')
577
- const looksLikeXliff2 = /^2\./i.test(version)
578
- || (!/<(?:[\w.-]+:)?trans-unit\b/i.test(input) && /<(?:[\w.-]+:)?unit\b/i.test(input))
579
- if (looksLikeXliff2) return parseXliff2(input, rootAttrs)
580
-
581
- const fileTag = input.match(/<((?:[\w.-]+:)?file)\b([^>]*)>/i)
582
- const sourceLang = readXmlAttr(fileTag?.[2], 'source-language')
583
- const targetLang = readXmlAttr(fileTag?.[2], 'target-language')
584
-
585
- const units = []
586
- const unitRe = /<(?:[\w.-]+:)?trans-unit\b([^>]*)>([\s\S]*?)<\/(?:[\w.-]+:)?trans-unit>/gi
587
- let match
588
- while ((match = unitRe.exec(input))) {
589
- const unit = parseXliffUnit(match[2], match[1], 'xliff', [])
590
- if (!unit) continue
591
- unit.paragraph = units.length
592
- units.push(unit)
593
- }
594
-
595
- if (!units.length) throw formatError('XLIFF_NO_UNITS', 'XLIFF 中没有可用的 trans-unit。')
596
- return { format: 'xliff', sourceLang, targetLang, segments: units, warnings: [] }
597
- }
598
-
599
- export function buildXliff(doc) {
600
- const units = doc.segments.map((segment, index) => {
601
- const id = String(segment.meta?.xliffId || index + 1)
602
- const tokens = Array.isArray(segment.meta?.inline) ? segment.meta.inline : []
603
- const target = completeInline(segment.target || segment.source, tokens)
604
- return [
605
- ` <trans-unit id="${escapeXml(id)}">`,
606
- ` <source xml:lang="${escapeXml(doc.sourceLang)}">${renderInline(segment.source, tokens)}</source>`,
607
- ` <target xml:lang="${escapeXml(doc.targetLang)}">${renderInline(target, tokens)}</target>`,
608
- ' </trans-unit>',
609
- ].join('\n')
610
- })
611
- return [
612
- '<?xml version="1.0" encoding="UTF-8"?>',
613
- '<xliff version="1.2">',
614
- ` <file source-language="${escapeXml(doc.sourceLang)}" target-language="${escapeXml(doc.targetLang)}" datatype="plaintext" original="${escapeXml(doc.name)}">`,
615
- ' <body>',
616
- ...units,
617
- ' </body>',
618
- ' </file>',
619
- '</xliff>',
620
- '',
621
- ].join('\n')
622
- }
623
-
624
- export function buildXliff2(doc) {
625
- const units = doc.segments.map((segment, index) => {
626
- const id = String(segment.meta?.xliffId || index + 1)
627
- const tokens = Array.isArray(segment.meta?.inline) ? segment.meta.inline : []
628
- const target = completeInline(segment.target || segment.source, tokens)
629
- return [
630
- ` <unit id="${escapeXml(id)}">`,
631
- ' <segment>',
632
- ` <source>${renderInline(segment.source, tokens)}</source>`,
633
- ` <target>${renderInline(target, tokens)}</target>`,
634
- ' </segment>',
635
- ' </unit>',
636
- ].join('\n')
637
- })
638
- return [
639
- '<?xml version="1.0" encoding="UTF-8"?>',
640
- '<xliff xmlns="urn:oasis:names:tc:xliff:document:2.0" version="2.0"',
641
- ` srcLang="${escapeXml(doc.sourceLang)}" trgLang="${escapeXml(doc.targetLang)}">`,
642
- ' <file id="f1">',
643
- ...units,
644
- ' </file>',
645
- '</xliff>',
646
- '',
647
- ].join('\n')
648
- }
649
-
650
- function htmlToText(input) {
651
- let text = String(input ?? '').replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n')
652
- text = text
653
- .replace(/<!--[\s\S]*?-->/g, '')
654
- .replace(/<(script|style|noscript|head)\b[\s\S]*?<\/\1>/gi, '')
655
- .replace(/<br\s*\/?>/gi, '\n')
656
- .replace(/<\/(?:p|div|li|h[1-6]|section|article|blockquote|pre|tr)>/gi, '\n\n')
657
- .replace(/<\/(?:td|th)>/gi, '\t')
658
- .replace(/<[^>]+>/g, '')
659
- return decodeXml(text)
660
- .replace(/[ \t]+\n/g, '\n')
661
- .replace(/\n{3,}/g, '\n\n')
662
- .trim()
663
- }
664
-
665
- export function parseHtml(text) {
666
- const input = String(text ?? '')
667
- if (!input.trim()) throw formatError('HTML_PARSE', 'HTML 文件内容为空。')
668
- const plain = htmlToText(input)
669
- if (!plain) throw formatError('HTML_NO_UNITS', 'HTML 中没有可用的正文。')
670
- const segments = plain
671
- .split(/\n{2,}/)
672
- .map((part) => part.trim())
673
- .filter(Boolean)
674
- .map((source, index) => ({
675
- source,
676
- target: '',
677
- paragraph: index,
678
- meta: { format: 'html' },
679
- }))
680
- if (!segments.length) throw formatError('HTML_NO_UNITS', 'HTML 中没有可用的正文。')
681
- return { format: 'html', segments, warnings: [] }
682
- }
683
-
684
- export function buildHtml(doc) {
685
- const groups = new Map()
686
- for (const segment of doc.segments) {
687
- const key = segment.paragraph ?? 0
688
- if (!groups.has(key)) groups.set(key, [])
689
- groups.get(key).push(String(segment.target || segment.source || '').trim())
690
- }
691
- const paragraphs = [...groups.values()]
692
- .map((parts) => parts.filter(Boolean).join(' '))
693
- .filter(Boolean)
694
- .map((text) => ` <p>${escapeXml(text)}</p>`)
695
- .join('\n')
696
- return [
697
- '<!doctype html>',
698
- '<html lang="' + escapeXml(doc.targetLang || 'en') + '">',
699
- '<head><meta charset="utf-8"><title>' + escapeXml(doc.name || 'CAIT export') + '</title></head>',
700
- '<body>',
701
- paragraphs,
702
- '</body>',
703
- '</html>',
704
- '',
705
- ].join('\n')
706
- }
707
-
708
- function escapeJsonPointer(value) {
709
- return String(value).replace(/~/g, '~0').replace(/\//g, '~1')
710
- }
711
-
712
- function unescapeJsonPointer(value) {
713
- return String(value).replace(/~1/g, '/').replace(/~0/g, '~')
714
- }
715
-
716
- function walkJsonStrings(value, pointer, visit) {
717
- if (typeof value === 'string') {
718
- visit(value, pointer)
719
- return
720
- }
721
- if (Array.isArray(value)) {
722
- value.forEach((item, index) => walkJsonStrings(item, `${pointer}/${index}`, visit))
723
- return
724
- }
725
- if (value && typeof value === 'object') {
726
- for (const [key, item] of Object.entries(value)) {
727
- walkJsonStrings(item, `${pointer}/${escapeJsonPointer(key)}`, visit)
728
- }
729
- }
730
- }
731
-
732
- function setJsonPointer(root, pointer, value) {
733
- const parts = String(pointer).split('/').slice(1).map(unescapeJsonPointer)
734
- if (!parts.length) return value
735
- let current = root
736
- for (let index = 0; index < parts.length - 1; index += 1) {
737
- if (current == null) throw new Error('JSON path does not exist')
738
- current = current[parts[index]]
739
- }
740
- if (current == null) throw new Error('JSON path does not exist')
741
- current[parts[parts.length - 1]] = value
742
- return root
743
- }
744
-
745
- export function parseJson(text) {
746
- const input = String(text ?? '')
747
- if (!input.trim()) throw formatError('JSON_PARSE', 'JSON 文件内容为空。')
748
- let root
749
- try {
750
- root = JSON.parse(input)
751
- } catch (error) {
752
- throw formatError('JSON_PARSE', `JSON 解析失败: ${error.message}`)
753
- }
754
- const segments = []
755
- walkJsonStrings(root, '', (source, pointer) => {
756
- if (!source.trim()) return
757
- segments.push({
758
- source,
759
- target: '',
760
- paragraph: segments.length,
761
- meta: { format: 'json', jsonPath: pointer },
762
- })
763
- })
764
- if (!segments.length) throw formatError('JSON_NO_UNITS', 'JSON 中没有可翻译的字符串。')
765
- return {
766
- format: 'json',
767
- segments,
768
- formatMeta: { jsonTemplate: JSON.stringify(root) },
769
- warnings: [],
770
- }
771
- }
772
-
773
- export function buildJson(doc) {
774
- let root
775
- try {
776
- root = doc.formatMeta?.jsonTemplate ? JSON.parse(doc.formatMeta.jsonTemplate) : {}
777
- } catch {
778
- root = {}
779
- }
780
- for (const segment of doc.segments) {
781
- const pointer = segment.meta?.jsonPath
782
- if (!pointer) continue
783
- const text = String(segment.target || segment.source || '')
784
- setJsonPointer(root, pointer, text)
785
- }
786
- return `${JSON.stringify(root, null, 2)}\n`
787
- }
788
-
789
- const RTF_DESTINATION_GROUP = /\{\\\*?\\?(?:fonttbl|colortbl|stylesheet|info|pict|header|footer|object|themedata|colorschememapping|datastore|latentstyles|rsidtbl|generator|xmlnstbl)\b[^{}]*(?:\{[^{}]*\}[^{}]*)*\}/gi
790
-
791
- function rtfUnicode(code) {
792
- const value = Number(code)
793
- return String.fromCharCode(value < 0 ? value + 65536 : value)
794
- }
795
-
796
- export function parseRtf(text) {
797
- let input = String(text ?? '').replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n')
798
- if (!input.trim()) throw formatError('RTF_PARSE', 'RTF 文件内容为空。')
799
- if (!input.trimStart().startsWith('{\\rtf')) {
800
- throw formatError('RTF_PARSE', 'RTF 文件缺少 {\\rtf 头。')
801
- }
802
- let previous
803
- do {
804
- previous = input
805
- input = input.replace(RTF_DESTINATION_GROUP, '')
806
- } while (previous !== input)
807
-
808
- let output = ''
809
- let index = 0
810
- let unicodeSkip = 1
811
- while (index < input.length) {
812
- const char = input[index]
813
- if (char === '{' || char === '}') {
814
- index += 1
815
- continue
816
- }
817
- if (char !== '\\') {
818
- output += char
819
- index += 1
820
- continue
821
- }
822
- const next = input[index + 1]
823
- if (next === '\\' || next === '{' || next === '}') {
824
- output += next
825
- index += 2
826
- continue
827
- }
828
- if (next === "'") {
829
- const hex = input.slice(index + 2, index + 4)
830
- if (!/^[0-9a-f]{2}$/i.test(hex)) throw formatError('RTF_PARSE', 'RTF 十六进制转义无效。')
831
- output += Buffer.from([Number.parseInt(hex, 16)]).toString('latin1')
832
- index += 4
833
- continue
834
- }
835
- if (next === '*') {
836
- index += 2
837
- continue
838
- }
839
- let cursor = index + 1
840
- let sign = 1
841
- if (input[cursor] === '-') {
842
- sign = -1
843
- cursor += 1
844
- }
845
- const wordStart = cursor
846
- while (/[a-z]/i.test(input[cursor] ?? '')) cursor += 1
847
- const word = input.slice(wordStart, cursor).toLowerCase()
848
- const numberStart = cursor
849
- while (/\d/.test(input[cursor] ?? '')) cursor += 1
850
- const number = input.slice(numberStart, cursor)
851
- if (input[cursor] === ' ') cursor += 1
852
- index = cursor
853
- if (!word) continue
854
- const value = number ? sign * Number(number) : null
855
- if (word === 'par') output += '\n\n'
856
- else if (word === 'line') output += '\n'
857
- else if (word === 'tab') output += '\t'
858
- else if (word === 'uc' && value !== null) unicodeSkip = Math.max(0, value)
859
- else if (word === 'u' && value !== null) {
860
- output += rtfUnicode(value)
861
- let skip = unicodeSkip
862
- while (skip > 0 && index < input.length) {
863
- index += 1
864
- skip -= 1
865
- }
866
- } else if (word === 'bin' && value !== null && value > 0) {
867
- index += value
868
- }
869
- }
870
-
871
- const segments = output
872
- .split(/\n{2,}/)
873
- .map((part) => part.replace(/^\n+|\n+$/g, '').trim())
874
- .filter(Boolean)
875
- .map((source, paragraph) => ({
876
- source,
877
- target: '',
878
- paragraph,
879
- meta: { format: 'rtf' },
880
- }))
881
- if (!segments.length) throw formatError('RTF_NO_UNITS', 'RTF 中没有可用的正文。')
882
- return { format: 'rtf', segments, warnings: [] }
883
- }
884
-
885
- function escapeRtf(value) {
886
- let output = ''
887
- for (const char of String(value)) {
888
- const code = char.codePointAt(0)
889
- if (char === '\\' || char === '{' || char === '}') output += `\\${char}`
890
- else if (char === '\n') output += '\\line '
891
- else if (char === '\t') output += '\\tab '
892
- else if (code >= 0x20 && code < 0x7f) output += char
893
- else if (code <= 0xffff) output += `\\u${code}?`
894
- else {
895
- const high = Math.floor((code - 0x10000) / 0x400) + 0xd800
896
- const low = ((code - 0x10000) % 0x400) + 0xdc00
897
- output += `\\u${high}?\\u${low}?`
898
- }
899
- }
900
- return output
901
- }
902
-
903
- export function buildRtf(doc) {
904
- const groups = new Map()
905
- for (const segment of doc.segments) {
906
- const key = segment.paragraph ?? 0
907
- if (!groups.has(key)) groups.set(key, [])
908
- groups.get(key).push(String(segment.target || segment.source || '').trim())
909
- }
910
- const body = [...groups.values()]
911
- .map((parts) => parts.filter(Boolean).join(' '))
912
- .filter(Boolean)
913
- .map((text) => `\\pard\\f0\\fs22 ${escapeRtf(text)}\\par`)
914
- .join('\n')
915
- return `{\\rtf1\\ansi\\deff0{\\fonttbl{\\f0 Calibri;}}\\viewkind4\\uc1\n${body}\n}\n`
916
- }
917
-
1
+ /**
2
+ * Dependency-free parsers/builders for the first-tier CAT exchange formats:
3
+ * CSV, SRT, WebVTT, PO and a basic XLIFF 1.2 subset.
4
+ *
5
+ * These formats are intentionally shallow: inline markup is flattened to text,
6
+ * complex subtitle styling and XLIFF extensions are not round-tripped. The
7
+ * original source text is kept so the normal CAT/TM/glossary flow applies.
8
+ */
9
+ import { CatError } from './errors.js'
10
+ import { decodeXml, escapeXml } from './xml.js'
11
+
12
+ const SRT_TIME = /^(\d{1,3}):(\d{2}):(\d{2})[,.](\d{1,3})$/
13
+ const VTT_TIME = /^(?:(\d{1,3}):)?(\d{1,2}):(\d{2})\.(\d{1,3})$/
14
+ const INLINE_XML = /<\/?(?:bpt|ept|ph|g|x|bx|ex|mrk|sub|hi|span)\b[^>]*>/gi
15
+ const INLINE_TOKEN = /\{(\d+)\}/g
16
+
17
+ function formatError(code, message) {
18
+ return new CatError(code, message)
19
+ }
20
+
21
+ function readXmlAttr(source, name) {
22
+ const match = String(source ?? '').match(new RegExp(`\\b${name}\\s*=\\s*["']([^"']*)["']`, 'i'))
23
+ return match ? decodeXml(match[1]) : ''
24
+ }
25
+
26
+ /**
27
+ * Convert XLIFF inline markup to stable {n} tokens while keeping the original
28
+ * XML for export. This prevents <ph>, <bpt>/<ept> and <g> placeholders from
29
+ * being silently dropped when a translation is written back.
30
+ */
31
+ function extractInline(raw) {
32
+ const tokens = []
33
+ const text = String(raw ?? '').replace(INLINE_XML, (xml) => {
34
+ const token = `{${tokens.length + 1}}`
35
+ tokens.push({ token, xml })
36
+ return token
37
+ })
38
+ return { text: decodeXml(text).trim(), tokens }
39
+ }
40
+
41
+ /** Render a tokenised string back to XML, escaping only the text portions. */
42
+ function renderInline(value, tokens) {
43
+ const text = String(value ?? '')
44
+ const map = new Map((Array.isArray(tokens) ? tokens : []).map((item) => [item.token, item.xml]))
45
+ let output = ''
46
+ let last = 0
47
+ for (const match of text.matchAll(INLINE_TOKEN)) {
48
+ output += escapeXml(text.slice(last, match.index))
49
+ output += map.get(match[0]) ?? escapeXml(match[0])
50
+ last = match.index + match[0].length
51
+ }
52
+ output += escapeXml(text.slice(last))
53
+ return output
54
+ }
55
+
56
+ /** Tokens present in the source but missing from the target. */
57
+ export function missingInlineTokens(tokens, target) {
58
+ if (!Array.isArray(tokens) || !tokens.length) return []
59
+ const value = String(target ?? '')
60
+ return tokens.map((item) => item.token).filter((token) => !value.includes(token))
61
+ }
62
+
63
+ /** Append placeholders the translator omitted, so export never silently loses them. */
64
+ function completeInline(value, tokens) {
65
+ let output = String(value ?? '')
66
+ for (const item of Array.isArray(tokens) ? tokens : []) {
67
+ if (!output.includes(item.token)) output += `${output && !/\s$/.test(output) ? ' ' : ''}${item.token}`
68
+ }
69
+ return output
70
+ }
71
+
72
+ export function parseCsvRows(text) {
73
+ const input = String(text ?? '').replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n')
74
+ const rows = []
75
+ let row = []
76
+ let field = ''
77
+ let quoted = false
78
+
79
+ for (let index = 0; index < input.length; index += 1) {
80
+ const char = input[index]
81
+ if (quoted) {
82
+ if (char === '"') {
83
+ if (input[index + 1] === '"') {
84
+ field += '"'
85
+ index += 1
86
+ } else {
87
+ quoted = false
88
+ }
89
+ } else {
90
+ field += char
91
+ }
92
+ continue
93
+ }
94
+
95
+ if (char === '"' && field === '') {
96
+ quoted = true
97
+ } else if (char === ',') {
98
+ row.push(field)
99
+ field = ''
100
+ } else if (char === '\n') {
101
+ row.push(field)
102
+ rows.push(row)
103
+ row = []
104
+ field = ''
105
+ } else {
106
+ field += char
107
+ }
108
+ }
109
+
110
+ if (quoted) throw formatError('CSV_PARSE', 'CSV 引号未闭合。')
111
+ if (field.length || row.length) {
112
+ row.push(field)
113
+ rows.push(row)
114
+ }
115
+ return rows
116
+ }
117
+
118
+ export function parseCsv(text, { sourceColumn, targetColumn } = {}) {
119
+ const rows = parseCsvRows(text)
120
+ if (!rows.length) throw formatError('CSV_PARSE', 'CSV 中没有可用的行。')
121
+
122
+ const normalize = (value) => String(value ?? '').trim().toLowerCase()
123
+ const sourceAliases = new Set(['source', 'src', 'original', '原文', '源文', '源文本'])
124
+ const targetAliases = new Set(['target', 'tgt', 'translation', '译文', '目標', '目标', '目标文本'])
125
+ const first = rows[0].map(normalize)
126
+ const hasHeader = first.some((value) => sourceAliases.has(value) || targetAliases.has(value))
127
+ const resolveColumn = (rawValue, aliases, fallback) => {
128
+ const value = String(rawValue ?? '').trim()
129
+ if (!value) return fallback
130
+ if (/^\d+$/.test(value)) {
131
+ const index = Number(value) - 1
132
+ if (index < 0) throw formatError('CSV_PARSE', 'CSV 列号从 1 开始。')
133
+ return index
134
+ }
135
+ const index = first.findIndex((item) => item === value.toLowerCase())
136
+ if (index < 0) throw formatError('CSV_PARSE', `CSV 中找不到列“${value}”。`)
137
+ return index
138
+ }
139
+
140
+ let sourceIndex = first.findIndex((value) => sourceAliases.has(value))
141
+ let targetIndex = first.findIndex((value) => targetAliases.has(value))
142
+ let start = 0
143
+
144
+ if (sourceColumn !== undefined || targetColumn !== undefined) {
145
+ sourceIndex = resolveColumn(sourceColumn, sourceAliases, sourceIndex >= 0 ? sourceIndex : 0)
146
+ targetIndex = resolveColumn(targetColumn, targetAliases, targetIndex)
147
+ start = hasHeader ? 1 : 0
148
+ } else if (sourceIndex >= 0 && targetIndex >= 0) {
149
+ start = 1
150
+ } else {
151
+ sourceIndex = 0
152
+ targetIndex = rows[0].length > 1 ? 1 : -1
153
+ }
154
+
155
+ const maxColumns = rows.reduce((max, row) => Math.max(max, row.length), 0)
156
+ if (sourceIndex < 0 || sourceIndex >= maxColumns) {
157
+ throw formatError('CSV_PARSE', 'CSV 源文列不存在。')
158
+ }
159
+ if (targetIndex >= maxColumns) {
160
+ throw formatError('CSV_PARSE', 'CSV 译文列不存在。')
161
+ }
162
+
163
+ const segments = []
164
+ for (let index = start; index < rows.length; index += 1) {
165
+ const row = rows[index]
166
+ const source = String(row[sourceIndex] ?? '').trim()
167
+ if (!source) continue
168
+ const target = targetIndex >= 0 ? String(row[targetIndex] ?? '').trim() : ''
169
+ segments.push({
170
+ source,
171
+ target,
172
+ paragraph: segments.length,
173
+ meta: { format: 'csv' },
174
+ })
175
+ }
176
+
177
+ if (!segments.length) throw formatError('CSV_NO_UNITS', 'CSV 中没有可用的源文行。')
178
+ return { format: 'csv', segments, warnings: [] }
179
+ }
180
+
181
+ function parseSrtTime(value) {
182
+ const match = String(value).trim().match(SRT_TIME)
183
+ if (!match) return null
184
+ const [, hours, minutes, seconds, millis] = match
185
+ return Number(hours) * 3600000 + Number(minutes) * 60000 + Number(seconds) * 1000 + Number(millis.padEnd(3, '0'))
186
+ }
187
+
188
+ export function formatSrtTime(milliseconds) {
189
+ const total = Math.max(0, Math.round(Number(milliseconds) || 0))
190
+ const hours = Math.floor(total / 3600000)
191
+ const minutes = Math.floor((total % 3600000) / 60000)
192
+ const seconds = Math.floor((total % 60000) / 1000)
193
+ const millis = total % 1000
194
+ return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')},${String(millis).padStart(3, '0')}`
195
+ }
196
+
197
+ function stripSubtitleMarkup(value) {
198
+ const assOverrides = String(value).match(/\{\\[^}]+\}/g) ?? []
199
+ return {
200
+ // Semantic tags such as <i>, <b>, <font> and WebVTT <c>/<v>/<ruby> are
201
+ // meaningful translation content and stay in the string verbatim.
202
+ text: decodeXml(String(value).replace(/<br\s*\/?>/gi, '\n').replace(/\{\\[^}]+\}/g, '')),
203
+ count: assOverrides.length,
204
+ }
205
+ }
206
+
207
+ function parseSubtitleBlocks(text, kind) {
208
+ const input = String(text ?? '').replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n').trim()
209
+ if (!input) throw formatError(`${kind.toUpperCase()}_PARSE`, `${kind.toUpperCase()} 文件内容为空。`)
210
+
211
+ const blocks = input.split(/\n{2,}/)
212
+ const segments = []
213
+ const warnings = []
214
+ let markupCount = 0
215
+
216
+ for (const block of blocks) {
217
+ const lines = block.split('\n')
218
+ if (!lines.length) continue
219
+ let cursor = 0
220
+ if (lines[0].trim().toUpperCase().startsWith('WEBVTT')) {
221
+ lines.shift()
222
+ while (lines.length && !lines[0].trim()) lines.shift()
223
+ if (!lines.length) continue
224
+ }
225
+ if (lines[0].trim().startsWith('NOTE') || lines[0].trim().startsWith('STYLE') || lines[0].trim().startsWith('REGION')) continue
226
+
227
+ let identifier = ''
228
+ if (!lines[cursor].includes('-->')) {
229
+ identifier = lines[cursor].trim()
230
+ cursor += 1
231
+ }
232
+ if (!lines[cursor]?.includes('-->')) continue
233
+ const timing = lines[cursor].trim()
234
+ cursor += 1
235
+
236
+ const timingMatch = timing.match(/^(\S+)\s*-->\s*(\S+)(.*)$/)
237
+ if (!timingMatch) {
238
+ throw formatError(`${kind.toUpperCase()}_PARSE`, `字幕时间轴无效: ${timing}`)
239
+ }
240
+ const start = kind === 'srt' ? parseSrtTime(timingMatch[1]) : parseVttTime(timingMatch[1])
241
+ const end = kind === 'srt' ? parseSrtTime(timingMatch[2]) : parseVttTime(timingMatch[2])
242
+ if (start === null || end === null) {
243
+ throw formatError(`${kind.toUpperCase()}_PARSE`, `字幕时间轴无效: ${timing}`)
244
+ }
245
+ if (end <= start) {
246
+ throw formatError(
247
+ `${kind.toUpperCase()}_PARSE`,
248
+ `字幕结束时间必须晚于开始时间: ${timing}`,
249
+ )
250
+ }
251
+
252
+ const rawText = lines.slice(cursor).join('\n').trim()
253
+ if (!rawText) continue
254
+ const stripped = stripSubtitleMarkup(rawText)
255
+ markupCount += stripped.count
256
+ const text = stripped.text.trim()
257
+ if (!text) continue
258
+ segments.push({
259
+ source: text,
260
+ target: '',
261
+ paragraph: segments.length,
262
+ meta: {
263
+ format: kind,
264
+ start,
265
+ end,
266
+ settings: timingMatch[3].trim(),
267
+ identifier,
268
+ },
269
+ })
270
+ }
271
+
272
+ if (!segments.length) throw formatError(`${kind.toUpperCase()}_NO_UNITS`, `${kind.toUpperCase()} 中没有可用的字幕。`)
273
+ if (markupCount) {
274
+ warnings.push({
275
+ code: `${kind.toUpperCase()}_INLINE_MARKUP_LOST`,
276
+ count: markupCount,
277
+ message: `${kind.toUpperCase()} 含 ${markupCount} 个 ASS 样式覆盖标记({\\...}),导入时已移除;语义标签已保留。`,
278
+ })
279
+ }
280
+ return { format: kind, segments, warnings }
281
+ }
282
+
283
+ export function parseSrt(text) {
284
+ return parseSubtitleBlocks(text, 'srt')
285
+ }
286
+
287
+ function parseVttTime(value) {
288
+ const match = String(value).trim().match(VTT_TIME)
289
+ if (!match) return null
290
+ const [, hours, minutes, seconds, millis] = match
291
+ return Number(hours || 0) * 3600000 + Number(minutes) * 60000 + Number(seconds) * 1000 + Number(millis.padEnd(3, '0'))
292
+ }
293
+
294
+ export function formatVttTime(milliseconds) {
295
+ const total = Math.max(0, Math.round(Number(milliseconds) || 0))
296
+ const hours = Math.floor(total / 3600000)
297
+ const minutes = Math.floor((total % 3600000) / 60000)
298
+ const seconds = Math.floor((total % 60000) / 1000)
299
+ const millis = total % 1000
300
+ return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}.${String(millis).padStart(3, '0')}`
301
+ }
302
+
303
+ export function parseVtt(text) {
304
+ return parseSubtitleBlocks(text, 'vtt')
305
+ }
306
+
307
+ function subtitleText(segment) {
308
+ return String(segment.target || segment.source || '').trim()
309
+ }
310
+
311
+ function cueTimes(segment, fallbackStart) {
312
+ const start = Number(segment.meta?.start)
313
+ const end = Number(segment.meta?.end)
314
+ if (Number.isFinite(start) && start >= 0 && Number.isFinite(end) && end > start) {
315
+ return { start, end }
316
+ }
317
+ // Text-only documents (and any cue without usable timing metadata) start
318
+ // where the previous cue ended. This avoids the old index * 2000 fallback
319
+ // overlapping an explicitly timed cue that sits earlier in the document.
320
+ const safeStart = Number.isFinite(start) && start >= 0 ? start : fallbackStart
321
+ return { start: safeStart, end: safeStart + 2000 }
322
+ }
323
+
324
+ function buildCues(segments) {
325
+ let cursor = 0
326
+ return segments.map((segment) => {
327
+ const times = cueTimes(segment, cursor)
328
+ cursor = Math.max(cursor, times.end)
329
+ return { segment, times }
330
+ })
331
+ }
332
+
333
+ export function buildSrt(doc) {
334
+ return buildCues(doc.segments)
335
+ .map(({ segment, times }, index) => [
336
+ String(index + 1),
337
+ `${formatSrtTime(times.start)} --> ${formatSrtTime(times.end)}`,
338
+ subtitleText(segment),
339
+ '',
340
+ ].join('\n'))
341
+ .join('\n')
342
+ }
343
+
344
+ export function buildVtt(doc) {
345
+ const cues = buildCues(doc.segments).map(({ segment, times }) => {
346
+ const identifier = segment.meta?.identifier ? `${segment.meta.identifier}\n` : ''
347
+ return `${identifier}${formatVttTime(times.start)} --> ${formatVttTime(times.end)}\n${subtitleText(segment)}\n`
348
+ })
349
+ return `WEBVTT\n\n${cues.join('\n')}`
350
+ }
351
+
352
+ function parsePoQuoted(raw) {
353
+ const match = String(raw).trim().match(/^"((?:\\.|[^"\\])*)"$/)
354
+ if (!match) return null
355
+ return match[1].replace(/\\(n|t|r|"|\\)/g, (all, char) => {
356
+ if (char === 'n') return '\n'
357
+ if (char === 't') return '\t'
358
+ if (char === 'r') return '\r'
359
+ return char
360
+ })
361
+ }
362
+
363
+ function poField(line) {
364
+ const match = line.match(/^(msgctxt|msgid_plural|msgid|msgstr(?:\[\d+\])?)\s+(.*)$/)
365
+ if (!match) return null
366
+ const value = parsePoQuoted(match[2])
367
+ if (value === null) return null
368
+ return { name: match[1], value }
369
+ }
370
+
371
+ export function parsePo(text) {
372
+ const input = String(text ?? '').replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n')
373
+ if (!input.trim()) throw formatError('PO_PARSE', 'PO 文件内容为空。')
374
+
375
+ const segments = []
376
+ const obsolete = []
377
+ let entry = null
378
+ let current = null
379
+ let pendingComments = []
380
+ let obsoleteBlock = null
381
+
382
+ const flushObsolete = () => {
383
+ if (!obsoleteBlock?.length) return
384
+ const block = obsoleteBlock.join('\n').trimEnd()
385
+ if (block) obsolete.push(block)
386
+ obsoleteBlock = null
387
+ }
388
+
389
+ const flush = () => {
390
+ if (!entry) return
391
+ const source = String(entry.msgid ?? '').trim()
392
+ if (source) {
393
+ const msgidPlural = String(entry.msgid_plural ?? '').trim()
394
+ const pluralKeys = Object.keys(entry.msgstr ?? {})
395
+ .filter((key) => /^\d+$/.test(key))
396
+ .sort((left, right) => Number(left) - Number(right))
397
+ const plurals = pluralKeys
398
+ .map((key) => String(entry.msgstr[key] ?? '').trim())
399
+ .filter(Boolean)
400
+ const target = String(entry.msgstr?.[''] ?? plurals[0] ?? '').trim()
401
+ const comments = entry.comments.filter(Boolean)
402
+ segments.push({
403
+ source,
404
+ target,
405
+ paragraph: segments.length,
406
+ meta: {
407
+ format: 'po',
408
+ context: String(entry.msgctxt ?? '').trim(),
409
+ msgidPlural,
410
+ plural: Boolean(msgidPlural),
411
+ ...(comments.length ? { comments } : {}),
412
+ ...(plurals.length ? { plurals } : {}),
413
+ },
414
+ })
415
+ }
416
+ entry = null
417
+ current = null
418
+ }
419
+
420
+ for (const rawLine of input.split('\n')) {
421
+ const line = rawLine.trim()
422
+ if (!line) {
423
+ flush()
424
+ flushObsolete()
425
+ continue
426
+ }
427
+ if (line.startsWith('#~')) {
428
+ if (!obsoleteBlock) {
429
+ flush()
430
+ obsoleteBlock = []
431
+ }
432
+ obsoleteBlock.push(rawLine.replace(/^#~\s?/, ''))
433
+ continue
434
+ }
435
+ if (obsoleteBlock) {
436
+ obsoleteBlock.push(rawLine)
437
+ continue
438
+ }
439
+ if (line.startsWith('#')) {
440
+ const comment = line.replace(/^#[.,:|\s]*/, '').trim()
441
+ if (comment) {
442
+ if (entry) entry.comments.push(comment)
443
+ else pendingComments.push(comment)
444
+ }
445
+ continue
446
+ }
447
+
448
+ const field = poField(line)
449
+ if (field) {
450
+ entry ??= { msgstr: {}, comments: pendingComments.splice(0) }
451
+ current = field.name
452
+ if (current.startsWith('msgstr')) {
453
+ entry.msgstr[current === 'msgstr' ? '' : current.match(/\[(\d+)\]/)?.[1] ?? '0'] = field.value
454
+ } else {
455
+ entry[current] = field.value
456
+ }
457
+ continue
458
+ }
459
+ if (line.startsWith('"') && current) {
460
+ const value = parsePoQuoted(line)
461
+ if (value === null) continue
462
+ if (current.startsWith('msgstr')) {
463
+ const key = current === 'msgstr' ? '' : current.match(/\[(\d+)\]/)?.[1] ?? '0'
464
+ entry.msgstr[key] = `${entry.msgstr[key] ?? ''}${value}`
465
+ } else {
466
+ entry[current] = `${entry[current] ?? ''}${value}`
467
+ }
468
+ }
469
+ }
470
+ flush()
471
+ flushObsolete()
472
+
473
+ if (!segments.length) throw formatError('PO_NO_UNITS', 'PO 中没有可用的 msgid/msgstr 条目。')
474
+ return { format: 'po', segments, obsolete, warnings: [] }
475
+ }
476
+
477
+ function poEscape(value) {
478
+ return String(value ?? '')
479
+ .replace(/\\/g, '\\\\')
480
+ .replace(/"/g, '\\"')
481
+ .replace(/\n/g, '\\n')
482
+ .replace(/\t/g, '\\t')
483
+ .replace(/\r/g, '\\r')
484
+ }
485
+
486
+ function poLine(field, value) {
487
+ return `${field} "${poEscape(value)}"`
488
+ }
489
+
490
+ export function buildPo(doc) {
491
+ const header = [
492
+ 'msgid ""',
493
+ 'msgstr ""',
494
+ '"Content-Type: text/plain; charset=UTF-8\\n"',
495
+ `"Language: ${doc.targetLang}\\n"`,
496
+ '',
497
+ ].join('\n')
498
+
499
+ const entries = doc.segments.map((segment) => {
500
+ const context = String(segment.meta?.context ?? '').trim()
501
+ const plural = String(segment.meta?.msgidPlural ?? '').trim()
502
+ const target = String(segment.target || segment.source || '').trim()
503
+ const comments = Array.isArray(segment.meta?.comments) ? segment.meta.comments : []
504
+ const lines = comments.filter(Boolean).map((comment) => `#. ${comment}`)
505
+ if (context) lines.push(poLine('msgctxt', context))
506
+ lines.push(poLine('msgid', segment.source))
507
+ if (plural) {
508
+ const forms = Array.isArray(segment.meta?.plurals) ? segment.meta.plurals : []
509
+ lines.push(poLine('msgid_plural', plural))
510
+ lines.push(poLine('msgstr[0]', target))
511
+ for (let index = 1; index < Math.max(forms.length, 2); index += 1) {
512
+ lines.push(poLine(`msgstr[${index}]`, forms[index] || target))
513
+ }
514
+ } else {
515
+ lines.push(poLine('msgstr', target))
516
+ }
517
+ return lines.join('\n')
518
+ })
519
+
520
+ const obsolete = Array.isArray(doc.formatMeta?.poObsolete)
521
+ ? doc.formatMeta.poObsolete
522
+ .filter((block) => typeof block === 'string' && block.trim())
523
+ .map((block) => block.split('\n').map((line) => `#~ ${line}`).join('\n'))
524
+ : []
525
+
526
+ const blocks = [...entries, ...obsolete].filter(Boolean)
527
+ return `${header}\n${blocks.join('\n\n')}\n`
528
+ }
529
+
530
+ function parseXliffUnit(body, attrs, format, tokens) {
531
+ const sourceMatch = body.match(/<(?:[\w.-]+:)?source\b[^>]*>([\s\S]*?)<\/(?:[\w.-]+:)?source>/i)
532
+ ?? body.match(/<(?:[\w.-]+:)?source\b[^>]*\/>/i)
533
+ if (!sourceMatch) return null
534
+ const sourceRaw = sourceMatch[1] ?? ''
535
+ const source = extractInline(sourceRaw)
536
+ if (!source.text) return null
537
+ const targetMatch = body.match(/<(?:[\w.-]+:)?target\b[^>]*>([\s\S]*?)<\/(?:[\w.-]+:)?target>/i)
538
+ ?? body.match(/<(?:[\w.-]+:)?target\b[^>]*\/>/i)
539
+ const target = targetMatch ? extractInline(targetMatch[1] ?? '').text : ''
540
+ tokens.push(...source.tokens)
541
+ return {
542
+ source: source.text,
543
+ target,
544
+ paragraph: 0,
545
+ meta: {
546
+ format,
547
+ xliffId: readXmlAttr(attrs, 'id'),
548
+ ...(source.tokens.length ? { inline: source.tokens } : {}),
549
+ },
550
+ }
551
+ }
552
+
553
+ function parseXliff2(input, rootAttrs) {
554
+ const sourceLang = readXmlAttr(rootAttrs, 'srcLang') || readXmlAttr(rootAttrs, 'source-language')
555
+ const targetLang = readXmlAttr(rootAttrs, 'trgLang') || readXmlAttr(rootAttrs, 'target-language')
556
+ const units = []
557
+ const inlineTokens = []
558
+ const unitRe = /<(?:[\w.-]+:)?unit\b([^>]*)>([\s\S]*?)<\/(?:[\w.-]+:)?unit>/gi
559
+ let match
560
+ while ((match = unitRe.exec(input))) {
561
+ const unit = parseXliffUnit(match[2], match[1], 'xliff2', inlineTokens)
562
+ if (!unit) continue
563
+ unit.paragraph = units.length
564
+ units.push(unit)
565
+ }
566
+ if (!units.length) throw formatError('XLIFF_NO_UNITS', 'XLIFF 中没有可用的 unit/segment。')
567
+ return { format: 'xliff2', sourceLang, targetLang, segments: units, warnings: [] }
568
+ }
569
+
570
+ export function parseXliff(text) {
571
+ const input = String(text ?? '').replace(/^\uFEFF/, '')
572
+ if (!input.trim()) throw formatError('XLIFF_PARSE', 'XLIFF 文件内容为空。')
573
+
574
+ const rootTag = input.match(/<((?:[\w.-]+:)?xliff)\b([^>]*)>/i)
575
+ const rootAttrs = rootTag?.[2] ?? ''
576
+ const version = readXmlAttr(rootAttrs, 'version')
577
+ const looksLikeXliff2 = /^2\./i.test(version)
578
+ || (!/<(?:[\w.-]+:)?trans-unit\b/i.test(input) && /<(?:[\w.-]+:)?unit\b/i.test(input))
579
+ if (looksLikeXliff2) return parseXliff2(input, rootAttrs)
580
+
581
+ const fileTag = input.match(/<((?:[\w.-]+:)?file)\b([^>]*)>/i)
582
+ const sourceLang = readXmlAttr(fileTag?.[2], 'source-language')
583
+ const targetLang = readXmlAttr(fileTag?.[2], 'target-language')
584
+
585
+ const units = []
586
+ const unitRe = /<(?:[\w.-]+:)?trans-unit\b([^>]*)>([\s\S]*?)<\/(?:[\w.-]+:)?trans-unit>/gi
587
+ let match
588
+ while ((match = unitRe.exec(input))) {
589
+ const unit = parseXliffUnit(match[2], match[1], 'xliff', [])
590
+ if (!unit) continue
591
+ unit.paragraph = units.length
592
+ units.push(unit)
593
+ }
594
+
595
+ if (!units.length) throw formatError('XLIFF_NO_UNITS', 'XLIFF 中没有可用的 trans-unit。')
596
+ return { format: 'xliff', sourceLang, targetLang, segments: units, warnings: [] }
597
+ }
598
+
599
+ export function buildXliff(doc) {
600
+ const units = doc.segments.map((segment, index) => {
601
+ const id = String(segment.meta?.xliffId || index + 1)
602
+ const tokens = Array.isArray(segment.meta?.inline) ? segment.meta.inline : []
603
+ const target = completeInline(segment.target || segment.source, tokens)
604
+ return [
605
+ ` <trans-unit id="${escapeXml(id)}">`,
606
+ ` <source xml:lang="${escapeXml(doc.sourceLang)}">${renderInline(segment.source, tokens)}</source>`,
607
+ ` <target xml:lang="${escapeXml(doc.targetLang)}">${renderInline(target, tokens)}</target>`,
608
+ ' </trans-unit>',
609
+ ].join('\n')
610
+ })
611
+ return [
612
+ '<?xml version="1.0" encoding="UTF-8"?>',
613
+ '<xliff version="1.2">',
614
+ ` <file source-language="${escapeXml(doc.sourceLang)}" target-language="${escapeXml(doc.targetLang)}" datatype="plaintext" original="${escapeXml(doc.name)}">`,
615
+ ' <body>',
616
+ ...units,
617
+ ' </body>',
618
+ ' </file>',
619
+ '</xliff>',
620
+ '',
621
+ ].join('\n')
622
+ }
623
+
624
+ export function buildXliff2(doc) {
625
+ const units = doc.segments.map((segment, index) => {
626
+ const id = String(segment.meta?.xliffId || index + 1)
627
+ const tokens = Array.isArray(segment.meta?.inline) ? segment.meta.inline : []
628
+ const target = completeInline(segment.target || segment.source, tokens)
629
+ return [
630
+ ` <unit id="${escapeXml(id)}">`,
631
+ ' <segment>',
632
+ ` <source>${renderInline(segment.source, tokens)}</source>`,
633
+ ` <target>${renderInline(target, tokens)}</target>`,
634
+ ' </segment>',
635
+ ' </unit>',
636
+ ].join('\n')
637
+ })
638
+ return [
639
+ '<?xml version="1.0" encoding="UTF-8"?>',
640
+ '<xliff xmlns="urn:oasis:names:tc:xliff:document:2.0" version="2.0"',
641
+ ` srcLang="${escapeXml(doc.sourceLang)}" trgLang="${escapeXml(doc.targetLang)}">`,
642
+ ' <file id="f1">',
643
+ ...units,
644
+ ' </file>',
645
+ '</xliff>',
646
+ '',
647
+ ].join('\n')
648
+ }
649
+
650
+ function htmlToText(input) {
651
+ let text = String(input ?? '').replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n')
652
+ text = text
653
+ .replace(/<!--[\s\S]*?-->/g, '')
654
+ .replace(/<(script|style|noscript|head)\b[\s\S]*?<\/\1>/gi, '')
655
+ .replace(/<br\s*\/?>/gi, '\n')
656
+ .replace(/<\/(?:p|div|li|h[1-6]|section|article|blockquote|pre|tr)>/gi, '\n\n')
657
+ .replace(/<\/(?:td|th)>/gi, '\t')
658
+ .replace(/<[^>]+>/g, '')
659
+ return decodeXml(text)
660
+ .replace(/[ \t]+\n/g, '\n')
661
+ .replace(/\n{3,}/g, '\n\n')
662
+ .trim()
663
+ }
664
+
665
+ export function parseHtml(text) {
666
+ const input = String(text ?? '')
667
+ if (!input.trim()) throw formatError('HTML_PARSE', 'HTML 文件内容为空。')
668
+ const plain = htmlToText(input)
669
+ if (!plain) throw formatError('HTML_NO_UNITS', 'HTML 中没有可用的正文。')
670
+ const segments = plain
671
+ .split(/\n{2,}/)
672
+ .map((part) => part.trim())
673
+ .filter(Boolean)
674
+ .map((source, index) => ({
675
+ source,
676
+ target: '',
677
+ paragraph: index,
678
+ meta: { format: 'html' },
679
+ }))
680
+ if (!segments.length) throw formatError('HTML_NO_UNITS', 'HTML 中没有可用的正文。')
681
+ return { format: 'html', segments, warnings: [] }
682
+ }
683
+
684
+ export function buildHtml(doc) {
685
+ const groups = new Map()
686
+ for (const segment of doc.segments) {
687
+ const key = segment.paragraph ?? 0
688
+ if (!groups.has(key)) groups.set(key, [])
689
+ groups.get(key).push(String(segment.target || segment.source || '').trim())
690
+ }
691
+ const paragraphs = [...groups.values()]
692
+ .map((parts) => parts.filter(Boolean).join(' '))
693
+ .filter(Boolean)
694
+ .map((text) => ` <p>${escapeXml(text)}</p>`)
695
+ .join('\n')
696
+ return [
697
+ '<!doctype html>',
698
+ '<html lang="' + escapeXml(doc.targetLang || 'en') + '">',
699
+ '<head><meta charset="utf-8"><title>' + escapeXml(doc.name || 'CAIT export') + '</title></head>',
700
+ '<body>',
701
+ paragraphs,
702
+ '</body>',
703
+ '</html>',
704
+ '',
705
+ ].join('\n')
706
+ }
707
+
708
+ function escapeJsonPointer(value) {
709
+ return String(value).replace(/~/g, '~0').replace(/\//g, '~1')
710
+ }
711
+
712
+ function unescapeJsonPointer(value) {
713
+ return String(value).replace(/~1/g, '/').replace(/~0/g, '~')
714
+ }
715
+
716
+ function walkJsonStrings(value, pointer, visit) {
717
+ if (typeof value === 'string') {
718
+ visit(value, pointer)
719
+ return
720
+ }
721
+ if (Array.isArray(value)) {
722
+ value.forEach((item, index) => walkJsonStrings(item, `${pointer}/${index}`, visit))
723
+ return
724
+ }
725
+ if (value && typeof value === 'object') {
726
+ for (const [key, item] of Object.entries(value)) {
727
+ walkJsonStrings(item, `${pointer}/${escapeJsonPointer(key)}`, visit)
728
+ }
729
+ }
730
+ }
731
+
732
+ function setJsonPointer(root, pointer, value) {
733
+ const parts = String(pointer).split('/').slice(1).map(unescapeJsonPointer)
734
+ if (!parts.length) return value
735
+ let current = root
736
+ for (let index = 0; index < parts.length - 1; index += 1) {
737
+ if (current == null) throw new Error('JSON path does not exist')
738
+ current = current[parts[index]]
739
+ }
740
+ if (current == null) throw new Error('JSON path does not exist')
741
+ current[parts[parts.length - 1]] = value
742
+ return root
743
+ }
744
+
745
+ export function parseJson(text) {
746
+ const input = String(text ?? '')
747
+ if (!input.trim()) throw formatError('JSON_PARSE', 'JSON 文件内容为空。')
748
+ let root
749
+ try {
750
+ root = JSON.parse(input)
751
+ } catch (error) {
752
+ throw formatError('JSON_PARSE', `JSON 解析失败: ${error.message}`)
753
+ }
754
+ const segments = []
755
+ walkJsonStrings(root, '', (source, pointer) => {
756
+ if (!source.trim()) return
757
+ segments.push({
758
+ source,
759
+ target: '',
760
+ paragraph: segments.length,
761
+ meta: { format: 'json', jsonPath: pointer },
762
+ })
763
+ })
764
+ if (!segments.length) throw formatError('JSON_NO_UNITS', 'JSON 中没有可翻译的字符串。')
765
+ return {
766
+ format: 'json',
767
+ segments,
768
+ formatMeta: { jsonTemplate: JSON.stringify(root) },
769
+ warnings: [],
770
+ }
771
+ }
772
+
773
+ export function buildJson(doc) {
774
+ let root
775
+ try {
776
+ root = doc.formatMeta?.jsonTemplate ? JSON.parse(doc.formatMeta.jsonTemplate) : {}
777
+ } catch {
778
+ root = {}
779
+ }
780
+ for (const segment of doc.segments) {
781
+ const pointer = segment.meta?.jsonPath
782
+ if (!pointer) continue
783
+ const text = String(segment.target || segment.source || '')
784
+ setJsonPointer(root, pointer, text)
785
+ }
786
+ return `${JSON.stringify(root, null, 2)}\n`
787
+ }
788
+
789
+ const RTF_DESTINATION_GROUP = /\{\\\*?\\?(?:fonttbl|colortbl|stylesheet|info|pict|header|footer|object|themedata|colorschememapping|datastore|latentstyles|rsidtbl|generator|xmlnstbl)\b[^{}]*(?:\{[^{}]*\}[^{}]*)*\}/gi
790
+
791
+ function rtfUnicode(code) {
792
+ const value = Number(code)
793
+ return String.fromCharCode(value < 0 ? value + 65536 : value)
794
+ }
795
+
796
+ export function parseRtf(text) {
797
+ let input = String(text ?? '').replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n')
798
+ if (!input.trim()) throw formatError('RTF_PARSE', 'RTF 文件内容为空。')
799
+ if (!input.trimStart().startsWith('{\\rtf')) {
800
+ throw formatError('RTF_PARSE', 'RTF 文件缺少 {\\rtf 头。')
801
+ }
802
+ let previous
803
+ do {
804
+ previous = input
805
+ input = input.replace(RTF_DESTINATION_GROUP, '')
806
+ } while (previous !== input)
807
+
808
+ let output = ''
809
+ let index = 0
810
+ let unicodeSkip = 1
811
+ while (index < input.length) {
812
+ const char = input[index]
813
+ if (char === '{' || char === '}') {
814
+ index += 1
815
+ continue
816
+ }
817
+ if (char !== '\\') {
818
+ output += char
819
+ index += 1
820
+ continue
821
+ }
822
+ const next = input[index + 1]
823
+ if (next === '\\' || next === '{' || next === '}') {
824
+ output += next
825
+ index += 2
826
+ continue
827
+ }
828
+ if (next === "'") {
829
+ const hex = input.slice(index + 2, index + 4)
830
+ if (!/^[0-9a-f]{2}$/i.test(hex)) throw formatError('RTF_PARSE', 'RTF 十六进制转义无效。')
831
+ output += Buffer.from([Number.parseInt(hex, 16)]).toString('latin1')
832
+ index += 4
833
+ continue
834
+ }
835
+ if (next === '*') {
836
+ index += 2
837
+ continue
838
+ }
839
+ let cursor = index + 1
840
+ let sign = 1
841
+ if (input[cursor] === '-') {
842
+ sign = -1
843
+ cursor += 1
844
+ }
845
+ const wordStart = cursor
846
+ while (/[a-z]/i.test(input[cursor] ?? '')) cursor += 1
847
+ const word = input.slice(wordStart, cursor).toLowerCase()
848
+ const numberStart = cursor
849
+ while (/\d/.test(input[cursor] ?? '')) cursor += 1
850
+ const number = input.slice(numberStart, cursor)
851
+ if (input[cursor] === ' ') cursor += 1
852
+ index = cursor
853
+ if (!word) continue
854
+ const value = number ? sign * Number(number) : null
855
+ if (word === 'par') output += '\n\n'
856
+ else if (word === 'line') output += '\n'
857
+ else if (word === 'tab') output += '\t'
858
+ else if (word === 'uc' && value !== null) unicodeSkip = Math.max(0, value)
859
+ else if (word === 'u' && value !== null) {
860
+ output += rtfUnicode(value)
861
+ let skip = unicodeSkip
862
+ while (skip > 0 && index < input.length) {
863
+ index += 1
864
+ skip -= 1
865
+ }
866
+ } else if (word === 'bin' && value !== null && value > 0) {
867
+ index += value
868
+ }
869
+ }
870
+
871
+ const segments = output
872
+ .split(/\n{2,}/)
873
+ .map((part) => part.replace(/^\n+|\n+$/g, '').trim())
874
+ .filter(Boolean)
875
+ .map((source, paragraph) => ({
876
+ source,
877
+ target: '',
878
+ paragraph,
879
+ meta: { format: 'rtf' },
880
+ }))
881
+ if (!segments.length) throw formatError('RTF_NO_UNITS', 'RTF 中没有可用的正文。')
882
+ return { format: 'rtf', segments, warnings: [] }
883
+ }
884
+
885
+ function escapeRtf(value) {
886
+ let output = ''
887
+ for (const char of String(value)) {
888
+ const code = char.codePointAt(0)
889
+ if (char === '\\' || char === '{' || char === '}') output += `\\${char}`
890
+ else if (char === '\n') output += '\\line '
891
+ else if (char === '\t') output += '\\tab '
892
+ else if (code >= 0x20 && code < 0x7f) output += char
893
+ else if (code <= 0xffff) output += `\\u${code}?`
894
+ else {
895
+ const high = Math.floor((code - 0x10000) / 0x400) + 0xd800
896
+ const low = ((code - 0x10000) % 0x400) + 0xdc00
897
+ output += `\\u${high}?\\u${low}?`
898
+ }
899
+ }
900
+ return output
901
+ }
902
+
903
+ export function buildRtf(doc) {
904
+ const groups = new Map()
905
+ for (const segment of doc.segments) {
906
+ const key = segment.paragraph ?? 0
907
+ if (!groups.has(key)) groups.set(key, [])
908
+ groups.get(key).push(String(segment.target || segment.source || '').trim())
909
+ }
910
+ const body = [...groups.values()]
911
+ .map((parts) => parts.filter(Boolean).join(' '))
912
+ .filter(Boolean)
913
+ .map((text) => `\\pard\\f0\\fs22 ${escapeRtf(text)}\\par`)
914
+ .join('\n')
915
+ return `{\\rtf1\\ansi\\deff0{\\fonttbl{\\f0 Calibri;}}\\viewkind4\\uc1\n${body}\n}\n`
916
+ }
917
+