dsh-cait 1.0.3 → 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/CHANGELOG.md +410 -352
- package/LICENSE +201 -201
- package/README.ja.md +55 -14
- package/README.md +466 -423
- package/README.zh-CN.md +429 -390
- package/client/components.js +2408 -2377
- package/client/helpers.js +103 -103
- package/client/locales.js +751 -730
- package/client/rpc.js +93 -93
- package/client/styles.js +258 -258
- package/client.js +3608 -3556
- package/icon.svg +8 -8
- package/index.js +45 -45
- package/lib/api.js +223 -223
- package/lib/docx.js +392 -389
- package/lib/epub.js +355 -0
- package/lib/errors.js +8 -8
- package/lib/formats.js +972 -917
- package/lib/glossary.js +178 -178
- package/lib/html.js +271 -0
- package/lib/import-export.js +650 -605
- package/lib/libraries.js +183 -183
- package/lib/markdown.js +28 -28
- package/lib/mt.js +152 -152
- package/lib/persistence.js +341 -321
- package/lib/pptx.js +89 -89
- package/lib/preprocess.js +65 -0
- package/lib/segmenter.js +84 -84
- package/lib/similarity.js +113 -113
- package/lib/store-utils.js +342 -339
- package/lib/store.js +746 -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/epub.js
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dependency-free EPUB 3 reader/writer.
|
|
3
|
+
*
|
|
4
|
+
* EPUB is a ZIP package of XHTML documents listed in an OPF spine. Import
|
|
5
|
+
* walks the spine, scans each chapter into inline runs and stores the run
|
|
6
|
+
* ordinal in segment metadata. Export re-reads the untouched source package
|
|
7
|
+
* and replaces exactly those runs, so CSS, images, navigation and any chapter
|
|
8
|
+
* without translations survive byte-for-byte.
|
|
9
|
+
*
|
|
10
|
+
* PDF is intentionally not supported: fixed-layout pages have no reliable
|
|
11
|
+
* reading order or text flow, which makes them unsuitable for CAT work.
|
|
12
|
+
*/
|
|
13
|
+
import { posix } from 'node:path'
|
|
14
|
+
import { CatError } from './errors.js'
|
|
15
|
+
import { readZipEntries, writeZip } from './docx.js'
|
|
16
|
+
import { parseHtmlBlocks, patchHtmlRuns, renderHtmlTokens } from './html.js'
|
|
17
|
+
import { decodeXml, escapeXml } from './xml.js'
|
|
18
|
+
|
|
19
|
+
export const EPUB_MIME = 'application/epub+zip'
|
|
20
|
+
|
|
21
|
+
function readAttribute(source, name) {
|
|
22
|
+
const match = String(source ?? '').match(
|
|
23
|
+
new RegExp(`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*=\\s*["']([^"']*)["']`, 'i'),
|
|
24
|
+
)
|
|
25
|
+
return match ? decodeXml(match[1]) : ''
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function lookupEntry(entriesByName, name) {
|
|
29
|
+
const normalized = String(name ?? '').replace(/^\.\//, '')
|
|
30
|
+
let decoded = normalized
|
|
31
|
+
try {
|
|
32
|
+
decoded = decodeURIComponent(normalized)
|
|
33
|
+
} catch {
|
|
34
|
+
// A stray percent sign is not valid URI escaping; keep the literal path.
|
|
35
|
+
}
|
|
36
|
+
return (
|
|
37
|
+
entriesByName.get(normalized) ??
|
|
38
|
+
entriesByName.get(decoded) ??
|
|
39
|
+
[...entriesByName.values()].find((item) => item.name.toLowerCase() === normalized.toLowerCase()) ??
|
|
40
|
+
null
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function readTextEntry(entries, name) {
|
|
45
|
+
const entry = lookupEntry(entries, name)
|
|
46
|
+
return entry ? entry.data.toString('utf8') : ''
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function resolveHref(baseDir, href) {
|
|
50
|
+
let clean = String(href ?? '').split('#')[0].replace(/\\/g, '/')
|
|
51
|
+
try {
|
|
52
|
+
clean = decodeURIComponent(clean)
|
|
53
|
+
} catch {
|
|
54
|
+
// A stray percent sign is not valid URI escaping; keep the literal path.
|
|
55
|
+
}
|
|
56
|
+
clean = clean.replace(/^\.\//, '')
|
|
57
|
+
if (!clean) return ''
|
|
58
|
+
const resolved = baseDir ? posix.join(baseDir, clean) : clean
|
|
59
|
+
return posix.normalize(resolved).replace(/^\.\//, '')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function parseContainer(xml) {
|
|
63
|
+
const match = String(xml ?? '').match(
|
|
64
|
+
/<(?:[\w.-]+:)?rootfile\b[^>]*\bfull-path\s*=\s*["']([^"']+)["']/i,
|
|
65
|
+
)
|
|
66
|
+
return match ? decodeXml(match[1]) : ''
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function parseOpf(xml) {
|
|
70
|
+
const manifest = new Map()
|
|
71
|
+
for (const match of String(xml ?? '').matchAll(/<(?:[\w.-]+:)?item\b([^>]*?)\/?>/gi)) {
|
|
72
|
+
const attrs = match[1]
|
|
73
|
+
const id = readAttribute(attrs, 'id')
|
|
74
|
+
const href = readAttribute(attrs, 'href')
|
|
75
|
+
if (!id || !href) continue
|
|
76
|
+
manifest.set(id, {
|
|
77
|
+
href,
|
|
78
|
+
mediaType: readAttribute(attrs, 'media-type'),
|
|
79
|
+
properties: readAttribute(attrs, 'properties'),
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const spineBlock = String(xml ?? '').match(
|
|
84
|
+
/<(?:[\w.-]+:)?spine\b[^>]*>([\s\S]*?)<\/(?:[\w.-]+:)?spine>/i,
|
|
85
|
+
)
|
|
86
|
+
const spine = []
|
|
87
|
+
if (spineBlock) {
|
|
88
|
+
for (const match of spineBlock[1].matchAll(/<(?:[\w.-]+:)?itemref\b([^>]*?)\/?>/gi)) {
|
|
89
|
+
const idref = readAttribute(match[1], 'idref')
|
|
90
|
+
if (idref) spine.push(idref)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const titleMatch = String(xml ?? '').match(
|
|
95
|
+
/<(?:[\w.-]+:)?title\b[^>]*>([\s\S]*?)<\/(?:[\w.-]+:)?title>/i,
|
|
96
|
+
)
|
|
97
|
+
const languageMatch = String(xml ?? '').match(
|
|
98
|
+
/<(?:[\w.-]+:)?language\b[^>]*>([\s\S]*?)<\/(?:[\w.-]+:)?language>/i,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
title: titleMatch ? decodeXml(titleMatch[1]).trim() : '',
|
|
103
|
+
language: languageMatch ? decodeXml(languageMatch[1]).trim() : '',
|
|
104
|
+
manifest,
|
|
105
|
+
spine,
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function readPackage(buffer) {
|
|
110
|
+
let entries
|
|
111
|
+
try {
|
|
112
|
+
entries = readZipEntries(buffer, 'EPUB')
|
|
113
|
+
} catch (error) {
|
|
114
|
+
throw new CatError('EPUB_PARSE', `EPUB 解析失败: ${error?.message ?? error}`)
|
|
115
|
+
}
|
|
116
|
+
const byName = new Map(entries.map((entry) => [entry.name, entry]))
|
|
117
|
+
const container = readTextEntry(byName, 'META-INF/container.xml')
|
|
118
|
+
if (!container) throw new CatError('EPUB_PARSE', 'EPUB 缺少 META-INF/container.xml。')
|
|
119
|
+
const opfPath = parseContainer(container)
|
|
120
|
+
if (!opfPath) throw new CatError('EPUB_PARSE', 'EPUB 的 container.xml 中没有 rootfile。')
|
|
121
|
+
const opf = readTextEntry(byName, opfPath)
|
|
122
|
+
if (!opf) throw new CatError('EPUB_PARSE', `EPUB 缺少 OPF 文件: ${opfPath}`)
|
|
123
|
+
return {
|
|
124
|
+
entries,
|
|
125
|
+
byName,
|
|
126
|
+
opfPath,
|
|
127
|
+
opfDir: posix.dirname(opfPath) === '.' ? '' : posix.dirname(opfPath),
|
|
128
|
+
package: parseOpf(opf),
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function chapterHref(baseDir, item) {
|
|
133
|
+
return resolveHref(baseDir, item.href)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function isHtmlItem(item) {
|
|
137
|
+
const type = String(item.mediaType || '').toLowerCase()
|
|
138
|
+
if (type) return type === 'application/xhtml+xml' || type === 'text/html'
|
|
139
|
+
return /\.x?html?$/i.test(item.href || '')
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Parse an EPUB package into CAIT segments.
|
|
144
|
+
* Returns title/language so a fresh document inherits the book metadata.
|
|
145
|
+
*/
|
|
146
|
+
export function parseEpub(buffer) {
|
|
147
|
+
if (!Buffer.isBuffer(buffer) || !buffer.length) {
|
|
148
|
+
throw new CatError('EPUB_PARSE', 'EPUB 文件内容为空。')
|
|
149
|
+
}
|
|
150
|
+
const { byName, opfDir, package: pkg } = readPackage(buffer)
|
|
151
|
+
if (!pkg.spine.length) throw new CatError('EPUB_PARSE', 'EPUB 的 OPF 中没有 spine 书脊。')
|
|
152
|
+
|
|
153
|
+
const segments = []
|
|
154
|
+
const warnings = []
|
|
155
|
+
const chapters = []
|
|
156
|
+
|
|
157
|
+
for (const idref of pkg.spine) {
|
|
158
|
+
const item = pkg.manifest.get(idref)
|
|
159
|
+
if (!item || !isHtmlItem(item)) continue
|
|
160
|
+
if (/\bnav\b/i.test(item.properties || '')) continue
|
|
161
|
+
const href = chapterHref(opfDir, item)
|
|
162
|
+
const html = readTextEntry(byName, href)
|
|
163
|
+
if (!html) {
|
|
164
|
+
warnings.push(`EPUB 章节缺失,已跳过: ${href}`)
|
|
165
|
+
continue
|
|
166
|
+
}
|
|
167
|
+
const parsed = parseHtmlBlocks(html)
|
|
168
|
+
let imported = 0
|
|
169
|
+
for (const run of parsed.runs) {
|
|
170
|
+
segments.push({
|
|
171
|
+
source: run.text,
|
|
172
|
+
target: '',
|
|
173
|
+
paragraph: segments.length,
|
|
174
|
+
meta: {
|
|
175
|
+
format: 'epub',
|
|
176
|
+
epubHref: href.slice(0, 500),
|
|
177
|
+
epubBlock: run.index,
|
|
178
|
+
...(run.tokens.length ? { inline: run.tokens } : {}),
|
|
179
|
+
},
|
|
180
|
+
})
|
|
181
|
+
imported += 1
|
|
182
|
+
}
|
|
183
|
+
if (imported) chapters.push({ href, units: imported })
|
|
184
|
+
|
|
185
|
+
if (!parsed.runs.length) {
|
|
186
|
+
warnings.push(`EPUB 章节没有可翻译的正文,已跳过: ${href}`)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (!segments.length) throw new CatError('EPUB_NO_UNITS', 'EPUB 中没有可翻译的正文。')
|
|
191
|
+
return {
|
|
192
|
+
title: pkg.title,
|
|
193
|
+
language: pkg.language,
|
|
194
|
+
segments,
|
|
195
|
+
chapters,
|
|
196
|
+
warnings,
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function segmentSource(segment) {
|
|
201
|
+
return String(segment?.source ?? '')
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function translatedHtml(segment) {
|
|
205
|
+
const tokens = Array.isArray(segment?.meta?.inline) ? segment.meta.inline : []
|
|
206
|
+
let value = String(segment?.target ?? '')
|
|
207
|
+
if (!value) value = segmentSource(segment)
|
|
208
|
+
else if (tokens.some((item) => !value.includes(item.token))) value = segmentSource(segment)
|
|
209
|
+
return renderHtmlTokens(value, tokens)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Patch translated text into the original package. Falls back to the caller
|
|
214
|
+
* when the package no longer matches the stored segment metadata.
|
|
215
|
+
*/
|
|
216
|
+
export function buildEpub(doc, template) {
|
|
217
|
+
const { entries, byName, opfDir, package: pkg } = readPackage(template)
|
|
218
|
+
const groups = new Map()
|
|
219
|
+
const known = new Map(
|
|
220
|
+
[...pkg.manifest.values()]
|
|
221
|
+
.map((item) => [chapterHref(opfDir, item), item])
|
|
222
|
+
.filter(([href]) => href),
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
for (const segment of doc.segments) {
|
|
226
|
+
const href = segment?.meta?.epubHref
|
|
227
|
+
const block = Number(segment?.meta?.epubBlock)
|
|
228
|
+
if (typeof href !== 'string' || !href || !Number.isInteger(block) || block < 0) continue
|
|
229
|
+
if (!known.has(href)) continue
|
|
230
|
+
if (!groups.has(href)) groups.set(href, new Map())
|
|
231
|
+
groups.get(href).set(block, translatedHtml(segment))
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (!groups.size) throw new Error('EPUB 源文件中没有可回写的章节')
|
|
235
|
+
|
|
236
|
+
let patched = 0
|
|
237
|
+
for (const [href, replacements] of groups) {
|
|
238
|
+
const entry = lookupEntry(byName, href)
|
|
239
|
+
if (!entry) continue
|
|
240
|
+
const source = entry.data.toString('utf8')
|
|
241
|
+
entry.data = Buffer.from(patchHtmlRuns(source, replacements), 'utf8')
|
|
242
|
+
patched += 1
|
|
243
|
+
}
|
|
244
|
+
if (!patched) throw new Error('EPUB 源文件中没有可回写的章节')
|
|
245
|
+
|
|
246
|
+
return writeZip(
|
|
247
|
+
entries.map((entry) => ({
|
|
248
|
+
...entry,
|
|
249
|
+
store: entry.name === 'mimetype',
|
|
250
|
+
})),
|
|
251
|
+
)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function paragraphText(doc, joiner) {
|
|
255
|
+
const groups = new Map()
|
|
256
|
+
for (const segment of doc.segments) {
|
|
257
|
+
const key = segment.paragraph ?? 0
|
|
258
|
+
if (!groups.has(key)) groups.set(key, [])
|
|
259
|
+
groups.get(key).push(segment)
|
|
260
|
+
}
|
|
261
|
+
return [...groups.values()]
|
|
262
|
+
.map((parts) => parts.map((segment) => translatedHtml(segment)).filter(Boolean).join(joiner).trim())
|
|
263
|
+
.filter(Boolean)
|
|
264
|
+
.join('\n')
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Build a minimal, valid EPUB 3 package when no source package is available. */
|
|
268
|
+
export function buildEpubFromText(doc) {
|
|
269
|
+
const lang = escapeXml(doc.targetLang || 'en')
|
|
270
|
+
const title = escapeXml(doc.name || 'CAIT export')
|
|
271
|
+
const identifier = escapeXml(`urn:cait:${doc.id || 'export'}`)
|
|
272
|
+
const modified = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z')
|
|
273
|
+
const body = paragraphText(doc, ' ')
|
|
274
|
+
.split('\n')
|
|
275
|
+
.filter(Boolean)
|
|
276
|
+
.map((paragraph) => ` <p>${paragraph}</p>`)
|
|
277
|
+
.join('\n')
|
|
278
|
+
|
|
279
|
+
const chapter = [
|
|
280
|
+
'<?xml version="1.0" encoding="utf-8"?>',
|
|
281
|
+
'<!DOCTYPE html>',
|
|
282
|
+
`<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="${lang}" lang="${lang}">`,
|
|
283
|
+
' <head>',
|
|
284
|
+
' <meta charset="utf-8"/>',
|
|
285
|
+
` <title>${title}</title>`,
|
|
286
|
+
' </head>',
|
|
287
|
+
' <body>',
|
|
288
|
+
body || ' <p></p>',
|
|
289
|
+
' </body>',
|
|
290
|
+
'</html>',
|
|
291
|
+
'',
|
|
292
|
+
].join('\n')
|
|
293
|
+
|
|
294
|
+
const nav = [
|
|
295
|
+
'<?xml version="1.0" encoding="utf-8"?>',
|
|
296
|
+
'<!DOCTYPE html>',
|
|
297
|
+
`<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" xml:lang="${lang}" lang="${lang}">`,
|
|
298
|
+
' <head>',
|
|
299
|
+
' <meta charset="utf-8"/>',
|
|
300
|
+
` <title>${title}</title>`,
|
|
301
|
+
' </head>',
|
|
302
|
+
' <body>',
|
|
303
|
+
' <nav epub:type="toc" id="toc">',
|
|
304
|
+
' <h1>Contents</h1>',
|
|
305
|
+
' <ol>',
|
|
306
|
+
' <li><a href="chapter.xhtml">1</a></li>',
|
|
307
|
+
' </ol>',
|
|
308
|
+
' </nav>',
|
|
309
|
+
' </body>',
|
|
310
|
+
'</html>',
|
|
311
|
+
'',
|
|
312
|
+
].join('\n')
|
|
313
|
+
|
|
314
|
+
const opf = [
|
|
315
|
+
'<?xml version="1.0" encoding="utf-8"?>',
|
|
316
|
+
'<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="cait-id">',
|
|
317
|
+
' <metadata xmlns:dc="http://purl.org/dc/elements/1.1/">',
|
|
318
|
+
` <dc:identifier id="cait-id">${identifier}</dc:identifier>`,
|
|
319
|
+
` <dc:title>${title}</dc:title>`,
|
|
320
|
+
` <dc:language>${lang}</dc:language>`,
|
|
321
|
+
` <meta property="dcterms:modified">${modified}</meta>`,
|
|
322
|
+
' </metadata>',
|
|
323
|
+
' <manifest>',
|
|
324
|
+
' <item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>',
|
|
325
|
+
' <item id="chapter" href="chapter.xhtml" media-type="application/xhtml+xml"/>',
|
|
326
|
+
' </manifest>',
|
|
327
|
+
' <spine>',
|
|
328
|
+
' <itemref idref="chapter"/>',
|
|
329
|
+
' </spine>',
|
|
330
|
+
'</package>',
|
|
331
|
+
'',
|
|
332
|
+
].join('\n')
|
|
333
|
+
|
|
334
|
+
return writeZip([
|
|
335
|
+
{ name: 'mimetype', data: Buffer.from(EPUB_MIME, 'utf8'), store: true },
|
|
336
|
+
{
|
|
337
|
+
name: 'META-INF/container.xml',
|
|
338
|
+
data: Buffer.from(
|
|
339
|
+
[
|
|
340
|
+
'<?xml version="1.0" encoding="utf-8"?>',
|
|
341
|
+
'<container xmlns="urn:oasis:names:tc:opendocument:xmlns:container" version="1.0">',
|
|
342
|
+
' <rootfiles>',
|
|
343
|
+
' <rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>',
|
|
344
|
+
' </rootfiles>',
|
|
345
|
+
'</container>',
|
|
346
|
+
'',
|
|
347
|
+
].join('\n'),
|
|
348
|
+
'utf8',
|
|
349
|
+
),
|
|
350
|
+
},
|
|
351
|
+
{ name: 'OEBPS/content.opf', data: Buffer.from(opf, 'utf8') },
|
|
352
|
+
{ name: 'OEBPS/nav.xhtml', data: Buffer.from(nav, 'utf8') },
|
|
353
|
+
{ name: 'OEBPS/chapter.xhtml', data: Buffer.from(chapter, 'utf8') },
|
|
354
|
+
])
|
|
355
|
+
}
|
package/lib/errors.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
export class CatError extends Error {
|
|
2
|
-
constructor(code, message, status = 400) {
|
|
3
|
-
super(message)
|
|
4
|
-
this.name = 'CatError'
|
|
5
|
-
this.code = code
|
|
6
|
-
this.status = status
|
|
7
|
-
}
|
|
8
|
-
}
|
|
1
|
+
export class CatError extends Error {
|
|
2
|
+
constructor(code, message, status = 400) {
|
|
3
|
+
super(message)
|
|
4
|
+
this.name = 'CatError'
|
|
5
|
+
this.code = code
|
|
6
|
+
this.status = status
|
|
7
|
+
}
|
|
8
|
+
}
|