dsh-cait 1.0.4 → 1.0.5
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 +43 -0
- package/README.ja.md +53 -13
- package/README.md +58 -16
- package/README.zh-CN.md +53 -15
- package/client/components.js +32 -1
- package/client/locales.js +27 -6
- package/client/rpc.js +1 -1
- package/client.js +60 -8
- package/lib/docx.js +12 -9
- package/lib/epub.js +355 -0
- package/lib/formats.js +88 -33
- package/lib/html.js +271 -0
- package/lib/import-export.js +136 -91
- package/lib/persistence.js +20 -0
- package/lib/preprocess.js +65 -0
- package/lib/store-utils.js +6 -3
- package/lib/store.js +12 -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,117 @@ 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
|
+
function sourceField(kind) {
|
|
21
|
+
return `source${kind[0].toUpperCase()}${kind.slice(1)}`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Keep the original binary package so exporting can patch translated text into
|
|
26
|
+
* the source layout. Persisted stores use a small sidecar file; transient
|
|
27
|
+
* in-memory stores keep the bytes in the document object only.
|
|
28
|
+
*/
|
|
29
|
+
function attachSource(store, doc, kind, buffer, name) {
|
|
30
|
+
if (!Buffer.isBuffer(buffer) || !buffer.length) return
|
|
31
|
+
const config = BINARY_SOURCES[kind]
|
|
32
|
+
const fileName = cleanName(name, config.fallback)
|
|
33
|
+
if (store.dataDir) {
|
|
34
|
+
const asset = `sources/${doc.id}.${kind}`
|
|
35
|
+
try {
|
|
36
|
+
mkdirSync(join(store.dataDir, 'sources'), { recursive: true })
|
|
37
|
+
writeFileSync(join(store.dataDir, asset), buffer)
|
|
38
|
+
} catch (error) {
|
|
39
|
+
throw new CatError(
|
|
40
|
+
`${kind.toUpperCase()}_SOURCE_WRITE_FAILED`,
|
|
41
|
+
`无法保存 ${config.label} 源文件,无法保留源格式: ${error.message}`,
|
|
42
|
+
500,
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
doc[sourceField(kind)] = { kind: 'file', asset, name: fileName }
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
doc[sourceField(kind)] = { kind: 'base64', data: buffer.toString('base64'), name: fileName }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function removeSource(store, doc, kind) {
|
|
52
|
+
const field = sourceField(kind)
|
|
53
|
+
const source = doc?.[field]
|
|
54
|
+
if (!source) return
|
|
55
|
+
if (source.kind === 'file' && store.dataDir) {
|
|
56
|
+
try {
|
|
57
|
+
unlinkSync(join(store.dataDir, source.asset))
|
|
58
|
+
} catch (error) {
|
|
59
|
+
if (error?.code !== 'ENOENT') {
|
|
60
|
+
store.logger?.warn?.(`CAIT: 删除 ${BINARY_SOURCES[kind].label} 源文件失败: ${error.message}`)
|
|
33
61
|
}
|
|
34
|
-
doc.sourceDocx = { kind: 'file', asset, name: fileName }
|
|
35
|
-
return
|
|
36
62
|
}
|
|
37
|
-
|
|
63
|
+
}
|
|
64
|
+
delete doc[field]
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function readSource(store, doc, kind) {
|
|
68
|
+
const source = doc?.[sourceField(kind)]
|
|
69
|
+
if (!source) return null
|
|
70
|
+
try {
|
|
71
|
+
if (source.kind === 'base64' && source.data) {
|
|
72
|
+
return Buffer.from(source.data, 'base64')
|
|
73
|
+
}
|
|
74
|
+
if (source.kind === 'file' && store.dataDir && source.asset) {
|
|
75
|
+
return readFileSync(join(store.dataDir, source.asset))
|
|
76
|
+
}
|
|
77
|
+
} catch (error) {
|
|
78
|
+
store.logger?.warn?.(`CAIT: 读取 ${BINARY_SOURCES[kind].label} 源文件失败: ${error.message}`)
|
|
79
|
+
}
|
|
80
|
+
return null
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const importExportMethods = {
|
|
84
|
+
attachDocxSource(doc, buffer, name = 'source.docx') {
|
|
85
|
+
attachSource(this, doc, 'docx', buffer, name)
|
|
38
86
|
},
|
|
39
87
|
|
|
40
88
|
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
|
|
89
|
+
removeSource(this, doc, 'docx')
|
|
53
90
|
},
|
|
54
91
|
|
|
55
92
|
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
|
|
93
|
+
return readSource(this, doc, 'docx')
|
|
69
94
|
},
|
|
70
95
|
|
|
71
96
|
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 }
|
|
97
|
+
attachSource(this, doc, 'pptx', buffer, name)
|
|
90
98
|
},
|
|
91
99
|
|
|
92
100
|
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
|
|
101
|
+
removeSource(this, doc, 'pptx')
|
|
105
102
|
},
|
|
106
103
|
|
|
107
104
|
readPptxSource(doc) {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
return
|
|
105
|
+
return readSource(this, doc, 'pptx')
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
attachEpubSource(doc, buffer, name = 'source.epub') {
|
|
109
|
+
attachSource(this, doc, 'epub', buffer, name)
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
removeEpubSource(doc) {
|
|
113
|
+
removeSource(this, doc, 'epub')
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
readEpubSource(doc) {
|
|
117
|
+
return readSource(this, doc, 'epub')
|
|
121
118
|
},
|
|
122
119
|
|
|
123
120
|
importTmx({ text, name, sourceLang, targetLang, importAs = 'tm', tmId } = {}) {
|
|
@@ -204,6 +201,35 @@ export const importExportMethods = {
|
|
|
204
201
|
})
|
|
205
202
|
}
|
|
206
203
|
|
|
204
|
+
if (extension === 'epub') {
|
|
205
|
+
if (!base64) throw new CatError('EMPTY_FILE', 'EPUB 文件内容为空。')
|
|
206
|
+
if (base64.length > MAX_DOCX_BASE64) throw new CatError('FILE_TOO_LARGE', 'EPUB 文件过大。')
|
|
207
|
+
const buffer = Buffer.from(base64, 'base64')
|
|
208
|
+
let parsed
|
|
209
|
+
try {
|
|
210
|
+
parsed = parseEpub(buffer)
|
|
211
|
+
} catch (error) {
|
|
212
|
+
if (error instanceof CatError) throw error
|
|
213
|
+
throw new CatError('EPUB_PARSE', `EPUB 解析失败: ${error?.message ?? error}`)
|
|
214
|
+
}
|
|
215
|
+
const created = this.createDocument({
|
|
216
|
+
name: fileName,
|
|
217
|
+
segments: parsed.segments,
|
|
218
|
+
sourceLang: sourceLang || parsed.language,
|
|
219
|
+
targetLang,
|
|
220
|
+
format: 'epub',
|
|
221
|
+
epubSource: buffer,
|
|
222
|
+
epubName: fileName,
|
|
223
|
+
})
|
|
224
|
+
return {
|
|
225
|
+
...created,
|
|
226
|
+
sourceLang: created.document.sourceLang,
|
|
227
|
+
targetLang: created.document.targetLang,
|
|
228
|
+
units: parsed.segments.length,
|
|
229
|
+
warnings: parsed.warnings ?? [],
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
207
233
|
if (extension === 'xlsx' || extension === 'pptx') {
|
|
208
234
|
if (!base64) throw new CatError('EMPTY_FILE', `${extension.toUpperCase()} 文件内容为空。`)
|
|
209
235
|
if (base64.length > MAX_DOCX_BASE64) throw new CatError('FILE_TOO_LARGE', `${extension.toUpperCase()} 文件过大。`)
|
|
@@ -385,6 +411,25 @@ export const importExportMethods = {
|
|
|
385
411
|
}
|
|
386
412
|
}
|
|
387
413
|
|
|
414
|
+
if (format === 'epub') {
|
|
415
|
+
let content = null
|
|
416
|
+
const template = this.readEpubSource(doc)
|
|
417
|
+
if (template) {
|
|
418
|
+
try {
|
|
419
|
+
content = buildEpub(doc, template)
|
|
420
|
+
} catch (error) {
|
|
421
|
+
this.logger?.warn?.(`CAIT: EPUB 模板套用失败,改用纯文本 EPUB: ${error.message}`)
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
if (!content) content = buildEpubFromText(doc)
|
|
425
|
+
return {
|
|
426
|
+
filename: `${base}.${doc.targetLang}.epub`,
|
|
427
|
+
mime: EPUB_MIME,
|
|
428
|
+
content,
|
|
429
|
+
count: doc.segments.length,
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
388
433
|
if (format === 'srt') {
|
|
389
434
|
return {
|
|
390
435
|
filename: `${base}.${doc.targetLang}.srt`,
|
package/lib/persistence.js
CHANGED
|
@@ -144,6 +144,20 @@ export const persistenceMethods = {
|
|
|
144
144
|
name: cleanName(sourcePptx.name, 'source.pptx'),
|
|
145
145
|
}
|
|
146
146
|
}
|
|
147
|
+
const sourceEpub = rawDoc.sourceEpub
|
|
148
|
+
if (
|
|
149
|
+
sourceEpub &&
|
|
150
|
+
typeof sourceEpub === 'object' &&
|
|
151
|
+
sourceEpub.kind === 'file' &&
|
|
152
|
+
typeof sourceEpub.asset === 'string' &&
|
|
153
|
+
/^sources\/[A-Za-z0-9_.-]+\.epub$/.test(sourceEpub.asset)
|
|
154
|
+
) {
|
|
155
|
+
doc.sourceEpub = {
|
|
156
|
+
kind: 'file',
|
|
157
|
+
asset: sourceEpub.asset,
|
|
158
|
+
name: cleanName(sourceEpub.name, 'source.epub'),
|
|
159
|
+
}
|
|
160
|
+
}
|
|
147
161
|
if (docs.has(doc.id)) throw new Error(`文档 id 重复: ${doc.id}`)
|
|
148
162
|
docs.set(doc.id, doc)
|
|
149
163
|
}
|
|
@@ -259,6 +273,12 @@ export const persistenceMethods = {
|
|
|
259
273
|
targetLang: this.settings.targetLang,
|
|
260
274
|
fuzzyThreshold: this.settings.fuzzyThreshold,
|
|
261
275
|
tmLimit: this.settings.tmLimit,
|
|
276
|
+
uiLang: this.settings.uiLang,
|
|
277
|
+
preprocess: {
|
|
278
|
+
normalizeWhitespace: this.settings.preprocess.normalizeWhitespace,
|
|
279
|
+
stripInvisible: this.settings.preprocess.stripInvisible,
|
|
280
|
+
collapseBlankLines: this.settings.preprocess.collapseBlankLines,
|
|
281
|
+
},
|
|
262
282
|
mt: {
|
|
263
283
|
provider: this.settings.mt.provider,
|
|
264
284
|
model: this.settings.mt.model,
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Import-time preprocessing stage.
|
|
3
|
+
*
|
|
4
|
+
* Parsers keep the source roughly as authored: pasted documents often contain
|
|
5
|
+
* non-breaking spaces, zero-width characters, CRLF line endings and stray
|
|
6
|
+
* indentation. None of that is meaningful for translation memory or MT, but it
|
|
7
|
+
* does leak into segments and makes matches fail. The stage is deliberately
|
|
8
|
+
* conservative: it only touches characters that carry no linguistic meaning and
|
|
9
|
+
* never rewrites punctuation, casing or inline placeholder tokens.
|
|
10
|
+
*/
|
|
11
|
+
export const DEFAULT_PREPROCESS = {
|
|
12
|
+
normalizeWhitespace: true,
|
|
13
|
+
stripInvisible: true,
|
|
14
|
+
collapseBlankLines: true,
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const INVISIBLE = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F\u200B-\u200F\u202A-\u202E\u2060-\u2064\uFEFF]/g
|
|
18
|
+
|
|
19
|
+
export function normalizePreprocessOptions(value) {
|
|
20
|
+
const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {}
|
|
21
|
+
return {
|
|
22
|
+
normalizeWhitespace:
|
|
23
|
+
typeof source.normalizeWhitespace === 'boolean'
|
|
24
|
+
? source.normalizeWhitespace
|
|
25
|
+
: DEFAULT_PREPROCESS.normalizeWhitespace,
|
|
26
|
+
stripInvisible:
|
|
27
|
+
typeof source.stripInvisible === 'boolean' ? source.stripInvisible : DEFAULT_PREPROCESS.stripInvisible,
|
|
28
|
+
collapseBlankLines:
|
|
29
|
+
typeof source.collapseBlankLines === 'boolean'
|
|
30
|
+
? source.collapseBlankLines
|
|
31
|
+
: DEFAULT_PREPROCESS.collapseBlankLines,
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function preprocessText(value, options = DEFAULT_PREPROCESS) {
|
|
36
|
+
const config = normalizePreprocessOptions(options)
|
|
37
|
+
let text = String(value ?? '')
|
|
38
|
+
// Line endings carry no linguistic meaning and would otherwise split CRLF
|
|
39
|
+
// imports into different segment fingerprints than the same text pasted in.
|
|
40
|
+
text = text.replace(/\r\n?/g, '\n')
|
|
41
|
+
if (config.stripInvisible) text = text.replace(INVISIBLE, '')
|
|
42
|
+
if (config.normalizeWhitespace) {
|
|
43
|
+
text = text
|
|
44
|
+
.replace(/\u00A0/g, ' ')
|
|
45
|
+
.replace(/[ \t]+\n/g, '\n')
|
|
46
|
+
.replace(/[ \t]{2,}/g, ' ')
|
|
47
|
+
.replace(/\n[ \t]+/g, '\n')
|
|
48
|
+
}
|
|
49
|
+
if (config.collapseBlankLines) text = text.replace(/\n{3,}/g, '\n\n')
|
|
50
|
+
return config.normalizeWhitespace || config.collapseBlankLines ? text.trim() : text
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Apply the configured stage to parsed import segments. Inline tokens such as
|
|
55
|
+
* `{1}` are untouched because they contain no whitespace or control characters.
|
|
56
|
+
*/
|
|
57
|
+
export function preprocessSegments(segments, options) {
|
|
58
|
+
const config = normalizePreprocessOptions(options)
|
|
59
|
+
return segments.map((segment) => {
|
|
60
|
+
if (!segment || typeof segment !== 'object') return segment
|
|
61
|
+
const source = preprocessText(segment.source, config)
|
|
62
|
+
if (source === segment.source) return segment
|
|
63
|
+
return { ...segment, source }
|
|
64
|
+
})
|
|
65
|
+
}
|
package/lib/store-utils.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto'
|
|
2
2
|
import { CatError } from './errors.js'
|
|
3
3
|
import { clamp, clampInt } from './similarity.js'
|
|
4
|
+
import { DEFAULT_PREPROCESS, normalizePreprocessOptions } from './preprocess.js'
|
|
4
5
|
|
|
5
6
|
export { clamp, clampInt }
|
|
6
7
|
|
|
7
8
|
export const MAX_SEGMENTS = 20000
|
|
8
|
-
export const CAIT_VERSION = '1.0.
|
|
9
|
+
export const CAIT_VERSION = '1.0.5'
|
|
9
10
|
export const MAX_SEGMENT_CHARS = 20000
|
|
10
11
|
export const MAX_IMPORT_CHARS = 8_000_000
|
|
11
12
|
export const MAX_DOCX_BASE64 = 28_000_000
|
|
@@ -26,6 +27,7 @@ export const DEFAULT_SETTINGS = {
|
|
|
26
27
|
fuzzyThreshold: 0.55,
|
|
27
28
|
tmLimit: 5,
|
|
28
29
|
uiLang: 'auto',
|
|
30
|
+
preprocess: { ...DEFAULT_PREPROCESS },
|
|
29
31
|
mt: {
|
|
30
32
|
provider: 'mock',
|
|
31
33
|
model: 'cait-mock',
|
|
@@ -161,11 +163,11 @@ function cleanInlineTokens(value) {
|
|
|
161
163
|
export function cleanSegmentMeta(value) {
|
|
162
164
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
|
|
163
165
|
const meta = {}
|
|
164
|
-
for (const key of ['start', 'end', 'xlsxRow', 'pptxParagraph']) {
|
|
166
|
+
for (const key of ['start', 'end', 'xlsxRow', 'pptxParagraph', 'epubBlock']) {
|
|
165
167
|
const number = Number(value[key])
|
|
166
168
|
if (Number.isFinite(number) && number >= 0) meta[key] = Math.round(number)
|
|
167
169
|
}
|
|
168
|
-
for (const key of ['settings', 'identifier', 'context', 'msgidPlural', 'xliffId', 'format', 'jsonPath', 'pptxSlide']) {
|
|
170
|
+
for (const key of ['settings', 'identifier', 'context', 'msgidPlural', 'xliffId', 'format', 'jsonPath', 'pptxSlide', 'epubHref']) {
|
|
169
171
|
if (typeof value[key] === 'string' && value[key].trim()) {
|
|
170
172
|
meta[key] = value[key].slice(0, 500)
|
|
171
173
|
}
|
|
@@ -212,6 +214,7 @@ export function restoreSettings(raw) {
|
|
|
212
214
|
if (Number.isFinite(limit)) settings.tmLimit = clampInt(limit, 1, 20, settings.tmLimit)
|
|
213
215
|
const uiLang = String(raw.uiLang ?? '').trim().toLowerCase()
|
|
214
216
|
if (UI_LANG_CODES.includes(uiLang)) settings.uiLang = uiLang
|
|
217
|
+
settings.preprocess = normalizePreprocessOptions(raw.preprocess)
|
|
215
218
|
|
|
216
219
|
if (raw.mt && typeof raw.mt === 'object' && !Array.isArray(raw.mt)) {
|
|
217
220
|
const provider = String(raw.mt.provider ?? '').trim()
|