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.
@@ -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 - gather until next special char
123
- let text = '';
124
- while (i < len && rtf[i] !== '\\' && rtf[i] !== '{' && rtf[i] !== '}' && rtf[i] !== '\r' && rtf[i] !== '\n') {
125
- text += rtf[i];
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 (text) {
129
- tokens.push({ type: 'text', value: text });
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
- let paragraphs = [];
231
- let paragraphAligns = [];
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
- 'pict', 'object', 'fldinst', 'xmlnstbl', 'listtable', 'listoverridetable',
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
- paragraphs.push(html);
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 (!trimmed) {
369
- if (!paragraphHasListMarker) {
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
- paragraphs.push('<hr>');
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();
@@ -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 alt-text placeholders), horizontal
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;