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/pptx.js
CHANGED
|
@@ -1,89 +1,89 @@
|
|
|
1
|
-
import { readZipEntries, writeZip } from './docx.js'
|
|
2
|
-
import { CatError } from './errors.js'
|
|
3
|
-
import { decodeXml, escapeXml } from './xml.js'
|
|
4
|
-
|
|
5
|
-
export const PPTX_MIME = 'application/vnd.openxmlformats-officedocument.presentationml.presentation'
|
|
6
|
-
|
|
7
|
-
function fail(code, message) {
|
|
8
|
-
return new CatError(code, message)
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
function slideNames(entries) {
|
|
12
|
-
return entries
|
|
13
|
-
.map((entry) => entry.name)
|
|
14
|
-
.filter((name) => /^ppt\/slides\/slide\d+\.xml$/i.test(name))
|
|
15
|
-
.sort((left, right) => Number(left.match(/(\d+)\.xml$/)[1]) - Number(right.match(/(\d+)\.xml$/)[1]))
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
function paragraphText(paragraph) {
|
|
19
|
-
return [...String(paragraph).matchAll(/<a:t\b[^>]*>([\s\S]*?)<\/a:t>/gi)]
|
|
20
|
-
.map((match) => decodeXml(match[1]))
|
|
21
|
-
.join('')
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export function parsePptxDetailed(buffer) {
|
|
25
|
-
let entries
|
|
26
|
-
try {
|
|
27
|
-
entries = readZipEntries(buffer)
|
|
28
|
-
} catch (error) {
|
|
29
|
-
throw fail('PPTX_PARSE', `PPTX 文件不是有效的 ZIP/PowerPoint 包: ${error.message}`)
|
|
30
|
-
}
|
|
31
|
-
const names = slideNames(entries)
|
|
32
|
-
if (!names.length) throw fail('PPTX_PARSE', 'PPTX 中找不到幻灯片。')
|
|
33
|
-
const segments = []
|
|
34
|
-
for (const name of names) {
|
|
35
|
-
const xml = entries.find((entry) => entry.name === name)?.data.toString('utf8') ?? ''
|
|
36
|
-
let index = 0
|
|
37
|
-
for (const match of xml.matchAll(/<a:p\b[^>]*>([\s\S]*?)<\/a:p>/gi)) {
|
|
38
|
-
const source = paragraphText(match[1]).trim()
|
|
39
|
-
const slideParagraph = index
|
|
40
|
-
index += 1
|
|
41
|
-
if (!source) continue
|
|
42
|
-
segments.push({
|
|
43
|
-
source,
|
|
44
|
-
target: '',
|
|
45
|
-
paragraph: segments.length,
|
|
46
|
-
meta: { format: 'pptx', pptxSlide: name, pptxParagraph: slideParagraph },
|
|
47
|
-
})
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
if (!segments.length) throw fail('PPTX_NO_UNITS', 'PPTX 中没有可用的正文文本。')
|
|
51
|
-
return { format: 'pptx', segments, warnings: [] }
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function patchParagraph(paragraph, text) {
|
|
55
|
-
let replaced = false
|
|
56
|
-
return String(paragraph).replace(/<a:t\b([^>]*)>([\s\S]*?)<\/a:t>/gi, (all, attrs) => {
|
|
57
|
-
if (replaced) return `<a:t${attrs}></a:t>`
|
|
58
|
-
replaced = true
|
|
59
|
-
return `<a:t${attrs}>${escapeXml(text)}</a:t>`
|
|
60
|
-
})
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function patchSlideXml(xml, replacements) {
|
|
64
|
-
let index = 0
|
|
65
|
-
return String(xml).replace(/<a:p\b[^>]*>[\s\S]*?<\/a:p>/gi, (paragraph) => {
|
|
66
|
-
const current = index
|
|
67
|
-
index += 1
|
|
68
|
-
if (!replacements.has(current)) return paragraph
|
|
69
|
-
return patchParagraph(paragraph, replacements.get(current))
|
|
70
|
-
})
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
export function buildPptx(template, replacements) {
|
|
74
|
-
let entries
|
|
75
|
-
try {
|
|
76
|
-
entries = readZipEntries(template)
|
|
77
|
-
} catch (error) {
|
|
78
|
-
throw fail('PPTX_TEMPLATE_INVALID', `PPTX 源模板损坏: ${error.message}`)
|
|
79
|
-
}
|
|
80
|
-
const patched = entries.map((entry) => {
|
|
81
|
-
const byParagraph = replacements.get(entry.name)
|
|
82
|
-
if (!byParagraph) return entry
|
|
83
|
-
return {
|
|
84
|
-
...entry,
|
|
85
|
-
data: Buffer.from(patchSlideXml(entry.data.toString('utf8'), byParagraph), 'utf8'),
|
|
86
|
-
}
|
|
87
|
-
})
|
|
88
|
-
return writeZip(patched)
|
|
89
|
-
}
|
|
1
|
+
import { readZipEntries, writeZip } from './docx.js'
|
|
2
|
+
import { CatError } from './errors.js'
|
|
3
|
+
import { decodeXml, escapeXml } from './xml.js'
|
|
4
|
+
|
|
5
|
+
export const PPTX_MIME = 'application/vnd.openxmlformats-officedocument.presentationml.presentation'
|
|
6
|
+
|
|
7
|
+
function fail(code, message) {
|
|
8
|
+
return new CatError(code, message)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function slideNames(entries) {
|
|
12
|
+
return entries
|
|
13
|
+
.map((entry) => entry.name)
|
|
14
|
+
.filter((name) => /^ppt\/slides\/slide\d+\.xml$/i.test(name))
|
|
15
|
+
.sort((left, right) => Number(left.match(/(\d+)\.xml$/)[1]) - Number(right.match(/(\d+)\.xml$/)[1]))
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function paragraphText(paragraph) {
|
|
19
|
+
return [...String(paragraph).matchAll(/<a:t\b[^>]*>([\s\S]*?)<\/a:t>/gi)]
|
|
20
|
+
.map((match) => decodeXml(match[1]))
|
|
21
|
+
.join('')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function parsePptxDetailed(buffer) {
|
|
25
|
+
let entries
|
|
26
|
+
try {
|
|
27
|
+
entries = readZipEntries(buffer)
|
|
28
|
+
} catch (error) {
|
|
29
|
+
throw fail('PPTX_PARSE', `PPTX 文件不是有效的 ZIP/PowerPoint 包: ${error.message}`)
|
|
30
|
+
}
|
|
31
|
+
const names = slideNames(entries)
|
|
32
|
+
if (!names.length) throw fail('PPTX_PARSE', 'PPTX 中找不到幻灯片。')
|
|
33
|
+
const segments = []
|
|
34
|
+
for (const name of names) {
|
|
35
|
+
const xml = entries.find((entry) => entry.name === name)?.data.toString('utf8') ?? ''
|
|
36
|
+
let index = 0
|
|
37
|
+
for (const match of xml.matchAll(/<a:p\b[^>]*>([\s\S]*?)<\/a:p>/gi)) {
|
|
38
|
+
const source = paragraphText(match[1]).trim()
|
|
39
|
+
const slideParagraph = index
|
|
40
|
+
index += 1
|
|
41
|
+
if (!source) continue
|
|
42
|
+
segments.push({
|
|
43
|
+
source,
|
|
44
|
+
target: '',
|
|
45
|
+
paragraph: segments.length,
|
|
46
|
+
meta: { format: 'pptx', pptxSlide: name, pptxParagraph: slideParagraph },
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (!segments.length) throw fail('PPTX_NO_UNITS', 'PPTX 中没有可用的正文文本。')
|
|
51
|
+
return { format: 'pptx', segments, warnings: [] }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function patchParagraph(paragraph, text) {
|
|
55
|
+
let replaced = false
|
|
56
|
+
return String(paragraph).replace(/<a:t\b([^>]*)>([\s\S]*?)<\/a:t>/gi, (all, attrs) => {
|
|
57
|
+
if (replaced) return `<a:t${attrs}></a:t>`
|
|
58
|
+
replaced = true
|
|
59
|
+
return `<a:t${attrs}>${escapeXml(text)}</a:t>`
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function patchSlideXml(xml, replacements) {
|
|
64
|
+
let index = 0
|
|
65
|
+
return String(xml).replace(/<a:p\b[^>]*>[\s\S]*?<\/a:p>/gi, (paragraph) => {
|
|
66
|
+
const current = index
|
|
67
|
+
index += 1
|
|
68
|
+
if (!replacements.has(current)) return paragraph
|
|
69
|
+
return patchParagraph(paragraph, replacements.get(current))
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function buildPptx(template, replacements) {
|
|
74
|
+
let entries
|
|
75
|
+
try {
|
|
76
|
+
entries = readZipEntries(template)
|
|
77
|
+
} catch (error) {
|
|
78
|
+
throw fail('PPTX_TEMPLATE_INVALID', `PPTX 源模板损坏: ${error.message}`)
|
|
79
|
+
}
|
|
80
|
+
const patched = entries.map((entry) => {
|
|
81
|
+
const byParagraph = replacements.get(entry.name)
|
|
82
|
+
if (!byParagraph) return entry
|
|
83
|
+
return {
|
|
84
|
+
...entry,
|
|
85
|
+
data: Buffer.from(patchSlideXml(entry.data.toString('utf8'), byParagraph), 'utf8'),
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
return writeZip(patched)
|
|
89
|
+
}
|
package/lib/segmenter.js
CHANGED
|
@@ -1,84 +1,84 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Paragraph-aware sentence segmentation.
|
|
3
|
-
*
|
|
4
|
-
* Paragraphs are separated by blank lines. Within a paragraph we split on
|
|
5
|
-
* sentence-final punctuation while protecting decimals and a small set of
|
|
6
|
-
* common abbreviations. CJK text usually has no space after the full stop,
|
|
7
|
-
* so a boundary is also recognised when the next character is CJK.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
const CJK = /[\u3400-\u9fff\u3040-\u30ff\uac00-\ud7af]/
|
|
11
|
-
|
|
12
|
-
function isAbbreviation(text, dotIndex) {
|
|
13
|
-
const before = text.slice(Math.max(0, dotIndex - 12), dotIndex)
|
|
14
|
-
return /(?:^|[\s(])(?:e\.g|i\.e|etc|Mr|Mrs|Ms|Dr|Prof|vs|No)$/i.test(before)
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
function splitSentences(text) {
|
|
18
|
-
const out = []
|
|
19
|
-
let start = 0
|
|
20
|
-
|
|
21
|
-
for (let i = 0; i < text.length; i += 1) {
|
|
22
|
-
const char = text[i]
|
|
23
|
-
const isCjkStop = char === '。' || char === '!' || char === '?' || char === '…'
|
|
24
|
-
const isLatinStop = char === '.' || char === '!' || char === '?'
|
|
25
|
-
if (!isCjkStop && !isLatinStop) continue
|
|
26
|
-
|
|
27
|
-
if (isCjkStop && char === '…' && text[i + 1] === '…') continue
|
|
28
|
-
if (isLatinStop && char === '.') {
|
|
29
|
-
const previous = text[i - 1] || ''
|
|
30
|
-
const next = text[i + 1] || ''
|
|
31
|
-
if (/\d/.test(previous) && /\d/.test(next)) continue
|
|
32
|
-
if (isAbbreviation(text, i)) continue
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
let end = i + 1
|
|
36
|
-
while (end < text.length && /["'”’))\]]/.test(text[end])) end += 1
|
|
37
|
-
|
|
38
|
-
if (isLatinStop) {
|
|
39
|
-
const next = text[end] || ''
|
|
40
|
-
if (!/\s/.test(next) && !CJK.test(next)) continue
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
const part = text.slice(start, end).trim()
|
|
44
|
-
if (part) out.push(part)
|
|
45
|
-
start = end
|
|
46
|
-
i = end - 1
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const tail = text.slice(start).trim()
|
|
50
|
-
if (tail) out.push(tail)
|
|
51
|
-
return out
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export function splitIntoSegments(input) {
|
|
55
|
-
const text = String(input ?? '').replace(/\r\n?/g, '\n').trim()
|
|
56
|
-
if (!text) return []
|
|
57
|
-
|
|
58
|
-
const segments = []
|
|
59
|
-
let paragraph = 0
|
|
60
|
-
|
|
61
|
-
for (const rawBlock of text.split(/\n{2,}/)) {
|
|
62
|
-
const block = rawBlock.trim()
|
|
63
|
-
if (!block) continue
|
|
64
|
-
|
|
65
|
-
if (/^#{1,6}\s+/.test(block)) {
|
|
66
|
-
const heading = block
|
|
67
|
-
.replace(/^#{1,6}\s+/, '')
|
|
68
|
-
.replace(/\s+#+\s*$/, '')
|
|
69
|
-
.replace(/\s*\n\s*/g, ' ')
|
|
70
|
-
.trim()
|
|
71
|
-
if (heading) segments.push({ source: heading, paragraph })
|
|
72
|
-
paragraph += 1
|
|
73
|
-
continue
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
const joined = block.replace(/\s*\n\s*/g, ' ').replace(/[ \t]{2,}/g, ' ').trim()
|
|
77
|
-
for (const sentence of splitSentences(joined)) {
|
|
78
|
-
segments.push({ source: sentence, paragraph })
|
|
79
|
-
}
|
|
80
|
-
paragraph += 1
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
return segments
|
|
84
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Paragraph-aware sentence segmentation.
|
|
3
|
+
*
|
|
4
|
+
* Paragraphs are separated by blank lines. Within a paragraph we split on
|
|
5
|
+
* sentence-final punctuation while protecting decimals and a small set of
|
|
6
|
+
* common abbreviations. CJK text usually has no space after the full stop,
|
|
7
|
+
* so a boundary is also recognised when the next character is CJK.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const CJK = /[\u3400-\u9fff\u3040-\u30ff\uac00-\ud7af]/
|
|
11
|
+
|
|
12
|
+
function isAbbreviation(text, dotIndex) {
|
|
13
|
+
const before = text.slice(Math.max(0, dotIndex - 12), dotIndex)
|
|
14
|
+
return /(?:^|[\s(])(?:e\.g|i\.e|etc|Mr|Mrs|Ms|Dr|Prof|vs|No)$/i.test(before)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function splitSentences(text) {
|
|
18
|
+
const out = []
|
|
19
|
+
let start = 0
|
|
20
|
+
|
|
21
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
22
|
+
const char = text[i]
|
|
23
|
+
const isCjkStop = char === '。' || char === '!' || char === '?' || char === '…'
|
|
24
|
+
const isLatinStop = char === '.' || char === '!' || char === '?'
|
|
25
|
+
if (!isCjkStop && !isLatinStop) continue
|
|
26
|
+
|
|
27
|
+
if (isCjkStop && char === '…' && text[i + 1] === '…') continue
|
|
28
|
+
if (isLatinStop && char === '.') {
|
|
29
|
+
const previous = text[i - 1] || ''
|
|
30
|
+
const next = text[i + 1] || ''
|
|
31
|
+
if (/\d/.test(previous) && /\d/.test(next)) continue
|
|
32
|
+
if (isAbbreviation(text, i)) continue
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let end = i + 1
|
|
36
|
+
while (end < text.length && /["'”’))\]]/.test(text[end])) end += 1
|
|
37
|
+
|
|
38
|
+
if (isLatinStop) {
|
|
39
|
+
const next = text[end] || ''
|
|
40
|
+
if (!/\s/.test(next) && !CJK.test(next)) continue
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const part = text.slice(start, end).trim()
|
|
44
|
+
if (part) out.push(part)
|
|
45
|
+
start = end
|
|
46
|
+
i = end - 1
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const tail = text.slice(start).trim()
|
|
50
|
+
if (tail) out.push(tail)
|
|
51
|
+
return out
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function splitIntoSegments(input) {
|
|
55
|
+
const text = String(input ?? '').replace(/\r\n?/g, '\n').trim()
|
|
56
|
+
if (!text) return []
|
|
57
|
+
|
|
58
|
+
const segments = []
|
|
59
|
+
let paragraph = 0
|
|
60
|
+
|
|
61
|
+
for (const rawBlock of text.split(/\n{2,}/)) {
|
|
62
|
+
const block = rawBlock.trim()
|
|
63
|
+
if (!block) continue
|
|
64
|
+
|
|
65
|
+
if (/^#{1,6}\s+/.test(block)) {
|
|
66
|
+
const heading = block
|
|
67
|
+
.replace(/^#{1,6}\s+/, '')
|
|
68
|
+
.replace(/\s+#+\s*$/, '')
|
|
69
|
+
.replace(/\s*\n\s*/g, ' ')
|
|
70
|
+
.trim()
|
|
71
|
+
if (heading) segments.push({ source: heading, paragraph })
|
|
72
|
+
paragraph += 1
|
|
73
|
+
continue
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const joined = block.replace(/\s*\n\s*/g, ' ').replace(/[ \t]{2,}/g, ' ').trim()
|
|
77
|
+
for (const sentence of splitSentences(joined)) {
|
|
78
|
+
segments.push({ source: sentence, paragraph })
|
|
79
|
+
}
|
|
80
|
+
paragraph += 1
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return segments
|
|
84
|
+
}
|
package/lib/similarity.js
CHANGED
|
@@ -1,113 +1,113 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Text normalisation + fuzzy similarity used by the translation memory.
|
|
3
|
-
* Deliberately dependency-free: character Dice gives robust short-segment
|
|
4
|
-
* scoring, token Jaccard keeps word order changes from overstating a match.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
export function normalizeText(value) {
|
|
8
|
-
return String(value ?? '')
|
|
9
|
-
.normalize('NFKC')
|
|
10
|
-
.replace(/\s+/g, ' ')
|
|
11
|
-
.trim()
|
|
12
|
-
.toLowerCase()
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export function normalizeSource(value) {
|
|
16
|
-
return String(value ?? '')
|
|
17
|
-
.normalize('NFKC')
|
|
18
|
-
.replace(/\s+/g, ' ')
|
|
19
|
-
.trim()
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Near-exact comparison key: numbers, punctuation and whitespace are ignored.
|
|
24
|
-
* This is intentionally separate from `similarity()`: punctuation-only edits can
|
|
25
|
-
* score 1.0 while a digit-only edit can score below 0.7, so no score cutoff can
|
|
26
|
-
* identify this class reliably.
|
|
27
|
-
*/
|
|
28
|
-
export function nearExactKey(value) {
|
|
29
|
-
return String(value ?? '')
|
|
30
|
-
.normalize('NFKC')
|
|
31
|
-
.toLowerCase()
|
|
32
|
-
.replace(/[\p{N}\p{P}\s]+/gu, '')
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* memoQ-style match grades for TM results.
|
|
37
|
-
*
|
|
38
|
-
* Boundaries are based on the observed score distribution:
|
|
39
|
-
* - one word / word-order edits land around 0.85-0.93;
|
|
40
|
-
* - a changed word or inflection lands around 0.75-0.85;
|
|
41
|
-
* - larger rewrites fall below 0.75.
|
|
42
|
-
* The default TM retrieval floor is 0.55, so low_fuzzy is normally the
|
|
43
|
-
* 0.55-0.7499 band rather than the whole range.
|
|
44
|
-
*
|
|
45
|
-
* `near_exact` is gated by `nearExactKey()` equality, never by `score`.
|
|
46
|
-
*/
|
|
47
|
-
export function classifyMatchType({ exact = false, score = 0, query = '', source = '' } = {}) {
|
|
48
|
-
if (exact) return 'exact'
|
|
49
|
-
const queryKey = nearExactKey(query)
|
|
50
|
-
if (queryKey && queryKey === nearExactKey(source)) return 'near_exact'
|
|
51
|
-
if (score >= 0.85) return 'high_fuzzy'
|
|
52
|
-
if (score >= 0.75) return 'medium_fuzzy'
|
|
53
|
-
return 'low_fuzzy'
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function bigrams(value) {
|
|
57
|
-
const chars = Array.from(value.replace(/[\s\p{P}\p{S}]+/gu, ''))
|
|
58
|
-
if (chars.length <= 1) return chars
|
|
59
|
-
const out = []
|
|
60
|
-
for (let i = 0; i < chars.length - 1; i += 1) out.push(chars[i] + chars[i + 1])
|
|
61
|
-
return out
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function diceCoefficient(a, b) {
|
|
65
|
-
const left = bigrams(a)
|
|
66
|
-
const right = bigrams(b)
|
|
67
|
-
if (!left.length || !right.length) return a === b ? 1 : 0
|
|
68
|
-
const counts = new Map()
|
|
69
|
-
for (const gram of left) counts.set(gram, (counts.get(gram) || 0) + 1)
|
|
70
|
-
let overlap = 0
|
|
71
|
-
for (const gram of right) {
|
|
72
|
-
const count = counts.get(gram) || 0
|
|
73
|
-
if (count > 0) {
|
|
74
|
-
overlap += 1
|
|
75
|
-
counts.set(gram, count - 1)
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
return (2 * overlap) / (left.length + right.length)
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
function tokenSet(value) {
|
|
82
|
-
const latin = value.match(/[a-z0-9]+(?:['’-][a-z0-9]+)*/g) || []
|
|
83
|
-
const cjk = value.match(/[\u3400-\u9fff\u3040-\u30ff\uac00-\ud7af]/g) || []
|
|
84
|
-
return new Set([...latin, ...cjk])
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function jaccard(a, b) {
|
|
88
|
-
const left = tokenSet(a)
|
|
89
|
-
const right = tokenSet(b)
|
|
90
|
-
if (!left.size || !right.size) return 0
|
|
91
|
-
let intersection = 0
|
|
92
|
-
for (const token of left) if (right.has(token)) intersection += 1
|
|
93
|
-
return intersection / (left.size + right.size - intersection)
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
export function similarity(a, b) {
|
|
97
|
-
const left = normalizeText(a)
|
|
98
|
-
const right = normalizeText(b)
|
|
99
|
-
if (!left || !right) return 0
|
|
100
|
-
if (left === right) return 1
|
|
101
|
-
const score = 0.72 * diceCoefficient(left, right) + 0.28 * jaccard(left, right)
|
|
102
|
-
return Math.round(Math.min(1, Math.max(0, score)) * 10000) / 10000
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
export function clamp(value, min, max) {
|
|
106
|
-
return Math.min(max, Math.max(min, value))
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
export function clampInt(value, min, max, fallback) {
|
|
110
|
-
const number = Number(value)
|
|
111
|
-
if (!Number.isFinite(number)) return fallback
|
|
112
|
-
return Math.round(clamp(number, min, max))
|
|
113
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Text normalisation + fuzzy similarity used by the translation memory.
|
|
3
|
+
* Deliberately dependency-free: character Dice gives robust short-segment
|
|
4
|
+
* scoring, token Jaccard keeps word order changes from overstating a match.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export function normalizeText(value) {
|
|
8
|
+
return String(value ?? '')
|
|
9
|
+
.normalize('NFKC')
|
|
10
|
+
.replace(/\s+/g, ' ')
|
|
11
|
+
.trim()
|
|
12
|
+
.toLowerCase()
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function normalizeSource(value) {
|
|
16
|
+
return String(value ?? '')
|
|
17
|
+
.normalize('NFKC')
|
|
18
|
+
.replace(/\s+/g, ' ')
|
|
19
|
+
.trim()
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Near-exact comparison key: numbers, punctuation and whitespace are ignored.
|
|
24
|
+
* This is intentionally separate from `similarity()`: punctuation-only edits can
|
|
25
|
+
* score 1.0 while a digit-only edit can score below 0.7, so no score cutoff can
|
|
26
|
+
* identify this class reliably.
|
|
27
|
+
*/
|
|
28
|
+
export function nearExactKey(value) {
|
|
29
|
+
return String(value ?? '')
|
|
30
|
+
.normalize('NFKC')
|
|
31
|
+
.toLowerCase()
|
|
32
|
+
.replace(/[\p{N}\p{P}\s]+/gu, '')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* memoQ-style match grades for TM results.
|
|
37
|
+
*
|
|
38
|
+
* Boundaries are based on the observed score distribution:
|
|
39
|
+
* - one word / word-order edits land around 0.85-0.93;
|
|
40
|
+
* - a changed word or inflection lands around 0.75-0.85;
|
|
41
|
+
* - larger rewrites fall below 0.75.
|
|
42
|
+
* The default TM retrieval floor is 0.55, so low_fuzzy is normally the
|
|
43
|
+
* 0.55-0.7499 band rather than the whole range.
|
|
44
|
+
*
|
|
45
|
+
* `near_exact` is gated by `nearExactKey()` equality, never by `score`.
|
|
46
|
+
*/
|
|
47
|
+
export function classifyMatchType({ exact = false, score = 0, query = '', source = '' } = {}) {
|
|
48
|
+
if (exact) return 'exact'
|
|
49
|
+
const queryKey = nearExactKey(query)
|
|
50
|
+
if (queryKey && queryKey === nearExactKey(source)) return 'near_exact'
|
|
51
|
+
if (score >= 0.85) return 'high_fuzzy'
|
|
52
|
+
if (score >= 0.75) return 'medium_fuzzy'
|
|
53
|
+
return 'low_fuzzy'
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function bigrams(value) {
|
|
57
|
+
const chars = Array.from(value.replace(/[\s\p{P}\p{S}]+/gu, ''))
|
|
58
|
+
if (chars.length <= 1) return chars
|
|
59
|
+
const out = []
|
|
60
|
+
for (let i = 0; i < chars.length - 1; i += 1) out.push(chars[i] + chars[i + 1])
|
|
61
|
+
return out
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function diceCoefficient(a, b) {
|
|
65
|
+
const left = bigrams(a)
|
|
66
|
+
const right = bigrams(b)
|
|
67
|
+
if (!left.length || !right.length) return a === b ? 1 : 0
|
|
68
|
+
const counts = new Map()
|
|
69
|
+
for (const gram of left) counts.set(gram, (counts.get(gram) || 0) + 1)
|
|
70
|
+
let overlap = 0
|
|
71
|
+
for (const gram of right) {
|
|
72
|
+
const count = counts.get(gram) || 0
|
|
73
|
+
if (count > 0) {
|
|
74
|
+
overlap += 1
|
|
75
|
+
counts.set(gram, count - 1)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return (2 * overlap) / (left.length + right.length)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function tokenSet(value) {
|
|
82
|
+
const latin = value.match(/[a-z0-9]+(?:['’-][a-z0-9]+)*/g) || []
|
|
83
|
+
const cjk = value.match(/[\u3400-\u9fff\u3040-\u30ff\uac00-\ud7af]/g) || []
|
|
84
|
+
return new Set([...latin, ...cjk])
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function jaccard(a, b) {
|
|
88
|
+
const left = tokenSet(a)
|
|
89
|
+
const right = tokenSet(b)
|
|
90
|
+
if (!left.size || !right.size) return 0
|
|
91
|
+
let intersection = 0
|
|
92
|
+
for (const token of left) if (right.has(token)) intersection += 1
|
|
93
|
+
return intersection / (left.size + right.size - intersection)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function similarity(a, b) {
|
|
97
|
+
const left = normalizeText(a)
|
|
98
|
+
const right = normalizeText(b)
|
|
99
|
+
if (!left || !right) return 0
|
|
100
|
+
if (left === right) return 1
|
|
101
|
+
const score = 0.72 * diceCoefficient(left, right) + 0.28 * jaccard(left, right)
|
|
102
|
+
return Math.round(Math.min(1, Math.max(0, score)) * 10000) / 10000
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function clamp(value, min, max) {
|
|
106
|
+
return Math.min(max, Math.max(min, value))
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function clampInt(value, min, max, fallback) {
|
|
110
|
+
const number = Number(value)
|
|
111
|
+
if (!Number.isFinite(number)) return fallback
|
|
112
|
+
return Math.round(clamp(number, min, max))
|
|
113
|
+
}
|