svelte-rtf-editor 1.0.3 → 1.2.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.
@@ -5,24 +5,22 @@
5
5
  onchange?: (color: string) => void;
6
6
  }
7
7
 
8
- let { value = '#2C2520', title = 'Color', onchange }: Props = $props();
9
-
10
- let currentColor = $state(value);
8
+ let { value = $bindable('#2C2520'), title = 'Color', onchange }: Props = $props();
11
9
 
12
10
  function handleChange(e: Event) {
13
- currentColor = (e.target as HTMLInputElement).value;
14
- onchange?.(currentColor);
11
+ value = (e.target as HTMLInputElement).value;
12
+ onchange?.(value);
15
13
  }
16
14
  </script>
17
15
 
18
16
  <div class="color-picker-wrap">
19
17
  <button class="color-swatch" {title} type="button">
20
- <div class="color-swatch-inner" style="background: {currentColor};"></div>
18
+ <div class="color-swatch-inner" style="background: {value};"></div>
21
19
  </button>
22
20
  <input
23
21
  type="color"
24
22
  class="color-input"
25
- value={currentColor}
23
+ value={value}
26
24
  onchange={handleChange}
27
25
  {title}
28
26
  />
@@ -3,6 +3,6 @@ interface Props {
3
3
  title?: string;
4
4
  onchange?: (color: string) => void;
5
5
  }
6
- declare const ColorPicker: import("svelte").Component<Props, {}, "">;
6
+ declare const ColorPicker: import("svelte").Component<Props, {}, "value">;
7
7
  type ColorPicker = ReturnType<typeof ColorPicker>;
8
8
  export default ColorPicker;
@@ -187,6 +187,13 @@
187
187
  function exec(cmd: string, value: string | null = null): void {
188
188
  restoreSelection();
189
189
  editorEl?.focus();
190
+ // Color commands require an actual text selection — a collapsed cursor
191
+ // would silently apply the color to nothing (or to subsequently typed
192
+ // text), which causes accidental color spans to appear in the RTF output.
193
+ if (cmd === 'foreColor' || cmd === 'hiliteColor') {
194
+ const sel = window.getSelection();
195
+ if (!sel || sel.isCollapsed) return;
196
+ }
190
197
  document.execCommand(cmd, false, value ?? undefined);
191
198
  updateToolbarState();
192
199
  }
@@ -65,7 +65,6 @@
65
65
  <!-- Colors -->
66
66
  <div class="toolbar-group">
67
67
  <ColorPicker value="#2C2520" title="Text color" onchange={(c) => exec('foreColor', c)} />
68
- <ColorPicker value="#FFFBCC" title="Highlight" onchange={(c) => exec('hiliteColor', c)} />
69
68
  </div>
70
69
 
71
70
  <div class="toolbar-divider"></div>
@@ -3,8 +3,11 @@
3
3
  *
4
4
  * Parses RTF control words and groups into an HTML string.
5
5
  * Supports: bold, italic, underline, strikethrough, super/subscript,
6
- * font size, foreground color, color table, font table, paragraphs,
7
- * bullet/numbered lists, Unicode characters, hex escapes, and nested groups.
6
+ * font size, foreground color (\cf), highlight background color (\highlight),
7
+ * color table, font table, paragraphs, bullet/numbered lists,
8
+ * Unicode characters, hex escapes, and nested groups.
9
+ *
10
+ * \cb (character background) is silently ignored on import.
8
11
  */
9
12
  export declare function rtfToHtml(rtfString: string): string;
10
13
  export declare function readRtfFile(file: File): Promise<string>;
@@ -3,9 +3,31 @@
3
3
  *
4
4
  * Parses RTF control words and groups into an HTML string.
5
5
  * Supports: bold, italic, underline, strikethrough, super/subscript,
