svelte-rtf-editor 1.3.0 → 1.4.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 +75 -2
- package/dist/RtfViewer.svelte +18 -0
- package/dist/components/ImageOverlay.svelte +302 -0
- package/dist/components/ImageOverlay.svelte.d.ts +27 -0
- package/dist/components/InkEditor.svelte +556 -33
- package/dist/components/InkEditor.svelte.d.ts +9 -0
- package/dist/images.d.ts +86 -0
- package/dist/images.js +286 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/rtf-parser.d.ts +2 -1
- package/dist/rtf-parser.js +188 -23
- package/dist/rtf-writer.d.ts +16 -2
- package/dist/rtf-writer.js +302 -27
- package/dist/utils.js +13 -0
- package/package.json +1 -1
package/dist/rtf-parser.js
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
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.
|
|
@@ -28,6 +29,125 @@ const HIGHLIGHT_COLORS = {
|
|
|
28
29
|
15: '#808080', // Dark Gray
|
|
29
30
|
16: '#c0c0c0', // Light Gray
|
|
30
31
|
};
|
|
32
|
+
// ── Picture helpers ──
|
|
33
|
+
const B64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
34
|
+
/** 1 px at 96 dpi = 15 twips. */
|
|
35
|
+
const TWIPS_PER_PX = 15;
|
|
36
|
+
/** A paragraph made up of nothing but pictures is rendered as a <figure>. */
|
|
37
|
+
const IMAGE_ONLY_PARAGRAPH = /^(?:<img\b[^>]*>\s*)+$/;
|
|
38
|
+
/** Nibble value of each hex digit's char code; -1 for anything else. */
|
|
39
|
+
const HEX_NIBBLE = (() => {
|
|
40
|
+
const table = new Int8Array(128).fill(-1);
|
|
41
|
+
for (let i = 0; i < 10; i++)
|
|
42
|
+
table[48 + i] = i; // '0'–'9'
|
|
43
|
+
for (let i = 0; i < 6; i++) {
|
|
44
|
+
table[97 + i] = 10 + i; // 'a'–'f'
|
|
45
|
+
table[65 + i] = 10 + i; // 'A'–'F'
|
|
46
|
+
}
|
|
47
|
+
return table;
|
|
48
|
+
})();
|
|
49
|
+
/** Hex picture data → base64, without depending on btoa/Buffer. */
|
|
50
|
+
function hexToBase64(hex) {
|
|
51
|
+
const byteCount = hex.length >> 1;
|
|
52
|
+
// A multi-MB picture runs to millions of digits, so this reads char codes
|
|
53
|
+
// against a lookup table rather than slicing and parsing per byte, and
|
|
54
|
+
// collects into an array rather than concatenating per output character.
|
|
55
|
+
const out = new Array(Math.ceil(byteCount / 3) * 4);
|
|
56
|
+
let written = 0;
|
|
57
|
+
let buffer = 0;
|
|
58
|
+
let bits = 0;
|
|
59
|
+
for (let i = 0; i < byteCount; i++) {
|
|
60
|
+
const hi = HEX_NIBBLE[hex.charCodeAt(i * 2)] ?? -1;
|
|
61
|
+
const lo = HEX_NIBBLE[hex.charCodeAt(i * 2 + 1)] ?? -1;
|
|
62
|
+
if (hi < 0 || lo < 0)
|
|
63
|
+
return '';
|
|
64
|
+
buffer = (buffer << 8) | ((hi << 4) | lo);
|
|
65
|
+
bits += 8;
|
|
66
|
+
while (bits >= 6) {
|
|
67
|
+
bits -= 6;
|
|
68
|
+
out[written++] = B64_ALPHABET[(buffer >> bits) & 0x3f];
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (bits > 0) {
|
|
72
|
+
out[written++] = B64_ALPHABET[(buffer << (6 - bits)) & 0x3f];
|
|
73
|
+
}
|
|
74
|
+
while (written % 4 !== 0)
|
|
75
|
+
out[written++] = '=';
|
|
76
|
+
out.length = written;
|
|
77
|
+
return out.join('');
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Render a {\pict …} group as an <img> with a data URL. Returns '' for picture
|
|
81
|
+
* formats a browser cannot display (metafiles, device-dependent bitmaps).
|
|
82
|
+
*
|
|
83
|
+
* Only hexadecimal picture data is read. RTF also allows \binN, where the next
|
|
84
|
+
* N bytes are raw binary — rare outside of Word's own output, and it cannot
|
|
85
|
+
* survive readRtfFile() reading the document as text anyway. Such a picture is
|
|
86
|
+
* skipped rather than mis-decoded: \bin is not a recognised control word here,
|
|
87
|
+
* so its bytes fall through the tokenizer and the hex filter discards them.
|
|
88
|
+
*/
|
|
89
|
+
function renderPicture(group) {
|
|
90
|
+
let mime = '';
|
|
91
|
+
let picw = 0, pich = 0, wgoal = 0, hgoal = 0;
|
|
92
|
+
let scaleX = 100, scaleY = 100;
|
|
93
|
+
const hexParts = [];
|
|
94
|
+
for (const node of group.children) {
|
|
95
|
+
if (node.type === 'control') {
|
|
96
|
+
switch (node.word) {
|
|
97
|
+
case 'pngblip':
|
|
98
|
+
mime = 'image/png';
|
|
99
|
+
break;
|
|
100
|
+
case 'jpegblip':
|
|
101
|
+
mime = 'image/jpeg';
|
|
102
|
+
break;
|
|
103
|
+
case 'picw':
|
|
104
|
+
picw = node.param ?? 0;
|
|
105
|
+
break;
|
|
106
|
+
case 'pich':
|
|
107
|
+
pich = node.param ?? 0;
|
|
108
|
+
break;
|
|
109
|
+
case 'picwgoal':
|
|
110
|
+
wgoal = node.param ?? 0;
|
|
111
|
+
break;
|
|
112
|
+
case 'pichgoal':
|
|
113
|
+
hgoal = node.param ?? 0;
|
|
114
|
+
break;
|
|
115
|
+
case 'picscalex':
|
|
116
|
+
scaleX = node.param ?? 100;
|
|
117
|
+
break;
|
|
118
|
+
case 'picscaley':
|
|
119
|
+
scaleY = node.param ?? 100;
|
|
120
|
+
break;
|
|
121
|
+
default: break;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
else if (node.type === 'text') {
|
|
125
|
+
hexParts.push(node.value);
|
|
126
|
+
}
|
|
127
|
+
// Nested groups such as {\*\blipuid …} carry no picture data.
|
|
128
|
+
}
|
|
129
|
+
if (!mime)
|
|
130
|
+
return '';
|
|
131
|
+
const hex = hexParts.join('').replace(/[^0-9a-fA-F]/g, '');
|
|
132
|
+
if (hex.length < 8)
|
|
133
|
+
return '';
|
|
134
|
+
const base64 = hexToBase64(hex);
|
|
135
|
+
if (!base64)
|
|
136
|
+
return '';
|
|
137
|
+
const widthPx = wgoal
|
|
138
|
+
? Math.round(wgoal / TWIPS_PER_PX)
|
|
139
|
+
: Math.round((picw * scaleX) / 100);
|
|
140
|
+
const heightPx = hgoal
|
|
141
|
+
? Math.round(hgoal / TWIPS_PER_PX)
|
|
142
|
+
: Math.round((pich * scaleY) / 100);
|
|
143
|
+
const styles = [];
|
|
144
|
+
if (widthPx > 0)
|
|
145
|
+
styles.push(`width:${widthPx}px`);
|
|
146
|
+
if (widthPx > 0 && heightPx > 0)
|
|
147
|
+
styles.push('height:auto');
|
|
148
|
+
const style = styles.length ? ` style="${styles.join(';')}"` : '';
|
|
149
|
+
return `<img src="data:${mime};base64,${base64}"${style} alt="">`;
|
|
150
|
+
}
|
|
31
151
|
// ── Tokenizer ──
|
|
32
152
|
function tokenize(rtf) {
|
|
33
153
|
const tokens = [];
|
|
@@ -119,14 +239,18 @@ function tokenize(rtf) {
|
|
|
119
239
|
i++;
|
|
120
240
|
}
|
|
121
241
|
else {
|
|
122
|
-
// Plain text
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
242
|
+
// Plain text — slice up to the next special char. Slicing rather than
|
|
243
|
+
// appending char-by-char keeps embedded picture data (which arrives as
|
|
244
|
+
// one very long text run) from being quadratic.
|
|
245
|
+
const start = i;
|
|
246
|
+
while (i < len) {
|
|
247
|
+
const c = rtf[i];
|
|
248
|
+
if (c === '\\' || c === '{' || c === '}' || c === '\r' || c === '\n')
|
|
249
|
+
break;
|
|
126
250
|
i++;
|
|
127
251
|
}
|
|
128
|
-
if (
|
|
129
|
-
tokens.push({ type: 'text', value:
|
|
252
|
+
if (i > start) {
|
|
253
|
+
tokens.push({ type: 'text', value: rtf.substring(start, i) });
|
|
130
254
|
}
|
|
131
255
|
}
|
|
132
256
|
}
|
|
@@ -227,10 +351,18 @@ export function rtfToHtml(rtfString) {
|
|
|
227
351
|
let colorTable = [''];
|
|
228
352
|
let fontTable = new Map();
|
|
229
353
|
let currentParagraph = '';
|
|
230
|
-
|
|
231
|
-
|
|
354
|
+
const paragraphs = [];
|
|
355
|
+
const paragraphAligns = [];
|
|
356
|
+
const paragraphCaptions = [];
|
|
232
357
|
let paragraphHasText = false;
|
|
233
358
|
let paragraphHasListMarker = false;
|
|
359
|
+
let paragraphIsCaption = false;
|
|
360
|
+
/** Append a finished paragraph, keeping the parallel metadata arrays in sync. */
|
|
361
|
+
function pushParagraph(html, align, isCaption = false) {
|
|
362
|
+
paragraphs.push(html);
|
|
363
|
+
paragraphAligns.push(align);
|
|
364
|
+
paragraphCaptions.push(isCaption);
|
|
365
|
+
}
|
|
234
366
|
// Paragraph alignment (reset by \pard, affects cells and paragraphs)
|
|
235
367
|
let currentAlign = 'left';
|
|
236
368
|
// Table state
|
|
@@ -330,7 +462,9 @@ export function rtfToHtml(rtfString) {
|
|
|
330
462
|
const destinationWords = new Set([
|
|
331
463
|
'fonttbl', 'colortbl', 'stylesheet', 'info', 'header', 'footer',
|
|
332
464
|
'headerl', 'headerr', 'headerf', 'footerl', 'footerr', 'footerf',
|
|
333
|
-
|
|
465
|
+
// \nonshppict duplicates the picture already carried by \*\shppict
|
|
466
|
+
'nonshppict',
|
|
467
|
+
'object', 'fldinst', 'xmlnstbl', 'listtable', 'listoverridetable',
|
|
334
468
|
'rsidtbl', 'generator', 'datafield', 'themedata', 'colorschememapping',
|
|
335
469
|
'latentstyles', 'datastore', 'mmathPr', 'author', 'operator',
|
|
336
470
|
'title', 'subject', 'doccomm', 'company', 'category', 'keywords'
|
|
@@ -358,26 +492,19 @@ export function rtfToHtml(rtfString) {
|
|
|
358
492
|
html += '</tr>';
|
|
359
493
|
}
|
|
360
494
|
html += '</table>';
|
|
361
|
-
|
|
362
|
-
paragraphAligns.push('left');
|
|
495
|
+
pushParagraph(html, 'left');
|
|
363
496
|
tableRows = [];
|
|
364
497
|
}
|
|
365
498
|
function flushParagraph() {
|
|
366
499
|
flushTable();
|
|
367
500
|
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);
|
|
501
|
+
if (trimmed || !paragraphHasListMarker) {
|
|
502
|
+
pushParagraph(currentParagraph, currentAlign, paragraphIsCaption);
|
|
377
503
|
}
|
|
378
504
|
currentParagraph = '';
|
|
379
505
|
paragraphHasText = false;
|
|
380
506
|
paragraphHasListMarker = false;
|
|
507
|
+
paragraphIsCaption = false;
|
|
381
508
|
currentAlign = 'left';
|
|
382
509
|
}
|
|
383
510
|
let pendingUnicodeSkip = 0;
|
|
@@ -395,12 +522,32 @@ export function rtfToHtml(rtfString) {
|
|
|
395
522
|
continue;
|
|
396
523
|
}
|
|
397
524
|
const firstCtrl = node.children.find((n) => n.type === 'control');
|
|
525
|
+
if (firstCtrl?.word === 'pict') {
|
|
526
|
+
const img = renderPicture(node);
|
|
527
|
+
if (img) {
|
|
528
|
+
currentParagraph += img;
|
|
529
|
+
paragraphHasText = true;
|
|
530
|
+
}
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
398
533
|
if (firstCtrl && destinationWords.has(firstCtrl.word)) {
|
|
399
534
|
continue;
|
|
400
535
|
}
|
|
401
536
|
const hasStarDest = node.children.length >= 2 &&
|
|
402
537
|
node.children[0].type === 'control' && node.children[0].word === '*';
|
|
403
538
|
if (hasStarDest) {
|
|
539
|
+
const dest = node.children.find((n) => n.type === 'control' && n.word !== '*');
|
|
540
|
+
// Word wraps pictures in {\*\shppict{\pict …}} — descend into it
|
|
541
|
+
// rather than dropping the picture with the rest of the ignorables.
|
|
542
|
+
if (dest?.word === 'shppict') {
|
|
543
|
+
stateStack.push(cloneState());
|
|
544
|
+
walk(node.children);
|
|
545
|
+
stateStack.pop();
|
|
546
|
+
}
|
|
547
|
+
else if (dest?.word === 'inkcap') {
|
|
548
|
+
// Marks the paragraph that follows as a picture caption.
|
|
549
|
+
paragraphIsCaption = true;
|
|
550
|
+
}
|
|
404
551
|
continue;
|
|
405
552
|
}
|
|
406
553
|
stateStack.push(cloneState());
|
|
@@ -574,8 +721,7 @@ export function rtfToHtml(rtfString) {
|
|
|
574
721
|
break;
|
|
575
722
|
case 'page':
|
|
576
723
|
flushParagraph();
|
|
577
|
-
|
|
578
|
-
paragraphAligns.push('left');
|
|
724
|
+
pushParagraph('<hr>', 'left');
|
|
579
725
|
break;
|
|
580
726
|
case 'emspace':
|
|
581
727
|
case 'enspace':
|
|
@@ -671,6 +817,21 @@ export function rtfToHtml(rtfString) {
|
|
|
671
817
|
rendered.push(trimmed);
|
|
672
818
|
continue;
|
|
673
819
|
}
|
|
820
|
+
// A paragraph tagged by {\*\inkcap} is the description of the figure above it.
|
|
821
|
+
if (paragraphCaptions[i]) {
|
|
822
|
+
const last = rendered.length - 1;
|
|
823
|
+
if (last >= 0 && rendered[last].startsWith('<figure') && !rendered[last].includes('<figcaption')) {
|
|
824
|
+
rendered[last] = rendered[last].replace('</figure>', `<figcaption>${stripWrappingEm(trimmed)}</figcaption></figure>`);
|
|
825
|
+
continue;
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
// A paragraph holding nothing but pictures becomes a figure, so it can carry
|
|
829
|
+
// a caption and be aligned/resized as a unit in the editor.
|
|
830
|
+
if (IMAGE_ONLY_PARAGRAPH.test(trimmed)) {
|
|
831
|
+
const alignAttr = align !== 'left' ? ` style="text-align:${align}"` : '';
|
|
832
|
+
rendered.push(`<figure${alignAttr}>${trimmed}</figure>`);
|
|
833
|
+
continue;
|
|
834
|
+
}
|
|
674
835
|
const sizeMatch = trimmed.match(/font-size:\s*([\d.]+)pt/);
|
|
675
836
|
if (sizeMatch) {
|
|
676
837
|
const pt = parseFloat(sizeMatch[1]);
|
|
@@ -699,6 +860,10 @@ export function rtfToHtml(rtfString) {
|
|
|
699
860
|
function stripSizeSpan(html) {
|
|
700
861
|
return html.replace(/^<span style="[^"]*font-size:[^"]*">([\s\S]*)<\/span>$/, '$1');
|
|
701
862
|
}
|
|
863
|
+
/** Captions are written as italic text; <figcaption> supplies its own styling. */
|
|
864
|
+
function stripWrappingEm(html) {
|
|
865
|
+
return html.replace(/^<em>([\s\S]*)<\/em>$/, '$1');
|
|
866
|
+
}
|
|
702
867
|
export function readRtfFile(file) {
|
|
703
868
|
return new Promise((resolve, reject) => {
|
|
704
869
|
const reader = new FileReader();
|
package/dist/rtf-writer.d.ts
CHANGED
|
@@ -4,10 +4,24 @@
|
|
|
4
4
|
* Walks a contenteditable DOM tree and produces a valid RTF 1.x string.
|
|
5
5
|
* Supports: bold, italic, underline, strikethrough, font sizes (h1–h3),
|
|
6
6
|
* foreground colors (\cf), paragraphs, bullet/numbered lists, blockquotes,
|
|
7
|
-
* code blocks, hyperlinks, images (as
|
|
8
|
-
* rules, line breaks, and Unicode characters.
|
|
7
|
+
* code blocks, hyperlinks, images (embedded as \pict picture groups with
|
|
8
|
+
* their captions), horizontal rules, line breaks, and Unicode characters.
|
|
9
9
|
*
|
|
10
10
|
* Background color is intentionally not written — RTF highlight support
|
|
11
11
|
* is inconsistent across viewers (TextEdit uses \AppleHighlight, not \highlight).
|
|
12
12
|
*/
|
|
13
|
+
/**
|
|
14
|
+
* Size htmlToRtf would produce, without doing the conversion.
|
|
15
|
+
*
|
|
16
|
+
* Useful for a live indicator — the exact figure requires hex-encoding every
|
|
17
|
+
* picture, which is too much work to repeat on each keystroke.
|
|
18
|
+
*
|
|
19
|
+
* Picture data, which dominates any document that has one, is counted exactly:
|
|
20
|
+
* two characters per byte. Everything else is approximated per element, which
|
|
21
|
+
* puts the error at a bounded number of bytes rather than a proportion — so it
|
|
22
|
+
* is negligible precisely when the number matters, and never more than a few
|
|
23
|
+
* hundred bytes even when it does not. The output is ASCII, so bytes and
|
|
24
|
+
* characters are the same number.
|
|
25
|
+
*/
|
|
26
|
+
export declare function estimateRtfBytes(editorEl: HTMLElement): number;
|
|
13
27
|
export declare function htmlToRtf(editorEl: HTMLElement): string;
|