dsh-cait 1.0.3 → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +367 -352
- package/LICENSE +201 -201
- package/README.ja.md +4 -3
- package/README.md +424 -423
- package/README.zh-CN.md +391 -390
- package/client/components.js +2377 -2377
- package/client/helpers.js +103 -103
- package/client/locales.js +730 -730
- package/client/rpc.js +93 -93
- package/client/styles.js +258 -258
- package/client.js +3556 -3556
- package/icon.svg +8 -8
- package/index.js +45 -45
- package/lib/api.js +223 -223
- package/lib/docx.js +389 -389
- package/lib/errors.js +8 -8
- package/lib/formats.js +917 -917
- package/lib/glossary.js +178 -178
- package/lib/import-export.js +605 -605
- package/lib/libraries.js +183 -183
- package/lib/markdown.js +28 -28
- package/lib/mt.js +152 -152
- package/lib/persistence.js +321 -321
- package/lib/pptx.js +89 -89
- package/lib/segmenter.js +84 -84
- package/lib/similarity.js +113 -113
- package/lib/store-utils.js +339 -339
- package/lib/store.js +734 -734
- package/lib/tm.js +160 -160
- package/lib/tmx.js +85 -85
- package/lib/tools.js +472 -472
- package/lib/xlsx.js +218 -218
- package/lib/xml.js +31 -31
- package/locale/en.json +11 -11
- package/locale/zh.json +11 -11
- package/package.json +83 -83
- package/scripts/build-client.mjs +13 -13
package/lib/tm.js
CHANGED
|
@@ -1,160 +1,160 @@
|
|
|
1
|
-
import { CatError } from './errors.js'
|
|
2
|
-
import { DEFAULT_TM_ID, MAX_SEGMENTS, canonicalLanguage, cleanLang, clamp, clampInt, makeId, now } from './store-utils.js'
|
|
3
|
-
import { classifyMatchType, normalizeSource, normalizeText, similarity } from './similarity.js'
|
|
4
|
-
|
|
5
|
-
export const tmMethods = {
|
|
6
|
-
mountedTmCount() {
|
|
7
|
-
const mounted = new Set(this.mounts.tmIds)
|
|
8
|
-
return this.tm.filter((entry) => mounted.has(entry.tmId || DEFAULT_TM_ID)).length
|
|
9
|
-
}
|
|
10
|
-
,
|
|
11
|
-
upsertTM(source, target, sourceLang, targetLang, tmId) {
|
|
12
|
-
const cleanSource = normalizeSource(source)
|
|
13
|
-
const cleanTarget = String(target ?? '').trim()
|
|
14
|
-
if (!cleanSource || !cleanTarget) {
|
|
15
|
-
throw new CatError('EMPTY_TM_ENTRY', '记忆库条目不能为空。')
|
|
16
|
-
}
|
|
17
|
-
const libraryId = this.requireTmLibrary(tmId)
|
|
18
|
-
const key = normalizeText(cleanSource)
|
|
19
|
-
const existing = this.tm.find(
|
|
20
|
-
(entry) =>
|
|
21
|
-
(entry.tmId || DEFAULT_TM_ID) === libraryId &&
|
|
22
|
-
entry.key === key &&
|
|
23
|
-
entry.sourceLang === sourceLang &&
|
|
24
|
-
entry.targetLang === targetLang,
|
|
25
|
-
)
|
|
26
|
-
if (existing) {
|
|
27
|
-
existing.source = cleanSource
|
|
28
|
-
existing.target = cleanTarget
|
|
29
|
-
existing.updatedAt = now()
|
|
30
|
-
return { entry: existing, created: false }
|
|
31
|
-
}
|
|
32
|
-
const entry = {
|
|
33
|
-
id: makeId('tm'),
|
|
34
|
-
tmId: libraryId,
|
|
35
|
-
key,
|
|
36
|
-
source: cleanSource,
|
|
37
|
-
target: cleanTarget,
|
|
38
|
-
sourceLang,
|
|
39
|
-
targetLang,
|
|
40
|
-
createdAt: now(),
|
|
41
|
-
updatedAt: now(),
|
|
42
|
-
}
|
|
43
|
-
this.tm.push(entry)
|
|
44
|
-
return { entry, created: true }
|
|
45
|
-
}
|
|
46
|
-
,
|
|
47
|
-
lookupTM({ query, threshold, limit, sourceLang, targetLang } = {}) {
|
|
48
|
-
const text = normalizeText(query)
|
|
49
|
-
const defaultThreshold = this.settings.fuzzyThreshold
|
|
50
|
-
const scoreFloor = clamp(Number(threshold ?? defaultThreshold), 0, 1)
|
|
51
|
-
const max = clampInt(limit ?? this.settings.tmLimit, 1, 30, 5)
|
|
52
|
-
const src = canonicalLanguage(sourceLang || this.settings.sourceLang)
|
|
53
|
-
const tgt = canonicalLanguage(targetLang || this.settings.targetLang)
|
|
54
|
-
|
|
55
|
-
if (!text) return { query: String(query ?? ''), matches: [] }
|
|
56
|
-
|
|
57
|
-
const mountedTm = new Set(this.mounts.tmIds)
|
|
58
|
-
const matches = this.tm
|
|
59
|
-
.filter(
|
|
60
|
-
(entry) =>
|
|
61
|
-
mountedTm.has(entry.tmId || DEFAULT_TM_ID) &&
|
|
62
|
-
entry.sourceLang === src &&
|
|
63
|
-
entry.targetLang === tgt,
|
|
64
|
-
)
|
|
65
|
-
.map((entry) => {
|
|
66
|
-
const exact = entry.key === text
|
|
67
|
-
const score = exact ? 1 : similarity(text, entry.key)
|
|
68
|
-
return { entry, exact, score }
|
|
69
|
-
})
|
|
70
|
-
.filter((item) => item.score >= scoreFloor)
|
|
71
|
-
.sort((a, b) => b.score - a.score || b.entry.updatedAt.localeCompare(a.entry.updatedAt))
|
|
72
|
-
.slice(0, max)
|
|
73
|
-
.map(({ entry, exact, score }) => ({
|
|
74
|
-
id: entry.id,
|
|
75
|
-
tmId: entry.tmId || DEFAULT_TM_ID,
|
|
76
|
-
source: entry.source,
|
|
77
|
-
target: entry.target,
|
|
78
|
-
sourceLang: entry.sourceLang,
|
|
79
|
-
targetLang: entry.targetLang,
|
|
80
|
-
score,
|
|
81
|
-
percent: Math.round(score * 100),
|
|
82
|
-
exact,
|
|
83
|
-
matchType: classifyMatchType({ exact, score, query: text, source: entry.key }),
|
|
84
|
-
updatedAt: entry.updatedAt,
|
|
85
|
-
}))
|
|
86
|
-
|
|
87
|
-
return { query: String(query ?? ''), matches }
|
|
88
|
-
}
|
|
89
|
-
,
|
|
90
|
-
addTM({ source, target, sourceLang, targetLang, tmId } = {}) {
|
|
91
|
-
const src = cleanLang(sourceLang || this.settings.sourceLang, this.settings.sourceLang)
|
|
92
|
-
const tgt = cleanLang(targetLang || this.settings.targetLang, this.settings.targetLang)
|
|
93
|
-
const result = this.upsertTM(source, target, src, tgt, tmId)
|
|
94
|
-
this.touch()
|
|
95
|
-
this.persist()
|
|
96
|
-
return {
|
|
97
|
-
entry: {
|
|
98
|
-
id: result.entry.id,
|
|
99
|
-
tmId: result.entry.tmId,
|
|
100
|
-
source: result.entry.source,
|
|
101
|
-
target: result.entry.target,
|
|
102
|
-
sourceLang: result.entry.sourceLang,
|
|
103
|
-
targetLang: result.entry.targetLang,
|
|
104
|
-
},
|
|
105
|
-
created: result.created,
|
|
106
|
-
count: this.tm.length,
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
,
|
|
110
|
-
importTM({ pairs, sourceLang, targetLang, tmId } = {}) {
|
|
111
|
-
if (!Array.isArray(pairs)) throw new CatError('INVALID_TM_IMPORT', '记忆库导入数据格式不正确。')
|
|
112
|
-
if (pairs.length > MAX_SEGMENTS) throw new CatError('TOO_MANY_TM', `一次最多导入 ${MAX_SEGMENTS} 条记忆。`)
|
|
113
|
-
|
|
114
|
-
const src = cleanLang(sourceLang || this.settings.sourceLang, this.settings.sourceLang)
|
|
115
|
-
const tgt = cleanLang(targetLang || this.settings.targetLang, this.settings.targetLang)
|
|
116
|
-
const libraryId = this.requireTmLibrary(tmId)
|
|
117
|
-
let added = 0
|
|
118
|
-
let updated = 0
|
|
119
|
-
|
|
120
|
-
for (const pair of pairs) {
|
|
121
|
-
if (!pair || !String(pair.source ?? '').trim() || !String(pair.target ?? '').trim()) continue
|
|
122
|
-
const result = this.upsertTM(
|
|
123
|
-
pair.source,
|
|
124
|
-
pair.target,
|
|
125
|
-
pair.sourceLang || src,
|
|
126
|
-
pair.targetLang || tgt,
|
|
127
|
-
libraryId,
|
|
128
|
-
)
|
|
129
|
-
if (result.created) added += 1
|
|
130
|
-
else updated += 1
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
if (added || updated) this.touch()
|
|
134
|
-
this.persist()
|
|
135
|
-
return { added, updated, total: this.tm.length }
|
|
136
|
-
}
|
|
137
|
-
,
|
|
138
|
-
clearTM({ tmIds, all = false } = {}) {
|
|
139
|
-
let targets
|
|
140
|
-
let scope
|
|
141
|
-
if (all === true) {
|
|
142
|
-
targets = new Set(this.tmLibraries.map((library) => library.tmId))
|
|
143
|
-
scope = 'all'
|
|
144
|
-
} else if (Array.isArray(tmIds) && tmIds.length) {
|
|
145
|
-
targets = new Set(tmIds.map((id) => this.requireMountedTmLibrary(id)))
|
|
146
|
-
scope = 'explicit'
|
|
147
|
-
} else {
|
|
148
|
-
targets = new Set(this.mounts.tmIds)
|
|
149
|
-
scope = 'mounted'
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
const before = this.tm.length
|
|
153
|
-
this.tm = this.tm.filter((entry) => !targets.has(entry.tmId || DEFAULT_TM_ID))
|
|
154
|
-
const removed = before - this.tm.length
|
|
155
|
-
this.touch()
|
|
156
|
-
this.persist()
|
|
157
|
-
return { removed, scope, tmIds: [...targets] }
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
}
|
|
1
|
+
import { CatError } from './errors.js'
|
|
2
|
+
import { DEFAULT_TM_ID, MAX_SEGMENTS, canonicalLanguage, cleanLang, clamp, clampInt, makeId, now } from './store-utils.js'
|
|
3
|
+
import { classifyMatchType, normalizeSource, normalizeText, similarity } from './similarity.js'
|
|
4
|
+
|
|
5
|
+
export const tmMethods = {
|
|
6
|
+
mountedTmCount() {
|
|
7
|
+
const mounted = new Set(this.mounts.tmIds)
|
|
8
|
+
return this.tm.filter((entry) => mounted.has(entry.tmId || DEFAULT_TM_ID)).length
|
|
9
|
+
}
|
|
10
|
+
,
|
|
11
|
+
upsertTM(source, target, sourceLang, targetLang, tmId) {
|
|
12
|
+
const cleanSource = normalizeSource(source)
|
|
13
|
+
const cleanTarget = String(target ?? '').trim()
|
|
14
|
+
if (!cleanSource || !cleanTarget) {
|
|
15
|
+
throw new CatError('EMPTY_TM_ENTRY', '记忆库条目不能为空。')
|
|
16
|
+
}
|
|
17
|
+
const libraryId = this.requireTmLibrary(tmId)
|
|
18
|
+
const key = normalizeText(cleanSource)
|
|
19
|
+
const existing = this.tm.find(
|
|
20
|
+
(entry) =>
|
|
21
|
+
(entry.tmId || DEFAULT_TM_ID) === libraryId &&
|
|
22
|
+
entry.key === key &&
|
|
23
|
+
entry.sourceLang === sourceLang &&
|
|
24
|
+
entry.targetLang === targetLang,
|
|
25
|
+
)
|
|
26
|
+
if (existing) {
|
|
27
|
+
existing.source = cleanSource
|
|
28
|
+
existing.target = cleanTarget
|
|
29
|
+
existing.updatedAt = now()
|
|
30
|
+
return { entry: existing, created: false }
|
|
31
|
+
}
|
|
32
|
+
const entry = {
|
|
33
|
+
id: makeId('tm'),
|
|
34
|
+
tmId: libraryId,
|
|
35
|
+
key,
|
|
36
|
+
source: cleanSource,
|
|
37
|
+
target: cleanTarget,
|
|
38
|
+
sourceLang,
|
|
39
|
+
targetLang,
|
|
40
|
+
createdAt: now(),
|
|
41
|
+
updatedAt: now(),
|
|
42
|
+
}
|
|
43
|
+
this.tm.push(entry)
|
|
44
|
+
return { entry, created: true }
|
|
45
|
+
}
|
|
46
|
+
,
|
|
47
|
+
lookupTM({ query, threshold, limit, sourceLang, targetLang } = {}) {
|
|
48
|
+
const text = normalizeText(query)
|
|
49
|
+
const defaultThreshold = this.settings.fuzzyThreshold
|
|
50
|
+
const scoreFloor = clamp(Number(threshold ?? defaultThreshold), 0, 1)
|
|
51
|
+
const max = clampInt(limit ?? this.settings.tmLimit, 1, 30, 5)
|
|
52
|
+
const src = canonicalLanguage(sourceLang || this.settings.sourceLang)
|
|
53
|
+
const tgt = canonicalLanguage(targetLang || this.settings.targetLang)
|
|
54
|
+
|
|
55
|
+
if (!text) return { query: String(query ?? ''), matches: [] }
|
|
56
|
+
|
|
57
|
+
const mountedTm = new Set(this.mounts.tmIds)
|
|
58
|
+
const matches = this.tm
|
|
59
|
+
.filter(
|
|
60
|
+
(entry) =>
|
|
61
|
+
mountedTm.has(entry.tmId || DEFAULT_TM_ID) &&
|
|
62
|
+
entry.sourceLang === src &&
|
|
63
|
+
entry.targetLang === tgt,
|
|
64
|
+
)
|
|
65
|
+
.map((entry) => {
|
|
66
|
+
const exact = entry.key === text
|
|
67
|
+
const score = exact ? 1 : similarity(text, entry.key)
|
|
68
|
+
return { entry, exact, score }
|
|
69
|
+
})
|
|
70
|
+
.filter((item) => item.score >= scoreFloor)
|
|
71
|
+
.sort((a, b) => b.score - a.score || b.entry.updatedAt.localeCompare(a.entry.updatedAt))
|
|
72
|
+
.slice(0, max)
|
|
73
|
+
.map(({ entry, exact, score }) => ({
|
|
74
|
+
id: entry.id,
|
|
75
|
+
tmId: entry.tmId || DEFAULT_TM_ID,
|
|
76
|
+
source: entry.source,
|
|
77
|
+
target: entry.target,
|
|
78
|
+
sourceLang: entry.sourceLang,
|
|
79
|
+
targetLang: entry.targetLang,
|
|
80
|
+
score,
|
|
81
|
+
percent: Math.round(score * 100),
|
|
82
|
+
exact,
|
|
83
|
+
matchType: classifyMatchType({ exact, score, query: text, source: entry.key }),
|
|
84
|
+
updatedAt: entry.updatedAt,
|
|
85
|
+
}))
|
|
86
|
+
|
|
87
|
+
return { query: String(query ?? ''), matches }
|
|
88
|
+
}
|
|
89
|
+
,
|
|
90
|
+
addTM({ source, target, sourceLang, targetLang, tmId } = {}) {
|
|
91
|
+
const src = cleanLang(sourceLang || this.settings.sourceLang, this.settings.sourceLang)
|
|
92
|
+
const tgt = cleanLang(targetLang || this.settings.targetLang, this.settings.targetLang)
|
|
93
|
+
const result = this.upsertTM(source, target, src, tgt, tmId)
|
|
94
|
+
this.touch()
|
|
95
|
+
this.persist()
|
|
96
|
+
return {
|
|
97
|
+
entry: {
|
|
98
|
+
id: result.entry.id,
|
|
99
|
+
tmId: result.entry.tmId,
|
|
100
|
+
source: result.entry.source,
|
|
101
|
+
target: result.entry.target,
|
|
102
|
+
sourceLang: result.entry.sourceLang,
|
|
103
|
+
targetLang: result.entry.targetLang,
|
|
104
|
+
},
|
|
105
|
+
created: result.created,
|
|
106
|
+
count: this.tm.length,
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
,
|
|
110
|
+
importTM({ pairs, sourceLang, targetLang, tmId } = {}) {
|
|
111
|
+
if (!Array.isArray(pairs)) throw new CatError('INVALID_TM_IMPORT', '记忆库导入数据格式不正确。')
|
|
112
|
+
if (pairs.length > MAX_SEGMENTS) throw new CatError('TOO_MANY_TM', `一次最多导入 ${MAX_SEGMENTS} 条记忆。`)
|
|
113
|
+
|
|
114
|
+
const src = cleanLang(sourceLang || this.settings.sourceLang, this.settings.sourceLang)
|
|
115
|
+
const tgt = cleanLang(targetLang || this.settings.targetLang, this.settings.targetLang)
|
|
116
|
+
const libraryId = this.requireTmLibrary(tmId)
|
|
117
|
+
let added = 0
|
|
118
|
+
let updated = 0
|
|
119
|
+
|
|
120
|
+
for (const pair of pairs) {
|
|
121
|
+
if (!pair || !String(pair.source ?? '').trim() || !String(pair.target ?? '').trim()) continue
|
|
122
|
+
const result = this.upsertTM(
|
|
123
|
+
pair.source,
|
|
124
|
+
pair.target,
|
|
125
|
+
pair.sourceLang || src,
|
|
126
|
+
pair.targetLang || tgt,
|
|
127
|
+
libraryId,
|
|
128
|
+
)
|
|
129
|
+
if (result.created) added += 1
|
|
130
|
+
else updated += 1
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (added || updated) this.touch()
|
|
134
|
+
this.persist()
|
|
135
|
+
return { added, updated, total: this.tm.length }
|
|
136
|
+
}
|
|
137
|
+
,
|
|
138
|
+
clearTM({ tmIds, all = false } = {}) {
|
|
139
|
+
let targets
|
|
140
|
+
let scope
|
|
141
|
+
if (all === true) {
|
|
142
|
+
targets = new Set(this.tmLibraries.map((library) => library.tmId))
|
|
143
|
+
scope = 'all'
|
|
144
|
+
} else if (Array.isArray(tmIds) && tmIds.length) {
|
|
145
|
+
targets = new Set(tmIds.map((id) => this.requireMountedTmLibrary(id)))
|
|
146
|
+
scope = 'explicit'
|
|
147
|
+
} else {
|
|
148
|
+
targets = new Set(this.mounts.tmIds)
|
|
149
|
+
scope = 'mounted'
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const before = this.tm.length
|
|
153
|
+
this.tm = this.tm.filter((entry) => !targets.has(entry.tmId || DEFAULT_TM_ID))
|
|
154
|
+
const removed = before - this.tm.length
|
|
155
|
+
this.touch()
|
|
156
|
+
this.persist()
|
|
157
|
+
return { removed, scope, tmIds: [...targets] }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
}
|
package/lib/tmx.js
CHANGED
|
@@ -1,85 +1,85 @@
|
|
|
1
|
-
/** TMX 1.4 subset: one `<tu>` per translation unit, one `<tuv>` per language. */
|
|
2
|
-
|
|
3
|
-
import { decodeXml, escapeXml } from './xml.js'
|
|
4
|
-
import { CAIT_VERSION } from './store-utils.js'
|
|
5
|
-
|
|
6
|
-
function attr(attrs, name) {
|
|
7
|
-
const match = String(attrs ?? '').match(
|
|
8
|
-
new RegExp(`(?:^|\\s)(?:xml:)?${name}\\s*=\\s*("([^"]*)"|'([^']*)')`, 'i'),
|
|
9
|
-
)
|
|
10
|
-
return match ? decodeXml(match[2] ?? match[3] ?? '') : ''
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
function segText(raw) {
|
|
14
|
-
const cdata = String(raw ?? '').replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
|
|
15
|
-
return decodeXml(cdata.replace(/<[^>]*>/g, ''))
|
|
16
|
-
.replace(/\s+/g, ' ')
|
|
17
|
-
.trim()
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export function parseTmx(xml) {
|
|
21
|
-
const text = String(xml ?? '')
|
|
22
|
-
const header = text.match(/<header\b([^>]*)>/i)
|
|
23
|
-
const units = []
|
|
24
|
-
let inlineMarkup = 0
|
|
25
|
-
const tuPattern = /<tu\b[^>]*>([\s\S]*?)<\/tu>/gi
|
|
26
|
-
let tu
|
|
27
|
-
|
|
28
|
-
while ((tu = tuPattern.exec(text)) !== null) {
|
|
29
|
-
const variants = []
|
|
30
|
-
const tuvPattern = /<tuv\b([^>]*)>([\s\S]*?)<\/tuv>/gi
|
|
31
|
-
let tuv
|
|
32
|
-
while ((tuv = tuvPattern.exec(tu[1])) !== null) {
|
|
33
|
-
const seg = tuv[2].match(/<seg\b[^>]*>([\s\S]*?)<\/seg>/i)
|
|
34
|
-
if (!seg) continue
|
|
35
|
-
// `<bpt>/<ept>/<ph>` encode inline formatting and placeholders in TMX.
|
|
36
|
-
// This subset intentionally imports plain text, so count the opening tags
|
|
37
|
-
// and warn instead of silently pretending the formatting survived.
|
|
38
|
-
inlineMarkup += (seg[1].match(/<(?:bpt|ept|ph)\b[^>]*>/gi) || []).length
|
|
39
|
-
const lang = attr(tuv[1], 'lang')
|
|
40
|
-
const value = segText(seg[1])
|
|
41
|
-
if (value) variants.push({ lang: lang || '', text: value })
|
|
42
|
-
}
|
|
43
|
-
if (variants.length) units.push(variants)
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
const sourceLang = attr(header?.[1], 'srclang') || ''
|
|
47
|
-
const languages = [...new Set(units.flat().map((variant) => variant.lang).filter(Boolean))]
|
|
48
|
-
const warnings = inlineMarkup
|
|
49
|
-
? [
|
|
50
|
-
{
|
|
51
|
-
code: 'TMX_INLINE_MARKUP_LOST',
|
|
52
|
-
count: inlineMarkup,
|
|
53
|
-
message: `TMX 含 ${inlineMarkup} 个内联占位符/格式标签(bpt/ept/ph),导入时已按纯文本处理,格式信息丢失。`,
|
|
54
|
-
},
|
|
55
|
-
]
|
|
56
|
-
: []
|
|
57
|
-
|
|
58
|
-
return {
|
|
59
|
-
sourceLang,
|
|
60
|
-
targetLang: languages.find((lang) => lang !== sourceLang) || '',
|
|
61
|
-
units,
|
|
62
|
-
warnings,
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export function buildTmx(doc) {
|
|
67
|
-
const lines = [
|
|
68
|
-
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
69
|
-
'<tmx version="1.4">',
|
|
70
|
-
` <header creationtool="CAIT" creationtoolversion="${CAIT_VERSION}" datatype="plaintext" segtype="sentence" adminlang="en" srclang="${escapeXml(doc.sourceLang)}" o-tmf="CAIT" creationdate="${new Date().toISOString()}"/>`,
|
|
71
|
-
' <body>',
|
|
72
|
-
]
|
|
73
|
-
|
|
74
|
-
for (const segment of doc.segments) {
|
|
75
|
-
lines.push(' <tu>')
|
|
76
|
-
lines.push(` <tuv xml:lang="${escapeXml(doc.sourceLang)}"><seg>${escapeXml(segment.source)}</seg></tuv>`)
|
|
77
|
-
if (segment.target) {
|
|
78
|
-
lines.push(` <tuv xml:lang="${escapeXml(doc.targetLang)}"><seg>${escapeXml(segment.target)}</seg></tuv>`)
|
|
79
|
-
}
|
|
80
|
-
lines.push(' </tu>')
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
lines.push(' </body>', '</tmx>', '')
|
|
84
|
-
return lines.join('\n')
|
|
85
|
-
}
|
|
1
|
+
/** TMX 1.4 subset: one `<tu>` per translation unit, one `<tuv>` per language. */
|
|
2
|
+
|
|
3
|
+
import { decodeXml, escapeXml } from './xml.js'
|
|
4
|
+
import { CAIT_VERSION } from './store-utils.js'
|
|
5
|
+
|
|
6
|
+
function attr(attrs, name) {
|
|
7
|
+
const match = String(attrs ?? '').match(
|
|
8
|
+
new RegExp(`(?:^|\\s)(?:xml:)?${name}\\s*=\\s*("([^"]*)"|'([^']*)')`, 'i'),
|
|
9
|
+
)
|
|
10
|
+
return match ? decodeXml(match[2] ?? match[3] ?? '') : ''
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function segText(raw) {
|
|
14
|
+
const cdata = String(raw ?? '').replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
|
|
15
|
+
return decodeXml(cdata.replace(/<[^>]*>/g, ''))
|
|
16
|
+
.replace(/\s+/g, ' ')
|
|
17
|
+
.trim()
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function parseTmx(xml) {
|
|
21
|
+
const text = String(xml ?? '')
|
|
22
|
+
const header = text.match(/<header\b([^>]*)>/i)
|
|
23
|
+
const units = []
|
|
24
|
+
let inlineMarkup = 0
|
|
25
|
+
const tuPattern = /<tu\b[^>]*>([\s\S]*?)<\/tu>/gi
|
|
26
|
+
let tu
|
|
27
|
+
|
|
28
|
+
while ((tu = tuPattern.exec(text)) !== null) {
|
|
29
|
+
const variants = []
|
|
30
|
+
const tuvPattern = /<tuv\b([^>]*)>([\s\S]*?)<\/tuv>/gi
|
|
31
|
+
let tuv
|
|
32
|
+
while ((tuv = tuvPattern.exec(tu[1])) !== null) {
|
|
33
|
+
const seg = tuv[2].match(/<seg\b[^>]*>([\s\S]*?)<\/seg>/i)
|
|
34
|
+
if (!seg) continue
|
|
35
|
+
// `<bpt>/<ept>/<ph>` encode inline formatting and placeholders in TMX.
|
|
36
|
+
// This subset intentionally imports plain text, so count the opening tags
|
|
37
|
+
// and warn instead of silently pretending the formatting survived.
|
|
38
|
+
inlineMarkup += (seg[1].match(/<(?:bpt|ept|ph)\b[^>]*>/gi) || []).length
|
|
39
|
+
const lang = attr(tuv[1], 'lang')
|
|
40
|
+
const value = segText(seg[1])
|
|
41
|
+
if (value) variants.push({ lang: lang || '', text: value })
|
|
42
|
+
}
|
|
43
|
+
if (variants.length) units.push(variants)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const sourceLang = attr(header?.[1], 'srclang') || ''
|
|
47
|
+
const languages = [...new Set(units.flat().map((variant) => variant.lang).filter(Boolean))]
|
|
48
|
+
const warnings = inlineMarkup
|
|
49
|
+
? [
|
|
50
|
+
{
|
|
51
|
+
code: 'TMX_INLINE_MARKUP_LOST',
|
|
52
|
+
count: inlineMarkup,
|
|
53
|
+
message: `TMX 含 ${inlineMarkup} 个内联占位符/格式标签(bpt/ept/ph),导入时已按纯文本处理,格式信息丢失。`,
|
|
54
|
+
},
|
|
55
|
+
]
|
|
56
|
+
: []
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
sourceLang,
|
|
60
|
+
targetLang: languages.find((lang) => lang !== sourceLang) || '',
|
|
61
|
+
units,
|
|
62
|
+
warnings,
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function buildTmx(doc) {
|
|
67
|
+
const lines = [
|
|
68
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
69
|
+
'<tmx version="1.4">',
|
|
70
|
+
` <header creationtool="CAIT" creationtoolversion="${CAIT_VERSION}" datatype="plaintext" segtype="sentence" adminlang="en" srclang="${escapeXml(doc.sourceLang)}" o-tmf="CAIT" creationdate="${new Date().toISOString()}"/>`,
|
|
71
|
+
' <body>',
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
for (const segment of doc.segments) {
|
|
75
|
+
lines.push(' <tu>')
|
|
76
|
+
lines.push(` <tuv xml:lang="${escapeXml(doc.sourceLang)}"><seg>${escapeXml(segment.source)}</seg></tuv>`)
|
|
77
|
+
if (segment.target) {
|
|
78
|
+
lines.push(` <tuv xml:lang="${escapeXml(doc.targetLang)}"><seg>${escapeXml(segment.target)}</seg></tuv>`)
|
|
79
|
+
}
|
|
80
|
+
lines.push(' </tu>')
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
lines.push(' </body>', '</tmx>', '')
|
|
84
|
+
return lines.join('\n')
|
|
85
|
+
}
|