dsh-cait 1.0.3 → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +367 -352
- package/LICENSE +201 -201
- package/README.ja.md +4 -3
- package/README.md +424 -423
- package/README.zh-CN.md +391 -390
- package/client/components.js +2377 -2377
- package/client/helpers.js +103 -103
- package/client/locales.js +730 -730
- package/client/rpc.js +93 -93
- package/client/styles.js +258 -258
- package/client.js +3556 -3556
- package/icon.svg +8 -8
- package/index.js +45 -45
- package/lib/api.js +223 -223
- package/lib/docx.js +389 -389
- package/lib/errors.js +8 -8
- package/lib/formats.js +917 -917
- package/lib/glossary.js +178 -178
- package/lib/import-export.js +605 -605
- package/lib/libraries.js +183 -183
- package/lib/markdown.js +28 -28
- package/lib/mt.js +152 -152
- package/lib/persistence.js +321 -321
- package/lib/pptx.js +89 -89
- package/lib/segmenter.js +84 -84
- package/lib/similarity.js +113 -113
- package/lib/store-utils.js +339 -339
- package/lib/store.js +734 -734
- package/lib/tm.js +160 -160
- package/lib/tmx.js +85 -85
- package/lib/tools.js +472 -472
- package/lib/xlsx.js +218 -218
- package/lib/xml.js +31 -31
- package/locale/en.json +11 -11
- package/locale/zh.json +11 -11
- package/package.json +83 -83
- package/scripts/build-client.mjs +13 -13
package/lib/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
|
+
|