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.
@@ -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 - 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];
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 (text) {
129
- tokens.push({ type: 'text', value: text });
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
- let paragraphs = [];
231
- let paragraphAligns = [];
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
- const ptSize = state.fontSize / 2;
296
- if (ptSize && ptSize !== 12) {
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
- paragraphs.push(html);
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 (!trimmed) {
369
- if (!paragraphHasListMarker) {
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
- paragraphs.push('<hr>');
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
- const pt = parseFloat(sizeMatch[1]);
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
- if (pt >= 24) {
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 (pt >= 18) {
895
+ if (atLeast(HEADING_SCALE.h2)) {
683
896
  rendered.push(`<h2${alignAttr}>${stripSizeSpan(trimmed)}</h2>`);
684
897
  continue;
685
898
  }
686
- if (pt >= 14) {
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();
@@ -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: bold, italic, underline, strikethrough, font sizes (h1–h3),
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 alt-text placeholders), horizontal
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
- export declare function htmlToRtf(editorEl: HTMLElement): string;
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;