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/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
+ }