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,281 @@
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
+ // ── Color helpers ──
10
+ function parseColor(colorStr) {
11
+ if (!colorStr)
12
+ return null;
13
+ const s = colorStr.trim().toLowerCase();
14
+ // rgb(r, g, b) or rgba(r, g, b, a)
15
+ const rgbMatch = s.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
16
+ if (rgbMatch) {
17
+ return { r: +rgbMatch[1], g: +rgbMatch[2], b: +rgbMatch[3] };
18
+ }
19
+ // Hex
20
+ const hexMatch = s.match(/^#([0-9a-f]{3,8})$/);
21
+ if (hexMatch) {
22
+ let hex = hexMatch[1];
23
+ if (hex.length === 3)
24
+ hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
25
+ return {
26
+ r: parseInt(hex.substring(0, 2), 16),
27
+ g: parseInt(hex.substring(2, 4), 16),
28
+ b: parseInt(hex.substring(4, 6), 16)
29
+ };
30
+ }
31
+ // Basic named colors
32
+ const named = {
33
+ black: { r: 0, g: 0, b: 0 },
34
+ white: { r: 255, g: 255, b: 255 },
35
+ red: { r: 255, g: 0, b: 0 },
36
+ green: { r: 0, g: 128, b: 0 },
37
+ blue: { r: 0, g: 0, b: 255 },
38
+ yellow: { r: 255, g: 255, b: 0 },
39
+ orange: { r: 255, g: 165, b: 0 },
40
+ purple: { r: 128, g: 0, b: 128 },
41
+ gray: { r: 128, g: 128, b: 128 },
42
+ grey: { r: 128, g: 128, b: 128 }
43
+ };
44
+ return named[s] || null;
45
+ }
46
+ function colorKey(c) {
47
+ return `${c.r},${c.g},${c.b}`;
48
+ }
49
+ // ── RTF text escaping ──
50
+ function escapeRtf(text) {
51
+ let out = '';
52
+ for (let i = 0; i < text.length; i++) {
53
+ const ch = text[i];
54
+ const code = text.charCodeAt(i);
55
+ if (ch === '\\')
56
+ out += '\\\\';
57
+ else if (ch === '{')
58
+ out += '\\{';
59
+ else if (ch === '}')
60
+ out += '\\}';
61
+ else if (code > 127) {
62
+ out += `\\u${code}\\'3f`;
63
+ }
64
+ else {
65
+ out += ch;
66
+ }
67
+ }
68
+ return out;
69
+ }
70
+ // ── DOM walker / RTF generator ──
71
+ export function htmlToRtf(editorEl) {
72
+ const colorMap = new Map();
73
+ colorMap.set('0,0,0', { r: 0, g: 0, b: 0 });
74
+ collectColors(editorEl, colorMap);
75
+ const colors = Array.from(colorMap.values());
76
+ const colorIndex = new Map();
77
+ colors.forEach((c, i) => {
78
+ colorIndex.set(colorKey(c), i + 1);
79
+ });
80
+ let colorTable = '{\\colortbl ;';
81
+ for (const c of colors) {
82
+ colorTable += `\\red${c.r}\\green${c.g}\\blue${c.b};`;
83
+ }
84
+ colorTable += '}';
85
+ const fontTable = '{\\fonttbl' +
86
+ '{\\f0\\fswiss\\fcharset0 Helvetica;}' +
87
+ '{\\f1\\froman\\fcharset0 Georgia;}' +
88
+ '{\\f2\\fmodern\\fcharset0 Courier New;}' +
89
+ '}';
90
+ const body = walkChildren(editorEl, { colorIndex, inPre: false, listCounter: 0 });
91
+ const rtf = '{\\rtf1\\ansi\\ansicpg1252\\deff0' +
92
+ fontTable +
93
+ colorTable +
94
+ '\\viewkind4\\uc1' +
95
+ '\\pard\\f0\\fs24 ' +
96
+ body +
97
+ '}';
98
+ return rtf;
99
+ }
100
+ 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) {
112
+ collectColors(child, colorMap);
113
+ }
114
+ }
115
+ function walkChildren(parent, ctx) {
116
+ let rtf = '';
117
+ const children = parent.childNodes;
118
+ for (let i = 0; i < children.length; i++) {
119
+ const node = children[i];
120
+ if (node.nodeType === Node.TEXT_NODE) {
121
+ const text = node.textContent || '';
122
+ if (ctx.inPre) {
123
+ rtf += escapeRtf(text).replace(/\n/g, '\\line\n');
124
+ }
125
+ else {
126
+ rtf += escapeRtf(text);
127
+ }
128
+ continue;
129
+ }
130
+ if (node.nodeType !== Node.ELEMENT_NODE)
131
+ continue;
132
+ const el = node;
133
+ const tag = el.tagName.toLowerCase();
134
+ switch (tag) {
135
+ case 'h1':
136
+ rtf += `\\pard\\f1\\fs48\\b ${walkChildren(el, ctx)}\\b0\\f0\\fs24\\par\n`;
137
+ break;
138
+ case 'h2':
139
+ rtf += `\\pard\\f1\\fs36\\b ${walkChildren(el, ctx)}\\b0\\f0\\fs24\\par\n`;
140
+ break;
141
+ case 'h3':
142
+ rtf += `\\pard\\f0\\fs28\\b ${walkChildren(el, ctx)}\\b0\\fs24\\par\n`;
143
+ break;
144
+ case 'p':
145
+ case 'div': {
146
+ // A <p> with no children or only a single <br> is a blank line —
147
+ // emit a bare \par rather than \pard \line\par, which adds an extra line.
148
+ const isBlankLine = el.childNodes.length === 0 ||
149
+ (el.childNodes.length === 1 && el.childNodes[0].nodeName === 'BR');
150
+ if (isBlankLine) {
151
+ rtf += '\\par\n';
152
+ break;
153
+ }
154
+ const inlineRtf = walkChildren(el, ctx);
155
+ const colorPrefix = getColorPrefix(el, ctx);
156
+ const colorSuffix = colorPrefix ? '\\cf0 ' : '';
157
+ rtf += `\\pard ${colorPrefix}${inlineRtf}${colorSuffix}\\par\n`;
158
+ break;
159
+ }
160
+ case 'blockquote':
161
+ rtf += `\\pard\\li720\\i ${walkChildren(el, ctx)}\\i0\\par\n`;
162
+ break;
163
+ case 'pre':
164
+ rtf += `\\pard\\f2\\fs20 ${walkChildren(el, { ...ctx, inPre: true })}\\f0\\fs24\\par\n`;
165
+ break;
166
+ case 'hr':
167
+ rtf += `\\pard\\qc \\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\par\n`;
168
+ break;
169
+ case 'ul': {
170
+ const items = el.querySelectorAll(':scope > li');
171
+ items.forEach((li) => {
172
+ rtf += `\\pard\\li720\\fi-360\\bullet\\tab ${walkChildren(li, ctx)}\\par\n`;
173
+ });
174
+ break;
175
+ }
176
+ case 'ol': {
177
+ let counter = 1;
178
+ const items = el.querySelectorAll(':scope > li');
179
+ items.forEach((li) => {
180
+ rtf += `\\pard\\li720\\fi-360 ${counter}.\\tab ${walkChildren(li, ctx)}\\par\n`;
181
+ counter++;
182
+ });
183
+ break;
184
+ }
185
+ case 'li':
186
+ rtf += walkChildren(el, ctx);
187
+ break;
188
+ case 'strong':
189
+ case 'b':
190
+ rtf += `\\b ${walkChildren(el, ctx)}\\b0 `;
191
+ break;
192
+ case 'em':
193
+ case 'i':
194
+ rtf += `\\i ${walkChildren(el, ctx)}\\i0 `;
195
+ break;
196
+ case 'u':
197
+ rtf += `\\ul ${walkChildren(el, ctx)}\\ulnone `;
198
+ break;
199
+ case 's':
200
+ case 'strike':
201
+ case 'del':
202
+ rtf += `\\strike ${walkChildren(el, ctx)}\\strike0 `;
203
+ break;
204
+ case 'sup':
205
+ rtf += `\\super ${walkChildren(el, ctx)}\\nosupersub `;
206
+ break;
207
+ case 'sub':
208
+ rtf += `\\sub ${walkChildren(el, ctx)}\\nosupersub `;
209
+ break;
210
+ case 'code':
211
+ if (!ctx.inPre) {
212
+ rtf += `\\f2 ${walkChildren(el, ctx)}\\f0 `;
213
+ }
214
+ else {
215
+ rtf += walkChildren(el, ctx);
216
+ }
217
+ break;
218
+ case 'a': {
219
+ const href = el.getAttribute('href') || '';
220
+ const inner = walkChildren(el, ctx);
221
+ if (href) {
222
+ rtf += `{\\field{\\*\\fldinst HYPERLINK "${escapeRtf(href)}"}{\\fldrslt\\ul\\cf1 ${inner}\\cf0\\ulnone }}`;
223
+ }
224
+ else {
225
+ rtf += inner;
226
+ }
227
+ break;
228
+ }
229
+ case 'img': {
230
+ const alt = el.getAttribute('alt') || '';
231
+ const src = el.getAttribute('src') || '';
232
+ const label = alt || src || '[image]';
233
+ rtf += `\\pard\\i [Image: ${escapeRtf(label)}]\\i0\\par\n`;
234
+ break;
235
+ }
236
+ case 'br':
237
+ rtf += '\\line\n';
238
+ break;
239
+ case 'span': {
240
+ const colorPfx = getColorPrefix(el, ctx);
241
+ const sizePfx = getSizePrefix(el);
242
+ const sizesfx = sizePfx ? '\\fs24 ' : '';
243
+ const colorSfx = colorPfx ? '\\cf0 ' : '';
244
+ rtf += `${colorPfx}${sizePfx}${walkChildren(el, ctx)}${sizesfx}${colorSfx}`;
245
+ break;
246
+ }
247
+ default:
248
+ rtf += walkChildren(el, ctx);
249
+ break;
250
+ }
251
+ }
252
+ return rtf;
253
+ }
254
+ function getColorPrefix(el, ctx) {
255
+ const color = el.style?.color;
256
+ if (!color)
257
+ return '';
258
+ const c = parseColor(color);
259
+ if (!c)
260
+ return '';
261
+ const idx = ctx.colorIndex.get(colorKey(c));
262
+ if (idx != null)
263
+ return `\\cf${idx} `;
264
+ return '';
265
+ }
266
+ function getSizePrefix(el) {
267
+ const fs = el.style?.fontSize;
268
+ if (!fs)
269
+ return '';
270
+ const ptMatch = fs.match(/([\d.]+)\s*pt/);
271
+ if (ptMatch) {
272
+ const halfPoints = Math.round(parseFloat(ptMatch[1]) * 2);
273
+ return `\\fs${halfPoints} `;
274
+ }
275
+ const pxMatch = fs.match(/([\d.]+)\s*px/);
276
+ if (pxMatch) {
277
+ const halfPoints = Math.round(parseFloat(pxMatch[1]) * 0.75 * 2);
278
+ return `\\fs${halfPoints} `;
279
+ }
280
+ return '';
281
+ }
@@ -0,0 +1,2 @@
1
+ export declare function htmlToMarkdown(el: HTMLElement): string;
2
+ export declare function downloadFile(filename: string, content: string, mimeType: string): void;
package/dist/utils.js ADDED
@@ -0,0 +1,96 @@
1
+ export function htmlToMarkdown(el) {
2
+ let md = '';
3
+ el.childNodes.forEach((node) => {
4
+ if (node.nodeType === Node.TEXT_NODE) {
5
+ md += node.textContent;
6
+ return;
7
+ }
8
+ if (node.nodeType !== Node.ELEMENT_NODE)
9
+ return;
10
+ const tag = node.tagName.toLowerCase();
11
+ const inner = htmlToMarkdown(node);
12
+ switch (tag) {
13
+ case 'h1':
14
+ md += `# ${inner}\n\n`;
15
+ break;
16
+ case 'h2':
17
+ md += `## ${inner}\n\n`;
18
+ break;
19
+ case 'h3':
20
+ md += `### ${inner}\n\n`;
21
+ break;
22
+ case 'p':
23
+ md += `${inner}\n\n`;
24
+ break;
25
+ case 'strong':
26
+ case 'b':
27
+ md += `**${inner}**`;
28
+ break;
29
+ case 'em':
30
+ case 'i':
31
+ md += `*${inner}*`;
32
+ break;
33
+ case 'u':
34
+ md += `<u>${inner}</u>`;
35
+ break;
36
+ case 's':
37
+ case 'strike':
38
+ md += `~~${inner}~~`;
39
+ break;
40
+ case 'a':
41
+ md += `[${inner}](${node.href || ''})`;
42
+ break;
43
+ case 'img':
44
+ md += `![${node.alt || ''}](${node.src || ''})`;
45
+ break;
46
+ case 'blockquote':
47
+ md += `> ${inner.trim()}\n\n`;
48
+ break;
49
+ case 'pre':
50
+ md += `\`\`\`\n${node.textContent}\n\`\`\`\n\n`;
51
+ break;
52
+ case 'code':
53
+ md += `\`${inner}\``;
54
+ break;
55
+ case 'ul':
56
+ node.querySelectorAll(':scope > li').forEach((li) => {
57
+ md += `- ${htmlToMarkdown(li).trim()}\n`;
58
+ });
59
+ md += '\n';
60
+ break;
61
+ case 'ol': {
62
+ let i = 1;
63
+ node.querySelectorAll(':scope > li').forEach((li) => {
64
+ md += `${i++}. ${htmlToMarkdown(li).trim()}\n`;
65
+ });
66
+ md += '\n';
67
+ break;
68
+ }
69
+ case 'li':
70
+ md += inner;
71
+ break;
72
+ case 'br':
73
+ md += '\n';
74
+ break;
75
+ case 'hr':
76
+ md += '\n---\n\n';
77
+ break;
78
+ case 'div':
79
+ md += `${inner}\n`;
80
+ break;
81
+ default:
82
+ md += inner;
83
+ break;
84
+ }
85
+ });
86
+ return md;
87
+ }
88
+ export function downloadFile(filename, content, mimeType) {
89
+ const blob = new Blob([content], { type: mimeType });
90
+ const url = URL.createObjectURL(blob);
91
+ const a = document.createElement('a');
92
+ a.href = url;
93
+ a.download = filename;
94
+ a.click();
95
+ URL.revokeObjectURL(url);
96
+ }
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "svelte-rtf-editor",
3
+ "version": "0.1.0",
4
+ "description": "RTF viewer and rich-text editor components for Svelte 5",
5
+ "license": "MIT",
6
+ "homepage": "https://github.com/jfc76/svelte-rtf-editor#readme",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/jfc76/svelte-rtf-editor.git"
10
+ },
11
+ "bugs": "https://github.com/jfc76/svelte-rtf-editor/issues",
12
+ "scripts": {
13
+ "dev": "vite dev",
14
+ "build": "vite build && npm run prepack",
15
+ "preview": "vite preview",
16
+ "prepare": "svelte-kit sync || echo ''",
17
+ "prepack": "svelte-kit sync && svelte-package && publint",
18
+ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
19
+ "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
20
+ },
21
+ "files": [
22
+ "dist",
23
+ "!dist/**/*.test.*",
24
+ "!dist/**/*.spec.*"
25
+ ],
26
+ "sideEffects": [
27
+ "**/*.css"
28
+ ],
29
+ "svelte": "./dist/index.js",
30
+ "types": "./dist/index.d.ts",
31
+ "type": "module",
32
+ "exports": {
33
+ ".": {
34
+ "types": "./dist/index.d.ts",
35
+ "svelte": "./dist/index.js"
36
+ }
37
+ },
38
+ "peerDependencies": {
39
+ "svelte": "^5.0.0"
40
+ },
41
+ "devDependencies": {
42
+ "@sveltejs/adapter-auto": "^7.0.0",
43
+ "@sveltejs/kit": "^2.50.2",
44
+ "@sveltejs/package": "^2.5.7",
45
+ "@sveltejs/vite-plugin-svelte": "^6.2.4",
46
+ "publint": "^0.3.17",
47
+ "svelte": "^5.51.0",
48
+ "svelte-check": "^4.4.2",
49
+ "typescript": "^5.9.3",
50
+ "vite": "^7.3.1"
51
+ },
52
+ "keywords": [
53
+ "svelte",
54
+ "rtf",
55
+ "editor",
56
+ "rich-text",
57
+ "contenteditable",
58
+ "viewer"
59
+ ]
60
+ }