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/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,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
- 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
+ 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
- doc.sourceDocx = { kind: 'base64', data: buffer.toString('base64'), name: fileName }
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
- 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
89
+ removeSource(this, doc, 'docx')
53
90
  },
54
91
 
55
92
  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
93
+ return readSource(this, doc, 'docx')
69
94
  },
70
95
 
71
96
  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 }
97
+ attachSource(this, doc, 'pptx', buffer, name)
90
98
  },
91
99
 
92
100
  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
101
+ removeSource(this, doc, 'pptx')
105
102
  },
106
103
 
107
104
  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
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`,
@@ -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
+ }
@@ -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.4'
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()