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 +2 -1
- package/dist/components/InkEditor.svelte +22 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/rtf-writer.d.ts +13 -0
- package/dist/rtf-writer.js +229 -73
- package/package.json +1 -1
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';
|
package/dist/rtf-writer.d.ts
CHANGED
|
@@ -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.
|
package/dist/rtf-writer.js
CHANGED
|
@@ -86,7 +86,10 @@ function escapeRtf(text) {
|
|
|
86
86
|
else if (ch === '}')
|
|
87
87
|
out += '\\}';
|
|
88
88
|
else if (code > 127) {
|
|
89
|
-
|
|
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
|
-
|
|
325
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
477
|
-
|
|
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
|
-
|
|
480
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
492
|
-
|
|
493
|
-
if (isBlankLine) {
|
|
494
|
-
rtf += '\\par ';
|
|
604
|
+
if (isBlankLine(el)) {
|
|
605
|
+
rtf += afterLooseText ? '\\par \\par ' : '\\par ';
|
|
495
606
|
break;
|
|
496
607
|
}
|
|
497
|
-
const
|
|
498
|
-
const
|
|
499
|
-
const
|
|
500
|
-
|
|
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
|
-
|
|
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
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
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
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
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 +=
|
|
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
|
|
639
|
-
const
|
|
640
|
-
const
|
|
641
|
-
const
|
|
642
|
-
|
|
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
|
|
648
|
-
const
|
|
649
|
-
|
|
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
|
-
|
|
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
|
-
|
|
691
|
-
if (idx != null)
|
|
692
|
-
return `\\cf${idx} `;
|
|
693
|
-
return '';
|
|
851
|
+
return null;
|
|
852
|
+
return ctx.colorIndex.get(colorKey(c)) ?? null;
|
|
694
853
|
}
|
|
695
|
-
|
|
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
|
-
|
|
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
|
-
|
|
707
|
-
|
|
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