svelte-rtf-editor 1.3.0 → 1.5.0
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/README.md +107 -6
- package/dist/RtfViewer.svelte +41 -3
- package/dist/RtfViewer.svelte.d.ts +2 -0
- package/dist/components/ImageOverlay.svelte +302 -0
- package/dist/components/ImageOverlay.svelte.d.ts +27 -0
- package/dist/components/InkEditor.svelte +598 -54
- package/dist/components/InkEditor.svelte.d.ts +11 -0
- package/dist/images.d.ts +86 -0
- package/dist/images.js +286 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -1
- package/dist/rtf-parser.d.ts +2 -1
- package/dist/rtf-parser.js +254 -37
- package/dist/rtf-writer.d.ts +56 -4
- package/dist/rtf-writer.js +384 -35
- package/dist/utils.js +13 -0
- package/package.json +1 -1
package/dist/rtf-parser.js
CHANGED
|
@@ -4,11 +4,13 @@
|
|
|
4
4
|
* Parses RTF control words and groups into an HTML string.
|
|
5
5
|
* Supports: bold, italic, underline, strikethrough, super/subscript,
|
|
6
6
|
* font size, foreground color (\cf), highlight background color (\highlight),
|
|
7
|
-
* color table, font table, paragraphs, bullet/numbered lists,
|
|
7
|
+
* color table, font table, paragraphs, bullet/numbered lists, tables,
|
|
8
|
+
* embedded pictures (\pict PNG/JPEG blips, with captions),
|
|
8
9
|
* Unicode characters, hex escapes, and nested groups.
|
|
9
10
|
*
|
|
10
11
|
* \cb (character background) is silently ignored on import.
|
|
11
12
|
*/
|
|
13
|
+
import { HEADING_SCALE } from './rtf-writer.js';
|
|
12
14
|
// ── Highlight palette (RTF \highlight 1-16) ──
|
|
13
15
|
const HIGHLIGHT_COLORS = {
|
|
14
16
|
1: '#000000', // Black
|
|
@@ -28,6 +30,125 @@ const HIGHLIGHT_COLORS = {
|
|
|
28
30
|
15: '#808080', // Dark Gray
|
|
29
31
|
16: '#c0c0c0', // Light Gray
|
|
30
32
|
};
|
|
33
|
+
// ── Picture helpers ──
|
|
34
|
+
const B64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
35
|
+
/** 1 px at 96 dpi = 15 twips. */
|
|
36
|
+
const TWIPS_PER_PX = 15;
|
|
37
|
+
/** A paragraph made up of nothing but pictures is rendered as a <figure>. */
|
|
38
|
+
const IMAGE_ONLY_PARAGRAPH = /^(?:<img\b[^>]*>\s*)+$/;
|
|
39
|
+
/** Nibble value of each hex digit's char code; -1 for anything else. */
|
|
40
|
+
const HEX_NIBBLE = (() => {
|
|
41
|
+
const table = new Int8Array(128).fill(-1);
|
|
42
|
+
for (let i = 0; i < 10; i++)
|
|
43
|
+
table[48 + i] = i; // '0'–'9'
|
|
44
|
+
for (let i = 0; i < 6; i++) {
|
|
45
|
+
table[97 + i] = 10 + i; // 'a'–'f'
|
|
46
|
+
table[65 + i] = 10 + i; // 'A'–'F'
|
|
47
|
+
}
|
|
48
|
+
return table;
|
|
49
|
+
})();
|
|
50
|
+
/** Hex picture data → base64, without depending on btoa/Buffer. */
|
|
51
|
+
function hexToBase64(hex) {
|
|
52
|
+
const byteCount = hex.length >> 1;
|
|
53
|
+
// A multi-MB picture runs to millions of digits, so this reads char codes
|
|
54
|
+
// against a lookup table rather than slicing and parsing per byte, and
|
|
55
|
+
// collects into an array rather than concatenating per output character.
|
|
56
|
+
const out = new Array(Math.ceil(byteCount / 3) * 4);
|
|
57
|
+
let written = 0;
|
|
58
|
+
let buffer = 0;
|
|
59
|
+
let bits = 0;
|
|
60
|
+
for (let i = 0; i < byteCount; i++) {
|
|
61
|
+
const hi = HEX_NIBBLE[hex.charCodeAt(i * 2)] ?? -1;
|
|
62
|
+
const lo = HEX_NIBBLE[hex.charCodeAt(i * 2 + 1)] ?? -1;
|
|
63
|
+
if (hi < 0 || lo < 0)
|
|
64
|
+
return '';
|
|
65
|
+
buffer = (buffer << 8) | ((hi << 4) | lo);
|
|
66
|
+
bits += 8;
|
|
67
|
+
while (bits >= 6) {
|
|
68
|
+
bits -= 6;
|
|
69
|
+
out[written++] = B64_ALPHABET[(buffer >> bits) & 0x3f];
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (bits > 0) {
|
|
73
|
+
out[written++] = B64_ALPHABET[(buffer << (6 - bits)) & 0x3f];
|
|
74
|
+
}
|
|
75
|
+
while (written % 4 !== 0)
|
|
76
|
+
out[written++] = '=';
|
|
77
|
+
out.length = written;
|
|
78
|
+
return out.join('');
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Render a {\pict …} group as an <img> with a data URL. Returns '' for picture
|
|
82
|
+
* formats a browser cannot display (metafiles, device-dependent bitmaps).
|
|
83
|
+
*
|
|
84
|
+
* Only hexadecimal picture data is read. RTF also allows \binN, where the next
|
|
85
|
+
* N bytes are raw binary — rare outside of Word's own output, and it cannot
|
|
86
|
+
* survive readRtfFile() reading the document as text anyway. Such a picture is
|
|
87
|
+
* skipped rather than mis-decoded: \bin is not a recognised control word here,
|
|
88
|
+
* so its bytes fall through the tokenizer and the hex filter discards them.
|
|
89
|
+
*/
|
|
90
|
+
function renderPicture(group) {
|
|
91
|
+
let mime = '';
|
|
92
|
+
let picw = 0, pich = 0, wgoal = 0, hgoal = 0;
|
|
93
|
+
let scaleX = 100, scaleY = 100;
|
|
94
|
+
const hexParts = [];
|
|
95
|
+
for (const node of group.children) {
|
|
96
|
+
if (node.type === 'control') {
|
|
97
|
+
switch (node.word) {
|
|
98
|
+
case 'pngblip':
|
|
99
|
+
mime = 'image/png';
|
|
100
|
+
break;
|
|
101
|
+
case 'jpegblip':
|
|
102
|
+
mime = 'image/jpeg';
|
|
103
|
+
break;
|
|
104
|
+
case 'picw':
|
|
105
|
+
picw = node.param ?? 0;
|
|
106
|
+
break;
|
|
107
|
+
case 'pich':
|
|
108
|
+
pich = node.param ?? 0;
|
|
109
|
+
break;
|
|
110
|
+
case 'picwgoal':
|
|
111
|
+
wgoal = node.param ?? 0;
|
|
112
|
+
break;
|
|
113
|
+
case 'pichgoal':
|
|
114
|
+
hgoal = node.param ?? 0;
|
|
115
|
+
break;
|
|
116
|
+
case 'picscalex':
|
|
117
|
+
scaleX = node.param ?? 100;
|
|
118
|
+
break;
|
|
119
|
+
case 'picscaley':
|
|
120
|
+
scaleY = node.param ?? 100;
|
|
121
|
+
break;
|
|
122
|
+
default: break;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
else if (node.type === 'text') {
|
|
126
|
+
hexParts.push(node.value);
|
|
127
|
+
}
|
|
128
|
+
// Nested groups such as {\*\blipuid …} carry no picture data.
|
|
129
|
+
}
|
|
130
|
+
if (!mime)
|
|
131
|
+
return '';
|
|
132
|
+
const hex = hexParts.join('').replace(/[^0-9a-fA-F]/g, '');
|
|
133
|
+
if (hex.length < 8)
|
|
134
|
+
return '';
|
|
135
|
+
const base64 = hexToBase64(hex);
|
|
136
|
+
if (!base64)
|
|
137
|
+
return '';
|
|
138
|
+
const widthPx = wgoal
|
|
139
|
+
? Math.round(wgoal / TWIPS_PER_PX)
|
|
140
|
+
: Math.round((picw * scaleX) / 100);
|
|
141
|
+
const heightPx = hgoal
|
|
142
|
+
? Math.round(hgoal / TWIPS_PER_PX)
|
|
143
|
+
: Math.round((pich * scaleY) / 100);
|
|
144
|
+
const styles = [];
|
|
145
|
+
if (widthPx > 0)
|
|
146
|
+
styles.push(`width:${widthPx}px`);
|
|
147
|
+
if (widthPx > 0 && heightPx > 0)
|
|
148
|
+
styles.push('height:auto');
|
|
149
|
+
const style = styles.length ? ` style="${styles.join(';')}"` : '';
|
|
150
|
+
return `<img src="data:${mime};base64,${base64}"${style} alt="">`;
|
|
151
|
+
}
|
|
31
152
|
// ── Tokenizer ──
|
|
32
153
|
function tokenize(rtf) {
|
|
33
154
|
const tokens = [];
|
|
@@ -119,14 +240,18 @@ function tokenize(rtf) {
|
|
|
119
240
|
i++;
|
|
120
241
|
}
|
|
121
242
|
else {
|
|
122
|
-
// Plain text
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
243
|
+
// Plain text — slice up to the next special char. Slicing rather than
|
|
244
|
+
// appending char-by-char keeps embedded picture data (which arrives as
|
|
245
|
+
// one very long text run) from being quadratic.
|
|
246
|
+
const start = i;
|
|
247
|
+
while (i < len) {
|
|
248
|
+
const c = rtf[i];
|
|
249
|
+
if (c === '\\' || c === '{' || c === '}' || c === '\r' || c === '\n')
|
|
250
|
+
break;
|
|
126
251
|
i++;
|
|
127
252
|
}
|
|
128
|
-
if (
|
|
129
|
-
tokens.push({ type: 'text', value:
|
|
253
|
+
if (i > start) {
|
|
254
|
+
tokens.push({ type: 'text', value: rtf.substring(start, i) });
|
|
130
255
|
}
|
|
131
256
|
}
|
|
132
257
|
}
|
|
@@ -220,6 +345,62 @@ function parseFontTable(group) {
|
|
|
220
345
|
}
|
|
221
346
|
return fonts;
|
|
222
347
|
}
|
|
348
|
+
// ── Body size ──
|
|
349
|
+
/** Groups whose content is not document text. */
|
|
350
|
+
const destinationWords = new Set([
|
|
351
|
+
'fonttbl', 'colortbl', 'stylesheet', 'info', 'header', 'footer',
|
|
352
|
+
'headerl', 'headerr', 'headerf', 'footerl', 'footerr', 'footerf',
|
|
353
|
+
// \nonshppict duplicates the picture already carried by \*\shppict
|
|
354
|
+
'nonshppict',
|
|
355
|
+
'object', 'fldinst', 'xmlnstbl', 'listtable', 'listoverridetable',
|
|
356
|
+
'rsidtbl', 'generator', 'datafield', 'themedata', 'colorschememapping',
|
|
357
|
+
'latentstyles', 'datastore', 'mmathPr', 'author', 'operator',
|
|
358
|
+
'title', 'subject', 'doccomm', 'company', 'category', 'keywords'
|
|
359
|
+
]);
|
|
360
|
+
/** Default RTF font size: 12 pt, in half-points. */
|
|
361
|
+
const DEFAULT_HALF_POINTS = 24;
|
|
362
|
+
/**
|
|
363
|
+
* The document's body text size, in half-points: the size most of its text is
|
|
364
|
+
* set in. Headings are recognized relative to it, so a document written at
|
|
365
|
+
* 10 or 14 pt keeps its paragraphs and headings apart instead of being judged
|
|
366
|
+
* against a fixed 12 pt.
|
|
367
|
+
*/
|
|
368
|
+
function bodyHalfPoints(root) {
|
|
369
|
+
const chars = new Map();
|
|
370
|
+
function visit(nodes, size) {
|
|
371
|
+
for (const node of nodes) {
|
|
372
|
+
if (node.type === 'group') {
|
|
373
|
+
const first = node.children.find((n) => n.type === 'control');
|
|
374
|
+
const starred = node.children[0]?.type === 'control' && node.children[0].word === '*';
|
|
375
|
+
if (starred || (first && (first.word === 'pict' || destinationWords.has(first.word))))
|
|
376
|
+
continue;
|
|
377
|
+
visit(node.children, size);
|
|
378
|
+
}
|
|
379
|
+
else if (node.type === 'control') {
|
|
380
|
+
if (node.word === 'fs' && node.param != null && node.param > 0)
|
|
381
|
+
size = node.param;
|
|
382
|
+
else if (node.word === 'plain')
|
|
383
|
+
size = DEFAULT_HALF_POINTS;
|
|
384
|
+
}
|
|
385
|
+
else {
|
|
386
|
+
const n = node.value.trim().length;
|
|
387
|
+
if (n)
|
|
388
|
+
chars.set(size, (chars.get(size) ?? 0) + n);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
return size;
|
|
392
|
+
}
|
|
393
|
+
visit(root.children, DEFAULT_HALF_POINTS);
|
|
394
|
+
let best = DEFAULT_HALF_POINTS;
|
|
395
|
+
let most = 0;
|
|
396
|
+
for (const [size, n] of chars) {
|
|
397
|
+
if (n > most) {
|
|
398
|
+
best = size;
|
|
399
|
+
most = n;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return best;
|
|
403
|
+
}
|
|
223
404
|
// ── Renderer ──
|
|
224
405
|
export function rtfToHtml(rtfString) {
|
|
225
406
|
const tokens = tokenize(rtfString);
|
|
@@ -227,10 +408,18 @@ export function rtfToHtml(rtfString) {
|
|
|
227
408
|
let colorTable = [''];
|
|
228
409
|
let fontTable = new Map();
|
|
229
410
|
let currentParagraph = '';
|
|
230
|
-
|
|
231
|
-
|
|
411
|
+
const paragraphs = [];
|
|
412
|
+
const paragraphAligns = [];
|
|
413
|
+
const paragraphCaptions = [];
|
|
232
414
|
let paragraphHasText = false;
|
|
233
415
|
let paragraphHasListMarker = false;
|
|
416
|
+
let paragraphIsCaption = false;
|
|
417
|
+
/** Append a finished paragraph, keeping the parallel metadata arrays in sync. */
|
|
418
|
+
function pushParagraph(html, align, isCaption = false) {
|
|
419
|
+
paragraphs.push(html);
|
|
420
|
+
paragraphAligns.push(align);
|
|
421
|
+
paragraphCaptions.push(isCaption);
|
|
422
|
+
}
|
|
234
423
|
// Paragraph alignment (reset by \pard, affects cells and paragraphs)
|
|
235
424
|
let currentAlign = 'left';
|
|
236
425
|
// Table state
|
|
@@ -255,6 +444,9 @@ export function rtfToHtml(rtfString) {
|
|
|
255
444
|
}
|
|
256
445
|
}
|
|
257
446
|
}
|
|
447
|
+
// Sizes are shown relative to the body: body text carries no size of its
|
|
448
|
+
// own, and headings are recognized by their ratio to it.
|
|
449
|
+
const bodySize = bodyHalfPoints(rootGroup);
|
|
258
450
|
// State stack for nested groups
|
|
259
451
|
function defaultState() {
|
|
260
452
|
return {
|
|
@@ -292,9 +484,8 @@ export function rtfToHtml(rtfString) {
|
|
|
292
484
|
if (bg)
|
|
293
485
|
styles.push(`background-color:${bg}`);
|
|
294
486
|
}
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
styles.push(`font-size:${ptSize}pt`);
|
|
487
|
+
if (state.fontSize && state.fontSize !== bodySize) {
|
|
488
|
+
styles.push(`font-size:${state.fontSize / 2}pt`);
|
|
298
489
|
}
|
|
299
490
|
if (styles.length === 0)
|
|
300
491
|
return '';
|
|
@@ -327,14 +518,6 @@ export function rtfToHtml(rtfString) {
|
|
|
327
518
|
return result;
|
|
328
519
|
}
|
|
329
520
|
let skipDepth = 0;
|
|
330
|
-
const destinationWords = new Set([
|
|
331
|
-
'fonttbl', 'colortbl', 'stylesheet', 'info', 'header', 'footer',
|
|
332
|
-
'headerl', 'headerr', 'headerf', 'footerl', 'footerr', 'footerf',
|
|
333
|
-
'pict', 'object', 'fldinst', 'xmlnstbl', 'listtable', 'listoverridetable',
|
|
334
|
-
'rsidtbl', 'generator', 'datafield', 'themedata', 'colorschememapping',
|
|
335
|
-
'latentstyles', 'datastore', 'mmathPr', 'author', 'operator',
|
|
336
|
-
'title', 'subject', 'doccomm', 'company', 'category', 'keywords'
|
|
337
|
-
]);
|
|
338
521
|
function flushTable() {
|
|
339
522
|
if (tableRows.length === 0)
|
|
340
523
|
return;
|
|
@@ -358,26 +541,19 @@ export function rtfToHtml(rtfString) {
|
|
|
358
541
|
html += '</tr>';
|
|
359
542
|
}
|
|
360
543
|
html += '</table>';
|
|
361
|
-
|
|
362
|
-
paragraphAligns.push('left');
|
|
544
|
+
pushParagraph(html, 'left');
|
|
363
545
|
tableRows = [];
|
|
364
546
|
}
|
|
365
547
|
function flushParagraph() {
|
|
366
548
|
flushTable();
|
|
367
549
|
const trimmed = currentParagraph.trim();
|
|
368
|
-
if (!
|
|
369
|
-
|
|
370
|
-
paragraphs.push(currentParagraph);
|
|
371
|
-
paragraphAligns.push(currentAlign);
|
|
372
|
-
}
|
|
373
|
-
}
|
|
374
|
-
else {
|
|
375
|
-
paragraphs.push(currentParagraph);
|
|
376
|
-
paragraphAligns.push(currentAlign);
|
|
550
|
+
if (trimmed || !paragraphHasListMarker) {
|
|
551
|
+
pushParagraph(currentParagraph, currentAlign, paragraphIsCaption);
|
|
377
552
|
}
|
|
378
553
|
currentParagraph = '';
|
|
379
554
|
paragraphHasText = false;
|
|
380
555
|
paragraphHasListMarker = false;
|
|
556
|
+
paragraphIsCaption = false;
|
|
381
557
|
currentAlign = 'left';
|
|
382
558
|
}
|
|
383
559
|
let pendingUnicodeSkip = 0;
|
|
@@ -395,12 +571,32 @@ export function rtfToHtml(rtfString) {
|
|
|
395
571
|
continue;
|
|
396
572
|
}
|
|
397
573
|
const firstCtrl = node.children.find((n) => n.type === 'control');
|
|
574
|
+
if (firstCtrl?.word === 'pict') {
|
|
575
|
+
const img = renderPicture(node);
|
|
576
|
+
if (img) {
|
|
577
|
+
currentParagraph += img;
|
|
578
|
+
paragraphHasText = true;
|
|
579
|
+
}
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
398
582
|
if (firstCtrl && destinationWords.has(firstCtrl.word)) {
|
|
399
583
|
continue;
|
|
400
584
|
}
|
|
401
585
|
const hasStarDest = node.children.length >= 2 &&
|
|
402
586
|
node.children[0].type === 'control' && node.children[0].word === '*';
|
|
403
587
|
if (hasStarDest) {
|
|
588
|
+
const dest = node.children.find((n) => n.type === 'control' && n.word !== '*');
|
|
589
|
+
// Word wraps pictures in {\*\shppict{\pict …}} — descend into it
|
|
590
|
+
// rather than dropping the picture with the rest of the ignorables.
|
|
591
|
+
if (dest?.word === 'shppict') {
|
|
592
|
+
stateStack.push(cloneState());
|
|
593
|
+
walk(node.children);
|
|
594
|
+
stateStack.pop();
|
|
595
|
+
}
|
|
596
|
+
else if (dest?.word === 'inkcap') {
|
|
597
|
+
// Marks the paragraph that follows as a picture caption.
|
|
598
|
+
paragraphIsCaption = true;
|
|
599
|
+
}
|
|
404
600
|
continue;
|
|
405
601
|
}
|
|
406
602
|
stateStack.push(cloneState());
|
|
@@ -574,8 +770,7 @@ export function rtfToHtml(rtfString) {
|
|
|
574
770
|
break;
|
|
575
771
|
case 'page':
|
|
576
772
|
flushParagraph();
|
|
577
|
-
|
|
578
|
-
paragraphAligns.push('left');
|
|
773
|
+
pushParagraph('<hr>', 'left');
|
|
579
774
|
break;
|
|
580
775
|
case 'emspace':
|
|
581
776
|
case 'enspace':
|
|
@@ -671,19 +866,37 @@ export function rtfToHtml(rtfString) {
|
|
|
671
866
|
rendered.push(trimmed);
|
|
672
867
|
continue;
|
|
673
868
|
}
|
|
869
|
+
// A paragraph tagged by {\*\inkcap} is the description of the figure above it.
|
|
870
|
+
if (paragraphCaptions[i]) {
|
|
871
|
+
const last = rendered.length - 1;
|
|
872
|
+
if (last >= 0 && rendered[last].startsWith('<figure') && !rendered[last].includes('<figcaption')) {
|
|
873
|
+
rendered[last] = rendered[last].replace('</figure>', `<figcaption>${stripWrappingEm(trimmed)}</figcaption></figure>`);
|
|
874
|
+
continue;
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
// A paragraph holding nothing but pictures becomes a figure, so it can carry
|
|
878
|
+
// a caption and be aligned/resized as a unit in the editor.
|
|
879
|
+
if (IMAGE_ONLY_PARAGRAPH.test(trimmed)) {
|
|
880
|
+
const alignAttr = align !== 'left' ? ` style="text-align:${align}"` : '';
|
|
881
|
+
rendered.push(`<figure${alignAttr}>${trimmed}</figure>`);
|
|
882
|
+
continue;
|
|
883
|
+
}
|
|
674
884
|
const sizeMatch = trimmed.match(/font-size:\s*([\d.]+)pt/);
|
|
675
885
|
if (sizeMatch) {
|
|
676
|
-
|
|
886
|
+
// Compared in half-points, rounded the way the writer rounds them, so
|
|
887
|
+
// a heading written at any body size is read back as the same level.
|
|
888
|
+
const halfPoints = Math.round(parseFloat(sizeMatch[1]) * 2);
|
|
677
889
|
const alignAttr = align !== 'left' ? ` style="text-align:${align}"` : '';
|
|
678
|
-
|
|
890
|
+
const atLeast = (scale) => halfPoints >= Math.round(bodySize * scale);
|
|
891
|
+
if (atLeast(HEADING_SCALE.h1)) {
|
|
679
892
|
rendered.push(`<h1${alignAttr}>${stripSizeSpan(trimmed)}</h1>`);
|
|
680
893
|
continue;
|
|
681
894
|
}
|
|
682
|
-
if (
|
|
895
|
+
if (atLeast(HEADING_SCALE.h2)) {
|
|
683
896
|
rendered.push(`<h2${alignAttr}>${stripSizeSpan(trimmed)}</h2>`);
|
|
684
897
|
continue;
|
|
685
898
|
}
|
|
686
|
-
if (
|
|
899
|
+
if (atLeast(HEADING_SCALE.h3)) {
|
|
687
900
|
rendered.push(`<h3${alignAttr}>${stripSizeSpan(trimmed)}</h3>`);
|
|
688
901
|
continue;
|
|
689
902
|
}
|
|
@@ -699,6 +912,10 @@ export function rtfToHtml(rtfString) {
|
|
|
699
912
|
function stripSizeSpan(html) {
|
|
700
913
|
return html.replace(/^<span style="[^"]*font-size:[^"]*">([\s\S]*)<\/span>$/, '$1');
|
|
701
914
|
}
|
|
915
|
+
/** Captions are written as italic text; <figcaption> supplies its own styling. */
|
|
916
|
+
function stripWrappingEm(html) {
|
|
917
|
+
return html.replace(/^<em>([\s\S]*)<\/em>$/, '$1');
|
|
918
|
+
}
|
|
702
919
|
export function readRtfFile(file) {
|
|
703
920
|
return new Promise((resolve, reject) => {
|
|
704
921
|
const reader = new FileReader();
|
package/dist/rtf-writer.d.ts
CHANGED
|
@@ -2,12 +2,64 @@
|
|
|
2
2
|
* HTML to RTF converter
|
|
3
3
|
*
|
|
4
4
|
* Walks a contenteditable DOM tree and produces a valid RTF 1.x string.
|
|
5
|
-
* Supports:
|
|
5
|
+
* Supports: a document font and body size (Arial 12 pt by default, set with
|
|
6
|
+
* RtfWriteOptions), bold, italic, underline, strikethrough, font sizes (h1–h3),
|
|
6
7
|
* foreground colors (\cf), paragraphs, bullet/numbered lists, blockquotes,
|
|
7
|
-
* code blocks, hyperlinks, images (as
|
|
8
|
-
* rules, line breaks, and Unicode characters.
|
|
8
|
+
* code blocks, hyperlinks, images (embedded as \pict picture groups with
|
|
9
|
+
* their captions), horizontal rules, line breaks, and Unicode characters.
|
|
9
10
|
*
|
|
10
11
|
* Background color is intentionally not written — RTF highlight support
|
|
11
12
|
* is inconsistent across viewers (TextEdit uses \AppleHighlight, not \highlight).
|
|
12
13
|
*/
|
|
13
|
-
|
|
14
|
+
/** Options for htmlToRtf. */
|
|
15
|
+
export interface RtfWriteOptions {
|
|
16
|
+
/**
|
|
17
|
+
* Font for the whole document: body text, headings, lists and tables.
|
|
18
|
+
* Code keeps a monospace font. Default: Arial.
|
|
19
|
+
*/
|
|
20
|
+
fontFamily?: string;
|
|
21
|
+
/**
|
|
22
|
+
* Body text size in points. Headings scale from it. Default: 12.
|
|
23
|
+
*/
|
|
24
|
+
fontSize?: number;
|
|
25
|
+
}
|
|
26
|
+
/** Font used when no fontFamily is given. */
|
|
27
|
+
export declare const DEFAULT_FONT_FAMILY = "Arial";
|
|
28
|
+
/** Body text size, in points, used when no fontSize is given. */
|
|
29
|
+
export declare const DEFAULT_FONT_SIZE = 12;
|
|
30
|
+
/**
|
|
31
|
+
* Heading sizes as a multiple of the body size: at 12 pt, h1 is 24 pt, h2 18 pt
|
|
32
|
+
* and h3 14 pt. The parser reads headings back with the same ratios.
|
|
33
|
+
*/
|
|
34
|
+
export declare const HEADING_SCALE: {
|
|
35
|
+
readonly h1: 2;
|
|
36
|
+
readonly h2: 1.5;
|
|
37
|
+
readonly h3: number;
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* Size htmlToRtf would produce, without doing the conversion.
|
|
41
|
+
*
|
|
42
|
+
* Useful for a live indicator — the exact figure requires hex-encoding every
|
|
43
|
+
* picture, which is too much work to repeat on each keystroke.
|
|
44
|
+
*
|
|
45
|
+
* Picture data, which dominates any document that has one, is counted exactly:
|
|
46
|
+
* two characters per byte. Everything else is approximated per element, which
|
|
47
|
+
* puts the error at a bounded number of bytes rather than a proportion — so it
|
|
48
|
+
* is negligible precisely when the number matters, and never more than a few
|
|
49
|
+
* hundred bytes even when it does not. The output is ASCII, so bytes and
|
|
50
|
+
* characters are the same number.
|
|
51
|
+
*/
|
|
52
|
+
export declare function estimateRtfBytes(editorEl: HTMLElement): number;
|
|
53
|
+
/**
|
|
54
|
+
* CSS font-family value for a font name, with the generic fallback that
|
|
55
|
+
* matches it, e.g. "Arial", sans-serif.
|
|
56
|
+
*/
|
|
57
|
+
export declare function cssFontFamily(name: string | undefined): string;
|
|
58
|
+
/**
|
|
59
|
+
* A body size in points, as the RTF will carry it: rounded to the half-point
|
|
60
|
+
* RTF measures in, kept within what readers accept, and the default for
|
|
61
|
+
* missing or nonsense input. The editor and viewer show this same size, so
|
|
62
|
+
* the screen and the RTF agree.
|
|
63
|
+
*/
|
|
64
|
+
export declare function normalizeFontSize(fontSize: number | undefined): number;
|
|
65
|
+
export declare function htmlToRtf(editorEl: HTMLElement, options?: RtfWriteOptions): string;
|