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 +4 -3
- package/dist/RtfViewer.svelte +1 -1
- package/dist/components/InkEditor.svelte +23 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -1
- package/dist/rtf-parser.d.ts +13 -2
- package/dist/rtf-parser.js +21 -59
- package/dist/rtf-writer.d.ts +13 -0
- package/dist/rtf-writer.js +252 -73
- package/package.json +1 -1
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`
|
|
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
|
-
- `
|
|
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
|
|
package/dist/RtfViewer.svelte
CHANGED
|
@@ -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';
|
package/dist/rtf-parser.d.ts
CHANGED
|
@@ -10,5 +10,16 @@
|
|
|
10
10
|
*
|
|
11
11
|
* \cb (character background) is silently ignored on import.
|
|
12
12
|
*/
|
|
13
|
-
|
|
14
|
-
export
|
|
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>;
|
package/dist/rtf-parser.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
448
|
-
//
|
|
449
|
-
|
|
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 !==
|
|
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, '&')
|
|
500
458
|
.replace(/</g, '<')
|
|
501
459
|
.replace(/>/g, '>');
|
|
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
|
-
|
|
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) {
|
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 +
|
|
@@ -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
|
-
|
|
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
|
-
|
|
472
|
-
|
|
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
|
-
|
|
475
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
487
|
-
|
|
488
|
-
if (isBlankLine) {
|
|
489
|
-
rtf += '\\par ';
|
|
604
|
+
if (isBlankLine(el)) {
|
|
605
|
+
rtf += afterLooseText ? '\\par \\par ' : '\\par ';
|
|
490
606
|
break;
|
|
491
607
|
}
|
|
492
|
-
const
|
|
493
|
-
const
|
|
494
|
-
const
|
|
495
|
-
|
|
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
|
-
|
|
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
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
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 +=
|
|
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
|
|
634
|
-
const
|
|
635
|
-
const
|
|
636
|
-
const
|
|
637
|
-
|
|
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
|
|
643
|
-
const
|
|
644
|
-
|
|
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
|
-
|
|
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
|
-
|
|
668
|
-
if (idx != null)
|
|
669
|
-
return `\\cf${idx} `;
|
|
670
|
-
return '';
|
|
851
|
+
return null;
|
|
852
|
+
return ctx.colorIndex.get(colorKey(c)) ?? null;
|
|
671
853
|
}
|
|
672
|
-
|
|
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
|
-
|
|
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
|
-
|
|
684
|
-
|
|
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