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.
@@ -2,14 +2,30 @@
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
  */
14
+ /** Font used when no fontFamily is given. */
15
+ export const DEFAULT_FONT_FAMILY = 'Arial';
16
+ /** Body text size, in points, used when no fontSize is given. */
17
+ export const DEFAULT_FONT_SIZE = 12;
18
+ /** Largest font size RTF readers accept (Word's limit), in points. */
19
+ const MAX_FONT_SIZE = 1638;
20
+ /**
21
+ * Heading sizes as a multiple of the body size: at 12 pt, h1 is 24 pt, h2 18 pt
22
+ * and h3 14 pt. The parser reads headings back with the same ratios.
23
+ */
24
+ export const HEADING_SCALE = { h1: 2, h2: 1.5, h3: 7 / 6 };
25
+ /** Code blocks are set slightly smaller than the body: 10 pt at 12 pt. */
26
+ const PRE_SCALE = 5 / 6;
27
+ /** 1 px at 96 dpi = 15 twips (1 twip = 1/1440 in). */
28
+ const TWIPS_PER_PX = 15;
13
29
  // ── Color helpers ──
14
30
  function parseColor(colorStr) {
15
31
  if (!colorStr)
@@ -52,10 +68,17 @@ function colorKey(c) {
52
68
  }
53
69
  // ── RTF text escaping ──
54
70
  function escapeRtf(text) {
71
+ // RTF readers ignore raw CR/LF, so a newline inside text content rendered as
72
+ // nothing ("Hello\nworld" → "Helloworld") while still emitting bytes that a
73
+ // CR-terminated transport treats as a record separator. Collapsing a run to
74
+ // the single space a browser would have shown is both safer and more
75
+ // faithful. Text is never split on newlines after this point, so <pre>
76
+ // content must be divided into lines before it gets here.
77
+ const source = text.replace(/[\r\n]+/g, ' ');
55
78
  let out = '';
56
- for (let i = 0; i < text.length; i++) {
57
- const ch = text[i];
58
- const code = text.charCodeAt(i);
79
+ for (let i = 0; i < source.length; i++) {
80
+ const ch = source[i];
81
+ const code = source.charCodeAt(i);
59
82
  if (ch === '\\')
60
83
  out += '\\\\';
61
84
  else if (ch === '{')
@@ -71,8 +94,296 @@ function escapeRtf(text) {
71
94
  }
72
95
  return out;
73
96
  }
97
+ // ── Image helpers ──
98
+ const B64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
99
+ const B64_LOOKUP = (() => {
100
+ const table = new Int16Array(128).fill(-1);
101
+ for (let i = 0; i < B64_ALPHABET.length; i++)
102
+ table[B64_ALPHABET.charCodeAt(i)] = i;
103
+ return table;
104
+ })();
105
+ // Uppercase: the RTF spec treats picture data as case-insensitive, but
106
+ // downstream consumers (HL7 interface engines, for one) sometimes match the
107
+ // leading PNG signature literally as 89504E470D0A1A0A.
108
+ const HEX_BYTE = Array.from({ length: 256 }, (_, i) => i.toString(16).padStart(2, '0').toUpperCase());
109
+ /** Decode base64 to bytes without depending on atob/Buffer (works in any runtime). */
110
+ function base64ToBytes(b64) {
111
+ let buffer = 0;
112
+ let bits = 0;
113
+ let out = 0;
114
+ const bytes = new Uint8Array(Math.ceil((b64.length * 3) / 4));
115
+ for (let i = 0; i < b64.length; i++) {
116
+ const code = b64.charCodeAt(i);
117
+ if (code === 61 /* = */)
118
+ break;
119
+ const v = code < 128 ? B64_LOOKUP[code] : -1;
120
+ if (v < 0) {
121
+ // Whitespace inside a data URL is legal; anything else is not base64.
122
+ if (code === 32 || code === 9 || code === 10 || code === 13)
123
+ continue;
124
+ return null;
125
+ }
126
+ buffer = (buffer << 6) | v;
127
+ bits += 6;
128
+ if (bits >= 8) {
129
+ bits -= 8;
130
+ bytes[out++] = (buffer >> bits) & 0xff;
131
+ }
132
+ }
133
+ return out > 0 ? bytes.subarray(0, out) : null;
134
+ }
135
+ function bytesToHex(bytes) {
136
+ const parts = new Array(bytes.length);
137
+ for (let i = 0; i < bytes.length; i++)
138
+ parts[i] = HEX_BYTE[bytes[i]];
139
+ return parts.join('');
140
+ }
141
+ /**
142
+ * Picture format according to the bytes themselves, not the label they arrived
143
+ * with. A file named .png can hold WebP — operating systems assign the MIME
144
+ * type from the extension — and writing those bytes as \pngblip produces a
145
+ * picture no reader can decode.
146
+ */
147
+ function sniffBytes(b) {
148
+ if (b.length >= 8 &&
149
+ b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47 &&
150
+ b[4] === 0x0d && b[5] === 0x0a && b[6] === 0x1a && b[7] === 0x0a) {
151
+ return 'png';
152
+ }
153
+ if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff)
154
+ return 'jpeg';
155
+ return null;
156
+ }
157
+ /** Read the intrinsic size out of the PNG IHDR chunk. */
158
+ function pngSize(b) {
159
+ if (b.length < 24)
160
+ return null;
161
+ if (b[0] !== 0x89 || b[1] !== 0x50 || b[2] !== 0x4e || b[3] !== 0x47)
162
+ return null;
163
+ const w = ((b[16] << 24) | (b[17] << 16) | (b[18] << 8) | b[19]) >>> 0;
164
+ const h = ((b[20] << 24) | (b[21] << 16) | (b[22] << 8) | b[23]) >>> 0;
165
+ return w && h ? { w, h } : null;
166
+ }
167
+ /** Read the intrinsic size out of the first JPEG start-of-frame marker. */
168
+ function jpegSize(b) {
169
+ if (b.length < 4 || b[0] !== 0xff || b[1] !== 0xd8)
170
+ return null;
171
+ let i = 2;
172
+ while (i + 9 < b.length) {
173
+ if (b[i] !== 0xff) {
174
+ i++;
175
+ continue;
176
+ }
177
+ const marker = b[i + 1];
178
+ // Standalone markers carry no length field.
179
+ if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
180
+ i += 2;
181
+ continue;
182
+ }
183
+ const len = (b[i + 2] << 8) | b[i + 3];
184
+ // SOF0–SOF15, excluding DHT (c4), JPG (c8) and DAC (cc)
185
+ if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
186
+ const h = (b[i + 5] << 8) | b[i + 6];
187
+ const w = (b[i + 7] << 8) | b[i + 8];
188
+ return w && h ? { w, h } : null;
189
+ }
190
+ if (len < 2)
191
+ break;
192
+ i += 2 + len;
193
+ }
194
+ return null;
195
+ }
196
+ function parsePx(value) {
197
+ if (!value)
198
+ return 0;
199
+ const m = value.match(/^([\d.]+)px$/);
200
+ return m ? Math.round(parseFloat(m[1])) : 0;
201
+ }
202
+ /**
203
+ * Displayed size of an image in CSS pixels. Prefers the explicit width the
204
+ * editor writes when the user resizes, then the width/height attributes, then
205
+ * the intrinsic size decoded from the image bytes (naturalWidth is unavailable
206
+ * when the DOM is detached or server-side).
207
+ */
208
+ function displaySize(el, natural) {
209
+ const img = el;
210
+ const nw = natural.w || img.naturalWidth || 0;
211
+ const nh = natural.h || img.naturalHeight || 0;
212
+ const attrW = parseInt(el.getAttribute('width') || '', 10);
213
+ const attrH = parseInt(el.getAttribute('height') || '', 10);
214
+ const w = parsePx(el.style?.width) || (Number.isFinite(attrW) ? attrW : 0) || nw;
215
+ let h = parsePx(el.style?.height) || (Number.isFinite(attrH) ? attrH : 0);
216
+ if (!h)
217
+ h = nw && nh ? Math.round((w * nh) / nw) : nh;
218
+ return { w, h };
219
+ }
220
+ /**
221
+ * Convert a data-URL <img> into an RTF \pict group. Returns '' for images the
222
+ * RTF format cannot carry (remote URLs, or formats other than PNG/JPEG), so
223
+ * the caller can fall back to a text placeholder.
224
+ */
225
+ function pictureRtf(el) {
226
+ const src = el.getAttribute('src') || '';
227
+ // Accept any image data URL and let the bytes decide the format — the label
228
+ // is not trustworthy, and a correctly-labelled PNG is not the only way to
229
+ // arrive at PNG bytes.
230
+ const m = src.match(/^data:image\/[a-z0-9.+-]+;base64,([\s\S]+)$/i);
231
+ if (!m)
232
+ return '';
233
+ const bytes = base64ToBytes(m[1]);
234
+ if (!bytes || bytes.length === 0)
235
+ return '';
236
+ const format = sniffBytes(bytes);
237
+ // Neither PNG nor JPEG: RTF cannot carry it, so fall back to the placeholder
238
+ // rather than emitting a blip whose declared type contradicts its content.
239
+ if (!format)
240
+ return '';
241
+ const isPng = format === 'png';
242
+ const natural = (isPng ? pngSize(bytes) : jpegSize(bytes)) ?? { w: 0, h: 0 };
243
+ const size = displaySize(el, natural);
244
+ if (!size.w || !size.h)
245
+ return '';
246
+ const picw = natural.w || size.w;
247
+ const pich = natural.h || size.h;
248
+ // The space after \pichgoal is the control word's delimiter — without it the
249
+ // leading hex digits would be read as part of its numeric parameter.
250
+ return (`{\\pict${isPng ? '\\pngblip' : '\\jpegblip'}` +
251
+ `\\picw${picw}\\pich${pich}` +
252
+ `\\picwgoal${Math.round(size.w * TWIPS_PER_PX)}\\pichgoal${Math.round(size.h * TWIPS_PER_PX)} ` +
253
+ `${bytesToHex(bytes)}}`);
254
+ }
255
+ /** Picture group if the bytes are embeddable, otherwise an italic placeholder. */
256
+ function imageBody(el) {
257
+ const pict = pictureRtf(el);
258
+ if (pict)
259
+ return pict;
260
+ const label = el.getAttribute('alt') || el.getAttribute('src') || 'image';
261
+ return `\\i [Image: ${escapeRtf(label)}]\\i0 `;
262
+ }
263
+ /** \ql / \qc / \qr for a figure's text-align. */
264
+ function alignControl(el) {
265
+ const align = (el.style?.textAlign || '').toLowerCase();
266
+ if (align === 'center')
267
+ return '\\qc';
268
+ if (align === 'right')
269
+ return '\\qr';
270
+ if (align === 'justify')
271
+ return '\\qj';
272
+ return '\\ql';
273
+ }
274
+ /** Decoded byte length of a base64 payload, without decoding it. */
275
+ function base64ByteLength(base64) {
276
+ const padding = base64.endsWith('==') ? 2 : base64.endsWith('=') ? 1 : 0;
277
+ return Math.max(0, Math.floor((base64.length * 3) / 4) - padding);
278
+ }
279
+ /**
280
+ * Bytes of RTF header, font table and colour table that precede the body, with
281
+ * the default font. Another font name changes it by the difference in length.
282
+ */
283
+ const RTF_PREAMBLE_BYTES = 165;
284
+ /** Control words wrapping one \pict group. */
285
+ const PICTURE_OVERHEAD_BYTES = 80;
286
+ /**
287
+ * Control-word bytes the writer wraps around each kind of element — the opening
288
+ * and closing runs measured from walkChildren. Anything not listed costs
289
+ * nothing of its own beyond the text it contains.
290
+ */
291
+ const BLOCK_OVERHEAD_BYTES = {
292
+ p: 14,
293
+ div: 14,
294
+ h1: 29,
295
+ h2: 29,
296
+ h3: 29,
297
+ li: 35,
298
+ blockquote: 22,
299
+ pre: 27,
300
+ figure: 15,
301
+ figcaption: 29,
302
+ tr: 45,
303
+ td: 55,
304
+ th: 69 // a header cell also carries \clbrdrb\brdrs
305
+ };
306
+ /**
307
+ * Size htmlToRtf would produce, without doing the conversion.
308
+ *
309
+ * Useful for a live indicator — the exact figure requires hex-encoding every
310
+ * picture, which is too much work to repeat on each keystroke.
311
+ *
312
+ * Picture data, which dominates any document that has one, is counted exactly:
313
+ * two characters per byte. Everything else is approximated per element, which
314
+ * puts the error at a bounded number of bytes rather than a proportion — so it
315
+ * is negligible precisely when the number matters, and never more than a few
316
+ * hundred bytes even when it does not. The output is ASCII, so bytes and
317
+ * characters are the same number.
318
+ */
319
+ export function estimateRtfBytes(editorEl) {
320
+ let total = RTF_PREAMBLE_BYTES;
321
+ for (const img of Array.from(editorEl.querySelectorAll('img'))) {
322
+ const src = img.getAttribute('src') || '';
323
+ const comma = src.indexOf(',');
324
+ // A picture that cannot be embedded costs only its placeholder text.
325
+ if (!src.startsWith('data:image/') || comma < 0)
326
+ continue;
327
+ total += base64ByteLength(src.slice(comma + 1)) * 2 + PICTURE_OVERHEAD_BYTES;
328
+ }
329
+ total += (editorEl.textContent || '').length;
330
+ for (const el of Array.from(editorEl.querySelectorAll('*'))) {
331
+ total += BLOCK_OVERHEAD_BYTES[el.tagName.toLowerCase()] ?? 0;
332
+ }
333
+ return total;
334
+ }
335
+ // ── Font helpers ──
336
+ /**
337
+ * A single font name, safe for the font table. A CSS list such as
338
+ * 'Arial, sans-serif' keeps only its first family, since RTF names one font.
339
+ * Anything that would break the table is removed: a name is terminated by ';'
340
+ * and cannot contain group delimiters or control words.
341
+ */
342
+ function cleanFontName(name) {
343
+ const first = (name ?? '').replace(/["']/g, '').split(',')[0];
344
+ const cleaned = first.replace(/[\\{};]/g, '').trim();
345
+ return cleaned || DEFAULT_FONT_FAMILY;
346
+ }
347
+ /** RTF font family class for a font name: \froman, \fmodern or \fswiss. */
348
+ function fontFamilyClass(name) {
349
+ const n = name.toLowerCase();
350
+ if (/courier|mono|consolas|menlo|monaco|typewriter|lucida console/.test(n))
351
+ return 'modern';
352
+ if (/times|georgia|garamond|cambria|palatino|antiqua|schoolbook|baskerville|didot|bodoni|serif/.test(n) &&
353
+ !/sans/.test(n)) {
354
+ return 'roman';
355
+ }
356
+ return 'swiss';
357
+ }
358
+ /**
359
+ * CSS font-family value for a font name, with the generic fallback that
360
+ * matches it, e.g. "Arial", sans-serif.
361
+ */
362
+ export function cssFontFamily(name) {
363
+ const font = cleanFontName(name);
364
+ const generic = { roman: 'serif', modern: 'monospace', swiss: 'sans-serif' }[fontFamilyClass(font)];
365
+ return `"${font}", ${generic}`;
366
+ }
367
+ /**
368
+ * A body size in points, as the RTF will carry it: rounded to the half-point
369
+ * RTF measures in, kept within what readers accept, and the default for
370
+ * missing or nonsense input. The editor and viewer show this same size, so
371
+ * the screen and the RTF agree.
372
+ */
373
+ export function normalizeFontSize(fontSize) {
374
+ if (typeof fontSize !== 'number' || !Number.isFinite(fontSize) || fontSize <= 0) {
375
+ return DEFAULT_FONT_SIZE;
376
+ }
377
+ return Math.min(MAX_FONT_SIZE, Math.max(1, Math.round(fontSize * 2) / 2));
378
+ }
379
+ /** Body size in RTF half-points. */
380
+ function bodyHalfPoints(fontSize) {
381
+ return normalizeFontSize(fontSize) * 2;
382
+ }
74
383
  // ── DOM walker / RTF generator ──
75
- export function htmlToRtf(editorEl) {
384
+ export function htmlToRtf(editorEl, options = {}) {
385
+ const fontName = cleanFontName(options.fontFamily);
386
+ const bodySize = bodyHalfPoints(options.fontSize);
76
387
  const colorMap = new Map();
77
388
  colorMap.set('0,0,0', { r: 0, g: 0, b: 0 });
78
389
  collectColors(editorEl, colorMap);
@@ -88,20 +399,32 @@ export function htmlToRtf(editorEl) {
88
399
  colorTable += `\\red${c.r}\\green${c.g}\\blue${c.b};`;
89
400
  }
90
401
  colorTable += '}';
402
+ // \f0 is the document font, used for everything except code; \f1 is the
403
+ // monospace font for code.
91
404
  const fontTable = '{\\fonttbl' +
92
- '{\\f0\\fswiss\\fcharset0 Helvetica;}' +
93
- '{\\f1\\froman\\fcharset0 Georgia;}' +
94
- '{\\f2\\fmodern\\fcharset0 Courier New;}' +
405
+ `{\\f0\\f${fontFamilyClass(fontName)}\\fcharset0 ${escapeRtf(fontName)};}` +
406
+ '{\\f1\\fmodern\\fcharset0 Courier New;}' +
95
407
  '}';
96
- const body = walkChildren(editorEl, { colorIndex, inPre: false, listCounter: 0, inTableCell: false });
408
+ const body = walkChildren(editorEl, {
409
+ colorIndex,
410
+ bodySize,
411
+ inPre: false,
412
+ listCounter: 0,
413
+ inTableCell: false,
414
+ root: editorEl
415
+ });
97
416
  const rtf = '{\\rtf1\\ansi\\ansicpg1252\\deff0' +
98
417
  fontTable +
99
418
  colorTable +
100
419
  '\\viewkind4\\uc1' +
101
- '\\pard\\f0\\fs24 ' +
420
+ `\\pard\\f0\\fs${bodySize} ` +
102
421
  body +
103
422
  '}';
104
- return rtf;
423
+ // Backstop for the single-line guarantee. escapeRtf already collapses CR/LF
424
+ // out of every text path, so this should never have anything to do — but the
425
+ // document is worthless to a CR-terminated transport if one slips through,
426
+ // and no newline carries meaning in the output.
427
+ return rtf.replace(/[\r\n]+/g, ' ');
105
428
  }
106
429
  function addColor(colorStr, colorMap) {
107
430
  if (!colorStr)
@@ -126,7 +449,10 @@ function walkChildren(parent, ctx) {
126
449
  if (node.nodeType === Node.TEXT_NODE) {
127
450
  const text = node.textContent || '';
128
451
  if (ctx.inPre) {
129
- rtf += escapeRtf(text).replace(/\n/g, '\\line\n');
452
+ // Split before escaping — escapeRtf collapses newlines, so the
453
+ // line structure a <pre> block depends on has to become \line
454
+ // first. Handles CR, LF and CRLF alike.
455
+ rtf += text.split(/\r\n|\r|\n/).map(escapeRtf).join('\\line ');
130
456
  }
131
457
  else {
132
458
  rtf += escapeRtf(text);
@@ -138,14 +464,15 @@ function walkChildren(parent, ctx) {
138
464
  const el = node;
139
465
  const tag = el.tagName.toLowerCase();
140
466
  switch (tag) {
467
+ // Heading sizes keep their ratio to the body (HEADING_SCALE).
141
468
  case 'h1':
142
- rtf += `\\pard\\f1\\fs48\\b ${walkChildren(el, ctx)}\\b0\\f0\\fs24\\par\n`;
469
+ rtf += `\\pard\\f0\\fs${scaled(ctx, HEADING_SCALE.h1)}\\b ${walkChildren(el, ctx)}\\b0\\fs${ctx.bodySize}\\par `;
143
470
  break;
144
471
  case 'h2':
145
- rtf += `\\pard\\f1\\fs36\\b ${walkChildren(el, ctx)}\\b0\\f0\\fs24\\par\n`;
472
+ rtf += `\\pard\\f0\\fs${scaled(ctx, HEADING_SCALE.h2)}\\b ${walkChildren(el, ctx)}\\b0\\fs${ctx.bodySize}\\par `;
146
473
  break;
147
474
  case 'h3':
148
- rtf += `\\pard\\f0\\fs28\\b ${walkChildren(el, ctx)}\\b0\\fs24\\par\n`;
475
+ rtf += `\\pard\\f0\\fs${scaled(ctx, HEADING_SCALE.h3)}\\b ${walkChildren(el, ctx)}\\b0\\fs${ctx.bodySize}\\par `;
149
476
  break;
150
477
  case 'p':
151
478
  case 'div': {
@@ -159,13 +486,13 @@ function walkChildren(parent, ctx) {
159
486
  const isBlankLine = el.childNodes.length === 0 ||
160
487
  (el.childNodes.length === 1 && el.childNodes[0].nodeName === 'BR');
161
488
  if (isBlankLine) {
162
- rtf += '\\par\n';
489
+ rtf += '\\par ';
163
490
  break;
164
491
  }
165
492
  const inlineRtf = walkChildren(el, ctx);
166
493
  const colorPrefix = getColorPrefix(el, ctx);
167
494
  const colorSuffix = colorPrefix ? '\\cf0 ' : '';
168
- rtf += `\\pard ${colorPrefix}${inlineRtf}${colorSuffix}\\par\n`;
495
+ rtf += `\\pard ${colorPrefix}${inlineRtf}${colorSuffix}\\par `;
169
496
  break;
170
497
  }
171
498
  case 'table': {
@@ -187,37 +514,37 @@ function walkChildren(parent, ctx) {
187
514
  }
188
515
  cellRights[cellRights.length - 1] = PAGE_WIDTH; // snap last edge to avoid rounding drift
189
516
  // Row definition
190
- let rowRtf = '{\n\\trowd \\trgaph120\n';
517
+ let rowRtf = '{\\trowd\\trgaph120';
191
518
  for (let ci = 0; ci < cells.length; ci++) {
192
519
  if (isHeader)
193
520
  rowRtf += `\\clbrdrb\\brdrs`;
194
521
  rowRtf += `\\cellx${cellRights[ci]}`;
195
522
  }
196
- rowRtf += '\n\\trkeep\\intbl\n{';
523
+ rowRtf += '\\trkeep\\intbl{';
197
524
  // Cell bodies
198
525
  const cellCtx = { ...ctx, inTableCell: true };
199
526
  for (const cell of cells) {
200
527
  const content = walkChildren(cell, cellCtx);
201
- rowRtf += `{\\pard\\intbl \\f0 \\sa0 \\li0 \\fi0 ${content}\\par}\n\\cell`;
528
+ rowRtf += `{\\pard\\intbl \\f0 \\sa0 \\li0 \\fi0 ${content}\\par}\\cell`;
202
529
  }
203
- rowRtf += '}\n\\intbl\\row}\n';
530
+ rowRtf += '}\\intbl\\row}';
204
531
  rtf += rowRtf;
205
532
  }
206
533
  break;
207
534
  }
208
535
  case 'blockquote':
209
- rtf += `\\pard\\li720\\i ${walkChildren(el, ctx)}\\i0\\par\n`;
536
+ rtf += `\\pard\\li720\\i ${walkChildren(el, ctx)}\\i0\\par `;
210
537
  break;
211
538
  case 'pre':
212
- rtf += `\\pard\\f2\\fs20 ${walkChildren(el, { ...ctx, inPre: true })}\\f0\\fs24\\par\n`;
539
+ rtf += `\\pard\\f1\\fs${scaled(ctx, PRE_SCALE)} ${walkChildren(el, { ...ctx, inPre: true })}\\f0\\fs${ctx.bodySize}\\par `;
213
540
  break;
214
541
  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`;
542
+ 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
543
  break;
217
544
  case 'ul': {
218
545
  const items = el.querySelectorAll(':scope > li');
219
546
  items.forEach((li) => {
220
- rtf += `\\pard\\li720\\fi-360\\bullet\\tab ${walkChildren(li, ctx)}\\par\n`;
547
+ rtf += `\\pard\\li720\\fi-360\\bullet\\tab ${walkChildren(li, ctx)}\\par `;
221
548
  });
222
549
  break;
223
550
  }
@@ -225,7 +552,7 @@ function walkChildren(parent, ctx) {
225
552
  let counter = 1;
226
553
  const items = el.querySelectorAll(':scope > li');
227
554
  items.forEach((li) => {
228
- rtf += `\\pard\\li720\\fi-360 ${counter}.\\tab ${walkChildren(li, ctx)}\\par\n`;
555
+ rtf += `\\pard\\li720\\fi-360 ${counter}.\\tab ${walkChildren(li, ctx)}\\par `;
229
556
  counter++;
230
557
  });
231
558
  break;
@@ -257,7 +584,7 @@ function walkChildren(parent, ctx) {
257
584
  break;
258
585
  case 'code':
259
586
  if (!ctx.inPre) {
260
- rtf += `\\f2 ${walkChildren(el, ctx)}\\f0 `;
587
+ rtf += `\\f1 ${walkChildren(el, ctx)}\\f0 `;
261
588
  }
262
589
  else {
263
590
  rtf += walkChildren(el, ctx);
@@ -274,20 +601,38 @@ function walkChildren(parent, ctx) {
274
601
  }
275
602
  break;
276
603
  }
604
+ // <figure><img><figcaption>caption</figcaption></figure> — the shape the
605
+ // editor inserts. The caption is written as its own paragraph below the
606
+ // picture, tagged with an ignorable {\*\inkcap} destination so it can be
607
+ // re-attached to the figure on import. Other RTF readers skip the tag and
608
+ // simply show an italic line under the image.
609
+ case 'figure': {
610
+ const img = el.querySelector('img');
611
+ const caption = (el.querySelector('figcaption')?.textContent || '').trim();
612
+ const align = alignControl(el);
613
+ if (img)
614
+ rtf += `\\pard${align} ${imageBody(img)}\\par `;
615
+ else
616
+ rtf += walkChildren(el, ctx);
617
+ if (caption) {
618
+ rtf += `{\\*\\inkcap}\\pard${align}\\i ${escapeRtf(caption)}\\i0\\par `;
619
+ }
620
+ break;
621
+ }
277
622
  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`;
623
+ // A picture directly under the editor root has no paragraph of its
624
+ // own; anywhere else it is inline content of the enclosing block.
625
+ const standalone = el.parentNode === ctx.root;
626
+ rtf += standalone ? `\\pard ${imageBody(el)}\\par ` : imageBody(el);
282
627
  break;
283
628
  }
284
629
  case 'br':
285
- rtf += '\\line\n';
630
+ rtf += '\\line ';
286
631
  break;
287
632
  case 'span': {
288
633
  const colorPfx = getColorPrefix(el, ctx);
289
634
  const sizePfx = getSizePrefix(el);
290
- const sizesfx = sizePfx ? '\\fs24 ' : '';
635
+ const sizesfx = sizePfx ? `\\fs${ctx.bodySize} ` : '';
291
636
  const colorSfx = colorPfx ? '\\cf0 ' : '';
292
637
  rtf += `${colorPfx}${sizePfx}${walkChildren(el, ctx)}${sizesfx}${colorSfx}`;
293
638
  break;
@@ -306,6 +651,10 @@ function walkChildren(parent, ctx) {
306
651
  }
307
652
  return rtf;
308
653
  }
654
+ /** Body size times a ratio, in half-points. */
655
+ function scaled(ctx, ratio) {
656
+ return Math.round(ctx.bodySize * ratio);
657
+ }
309
658
  function getColorPrefix(el, ctx) {
310
659
  // Support both inline style.color (spans) and the color attribute (<font>)
311
660
  const color = el.style?.color ||
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.5.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",