svelte-rtf-editor 1.3.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 +107 -6
- package/dist/RtfViewer.svelte +41 -3
- package/dist/RtfViewer.svelte.d.ts +2 -0
- package/dist/components/ImageOverlay.svelte +302 -0
- package/dist/components/ImageOverlay.svelte.d.ts +27 -0
- package/dist/components/InkEditor.svelte +598 -54
- package/dist/components/InkEditor.svelte.d.ts +11 -0
- package/dist/images.d.ts +86 -0
- package/dist/images.js +286 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -1
- package/dist/rtf-parser.d.ts +2 -1
- package/dist/rtf-parser.js +254 -37
- package/dist/rtf-writer.d.ts +56 -4
- package/dist/rtf-writer.js +384 -35
- package/dist/utils.js +13 -0
- package/package.json +1 -1
package/dist/rtf-writer.js
CHANGED
|
@@ -2,14 +2,30 @@
|
|
|
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:
|
|
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
|
-
* code blocks, hyperlinks, images (as
|
|
8
|
-
* rules, line breaks, and Unicode characters.
|
|
8
|
+
* code blocks, hyperlinks, images (embedded as \pict picture groups with
|
|
9
|
+
* their captions), horizontal rules, line breaks, and Unicode characters.
|
|
9
10
|
*
|
|
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;
|
|
27
|
+
/** 1 px at 96 dpi = 15 twips (1 twip = 1/1440 in). */
|
|
28
|
+
const TWIPS_PER_PX = 15;
|
|
13
29
|
// ── Color helpers ──
|
|
14
30
|
function parseColor(colorStr) {
|
|
15
31
|
if (!colorStr)
|
|
@@ -52,10 +68,17 @@ function colorKey(c) {
|
|
|
52
68
|
}
|
|
53
69
|
// ── RTF text escaping ──
|
|
54
70
|
function escapeRtf(text) {
|
|
71
|
+
// RTF readers ignore raw CR/LF, so a newline inside text content rendered as
|
|
72
|
+
// nothing ("Hello\nworld" → "Helloworld") while still emitting bytes that a
|
|
73
|
+
// CR-terminated transport treats as a record separator. Collapsing a run to
|
|
74
|
+
// the single space a browser would have shown is both safer and more
|
|
75
|
+
// faithful. Text is never split on newlines after this point, so <pre>
|
|
76
|
+
// content must be divided into lines before it gets here.
|
|
77
|
+
const source = text.replace(/[\r\n]+/g, ' ');
|
|
55
78
|
let out = '';
|
|
56
|
-
for (let i = 0; i <
|
|
57
|
-
const ch =
|
|
58
|
-
const code =
|
|
79
|
+
for (let i = 0; i < source.length; i++) {
|
|
80
|
+
const ch = source[i];
|
|
81
|
+
const code = source.charCodeAt(i);
|
|
59
82
|
if (ch === '\\')
|
|
60
83
|
out += '\\\\';
|
|
61
84
|
else if (ch === '{')
|
|
@@ -71,8 +94,296 @@ function escapeRtf(text) {
|
|
|
71
94
|
}
|
|
72
95
|
return out;
|
|
73
96
|
}
|
|
97
|
+
// ── Image helpers ──
|
|
98
|
+
const B64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
99
|
+
const B64_LOOKUP = (() => {
|
|
100
|
+
const table = new Int16Array(128).fill(-1);
|
|
101
|
+
for (let i = 0; i < B64_ALPHABET.length; i++)
|
|
102
|
+
table[B64_ALPHABET.charCodeAt(i)] = i;
|
|
103
|
+
return table;
|
|
104
|
+
})();
|
|
105
|
+
// Uppercase: the RTF spec treats picture data as case-insensitive, but
|
|
106
|
+
// downstream consumers (HL7 interface engines, for one) sometimes match the
|
|
107
|
+
// leading PNG signature literally as 89504E470D0A1A0A.
|
|
108
|
+
const HEX_BYTE = Array.from({ length: 256 }, (_, i) => i.toString(16).padStart(2, '0').toUpperCase());
|
|
109
|
+
/** Decode base64 to bytes without depending on atob/Buffer (works in any runtime). */
|
|
110
|
+
function base64ToBytes(b64) {
|
|
111
|
+
let buffer = 0;
|
|
112
|
+
let bits = 0;
|
|
113
|
+
let out = 0;
|
|
114
|
+
const bytes = new Uint8Array(Math.ceil((b64.length * 3) / 4));
|
|
115
|
+
for (let i = 0; i < b64.length; i++) {
|
|
116
|
+
const code = b64.charCodeAt(i);
|
|
117
|
+
if (code === 61 /* = */)
|
|
118
|
+
break;
|
|
119
|
+
const v = code < 128 ? B64_LOOKUP[code] : -1;
|
|
120
|
+
if (v < 0) {
|
|
121
|
+
// Whitespace inside a data URL is legal; anything else is not base64.
|
|
122
|
+
if (code === 32 || code === 9 || code === 10 || code === 13)
|
|
123
|
+
continue;
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
buffer = (buffer << 6) | v;
|
|
127
|
+
bits += 6;
|
|
128
|
+
if (bits >= 8) {
|
|
129
|
+
bits -= 8;
|
|
130
|
+
bytes[out++] = (buffer >> bits) & 0xff;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return out > 0 ? bytes.subarray(0, out) : null;
|
|
134
|
+
}
|
|
135
|
+
function bytesToHex(bytes) {
|
|
136
|
+
const parts = new Array(bytes.length);
|
|
137
|
+
for (let i = 0; i < bytes.length; i++)
|
|
138
|
+
parts[i] = HEX_BYTE[bytes[i]];
|
|
139
|
+
return parts.join('');
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Picture format according to the bytes themselves, not the label they arrived
|
|
143
|
+
* with. A file named .png can hold WebP — operating systems assign the MIME
|
|
144
|
+
* type from the extension — and writing those bytes as \pngblip produces a
|
|
145
|
+
* picture no reader can decode.
|
|
146
|
+
*/
|
|
147
|
+
function sniffBytes(b) {
|
|
148
|
+
if (b.length >= 8 &&
|
|
149
|
+
b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47 &&
|
|
150
|
+
b[4] === 0x0d && b[5] === 0x0a && b[6] === 0x1a && b[7] === 0x0a) {
|
|
151
|
+
return 'png';
|
|
152
|
+
}
|
|
153
|
+
if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff)
|
|
154
|
+
return 'jpeg';
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
/** Read the intrinsic size out of the PNG IHDR chunk. */
|
|
158
|
+
function pngSize(b) {
|
|
159
|
+
if (b.length < 24)
|
|
160
|
+
return null;
|
|
161
|
+
if (b[0] !== 0x89 || b[1] !== 0x50 || b[2] !== 0x4e || b[3] !== 0x47)
|
|
162
|
+
return null;
|
|
163
|
+
const w = ((b[16] << 24) | (b[17] << 16) | (b[18] << 8) | b[19]) >>> 0;
|
|
164
|
+
const h = ((b[20] << 24) | (b[21] << 16) | (b[22] << 8) | b[23]) >>> 0;
|
|
165
|
+
return w && h ? { w, h } : null;
|
|
166
|
+
}
|
|
167
|
+
/** Read the intrinsic size out of the first JPEG start-of-frame marker. */
|
|
168
|
+
function jpegSize(b) {
|
|
169
|
+
if (b.length < 4 || b[0] !== 0xff || b[1] !== 0xd8)
|
|
170
|
+
return null;
|
|
171
|
+
let i = 2;
|
|
172
|
+
while (i + 9 < b.length) {
|
|
173
|
+
if (b[i] !== 0xff) {
|
|
174
|
+
i++;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
const marker = b[i + 1];
|
|
178
|
+
// Standalone markers carry no length field.
|
|
179
|
+
if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
|
|
180
|
+
i += 2;
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
const len = (b[i + 2] << 8) | b[i + 3];
|
|
184
|
+
// SOF0–SOF15, excluding DHT (c4), JPG (c8) and DAC (cc)
|
|
185
|
+
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
|
|
186
|
+
const h = (b[i + 5] << 8) | b[i + 6];
|
|
187
|
+
const w = (b[i + 7] << 8) | b[i + 8];
|
|
188
|
+
return w && h ? { w, h } : null;
|
|
189
|
+
}
|
|
190
|
+
if (len < 2)
|
|
191
|
+
break;
|
|
192
|
+
i += 2 + len;
|
|
193
|
+
}
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
function parsePx(value) {
|
|
197
|
+
if (!value)
|
|
198
|
+
return 0;
|
|
199
|
+
const m = value.match(/^([\d.]+)px$/);
|
|
200
|
+
return m ? Math.round(parseFloat(m[1])) : 0;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Displayed size of an image in CSS pixels. Prefers the explicit width the
|
|
204
|
+
* editor writes when the user resizes, then the width/height attributes, then
|
|
205
|
+
* the intrinsic size decoded from the image bytes (naturalWidth is unavailable
|
|
206
|
+
* when the DOM is detached or server-side).
|
|
207
|
+
*/
|
|
208
|
+
function displaySize(el, natural) {
|
|
209
|
+
const img = el;
|
|
210
|
+
const nw = natural.w || img.naturalWidth || 0;
|
|
211
|
+
const nh = natural.h || img.naturalHeight || 0;
|
|
212
|
+
const attrW = parseInt(el.getAttribute('width') || '', 10);
|
|
213
|
+
const attrH = parseInt(el.getAttribute('height') || '', 10);
|
|
214
|
+
const w = parsePx(el.style?.width) || (Number.isFinite(attrW) ? attrW : 0) || nw;
|
|
215
|
+
let h = parsePx(el.style?.height) || (Number.isFinite(attrH) ? attrH : 0);
|
|
216
|
+
if (!h)
|
|
217
|
+
h = nw && nh ? Math.round((w * nh) / nw) : nh;
|
|
218
|
+
return { w, h };
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Convert a data-URL <img> into an RTF \pict group. Returns '' for images the
|
|
222
|
+
* RTF format cannot carry (remote URLs, or formats other than PNG/JPEG), so
|
|
223
|
+
* the caller can fall back to a text placeholder.
|
|
224
|
+
*/
|
|
225
|
+
function pictureRtf(el) {
|
|
226
|
+
const src = el.getAttribute('src') || '';
|
|
227
|
+
// Accept any image data URL and let the bytes decide the format — the label
|
|
228
|
+
// is not trustworthy, and a correctly-labelled PNG is not the only way to
|
|
229
|
+
// arrive at PNG bytes.
|
|
230
|
+
const m = src.match(/^data:image\/[a-z0-9.+-]+;base64,([\s\S]+)$/i);
|
|
231
|
+
if (!m)
|
|
232
|
+
return '';
|
|
233
|
+
const bytes = base64ToBytes(m[1]);
|
|
234
|
+
if (!bytes || bytes.length === 0)
|
|
235
|
+
return '';
|
|
236
|
+
const format = sniffBytes(bytes);
|
|
237
|
+
// Neither PNG nor JPEG: RTF cannot carry it, so fall back to the placeholder
|
|
238
|
+
// rather than emitting a blip whose declared type contradicts its content.
|
|
239
|
+
if (!format)
|
|
240
|
+
return '';
|
|
241
|
+
const isPng = format === 'png';
|
|
242
|
+
const natural = (isPng ? pngSize(bytes) : jpegSize(bytes)) ?? { w: 0, h: 0 };
|
|
243
|
+
const size = displaySize(el, natural);
|
|
244
|
+
if (!size.w || !size.h)
|
|
245
|
+
return '';
|
|
246
|
+
const picw = natural.w || size.w;
|
|
247
|
+
const pich = natural.h || size.h;
|
|
248
|
+
// The space after \pichgoal is the control word's delimiter — without it the
|
|
249
|
+
// leading hex digits would be read as part of its numeric parameter.
|
|
250
|
+
return (`{\\pict${isPng ? '\\pngblip' : '\\jpegblip'}` +
|
|
251
|
+
`\\picw${picw}\\pich${pich}` +
|
|
252
|
+
`\\picwgoal${Math.round(size.w * TWIPS_PER_PX)}\\pichgoal${Math.round(size.h * TWIPS_PER_PX)} ` +
|
|
253
|
+
`${bytesToHex(bytes)}}`);
|
|
254
|
+
}
|
|
255
|
+
/** Picture group if the bytes are embeddable, otherwise an italic placeholder. */
|
|
256
|
+
function imageBody(el) {
|
|
257
|
+
const pict = pictureRtf(el);
|
|
258
|
+
if (pict)
|
|
259
|
+
return pict;
|
|
260
|
+
const label = el.getAttribute('alt') || el.getAttribute('src') || 'image';
|
|
261
|
+
return `\\i [Image: ${escapeRtf(label)}]\\i0 `;
|
|
262
|
+
}
|
|
263
|
+
/** \ql / \qc / \qr for a figure's text-align. */
|
|
264
|
+
function alignControl(el) {
|
|
265
|
+
const align = (el.style?.textAlign || '').toLowerCase();
|
|
266
|
+
if (align === 'center')
|
|
267
|
+
return '\\qc';
|
|
268
|
+
if (align === 'right')
|
|
269
|
+
return '\\qr';
|
|
270
|
+
if (align === 'justify')
|
|
271
|
+
return '\\qj';
|
|
272
|
+
return '\\ql';
|
|
273
|
+
}
|
|
274
|
+
/** Decoded byte length of a base64 payload, without decoding it. */
|
|
275
|
+
function base64ByteLength(base64) {
|
|
276
|
+
const padding = base64.endsWith('==') ? 2 : base64.endsWith('=') ? 1 : 0;
|
|
277
|
+
return Math.max(0, Math.floor((base64.length * 3) / 4) - padding);
|
|
278
|
+
}
|
|
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;
|
|
284
|
+
/** Control words wrapping one \pict group. */
|
|
285
|
+
const PICTURE_OVERHEAD_BYTES = 80;
|
|
286
|
+
/**
|
|
287
|
+
* Control-word bytes the writer wraps around each kind of element — the opening
|
|
288
|
+
* and closing runs measured from walkChildren. Anything not listed costs
|
|
289
|
+
* nothing of its own beyond the text it contains.
|
|
290
|
+
*/
|
|
291
|
+
const BLOCK_OVERHEAD_BYTES = {
|
|
292
|
+
p: 14,
|
|
293
|
+
div: 14,
|
|
294
|
+
h1: 29,
|
|
295
|
+
h2: 29,
|
|
296
|
+
h3: 29,
|
|
297
|
+
li: 35,
|
|
298
|
+
blockquote: 22,
|
|
299
|
+
pre: 27,
|
|
300
|
+
figure: 15,
|
|
301
|
+
figcaption: 29,
|
|
302
|
+
tr: 45,
|
|
303
|
+
td: 55,
|
|
304
|
+
th: 69 // a header cell also carries \clbrdrb\brdrs
|
|
305
|
+
};
|
|
306
|
+
/**
|
|
307
|
+
* Size htmlToRtf would produce, without doing the conversion.
|
|
308
|
+
*
|
|
309
|
+
* Useful for a live indicator — the exact figure requires hex-encoding every
|
|
310
|
+
* picture, which is too much work to repeat on each keystroke.
|
|
311
|
+
*
|
|
312
|
+
* Picture data, which dominates any document that has one, is counted exactly:
|
|
313
|
+
* two characters per byte. Everything else is approximated per element, which
|
|
314
|
+
* puts the error at a bounded number of bytes rather than a proportion — so it
|
|
315
|
+
* is negligible precisely when the number matters, and never more than a few
|
|
316
|
+
* hundred bytes even when it does not. The output is ASCII, so bytes and
|
|
317
|
+
* characters are the same number.
|
|
318
|
+
*/
|
|
319
|
+
export function estimateRtfBytes(editorEl) {
|
|
320
|
+
let total = RTF_PREAMBLE_BYTES;
|
|
321
|
+
for (const img of Array.from(editorEl.querySelectorAll('img'))) {
|
|
322
|
+
const src = img.getAttribute('src') || '';
|
|
323
|
+
const comma = src.indexOf(',');
|
|
324
|
+
// A picture that cannot be embedded costs only its placeholder text.
|
|
325
|
+
if (!src.startsWith('data:image/') || comma < 0)
|
|
326
|
+
continue;
|
|
327
|
+
total += base64ByteLength(src.slice(comma + 1)) * 2 + PICTURE_OVERHEAD_BYTES;
|
|
328
|
+
}
|
|
329
|
+
total += (editorEl.textContent || '').length;
|
|
330
|
+
for (const el of Array.from(editorEl.querySelectorAll('*'))) {
|
|
331
|
+
total += BLOCK_OVERHEAD_BYTES[el.tagName.toLowerCase()] ?? 0;
|
|
332
|
+
}
|
|
333
|
+
return total;
|
|
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
|
+
}
|
|
74
383
|
// ── DOM walker / RTF generator ──
|
|
75
|
-
export function htmlToRtf(editorEl) {
|
|
384
|
+
export function htmlToRtf(editorEl, options = {}) {
|
|
385
|
+
const fontName = cleanFontName(options.fontFamily);
|
|
386
|
+
const bodySize = bodyHalfPoints(options.fontSize);
|
|
76
387
|
const colorMap = new Map();
|
|
77
388
|
colorMap.set('0,0,0', { r: 0, g: 0, b: 0 });
|
|
78
389
|
collectColors(editorEl, colorMap);
|
|
@@ -88,20 +399,32 @@ export function htmlToRtf(editorEl) {
|
|
|
88
399
|
colorTable += `\\red${c.r}\\green${c.g}\\blue${c.b};`;
|
|
89
400
|
}
|
|
90
401
|
colorTable += '}';
|
|
402
|
+
// \f0 is the document font, used for everything except code; \f1 is the
|
|
403
|
+
// monospace font for code.
|
|
91
404
|
const fontTable = '{\\fonttbl' +
|
|
92
|
-
|
|
93
|
-
'{\\f1\\
|
|
94
|
-
'{\\f2\\fmodern\\fcharset0 Courier New;}' +
|
|
405
|
+
`{\\f0\\f${fontFamilyClass(fontName)}\\fcharset0 ${escapeRtf(fontName)};}` +
|
|
406
|
+
'{\\f1\\fmodern\\fcharset0 Courier New;}' +
|
|
95
407
|
'}';
|
|
96
|
-
const body = walkChildren(editorEl, {
|
|
408
|
+
const body = walkChildren(editorEl, {
|
|
409
|
+
colorIndex,
|
|
410
|
+
bodySize,
|
|
411
|
+
inPre: false,
|
|
412
|
+
listCounter: 0,
|
|
413
|
+
inTableCell: false,
|
|
414
|
+
root: editorEl
|
|
415
|
+
});
|
|
97
416
|
const rtf = '{\\rtf1\\ansi\\ansicpg1252\\deff0' +
|
|
98
417
|
fontTable +
|
|
99
418
|
colorTable +
|
|
100
419
|
'\\viewkind4\\uc1' +
|
|
101
|
-
|
|
420
|
+
`\\pard\\f0\\fs${bodySize} ` +
|
|
102
421
|
body +
|
|
103
422
|
'}';
|
|
104
|
-
|
|
423
|
+
// Backstop for the single-line guarantee. escapeRtf already collapses CR/LF
|
|
424
|
+
// out of every text path, so this should never have anything to do — but the
|
|
425
|
+
// document is worthless to a CR-terminated transport if one slips through,
|
|
426
|
+
// and no newline carries meaning in the output.
|
|
427
|
+
return rtf.replace(/[\r\n]+/g, ' ');
|
|
105
428
|
}
|
|
106
429
|
function addColor(colorStr, colorMap) {
|
|
107
430
|
if (!colorStr)
|
|
@@ -126,7 +449,10 @@ function walkChildren(parent, ctx) {
|
|
|
126
449
|
if (node.nodeType === Node.TEXT_NODE) {
|
|
127
450
|
const text = node.textContent || '';
|
|
128
451
|
if (ctx.inPre) {
|
|
129
|
-
|
|
452
|
+
// Split before escaping — escapeRtf collapses newlines, so the
|
|
453
|
+
// line structure a <pre> block depends on has to become \line
|
|
454
|
+
// first. Handles CR, LF and CRLF alike.
|
|
455
|
+
rtf += text.split(/\r\n|\r|\n/).map(escapeRtf).join('\\line ');
|
|
130
456
|
}
|
|
131
457
|
else {
|
|
132
458
|
rtf += escapeRtf(text);
|
|
@@ -138,14 +464,15 @@ function walkChildren(parent, ctx) {
|
|
|
138
464
|
const el = node;
|
|
139
465
|
const tag = el.tagName.toLowerCase();
|
|
140
466
|
switch (tag) {
|
|
467
|
+
// Heading sizes keep their ratio to the body (HEADING_SCALE).
|
|
141
468
|
case 'h1':
|
|
142
|
-
rtf += `\\pard\\
|
|
469
|
+
rtf += `\\pard\\f0\\fs${scaled(ctx, HEADING_SCALE.h1)}\\b ${walkChildren(el, ctx)}\\b0\\fs${ctx.bodySize}\\par `;
|
|
143
470
|
break;
|
|
144
471
|
case 'h2':
|
|
145
|
-
rtf += `\\pard\\
|
|
472
|
+
rtf += `\\pard\\f0\\fs${scaled(ctx, HEADING_SCALE.h2)}\\b ${walkChildren(el, ctx)}\\b0\\fs${ctx.bodySize}\\par `;
|
|
146
473
|
break;
|
|
147
474
|
case 'h3':
|
|
148
|
-
rtf += `\\pard\\f0\\
|
|
475
|
+
rtf += `\\pard\\f0\\fs${scaled(ctx, HEADING_SCALE.h3)}\\b ${walkChildren(el, ctx)}\\b0\\fs${ctx.bodySize}\\par `;
|
|
149
476
|
break;
|
|
150
477
|
case 'p':
|
|
151
478
|
case 'div': {
|
|
@@ -159,13 +486,13 @@ function walkChildren(parent, ctx) {
|
|
|
159
486
|
const isBlankLine = el.childNodes.length === 0 ||
|
|
160
487
|
(el.childNodes.length === 1 && el.childNodes[0].nodeName === 'BR');
|
|
161
488
|
if (isBlankLine) {
|
|
162
|
-
rtf += '\\par
|
|
489
|
+
rtf += '\\par ';
|
|
163
490
|
break;
|
|
164
491
|
}
|
|
165
492
|
const inlineRtf = walkChildren(el, ctx);
|
|
166
493
|
const colorPrefix = getColorPrefix(el, ctx);
|
|
167
494
|
const colorSuffix = colorPrefix ? '\\cf0 ' : '';
|
|
168
|
-
rtf += `\\pard ${colorPrefix}${inlineRtf}${colorSuffix}\\par
|
|
495
|
+
rtf += `\\pard ${colorPrefix}${inlineRtf}${colorSuffix}\\par `;
|
|
169
496
|
break;
|
|
170
497
|
}
|
|
171
498
|
case 'table': {
|
|
@@ -187,37 +514,37 @@ function walkChildren(parent, ctx) {
|
|
|
187
514
|
}
|
|
188
515
|
cellRights[cellRights.length - 1] = PAGE_WIDTH; // snap last edge to avoid rounding drift
|
|
189
516
|
// Row definition
|
|
190
|
-
let rowRtf = '{
|
|
517
|
+
let rowRtf = '{\\trowd\\trgaph120';
|
|
191
518
|
for (let ci = 0; ci < cells.length; ci++) {
|
|
192
519
|
if (isHeader)
|
|
193
520
|
rowRtf += `\\clbrdrb\\brdrs`;
|
|
194
521
|
rowRtf += `\\cellx${cellRights[ci]}`;
|
|
195
522
|
}
|
|
196
|
-
rowRtf += '
|
|
523
|
+
rowRtf += '\\trkeep\\intbl{';
|
|
197
524
|
// Cell bodies
|
|
198
525
|
const cellCtx = { ...ctx, inTableCell: true };
|
|
199
526
|
for (const cell of cells) {
|
|
200
527
|
const content = walkChildren(cell, cellCtx);
|
|
201
|
-
rowRtf += `{\\pard\\intbl \\f0 \\sa0 \\li0 \\fi0 ${content}\\par}
|
|
528
|
+
rowRtf += `{\\pard\\intbl \\f0 \\sa0 \\li0 \\fi0 ${content}\\par}\\cell`;
|
|
202
529
|
}
|
|
203
|
-
rowRtf += '}
|
|
530
|
+
rowRtf += '}\\intbl\\row}';
|
|
204
531
|
rtf += rowRtf;
|
|
205
532
|
}
|
|
206
533
|
break;
|
|
207
534
|
}
|
|
208
535
|
case 'blockquote':
|
|
209
|
-
rtf += `\\pard\\li720\\i ${walkChildren(el, ctx)}\\i0\\par
|
|
536
|
+
rtf += `\\pard\\li720\\i ${walkChildren(el, ctx)}\\i0\\par `;
|
|
210
537
|
break;
|
|
211
538
|
case 'pre':
|
|
212
|
-
rtf += `\\pard\\
|
|
539
|
+
rtf += `\\pard\\f1\\fs${scaled(ctx, PRE_SCALE)} ${walkChildren(el, { ...ctx, inPre: true })}\\f0\\fs${ctx.bodySize}\\par `;
|
|
213
540
|
break;
|
|
214
541
|
case 'hr':
|
|
215
|
-
rtf += `\\pard\\qc \\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\par
|
|
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 `;
|
|
216
543
|
break;
|
|
217
544
|
case 'ul': {
|
|
218
545
|
const items = el.querySelectorAll(':scope > li');
|
|
219
546
|
items.forEach((li) => {
|
|
220
|
-
rtf += `\\pard\\li720\\fi-360\\bullet\\tab ${walkChildren(li, ctx)}\\par
|
|
547
|
+
rtf += `\\pard\\li720\\fi-360\\bullet\\tab ${walkChildren(li, ctx)}\\par `;
|
|
221
548
|
});
|
|
222
549
|
break;
|
|
223
550
|
}
|
|
@@ -225,7 +552,7 @@ function walkChildren(parent, ctx) {
|
|
|
225
552
|
let counter = 1;
|
|
226
553
|
const items = el.querySelectorAll(':scope > li');
|
|
227
554
|
items.forEach((li) => {
|
|
228
|
-
rtf += `\\pard\\li720\\fi-360 ${counter}.\\tab ${walkChildren(li, ctx)}\\par
|
|
555
|
+
rtf += `\\pard\\li720\\fi-360 ${counter}.\\tab ${walkChildren(li, ctx)}\\par `;
|
|
229
556
|
counter++;
|
|
230
557
|
});
|
|
231
558
|
break;
|
|
@@ -257,7 +584,7 @@ function walkChildren(parent, ctx) {
|
|
|
257
584
|
break;
|
|
258
585
|
case 'code':
|
|
259
586
|
if (!ctx.inPre) {
|
|
260
|
-
rtf += `\\
|
|
587
|
+
rtf += `\\f1 ${walkChildren(el, ctx)}\\f0 `;
|
|
261
588
|
}
|
|
262
589
|
else {
|
|
263
590
|
rtf += walkChildren(el, ctx);
|
|
@@ -274,20 +601,38 @@ function walkChildren(parent, ctx) {
|
|
|
274
601
|
}
|
|
275
602
|
break;
|
|
276
603
|
}
|
|
604
|
+
// <figure><img><figcaption>caption</figcaption></figure> — the shape the
|
|
605
|
+
// editor inserts. The caption is written as its own paragraph below the
|
|
606
|
+
// picture, tagged with an ignorable {\*\inkcap} destination so it can be
|
|
607
|
+
// re-attached to the figure on import. Other RTF readers skip the tag and
|
|
608
|
+
// simply show an italic line under the image.
|
|
609
|
+
case 'figure': {
|
|
610
|
+
const img = el.querySelector('img');
|
|
611
|
+
const caption = (el.querySelector('figcaption')?.textContent || '').trim();
|
|
612
|
+
const align = alignControl(el);
|
|
613
|
+
if (img)
|
|
614
|
+
rtf += `\\pard${align} ${imageBody(img)}\\par `;
|
|
615
|
+
else
|
|
616
|
+
rtf += walkChildren(el, ctx);
|
|
617
|
+
if (caption) {
|
|
618
|
+
rtf += `{\\*\\inkcap}\\pard${align}\\i ${escapeRtf(caption)}\\i0\\par `;
|
|
619
|
+
}
|
|
620
|
+
break;
|
|
621
|
+
}
|
|
277
622
|
case 'img': {
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
const
|
|
281
|
-
rtf += `\\pard
|
|
623
|
+
// A picture directly under the editor root has no paragraph of its
|
|
624
|
+
// own; anywhere else it is inline content of the enclosing block.
|
|
625
|
+
const standalone = el.parentNode === ctx.root;
|
|
626
|
+
rtf += standalone ? `\\pard ${imageBody(el)}\\par ` : imageBody(el);
|
|
282
627
|
break;
|
|
283
628
|
}
|
|
284
629
|
case 'br':
|
|
285
|
-
rtf += '\\line
|
|
630
|
+
rtf += '\\line ';
|
|
286
631
|
break;
|
|
287
632
|
case 'span': {
|
|
288
633
|
const colorPfx = getColorPrefix(el, ctx);
|
|
289
634
|
const sizePfx = getSizePrefix(el);
|
|
290
|
-
const sizesfx = sizePfx ?
|
|
635
|
+
const sizesfx = sizePfx ? `\\fs${ctx.bodySize} ` : '';
|
|
291
636
|
const colorSfx = colorPfx ? '\\cf0 ' : '';
|
|
292
637
|
rtf += `${colorPfx}${sizePfx}${walkChildren(el, ctx)}${sizesfx}${colorSfx}`;
|
|
293
638
|
break;
|
|
@@ -306,6 +651,10 @@ function walkChildren(parent, ctx) {
|
|
|
306
651
|
}
|
|
307
652
|
return rtf;
|
|
308
653
|
}
|
|
654
|
+
/** Body size times a ratio, in half-points. */
|
|
655
|
+
function scaled(ctx, ratio) {
|
|
656
|
+
return Math.round(ctx.bodySize * ratio);
|
|
657
|
+
}
|
|
309
658
|
function getColorPrefix(el, ctx) {
|
|
310
659
|
// Support both inline style.color (spans) and the color attribute (<font>)
|
|
311
660
|
const color = el.style?.color ||
|
package/dist/utils.js
CHANGED
|
@@ -43,6 +43,19 @@ export function htmlToMarkdown(el) {
|
|
|
43
43
|
case 'img':
|
|
44
44
|
md += ``;
|
|
45
45
|
break;
|
|
46
|
+
case 'figure': {
|
|
47
|
+
const img = node.querySelector('img');
|
|
48
|
+
const caption = (node.querySelector('figcaption')?.textContent || '').trim();
|
|
49
|
+
if (img) {
|
|
50
|
+
md += ` || ''})\n\n`;
|
|
51
|
+
if (caption)
|
|
52
|
+
md += `*${caption}*\n\n`;
|
|
53
|
+
}
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
case 'figcaption':
|
|
57
|
+
// Rendered by the enclosing <figure>.
|
|
58
|
+
break;
|
|
46
59
|
case 'blockquote':
|
|
47
60
|
md += `> ${inner.trim()}\n\n`;
|
|
48
61
|
break;
|
package/package.json
CHANGED