svelte-rtf-editor 0.1.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.
@@ -0,0 +1,553 @@
1
+ /**
2
+ * RTF to HTML converter
3
+ *
4
+ * Parses RTF control words and groups into an HTML string.
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.
8
+ */
9
+ // ── Tokenizer ──
10
+ function tokenize(rtf) {
11
+ const tokens = [];
12
+ let i = 0;
13
+ const len = rtf.length;
14
+ while (i < len) {
15
+ const ch = rtf[i];
16
+ if (ch === '{') {
17
+ tokens.push({ type: 'open' });
18
+ i++;
19
+ }
20
+ else if (ch === '}') {
21
+ tokens.push({ type: 'close' });
22
+ i++;
23
+ }
24
+ else if (ch === '\\') {
25
+ i++;
26
+ if (i >= len)
27
+ break;
28
+ const next = rtf[i];
29
+ // Hex escape \'xx
30
+ if (next === "'") {
31
+ i++;
32
+ const hex = rtf.substring(i, i + 2);
33
+ i += 2;
34
+ const code = parseInt(hex, 16);
35
+ tokens.push({ type: 'text', value: String.fromCharCode(code) });
36
+ }
37
+ // Special escapes
38
+ else if (next === '\\' || next === '{' || next === '}') {
39
+ tokens.push({ type: 'text', value: next });
40
+ i++;
41
+ }
42
+ // Line breaks as control
43
+ else if (next === '\n' || next === '\r') {
44
+ tokens.push({ type: 'control', word: 'par', param: null });
45
+ i++;
46
+ if (i < len && rtf[i] === '\n')
47
+ i++;
48
+ }
49
+ // Tilde = non-breaking space
50
+ else if (next === '~') {
51
+ tokens.push({ type: 'text', value: '\u00A0' });
52
+ i++;
53
+ }
54
+ // Hyphen shortcuts
55
+ else if (next === '-') {
56
+ tokens.push({ type: 'text', value: '\u00AD' }); // soft hyphen
57
+ i++;
58
+ }
59
+ else if (next === '_') {
60
+ tokens.push({ type: 'text', value: '\u2011' }); // non-breaking hyphen
61
+ i++;
62
+ }
63
+ // Control word or control symbol
64
+ else if (/[a-zA-Z]/.test(next)) {
65
+ let word = '';
66
+ while (i < len && /[a-zA-Z]/.test(rtf[i])) {
67
+ word += rtf[i];
68
+ i++;
69
+ }
70
+ // Optional numeric parameter (can be negative)
71
+ let param = null;
72
+ if (i < len && (rtf[i] === '-' || /[0-9]/.test(rtf[i]))) {
73
+ let numStr = '';
74
+ if (rtf[i] === '-') {
75
+ numStr += '-';
76
+ i++;
77
+ }
78
+ while (i < len && /[0-9]/.test(rtf[i])) {
79
+ numStr += rtf[i];
80
+ i++;
81
+ }
82
+ param = parseInt(numStr, 10);
83
+ }
84
+ // Consume optional trailing space delimiter
85
+ if (i < len && rtf[i] === ' ')
86
+ i++;
87
+ tokens.push({ type: 'control', word, param });
88
+ }
89
+ // Control symbol (single non-alpha char)
90
+ else {
91
+ tokens.push({ type: 'control', word: next, param: null });
92
+ i++;
93
+ }
94
+ }
95
+ else if (ch === '\r' || ch === '\n') {
96
+ // Skip bare newlines (they're not meaningful in RTF)
97
+ i++;
98
+ }
99
+ else {
100
+ // Plain text - gather until next special char
101
+ let text = '';
102
+ while (i < len && rtf[i] !== '\\' && rtf[i] !== '{' && rtf[i] !== '}' && rtf[i] !== '\r' && rtf[i] !== '\n') {
103
+ text += rtf[i];
104
+ i++;
105
+ }
106
+ if (text) {
107
+ tokens.push({ type: 'text', value: text });
108
+ }
109
+ }
110
+ }
111
+ return tokens;
112
+ }
113
+ // ── AST Builder ──
114
+ function buildTree(tokens) {
115
+ const root = { type: 'group', children: [] };
116
+ const stack = [root];
117
+ for (const tok of tokens) {
118
+ const current = stack[stack.length - 1];
119
+ if (tok.type === 'open') {
120
+ const group = { type: 'group', children: [] };
121
+ current.children.push(group);
122
+ stack.push(group);
123
+ }
124
+ else if (tok.type === 'close') {
125
+ if (stack.length > 1)
126
+ stack.pop();
127
+ }
128
+ else if (tok.type === 'control') {
129
+ current.children.push({ type: 'control', word: tok.word, param: tok.param ?? null });
130
+ }
131
+ else if (tok.type === 'text') {
132
+ current.children.push({ type: 'text', value: tok.value });
133
+ }
134
+ }
135
+ return root;
136
+ }
137
+ // ── Color table parser ──
138
+ function parseColorTable(group) {
139
+ const colors = ['']; // index 0 = auto/default (black)
140
+ let r = 0, g = 0, b = 0;
141
+ for (const node of group.children) {
142
+ if (node.type === 'control') {
143
+ if (node.word === 'red')
144
+ r = node.param ?? 0;
145
+ else if (node.word === 'green')
146
+ g = node.param ?? 0;
147
+ else if (node.word === 'blue')
148
+ b = node.param ?? 0;
149
+ }
150
+ else if (node.type === 'text' && node.value.includes(';')) {
151
+ // Semicolons delimit color entries; there may be multiple in one text node
152
+ const parts = node.value.split(';');
153
+ for (let i = 0; i < parts.length; i++) {
154
+ if (i < parts.length - 1 || node.value.endsWith(';')) {
155
+ colors.push(`rgb(${r},${g},${b})`);
156
+ r = 0;
157
+ g = 0;
158
+ b = 0;
159
+ }
160
+ }
161
+ }
162
+ }
163
+ return colors;
164
+ }
165
+ // ── Font table parser ──
166
+ function parseFontTable(group) {
167
+ const fonts = new Map();
168
+ for (const child of group.children) {
169
+ if (child.type === 'group') {
170
+ let fNum = 0;
171
+ let fName = '';
172
+ for (const node of child.children) {
173
+ if (node.type === 'control' && node.word === 'f' && node.param != null) {
174
+ fNum = node.param;
175
+ }
176
+ if (node.type === 'text') {
177
+ fName += node.value;
178
+ }
179
+ }
180
+ // Remove trailing semicolons
181
+ fName = fName.replace(/;$/, '').trim();
182
+ if (fName)
183
+ fonts.set(fNum, fName);
184
+ }
185
+ }
186
+ return fonts;
187
+ }
188
+ // ── Renderer ──
189
+ export function rtfToHtml(rtfString) {
190
+ const tokens = tokenize(rtfString);
191
+ const tree = buildTree(tokens);
192
+ let colorTable = [''];
193
+ let fontTable = new Map();
194
+ let currentParagraph = '';
195
+ let paragraphs = [];
196
+ let paragraphHasText = false;
197
+ let paragraphHasListMarker = false;
198
+ // First pass: extract color table and font table from the root group
199
+ const rootGroup = tree.children[0]?.type === 'group' ? tree.children[0] : tree;
200
+ for (let i = 0; i < rootGroup.children.length; i++) {
201
+ const child = rootGroup.children[i];
202
+ if (child.type === 'group') {
203
+ const firstControl = child.children.find((n) => n.type === 'control');
204
+ if (firstControl?.word === 'colortbl') {
205
+ colorTable = parseColorTable(child);
206
+ }
207
+ else if (firstControl?.word === 'fonttbl') {
208
+ fontTable = parseFontTable(child);
209
+ }
210
+ }
211
+ }
212
+ // State stack for nested groups
213
+ function defaultState() {
214
+ return {
215
+ bold: false,
216
+ italic: false,
217
+ underline: false,
218
+ strike: false,
219
+ super_: false,
220
+ sub: false,
221
+ fontSize: 24, // 12pt default
222
+ colorIndex: 0,
223
+ fontIndex: 0,
224
+ ucSkip: 1,
225
+ inList: false,
226
+ listType: null
227
+ };
228
+ }
229
+ const stateStack = [defaultState()];
230
+ function currentState() {
231
+ return stateStack[stateStack.length - 1];
232
+ }
233
+ function cloneState() {
234
+ return { ...currentState() };
235
+ }
236
+ function buildSpanOpen(state) {
237
+ const styles = [];
238
+ if (state.colorIndex > 0 && state.colorIndex < colorTable.length) {
239
+ const color = colorTable[state.colorIndex];
240
+ if (color && color !== 'rgb(0,0,0)') {
241
+ styles.push(`color:${color}`);
242
+ }
243
+ }
244
+ const ptSize = state.fontSize / 2;
245
+ if (ptSize && ptSize !== 12) {
246
+ styles.push(`font-size:${ptSize}pt`);
247
+ }
248
+ if (styles.length === 0)
249
+ return '';
250
+ return `<span style="${styles.join(';')}">`;
251
+ }
252
+ function wrapText(text, state) {
253
+ if (!text)
254
+ return '';
255
+ let result = text
256
+ .replace(/&/g, '&amp;')
257
+ .replace(/</g, '&lt;')
258
+ .replace(/>/g, '&gt;');
259
+ // Apply span styles
260
+ const spanOpen = buildSpanOpen(state);
261
+ if (spanOpen) {
262
+ result = `${spanOpen}${result}</span>`;
263
+ }
264
+ if (state.bold)
265
+ result = `<strong>${result}</strong>`;
266
+ if (state.italic)
267
+ result = `<em>${result}</em>`;
268
+ if (state.underline)
269
+ result = `<u>${result}</u>`;
270
+ if (state.strike)
271
+ result = `<s>${result}</s>`;
272
+ if (state.super_)
273
+ result = `<sup>${result}</sup>`;
274
+ if (state.sub)
275
+ result = `<sub>${result}</sub>`;
276
+ return result;
277
+ }
278
+ let skipDepth = 0;
279
+ const destinationWords = new Set([
280
+ 'fonttbl', 'colortbl', 'stylesheet', 'info', 'header', 'footer',
281
+ 'headerl', 'headerr', 'headerf', 'footerl', 'footerr', 'footerf',
282
+ 'pict', 'object', 'fldinst', 'xmlnstbl', 'listtable', 'listoverridetable',
283
+ 'rsidtbl', 'generator', 'datafield', 'themedata', 'colorschememapping',
284
+ 'latentstyles', 'datastore', 'mmathPr', 'author', 'operator',
285
+ 'title', 'subject', 'doccomm', 'company', 'category', 'keywords'
286
+ ]);
287
+ function flushParagraph() {
288
+ const trimmed = currentParagraph.trim();
289
+ if (!trimmed) {
290
+ if (!paragraphHasListMarker) {
291
+ paragraphs.push(currentParagraph);
292
+ }
293
+ }
294
+ else {
295
+ paragraphs.push(currentParagraph);
296
+ }
297
+ currentParagraph = '';
298
+ paragraphHasText = false;
299
+ paragraphHasListMarker = false;
300
+ }
301
+ let pendingUnicodeSkip = 0;
302
+ function walk(nodes) {
303
+ for (let i = 0; i < nodes.length; i++) {
304
+ const node = nodes[i];
305
+ if (skipDepth > 0 && node.type !== 'group') {
306
+ continue;
307
+ }
308
+ if (node.type === 'group') {
309
+ if (skipDepth > 0) {
310
+ skipDepth++;
311
+ walk(node.children);
312
+ skipDepth--;
313
+ continue;
314
+ }
315
+ const firstCtrl = node.children.find((n) => n.type === 'control');
316
+ if (firstCtrl && destinationWords.has(firstCtrl.word)) {
317
+ continue;
318
+ }
319
+ const hasStarDest = node.children.length >= 2 &&
320
+ node.children[0].type === 'control' && node.children[0].word === '*';
321
+ if (hasStarDest) {
322
+ continue;
323
+ }
324
+ stateStack.push(cloneState());
325
+ walk(node.children);
326
+ stateStack.pop();
327
+ }
328
+ else if (node.type === 'control') {
329
+ const state = currentState();
330
+ const w = node.word;
331
+ const p = node.param;
332
+ switch (w) {
333
+ case 'b':
334
+ state.bold = p !== 0;
335
+ break;
336
+ case 'i':
337
+ state.italic = p !== 0;
338
+ break;
339
+ case 'ul':
340
+ case 'ulnone':
341
+ state.underline = w === 'ul' && p !== 0;
342
+ break;
343
+ case 'strike':
344
+ state.strike = p !== 0;
345
+ break;
346
+ case 'super':
347
+ state.super_ = true;
348
+ state.sub = false;
349
+ break;
350
+ case 'sub':
351
+ state.sub = true;
352
+ state.super_ = false;
353
+ break;
354
+ case 'nosupersub':
355
+ state.super_ = false;
356
+ state.sub = false;
357
+ break;
358
+ case 'fs':
359
+ if (p != null)
360
+ state.fontSize = p;
361
+ break;
362
+ case 'cf':
363
+ if (p != null)
364
+ state.colorIndex = p;
365
+ break;
366
+ case 'f':
367
+ if (p != null)
368
+ state.fontIndex = p;
369
+ break;
370
+ case 'par':
371
+ flushParagraph();
372
+ break;
373
+ case 'pard':
374
+ state.bold = false;
375
+ state.italic = false;
376
+ state.underline = false;
377
+ state.strike = false;
378
+ break;
379
+ case 'line':
380
+ currentParagraph += '<br>';
381
+ paragraphHasText = true;
382
+ break;
383
+ case 'tab':
384
+ currentParagraph += '&emsp;';
385
+ paragraphHasText = true;
386
+ break;
387
+ case 'pntext':
388
+ case 'listtext':
389
+ state.inList = true;
390
+ paragraphHasListMarker = true;
391
+ break;
392
+ case 'u': {
393
+ if (p != null) {
394
+ let codePoint = p;
395
+ if (codePoint < 0)
396
+ codePoint += 65536;
397
+ currentParagraph += wrapText(String.fromCodePoint(codePoint), state);
398
+ paragraphHasText = true;
399
+ pendingUnicodeSkip = state.ucSkip ?? 1;
400
+ }
401
+ break;
402
+ }
403
+ case 'uc':
404
+ if (p != null) {
405
+ state.ucSkip = p;
406
+ }
407
+ break;
408
+ case 'plain':
409
+ state.bold = false;
410
+ state.italic = false;
411
+ state.underline = false;
412
+ state.strike = false;
413
+ state.super_ = false;
414
+ state.sub = false;
415
+ state.fontSize = 24;
416
+ state.colorIndex = 0;
417
+ break;
418
+ case 'page':
419
+ flushParagraph();
420
+ paragraphs.push('<hr>');
421
+ break;
422
+ case 'emspace':
423
+ case 'enspace':
424
+ currentParagraph += '&emsp;';
425
+ paragraphHasText = true;
426
+ break;
427
+ case 'emdash':
428
+ currentParagraph += '—';
429
+ paragraphHasText = true;
430
+ break;
431
+ case 'endash':
432
+ currentParagraph += '–';
433
+ paragraphHasText = true;
434
+ break;
435
+ case 'lquote':
436
+ currentParagraph += '\u2018';
437
+ paragraphHasText = true;
438
+ break;
439
+ case 'rquote':
440
+ currentParagraph += '\u2019';
441
+ paragraphHasText = true;
442
+ break;
443
+ case 'ldblquote':
444
+ currentParagraph += '\u201C';
445
+ paragraphHasText = true;
446
+ break;
447
+ case 'rdblquote':
448
+ currentParagraph += '\u201D';
449
+ paragraphHasText = true;
450
+ break;
451
+ case 'bullet':
452
+ currentParagraph += '\u2022';
453
+ paragraphHasText = true;
454
+ break;
455
+ default:
456
+ break;
457
+ }
458
+ }
459
+ else if (node.type === 'text') {
460
+ if (skipDepth > 0)
461
+ continue;
462
+ const state = currentState();
463
+ if (pendingUnicodeSkip > 0) {
464
+ const skipCount = Math.min(pendingUnicodeSkip, node.value.length);
465
+ pendingUnicodeSkip -= skipCount;
466
+ const remainder = node.value.slice(skipCount);
467
+ if (remainder) {
468
+ currentParagraph += wrapText(remainder, state);
469
+ if (/[^\s]/.test(remainder))
470
+ paragraphHasText = true;
471
+ }
472
+ continue;
473
+ }
474
+ currentParagraph += wrapText(node.value, state);
475
+ if (/[^\s]/.test(node.value))
476
+ paragraphHasText = true;
477
+ }
478
+ }
479
+ }
480
+ walk(rootGroup.children);
481
+ // Only flush if there's actually pending content — the final \par in most RTF
482
+ // files already flushed the last paragraph, so an unconditional call here
483
+ // would push a spurious empty paragraph that becomes a trailing <p><br></p>.
484
+ if (currentParagraph || paragraphHasText) {
485
+ flushParagraph();
486
+ }
487
+ // Build final HTML from paragraphs (collapse repeated empty lines)
488
+ const rendered = [];
489
+ const trimmedParagraphs = paragraphs.map((p) => p.trim());
490
+ const isListLine = (text) => /^[-\u2022]/.test(text.replace(/^&emsp;+/g, ''));
491
+ for (let i = 0; i < trimmedParagraphs.length; i++) {
492
+ const trimmed = trimmedParagraphs[i];
493
+ const prev = i > 0 ? trimmedParagraphs[i - 1] : '';
494
+ const next = i + 1 < trimmedParagraphs.length ? trimmedParagraphs[i + 1] : '';
495
+ if (!trimmed) {
496
+ if (isListLine(prev) || isListLine(next)) {
497
+ continue;
498
+ }
499
+ const last = rendered[rendered.length - 1];
500
+ if (last === '<p><br></p>') {
501
+ continue;
502
+ }
503
+ rendered.push('<p><br></p>');
504
+ continue;
505
+ }
506
+ if (trimmed === '<hr>') {
507
+ rendered.push('<hr>');
508
+ continue;
509
+ }
510
+ const sizeMatch = trimmed.match(/font-size:\s*([\d.]+)pt/);
511
+ if (sizeMatch) {
512
+ const pt = parseFloat(sizeMatch[1]);
513
+ if (pt >= 24) {
514
+ rendered.push(`<h1>${stripSizeSpan(trimmed)}</h1>`);
515
+ continue;
516
+ }
517
+ if (pt >= 18) {
518
+ rendered.push(`<h2>${stripSizeSpan(trimmed)}</h2>`);
519
+ continue;
520
+ }
521
+ if (pt >= 14) {
522
+ rendered.push(`<h3>${stripSizeSpan(trimmed)}</h3>`);
523
+ continue;
524
+ }
525
+ }
526
+ rendered.push(`<p>${trimmed}</p>`);
527
+ }
528
+ const output = rendered.join('\n');
529
+ // Suppress unused variable warnings
530
+ void fontTable;
531
+ void paragraphHasText;
532
+ return output || '<p></p>';
533
+ }
534
+ function stripSizeSpan(html) {
535
+ return html.replace(/^<span style="[^"]*font-size:[^"]*">([\s\S]*)<\/span>$/, '$1');
536
+ }
537
+ export function readRtfFile(file) {
538
+ return new Promise((resolve, reject) => {
539
+ const reader = new FileReader();
540
+ reader.onload = () => {
541
+ try {
542
+ const text = reader.result;
543
+ const html = rtfToHtml(text);
544
+ resolve(html);
545
+ }
546
+ catch (err) {
547
+ reject(new Error(`Failed to parse RTF: ${err.message}`));
548
+ }
549
+ };
550
+ reader.onerror = () => reject(new Error('Failed to read file'));
551
+ reader.readAsText(file, 'ascii');
552
+ });
553
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * HTML to RTF converter
3
+ *
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.
8
+ */
9
+ export declare function htmlToRtf(editorEl: HTMLElement): string;