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/CHANGELOG.md +43 -0
- package/README.ja.md +53 -13
- package/README.md +58 -16
- package/README.zh-CN.md +53 -15
- package/client/components.js +32 -1
- package/client/locales.js +27 -6
- package/client/rpc.js +1 -1
- package/client.js +60 -8
- package/lib/docx.js +12 -9
- package/lib/epub.js +355 -0
- package/lib/formats.js +88 -33
- package/lib/html.js +271 -0
- package/lib/import-export.js +136 -91
- package/lib/persistence.js +20 -0
- package/lib/preprocess.js +65 -0
- package/lib/store-utils.js +6 -3
- package/lib/store.js +12 -0
- package/package.json +2 -2
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/formats.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { CatError } from './errors.js'
|
|
10
10
|
import { decodeXml, escapeXml } from './xml.js'
|
|
11
|
+
import { parseHtmlBlocks, renderHtmlTokens } from './html.js'
|
|
11
12
|
|
|
12
13
|
const SRT_TIME = /^(\d{1,3}):(\d{2}):(\d{2})[,.](\d{1,3})$/
|
|
13
14
|
const VTT_TIME = /^(?:(\d{1,3}):)?(\d{1,2}):(\d{2})\.(\d{1,3})$/
|
|
@@ -69,7 +70,44 @@ function completeInline(value, tokens) {
|
|
|
69
70
|
return output
|
|
70
71
|
}
|
|
71
72
|
|
|
72
|
-
export function
|
|
73
|
+
export function detectCsvDelimiter(text) {
|
|
74
|
+
const input = String(text ?? '').replace(/^\uFEFF/, '')
|
|
75
|
+
const candidates = [',', ';', '\t', '|']
|
|
76
|
+
const counts = new Map(candidates.map((candidate) => [candidate, 0]))
|
|
77
|
+
let quoted = false
|
|
78
|
+
let lines = 0
|
|
79
|
+
|
|
80
|
+
for (let index = 0; index < input.length; index += 1) {
|
|
81
|
+
const char = input[index]
|
|
82
|
+
if (char === '"') {
|
|
83
|
+
if (quoted && input[index + 1] === '"') {
|
|
84
|
+
index += 1
|
|
85
|
+
continue
|
|
86
|
+
}
|
|
87
|
+
quoted = !quoted
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
if (quoted) continue
|
|
91
|
+
if (char === '\n') {
|
|
92
|
+
lines += 1
|
|
93
|
+
if (lines >= 5) break
|
|
94
|
+
continue
|
|
95
|
+
}
|
|
96
|
+
if (counts.has(char)) counts.set(char, counts.get(char) + 1)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let best = ','
|
|
100
|
+
let bestCount = 0
|
|
101
|
+
for (const candidate of candidates) {
|
|
102
|
+
if (counts.get(candidate) > bestCount) {
|
|
103
|
+
best = candidate
|
|
104
|
+
bestCount = counts.get(candidate)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return best
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function parseCsvRows(text, delimiter = ',') {
|
|
73
111
|
const input = String(text ?? '').replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n')
|
|
74
112
|
const rows = []
|
|
75
113
|
let row = []
|
|
@@ -94,7 +132,7 @@ export function parseCsvRows(text) {
|
|
|
94
132
|
|
|
95
133
|
if (char === '"' && field === '') {
|
|
96
134
|
quoted = true
|
|
97
|
-
} else if (char ===
|
|
135
|
+
} else if (char === delimiter) {
|
|
98
136
|
row.push(field)
|
|
99
137
|
field = ''
|
|
100
138
|
} else if (char === '\n') {
|
|
@@ -116,7 +154,8 @@ export function parseCsvRows(text) {
|
|
|
116
154
|
}
|
|
117
155
|
|
|
118
156
|
export function parseCsv(text, { sourceColumn, targetColumn } = {}) {
|
|
119
|
-
const
|
|
157
|
+
const delimiter = detectCsvDelimiter(text)
|
|
158
|
+
const rows = parseCsvRows(text, delimiter)
|
|
120
159
|
if (!rows.length) throw formatError('CSV_PARSE', 'CSV 中没有可用的行。')
|
|
121
160
|
|
|
122
161
|
const normalize = (value) => String(value ?? '').trim().toLowerCase()
|
|
@@ -344,7 +383,8 @@ export function buildSrt(doc) {
|
|
|
344
383
|
export function buildVtt(doc) {
|
|
345
384
|
const cues = buildCues(doc.segments).map(({ segment, times }) => {
|
|
346
385
|
const identifier = segment.meta?.identifier ? `${segment.meta.identifier}\n` : ''
|
|
347
|
-
|
|
386
|
+
const settings = segment.meta?.settings ? ` ${segment.meta.settings}` : ''
|
|
387
|
+
return `${identifier}${formatVttTime(times.start)} --> ${formatVttTime(times.end)}${settings}\n${subtitleText(segment)}\n`
|
|
348
388
|
})
|
|
349
389
|
return `WEBVTT\n\n${cues.join('\n')}`
|
|
350
390
|
}
|
|
@@ -648,50 +688,47 @@ export function buildXliff2(doc) {
|
|
|
648
688
|
}
|
|
649
689
|
|
|
650
690
|
function htmlToText(input) {
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
.replace(/<!--[\s\S]*?-->/g, '')
|
|
654
|
-
.replace(/<(script|style|noscript|head)\b[\s\S]*?<\/\1>/gi, '')
|
|
655
|
-
.replace(/<br\s*\/?>/gi, '\n')
|
|
656
|
-
.replace(/<\/(?:p|div|li|h[1-6]|section|article|blockquote|pre|tr)>/gi, '\n\n')
|
|
657
|
-
.replace(/<\/(?:td|th)>/gi, '\t')
|
|
658
|
-
.replace(/<[^>]+>/g, '')
|
|
659
|
-
return decodeXml(text)
|
|
660
|
-
.replace(/[ \t]+\n/g, '\n')
|
|
661
|
-
.replace(/\n{3,}/g, '\n\n')
|
|
662
|
-
.trim()
|
|
691
|
+
const { runs } = parseHtmlBlocks(input)
|
|
692
|
+
return runs.map((run) => run.text).join('\n\n').trim()
|
|
663
693
|
}
|
|
664
694
|
|
|
665
695
|
export function parseHtml(text) {
|
|
666
696
|
const input = String(text ?? '')
|
|
667
697
|
if (!input.trim()) throw formatError('HTML_PARSE', 'HTML 文件内容为空。')
|
|
668
|
-
const
|
|
669
|
-
if (!
|
|
670
|
-
const segments =
|
|
671
|
-
.
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
}))
|
|
680
|
-
if (!segments.length) throw formatError('HTML_NO_UNITS', 'HTML 中没有可用的正文。')
|
|
698
|
+
const { runs } = parseHtmlBlocks(input)
|
|
699
|
+
if (!runs.length) throw formatError('HTML_NO_UNITS', 'HTML 中没有可用的正文。')
|
|
700
|
+
const segments = runs.map((run, index) => ({
|
|
701
|
+
source: run.text,
|
|
702
|
+
target: '',
|
|
703
|
+
paragraph: index,
|
|
704
|
+
meta: {
|
|
705
|
+
format: 'html',
|
|
706
|
+
...(run.tokens.length ? { inline: run.tokens } : {}),
|
|
707
|
+
},
|
|
708
|
+
}))
|
|
681
709
|
return { format: 'html', segments, warnings: [] }
|
|
682
710
|
}
|
|
683
711
|
|
|
712
|
+
function renderHtmlSegment(segment) {
|
|
713
|
+
const tokens = Array.isArray(segment?.meta?.inline) ? segment.meta.inline : []
|
|
714
|
+
let value = String(segment?.target || segment?.source || '')
|
|
715
|
+
if (tokens.length && tokens.some((item) => !value.includes(item.token))) {
|
|
716
|
+
value = String(segment?.source ?? '')
|
|
717
|
+
}
|
|
718
|
+
return tokens.length ? renderHtmlTokens(value, tokens) : escapeXml(value)
|
|
719
|
+
}
|
|
720
|
+
|
|
684
721
|
export function buildHtml(doc) {
|
|
685
722
|
const groups = new Map()
|
|
686
723
|
for (const segment of doc.segments) {
|
|
687
724
|
const key = segment.paragraph ?? 0
|
|
688
725
|
if (!groups.has(key)) groups.set(key, [])
|
|
689
|
-
groups.get(key).push(
|
|
726
|
+
groups.get(key).push(segment)
|
|
690
727
|
}
|
|
691
728
|
const paragraphs = [...groups.values()]
|
|
692
|
-
.map((parts) => parts.filter(Boolean).join(' '))
|
|
729
|
+
.map((parts) => parts.map((segment) => renderHtmlSegment(segment).trim()).filter(Boolean).join(' '))
|
|
693
730
|
.filter(Boolean)
|
|
694
|
-
.map((text) => ` <p>${
|
|
731
|
+
.map((text) => ` <p>${text}</p>`)
|
|
695
732
|
.join('\n')
|
|
696
733
|
return [
|
|
697
734
|
'<!doctype html>',
|
|
@@ -743,7 +780,7 @@ function setJsonPointer(root, pointer, value) {
|
|
|
743
780
|
}
|
|
744
781
|
|
|
745
782
|
export function parseJson(text) {
|
|
746
|
-
const input = String(text ?? '')
|
|
783
|
+
const input = String(text ?? '').replace(/^\uFEFF/, '')
|
|
747
784
|
if (!input.trim()) throw formatError('JSON_PARSE', 'JSON 文件内容为空。')
|
|
748
785
|
let root
|
|
749
786
|
try {
|
|
@@ -836,6 +873,16 @@ export function parseRtf(text) {
|
|
|
836
873
|
index += 2
|
|
837
874
|
continue
|
|
838
875
|
}
|
|
876
|
+
if (next === '~') {
|
|
877
|
+
output += ' '
|
|
878
|
+
index += 2
|
|
879
|
+
continue
|
|
880
|
+
}
|
|
881
|
+
if (next === '_') {
|
|
882
|
+
output += '-'
|
|
883
|
+
index += 2
|
|
884
|
+
continue
|
|
885
|
+
}
|
|
839
886
|
let cursor = index + 1
|
|
840
887
|
let sign = 1
|
|
841
888
|
if (input[cursor] === '-') {
|
|
@@ -855,6 +902,14 @@ export function parseRtf(text) {
|
|
|
855
902
|
if (word === 'par') output += '\n\n'
|
|
856
903
|
else if (word === 'line') output += '\n'
|
|
857
904
|
else if (word === 'tab') output += '\t'
|
|
905
|
+
else if (word === 'cell') output += '\t'
|
|
906
|
+
else if (word === 'row') output += '\n'
|
|
907
|
+
else if (word === 'page' || word === 'sect') output += '\n\n'
|
|
908
|
+
else if (word === 'emdash') output += '—'
|
|
909
|
+
else if (word === 'endash') output += '–'
|
|
910
|
+
else if (word === 'bullet') output += '•'
|
|
911
|
+
else if (word === 'lquote' || word === 'rquote') output += "'"
|
|
912
|
+
else if (word === 'ldblquote' || word === 'rdblquote') output += '"'
|
|
858
913
|
else if (word === 'uc' && value !== null) unicodeSkip = Math.max(0, value)
|
|
859
914
|
else if (word === 'u' && value !== null) {
|
|
860
915
|
output += rtfUnicode(value)
|