6
- * font size, foreground color, color table, font table, paragraphs,
7
- * bullet/numbered lists, Unicode characters, hex escapes, and nested groups.
6
+ * font size, foreground color (\cf), highlight background color (\highlight),
7
+ * color table, font table, paragraphs, bullet/numbered lists,
8
+ * Unicode characters, hex escapes, and nested groups.
9
+ *
10
+ * \cb (character background) is silently ignored on import.
8
11
  */
12
+ // ── Highlight palette (RTF \highlight 1-16) ──
13
+ const HIGHLIGHT_COLORS = {
14
+ 1: '#000000', // Black
15
+ 2: '#0000ff', // Blue
16
+ 3: '#00ffff', // Cyan
17
+ 4: '#00ff00', // Green
18
+ 5: '#ff00ff', // Magenta
19
+ 6: '#ff0000', // Red
20
+ 7: '#ffff00', // Yellow
21
+ 8: '#ffffff', // White
22
+ 9: '#000080', // Dark Blue
23
+ 10: '#008080', // Dark Cyan
24
+ 11: '#008000', // Dark Green
25
+ 12: '#800080', // Dark Magenta
26
+ 13: '#800000', // Dark Red
27
+ 14: '#808000', // Dark Yellow
28
+ 15: '#808080', // Dark Gray
29
+ 16: '#c0c0c0', // Light Gray
30
+ };
9
31
  // ── Tokenizer ──
