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,12 +4,14 @@
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
+ /** 1 px at 96 dpi = 15 twips (1 twip = 1/1440 in). */
14
+ const TWIPS_PER_PX = 15;
13
15
  // ── Color helpers ──
14
16
  function parseColor(colorStr) {
15
17
  if (!colorStr)
@@ -52,10 +54,17 @@ function colorKey(c) {
52
54
  }
53
55
  // ── RTF text escaping ──
54
56
  function escapeRtf(text) {
57
+ // RTF readers ignore raw CR/LF, so a newline inside text content rendered as
58
+ // nothing ("Hello\nworld" → "Helloworld") while still emitting bytes that a
59
+ // CR-terminated transport treats as a record separator. Collapsing a run to
60
+ // the single space a browser would have shown is both safer and more
61
+ // faithful. Text is never split on newlines after this point, so <pre>
62
+ // content must be divided into lines before it gets here.
63
+ const source = text.replace(/[\r\n]+/g, ' ');
55
64
  let out = '';
56
- for (let i = 0; i < text.length; i++) {
57
- const ch = text[i];
58
- const code = text.charCodeAt(i);
65
+ for (let i = 0; i < source.length; i++) {
66
+ const ch = source[i];
67
+ const code = source.charCodeAt(i);
59
68
  if (ch === '\\')
60
69
  out += '\\\\';
61
70
  else if (ch === '{')
@@ -71,6 +80,241 @@ function escapeRtf(text) {
71
80
  }
72
81
  return out;
73
82
  }
83
+ // ── Image helpers ──
84
+ const B64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
85
+ const B64_LOOKUP = (() => {
86
+ const table = new Int16Array(128).fill(-1);
87
+ for (let i = 0; i < B64_ALPHABET.length; i++)
88
+ table[B64_ALPHABET.charCodeAt(i)] = i;
89
+ return table;
90
+ })();
91
+ // Uppercase: the RTF spec treats picture data as case-insensitive, but
92
+ // downstream consumers (HL7 interface engines, for one) sometimes match the
93
+ // leading PNG signature literally as 89504E470D0A1A0A.
94
+ const HEX_BYTE = Array.from({ length: 256 }, (_, i) => i.toString(16).padStart(2, '0').toUpperCase());
95
+ /** Decode base64 to bytes without depending on atob/Buffer (works in any runtime). */
96
+ function base64ToBytes(b64) {
97
+ let buffer = 0;
98
+ let bits = 0;
99
+ let out = 0;
100
+ const bytes = new Uint8Array(Math.ceil((b64.length * 3) / 4));
101
+ for (let i = 0; i < b64.length; i++) {
102
+ const code = b64.charCodeAt(i);
103
+ if (code === 61 /* = */)
104
+ break;
105
+ const v = code < 128 ? B64_LOOKUP[code] : -1;
106
+ if (v < 0) {
107
+ // Whitespace inside a data URL is legal; anything else is not base64.
108
+ if (code === 32 || code === 9 || code === 10 || code === 13)
109
+ continue;
110
+ return null;
111
+ }
112
+ buffer = (buffer << 6) | v;
113
+ bits += 6;
114
+ if (bits >= 8) {
115
+ bits -= 8;
116
+ bytes[out++] = (buffer >> bits) & 0xff;
117
+ }
118
+ }
119
+ return out > 0 ? bytes.subarray(0, out) : null;
120
+ }
121
+ function bytesToHex(bytes) {
122
+ const parts = new Array(bytes.length);
123
+ for (let i = 0; i < bytes.length; i++)
124
+ parts[i] = HEX_BYTE[bytes[i]];
125
+ return parts.join('');
126
+ }
127
+ /**
128
+ * Picture format according to the bytes themselves, not the label they arrived
129
+ * with. A file named .png can hold WebP — operating systems assign the MIME
130
+ * type from the extension — and writing those bytes as \pngblip produces a
131
+ * picture no reader can decode.
132
+ */
133
+ function sniffBytes(b) {
134
+ if (b.length >= 8 &&
135
+ b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47 &&
136
+ b[4] === 0x0d && b[5] === 0x0a && b[6] === 0x1a && b[7] === 0x0a) {
137
+ return 'png';
138
+ }
139
+ if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff)
140
+ return 'jpeg';
141
+ return null;
142
+ }
143
+ /** Read the intrinsic size out of the PNG IHDR chunk. */
144
+ function pngSize(b) {
145
+ if (b.length < 24)
146
+ return null;
147
+ if (b[0] !== 0x89 || b[1] !== 0x50 || b[2] !== 0x4e || b[3] !== 0x47)
148
+ return null;
149
+ const w = ((b[16] << 24) | (b[17] << 16) | (b[18] << 8) | b[19]) >>> 0;
150
+ const h = ((b[20] << 24) | (b[21] << 16) | (b[22] << 8) | b[23]) >>> 0;
151
+ return w && h ? { w, h } : null;
152
+ }
153
+ /** Read the intrinsic size out of the first JPEG start-of-frame marker. */
154
+ function jpegSize(b) {
155
+ if (b.length < 4 || b[0] !== 0xff || b[1] !== 0xd8)
156
+ return null;
157
+ let i = 2;
158
+ while (i + 9 < b.length) {
159
+ if (b[i] !== 0xff) {
160
+ i++;
161
+ continue;
162
+ }
163
+ const marker = b[i + 1];
164
+ // Standalone markers carry no length field.
165
+ if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
166
+ i += 2;
167
+ continue;
168
+ }
169
+ const len = (b[i + 2] << 8) | b[i + 3];
170
+ // SOF0–SOF15, excluding DHT (c4), JPG (c8) and DAC (cc)
171
+ if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
172
+ const h = (b[i + 5] << 8) | b[i + 6];
173
+ const w = (b[i + 7] << 8) | b[i + 8];
174
+ return w && h ? { w, h } : null;
175
+ }
176
+ if (len < 2)
177
+ break;
178
+ i += 2 + len;
179
+ }
180
+ return null;
181
+ }
182
+ function parsePx(value) {
183
+ if (!value)
184
+ return 0;
185
+ const m = value.match(/^([\d.]+)px$/);
186
+ return m ? Math.round(parseFloat(m[1])) : 0;
187
+ }
188
+ /**
189
+ * Displayed size of an image in CSS pixels. Prefers the explicit width the
190
+ * editor writes when the user resizes, then the width/height attributes, then
191
+ * the intrinsic size decoded from the image bytes (naturalWidth is unavailable
192
+ * when the DOM is detached or server-side).
193
+ */
194
+ function displaySize(el, natural) {
195
+ const img = el;
196
+ const nw = natural.w || img.naturalWidth || 0;
197
+ const nh = natural.h || img.naturalHeight || 0;
198
+ const attrW = parseInt(el.getAttribute('width') || '', 10);
199
+ const attrH = parseInt(el.getAttribute('height') || '', 10);
200
+ const w = parsePx(el.style?.width) || (Number.isFinite(attrW) ? attrW : 0) || nw;
201
+ let h = parsePx(el.style?.height) || (Number.isFinite(attrH) ? attrH : 0);
202
+ if (!h)
203
+ h = nw && nh ? Math.round((w * nh) / nw) : nh;
204
+ return { w, h };
205
+ }
206
+ /**
207
+ * Convert a data-URL <img> into an RTF \pict group. Returns '' for images the
208
+ * RTF format cannot carry (remote URLs, or formats other than PNG/JPEG), so
209
+ * the caller can fall back to a text placeholder.
210
+ */
211
+ function pictureRtf(el) {
212
+ const src = el.getAttribute('src') || '';
213
+ // Accept any image data URL and let the bytes decide the format — the label
214
+ // is not trustworthy, and a correctly-labelled PNG is not the only way to
215
+ // arrive at PNG bytes.
216
+ const m = src.match(/^data:image\/[a-z0-9.+-]+;base64,([\s\S]+)$/i);
217
+ if (!m)
218
+ return '';
219
+ const bytes = base64ToBytes(m[1]);
220
+ if (!bytes || bytes.length === 0)
221
+ return '';
222
+ const format = sniffBytes(bytes);
223
+ // Neither PNG nor JPEG: RTF cannot carry it, so fall back to the placeholder
224
+ // rather than emitting a blip whose declared type contradicts its content.
225
+ if (!format)
226
+ return '';
227
+ const isPng = format === 'png';
228
+ const natural = (isPng ? pngSize(bytes) : jpegSize(bytes)) ?? { w: 0, h: 0 };
229
+ const size = displaySize(el, natural);
230
+ if (!size.w || !size.h)
231
+ return '';
232
+ const picw = natural.w || size.w;
233
+ const pich = natural.h || size.h;
234
+ // The space after \pichgoal is the control word's delimiter — without it the
235
+ // leading hex digits would be read as part of its numeric parameter.
236
+ return (`{\\pict${isPng ? '\\pngblip' : '\\jpegblip'}` +
237
+ `\\picw${picw}\\pich${pich}` +
238
+ `\\picwgoal${Math.round(size.w * TWIPS_PER_PX)}\\pichgoal${Math.round(size.h * TWIPS_PER_PX)} ` +
239
+ `${bytesToHex(bytes)}}`);
240
+ }
241
+ /** Picture group if the bytes are embeddable, otherwise an italic placeholder. */
242
+ function imageBody(el) {
243
+ const pict = pictureRtf(el);
244
+ if (pict)
245
+ return pict;
246
+ const label = el.getAttribute('alt') || el.getAttribute('src') || 'image';
247
+ return `\\i [Image: ${escapeRtf(label)}]\\i0 `;
248
+ }
249
+ /** \ql / \qc / \qr for a figure's text-align. */
250
+ function alignControl(el) {
251
+ const align = (el.style?.textAlign || '').toLowerCase();
252
+ if (align === 'center')
253
+ return '\\qc';
254
+ if (align === 'right')
255
+ return '\\qr';
256
+ if (align === 'justify')
257
+ return '\\qj';
258
+ return '\\ql';
259
+ }
260
+ /** Decoded byte length of a base64 payload, without decoding it. */
261
+ function base64ByteLength(base64) {
262
+ const padding = base64.endsWith('==') ? 2 : base64.endsWith('=') ? 1 : 0;
263
+ return Math.max(0, Math.floor((base64.length * 3) / 4) - padding);
264
+ }
265
+ /** Bytes of RTF header, font table and colour table that precede the body. */
266
+ const RTF_PREAMBLE_BYTES = 200;
267
+ /** Control words wrapping one \pict group. */
268
+ const PICTURE_OVERHEAD_BYTES = 80;
269
+ /**
270
+ * Control-word bytes the writer wraps around each kind of element — the opening
271
+ * and closing runs measured from walkChildren. Anything not listed costs
272
+ * nothing of its own beyond the text it contains.
273
+ */
274
+ const BLOCK_OVERHEAD_BYTES = {
275
+ p: 14,
276
+ div: 14,
277
+ h1: 32,
278
+ h2: 32,
279
+ h3: 32,
280
+ li: 35,
281
+ blockquote: 22,
282
+ pre: 27,
283
+ figure: 15,
284
+ figcaption: 29,
285
+ tr: 45,
286
+ td: 55,
287
+ th: 69 // a header cell also carries \clbrdrb\brdrs
288
+ };
289
+ /**
290
+ * Size htmlToRtf would produce, without doing the conversion.
291
+ *
292
+ * Useful for a live indicator — the exact figure requires hex-encoding every
293
+ * picture, which is too much work to repeat on each keystroke.
294
+ *
295
+ * Picture data, which dominates any document that has one, is counted exactly:
296
+ * two characters per byte. Everything else is approximated per element, which
297
+ * puts the error at a bounded number of bytes rather than a proportion — so it
298
+ * is negligible precisely when the number matters, and never more than a few
299
+ * hundred bytes even when it does not. The output is ASCII, so bytes and
300
+ * characters are the same number.
301
+ */
302
+ export function estimateRtfBytes(editorEl) {
303
+ let total = RTF_PREAMBLE_BYTES;
304
+ for (const img of Array.from(editorEl.querySelectorAll('img'))) {
305
+ const src = img.getAttribute('src') || '';
306
+ const comma = src.indexOf(',');
307
+ // A picture that cannot be embedded costs only its placeholder text.
308
+ if (!src.startsWith('data:image/') || comma < 0)
309
+ continue;
310
+ total += base64ByteLength(src.slice(comma + 1)) * 2 + PICTURE_OVERHEAD_BYTES;
311
+ }
312
+ total += (editorEl.textContent || '').length;
313
+ for (const el of Array.from(editorEl.querySelectorAll('*'))) {
314
+ total += BLOCK_OVERHEAD_BYTES[el.tagName.toLowerCase()] ?? 0;
315
+ }
316
+ return total;
317
+ }
74
318
  // ── DOM walker / RTF generator ──
