dsh-cait 1.0.4 → 1.1.3
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 +202 -0
- package/README.ja.md +147 -25
- package/README.md +176 -27
- package/README.zh-CN.md +137 -26
- package/client/components.js +2047 -340
- package/client/entry.js +13 -0
- package/client/helpers.js +110 -7
- package/client/locales.js +435 -6
- package/client/rpc.js +1 -1
- package/client/styles.js +137 -3
- package/client.js +2742 -356
- package/index.js +3 -0
- package/lib/aligner.js +411 -0
- package/lib/api.js +31 -0
- package/lib/context.js +71 -0
- package/lib/docx.js +12 -9
- package/lib/epub.js +355 -0
- package/lib/formats.js +88 -33
- package/lib/glossary.js +4 -4
- package/lib/html.js +271 -0
- package/lib/import-export.js +208 -95
- package/lib/libraries.js +55 -0
- package/lib/library-export.js +75 -0
- package/lib/library-view.js +215 -0
- package/lib/mt.js +17 -7
- package/lib/persistence.js +92 -7
- package/lib/pre-translate.js +263 -0
- package/lib/preprocess.js +65 -0
- package/lib/propagate.js +133 -0
- package/lib/qa.js +167 -0
- package/lib/store-utils.js +48 -6
- package/lib/store.js +143 -12
- package/lib/term-extract.js +190 -0
- package/lib/terms.js +102 -0
- package/lib/tm.js +123 -11
- package/lib/tmx.js +47 -2
- package/lib/tools.js +472 -0
- package/package.json +2 -2
package/lib/html.js
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dependency-free HTML/XHTML block scanner shared by the HTML and EPUB
|
|
3
|
+
* adapters.
|
|
4
|
+
*
|
|
5
|
+
* A real DOM is unnecessary for CAT import: what matters is a stable mapping
|
|
6
|
+
* between translatable inline runs and exact byte ranges of the source file.
|
|
7
|
+
* The scanner records contiguous runs between block-level elements, so export
|
|
8
|
+
* can replace exactly those ranges while leaving structure, attributes,
|
|
9
|
+
* scripts and styles untouched.
|
|
10
|
+
*/
|
|
11
|
+
import { decodeXml, escapeXml } from './xml.js'
|
|
12
|
+
|
|
13
|
+
const VOID_TAGS = new Set([
|
|
14
|
+
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link',
|
|
15
|
+
'meta', 'param', 'source', 'track', 'wbr',
|
|
16
|
+
])
|
|
17
|
+
|
|
18
|
+
const BLOCK_TAGS = new Set([
|
|
19
|
+
'address', 'article', 'aside', 'blockquote', 'body', 'caption', 'dd', 'details',
|
|
20
|
+
'dialog', 'div', 'dl', 'dt', 'fieldset', 'figcaption', 'figure', 'footer',
|
|
21
|
+
'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hgroup', 'hr', 'html',
|
|
22
|
+
'li', 'main', 'nav', 'ol', 'p', 'pre', 'section', 'summary', 'table',
|
|
23
|
+
'tbody', 'td', 'tfoot', 'th', 'thead', 'tr', 'ul',
|
|
24
|
+
])
|
|
25
|
+
|
|
26
|
+
const SKIP_TAGS = new Set([
|
|
27
|
+
'canvas', 'head', 'iframe', 'math', 'noscript', 'object', 'rp', 'rt',
|
|
28
|
+
'script', 'style', 'svg', 'template',
|
|
29
|
+
])
|
|
30
|
+
|
|
31
|
+
const TAG_PATTERN = /<!--[\s\S]*?-->|<!\[CDATA\[[\s\S]*?\]\]>|<![^>]*>|<\?[\s\S]*?\?>|<(\/?)([A-Za-z][\w:.-]*)((?:"[^"]*"|'[^']*'|[^>"'])*?)(\/?)>/g
|
|
32
|
+
|
|
33
|
+
function parseAttributes(source) {
|
|
34
|
+
const attrs = {}
|
|
35
|
+
const pattern = /([^\s=/>]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?/g
|
|
36
|
+
let match
|
|
37
|
+
while ((match = pattern.exec(String(source ?? '')))) {
|
|
38
|
+
const name = match[1].toLowerCase()
|
|
39
|
+
attrs[name] = decodeXml(match[2] ?? match[3] ?? match[4] ?? '')
|
|
40
|
+
}
|
|
41
|
+
return attrs
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function escapeRegExp(value) {
|
|
45
|
+
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function findClosingTag(html, tag, from) {
|
|
49
|
+
const pattern = new RegExp(`</${escapeRegExp(tag)}\\s*>`, 'ig')
|
|
50
|
+
pattern.lastIndex = from
|
|
51
|
+
const match = pattern.exec(html)
|
|
52
|
+
return match ? { start: match.index, end: match.index + match[0].length } : null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isHidden(node) {
|
|
56
|
+
const attrs = node.attrs || {}
|
|
57
|
+
if (Object.prototype.hasOwnProperty.call(attrs, 'hidden')) return true
|
|
58
|
+
if (String(attrs['aria-hidden'] || '').toLowerCase() === 'true') return true
|
|
59
|
+
if (/\bdisplay\s*:\s*none\b/i.test(attrs.style || '')) return true
|
|
60
|
+
return false
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Tokenise inline markup into stable {n} placeholders so tags survive
|
|
65
|
+
* translation and export can restore them verbatim.
|
|
66
|
+
*/
|
|
67
|
+
export function tokenizeHtml(raw, { preformatted = false } = {}) {
|
|
68
|
+
const tokens = []
|
|
69
|
+
const stripped = String(raw ?? '').replace(
|
|
70
|
+
/<!--[\s\S]*?-->|<\/?[A-Za-z][\w:.-]*(?:"[^"]*"|'[^']*'|[^>"'])*?\/?>/g,
|
|
71
|
+
(xml) => {
|
|
72
|
+
if (xml.startsWith('<!--')) return ''
|
|
73
|
+
const token = `{${tokens.length + 1}}`
|
|
74
|
+
tokens.push({ token, xml })
|
|
75
|
+
return token
|
|
76
|
+
},
|
|
77
|
+
)
|
|
78
|
+
let text = decodeXml(stripped).replace(/\r\n?/g, '\n')
|
|
79
|
+
if (preformatted) {
|
|
80
|
+
text = text.replace(/[ \t]+\n/g, '\n').replace(/[ \t]{2,}/g, ' ').replace(/^\n+|\n+$/g, '')
|
|
81
|
+
} else {
|
|
82
|
+
text = text.replace(/\s+/g, ' ')
|
|
83
|
+
}
|
|
84
|
+
return { text: text.trim(), tokens }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Render a tokenised string back to HTML, escaping only text portions. */
|
|
88
|
+
export function renderHtmlTokens(value, tokens) {
|
|
89
|
+
const text = String(value ?? '')
|
|
90
|
+
const map = new Map((Array.isArray(tokens) ? tokens : []).map((item) => [item.token, item.xml]))
|
|
91
|
+
const tokenPattern = /\{(\d+)\}/g
|
|
92
|
+
let output = ''
|
|
93
|
+
let last = 0
|
|
94
|
+
for (const match of text.matchAll(tokenPattern)) {
|
|
95
|
+
output += escapeXml(text.slice(last, match.index))
|
|
96
|
+
output += map.get(match[0]) ?? escapeXml(match[0])
|
|
97
|
+
last = match.index + match[0].length
|
|
98
|
+
}
|
|
99
|
+
output += escapeXml(text.slice(last))
|
|
100
|
+
return output
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Plain-text view of an HTML fragment (used for metadata and fallbacks). */
|
|
104
|
+
export function htmlFragmentToText(raw) {
|
|
105
|
+
return decodeXml(
|
|
106
|
+
String(raw ?? '')
|
|
107
|
+
.replace(/<!--[\s\S]*?-->/g, '')
|
|
108
|
+
.replace(/<(script|style|noscript|head)\b[\s\S]*?<\/\1>/gi, '')
|
|
109
|
+
.replace(/<br\s*\/?>/gi, '\n')
|
|
110
|
+
.replace(/<\/(?:p|div|li|h[1-6]|section|article|blockquote|pre|tr)>/gi, '\n\n')
|
|
111
|
+
.replace(/<\/(?:td|th)>/gi, '\t')
|
|
112
|
+
.replace(/<[^>]+>/g, ''),
|
|
113
|
+
)
|
|
114
|
+
.replace(/[ \t]+\n/g, '\n')
|
|
115
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
116
|
+
.trim()
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function parseTree(html) {
|
|
120
|
+
const root = { type: 'element', tag: '#document', attrs: {}, children: [] }
|
|
121
|
+
const stack = [root]
|
|
122
|
+
let cursor = 0
|
|
123
|
+
|
|
124
|
+
const closeTo = (depth, closeStart, end) => {
|
|
125
|
+
while (stack.length > depth) {
|
|
126
|
+
const node = stack.pop()
|
|
127
|
+
node.closeStart = closeStart
|
|
128
|
+
node.end = end
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
TAG_PATTERN.lastIndex = 0
|
|
133
|
+
let match
|
|
134
|
+
while ((match = TAG_PATTERN.exec(html))) {
|
|
135
|
+
if (match.index > cursor) {
|
|
136
|
+
stack[stack.length - 1].children.push({
|
|
137
|
+
type: 'text',
|
|
138
|
+
start: cursor,
|
|
139
|
+
end: match.index,
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
cursor = match.index + match[0].length
|
|
143
|
+
if (!match[2]) continue
|
|
144
|
+
|
|
145
|
+
const rawTag = match[2]
|
|
146
|
+
const tag = rawTag.toLowerCase()
|
|
147
|
+
if (match[1] === '/') {
|
|
148
|
+
let depth = stack.length - 1
|
|
149
|
+
while (depth > 0 && stack[depth].tag !== tag) depth -= 1
|
|
150
|
+
if (depth > 0) closeTo(depth, match.index, cursor)
|
|
151
|
+
continue
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const node = {
|
|
155
|
+
type: 'element',
|
|
156
|
+
tag,
|
|
157
|
+
attrs: parseAttributes(match[3]),
|
|
158
|
+
children: [],
|
|
159
|
+
start: match.index,
|
|
160
|
+
openEnd: cursor,
|
|
161
|
+
closeStart: null,
|
|
162
|
+
end: null,
|
|
163
|
+
}
|
|
164
|
+
stack[stack.length - 1].children.push(node)
|
|
165
|
+
|
|
166
|
+
if (match[4] === '/' || VOID_TAGS.has(tag)) {
|
|
167
|
+
node.closeStart = cursor
|
|
168
|
+
node.end = cursor
|
|
169
|
+
continue
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (SKIP_TAGS.has(tag)) {
|
|
173
|
+
const close = findClosingTag(html, rawTag, cursor)
|
|
174
|
+
node.closeStart = close ? close.start : html.length
|
|
175
|
+
node.end = close ? close.end : html.length
|
|
176
|
+
cursor = node.end
|
|
177
|
+
TAG_PATTERN.lastIndex = cursor
|
|
178
|
+
continue
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
stack.push(node)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (cursor < html.length) {
|
|
185
|
+
stack[stack.length - 1].children.push({ type: 'text', start: cursor, end: html.length })
|
|
186
|
+
}
|
|
187
|
+
closeTo(1, html.length, html.length)
|
|
188
|
+
return root
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function collectRuns(html, root) {
|
|
192
|
+
const runs = []
|
|
193
|
+
|
|
194
|
+
const walk = (node) => {
|
|
195
|
+
let runStart = null
|
|
196
|
+
let runEnd = null
|
|
197
|
+
const preformatted = node.tag === 'pre'
|
|
198
|
+
|
|
199
|
+
const flush = () => {
|
|
200
|
+
if (runStart === null) return
|
|
201
|
+
const { text, tokens } = tokenizeHtml(html.slice(runStart, runEnd), { preformatted })
|
|
202
|
+
if (text) {
|
|
203
|
+
runs.push({ index: runs.length, start: runStart, end: runEnd, text, tokens })
|
|
204
|
+
}
|
|
205
|
+
runStart = null
|
|
206
|
+
runEnd = null
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
for (const child of node.children) {
|
|
210
|
+
if (child.type === 'text') {
|
|
211
|
+
if (runStart !== null && child.start > runEnd) flush()
|
|
212
|
+
if (runStart === null) runStart = child.start
|
|
213
|
+
runEnd = child.end
|
|
214
|
+
continue
|
|
215
|
+
}
|
|
216
|
+
if (SKIP_TAGS.has(child.tag) || isHidden(child)) {
|
|
217
|
+
// Do not let a run span across skipped markup: patching the range
|
|
218
|
+
// would otherwise delete the skipped element.
|
|
219
|
+
flush()
|
|
220
|
+
continue
|
|
221
|
+
}
|
|
222
|
+
if (BLOCK_TAGS.has(child.tag)) {
|
|
223
|
+
flush()
|
|
224
|
+
walk(child)
|
|
225
|
+
continue
|
|
226
|
+
}
|
|
227
|
+
if (runStart !== null && child.start > runEnd) flush()
|
|
228
|
+
if (runStart === null) runStart = child.start
|
|
229
|
+
runEnd = child.end
|
|
230
|
+
}
|
|
231
|
+
flush()
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
walk(root)
|
|
235
|
+
return runs
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Parse an HTML/XHTML document into ordered inline runs. `runs[n].start` and
|
|
240
|
+
* `runs[n].end` index into the original string, which is what EPUB export
|
|
241
|
+
* needs to patch translated text back without reserialising the document.
|
|
242
|
+
*/
|
|
243
|
+
export function parseHtmlBlocks(input) {
|
|
244
|
+
// Keep the original string untouched: run offsets must index into the exact
|
|
245
|
+
// bytes that EPUB export will patch later.
|
|
246
|
+
const html = String(input ?? '')
|
|
247
|
+
const titleMatch = html.match(/<title\b[^>]*>([\s\S]*?)<\/title>/i)
|
|
248
|
+
const root = parseTree(html)
|
|
249
|
+
const runs = collectRuns(html, root)
|
|
250
|
+
return {
|
|
251
|
+
runs,
|
|
252
|
+
title: titleMatch ? htmlFragmentToText(titleMatch[1]) : '',
|
|
253
|
+
warnings: [],
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Replace run ranges with already-rendered HTML. */
|
|
258
|
+
export function patchHtmlRuns(html, replacements) {
|
|
259
|
+
const source = String(html ?? '')
|
|
260
|
+
const { runs } = parseHtmlBlocks(source)
|
|
261
|
+
let output = ''
|
|
262
|
+
let last = 0
|
|
263
|
+
for (const run of runs) {
|
|
264
|
+
if (!replacements.has(run.index)) continue
|
|
265
|
+
if (run.start < last) continue
|
|
266
|
+
output += source.slice(last, run.start)
|
|
267
|
+
output += replacements.get(run.index)
|
|
268
|
+
last = run.end
|
|
269
|
+
}
|
|
270
|
+
return output + source.slice(last)
|
|
271
|
+
}
|
package/lib/import-export.js
CHANGED
|
@@ -4,120 +4,155 @@ import { CatError } from './errors.js'
|
|
|
4
4
|
import { MAX_DOCX_BASE64, MAX_IMPORT_CHARS, MAX_SEGMENTS, MAX_SEGMENT_CHARS, canonicalLanguage, cleanLang, cleanName, cleanText, csvCell, makeId, mdCell, now, safeBaseName } from './store-utils.js'
|
|
5
5
|
import { stripMarkdown } from './markdown.js'
|
|
6
6
|
import { DOCX_MIME, buildDocx, buildDocxFromText, parseDocxDetailed } from './docx.js'
|
|
7
|
+
import { EPUB_MIME, buildEpub, buildEpubFromText, parseEpub } from './epub.js'
|
|
7
8
|
import { PPTX_MIME, buildPptx, parsePptxDetailed } from './pptx.js'
|
|
8
9
|
import { XLSX_MIME, buildXlsx, parseXlsx } from './xlsx.js'
|
|
9
10
|
import { buildTmx, parseTmx } from './tmx.js'
|
|
10
11
|
import { buildHtml, buildJson, buildPo, buildRtf, buildSrt, buildVtt, buildXliff, buildXliff2, parseCsv, parseHtml, parseJson, parsePo, parseRtf, parseSrt, parseVtt, parseXliff } from './formats.js'
|
|
11
12
|
import { decodeXml } from './xml.js'
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
14
|
+
const BINARY_SOURCES = {
|
|
15
|
+
docx: { label: 'DOCX', fallback: 'source.docx' },
|
|
16
|
+
pptx: { label: 'PPTX', fallback: 'source.pptx' },
|
|
17
|
+
epub: { label: 'EPUB', fallback: 'source.epub' },
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const ALIGN_TEXT_PARSERS = {
|
|
21
|
+
csv: parseCsv,
|
|
22
|
+
srt: parseSrt,
|
|
23
|
+
vtt: parseVtt,
|
|
24
|
+
po: parsePo,
|
|
25
|
+
pot: parsePo,
|
|
26
|
+
json: parseJson,
|
|
27
|
+
rtf: parseRtf,
|
|
28
|
+
xliff: parseXliff,
|
|
29
|
+
xlf: parseXliff,
|
|
30
|
+
sdlxliff: parseXliff,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Text of an uploaded file, without creating a document. The aligner works on
|
|
35
|
+
* two files and only needs their paragraphs, so this path must not consume the
|
|
36
|
+
* segment budget or appear in the document list.
|
|
37
|
+
*/
|
|
38
|
+
function readUploadBody(fileName, extension, text, base64) {
|
|
39
|
+
if (extension === 'docx' || extension === 'epub' || extension === 'xlsx' || extension === 'pptx') {
|
|
40
|
+
if (!base64) throw new CatError('EMPTY_FILE', `${fileName} 内容为空。`)
|
|
41
|
+
if (base64.length > MAX_DOCX_BASE64) throw new CatError('FILE_TOO_LARGE', `${fileName} 过大。`)
|
|
42
|
+
const buffer = Buffer.from(base64, 'base64')
|
|
43
|
+
if (extension === 'docx') return parseDocxDetailed(buffer).text
|
|
44
|
+
if (extension === 'epub') return parseEpub(buffer).segments.map((segment) => segment.source).join('\n')
|
|
45
|
+
const parsed = extension === 'xlsx' ? parseXlsx(buffer) : parsePptxDetailed(buffer)
|
|
46
|
+
return parsed.segments.map((segment) => segment.source).join('\n')
|
|
47
|
+
}
|
|
48
|
+
const raw = String(text ?? '')
|
|
49
|
+
if (!raw.trim()) throw new CatError('EMPTY_FILE', `${fileName} 内容为空。`)
|
|
50
|
+
if (raw.length > MAX_IMPORT_CHARS) throw new CatError('FILE_TOO_LARGE', `${fileName} 过大。`)
|
|
51
|
+
if (extension === 'md' || extension === 'markdown') return stripMarkdown(raw)
|
|
52
|
+
if (ALIGN_TEXT_PARSERS[extension]) {
|
|
53
|
+
return ALIGN_TEXT_PARSERS[extension](raw).segments.map((segment) => segment.source).join('\n')
|
|
54
|
+
}
|
|
55
|
+
return raw
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function sourceField(kind) {
|
|
59
|
+
return `source${kind[0].toUpperCase()}${kind.slice(1)}`
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Keep the original binary package so exporting can patch translated text into
|
|
64
|
+
* the source layout. Persisted stores use a small sidecar file; transient
|
|
65
|
+
* in-memory stores keep the bytes in the document object only.
|
|
66
|
+
*/
|
|
67
|
+
function attachSource(store, doc, kind, buffer, name) {
|
|
68
|
+
if (!Buffer.isBuffer(buffer) || !buffer.length) return
|
|
69
|
+
const config = BINARY_SOURCES[kind]
|
|
70
|
+
const fileName = cleanName(name, config.fallback)
|
|
71
|
+
if (store.dataDir) {
|
|
72
|
+
const asset = `sources/${doc.id}.${kind}`
|
|
73
|
+
try {
|
|
74
|
+
mkdirSync(join(store.dataDir, 'sources'), { recursive: true })
|
|
75
|
+
writeFileSync(join(store.dataDir, asset), buffer)
|
|
76
|
+
} catch (error) {
|
|
77
|
+
throw new CatError(
|
|
78
|
+
`${kind.toUpperCase()}_SOURCE_WRITE_FAILED`,
|
|
79
|
+
`无法保存 ${config.label} 源文件,无法保留源格式: ${error.message}`,
|
|
80
|
+
500,
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
doc[sourceField(kind)] = { kind: 'file', asset, name: fileName }
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
doc[sourceField(kind)] = { kind: 'base64', data: buffer.toString('base64'), name: fileName }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function removeSource(store, doc, kind) {
|
|
90
|
+
const field = sourceField(kind)
|
|
91
|
+
const source = doc?.[field]
|
|
92
|
+
if (!source) return
|
|
93
|
+
if (source.kind === 'file' && store.dataDir) {
|
|
94
|
+
try {
|
|
95
|
+
unlinkSync(join(store.dataDir, source.asset))
|
|
96
|
+
} catch (error) {
|
|
97
|
+
if (error?.code !== 'ENOENT') {
|
|
98
|
+
store.logger?.warn?.(`CAIT: 删除 ${BINARY_SOURCES[kind].label} 源文件失败: ${error.message}`)
|
|
33
99
|
}
|
|
34
|
-
doc.sourceDocx = { kind: 'file', asset, name: fileName }
|
|
35
|
-
return
|
|
36
100
|
}
|
|
37
|
-
|
|
101
|
+
}
|
|
102
|
+
delete doc[field]
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function readSource(store, doc, kind) {
|
|
106
|
+
const source = doc?.[sourceField(kind)]
|
|
107
|
+
if (!source) return null
|
|
108
|
+
try {
|
|
109
|
+
if (source.kind === 'base64' && source.data) {
|
|
110
|
+
return Buffer.from(source.data, 'base64')
|
|
111
|
+
}
|
|
112
|
+
if (source.kind === 'file' && store.dataDir && source.asset) {
|
|
113
|
+
return readFileSync(join(store.dataDir, source.asset))
|
|
114
|
+
}
|
|
115
|
+
} catch (error) {
|
|
116
|
+
store.logger?.warn?.(`CAIT: 读取 ${BINARY_SOURCES[kind].label} 源文件失败: ${error.message}`)
|
|
117
|
+
}
|
|
118
|
+
return null
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export const importExportMethods = {
|
|
122
|
+
attachDocxSource(doc, buffer, name = 'source.docx') {
|
|
123
|
+
attachSource(this, doc, 'docx', buffer, name)
|
|
38
124
|
},
|
|
39
125
|
|
|
40
126
|
removeDocxSource(doc) {
|
|
41
|
-
|
|
42
|
-
if (!source) return
|
|
43
|
-
if (source.kind === 'file' && this.dataDir) {
|
|
44
|
-
try {
|
|
45
|
-
unlinkSync(join(this.dataDir, source.asset))
|
|
46
|
-
} catch (error) {
|
|
47
|
-
if (error?.code !== 'ENOENT') {
|
|
48
|
-
this.logger?.warn?.(`CAIT: 删除 DOCX 源文件失败: ${error.message}`)
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
delete doc.sourceDocx
|
|
127
|
+
removeSource(this, doc, 'docx')
|
|
53
128
|
},
|
|
54
129
|
|
|
55
130
|
readDocxSource(doc) {
|
|
56
|
-
|
|
57
|
-
if (!source) return null
|
|
58
|
-
try {
|
|
59
|
-
if (source.kind === 'base64' && source.data) {
|
|
60
|
-
return Buffer.from(source.data, 'base64')
|
|
61
|
-
}
|
|
62
|
-
if (source.kind === 'file' && this.dataDir && source.asset) {
|
|
63
|
-
return readFileSync(join(this.dataDir, source.asset))
|
|
64
|
-
}
|
|
65
|
-
} catch (error) {
|
|
66
|
-
this.logger?.warn?.(`CAIT: 读取 DOCX 源文件失败: ${error.message}`)
|
|
67
|
-
}
|
|
68
|
-
return null
|
|
131
|
+
return readSource(this, doc, 'docx')
|
|
69
132
|
},
|
|
70
133
|
|
|
71
134
|
attachPptxSource(doc, buffer, name = 'source.pptx') {
|
|
72
|
-
|
|
73
|
-
const fileName = cleanName(name, 'source.pptx')
|
|
74
|
-
if (this.dataDir) {
|
|
75
|
-
const asset = `sources/${doc.id}.pptx`
|
|
76
|
-
try {
|
|
77
|
-
mkdirSync(join(this.dataDir, 'sources'), { recursive: true })
|
|
78
|
-
writeFileSync(join(this.dataDir, asset), buffer)
|
|
79
|
-
} catch (error) {
|
|
80
|
-
throw new CatError(
|
|
81
|
-
'PPTX_SOURCE_WRITE_FAILED',
|
|
82
|
-
`无法保存 PPTX 源文件,无法保留源格式: ${error.message}`,
|
|
83
|
-
500,
|
|
84
|
-
)
|
|
85
|
-
}
|
|
86
|
-
doc.sourcePptx = { kind: 'file', asset, name: fileName }
|
|
87
|
-
return
|
|
88
|
-
}
|
|
89
|
-
doc.sourcePptx = { kind: 'base64', data: buffer.toString('base64'), name: fileName }
|
|
135
|
+
attachSource(this, doc, 'pptx', buffer, name)
|
|
90
136
|
},
|
|
91
137
|
|
|
92
138
|
removePptxSource(doc) {
|
|
93
|
-
|
|
94
|
-
if (!source) return
|
|
95
|
-
if (source.kind === 'file' && this.dataDir) {
|
|
96
|
-
try {
|
|
97
|
-
unlinkSync(join(this.dataDir, source.asset))
|
|
98
|
-
} catch (error) {
|
|
99
|
-
if (error?.code !== 'ENOENT') {
|
|
100
|
-
this.logger?.warn?.(`CAIT: 删除 PPTX 源文件失败: ${error.message}`)
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
delete doc.sourcePptx
|
|
139
|
+
removeSource(this, doc, 'pptx')
|
|
105
140
|
},
|
|
106
141
|
|
|
107
142
|
readPptxSource(doc) {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
return
|
|
143
|
+
return readSource(this, doc, 'pptx')
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
attachEpubSource(doc, buffer, name = 'source.epub') {
|
|
147
|
+
attachSource(this, doc, 'epub', buffer, name)
|
|
148
|
+
},
|
|
149
|
+
|
|
150
|
+
removeEpubSource(doc) {
|
|
151
|
+
removeSource(this, doc, 'epub')
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
readEpubSource(doc) {
|
|
155
|
+
return readSource(this, doc, 'epub')
|
|
121
156
|
},
|
|
122
157
|
|
|
123
158
|
importTmx({ text, name, sourceLang, targetLang, importAs = 'tm', tmId } = {}) {
|
|
@@ -138,16 +173,22 @@ export const importExportMethods = {
|
|
|
138
173
|
const pairs = []
|
|
139
174
|
const segments = []
|
|
140
175
|
|
|
141
|
-
|
|
176
|
+
parsed.units.forEach((variants, index) => {
|
|
142
177
|
const source =
|
|
143
178
|
variants.find((variant) => variant.lang === src) ??
|
|
144
179
|
variants.find((variant) => variant.lang !== tgt) ??
|
|
145
180
|
variants[0]
|
|
146
|
-
if (!source?.text)
|
|
181
|
+
if (!source?.text) return
|
|
147
182
|
const target = variants.find((variant) => variant.lang === tgt)
|
|
148
|
-
pairs.push({
|
|
183
|
+
pairs.push({
|
|
184
|
+
source: source.text,
|
|
185
|
+
target: target?.text || '',
|
|
186
|
+
sourceLang: src,
|
|
187
|
+
targetLang: tgt,
|
|
188
|
+
context: parsed.contexts?.[index] ?? null,
|
|
189
|
+
})
|
|
149
190
|
segments.push({ source: source.text, target: target?.text || '', paragraph: segments.length })
|
|
150
|
-
}
|
|
191
|
+
})
|
|
151
192
|
|
|
152
193
|
if (!pairs.length) throw new CatError('TMX_NO_UNITS', 'TMX 中没有可用的翻译单元。')
|
|
153
194
|
|
|
@@ -178,6 +219,30 @@ export const importExportMethods = {
|
|
|
178
219
|
}
|
|
179
220
|
}
|
|
180
221
|
,
|
|
222
|
+
readUploadText({ name, text, base64 } = {}) {
|
|
223
|
+
const fileName = cleanName(name, '文件')
|
|
224
|
+
const extension = (fileName.match(/\.([a-z0-9]+)$/i)?.[1] ?? '').toLowerCase()
|
|
225
|
+
let body
|
|
226
|
+
try {
|
|
227
|
+
body = readUploadBody(fileName, extension, text, base64)
|
|
228
|
+
} catch (error) {
|
|
229
|
+
if (error instanceof CatError) throw error
|
|
230
|
+
throw new CatError(
|
|
231
|
+
'ALIGN_PARSE',
|
|
232
|
+
`${fileName} 解析失败: ${error?.message ?? error}`,
|
|
233
|
+
400,
|
|
234
|
+
)
|
|
235
|
+
}
|
|
236
|
+
const content = String(body ?? '').trim()
|
|
237
|
+
if (!content) throw new CatError('EMPTY_FILE', `${fileName} 没有可用文本。`)
|
|
238
|
+
return {
|
|
239
|
+
name: fileName,
|
|
240
|
+
format: extension || 'txt',
|
|
241
|
+
text: content,
|
|
242
|
+
chars: Array.from(content).length,
|
|
243
|
+
}
|
|
244
|
+
},
|
|
245
|
+
|
|
181
246
|
importFile({ name, text, base64, sourceLang, targetLang, importAs, csvSourceColumn, csvTargetColumn } = {}) {
|
|
182
247
|
const fileName = cleanName(name, '导入文档')
|
|
183
248
|
const extension = (fileName.match(/\.([a-z0-9]+)$/i)?.[1] ?? '').toLowerCase()
|
|
@@ -204,6 +269,35 @@ export const importExportMethods = {
|
|
|
204
269
|
})
|
|
205
270
|
}
|
|
206
271
|
|
|
272
|
+
if (extension === 'epub') {
|
|
273
|
+
if (!base64) throw new CatError('EMPTY_FILE', 'EPUB 文件内容为空。')
|
|
274
|
+
if (base64.length > MAX_DOCX_BASE64) throw new CatError('FILE_TOO_LARGE', 'EPUB 文件过大。')
|
|
275
|
+
const buffer = Buffer.from(base64, 'base64')
|
|
276
|
+
let parsed
|
|
277
|
+
try {
|
|
278
|
+
parsed = parseEpub(buffer)
|
|
279
|
+
} catch (error) {
|
|
280
|
+
if (error instanceof CatError) throw error
|
|
281
|
+
throw new CatError('EPUB_PARSE', `EPUB 解析失败: ${error?.message ?? error}`)
|
|
282
|
+
}
|
|
283
|
+
const created = this.createDocument({
|
|
284
|
+
name: fileName,
|
|
285
|
+
segments: parsed.segments,
|
|
286
|
+
sourceLang: sourceLang || parsed.language,
|
|
287
|
+
targetLang,
|
|
288
|
+
format: 'epub',
|
|
289
|
+
epubSource: buffer,
|
|
290
|
+
epubName: fileName,
|
|
291
|
+
})
|
|
292
|
+
return {
|
|
293
|
+
...created,
|
|
294
|
+
sourceLang: created.document.sourceLang,
|
|
295
|
+
targetLang: created.document.targetLang,
|
|
296
|
+
units: parsed.segments.length,
|
|
297
|
+
warnings: parsed.warnings ?? [],
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
207
301
|
if (extension === 'xlsx' || extension === 'pptx') {
|
|
208
302
|
if (!base64) throw new CatError('EMPTY_FILE', `${extension.toUpperCase()} 文件内容为空。`)
|
|
209
303
|
if (base64.length > MAX_DOCX_BASE64) throw new CatError('FILE_TOO_LARGE', `${extension.toUpperCase()} 文件过大。`)
|
|
@@ -385,6 +479,25 @@ export const importExportMethods = {
|
|
|
385
479
|
}
|
|
386
480
|
}
|
|
387
481
|
|
|
482
|
+
if (format === 'epub') {
|
|
483
|
+
let content = null
|
|
484
|
+
const template = this.readEpubSource(doc)
|
|
485
|
+
if (template) {
|
|
486
|
+
try {
|
|
487
|
+
content = buildEpub(doc, template)
|
|
488
|
+
} catch (error) {
|
|
489
|
+
this.logger?.warn?.(`CAIT: EPUB 模板套用失败,改用纯文本 EPUB: ${error.message}`)
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
if (!content) content = buildEpubFromText(doc)
|
|
493
|
+
return {
|
|
494
|
+
filename: `${base}.${doc.targetLang}.epub`,
|
|
495
|
+
mime: EPUB_MIME,
|
|
496
|
+
content,
|
|
497
|
+
count: doc.segments.length,
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
388
501
|
if (format === 'srt') {
|
|
389
502
|
return {
|
|
390
503
|
filename: `${base}.${doc.targetLang}.srt`,
|