svelte-rtf-editor 1.4.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -5
- package/dist/RtfViewer.svelte +23 -3
- package/dist/RtfViewer.svelte.d.ts +2 -0
- package/dist/components/InkEditor.svelte +45 -24
- package/dist/components/InkEditor.svelte.d.ts +2 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -1
- package/dist/rtf-parser.js +69 -17
- package/dist/rtf-writer.d.ts +40 -2
- package/dist/rtf-writer.js +91 -17
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -105,9 +105,11 @@ Pass an HTML string as `content`. If the content is already RTF, convert it firs
|
|
|
105
105
|
|
|
106
106
|
### `<RtfViewer>`
|
|
107
107
|
|
|
108
|
-
| Prop
|
|
109
|
-
|
|
110
|
-
| `content`
|
|
108
|
+
| Prop | Type | Default | Description |
|
|
109
|
+
|--------------|----------|-----------|------------------------------------------|
|
|
110
|
+
| `content` | `string` | — | Raw RTF string to parse and display |
|
|
111
|
+
| `fontFamily` | `string` | `'Arial'` | Font the document is shown in |
|
|
112
|
+
| `fontSize` | `number` | `12` | Body text size in points |
|
|
111
113
|
|
|
112
114
|
---
|
|
113
115
|
|
|
@@ -128,6 +130,8 @@ Pass an HTML string as `content`. If the content is already RTF, convert it firs
|
|
|
128
130
|
| `maxImageEdge` | `number` | `1600` | Longest edge (px) an inserted image is scaled down to; `0` keeps full size |
|
|
129
131
|
| `maxImageBytes` | `number` | `524288` | Encoded byte ceiling per image; oversized pictures are re-encoded until they fit. `0` disables |
|
|
130
132
|
| `maxImageDisplayWidth` | `number` | `624` | Width (px) a newly inserted image is displayed at, unless the column is narrower — 6.5in of page. `0` fills the column |
|
|
133
|
+
| `fontFamily` | `string` | `'Arial'` | Document font, on screen and in the RTF. See [Fonts](#fonts) |
|
|
134
|
+
| `fontSize` | `number` | `12` | Body text size in points, on screen and in the RTF |
|
|
131
135
|
|
|
132
136
|
#### Callback props
|
|
133
137
|
|
|
@@ -158,14 +162,15 @@ editor.importRtf() // → void — open the file picker to import .rtf
|
|
|
158
162
|
|
|
159
163
|
```ts
|
|
160
164
|
import {
|
|
161
|
-
rtfToHtml, htmlToRtf, estimateRtfBytes, readRtfFile, htmlToMarkdown, downloadFile
|
|
165
|
+
rtfToHtml, htmlToRtf, estimateRtfBytes, readRtfFile, htmlToMarkdown, downloadFile,
|
|
166
|
+
DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE
|
|
162
167
|
} from 'svelte-rtf-editor';
|
|
163
168
|
```
|
|
164
169
|
|
|
165
170
|
| Function | Description |
|
|
166
171
|
|---------------------------------------|----------------------------------------------------|
|
|
167
172
|
| `rtfToHtml(rtf: string): string` | Parse an RTF string and return an HTML string |
|
|
168
|
-
| `htmlToRtf(
|
|
173
|
+
| `htmlToRtf(el: HTMLElement, options?: { fontFamily?, fontSize? }): string` | Convert a DOM element's content to RTF, in Arial 12 pt unless told otherwise |
|
|
169
174
|
| `estimateRtfBytes(el: HTMLElement): number` | Approximate RTF size of a DOM element without converting it |
|
|
170
175
|
| `readRtfFile(file: File): Promise<string>` | Read a `.rtf` File object and return HTML |
|
|
171
176
|
| `htmlToMarkdown(el: HTMLElement): string` | Convert a DOM element's content to Markdown |
|
|
@@ -173,6 +178,29 @@ import {
|
|
|
173
178
|
|
|
174
179
|
---
|
|
175
180
|
|
|
181
|
+
## Fonts
|
|
182
|
+
|
|
183
|
+
The editor and the viewer show a document in the same font and size the RTF carries, so what you see on screen is what the receiving system gets. Both default to **Arial 12 pt**.
|
|
184
|
+
|
|
185
|
+
```svelte
|
|
186
|
+
<InkEditor fontFamily="Calibri" fontSize={11} bind:this={editor} />
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
htmlToRtf(el, { fontFamily: 'Calibri', fontSize: 11 });
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
- `fontFamily` is used for the whole document: body text, headings, lists and tables. Code keeps Courier New.
|
|
194
|
+
- `fontSize` is the body size in points. Headings scale from it: at 12 pt, `h1` is 24 pt, `h2` 18 pt and `h3` 14 pt.
|
|
195
|
+
- 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
|
+
- `fontFamily` names one font. Given a CSS list such as `'Arial, sans-serif'`, only the first family is used.
|
|
197
|
+
- `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
|
+
|
|
200
|
+
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
|
+
---
|
|
203
|
+
|
|
176
204
|
## Theming
|
|
177
205
|
|
|
178
206
|
The components use CSS custom properties with sensible fallbacks, so they work out of the box but are easy to customise. Override these in your own CSS:
|
package/dist/RtfViewer.svelte
CHANGED
|
@@ -1,7 +1,23 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { rtfToHtml } from "./rtf-parser.js";
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
import {
|
|
4
|
+
cssFontFamily,
|
|
5
|
+
normalizeFontSize,
|
|
6
|
+
DEFAULT_FONT_FAMILY,
|
|
7
|
+
DEFAULT_FONT_SIZE
|
|
8
|
+
} from "./rtf-writer.js";
|
|
9
|
+
|
|
10
|
+
// fontFamily and fontSize (points) set how the document is shown, and
|
|
11
|
+
// default to the font the editor writes, so a document reads the same in
|
|
12
|
+
// the viewer as in the editor.
|
|
13
|
+
let {
|
|
14
|
+
content,
|
|
15
|
+
fontFamily = DEFAULT_FONT_FAMILY,
|
|
16
|
+
fontSize = DEFAULT_FONT_SIZE
|
|
17
|
+
}: { content: string; fontFamily?: string; fontSize?: number } = $props();
|
|
18
|
+
|
|
19
|
+
const screenFontFamily = $derived(cssFontFamily(fontFamily));
|
|
20
|
+
const screenFontSize = $derived(normalizeFontSize(fontSize));
|
|
5
21
|
|
|
6
22
|
const htmlContent = $derived(rtfToHtml(content));
|
|
7
23
|
|
|
@@ -24,7 +40,11 @@
|
|
|
24
40
|
Copy RTF
|
|
25
41
|
{/if}
|
|
26
42
|
</button>
|
|
27
|
-
<div
|
|
43
|
+
<div
|
|
44
|
+
class="rtf-viewer"
|
|
45
|
+
style:font-family={screenFontFamily}
|
|
46
|
+
style:font-size="{screenFontSize}pt"
|
|
47
|
+
>
|
|
28
48
|
{@html htmlContent}
|
|
29
49
|
</div>
|
|
30
50
|
</div>
|
|
@@ -23,6 +23,10 @@
|
|
|
23
23
|
* which is what bounds the RTF payload — it costs two hex
|
|
24
24
|
* characters per byte. 0 disables. Default: 524288 (512 KB,
|
|
25
25
|
* about 1 MB of document per image).
|
|
26
|
+
* fontFamily — Font for the document, both on screen and in the RTF.
|
|
27
|
+
* Default: 'Arial'.
|
|
28
|
+
* fontSize — Body text size in points, both on screen and in the RTF.
|
|
29
|
+
* Headings scale from it. Default: 12.
|
|
26
30
|
*
|
|
27
31
|
* Events (callback props):
|
|
28
32
|
* onchange — Fires on content change with
|
|
@@ -49,7 +53,14 @@
|
|
|
49
53
|
import ImageOverlay from './ImageOverlay.svelte';
|
|
50
54
|
import { htmlToMarkdown, downloadFile } from '../utils.js';
|
|
51
55
|
import { readRtfFile } from '../rtf-parser.js';
|
|
52
|
-
import {
|
|
56
|
+
import {
|
|
57
|
+
htmlToRtf,
|
|
58
|
+
estimateRtfBytes,
|
|
59
|
+
cssFontFamily,
|
|
60
|
+
normalizeFontSize,
|
|
61
|
+
DEFAULT_FONT_FAMILY,
|
|
62
|
+
DEFAULT_FONT_SIZE
|
|
63
|
+
} from '../rtf-writer.js';
|
|
53
64
|
import {
|
|
54
65
|
fileToImageSrc,
|
|
55
66
|
urlToImageSrc,
|
|
@@ -85,6 +96,8 @@
|
|
|
85
96
|
maxImageEdge?: number;
|
|
86
97
|
maxImageBytes?: number;
|
|
87
98
|
maxImageDisplayWidth?: number;
|
|
99
|
+
fontFamily?: string;
|
|
100
|
+
fontSize?: number;
|
|
88
101
|
onchange?: (payload: ChangePayload) => void;
|
|
89
102
|
onsave?: (payload: { html: string }) => void;
|
|
90
103
|
onimport?: (payload: { html: string }) => void;
|
|
@@ -103,11 +116,19 @@
|
|
|
103
116
|
maxImageEdge = DEFAULT_MAX_IMAGE_EDGE,
|
|
104
117
|
maxImageBytes = DEFAULT_MAX_IMAGE_BYTES,
|
|
105
118
|
maxImageDisplayWidth = DEFAULT_MAX_IMAGE_DISPLAY_WIDTH,
|
|
119
|
+
fontFamily = DEFAULT_FONT_FAMILY,
|
|
120
|
+
fontSize = DEFAULT_FONT_SIZE,
|
|
106
121
|
onchange,
|
|
107
122
|
onsave,
|
|
108
123
|
onimport
|
|
109
124
|
}: Props = $props();
|
|
110
125
|
|
|
126
|
+
// The same font and size go to the screen and to the RTF, so the editor
|
|
127
|
+
// shows what the receiving system will show.
|
|
128
|
+
const rtfOptions = $derived({ fontFamily, fontSize });
|
|
129
|
+
const screenFontFamily = $derived(cssFontFamily(fontFamily));
|
|
130
|
+
const screenFontSize = $derived(normalizeFontSize(fontSize));
|
|
131
|
+
|
|
111
132
|
// ── Internal state ──
|
|
112
133
|
let editorEl: HTMLDivElement | null = $state(null);
|
|
113
134
|
let contentWrapEl: HTMLDivElement | null = $state(null);
|
|
@@ -170,7 +191,7 @@
|
|
|
170
191
|
|
|
171
192
|
export function getRTF(): string {
|
|
172
193
|
if (!editorEl) return '';
|
|
173
|
-
return htmlToRtf(editorEl);
|
|
194
|
+
return htmlToRtf(editorEl, rtfOptions);
|
|
174
195
|
}
|
|
175
196
|
|
|
176
197
|
/**
|
|
@@ -182,7 +203,7 @@
|
|
|
182
203
|
*/
|
|
183
204
|
export function getRtfSize(): number {
|
|
184
205
|
if (!editorEl) return 0;
|
|
185
|
-
return htmlToRtf(editorEl).length;
|
|
206
|
+
return htmlToRtf(editorEl, rtfOptions).length;
|
|
186
207
|
}
|
|
187
208
|
|
|
188
209
|
export function clear(): void {
|
|
@@ -215,7 +236,7 @@
|
|
|
215
236
|
break;
|
|
216
237
|
}
|
|
217
238
|
case 'rtf': {
|
|
218
|
-
const rtf = htmlToRtf(editorEl);
|
|
239
|
+
const rtf = htmlToRtf(editorEl, rtfOptions);
|
|
219
240
|
downloadFile('document.rtf', rtf, 'application/rtf');
|
|
220
241
|
break;
|
|
221
242
|
}
|
|
@@ -788,7 +809,12 @@
|
|
|
788
809
|
onchange={handleImageFilesChosen}
|
|
789
810
|
/>
|
|
790
811
|
|
|
791
|
-
<div
|
|
812
|
+
<div
|
|
813
|
+
class="ink-editor"
|
|
814
|
+
style:--ink-min-height={minHeight}
|
|
815
|
+
style:--ink-font-family={screenFontFamily}
|
|
816
|
+
style:--ink-font-size="{screenFontSize}pt"
|
|
817
|
+
>
|
|
792
818
|
{#if showToolbar && !readonly}
|
|
793
819
|
<Toolbar
|
|
794
820
|
{formatState}
|
|
@@ -930,8 +956,8 @@
|
|
|
930
956
|
border-radius: var(--radius, 8px);
|
|
931
957
|
box-shadow: var(--shadow-md, 0 4px 12px rgba(44, 37, 32, 0.08));
|
|
932
958
|
outline: none;
|
|
933
|
-
font-family:
|
|
934
|
-
font-size:
|
|
959
|
+
font-family: var(--ink-font-family, Arial, sans-serif);
|
|
960
|
+
font-size: var(--ink-font-size, 12pt);
|
|
935
961
|
line-height: 1.75;
|
|
936
962
|
color: var(--text, #2c2520);
|
|
937
963
|
caret-color: var(--accent, #d4622b);
|
|
@@ -951,9 +977,10 @@
|
|
|
951
977
|
}
|
|
952
978
|
|
|
953
979
|
/* ── Typography inside editor ── */
|
|
980
|
+
/* Headings use the document font and keep the same ratio to the body text
|
|
981
|
+
as the RTF writer gives them, so the screen matches the RTF. */
|
|
954
982
|
.ink-content :global(h1) {
|
|
955
|
-
font-
|
|
956
|
-
font-size: 38px;
|
|
983
|
+
font-size: 2em;
|
|
957
984
|
line-height: 1.2;
|
|
958
985
|
color: var(--ink, #1a1714);
|
|
959
986
|
margin-bottom: 16px;
|
|
@@ -961,8 +988,7 @@
|
|
|
961
988
|
}
|
|
962
989
|
|
|
963
990
|
.ink-content :global(h2) {
|
|
964
|
-
font-
|
|
965
|
-
font-size: 28px;
|
|
991
|
+
font-size: 1.5em;
|
|
966
992
|
line-height: 1.3;
|
|
967
993
|
color: var(--ink, #1a1714);
|
|
968
994
|
margin-bottom: 12px;
|
|
@@ -970,8 +996,7 @@
|
|
|
970
996
|
}
|
|
971
997
|
|
|
972
998
|
.ink-content :global(h3) {
|
|
973
|
-
font-
|
|
974
|
-
font-size: 20px;
|
|
999
|
+
font-size: calc(7em / 6);
|
|
975
1000
|
font-weight: 600;
|
|
976
1001
|
line-height: 1.4;
|
|
977
1002
|
color: var(--ink, #1a1714);
|
|
@@ -998,16 +1023,17 @@
|
|
|
998
1023
|
color: #e8e2da;
|
|
999
1024
|
padding: 20px 24px;
|
|
1000
1025
|
border-radius: var(--radius, 8px);
|
|
1001
|
-
font
|
|
1002
|
-
font-
|
|
1026
|
+
/* Same font and ratio to the body as the RTF writes code blocks in. */
|
|
1027
|
+
font-family: 'Courier New', monospace;
|
|
1028
|
+
font-size: calc(5em / 6);
|
|
1003
1029
|
line-height: 1.6;
|
|
1004
1030
|
margin: 16px 0;
|
|
1005
1031
|
overflow-x: auto;
|
|
1006
1032
|
}
|
|
1007
1033
|
|
|
1008
1034
|
.ink-content :global(code) {
|
|
1009
|
-
font-family: '
|
|
1010
|
-
font-size:
|
|
1035
|
+
font-family: 'Courier New', monospace;
|
|
1036
|
+
font-size: 1em;
|
|
1011
1037
|
background: var(--surface, #f2f0ec);
|
|
1012
1038
|
padding: 2px 6px;
|
|
1013
1039
|
border-radius: 3px;
|
|
@@ -1056,7 +1082,8 @@
|
|
|
1056
1082
|
|
|
1057
1083
|
.ink-content :global(figcaption) {
|
|
1058
1084
|
margin-top: 8px;
|
|
1059
|
-
|
|
1085
|
+
/* Body size, as the RTF writes captions. */
|
|
1086
|
+
font-size: 1em;
|
|
1060
1087
|
line-height: 1.5;
|
|
1061
1088
|
font-style: italic;
|
|
1062
1089
|
color: var(--text-muted, #8a7e72);
|
|
@@ -1223,11 +1250,5 @@
|
|
|
1223
1250
|
.ink-content {
|
|
1224
1251
|
padding: 28px 24px;
|
|
1225
1252
|
}
|
|
1226
|
-
.ink-content :global(h1) {
|
|
1227
|
-
font-size: 28px;
|
|
1228
|
-
}
|
|
1229
|
-
.ink-content :global(h2) {
|
|
1230
|
-
font-size: 22px;
|
|
1231
|
-
}
|
|
1232
1253
|
}
|
|
1233
1254
|
</style>
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
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 } from './rtf-writer.js';
|
|
4
|
+
export { htmlToRtf, estimateRtfBytes, DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE } from './rtf-writer.js';
|
|
5
|
+
export type { RtfWriteOptions } from './rtf-writer.js';
|
|
5
6
|
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 } from './rtf-writer.js';
|
|
6
|
+
export { htmlToRtf, estimateRtfBytes, DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE } from './rtf-writer.js';
|
|
7
7
|
export { htmlToMarkdown, downloadFile } from './utils.js';
|
package/dist/rtf-parser.js
CHANGED
|
@@ -10,6 +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
14
|
// ── Highlight palette (RTF \highlight 1-16) ──
|
|
14
15
|
const HIGHLIGHT_COLORS = {
|
|
15
16
|
1: '#000000', // Black
|
|
@@ -344,6 +345,62 @@ function parseFontTable(group) {
|
|
|
344
345
|
}
|
|
345
346
|
return fonts;
|
|
346
347
|
}
|
|
348
|
+
// ── Body size ──
|
|
349
|
+
/** Groups whose content is not document text. */
|
|
350
|
+
const destinationWords = new Set([
|
|
351
|
+
'fonttbl', 'colortbl', 'stylesheet', 'info', 'header', 'footer',
|
|
352
|
+
'headerl', 'headerr', 'headerf', 'footerl', 'footerr', 'footerf',
|
|
353
|
+
// \nonshppict duplicates the picture already carried by \*\shppict
|
|
354
|
+
'nonshppict',
|
|
355
|
+
'object', 'fldinst', 'xmlnstbl', 'listtable', 'listoverridetable',
|
|
356
|
+
'rsidtbl', 'generator', 'datafield', 'themedata', 'colorschememapping',
|
|
357
|
+
'latentstyles', 'datastore', 'mmathPr', 'author', 'operator',
|
|
358
|
+
'title', 'subject', 'doccomm', 'company', 'category', 'keywords'
|
|
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
|
+
}
|
|
347
404
|
// ── Renderer ──
|
|
348
405
|
export function rtfToHtml(rtfString) {
|
|
349
406
|
const tokens = tokenize(rtfString);
|
|
@@ -387,6 +444,9 @@ export function rtfToHtml(rtfString) {
|
|
|
387
444
|
}
|
|
388
445
|
}
|
|
389
446
|
}
|
|
447
|
+
// Sizes are shown relative to the body: body text carries no size of its
|
|
448
|
+
// own, and headings are recognized by their ratio to it.
|
|
449
|
+
const bodySize = bodyHalfPoints(rootGroup);
|
|
390
450
|
// State stack for nested groups
|
|
391
451
|
function defaultState() {
|
|
392
452
|
return {
|
|
@@ -424,9 +484,8 @@ export function rtfToHtml(rtfString) {
|
|
|
424
484
|
if (bg)
|
|
425
485
|
styles.push(`background-color:${bg}`);
|
|
426
486
|
}
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
styles.push(`font-size:${ptSize}pt`);
|
|
487
|
+
if (state.fontSize && state.fontSize !== bodySize) {
|
|
488
|
+
styles.push(`font-size:${state.fontSize / 2}pt`);
|
|
430
489
|
}
|
|
431
490
|
if (styles.length === 0)
|
|
432
491
|
return '';
|
|
@@ -459,16 +518,6 @@ export function rtfToHtml(rtfString) {
|
|
|
459
518
|
return result;
|
|
460
519
|
}
|
|
461
520
|
let skipDepth = 0;
|
|
462
|
-
const destinationWords = new Set([
|
|
463
|
-
'fonttbl', 'colortbl', 'stylesheet', 'info', 'header', 'footer',
|
|
464
|
-
'headerl', 'headerr', 'headerf', 'footerl', 'footerr', 'footerf',
|
|
465
|
-
// \nonshppict duplicates the picture already carried by \*\shppict
|
|
466
|
-
'nonshppict',
|
|
467
|
-
'object', 'fldinst', 'xmlnstbl', 'listtable', 'listoverridetable',
|
|
468
|
-
'rsidtbl', 'generator', 'datafield', 'themedata', 'colorschememapping',
|
|
469
|
-
'latentstyles', 'datastore', 'mmathPr', 'author', 'operator',
|
|
470
|
-
'title', 'subject', 'doccomm', 'company', 'category', 'keywords'
|
|
471
|
-
]);
|
|
472
521
|
function flushTable() {
|
|
473
522
|
if (tableRows.length === 0)
|
|
474
523
|
return;
|
|
@@ -834,17 +883,20 @@ export function rtfToHtml(rtfString) {
|
|
|
834
883
|
}
|
|
835
884
|
const sizeMatch = trimmed.match(/font-size:\s*([\d.]+)pt/);
|
|
836
885
|
if (sizeMatch) {
|
|
837
|
-
|
|
886
|
+
// Compared in half-points, rounded the way the writer rounds them, so
|
|
887
|
+
// a heading written at any body size is read back as the same level.
|
|
888
|
+
const halfPoints = Math.round(parseFloat(sizeMatch[1]) * 2);
|
|
838
889
|
const alignAttr = align !== 'left' ? ` style="text-align:${align}"` : '';
|
|
839
|
-
|
|
890
|
+
const atLeast = (scale) => halfPoints >= Math.round(bodySize * scale);
|
|
891
|
+
if (atLeast(HEADING_SCALE.h1)) {
|
|
840
892
|
rendered.push(`<h1${alignAttr}>${stripSizeSpan(trimmed)}</h1>`);
|
|
841
893
|
continue;
|
|
842
894
|
}
|
|
843
|
-
if (
|
|
895
|
+
if (atLeast(HEADING_SCALE.h2)) {
|
|
844
896
|
rendered.push(`<h2${alignAttr}>${stripSizeSpan(trimmed)}</h2>`);
|
|
845
897
|
continue;
|
|
846
898
|
}
|
|
847
|
-
if (
|
|
899
|
+
if (atLeast(HEADING_SCALE.h3)) {
|
|
848
900
|
rendered.push(`<h3${alignAttr}>${stripSizeSpan(trimmed)}</h3>`);
|
|
849
901
|
continue;
|
|
850
902
|
}
|
package/dist/rtf-writer.d.ts
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* HTML to RTF converter
|
|
3
3
|
*
|
|
4
4
|
* Walks a contenteditable DOM tree and produces a valid RTF 1.x string.
|
|
5
|
-
* Supports:
|
|
5
|
+
* Supports: a document font and body size (Arial 12 pt by default, set with
|
|
6
|
+
* RtfWriteOptions), bold, italic, underline, strikethrough, font sizes (h1–h3),
|
|
6
7
|
* foreground colors (\cf), paragraphs, bullet/numbered lists, blockquotes,
|
|
7
8
|
* code blocks, hyperlinks, images (embedded as \pict picture groups with
|
|
8
9
|
* their captions), horizontal rules, line breaks, and Unicode characters.
|
|
@@ -10,6 +11,31 @@
|
|
|
10
11
|
* Background color is intentionally not written — RTF highlight support
|
|
11
12
|
* is inconsistent across viewers (TextEdit uses \AppleHighlight, not \highlight).
|
|
12
13
|
*/
|
|
14
|
+
/** Options for htmlToRtf. */
|
|
15
|
+
export interface RtfWriteOptions {
|
|
16
|
+
/**
|
|
17
|
+
* Font for the whole document: body text, headings, lists and tables.
|
|
18
|
+
* Code keeps a monospace font. Default: Arial.
|
|
19
|
+
*/
|
|
20
|
+
fontFamily?: string;
|
|
21
|
+
/**
|
|
22
|
+
* Body text size in points. Headings scale from it. Default: 12.
|
|
23
|
+
*/
|
|
24
|
+
fontSize?: number;
|
|
25
|
+
}
|
|
26
|
+
/** Font used when no fontFamily is given. */
|
|
27
|
+
export declare const DEFAULT_FONT_FAMILY = "Arial";
|
|
28
|
+
/** Body text size, in points, used when no fontSize is given. */
|
|
29
|
+
export declare const DEFAULT_FONT_SIZE = 12;
|
|
30
|
+
/**
|
|
31
|
+
* Heading sizes as a multiple of the body size: at 12 pt, h1 is 24 pt, h2 18 pt
|
|
32
|
+
* and h3 14 pt. The parser reads headings back with the same ratios.
|
|
33
|
+
*/
|
|
34
|
+
export declare const HEADING_SCALE: {
|
|
35
|
+
readonly h1: 2;
|
|
36
|
+
readonly h2: 1.5;
|
|
37
|
+
readonly h3: number;
|
|
38
|
+
};
|
|
13
39
|
/**
|
|
14
40
|
* Size htmlToRtf would produce, without doing the conversion.
|
|
15
41
|
*
|
|
@@ -24,4 +50,16 @@
|
|
|
24
50
|
* characters are the same number.
|
|
25
51
|
*/
|
|
26
52
|
export declare function estimateRtfBytes(editorEl: HTMLElement): number;
|
|
27
|
-
|
|
53
|
+
/**
|
|
54
|
+
* CSS font-family value for a font name, with the generic fallback that
|
|
55
|
+
* matches it, e.g. "Arial", sans-serif.
|
|
56
|
+
*/
|
|
57
|
+
export declare function cssFontFamily(name: string | undefined): string;
|
|
58
|
+
/**
|
|
59
|
+
* A body size in points, as the RTF will carry it: rounded to the half-point
|
|
60
|
+
* RTF measures in, kept within what readers accept, and the default for
|
|
61
|
+
* missing or nonsense input. The editor and viewer show this same size, so
|
|
62
|
+
* the screen and the RTF agree.
|
|
63
|
+
*/
|
|
64
|
+
export declare function normalizeFontSize(fontSize: number | undefined): number;
|
|
65
|
+
export declare function htmlToRtf(editorEl: HTMLElement, options?: RtfWriteOptions): string;
|
package/dist/rtf-writer.js
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* HTML to RTF converter
|
|
3
3
|
*
|
|
4
4
|
* Walks a contenteditable DOM tree and produces a valid RTF 1.x string.
|
|
5
|
-
* Supports:
|
|
5
|
+
* Supports: a document font and body size (Arial 12 pt by default, set with
|
|
6
|
+
* RtfWriteOptions), bold, italic, underline, strikethrough, font sizes (h1–h3),
|
|
6
7
|
* foreground colors (\cf), paragraphs, bullet/numbered lists, blockquotes,
|
|
7
8
|
* code blocks, hyperlinks, images (embedded as \pict picture groups with
|
|
8
9
|
* their captions), horizontal rules, line breaks, and Unicode characters.
|
|
@@ -10,6 +11,19 @@
|
|
|
10
11
|
* Background color is intentionally not written — RTF highlight support
|
|
11
12
|
* is inconsistent across viewers (TextEdit uses \AppleHighlight, not \highlight).
|
|
12
13
|
*/
|
|
14
|
+
/** Font used when no fontFamily is given. */
|
|
15
|
+
export const DEFAULT_FONT_FAMILY = 'Arial';
|
|
16
|
+
/** Body text size, in points, used when no fontSize is given. */
|
|
17
|
+
export const DEFAULT_FONT_SIZE = 12;
|
|
18
|
+
/** Largest font size RTF readers accept (Word's limit), in points. */
|
|
19
|
+
const MAX_FONT_SIZE = 1638;
|
|
20
|
+
/**
|
|
21
|
+
* Heading sizes as a multiple of the body size: at 12 pt, h1 is 24 pt, h2 18 pt
|
|
22
|
+
* and h3 14 pt. The parser reads headings back with the same ratios.
|
|
23
|
+
*/
|
|
24
|
+
export const HEADING_SCALE = { h1: 2, h2: 1.5, h3: 7 / 6 };
|
|
25
|
+
/** Code blocks are set slightly smaller than the body: 10 pt at 12 pt. */
|
|
26
|
+
const PRE_SCALE = 5 / 6;
|
|
13
27
|
/** 1 px at 96 dpi = 15 twips (1 twip = 1/1440 in). */
|
|
14
28
|
const TWIPS_PER_PX = 15;
|
|
15
29
|
// ── Color helpers ──
|
|
@@ -262,8 +276,11 @@ function base64ByteLength(base64) {
|
|
|
262
276
|
const padding = base64.endsWith('==') ? 2 : base64.endsWith('=') ? 1 : 0;
|
|
263
277
|
return Math.max(0, Math.floor((base64.length * 3) / 4) - padding);
|
|
264
278
|
}
|
|
265
|
-
/**
|
|
266
|
-
|
|
279
|
+
/**
|
|
280
|
+
* Bytes of RTF header, font table and colour table that precede the body, with
|
|
281
|
+
* the default font. Another font name changes it by the difference in length.
|
|
282
|
+
*/
|
|
283
|
+
const RTF_PREAMBLE_BYTES = 165;
|
|
267
284
|
/** Control words wrapping one \pict group. */
|
|
268
285
|
const PICTURE_OVERHEAD_BYTES = 80;
|
|
269
286
|
/**
|
|
@@ -274,9 +291,9 @@ const PICTURE_OVERHEAD_BYTES = 80;
|
|
|
274
291
|
const BLOCK_OVERHEAD_BYTES = {
|
|
275
292
|
p: 14,
|
|
276
293
|
div: 14,
|
|
277
|
-
h1:
|
|
278
|
-
h2:
|
|
279
|
-
h3:
|
|
294
|
+
h1: 29,
|
|
295
|
+
h2: 29,
|
|
296
|
+
h3: 29,
|
|
280
297
|
li: 35,
|
|
281
298
|
blockquote: 22,
|
|
282
299
|
pre: 27,
|
|
@@ -315,8 +332,58 @@ export function estimateRtfBytes(editorEl) {
|
|
|
315
332
|
}
|
|
316
333
|
return total;
|
|
317
334
|
}
|
|
335
|
+
// ── Font helpers ──
|
|
336
|
+
/**
|
|
337
|
+
* A single font name, safe for the font table. A CSS list such as
|
|
338
|
+
* 'Arial, sans-serif' keeps only its first family, since RTF names one font.
|
|
339
|
+
* Anything that would break the table is removed: a name is terminated by ';'
|
|
340
|
+
* and cannot contain group delimiters or control words.
|
|
341
|
+
*/
|
|
342
|
+
function cleanFontName(name) {
|
|
343
|
+
const first = (name ?? '').replace(/["']/g, '').split(',')[0];
|
|
344
|
+
const cleaned = first.replace(/[\\{};]/g, '').trim();
|
|
345
|
+
return cleaned || DEFAULT_FONT_FAMILY;
|
|
346
|
+
}
|
|
347
|
+
/** RTF font family class for a font name: \froman, \fmodern or \fswiss. */
|
|
348
|
+
function fontFamilyClass(name) {
|
|
349
|
+
const n = name.toLowerCase();
|
|
350
|
+
if (/courier|mono|consolas|menlo|monaco|typewriter|lucida console/.test(n))
|
|
351
|
+
return 'modern';
|
|
352
|
+
if (/times|georgia|garamond|cambria|palatino|antiqua|schoolbook|baskerville|didot|bodoni|serif/.test(n) &&
|
|
353
|
+
!/sans/.test(n)) {
|
|
354
|
+
return 'roman';
|
|
355
|
+
}
|
|
356
|
+
return 'swiss';
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* CSS font-family value for a font name, with the generic fallback that
|
|
360
|
+
* matches it, e.g. "Arial", sans-serif.
|
|
361
|
+
*/
|
|
362
|
+
export function cssFontFamily(name) {
|
|
363
|
+
const font = cleanFontName(name);
|
|
364
|
+
const generic = { roman: 'serif', modern: 'monospace', swiss: 'sans-serif' }[fontFamilyClass(font)];
|
|
365
|
+
return `"${font}", ${generic}`;
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* A body size in points, as the RTF will carry it: rounded to the half-point
|
|
369
|
+
* RTF measures in, kept within what readers accept, and the default for
|
|
370
|
+
* missing or nonsense input. The editor and viewer show this same size, so
|
|
371
|
+
* the screen and the RTF agree.
|
|
372
|
+
*/
|
|
373
|
+
export function normalizeFontSize(fontSize) {
|
|
374
|
+
if (typeof fontSize !== 'number' || !Number.isFinite(fontSize) || fontSize <= 0) {
|
|
375
|
+
return DEFAULT_FONT_SIZE;
|
|
376
|
+
}
|
|
377
|
+
return Math.min(MAX_FONT_SIZE, Math.max(1, Math.round(fontSize * 2) / 2));
|
|
378
|
+
}
|
|
379
|
+
/** Body size in RTF half-points. */
|
|
380
|
+
function bodyHalfPoints(fontSize) {
|
|
381
|
+
return normalizeFontSize(fontSize) * 2;
|
|
382
|
+
}
|
|
318
383
|
// ── DOM walker / RTF generator ──
|
|
319
|
-
export function htmlToRtf(editorEl) {
|
|
384
|
+
export function htmlToRtf(editorEl, options = {}) {
|
|
385
|
+
const fontName = cleanFontName(options.fontFamily);
|
|
386
|
+
const bodySize = bodyHalfPoints(options.fontSize);
|
|
320
387
|
const colorMap = new Map();
|
|
321
388
|
colorMap.set('0,0,0', { r: 0, g: 0, b: 0 });
|
|
322
389
|
collectColors(editorEl, colorMap);
|
|
@@ -332,13 +399,15 @@ export function htmlToRtf(editorEl) {
|
|
|
332
399
|
colorTable += `\\red${c.r}\\green${c.g}\\blue${c.b};`;
|
|
333
400
|
}
|
|
334
401
|
colorTable += '}';
|
|
402
|
+
// \f0 is the document font, used for everything except code; \f1 is the
|
|
403
|
+
// monospace font for code.
|
|
335
404
|
const fontTable = '{\\fonttbl' +
|
|
336
|
-
|
|
337
|
-
'{\\f1\\
|
|
338
|
-
'{\\f2\\fmodern\\fcharset0 Courier New;}' +
|
|
405
|
+
`{\\f0\\f${fontFamilyClass(fontName)}\\fcharset0 ${escapeRtf(fontName)};}` +
|
|
406
|
+
'{\\f1\\fmodern\\fcharset0 Courier New;}' +
|
|
339
407
|
'}';
|
|
340
408
|
const body = walkChildren(editorEl, {
|
|
341
409
|
colorIndex,
|
|
410
|
+
bodySize,
|
|
342
411
|
inPre: false,
|
|
343
412
|
listCounter: 0,
|
|
344
413
|
inTableCell: false,
|
|
@@ -348,7 +417,7 @@ export function htmlToRtf(editorEl) {
|
|
|
348
417
|
fontTable +
|
|
349
418
|
colorTable +
|
|
350
419
|
'\\viewkind4\\uc1' +
|
|
351
|
-
|
|
420
|
+
`\\pard\\f0\\fs${bodySize} ` +
|
|
352
421
|
body +
|
|
353
422
|
'}';
|
|
354
423
|
// Backstop for the single-line guarantee. escapeRtf already collapses CR/LF
|
|
@@ -395,14 +464,15 @@ function walkChildren(parent, ctx) {
|
|
|
395
464
|
const el = node;
|
|
396
465
|
const tag = el.tagName.toLowerCase();
|
|
397
466
|
switch (tag) {
|
|
467
|
+
// Heading sizes keep their ratio to the body (HEADING_SCALE).
|
|
398
468
|
case 'h1':
|
|
399
|
-
rtf += `\\pard\\
|
|
469
|
+
rtf += `\\pard\\f0\\fs${scaled(ctx, HEADING_SCALE.h1)}\\b ${walkChildren(el, ctx)}\\b0\\fs${ctx.bodySize}\\par `;
|
|
400
470
|
break;
|
|
401
471
|
case 'h2':
|
|
402
|
-
rtf += `\\pard\\
|
|
472
|
+
rtf += `\\pard\\f0\\fs${scaled(ctx, HEADING_SCALE.h2)}\\b ${walkChildren(el, ctx)}\\b0\\fs${ctx.bodySize}\\par `;
|
|
403
473
|
break;
|
|
404
474
|
case 'h3':
|
|
405
|
-
rtf += `\\pard\\f0\\
|
|
475
|
+
rtf += `\\pard\\f0\\fs${scaled(ctx, HEADING_SCALE.h3)}\\b ${walkChildren(el, ctx)}\\b0\\fs${ctx.bodySize}\\par `;
|
|
406
476
|
break;
|
|
407
477
|
case 'p':
|
|
408
478
|
case 'div': {
|
|
@@ -466,7 +536,7 @@ function walkChildren(parent, ctx) {
|
|
|
466
536
|
rtf += `\\pard\\li720\\i ${walkChildren(el, ctx)}\\i0\\par `;
|
|
467
537
|
break;
|
|
468
538
|
case 'pre':
|
|
469
|
-
rtf += `\\pard\\
|
|
539
|
+
rtf += `\\pard\\f1\\fs${scaled(ctx, PRE_SCALE)} ${walkChildren(el, { ...ctx, inPre: true })}\\f0\\fs${ctx.bodySize}\\par `;
|
|
470
540
|
break;
|
|
471
541
|
case 'hr':
|
|
472
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 `;
|
|
@@ -514,7 +584,7 @@ function walkChildren(parent, ctx) {
|
|
|
514
584
|
break;
|
|
515
585
|
case 'code':
|
|
516
586
|
if (!ctx.inPre) {
|
|
517
|
-
rtf += `\\
|
|
587
|
+
rtf += `\\f1 ${walkChildren(el, ctx)}\\f0 `;
|
|
518
588
|
}
|
|
519
589
|
else {
|
|
520
590
|
rtf += walkChildren(el, ctx);
|
|
@@ -562,7 +632,7 @@ function walkChildren(parent, ctx) {
|
|
|
562
632
|
case 'span': {
|
|
563
633
|
const colorPfx = getColorPrefix(el, ctx);
|
|
564
634
|
const sizePfx = getSizePrefix(el);
|
|
565
|
-
const sizesfx = sizePfx ?
|
|
635
|
+
const sizesfx = sizePfx ? `\\fs${ctx.bodySize} ` : '';
|
|
566
636
|
const colorSfx = colorPfx ? '\\cf0 ' : '';
|
|
567
637
|
rtf += `${colorPfx}${sizePfx}${walkChildren(el, ctx)}${sizesfx}${colorSfx}`;
|
|
568
638
|
break;
|
|
@@ -581,6 +651,10 @@ function walkChildren(parent, ctx) {
|
|
|
581
651
|
}
|
|
582
652
|
return rtf;
|
|
583
653
|
}
|
|
654
|
+
/** Body size times a ratio, in half-points. */
|
|
655
|
+
function scaled(ctx, ratio) {
|
|
656
|
+
return Math.round(ctx.bodySize * ratio);
|
|
657
|
+
}
|
|
584
658
|
function getColorPrefix(el, ctx) {
|
|
585
659
|
// Support both inline style.color (spans) and the color attribute (<font>)
|
|
586
660
|
const color = el.style?.color ||
|
package/package.json
CHANGED