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/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
+ }
@@ -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
- export const importExportMethods = {
14
- /**
15
- * Keep the original DOCX package so exporting can patch translated text into
16
- * the source layout. Persisted stores use a small sidecar file; transient
17
- * in-memory stores keep the bytes in the document object only.
18
- */
19
- attachDocxSource(doc, buffer, name = 'source.docx') {
20
- if (!Buffer.isBuffer(buffer) || !buffer.length) return
21
- const fileName = cleanName(name, 'source.docx')
22
- if (this.dataDir) {
23
- const asset = `sources/${doc.id}.docx`
24
- try {
25
- mkdirSync(join(this.dataDir, 'sources'), { recursive: true })
26
- writeFileSync(join(this.dataDir, asset), buffer)
27
- } catch (error) {
28
- throw new CatError(
29
- 'DOCX_SOURCE_WRITE_FAILED',
30
- `无法保存 DOCX 源文件,无法保留源格式: ${error.message}`,
31
- 500,
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
- doc.sourceDocx = { kind: 'base64', data: buffer.toString('base64'), name: fileName }
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
- const source = doc?.sourceDocx
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
- const source = doc?.sourceDocx
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
- if (!Buffer.isBuffer(buffer) || !buffer.length) return
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
- const source = doc?.sourcePptx
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
- const source = doc?.sourcePptx
109
- if (!source) return null
110
- try {
111
- if (source.kind === 'base64' && source.data) {
112
- return Buffer.from(source.data, 'base64')
113
- }
114
- if (source.kind === 'file' && this.dataDir && source.asset) {
115
- return readFileSync(join(this.dataDir, source.asset))
116
- }
117
- } catch (error) {
118
- this.logger?.warn?.(`CAIT: 读取 PPTX 源文件失败: ${error.message}`)
119
- }
120
- return null
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
- for (const variants of parsed.units) {
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) continue
181
+ if (!source?.text) return
147
182
  const target = variants.find((variant) => variant.lang === tgt)
148
- pairs.push({ source: source.text, target: target?.text || '', sourceLang: src, targetLang: tgt })
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`,