svelte-rtf-editor 1.5.1 → 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,15 +163,16 @@ 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
 
170
170
  | Function | Description |
171
171
  |---------------------------------------|----------------------------------------------------|
172
- | `rtfToHtml(rtf: string): string` | Parse an RTF string and return an HTML string |
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 |
@@ -195,7 +196,7 @@ htmlToRtf(el, { fontFamily: 'Calibri', fontSize: 11 });
195
196
  - If you call `htmlToRtf` yourself on the editor's content, pass the same options you gave the editor, or the two produce different RTF.
196
197
  - `fontFamily` names one font. Given a CSS list such as `'Arial, sans-serif'`, only the first family is used.
197
198
  - `fontSize` is rounded to the nearest half-point, which is what RTF measures in, and kept between 1 and 1638 pt.
198
- - `rtfToHtml` recognizes headings relative to the document's own body size, so a document written at any size reads back with the same headings.
199
+ - If you show RTF in an editor or viewer set to another size, pass the same `fontSize` to `rtfToHtml`. Text at that size then carries no size of its own, text at any other size keeps it, and headings are recognized by the same ratios the writer uses. `InkEditor`'s RTF import and `RtfViewer` do this themselves. At another size than 12 pt, a heading must also be bold, as every heading the writer emits is.
199
200
 
200
201
  The font has to be installed on the machine that displays the document. The RTF names it, and a reader without it substitutes one of its own.
201
202
 
@@ -19,7 +19,7 @@
19
19
  const screenFontFamily = $derived(cssFontFamily(fontFamily));
20
20
  const screenFontSize = $derived(normalizeFontSize(fontSize));
21
21
 
22
- const htmlContent = $derived(rtfToHtml(content));
22
+ const htmlContent = $derived(rtfToHtml(content, { fontSize }));
23
23
 
24
24
  let copied = $state(false);
25
25
 
@@ -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
 
@@ -694,7 +705,7 @@
694
705
  importing = true;
695
706
 
