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.
- package/dist/components/ColorPicker.svelte +5 -7
- package/dist/components/ColorPicker.svelte.d.ts +1 -1
- package/dist/components/InkEditor.svelte +7 -0
- package/dist/components/Toolbar.svelte +0 -1
- package/dist/rtf-parser.d.ts +5 -2
- package/dist/rtf-parser.js +134 -11
- package/dist/rtf-writer.d.ts +7 -3
- package/dist/rtf-writer.js +75 -18
- package/package.json +7 -6
|
@@ -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
|
-
|
|
14
|
-
onchange?.(
|
|
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: {
|
|
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={
|
|
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>
|
package/dist/rtf-parser.d.ts
CHANGED
|
@@ -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,
|
|
7
|
-
*
|
|
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>;
|
package/dist/rtf-parser.js
CHANGED
|
@@ -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,
|
|
7
|
-
*
|
|
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
|
|
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
|
-
|
|
175
|
+
hasComponents = true;
|
|
176
|
+
}
|
|
177
|
+
else if (node.word === 'green') {
|
|
146
178
|
g = node.param ?? 0;
|
|
147
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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) {
|
package/dist/rtf-writer.d.ts
CHANGED
|
@@ -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 (
|
|
6
|
-
* foreground colors, paragraphs, bullet
|
|
7
|
-
* code blocks,
|
|
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;
|
package/dist/rtf-writer.js
CHANGED
|
@@ -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 (
|
|
6
|
-
* foreground colors, paragraphs, bullet
|
|
7
|
-
* code blocks,
|
|
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
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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
|
+
"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.
|
|
49
|
-
"@sveltejs/kit": "^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
|
-
"
|
|
53
|
-
"
|
|
54
|
-
"svelte
|
|
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"
|