10
32
  function tokenize(rtf) {
11
33
  const tokens = [];
@@ -136,25 +158,40 @@ function buildTree(tokens) {
136
158
  }
137
159
  // ── Color table parser ──
138
160
  function parseColorTable(group) {
139
- const colors = ['']; // index 0 = auto/default (black)
161
+ const colors = ['']; // index 0 = auto/default
140
162
  let r = 0, g = 0, b = 0;
163
+ let hasComponents = false;
164
+ function finalizeEntry() {
165
+ // In standard RTF, a leading ";" represents the auto color (index 0).
166
+ // We already reserve colors[0] for auto, so do not append another entry.
167
+ if (colors.length === 1 && !hasComponents)
168
+ return;
169
+ colors.push(`rgb(${r},${g},${b})`);
170
+ }
141
171
  for (const node of group.children) {
142
172
  if (node.type === 'control') {
143
- if (node.word === 'red')
173
+ if (node.word === 'red') {
144
174
  r = node.param ?? 0;
145
- else if (node.word === 'green')
175
+ hasComponents = true;
176
+ }
177
+ else if (node.word === 'green') {
146
178
  g = node.param ?? 0;
147
- else if (node.word === 'blue')
179
+ hasComponents = true;
180
+ }
181
+ else if (node.word === 'blue') {
148
182
  b = node.param ?? 0;
183
+ hasComponents = true;
184
+ }
149
185
  }
150
186
  else if (node.type === 'text' && node.value.includes(';')) {
151
187
  // Each ';' terminates one color entry
152
188
  const semiCount = (node.value.match(/;/g) || []).length;
153
189
  for (let i = 0; i < semiCount; i++) {
154
- colors.push(`rgb(${r},${g},${b})`);
190
+ finalizeEntry();
155
191
  r = 0;
156
192
  g = 0;
157
193
  b = 0;
194
+ hasComponents = false;
158
195
  }
159
196
  }
160
197
  }
@@ -193,6 +230,12 @@ export function rtfToHtml(rtfString) {
193
230
  let paragraphs = [];
194
231
  let paragraphHasText = false;
195
232
  let paragraphHasListMarker = false;
233
+ // Table state
234
+ let tableRows = [];
235
+ let currentRowCells = [];
236
+ let currentRowIsHeader = false;
237
+ let currentRowCellRights = [];
238
+ let inTableCell = false;
196
239
  // First pass: extract color table and font table from the root group
197
240
  const rootGroup = tree.children[0]?.type === 'group' ? tree.children[0] : tree;
198
241
  for (let i = 0; i < rootGroup.children.length; i++) {
@@ -218,10 +261,10 @@ export function rtfToHtml(rtfString) {
218
261
  sub: false,
219
262
  fontSize: 24, // 12pt default
220
263
  colorIndex: 0,
264
+ highlightIndex: 0,
221
265
  fontIndex: 0,
222
266
  ucSkip: 1,
223
267
  inList: false,
224
- listType: null
225
268
  };
226
269
  }
227
270
  const stateStack = [defaultState()];
@@ -239,6 +282,11 @@ export function rtfToHtml(rtfString) {
239
282
  styles.push(`color:${color}`);
240
283
  }
241
284
  }
285
+ if (state.highlightIndex > 0) {
286
+ const bg = HIGHLIGHT_COLORS[state.highlightIndex];
287
+ if (bg)
288
+ styles.push(`background-color:${bg}`);
289
+ }
242
290
  const ptSize = state.fontSize / 2;
243
291
  if (ptSize && ptSize !== 12) {
244
292
  styles.push(`font-size:${ptSize}pt`);
@@ -282,7 +330,32 @@ export function rtfToHtml(rtfString) {
282
330
  'latentstyles', 'datastore', 'mmathPr', 'author', 'operator',
283
331
  'title', 'subject', 'doccomm', 'company', 'category', 'keywords'
284
332
  ]);
333
+ function flushTable() {
334
+ if (tableRows.length === 0)
335
+ return;
336
+ let html = '<table style="border-collapse:collapse;width:100%;margin:4px 0">';
337
+ for (const row of tableRows) {
338
+ html += '<tr>';
339
+ const tag = row.isHeader ? 'th' : 'td';
340
+ const total = row.cellRights[row.cellRights.length - 1] || 1;
341
+ let prevRight = 0;
342
+ for (let ci = 0; ci < row.cells.length; ci++) {
343
+ const right = row.cellRights[ci] ?? total;
344
+ const pct = ((right - prevRight) / total * 100).toFixed(1);
345
+ prevRight = right;
346
+ const style = row.isHeader
347
+ ? `border-bottom:1px solid;padding:2px 6px;text-align:left;width:${pct}%`
348
+ : `padding:2px 6px;width:${pct}%`;
349
+ html += `<${tag} style="${style}">${row.cells[ci] || ''}</${tag}>`;
350
+ }
351
+ html += '</tr>';
352
+ }
353
+ html += '</table>';
354
+ paragraphs.push(html);
355
+ tableRows = [];
356
+ }
285
357
  function flushParagraph() {
358
+ flushTable();
286
359
  const trimmed = currentParagraph.trim();
287
360
  if (!trimmed) {
288
361
  if (!paragraphHasListMarker) {
@@ -361,12 +434,56 @@ export function rtfToHtml(rtfString) {
361
434
  if (p != null)
362
435
  state.colorIndex = p;
363
436
  break;
437
+ case 'cb':
438
+ break; // \cb not rendered on import
439
+ case 'highlight':
440
+ if (p != null)
441
+ state.highlightIndex = p;
442
+ break;
364
443
  case 'f':
365
444
  if (p != null)
366
445
  state.fontIndex = p;
367
446
  break;
368
447
  case 'par':
369
- flushParagraph();
448
+ if (!inTableCell)
449
+ flushParagraph();
450
+ break;
451
+ case 'trowd':
452
+ currentRowCells = [];
453
+ currentRowIsHeader = false;
454
+ currentRowCellRights = [];
455
+ break;
456
+ case 'trgaph':
457
+ case 'trkeep':
458
+ case 'trleft':
459
+ case 'trhdr':
460
+ case 'brdrs':
461
+ case 'brdrnil':
462
+ case 'clbrdrr':
463
+ case 'clbrdrl':
464
+ case 'clbrdrt':
465
+ break; // table formatting, ignored
466
+ case 'clbrdrb':
467
+ currentRowIsHeader = true;
468
+ break;
469
+ case 'cellx':
470
+ if (p != null)
471
+ currentRowCellRights.push(p);
472
+ break;
473
+ case 'intbl':
474
+ inTableCell = true;
475
+ break;
476
+ case 'cell':
477
+ currentRowCells.push(currentParagraph.trim());
478
+ currentParagraph = '';
479
+ paragraphHasText = false;
480
+ break;
481
+ case 'row':
482
+ tableRows.push({ cells: currentRowCells, isHeader: currentRowIsHeader, cellRights: currentRowCellRights });
483
+ currentRowCells = [];
484
+ currentRowIsHeader = false;
485
+ currentRowCellRights = [];
486
+ inTableCell = false;
370
487
  break;
371
488
  case 'pard':
372
489
  state.bold = false;
@@ -412,6 +529,8 @@ export function rtfToHtml(rtfString) {
412
529
  state.sub = false;
413
530
  state.fontSize = 24;
414
531
  state.colorIndex = 0;
532
+ state.highlightIndex = 0;
533
+ state.fontIndex = 0;
415
534
  break;
416
535
  case 'page':
417
536
  flushParagraph();
@@ -476,6 +595,7 @@ export function rtfToHtml(rtfString) {
476
595
  }
477
596
  }
478
597
  walk(rootGroup.children);
598
+ flushTable(); // flush any trailing table rows
479
599
  // Only flush if there's actually pending content — the final \par in most RTF
480
600
  // files already flushed the last paragraph, so an unconditional call here
481
601
  // would push a spurious empty paragraph that becomes a trailing <p><br></p>.
@@ -505,6 +625,10 @@ export function rtfToHtml(rtfString) {
505
625
  rendered.push('<hr>');
506
626
  continue;
507
627
  }
628
+ if (trimmed.startsWith('<table')) {
629
+ rendered.push(trimmed);
630
+ continue;
631
+ }
508
632
  const sizeMatch = trimmed.match(/font-size:\s*([\d.]+)pt/);
509
633
  if (sizeMatch) {
510
634
  const pt = parseFloat(sizeMatch[1]);
@@ -524,9 +648,8 @@ export function rtfToHtml(rtfString) {
524
648
  rendered.push(`<p>${trimmed}</p>`);
525
649
  }
526
650
  const output = rendered.join('\n');
527
- // Suppress unused variable warnings
651
+ // fontTable is parsed (for future font rendering) but not yet used in output
528
652
  void fontTable;
529
- void paragraphHasText;
530
653
  return output || '<p></p>';
531
654
  }
532
655
  function stripSizeSpan(html) {
@@ -2,8 +2,12 @@
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 (headings),
6
- * foreground colors, paragraphs, bullet lists, numbered lists, blockquotes,
7
- * code blocks, links, horizontal rules, line breaks, and inline styles.
5
+ * Supports: bold, italic, underline, strikethrough, font sizes (h1–h3),
6
+ * foreground colors (\cf), paragraphs, bullet/numbered lists, blockquotes,
7
+ * code blocks, hyperlinks, images (as alt-text placeholders), horizontal
8
+ * rules, line breaks, and Unicode characters.
9
+ *
10
+ * Background color is intentionally not written — RTF highlight support
11
+ * is inconsistent across viewers (TextEdit uses \AppleHighlight, not \highlight).
8
12
  */
9
13
  export declare function htmlToRtf(editorEl: HTMLElement): string;
@@ -2,9 +2,13 @@
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 (headings),
6
- * foreground colors, paragraphs, bullet lists, numbered lists, blockquotes,
7
- * code blocks, links, horizontal rules, line breaks, and inline styles.
5
+ * Supports: bold, italic, underline, strikethrough, font sizes (h1–h3),
6
+ * foreground colors (\cf), paragraphs, bullet/numbered lists, blockquotes,
7
+ * code blocks, hyperlinks, images (as alt-text placeholders), horizontal
8
+ * rules, line breaks, and Unicode characters.
9
+ *
10
+ * Background color is intentionally not written — RTF highlight support
11
+ * is inconsistent across viewers (TextEdit uses \AppleHighlight, not \highlight).
8
12
  */
9
13
  // ── Color helpers ──
10
14
  function parseColor(colorStr) {
@@ -77,7 +81,9 @@ export function htmlToRtf(editorEl) {
77
81
  colors.forEach((c, i) => {
78
82
  colorIndex.set(colorKey(c), i + 1);
79
83
  });
80
- let colorTable = '{\\colortbl ;';
84
+ // Emit a leading auto/default color entry for broad RTF compatibility:
85
+ // \cf0 = auto, first explicit color starts at \cf1.
86
+ let colorTable = '{\\colortbl;';
81
87
  for (const c of colors) {
82
88
  colorTable += `\\red${c.r}\\green${c.g}\\blue${c.b};`;
83
89
  }
@@ -87,7 +93,7 @@ export function htmlToRtf(editorEl) {
87
93
  '{\\f1\\froman\\fcharset0 Georgia;}' +
88
94
  '{\\f2\\fmodern\\fcharset0 Courier New;}' +
89
95
  '}';
90
- const body = walkChildren(editorEl, { colorIndex, inPre: false, listCounter: 0 });
96
+ const body = walkChildren(editorEl, { colorIndex, inPre: false, listCounter: 0, inTableCell: false });
91
97
  const rtf = '{\\rtf1\\ansi\\ansicpg1252\\deff0' +
92
98
  fontTable +
93
99
  colorTable +
@@ -97,20 +103,20 @@ export function htmlToRtf(editorEl) {
97
103
  '}';
98
104
  return rtf;
99
105
  }
106
+ function addColor(colorStr, colorMap) {
107
+ if (!colorStr)
108
+ return;
109
+ const c = parseColor(colorStr);
110
+ if (c)
111
+ colorMap.set(colorKey(c), c);
112
+ }
100
113
  function collectColors(el, colorMap) {
101
- if (el.style?.color) {
102
- const c = parseColor(el.style.color);
103
- if (c)
104
- colorMap.set(colorKey(c), c);
105
- }
106
- if (el.style?.backgroundColor) {
107
- const c = parseColor(el.style.backgroundColor);
108
- if (c)
109
- colorMap.set(colorKey(c), c);
110
- }
111
- for (const child of el.children) {
114
+ addColor(el.style?.color, colorMap);
115
+ // <font color="..."> is produced by execCommand('foreColor')
116
+ if (el.tagName?.toLowerCase() === 'font')
117
+ addColor(el.getAttribute('color'), colorMap);
118
+ for (const child of el.children)
112
119
  collectColors(child, colorMap);
113
- }
114
120
  }
115
121
  function walkChildren(parent, ctx) {
116
122
  let rtf = '';
@@ -143,6 +149,11 @@ function walkChildren(parent, ctx) {
143
149
  break;
144
150
  case 'p':
145
151
  case 'div': {
152
+ // Inside a table cell \pard is already declared; just emit inline content.
153
+ if (ctx.inTableCell) {
154
+ rtf += walkChildren(el, ctx);
155
+ break;
156
+ }
146
157
  // A <p> with no children or only a single <br> is a blank line —
147
158
  // emit a bare \par rather than \pard \line\par, which adds an extra line.
148
159
  const isBlankLine = el.childNodes.length === 0 ||
@@ -157,6 +168,43 @@ function walkChildren(parent, ctx) {
157
168
  rtf += `\\pard ${colorPrefix}${inlineRtf}${colorSuffix}\\par\n`;
158
169
  break;
159
170
  }
171
+ case 'table': {
172
+ const PAGE_WIDTH = 8640; // twips ≈ 6 in (standard letter body width)
173
+ const rows = Array.from(el.querySelectorAll(':scope > tr, :scope > thead > tr, :scope > tbody > tr, :scope > tfoot > tr'));
174
+ for (const row of rows) {
175
+ const cells = Array.from(row.querySelectorAll(':scope > td, :scope > th'));
176
+ if (cells.length === 0)
177
+ continue;
178
+ const isHeader = cells.some(c => c.tagName.toLowerCase() === 'th');
179
+ // Derive right-edge twip positions from the width:X% style set by the parser.
180
+ // Fall back to equal column widths if the style is absent.
181
+ const cellRights = [];
182
+ let cumTwips = 0;
183
+ for (const cell of cells) {
184
+ const pct = parseFloat(cell.style.width) || (100 / cells.length);
185
+ cumTwips += Math.round((pct / 100) * PAGE_WIDTH);
186
+ cellRights.push(cumTwips);
187
+ }
188
+ cellRights[cellRights.length - 1] = PAGE_WIDTH; // snap last edge to avoid rounding drift
189
+ // Row definition
190
+ let rowRtf = '{\n\\trowd \\trgaph120\n';
191
+ for (let ci = 0; ci < cells.length; ci++) {
192
+ if (isHeader)
193
+ rowRtf += `\\clbrdrb\\brdrs`;
194
+ rowRtf += `\\cellx${cellRights[ci]}`;
195
+ }
196
+ rowRtf += '\n\\trkeep\\intbl\n{';
197
+ // Cell bodies
198
+ const cellCtx = { ...ctx, inTableCell: true };
199
+ for (const cell of cells) {
200
+ const content = walkChildren(cell, cellCtx);
201
+ rowRtf += `{\\pard\\intbl \\f0 \\sa0 \\li0 \\fi0 ${content}\\par}\n\\cell`;
202
+ }
203
+ rowRtf += '}\n\\intbl\\row}\n';
204
+ rtf += rowRtf;
205
+ }
206
+ break;
207
+ }
160
208
  case 'blockquote':
161
209
  rtf += `\\pard\\li720\\i ${walkChildren(el, ctx)}\\i0\\par\n`;
162
210
  break;
@@ -244,6 +292,13 @@ function walkChildren(parent, ctx) {
244
292
  rtf += `${colorPfx}${sizePfx}${walkChildren(el, ctx)}${sizesfx}${colorSfx}`;
245
293
  break;
246
294
  }
295
+ // <font color="..."> is produced by execCommand('foreColor') in contenteditable
296
+ case 'font': {
297
+ const colorPfx = getColorPrefix(el, ctx);
298
+ const colorSfx = colorPfx ? '\\cf0 ' : '';
299
+ rtf += `${colorPfx}${walkChildren(el, ctx)}${colorSfx}`;
300
+ break;
301
+ }
247
302
  default:
248
303
  rtf += walkChildren(el, ctx);
249
304
  break;
@@ -252,7 +307,9 @@ function walkChildren(parent, ctx) {
252
307
  return rtf;
253
308
  }
254
309
  function getColorPrefix(el, ctx) {
255
- const color = el.style?.color;
310
+ // Support both inline style.color (spans) and the color attribute (<font>)
311
+ const color = el.style?.color ||
312
+ (el.tagName.toLowerCase() === 'font' ? (el.getAttribute('color') ?? '') : '');
256
313
  if (!color)
257
314
  return '';
258
315
  const c = parseColor(color);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-rtf-editor",
3
- "version": "1.0.3",
3
+ "version": "1.2.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",
@@ -45,13 +45,14 @@
45
45
  },
46
46
  "devDependencies": {
47
47
  "@changesets/cli": "^2.30.0",
48
- "@sveltejs/adapter-auto": "^7.0.0",
49
- "@sveltejs/kit": "^2.50.2",
48
+ "@sveltejs/adapter-auto": "^7.0.1",
49
+ "@sveltejs/kit": "^2.53.4",
50
50
  "@sveltejs/package": "^2.5.7",
51
51
  "@sveltejs/vite-plugin-svelte": "^6.2.4",
52
- "publint": "^0.3.17",
53
- "svelte": "^5.51.0",
54
- "svelte-check": "^4.4.2",
52
+ "happy-dom": "^20.8.3",
53
+ "publint": "^0.3.18",
54
+ "svelte": "^5.53.7",
55
+ "svelte-check": "^4.4.4",
55
56
  "typescript": "^5.9.3",
56
57
  "vite": "^7.3.1",
57
58
  "vitest": "^4.0.18"