svelte-rtf-editor 1.2.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,15 +351,28 @@ export function rtfToHtml(rtfString) {
227
351
  let colorTable = [''];
228
352
  let fontTable = new Map();
229
353
  let currentParagraph = '';
230
- let paragraphs = [];
354
+ const paragraphs = [];
355
+ const paragraphAligns = [];
356
+ const paragraphCaptions = [];
231
357
  let paragraphHasText = false;
232
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
+ }
366
+ // Paragraph alignment (reset by \pard, affects cells and paragraphs)
367
+ let currentAlign = 'left';
233
368
  // Table state
234
369
  let tableRows = [];
235
370
  let currentRowCells = [];
236
371
  let currentRowIsHeader = false;
237
372
  let currentRowCellRights = [];
373
+ let currentRowCellAligns = [];
238
374
  let inTableCell = false;
375
+ let pendingBottomBorder = false; // set by \clbrdrb, confirmed by a real border type
239
376
  // First pass: extract color table and font table from the root group
240
377
  const rootGroup = tree.children[0]?.type === 'group' ? tree.children[0] : tree;
241
378
  for (let i = 0; i < rootGroup.children.length; i++) {
@@ -325,7 +462,9 @@ export function rtfToHtml(rtfString) {
325
462
  const destinationWords = new Set([
326
463
  'fonttbl', 'colortbl', 'stylesheet', 'info', 'header', 'footer',
327
464
  'headerl', 'headerr', 'headerf', 'footerl', 'footerr', 'footerf',
328
- 'pict', 'object', 'fldinst', 'xmlnstbl', 'listtable', 'listoverridetable',
465
+ // \nonshppict duplicates the picture already carried by \*\shppict
466
+ 'nonshppict',
467
+ 'object', 'fldinst', 'xmlnstbl', 'listtable', 'listoverridetable',
329
468
  'rsidtbl', 'generator', 'datafield', 'themedata', 'colorschememapping',
330
469
  'latentstyles', 'datastore', 'mmathPr', 'author', 'operator',
331
470
  'title', 'subject', 'doccomm', 'company', 'category', 'keywords'
@@ -343,31 +482,30 @@ export function rtfToHtml(rtfString) {
343
482
  const right = row.cellRights[ci] ?? total;
344
483
  const pct = ((right - prevRight) / total * 100).toFixed(1);
345
484
  prevRight = right;
485
+ const align = row.cellAligns[ci] ?? 'left';
486
+ const alignStyle = align !== 'left' ? `;text-align:${align}` : '';
346
487
  const style = row.isHeader
347
- ? `border-bottom:1px solid;padding:2px 6px;text-align:left;width:${pct}%`
348
- : `padding:2px 6px;width:${pct}%`;
488
+ ? `border-bottom:1px solid;padding:2px 6px;text-align:${align};width:${pct}%`
489
+ : `padding:2px 6px;width:${pct}%${alignStyle}`;
349
490
  html += `<${tag} style="${style}">${row.cells[ci] || ''}</${tag}>`;
350
491
  }
351
492
  html += '</tr>';
352
493
  }
353
494
  html += '</table>';
354
- paragraphs.push(html);
495
+ pushParagraph(html, 'left');
355
496
  tableRows = [];
356
497
  }
357
498
  function flushParagraph() {
358
499
  flushTable();
359
500
  const trimmed = currentParagraph.trim();
360
- if (!trimmed) {
361
- if (!paragraphHasListMarker) {
362
- paragraphs.push(currentParagraph);
363
- }
364
- }
365
- else {
366
- paragraphs.push(currentParagraph);
501
+ if (trimmed || !paragraphHasListMarker) {
502
+ pushParagraph(currentParagraph, currentAlign, paragraphIsCaption);
367
503
  }
368
504
  currentParagraph = '';
369
505
  paragraphHasText = false;
370
506
  paragraphHasListMarker = false;
507
+ paragraphIsCaption = false;
508
+ currentAlign = 'left';
371
509
  }
372
510
  let pendingUnicodeSkip = 0;
373
511
  function walk(nodes) {
@@ -384,12 +522,32 @@ export function rtfToHtml(rtfString) {
384
522
  continue;
385
523
  }
386
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
+ }
387
533
  if (firstCtrl && destinationWords.has(firstCtrl.word)) {
388
534
  continue;
389
535
  }
390
536
  const hasStarDest = node.children.length >= 2 &&
391
537
  node.children[0].type === 'control' && node.children[0].word === '*';
392
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
+ }
393
551
  continue;
394
552
  }
395
553
  stateStack.push(cloneState());
@@ -444,6 +602,18 @@ export function rtfToHtml(rtfString) {
444
602
  if (p != null)
445
603
  state.fontIndex = p;
446
604
  break;
605
+ case 'ql':
606
+ currentAlign = 'left';
607
+ break;
608
+ case 'qr':
609
+ currentAlign = 'right';
610
+ break;
611
+ case 'qc':
612
+ currentAlign = 'center';
613
+ break;
614
+ case 'qj':
615
+ currentAlign = 'justify';
616
+ break;
447
617
  case 'par':
448
618
  if (!inTableCell)
449
619
  flushParagraph();
@@ -452,19 +622,32 @@ export function rtfToHtml(rtfString) {
452
622
  currentRowCells = [];
453
623
  currentRowIsHeader = false;
454
624
  currentRowCellRights = [];
625
+ pendingBottomBorder = false;
455
626
  break;
456
627
  case 'trgaph':
457
628
  case 'trkeep':
458
629
  case 'trleft':
459
630
  case 'trhdr':
460
- case 'brdrs':
461
- case 'brdrnil':
462
631
  case 'clbrdrr':
463
632
  case 'clbrdrl':
464
633
  case 'clbrdrt':
634
+ pendingBottomBorder = false;
465
635
  break; // table formatting, ignored
466
636
  case 'clbrdrb':
467
- currentRowIsHeader = true;
637
+ pendingBottomBorder = true;
638
+ break;
639
+ case 'brdrnone':
640
+ case 'brdrnil':
641
+ pendingBottomBorder = false;
642
+ break;
643
+ case 'brdrs':
644
+ case 'brdrdb':
645
+ case 'brdrth':
646
+ case 'brdrdot':
647
+ case 'brdrdash':
648
+ if (pendingBottomBorder)
649
+ currentRowIsHeader = true;
650
+ pendingBottomBorder = false;
468
651
  break;
469
652
  case 'cellx':
470
653
  if (p != null)
@@ -475,14 +658,17 @@ export function rtfToHtml(rtfString) {
475
658
  break;
476
659
  case 'cell':
477
660
  currentRowCells.push(currentParagraph.trim());
661
+ currentRowCellAligns.push(currentAlign);
478
662
  currentParagraph = '';
479
663
  paragraphHasText = false;
664
+ currentAlign = 'left';
480
665
  break;
481
666
  case 'row':
482
- tableRows.push({ cells: currentRowCells, isHeader: currentRowIsHeader, cellRights: currentRowCellRights });
667
+ tableRows.push({ cells: currentRowCells, isHeader: currentRowIsHeader, cellRights: currentRowCellRights, cellAligns: currentRowCellAligns });
483
668
  currentRowCells = [];
484
669
  currentRowIsHeader = false;
485
670
  currentRowCellRights = [];
671
+ currentRowCellAligns = [];
486
672
  inTableCell = false;
487
673
  break;
488
674
  case 'pard':
@@ -490,6 +676,7 @@ export function rtfToHtml(rtfString) {
490
676
  state.italic = false;
491
677
  state.underline = false;
492
678
  state.strike = false;
679
+ currentAlign = 'left';
493
680
  break;
494
681
  case 'line':
495
682
  currentParagraph += '<br>';
@@ -534,7 +721,7 @@ export function rtfToHtml(rtfString) {
534
721
  break;
535
722
  case 'page':
536
723
  flushParagraph();
537
- paragraphs.push('<hr>');
724
+ pushParagraph('<hr>', 'left');
538
725
  break;
539
726
  case 'emspace':
540
727
  case 'enspace':
@@ -608,6 +795,7 @@ export function rtfToHtml(rtfString) {
608
795
  const isListLine = (text) => /^[-\u2022]/.test(text.replace(/^&emsp;+/g, ''));
609
796
  for (let i = 0; i < trimmedParagraphs.length; i++) {
610
797
  const trimmed = trimmedParagraphs[i];
798
+ const align = paragraphAligns[i] ?? 'left';
611
799
  const prev = i > 0 ? trimmedParagraphs[i - 1] : '';
612
800
  const next = i + 1 < trimmedParagraphs.length ? trimmedParagraphs[i + 1] : '';
613
801
  if (!trimmed) {
@@ -629,23 +817,40 @@ export function rtfToHtml(rtfString) {
629
817
  rendered.push(trimmed);
630
818
  continue;
631
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
+ }
632
835
  const sizeMatch = trimmed.match(/font-size:\s*([\d.]+)pt/);
633
836
  if (sizeMatch) {
634
837
  const pt = parseFloat(sizeMatch[1]);
838
+ const alignAttr = align !== 'left' ? ` style="text-align:${align}"` : '';
635
839
  if (pt >= 24) {
636
- rendered.push(`<h1>${stripSizeSpan(trimmed)}</h1>`);
840
+ rendered.push(`<h1${alignAttr}>${stripSizeSpan(trimmed)}</h1>`);
637
841
  continue;
638
842
  }
639
843
  if (pt >= 18) {
640
- rendered.push(`<h2>${stripSizeSpan(trimmed)}</h2>`);
844
+ rendered.push(`<h2${alignAttr}>${stripSizeSpan(trimmed)}</h2>`);
641
845
  continue;
642
846
  }
643
847
  if (pt >= 14) {
644
- rendered.push(`<h3>${stripSizeSpan(trimmed)}</h3>`);
848
+ rendered.push(`<h3${alignAttr}>${stripSizeSpan(trimmed)}</h3>`);
645
849
  continue;
646
850
  }
647
851
  }
648
- rendered.push(`<p>${trimmed}</p>`);
852
+ const pStyle = align !== 'left' ? ` style="text-align:${align}"` : '';
853
+ rendered.push(`<p${pStyle}>${trimmed}</p>`);
649
854
  }
650
855
  const output = rendered.join('\n');
651
856
  // fontTable is parsed (for future font rendering) but not yet used in output
@@ -655,6 +860,10 @@ export function rtfToHtml(rtfString) {
655
860
  function stripSizeSpan(html) {
656
861
  return html.replace(/^<span style="[^"]*font-size:[^"]*">([\s\S]*)<\/span>$/, '$1');
657
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
+ }
658
867
  export function readRtfFile(file) {
659
868
  return new Promise((resolve, reject) => {
660
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;