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/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 parseCsvRows(text) {
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 rows = parseCsvRows(text)
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
- return `${identifier}${formatVttTime(times.start)} --> ${formatVttTime(times.end)}\n${subtitleText(segment)}\n`
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
- let text = String(input ?? '').replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n')
652
- text = text
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 plain = htmlToText(input)
669
- if (!plain) throw formatError('HTML_NO_UNITS', 'HTML 中没有可用的正文。')
670
- const segments = plain
671
- .split(/\n{2,}/)
672
- .map((part) => part.trim())
673
- .filter(Boolean)
674
- .map((source, index) => ({
675
- source,
676
- target: '',
677
- paragraph: index,
678
- meta: { format: 'html' },
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(String(segment.target || segment.source || '').trim())
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>${escapeXml(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)