696
707
  try {
697
- const html = await readRtfFile(file);
708
+ const html = await readRtfFile(file, { fontSize });
698
709
  if (editorEl) {
699
710
  selectedImage = null;
700
711
  editorEl.innerHTML = html;
@@ -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,6 +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
+ export type { RtfReadOptions } from './rtf-parser.js';
6
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';
@@ -10,5 +10,16 @@
10
10
  *
11
11
  * \cb (character background) is silently ignored on import.
12
12
  */
13
- export declare function rtfToHtml(rtfString: string): string;
14
- export declare function readRtfFile(file: File): Promise<string>;
13
+ /** Options for rtfToHtml. */
14
+ export interface RtfReadOptions {
15
+ /**
16
+ * The body text size of the screen the HTML will be shown on, in points:
17
+ * the editor's or viewer's fontSize. Text at this size carries no size of
18
+ * its own, text at any other size keeps its font-size, and headings are
19
+ * recognized by their ratio to it (HEADING_SCALE). Default: 12, which reads
20
+ * a document exactly as 1.4.0 did.
21
+ */
22
+ fontSize?: number;
23
+ }
24
+ export declare function rtfToHtml(rtfString: string, options?: RtfReadOptions): string;
25
+ export declare function readRtfFile(file: File, options?: RtfReadOptions): Promise<string>;
@@ -10,7 +10,7 @@
10
10
  *
11
11
  * \cb (character background) is silently ignored on import.
12
12
  */
13
- import { HEADING_SCALE } from './rtf-writer.js';
13
+ import { HEADING_SCALE, normalizeFontSize } from './rtf-writer.js';
14
14
  // ── Highlight palette (RTF \highlight 1-16) ──
15
15
  const HIGHLIGHT_COLORS = {
16
16
  1: '#000000', // Black
@@ -357,52 +357,7 @@ const destinationWords = new Set([
357
357
  'latentstyles', 'datastore', 'mmathPr', 'author', 'operator',
358
358
  'title', 'subject', 'doccomm', 'company', 'category', 'keywords'
359
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
- }
404
- // ── Renderer ──
405
- export function rtfToHtml(rtfString) {
360
+ export function rtfToHtml(rtfString, options = {}) {
406
361
  const tokens = tokenize(rtfString);
407
362
  const tree = buildTree(tokens);
408
363
  let colorTable = [''];
@@ -444,10 +399,9 @@ export function rtfToHtml(rtfString) {
444
399
  }
445
400
  }
446
401
  }
447
- // Headings are recognized by their ratio to the body size. Text keeps its
448
- // own size, though: anything other than 12 pt carries a font-size, so a
449
- // document written at 9 pt still shows at 9 pt.
450
- const bodySize = bodyHalfPoints(rootGroup);
402
+ // The screen's body size, in half-points. It is given, not guessed from the
403
+ // document: guessing misread short documents and ones set below 12 pt.
404
+ const bodySize = normalizeFontSize(options.fontSize) * 2;
451
405
  // State stack for nested groups
452
406
  function defaultState() {
453
407
  return {
@@ -472,7 +426,7 @@ export function rtfToHtml(rtfString) {
472
426
  function cloneState() {
473
427
  return { ...currentState() };
474
428
  }
475
- function buildSpanOpen(state) {
429
+ function buildSpanOpen(state, withSize = true) {
476
430
  const styles = [];
477
431
  if (state.colorIndex > 0 && state.colorIndex < colorTable.length) {
478
432
  const color = colorTable[state.colorIndex];
@@ -485,7 +439,7 @@ export function rtfToHtml(rtfString) {
485
439
  if (bg)
486
440
  styles.push(`background-color:${bg}`);
487
441
  }
488
- if (state.fontSize && state.fontSize !== DEFAULT_HALF_POINTS) {
442
+ if (withSize && state.fontSize && state.fontSize !== bodySize) {
489
443
  styles.push(`font-size:${state.fontSize / 2}pt`);
490
444
  }
491
445
  if (styles.length === 0)
@@ -495,12 +449,16 @@ export function rtfToHtml(rtfString) {
495
449
  function wrapText(text, state) {
496
450
  if (!text)
497
451
  return '';
452
+ // A size on spaces alone is not shown and would be read as the
453
+ // paragraph's size, so it is dropped. Underline, strikethrough and
454
+ // highlight are kept: an underlined blank is a signature line.
455
+ const onlySpaces = /^[ \t\r\n\f]*$/.test(text);
498
456
  let result = text
499
457
  .replace(/&/g, '&amp;')
500
458
  .replace(/</g, '&lt;')
501
459
  .replace(/>/g, '&gt;');
502
460
  // Apply span styles
503
- const spanOpen = buildSpanOpen(state);
461
+ const spanOpen = buildSpanOpen(state, !onlySpaces);
504
462
  if (spanOpen) {
505
463
  result = `${spanOpen}${result}</span>`;
506
464
  }
@@ -889,15 +847,19 @@ export function rtfToHtml(rtfString) {
889
847
  const halfPoints = Math.round(parseFloat(sizeMatch[1]) * 2);
890
848
  const alignAttr = align !== 'left' ? ` style="text-align:${align}"` : '';
891
849
  const atLeast = (scale) => halfPoints >= Math.round(bodySize * scale);
892
- if (atLeast(HEADING_SCALE.h1)) {
850
+ // At the default 12 pt, size alone makes a heading, as it always has. At
851
+ // another screen size, ordinary 12 pt text can be a step above the body,
852
+ // so a heading must also be bold, as every heading the writer emits is.
853
+ const looksLikeHeading = bodySize === 24 || /^<strong>/.test(trimmed);
854
+ if (looksLikeHeading && atLeast(HEADING_SCALE.h1)) {
893
855
  rendered.push(`<h1${alignAttr}>${stripSizeSpan(trimmed)}</h1>`);
894
856
  continue;
895
857
  }
896
- if (atLeast(HEADING_SCALE.h2)) {
858
+ if (looksLikeHeading && atLeast(HEADING_SCALE.h2)) {
897
859
  rendered.push(`<h2${alignAttr}>${stripSizeSpan(trimmed)}</h2>`);
898
860
  continue;
899
861
  }
900
- if (atLeast(HEADING_SCALE.h3)) {
862
+ if (looksLikeHeading && atLeast(HEADING_SCALE.h3)) {
901
863
  rendered.push(`<h3${alignAttr}>${stripSizeSpan(trimmed)}</h3>`);
902
864
  continue;
903
865
  }
@@ -917,13 +879,13 @@ function stripSizeSpan(html) {
917
879
  function stripWrappingEm(html) {
918
880
  return html.replace(/^<em>([\s\S]*)<\/em>$/, '$1');
919
881
  }
920
- export function readRtfFile(file) {
882
+ export function readRtfFile(file, options = {}) {
921
883
  return new Promise((resolve, reject) => {
922
884
  const reader = new FileReader();
923
885
  reader.onload = () => {
924
886
  try {
925
887
  const text = reader.result;
926
- const html = rtfToHtml(text);
888
+ const html = rtfToHtml(text, options);
927
889
  resolve(html);
928
890
  }
929
891
  catch (err) {
@@ -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 +
@@ -448,6 +537,11 @@ function walkChildren(parent, ctx) {
448
537
  const node = children[i];
449
538
  if (node.nodeType === Node.TEXT_NODE) {
450
539
  const text = node.textContent || '';
540
+ // Whitespace between two blocks (the newline in "</p>\n<p>") shows as
541
+ // nothing in a browser. Written out, it became a space at the start of
542
+ // the next paragraph.
543
+ if (!ctx.inPre && COLLAPSIBLE_WHITESPACE.test(text) && besideBlock(node))
544
+ continue;
451
545
  if (ctx.inPre) {
452
546
  // Split before escaping — escapeRtf collapses newlines, so the
453
547
  // line structure a <pre> block depends on has to become \line
@@ -465,34 +559,57 @@ function walkChildren(parent, ctx) {
465
559
  const tag = el.tagName.toLowerCase();
466
560
  switch (tag) {
467
561
  // Heading sizes keep their ratio to the body (HEADING_SCALE).
468
- case 'h1':
469
- 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 `;
470
565
  break;
471
- case 'h2':
472
- 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 `;
473
570
  break;
474
- case 'h3':
475
- 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 `;
476
575
  break;
576
+ }
477
577
  case 'p':
478
578
  case 'div': {
479
- // 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);
480
587
  if (ctx.inTableCell) {
481
- 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 `;
482
600
  break;
483
601
  }
484
602
  // A <p> with no children or only a single <br> is a blank line —
485
603
  // emit a bare \par rather than \pard \line\par, which adds an extra line.
486
- const isBlankLine = el.childNodes.length === 0 ||
487
- (el.childNodes.length === 1 && el.childNodes[0].nodeName === 'BR');
488
- if (isBlankLine) {
489
- rtf += '\\par ';
604
+ if (isBlankLine(el)) {
605
+ rtf += afterLooseText ? '\\par \\par ' : '\\par ';
490
606
  break;
491
607
  }
492
- const inlineRtf = walkChildren(el, ctx);
493
- const colorPrefix = getColorPrefix(el, ctx);
494
- const colorSuffix = colorPrefix ? '\\cf0 ' : '';
495
- 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 `;
496
613
  break;
497
614
  }
498
615
  case 'table': {
@@ -524,7 +641,7 @@ function walkChildren(parent, ctx) {
524
641
  // Cell bodies
525
642
  const cellCtx = { ...ctx, inTableCell: true };
526
643
  for (const cell of cells) {
527
- const content = walkChildren(cell, cellCtx);
644
+ const content = withoutTrailingPar(walkChildren(cell, cellCtx));
528
645
  rowRtf += `{\\pard\\intbl \\f0 \\sa0 \\li0 \\fi0 ${content}\\par}\\cell`;
529
646
  }
530
647
  rowRtf += '}\\intbl\\row}';
@@ -533,18 +650,20 @@ function walkChildren(parent, ctx) {
533
650
  break;
534
651
  }
535
652
  case 'blockquote':
536
- 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 `;
537
654
  break;
538
- case 'pre':
539
- 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 `;
540
658
  break;
659
+ }
541
660
  case 'hr':
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 `;
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 `;
543
662
  break;
544
663
  case 'ul': {
545
664
  const items = el.querySelectorAll(':scope > li');
546
665
  items.forEach((li) => {
547
- 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 `;
548
667
  });
549
668
  break;
550
669
  }
@@ -552,7 +671,7 @@ function walkChildren(parent, ctx) {
552
671
  let counter = 1;
553
672
  const items = el.querySelectorAll(':scope > li');
554
673
  items.forEach((li) => {
555
- 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 `;
556
675
  counter++;
557
676
  });
558
677
  break;
@@ -562,42 +681,50 @@ function walkChildren(parent, ctx) {
562
681
  break;
563
682
  case 'strong':
564
683
  case 'b':
565
- rtf += `\\b ${walkChildren(el, ctx)}\\b0 `;
684
+ rtf += ctx.bold ? walkChildren(el, ctx) : `\\b ${walkChildren(el, { ...ctx, bold: true })}\\b0 `;
566
685
  break;
567
686
  case 'em':
568
687
  case 'i':
569
- rtf += `\\i ${walkChildren(el, ctx)}\\i0 `;
688
+ rtf += ctx.italic ? walkChildren(el, ctx) : `\\i ${walkChildren(el, { ...ctx, italic: true })}\\i0 `;
570
689
  break;
571
690
  case 'u':
572
- rtf += `\\ul ${walkChildren(el, ctx)}\\ulnone `;
691
+ rtf += ctx.underline ? walkChildren(el, ctx) : `\\ul ${walkChildren(el, { ...ctx, underline: true })}\\ulnone `;
573
692
  break;
574
693
  case 's':
575
694
  case 'strike':
576
695
  case 'del':
577
- rtf += `\\strike ${walkChildren(el, ctx)}\\strike0 `;
696
+ rtf += ctx.strike ? walkChildren(el, ctx) : `\\strike ${walkChildren(el, { ...ctx, strike: true })}\\strike0 `;
578
697
  break;
579
698
  case 'sup':
580
- rtf += `\\super ${walkChildren(el, ctx)}\\nosupersub `;
581
- break;
582
- case 'sub':
583
- 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}`;
584
707
  break;
708
+ }
585
709
  case 'code':
586
- if (!ctx.inPre) {
587
- rtf += `\\f1 ${walkChildren(el, ctx)}\\f0 `;
710
+ if (!ctx.inPre && !ctx.mono) {
711
+ rtf += `\\f1 ${walkChildren(el, { ...ctx, mono: true })}\\f0 `;
588
712
  }
589
713
  else {
590
714
  rtf += walkChildren(el, ctx);
591
715
  }
592
716
  break;
593
717
  case 'a': {
594
- const href = el.getAttribute('href') || '';
595
- const inner = walkChildren(el, ctx);
596
- if (href) {
597
- 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} }}`;
598
725
  }
599
726
  else {
600
- rtf += inner;
727
+ rtf += walkChildren(el, ctx);
601
728
  }
602
729
  break;
603
730
  }
@@ -611,11 +738,11 @@ function walkChildren(parent, ctx) {
611
738
  const caption = (el.querySelector('figcaption')?.textContent || '').trim();
612
739
  const align = alignControl(el);
613
740
  if (img)
614
- rtf += `\\pard${align} ${imageBody(img)}\\par `;
741
+ rtf += `\\pard${inCell(ctx)}${align} ${imageBody(img)}\\par `;
615
742
  else
616
743
  rtf += walkChildren(el, ctx);
617
744
  if (caption) {
618
- rtf += `{\\*\\inkcap}\\pard${align}\\i ${escapeRtf(caption)}\\i0\\par `;
745
+ rtf += `{\\*\\inkcap}\\pard${inCell(ctx)}${align}\\i ${escapeRtf(caption)}${ctx.italic ? '' : '\\i0'}\\par `;
619
746
  }
620
747
  break;
621
748
  }
@@ -630,18 +757,22 @@ function walkChildren(parent, ctx) {
630
757
  rtf += '\\line ';
631
758
  break;
632
759
  case 'span': {
633
- const colorPfx = getColorPrefix(el, ctx);
634
- const sizePfx = getSizePrefix(el);
635
- const sizesfx = sizePfx ? `\\fs${ctx.bodySize} ` : '';
636
- const colorSfx = colorPfx ? '\\cf0 ' : '';
637
- 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}`;
638
768
  break;
639
769
  }
640
770
  // <font color="..."> is produced by execCommand('foreColor') in contenteditable
641
771
  case 'font': {
642
- const colorPfx = getColorPrefix(el, ctx);
643
- const colorSfx = colorPfx ? '\\cf0 ' : '';
644
- 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}`;
645
776
  break;
646
777
  }
647
778
  default:
@@ -655,33 +786,81 @@ function walkChildren(parent, ctx) {
655
786
  function scaled(ctx, ratio) {
656
787
  return Math.round(ctx.bodySize * ratio);
657
788
  }
658
- function getColorPrefix(el, ctx) {
789
+ /**
790
+ * Whitespace a browser collapses: spaces, tabs and line breaks, but not a
791
+ * non-breaking space, which is there on purpose.
792
+ */
793
+ const COLLAPSIBLE_WHITESPACE = /^[ \t\r\n\f]*$/;
794
+ const BLOCK_TAGS = new Set([
795
+ 'p', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'table', 'thead',
796
+ 'tbody', 'tfoot', 'tr', 'td', 'th', 'blockquote', 'pre', 'hr', 'figure', 'figcaption'
797
+ ]);
798
+ function isBlock(node) {
799
+ return node?.nodeType === Node.ELEMENT_NODE && BLOCK_TAGS.has(node.tagName.toLowerCase());
800
+ }
801
+ /** A text node with a block element on either side, or at the edge of one. */
802
+ function besideBlock(node) {
803
+ const prev = node.previousSibling;
804
+ const next = node.nextSibling;
805
+ return isBlock(prev) || isBlock(next) || (!prev && !next && isBlock(node.parentNode));
806
+ }
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) {
659
844
  // Support both inline style.color (spans) and the color attribute (<font>)
660
845
  const color = el.style?.color ||
661
846
  (el.tagName.toLowerCase() === 'font' ? (el.getAttribute('color') ?? '') : '');
662
847
  if (!color)
663
- return '';
848
+ return null;
664
849
  const c = parseColor(color);
665
850
  if (!c)
666
- return '';
667
- const idx = ctx.colorIndex.get(colorKey(c));
668
- if (idx != null)
669
- return `\\cf${idx} `;
670
- return '';
851
+ return null;
852
+ return ctx.colorIndex.get(colorKey(c)) ?? null;
671
853
  }
672
- function getSizePrefix(el) {
854
+ /** The element's own font size in half-points, or null when it sets none. */
855
+ function sizeOf(el) {
673
856
  const fs = el.style?.fontSize;
674
857
  if (!fs)
675
- return '';
858
+ return null;
676
859
  const ptMatch = fs.match(/([\d.]+)\s*pt/);
677
- if (ptMatch) {
678
- const halfPoints = Math.round(parseFloat(ptMatch[1]) * 2);
679
- return `\\fs${halfPoints} `;
680
- }
860
+ if (ptMatch)
861
+ return Math.round(parseFloat(ptMatch[1]) * 2);
681
862
  const pxMatch = fs.match(/([\d.]+)\s*px/);
682
- if (pxMatch) {
683
- const halfPoints = Math.round(parseFloat(pxMatch[1]) * 0.75 * 2);
684
- return `\\fs${halfPoints} `;
685
- }
686
- return '';
863
+ if (pxMatch)
864
+ return Math.round(parseFloat(pxMatch[1]) * 0.75 * 2);
865
+ return null;
687
866
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-rtf-editor",
3
- "version": "1.5.1",
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",