svelte-rtf-editor 1.5.2 → 1.5.3

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.
package/README.md CHANGED
@@ -163,7 +163,7 @@ editor.importRtf() // → void — open the file picker to import .rtf
163
163
  ```ts
164
164
  import {
165
165
  rtfToHtml, htmlToRtf, estimateRtfBytes, readRtfFile, htmlToMarkdown, downloadFile,
166
- DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE
166
+ DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE, isSafeLinkUrl
167
167
  } from 'svelte-rtf-editor';
168
168
  ```
169
169
 
@@ -172,6 +172,7 @@ import {
172
172
  | `rtfToHtml(rtf: string, options?: { fontSize? }): string` | Parse an RTF string and return an HTML string. `fontSize` is the size of the screen it will be shown on (default 12) |
173
173
  | `htmlToRtf(el: HTMLElement, options?: { fontFamily?, fontSize? }): string` | Convert a DOM element's content to RTF, in Arial 12 pt unless told otherwise |
174
174
  | `estimateRtfBytes(el: HTMLElement): number` | Approximate RTF size of a DOM element without converting it |
175
+ | `isSafeLinkUrl(url: string): boolean` | Whether a link address is a web, mail or phone address; `htmlToRtf` writes any other link as its text alone |
175
176
  | `readRtfFile(file: File): Promise<string>` | Read a `.rtf` File object and return HTML |
176
177
  | `htmlToMarkdown(el: HTMLElement): string` | Convert a DOM element's content to Markdown |
177
178
  | `downloadFile(name, content, mime): void` | Trigger a file download in the browser |
@@ -56,6 +56,7 @@
56
56
  import {
57
57
  htmlToRtf,
58
58
  estimateRtfBytes,
59
+ isSafeLinkUrl,
59
60
  cssFontFamily,
60
61
  normalizeFontSize,
61
62
  DEFAULT_FONT_FAMILY,
@@ -148,6 +149,7 @@
148
149
  let linkModalOpen: boolean = $state(false);
149
150
  let imageModalOpen: boolean = $state(false);
150
151
  let linkUrl: string = $state('');
152
+ let linkError: string = $state('');
151
153
  let linkText: string = $state('');
152
154
  let imageUrl: string = $state('');
153
155
  let imageAlt: string = $state('');
@@ -363,11 +365,20 @@
363
365
  saveSelection();
364
366
  linkUrl = '';
365
367
  linkText = '';
368
+ linkError = '';
366
369
  linkModalOpen = true;
367
370
  }
368
371
 
369
372
  function applyLink(): void {
370
373
  if (!linkUrl) { linkModalOpen = false; return; }
374
+ // Only a web, mail or phone address becomes a link; a javascript: address would
375
+ // run when clicked in the editor and in any viewer of the stored HTML. The dialog
376
+ // stays open and says why, rather than closing as if the link had been added.
377
+ if (!isSafeLinkUrl(linkUrl)) {
378
+ linkError = 'Enter a web address (https://…), an email address (mailto:…) or a phone number (tel:…).';
379
+ return;
380
+ }
381
+ linkError = '';
371
382
  restoreSelection();
372
383
  editorEl?.focus();
373
384
 
@@ -875,7 +886,12 @@
875
886
  placeholder="https://example.com"
876
887
  bind:value={linkUrl}
877
888
  onkeydown={(e) => e.key === 'Enter' && applyLink()}
889
+ aria-invalid={linkError ? 'true' : undefined}
890
+ aria-describedby={linkError ? 'ink-link-error' : undefined}
878
891
  />
892
+ {#if linkError}
893
+ <p class="ink-modal-error" id="ink-link-error" role="alert">{linkError}</p>
894
+ {/if}
879
895
  {#snippet actions()}
880
896
  <button class="ink-btn-ghost" onclick={() => (linkModalOpen = false)}>Cancel</button>
881
897
  <button class="ink-btn-primary" onclick={applyLink}>Insert</button>
@@ -1178,6 +1194,12 @@
1178
1194
  color: var(--text-muted, #8a7e72);
1179
1195
  }
1180
1196
 
1197
+ .ink-modal-error {
1198
+ margin: 0 0 12px;
1199
+ font-size: 12px;
1200
+ color: var(--danger, #b42318);
1201
+ }
1202
+
1181
1203
  .ink-modal-or {
1182
1204
  display: flex;
1183
1205
  align-items: center;
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export { default as RtfViewer } from './RtfViewer.svelte';
2
2
  export { default as InkEditor } from './components/InkEditor.svelte';
3
3
  export { rtfToHtml, readRtfFile } from './rtf-parser.js';
4
- export { htmlToRtf, estimateRtfBytes, DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE } from './rtf-writer.js';
4
+ export { htmlToRtf, estimateRtfBytes, isSafeLinkUrl, DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE } from './rtf-writer.js';
5
5
  export type { RtfWriteOptions } from './rtf-writer.js';
6
6
  export type { RtfReadOptions } from './rtf-parser.js';
7
7
  export { htmlToMarkdown, downloadFile } from './utils.js';
package/dist/index.js CHANGED
@@ -3,5 +3,5 @@ export { default as RtfViewer } from './RtfViewer.svelte';
3
3
  export { default as InkEditor } from './components/InkEditor.svelte';
4
4
  // RTF utilities
5
5
  export { rtfToHtml, readRtfFile } from './rtf-parser.js';
6
- export { htmlToRtf, estimateRtfBytes, DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE } from './rtf-writer.js';
6
+ export { htmlToRtf, estimateRtfBytes, isSafeLinkUrl, DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE } from './rtf-writer.js';
7
7
  export { htmlToMarkdown, downloadFile } from './utils.js';
@@ -50,6 +50,19 @@ export declare const HEADING_SCALE: {
50
50
  * characters are the same number.
51
51
  */
52
52
  export declare function estimateRtfBytes(editorEl: HTMLElement): number;
53
+ /**
54
+ * The address a link should carry in the RTF, or null if it should not be a link.
55
+ *
56
+ * Only an absolute web, mail or phone address qualifies. A relative path, a UNC path
57
+ * (\\server\share) or a javascript:, data:, file: or vbscript: address is refused: the
58
+ * receiving system opens a link in its own context, where a relative or UNC path names
59
+ * a file on the reader's network. The address returned is the parsed one, which
60
+ * percent-encodes characters such as a quote that would otherwise end the field's
61
+ * address and add switches of its own.
62
+ */
63
+ export declare function linkTarget(url: string): string | null;
64
+ /** Whether a link address is an absolute web, mail or phone address (see linkTarget). */
65
+ export declare function isSafeLinkUrl(url: string): boolean;
53
66
  /**
54
67
  * CSS font-family value for a font name, with the generic fallback that
55
68
  * matches it, e.g. "Arial", sans-serif.
@@ -86,7 +86,10 @@ function escapeRtf(text) {
86
86
  else if (ch === '}')
87
87
  out += '\\}';
88
88
  else if (code > 127) {
89
- out += `\\u${code}\\'3f`;
89
+ // RTF's \uN is a SIGNED 16-bit number: from U+8000 up, and for each half of
90
+ // an emoji's surrogate pair, it is written as N - 65536. Written unsigned,
91
+ // Word and Epic read it as a different character.
92
+ out += `\\u${code > 32767 ? code - 65536 : code}\\'3f`;
90
93
  }
91
94
  else {
92
95
  out += ch;
@@ -301,8 +304,28 @@ const BLOCK_OVERHEAD_BYTES = {
301
304
  figcaption: 29,
302
305
  tr: 45,
303
306
  td: 55,
304
- th: 69 // a header cell also carries \clbrdrb\brdrs
307
+ th: 69, // a header cell also carries \clbrdrb\brdrs
308
+ // Inline formatting: the control words on either side of the text.
309
+ b: 7, // "\b " + "\b0 "
310
+ strong: 7,
311
+ i: 7,
312
+ em: 7,
313
+ u: 12, // "\ul " + "\ulnone "
314
+ s: 17, // "\strike " + "\strike0 "
315
+ strike: 17,
316
+ del: 17,
317
+ sup: 19, // "\super " + "\nosupersub "
318
+ sub: 17,
319
+ code: 8, // "\f1 " + "\f0 "
320
+ br: 6, // "\line "
321
+ hr: 154, // "\pard\qc " + 20 x "\emdash" + "\par "
322
+ ul: 0,
323
+ ol: 0
305
324
  };
325
+ /** Elements that write a paragraph of their own, which inside a table cell carries \intbl. */
326
+ const CELL_PARAGRAPH_TAGS = new Set(['p', 'div', 'h1', 'h2', 'h3', 'li', 'blockquote', 'pre', 'hr', 'figure', 'figcaption']);
327
+ /** The field wrapper around a link, apart from its address. */
328
+ const LINK_OVERHEAD_BYTES = '{\\field{\\*\\fldinst HYPERLINK ""}{\\fldrslt\\ul\\cf1 \\cf0\\ulnone }}'.length;
306
329
  /**
307
330
  * Size htmlToRtf would produce, without doing the conversion.
308
331
  *
@@ -321,17 +344,75 @@ export function estimateRtfBytes(editorEl) {
321
344
  for (const img of Array.from(editorEl.querySelectorAll('img'))) {
322
345
  const src = img.getAttribute('src') || '';
323
346
  const comma = src.indexOf(',');
324
- // A picture that cannot be embedded costs only its placeholder text.
325
- if (!src.startsWith('data:image/') || comma < 0)
347
+ if (!src.startsWith('data:image/') || comma < 0) {
348
+ // A picture that cannot be embedded costs its placeholder text.
349
+ total += `\\i [Image: ]\\i0 `.length + escapeRtf(img.getAttribute('alt') || src || 'image').length;
326
350
  continue;
351
+ }
327
352
  total += base64ByteLength(src.slice(comma + 1)) * 2 + PICTURE_OVERHEAD_BYTES;
328
353
  }
329
- total += (editorEl.textContent || '').length;
354
+ // Text at its escaped length: a character outside ASCII costs its \uN\'3f escape,
355
+ // six to nine bytes rather than one.
356
+ total += escapeRtf(editorEl.textContent || '').length;
330
357
  for (const el of Array.from(editorEl.querySelectorAll('*'))) {
331
- total += BLOCK_OVERHEAD_BYTES[el.tagName.toLowerCase()] ?? 0;
358
+ const tag = el.tagName.toLowerCase();
359
+ total += BLOCK_OVERHEAD_BYTES[tag] ?? 0;
360
+ if (tag === 'a') {
361
+ const target = linkTarget(el.getAttribute('href') || '');
362
+ if (target)
363
+ total += LINK_OVERHEAD_BYTES + escapeRtf(target).length;
364
+ }
365
+ // Each line break in a code block is written as "\line " (6) and counted as 1.
366
+ if (tag === 'pre')
367
+ total += ((el.textContent || '').match(/\r\n|\r|\n/g)?.length ?? 0) * 5;
368
+ // A paragraph a block writes inside a table cell also carries \intbl (6). A list
369
+ // writes one paragraph per item, so the item is counted, not the list.
370
+ if (CELL_PARAGRAPH_TAGS.has(tag) && el.closest('td, th'))
371
+ total += 6;
372
+ // Every cell in a row with a header cell carries \clbrdrb\brdrs.
373
+ if (tag === 'td' && el.parentElement?.querySelector(':scope > th'))
374
+ total += 14;
375
+ // A figure with a caption and no picture writes its caption twice.
376
+ if (tag === 'figure' && !el.querySelector('img'))
377
+ total += escapeRtf(el.textContent || '').length;
378
+ // A color costs "\cfN " on and off and, the first time, its color-table entry (at most
379
+ // 22 bytes, "\red255\green255\blue255;"); a size costs "\fsNN " on and off.
380
+ if (el.style?.color || (tag === 'font' && el.hasAttribute('color')))
381
+ total += 10 + 22;
382
+ if (el.style?.fontSize)
383
+ total += 14; // "\fsNNN " on and off, for sizes up to 499 pt
332
384
  }
333
385
  return total;
334
386
  }
387
+ // ── Link helpers ──
388
+ /**
389
+ * The address a link should carry in the RTF, or null if it should not be a link.
390
+ *
391
+ * Only an absolute web, mail or phone address qualifies. A relative path, a UNC path
392
+ * (\\server\share) or a javascript:, data:, file: or vbscript: address is refused: the
393
+ * receiving system opens a link in its own context, where a relative or UNC path names
394
+ * a file on the reader's network. The address returned is the parsed one, which
395
+ * percent-encodes characters such as a quote that would otherwise end the field's
396
+ * address and add switches of its own.
397
+ */
398
+ export function linkTarget(url) {
399
+ const value = url.trim();
400
+ if (!/^(https?:\/\/|mailto:|tel:)/i.test(value) || value.includes('\\'))
401
+ return null;
402
+ try {
403
+ const parsed = new URL(value);
404
+ if (!['http:', 'https:', 'mailto:', 'tel:'].includes(parsed.protocol))
405
+ return null;
406
+ return parsed.href.replace(/"/g, '%22');
407
+ }
408
+ catch {
409
+ return null;
410
+ }
411
+ }
412
+ /** Whether a link address is an absolute web, mail or phone address (see linkTarget). */
413
+ export function isSafeLinkUrl(url) {
414
+ return linkTarget(url) !== null;
415
+ }
335
416
  // ── Font helpers ──
336
417
  /**
337
418
  * A single font name, safe for the font table. A CSS list such as
@@ -411,7 +492,15 @@ export function htmlToRtf(editorEl, options = {}) {
411
492
  inPre: false,
412
493
  listCounter: 0,
413
494
  inTableCell: false,
414
- root: editorEl
495
+ root: editorEl,
496
+ bold: false,
497
+ italic: false,
498
+ underline: false,
499
+ strike: false,
500
+ size: bodySize,
501
+ color: 0,
502
+ script: null,
503
+ mono: false
415
504
  });
416
505
  const rtf = '{\\rtf1\\ansi\\ansicpg1252\\deff0' +
417
506
  fontTable +
@@ -470,34 +559,57 @@ function walkChildren(parent, ctx) {
470
559
  const tag = el.tagName.toLowerCase();
471
560
  switch (tag) {
472
561
  // Heading sizes keep their ratio to the body (HEADING_SCALE).
473
- case 'h1':
474
- rtf += `\\pard\\f0\\fs${scaled(ctx, HEADING_SCALE.h1)}\\b ${walkChildren(el, ctx)}\\b0\\fs${ctx.bodySize}\\par `;
562
+ case 'h1': {
563
+ const size = scaled(ctx, HEADING_SCALE.h1);
564
+ rtf += `\\pard${inCell(ctx)}\\f0\\fs${size}\\b ${walkChildren(el, { ...ctx, bold: true, size })}${ctx.bold ? '' : '\\b0'}\\fs${ctx.size}\\par `;
475
565
  break;
476
- case 'h2':
477
- rtf += `\\pard\\f0\\fs${scaled(ctx, HEADING_SCALE.h2)}\\b ${walkChildren(el, ctx)}\\b0\\fs${ctx.bodySize}\\par `;
566
+ }
567
+ case 'h2': {
568
+ const size = scaled(ctx, HEADING_SCALE.h2);
569
+ rtf += `\\pard${inCell(ctx)}\\f0\\fs${size}\\b ${walkChildren(el, { ...ctx, bold: true, size })}${ctx.bold ? '' : '\\b0'}\\fs${ctx.size}\\par `;
478
570
  break;
479
- case 'h3':
480
- rtf += `\\pard\\f0\\fs${scaled(ctx, HEADING_SCALE.h3)}\\b ${walkChildren(el, ctx)}\\b0\\fs${ctx.bodySize}\\par `;
571
+ }
572
+ case 'h3': {
573
+ const size = scaled(ctx, HEADING_SCALE.h3);
574
+ rtf += `\\pard${inCell(ctx)}\\f0\\fs${size}\\b ${walkChildren(el, { ...ctx, bold: true, size })}${ctx.bold ? '' : '\\b0'}\\fs${ctx.size}\\par `;
481
575
  break;
576
+ }
482
577
  case 'p':
483
578
  case 'div': {
484
- // Inside a table cell \pard is already declared; just emit inline content.
579
+ // Inside a table cell the cell has already declared \pard\intbl. Each paragraph
580
+ // ends with \par, so two paragraphs in one cell stay two (the cell drops the last
581
+ // one's); one that follows a list or heading in the cell restates \pard\intbl,
582
+ // so it does not inherit that block's indent.
583
+ // Text typed straight into the parent before this block ("abc<div>def</div>",
584
+ // the shape Chrome makes on Enter) has no paragraph of its own: end it here, or
585
+ // the two lines run together.
586
+ const afterLooseText = followsInlineContent(el);
485
587
  if (ctx.inTableCell) {
486
- rtf += walkChildren(el, ctx);
588
+ const restate = afterLooseText
589
+ ? '\\par \\pard\\intbl '
590
+ : el.previousElementSibling ? '\\pard\\intbl ' : '';
591
+ if (isBlankLine(el)) {
592
+ rtf += `${restate}\\par `;
593
+ break;
594
+ }
595
+ const colorIdx = colorIndexOf(el, ctx);
596
+ const inner = walkChildren(el, colorIdx != null ? { ...ctx, color: colorIdx } : ctx);
597
+ rtf += colorIdx != null
598
+ ? `${restate}\\cf${colorIdx} ${inner}\\cf${ctx.color} \\par `
599
+ : `${restate}${inner}\\par `;
487
600
  break;
488
601
  }
489
602
  // A <p> with no children or only a single <br> is a blank line —
490
603
  // emit a bare \par rather than \pard \line\par, which adds an extra line.
491
- const isBlankLine = el.childNodes.length === 0 ||
492
- (el.childNodes.length === 1 && el.childNodes[0].nodeName === 'BR');
493
- if (isBlankLine) {
494
- rtf += '\\par ';
604
+ if (isBlankLine(el)) {
605
+ rtf += afterLooseText ? '\\par \\par ' : '\\par ';
495
606
  break;
496
607
  }
497
- const inlineRtf = walkChildren(el, ctx);
498
- const colorPrefix = getColorPrefix(el, ctx);
499
- const colorSuffix = colorPrefix ? '\\cf0 ' : '';
500
- rtf += `\\pard ${colorPrefix}${inlineRtf}${colorSuffix}\\par `;
608
+ const colorIdx = colorIndexOf(el, ctx);
609
+ const inlineRtf = walkChildren(el, colorIdx != null ? { ...ctx, color: colorIdx } : ctx);
610
+ const colorPrefix = colorIdx != null ? `\\cf${colorIdx} ` : '';
611
+ const colorSuffix = colorIdx != null ? `\\cf${ctx.color} ` : '';
612
+ rtf += `${afterLooseText ? '\\par ' : ''}\\pard ${colorPrefix}${inlineRtf}${colorSuffix}\\par `;
501
613
  break;
502
614
  }
503
615
  case 'table': {
@@ -529,7 +641,7 @@ function walkChildren(parent, ctx) {
529
641
  // Cell bodies
530
642
  const cellCtx = { ...ctx, inTableCell: true };
531
643
  for (const cell of cells) {
532
- const content = walkChildren(cell, cellCtx);
644
+ const content = withoutTrailingPar(walkChildren(cell, cellCtx));
533
645
  rowRtf += `{\\pard\\intbl \\f0 \\sa0 \\li0 \\fi0 ${content}\\par}\\cell`;
534
646
  }
535
647
  rowRtf += '}\\intbl\\row}';
@@ -538,18 +650,20 @@ function walkChildren(parent, ctx) {
538
650
  break;
539
651
  }
540
652
  case 'blockquote':
541
- rtf += `\\pard\\li720\\i ${walkChildren(el, ctx)}\\i0\\par `;
653
+ rtf += `\\pard${inCell(ctx)}\\li720\\i ${walkChildren(el, { ...ctx, italic: true })}${ctx.italic ? '' : '\\i0'}\\par `;
542
654
  break;
543
- case 'pre':
544
- rtf += `\\pard\\f1\\fs${scaled(ctx, PRE_SCALE)} ${walkChildren(el, { ...ctx, inPre: true })}\\f0\\fs${ctx.bodySize}\\par `;
655
+ case 'pre': {
656
+ const size = scaled(ctx, PRE_SCALE);
657
+ rtf += `\\pard${inCell(ctx)}\\f1\\fs${size} ${walkChildren(el, { ...ctx, inPre: true, size })}\\f0\\fs${ctx.size}\\par `;
545
658
  break;
659
+ }
546
660
  case 'hr':
547
- rtf += `\\pard\\qc \\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\par `;
661
+ rtf += `\\pard${inCell(ctx)}\\qc \\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\par `;
548
662
  break;
549
663
  case 'ul': {
550
664
  const items = el.querySelectorAll(':scope > li');
551
665
  items.forEach((li) => {
552
- rtf += `\\pard\\li720\\fi-360\\bullet\\tab ${walkChildren(li, ctx)}\\par `;
666
+ rtf += `\\pard${inCell(ctx)}\\li720\\fi-360\\bullet\\tab ${walkChildren(li, ctx)}\\par `;
553
667
  });
554
668
  break;
555
669
  }
@@ -557,7 +671,7 @@ function walkChildren(parent, ctx) {
557
671
  let counter = 1;
558
672
  const items = el.querySelectorAll(':scope > li');
559
673
  items.forEach((li) => {
560
- rtf += `\\pard\\li720\\fi-360 ${counter}.\\tab ${walkChildren(li, ctx)}\\par `;
674
+ rtf += `\\pard${inCell(ctx)}\\li720\\fi-360 ${counter}.\\tab ${walkChildren(li, ctx)}\\par `;
561
675
  counter++;
562
676
  });
563
677
  break;
@@ -567,42 +681,50 @@ function walkChildren(parent, ctx) {
567
681
  break;
568
682
  case 'strong':
569
683
  case 'b':
570
- rtf += `\\b ${walkChildren(el, ctx)}\\b0 `;
684
+ rtf += ctx.bold ? walkChildren(el, ctx) : `\\b ${walkChildren(el, { ...ctx, bold: true })}\\b0 `;
571
685
  break;
572
686
  case 'em':
573
687
  case 'i':
574
- rtf += `\\i ${walkChildren(el, ctx)}\\i0 `;
688
+ rtf += ctx.italic ? walkChildren(el, ctx) : `\\i ${walkChildren(el, { ...ctx, italic: true })}\\i0 `;
575
689
  break;
576
690
  case 'u':
577
- rtf += `\\ul ${walkChildren(el, ctx)}\\ulnone `;
691
+ rtf += ctx.underline ? walkChildren(el, ctx) : `\\ul ${walkChildren(el, { ...ctx, underline: true })}\\ulnone `;
578
692
  break;
579
693
  case 's':
580
694
  case 'strike':
581
695
  case 'del':
582
- rtf += `\\strike ${walkChildren(el, ctx)}\\strike0 `;
696
+ rtf += ctx.strike ? walkChildren(el, ctx) : `\\strike ${walkChildren(el, { ...ctx, strike: true })}\\strike0 `;
583
697
  break;
584
698
  case 'sup':
585
- rtf += `\\super ${walkChildren(el, ctx)}\\nosupersub `;
586
- break;
587
- case 'sub':
588
- rtf += `\\sub ${walkChildren(el, ctx)}\\nosupersub `;
699
+ case 'sub': {
700
+ const script = tag === 'sup' ? 'super' : 'sub';
701
+ if (ctx.script === script) {
702
+ rtf += walkChildren(el, ctx);
703
+ break;
704
+ }
705
+ const restore = ctx.script ? `\\${ctx.script} ` : '\\nosupersub ';
706
+ rtf += `\\${script} ${walkChildren(el, { ...ctx, script })}${restore}`;
589
707
  break;
708
+ }
590
709
  case 'code':
591
- if (!ctx.inPre) {
592
- rtf += `\\f1 ${walkChildren(el, ctx)}\\f0 `;
710
+ if (!ctx.inPre && !ctx.mono) {
711
+ rtf += `\\f1 ${walkChildren(el, { ...ctx, mono: true })}\\f0 `;
593
712
  }
594
713
  else {
595
714
  rtf += walkChildren(el, ctx);
596
715
  }
597
716
  break;
598
717
  case 'a': {
599
- const href = el.getAttribute('href') || '';
600
- const inner = walkChildren(el, ctx);
601
- if (href) {
602
- rtf += `{\\field{\\*\\fldinst HYPERLINK "${escapeRtf(href)}"}{\\fldrslt\\ul\\cf1 ${inner}\\cf0\\ulnone }}`;
718
+ // A link is written only for an absolute web, mail or phone address; anything
719
+ // else (a relative or UNC path, javascript:, data:, file:) as its text alone.
720
+ const target = linkTarget(el.getAttribute('href') || '');
721
+ if (target) {
722
+ const inner = walkChildren(el, { ...ctx, underline: true, color: 1 });
723
+ const underlineOff = ctx.underline ? '' : '\\ulnone';
724
+ rtf += `{\\field{\\*\\fldinst HYPERLINK "${escapeRtf(target)}"}{\\fldrslt\\ul\\cf1 ${inner}\\cf${ctx.color}${underlineOff} }}`;
603
725
  }
604
726
  else {
605
- rtf += inner;
727
+ rtf += walkChildren(el, ctx);
606
728
  }
607
729
  break;
608
730
  }
@@ -616,11 +738,11 @@ function walkChildren(parent, ctx) {
616
738
  const caption = (el.querySelector('figcaption')?.textContent || '').trim();
617
739
  const align = alignControl(el);
618
740
  if (img)
619
- rtf += `\\pard${align} ${imageBody(img)}\\par `;
741
+ rtf += `\\pard${inCell(ctx)}${align} ${imageBody(img)}\\par `;
620
742
  else
621
743
  rtf += walkChildren(el, ctx);
622
744
  if (caption) {
623
- rtf += `{\\*\\inkcap}\\pard${align}\\i ${escapeRtf(caption)}\\i0\\par `;
745
+ rtf += `{\\*\\inkcap}\\pard${inCell(ctx)}${align}\\i ${escapeRtf(caption)}${ctx.italic ? '' : '\\i0'}\\par `;
624
746
  }
625
747
  break;
626
748
  }
@@ -635,18 +757,22 @@ function walkChildren(parent, ctx) {
635
757
  rtf += '\\line ';
636
758
  break;
637
759
  case 'span': {
638
- const colorPfx = getColorPrefix(el, ctx);
639
- const sizePfx = getSizePrefix(el);
640
- const sizesfx = sizePfx ? `\\fs${ctx.bodySize} ` : '';
641
- const colorSfx = colorPfx ? '\\cf0 ' : '';
642
- rtf += `${colorPfx}${sizePfx}${walkChildren(el, ctx)}${sizesfx}${colorSfx}`;
760
+ const colorIdx = colorIndexOf(el, ctx);
761
+ const size = sizeOf(el);
762
+ const colorPfx = colorIdx != null ? `\\cf${colorIdx} ` : '';
763
+ const sizePfx = size != null ? `\\fs${size} ` : '';
764
+ const sizeSfx = size != null ? `\\fs${ctx.size} ` : '';
765
+ const colorSfx = colorIdx != null ? `\\cf${ctx.color} ` : '';
766
+ const inner = walkChildren(el, { ...ctx, color: colorIdx ?? ctx.color, size: size ?? ctx.size });
767
+ rtf += `${colorPfx}${sizePfx}${inner}${sizeSfx}${colorSfx}`;
643
768
  break;
644
769
  }
645
770
  // <font color="..."> is produced by execCommand('foreColor') in contenteditable
646
771
  case 'font': {
647
- const colorPfx = getColorPrefix(el, ctx);
648
- const colorSfx = colorPfx ? '\\cf0 ' : '';
649
- rtf += `${colorPfx}${walkChildren(el, ctx)}${colorSfx}`;
772
+ const colorIdx = colorIndexOf(el, ctx);
773
+ const colorPfx = colorIdx != null ? `\\cf${colorIdx} ` : '';
774
+ const colorSfx = colorIdx != null ? `\\cf${ctx.color} ` : '';
775
+ rtf += `${colorPfx}${walkChildren(el, { ...ctx, color: colorIdx ?? ctx.color })}${colorSfx}`;
650
776
  break;
651
777
  }
652
778
  default:
@@ -678,33 +804,63 @@ function besideBlock(node) {
678
804
  const next = node.nextSibling;
679
805
  return isBlock(prev) || isBlock(next) || (!prev && !next && isBlock(node.parentNode));
680
806
  }
681
- function getColorPrefix(el, ctx) {
807
+ /** A <p> or <div> with no content, or only a <br>: a blank line. */
808
+ function isBlankLine(el) {
809
+ return el.childNodes.length === 0 || (el.childNodes.length === 1 && el.childNodes[0].nodeName === 'BR');
810
+ }
811
+ /**
812
+ * Whether the nearest sibling before this element, skipping collapsible whitespace, is
813
+ * text or an inline element: content with no paragraph of its own.
814
+ */
815
+ function followsInlineContent(el) {
816
+ let prev = el.previousSibling;
817
+ while (prev && prev.nodeType === Node.TEXT_NODE && COLLAPSIBLE_WHITESPACE.test(prev.textContent || '')) {
818
+ prev = prev.previousSibling;
819
+ }
820
+ if (!prev)
821
+ return false;
822
+ if (prev.nodeType === Node.TEXT_NODE)
823
+ return true;
824
+ return prev.nodeType === Node.ELEMENT_NODE && !isBlock(prev);
825
+ }
826
+ /**
827
+ * The cell's own \par ends its last paragraph, so that paragraph's mark is dropped. Only a
828
+ * real control word: "\par " preceded by an odd run of backslashes is escaped text.
829
+ */
830
+ function withoutTrailingPar(content) {
831
+ if (!content.endsWith('\\par '))
832
+ return content;
833
+ let slashes = 0;
834
+ for (let i = content.length - 6; i >= 0 && content[i] === '\\'; i--)
835
+ slashes++;
836
+ return slashes % 2 === 0 ? content.slice(0, -5) : content;
837
+ }
838
+ /** \intbl for a paragraph a block writes inside a table cell, so it stays in the cell. */
839
+ function inCell(ctx) {
840
+ return ctx.inTableCell ? '\\intbl' : '';
841
+ }
842
+ /** Color-table index of the element's own color, or null when it sets none. */
843
+ function colorIndexOf(el, ctx) {
682
844
  // Support both inline style.color (spans) and the color attribute (<font>)
683
845
  const color = el.style?.color ||
684
846
  (el.tagName.toLowerCase() === 'font' ? (el.getAttribute('color') ?? '') : '');
685
847
  if (!color)
686
- return '';
848
+ return null;
687
849
  const c = parseColor(color);
688
850
  if (!c)
689
- return '';
690
- const idx = ctx.colorIndex.get(colorKey(c));
691
- if (idx != null)
692
- return `\\cf${idx} `;
693
- return '';
851
+ return null;
852
+ return ctx.colorIndex.get(colorKey(c)) ?? null;
694
853
  }
695
- function getSizePrefix(el) {
854
+ /** The element's own font size in half-points, or null when it sets none. */
855
+ function sizeOf(el) {
696
856
  const fs = el.style?.fontSize;
697
857
  if (!fs)
698
- return '';
858
+ return null;
699
859
  const ptMatch = fs.match(/([\d.]+)\s*pt/);
700
- if (ptMatch) {
701
- const halfPoints = Math.round(parseFloat(ptMatch[1]) * 2);
702
- return `\\fs${halfPoints} `;
703
- }
860
+ if (ptMatch)
861
+ return Math.round(parseFloat(ptMatch[1]) * 2);
704
862
  const pxMatch = fs.match(/([\d.]+)\s*px/);
705
- if (pxMatch) {
706
- const halfPoints = Math.round(parseFloat(pxMatch[1]) * 0.75 * 2);
707
- return `\\fs${halfPoints} `;
708
- }
709
- return '';
863
+ if (pxMatch)
864
+ return Math.round(parseFloat(pxMatch[1]) * 0.75 * 2);
865
+ return null;
710
866
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-rtf-editor",
3
- "version": "1.5.2",
3
+ "version": "1.5.3",
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",