75
319
  export function htmlToRtf(editorEl) {
76
320
  const colorMap = new Map();
@@ -93,7 +337,13 @@ export function htmlToRtf(editorEl) {
93
337
  '{\\f1\\froman\\fcharset0 Georgia;}' +
94
338
  '{\\f2\\fmodern\\fcharset0 Courier New;}' +
95
339
  '}';
96
- const body = walkChildren(editorEl, { colorIndex, inPre: false, listCounter: 0, inTableCell: false });
340
+ const body = walkChildren(editorEl, {
341
+ colorIndex,
342
+ inPre: false,
343
+ listCounter: 0,
344
+ inTableCell: false,
345
+ root: editorEl
346
+ });
97
347
  const rtf = '{\\rtf1\\ansi\\ansicpg1252\\deff0' +
98
348
  fontTable +
99
349
  colorTable +
@@ -101,7 +351,11 @@ export function htmlToRtf(editorEl) {
101
351
  '\\pard\\f0\\fs24 ' +
102
352
  body +
103
353
  '}';
104
- return rtf;
354
+ // Backstop for the single-line guarantee. escapeRtf already collapses CR/LF
355
+ // out of every text path, so this should never have anything to do — but the
356
+ // document is worthless to a CR-terminated transport if one slips through,
357
+ // and no newline carries meaning in the output.
358
+ return rtf.replace(/[\r\n]+/g, ' ');
105
359
  }
106
360
  function addColor(colorStr, colorMap) {
107
361
  if (!colorStr)
@@ -126,7 +380,10 @@ function walkChildren(parent, ctx) {
126
380
  if (node.nodeType === Node.TEXT_NODE) {
127
381
  const text = node.textContent || '';
128
382
  if (ctx.inPre) {
129
- rtf += escapeRtf(text).replace(/\n/g, '\\line\n');
383
+ // Split before escaping — escapeRtf collapses newlines, so the
384
+ // line structure a <pre> block depends on has to become \line
385
+ // first. Handles CR, LF and CRLF alike.
386
+ rtf += text.split(/\r\n|\r|\n/).map(escapeRtf).join('\\line ');
130
387
  }
131
388
  else {
132
389
  rtf += escapeRtf(text);
@@ -139,13 +396,13 @@ function walkChildren(parent, ctx) {
139
396
  const tag = el.tagName.toLowerCase();
140
397
  switch (tag) {
141
398
  case 'h1':
142
- rtf += `\\pard\\f1\\fs48\\b ${walkChildren(el, ctx)}\\b0\\f0\\fs24\\par\n`;
399
+ rtf += `\\pard\\f1\\fs48\\b ${walkChildren(el, ctx)}\\b0\\f0\\fs24\\par `;
143
400
  break;
144
401
  case 'h2':
145
- rtf += `\\pard\\f1\\fs36\\b ${walkChildren(el, ctx)}\\b0\\f0\\fs24\\par\n`;
402
+ rtf += `\\pard\\f1\\fs36\\b ${walkChildren(el, ctx)}\\b0\\f0\\fs24\\par `;
146
403
  break;
147
404
  case 'h3':
148
- rtf += `\\pard\\f0\\fs28\\b ${walkChildren(el, ctx)}\\b0\\fs24\\par\n`;
405
+ rtf += `\\pard\\f0\\fs28\\b ${walkChildren(el, ctx)}\\b0\\fs24\\par `;
149
406
  break;
150
407
  case 'p':
151
408
  case 'div': {
@@ -159,13 +416,13 @@ function walkChildren(parent, ctx) {
159
416
  const isBlankLine = el.childNodes.length === 0 ||
160
417
  (el.childNodes.length === 1 && el.childNodes[0].nodeName === 'BR');
161
418
  if (isBlankLine) {
162
- rtf += '\\par\n';
419
+ rtf += '\\par ';
163
420
  break;
164
421
  }
165
422
  const inlineRtf = walkChildren(el, ctx);
166
423
  const colorPrefix = getColorPrefix(el, ctx);
167
424
  const colorSuffix = colorPrefix ? '\\cf0 ' : '';
168
- rtf += `\\pard ${colorPrefix}${inlineRtf}${colorSuffix}\\par\n`;
425
+ rtf += `\\pard ${colorPrefix}${inlineRtf}${colorSuffix}\\par `;
169
426
  break;
170
427
  }
171
428
  case 'table': {
@@ -187,37 +444,37 @@ function walkChildren(parent, ctx) {
187
444
  }
188
445
  cellRights[cellRights.length - 1] = PAGE_WIDTH; // snap last edge to avoid rounding drift
189
446
  // Row definition
190
- let rowRtf = '{\n\\trowd \\trgaph120\n';
447
+ let rowRtf = '{\\trowd\\trgaph120';
191
448
  for (let ci = 0; ci < cells.length; ci++) {
192
449
  if (isHeader)
193
450
  rowRtf += `\\clbrdrb\\brdrs`;
194
451
  rowRtf += `\\cellx${cellRights[ci]}`;
195
452
  }
196
- rowRtf += '\n\\trkeep\\intbl\n{';
453
+ rowRtf += '\\trkeep\\intbl{';
197
454
  // Cell bodies
198
455
  const cellCtx = { ...ctx, inTableCell: true };
199
456
  for (const cell of cells) {
200
457
  const content = walkChildren(cell, cellCtx);
201
- rowRtf += `{\\pard\\intbl \\f0 \\sa0 \\li0 \\fi0 ${content}\\par}\n\\cell`;
458
+ rowRtf += `{\\pard\\intbl \\f0 \\sa0 \\li0 \\fi0 ${content}\\par}\\cell`;
202
459
  }
203
- rowRtf += '}\n\\intbl\\row}\n';
460
+ rowRtf += '}\\intbl\\row}';
204
461
  rtf += rowRtf;
205
462
  }
206
463
  break;
207
464
  }
208
465
  case 'blockquote':
209
- rtf += `\\pard\\li720\\i ${walkChildren(el, ctx)}\\i0\\par\n`;
466
+ rtf += `\\pard\\li720\\i ${walkChildren(el, ctx)}\\i0\\par `;
210
467
  break;
211
468
  case 'pre':
212
- rtf += `\\pard\\f2\\fs20 ${walkChildren(el, { ...ctx, inPre: true })}\\f0\\fs24\\par\n`;
469
+ rtf += `\\pard\\f2\\fs20 ${walkChildren(el, { ...ctx, inPre: true })}\\f0\\fs24\\par `;
213
470
  break;
214
471
  case 'hr':
215
- rtf += `\\pard\\qc \\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\par\n`;
472
+ rtf += `\\pard\\qc \\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\par `;
216
473
  break;
217
474
  case 'ul': {
218
475
  const items = el.querySelectorAll(':scope > li');
219
476
  items.forEach((li) => {
220
- rtf += `\\pard\\li720\\fi-360\\bullet\\tab ${walkChildren(li, ctx)}\\par\n`;
477
+ rtf += `\\pard\\li720\\fi-360\\bullet\\tab ${walkChildren(li, ctx)}\\par `;
221
478
  });
222
479
  break;
223
480
  }
@@ -225,7 +482,7 @@ function walkChildren(parent, ctx) {
225
482
  let counter = 1;
226
483
  const items = el.querySelectorAll(':scope > li');
227
484
  items.forEach((li) => {
228
- rtf += `\\pard\\li720\\fi-360 ${counter}.\\tab ${walkChildren(li, ctx)}\\par\n`;
485
+ rtf += `\\pard\\li720\\fi-360 ${counter}.\\tab ${walkChildren(li, ctx)}\\par `;
229
486
  counter++;
230
487
  });
231
488
  break;
@@ -274,15 +531,33 @@ function walkChildren(parent, ctx) {
274
531
  }
275
532
  break;
276
533
  }
534
+ // <figure><img><figcaption>caption</figcaption></figure> — the shape the
535
+ // editor inserts. The caption is written as its own paragraph below the
536
+ // picture, tagged with an ignorable {\*\inkcap} destination so it can be
537
+ // re-attached to the figure on import. Other RTF readers skip the tag and
538
+ // simply show an italic line under the image.
539
+ case 'figure': {
540
+ const img = el.querySelector('img');
541
+ const caption = (el.querySelector('figcaption')?.textContent || '').trim();
542
+ const align = alignControl(el);
543
+ if (img)
544
+ rtf += `\\pard${align} ${imageBody(img)}\\par `;
545
+ else
546
+ rtf += walkChildren(el, ctx);
547
+ if (caption) {
548
+ rtf += `{\\*\\inkcap}\\pard${align}\\i ${escapeRtf(caption)}\\i0\\par `;
549
+ }
550
+ break;
551
+ }
277
552
  case 'img': {
278
- const alt = el.getAttribute('alt') || '';
279
- const src = el.getAttribute('src') || '';
280
- const label = alt || src || '[image]';
281
- rtf += `\\pard\\i [Image: ${escapeRtf(label)}]\\i0\\par\n`;
553
+ // A picture directly under the editor root has no paragraph of its
554
+ // own; anywhere else it is inline content of the enclosing block.
555
+ const standalone = el.parentNode === ctx.root;
556
+ rtf += standalone ? `\\pard ${imageBody(el)}\\par ` : imageBody(el);
282
557
  break;
283
558
  }
284
559
  case 'br':
285
- rtf += '\\line\n';
560
+ rtf += '\\line ';
286
561
  break;
287
562
  case 'span': {
288
563
  const colorPfx = getColorPrefix(el, ctx);
package/dist/utils.js CHANGED
@@ -43,6 +43,19 @@ export function htmlToMarkdown(el) {
43
43
  case 'img':
44
44
  md += `![${node.alt || ''}](${node.src || ''})`;
45
45
  break;
46
+ case 'figure': {
47
+ const img = node.querySelector('img');
48
+ const caption = (node.querySelector('figcaption')?.textContent || '').trim();
49
+ if (img) {
50
+ md += `![${img.getAttribute('alt') || caption}](${img.getAttribute('src') || ''})\n\n`;
51
+ if (caption)
52
+ md += `*${caption}*\n\n`;
53
+ }
54
+ break;
55
+ }
56
+ case 'figcaption':
57
+ // Rendered by the enclosing <figure>.
58
+ break;
46
59
  case 'blockquote':
47
60
  md += `> ${inner.trim()}\n\n`;
48
61
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-rtf-editor",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "RTF viewer and rich-text editor components for Svelte 5",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/jcelano/svelte-rtf-editor#readme",