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 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 | Type | Default | Description |
109
- |-----------|----------|---------|------------------------------------------|
110
- | `content` | `string` | — | Raw RTF string to parse and display |
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(html: string): string` | Convert an HTML string to RTF |
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:
@@ -1,7 +1,23 @@
1
1
  <script lang="ts">
2
2
  import { rtfToHtml } from "./rtf-parser.js";
3
-
4
- let { content }: { content: string } = $props();
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 class="rtf-viewer">
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>
@@ -1,5 +1,7 @@
1
1
  type $$ComponentProps = {
2
2
  content: string;
3
+ fontFamily?: string;
4
+ fontSize?: number;
3
5
  };
4
6
  declare const RtfViewer: import("svelte").Component<$$ComponentProps, {}, "">;
5
7
  type RtfViewer = ReturnType<typeof RtfViewer>;
@@ -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 { htmlToRtf, estimateRtfBytes } from '../rtf-writer.js';
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 class="ink-editor" style="--ink-min-height: {minHeight};">
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: 'DM Sans', sans-serif;
934
- font-size: 16px;
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-family: 'Instrument Serif', serif;
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-family: 'Instrument Serif', serif;
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-family: 'DM Sans', sans-serif;
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-family: 'JetBrains Mono', monospace;
1002
- font-size: 14px;
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: 'JetBrains Mono', monospace;
1010
- font-size: 14px;
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
- font-size: 13px;
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>
@@ -21,6 +21,8 @@ interface Props {
21
21
  maxImageEdge?: number;
22
22
  maxImageBytes?: number;
23
23
  maxImageDisplayWidth?: number;
24
+ fontFamily?: string;
25
+ fontSize?: number;
24
26
  onchange?: (payload: ChangePayload) => void;
25
27
  onsave?: (payload: {
26
28
  html: string;
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';
@@ -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
- const ptSize = state.fontSize / 2;
428
- if (ptSize && ptSize !== 12) {
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
- const pt = parseFloat(sizeMatch[1]);
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
- if (pt >= 24) {
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 (pt >= 18) {
895
+ if (atLeast(HEADING_SCALE.h2)) {
844
896
  rendered.push(`<h2${alignAttr}>${stripSizeSpan(trimmed)}</h2>`);
845
897
  continue;
846
898
  }
847
- if (pt >= 14) {
899
+ if (atLeast(HEADING_SCALE.h3)) {
848
900
  rendered.push(`<h3${alignAttr}>${stripSizeSpan(trimmed)}</h3>`);
849
901
  continue;
850
902
  }
@@ -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: bold, italic, underline, strikethrough, font sizes (h1–h3),
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
- export declare function htmlToRtf(editorEl: HTMLElement): string;
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;
@@ -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: bold, italic, underline, strikethrough, font sizes (h1–h3),
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
- /** Bytes of RTF header, font table and colour table that precede the body. */
266
- const RTF_PREAMBLE_BYTES = 200;
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: 32,
278
- h2: 32,
279
- h3: 32,
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
- '{\\f0\\fswiss\\fcharset0 Helvetica;}' +
337
- '{\\f1\\froman\\fcharset0 Georgia;}' +
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
- '\\pard\\f0\\fs24 ' +
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\\f1\\fs48\\b ${walkChildren(el, ctx)}\\b0\\f0\\fs24\\par `;
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\\f1\\fs36\\b ${walkChildren(el, ctx)}\\b0\\f0\\fs24\\par `;
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\\fs28\\b ${walkChildren(el, ctx)}\\b0\\fs24\\par `;
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\\f2\\fs20 ${walkChildren(el, { ...ctx, inPre: true })}\\f0\\fs24\\par `;
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 += `\\f2 ${walkChildren(el, ctx)}\\f0 `;
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 ? '\\fs24 ' : '';
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-rtf-editor",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "RTF viewer and rich-text editor components for Svelte 5",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/jcelano/svelte-rtf-editor#readme",