svelte-rtf-editor 1.3.0 → 1.4.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 +75 -2
- package/dist/RtfViewer.svelte +18 -0
- package/dist/components/ImageOverlay.svelte +302 -0
- package/dist/components/ImageOverlay.svelte.d.ts +27 -0
- package/dist/components/InkEditor.svelte +556 -33
- package/dist/components/InkEditor.svelte.d.ts +9 -0
- package/dist/images.d.ts +86 -0
- package/dist/images.js +286 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/rtf-parser.d.ts +2 -1
- package/dist/rtf-parser.js +188 -23
- package/dist/rtf-writer.d.ts +16 -2
- package/dist/rtf-writer.js +302 -27
- package/dist/utils.js +13 -0
- package/package.json +1 -1
package/dist/rtf-writer.js
CHANGED
|
@@ -4,12 +4,14 @@
|
|
|
4
4
|
* Walks a contenteditable DOM tree and produces a valid RTF 1.x string.
|
|
5
5
|
* Supports: bold, italic, underline, strikethrough, font sizes (h1–h3),
|
|
6
6
|
* foreground colors (\cf), paragraphs, bullet/numbered lists, blockquotes,
|
|
7
|
-
* code blocks, hyperlinks, images (as
|
|
8
|
-
* rules, line breaks, and Unicode characters.
|
|
7
|
+
* code blocks, hyperlinks, images (embedded as \pict picture groups with
|
|
8
|
+
* their captions), horizontal rules, line breaks, and Unicode characters.
|
|
9
9
|
*
|
|
10
10
|
* Background color is intentionally not written — RTF highlight support
|
|
11
11
|
* is inconsistent across viewers (TextEdit uses \AppleHighlight, not \highlight).
|
|
12
12
|
*/
|
|
13
|
+
/** 1 px at 96 dpi = 15 twips (1 twip = 1/1440 in). */
|
|
14
|
+
const TWIPS_PER_PX = 15;
|
|
13
15
|
// ── Color helpers ──
|
|
14
16
|
function parseColor(colorStr) {
|
|
15
17
|
if (!colorStr)
|
|
@@ -52,10 +54,17 @@ function colorKey(c) {
|
|
|
52
54
|
}
|
|
53
55
|
// ── RTF text escaping ──
|
|
54
56
|
function escapeRtf(text) {
|
|
57
|
+
// RTF readers ignore raw CR/LF, so a newline inside text content rendered as
|
|
58
|
+
// nothing ("Hello\nworld" → "Helloworld") while still emitting bytes that a
|
|
59
|
+
// CR-terminated transport treats as a record separator. Collapsing a run to
|
|
60
|
+
// the single space a browser would have shown is both safer and more
|
|
61
|
+
// faithful. Text is never split on newlines after this point, so <pre>
|
|
62
|
+
// content must be divided into lines before it gets here.
|
|
63
|
+
const source = text.replace(/[\r\n]+/g, ' ');
|
|
55
64
|
let out = '';
|
|
56
|
-
for (let i = 0; i <
|
|
57
|
-
const ch =
|
|
58
|
-
const code =
|
|
65
|
+
for (let i = 0; i < source.length; i++) {
|
|
66
|
+
const ch = source[i];
|
|
67
|
+
const code = source.charCodeAt(i);
|
|
59
68
|
if (ch === '\\')
|
|
60
69
|
out += '\\\\';
|
|
61
70
|
else if (ch === '{')
|
|
@@ -71,6 +80,241 @@ function escapeRtf(text) {
|
|
|
71
80
|
}
|
|
72
81
|
return out;
|
|
73
82
|
}
|
|
83
|
+
// ── Image helpers ──
|
|
84
|
+
const B64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
85
|
+
const B64_LOOKUP = (() => {
|
|
86
|
+
const table = new Int16Array(128).fill(-1);
|
|
87
|
+
for (let i = 0; i < B64_ALPHABET.length; i++)
|
|
88
|
+
table[B64_ALPHABET.charCodeAt(i)] = i;
|
|
89
|
+
return table;
|
|
90
|
+
})();
|
|
91
|
+
// Uppercase: the RTF spec treats picture data as case-insensitive, but
|
|
92
|
+
// downstream consumers (HL7 interface engines, for one) sometimes match the
|
|
93
|
+
// leading PNG signature literally as 89504E470D0A1A0A.
|
|
94
|
+
const HEX_BYTE = Array.from({ length: 256 }, (_, i) => i.toString(16).padStart(2, '0').toUpperCase());
|
|
95
|
+
/** Decode base64 to bytes without depending on atob/Buffer (works in any runtime). */
|
|
96
|
+
function base64ToBytes(b64) {
|
|
97
|
+
let buffer = 0;
|
|
98
|
+
let bits = 0;
|
|
99
|
+
let out = 0;
|
|
100
|
+
const bytes = new Uint8Array(Math.ceil((b64.length * 3) / 4));
|
|
101
|
+
for (let i = 0; i < b64.length; i++) {
|
|
102
|
+
const code = b64.charCodeAt(i);
|
|
103
|
+
if (code === 61 /* = */)
|
|
104
|
+
break;
|
|
105
|
+
const v = code < 128 ? B64_LOOKUP[code] : -1;
|
|
106
|
+
if (v < 0) {
|
|
107
|
+
// Whitespace inside a data URL is legal; anything else is not base64.
|
|
108
|
+
if (code === 32 || code === 9 || code === 10 || code === 13)
|
|
109
|
+
continue;
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
buffer = (buffer << 6) | v;
|
|
113
|
+
bits += 6;
|
|
114
|
+
if (bits >= 8) {
|
|
115
|
+
bits -= 8;
|
|
116
|
+
bytes[out++] = (buffer >> bits) & 0xff;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return out > 0 ? bytes.subarray(0, out) : null;
|
|
120
|
+
}
|
|
121
|
+
function bytesToHex(bytes) {
|
|
122
|
+
const parts = new Array(bytes.length);
|
|
123
|
+
for (let i = 0; i < bytes.length; i++)
|
|
124
|
+
parts[i] = HEX_BYTE[bytes[i]];
|
|
125
|
+
return parts.join('');
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Picture format according to the bytes themselves, not the label they arrived
|
|
129
|
+
* with. A file named .png can hold WebP — operating systems assign the MIME
|
|
130
|
+
* type from the extension — and writing those bytes as \pngblip produces a
|
|
131
|
+
* picture no reader can decode.
|
|
132
|
+
*/
|
|
133
|
+
function sniffBytes(b) {
|
|
134
|
+
if (b.length >= 8 &&
|
|
135
|
+
b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47 &&
|
|
136
|
+
b[4] === 0x0d && b[5] === 0x0a && b[6] === 0x1a && b[7] === 0x0a) {
|
|
137
|
+
return 'png';
|
|
138
|
+
}
|
|
139
|
+
if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff)
|
|
140
|
+
return 'jpeg';
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
/** Read the intrinsic size out of the PNG IHDR chunk. */
|
|
144
|
+
function pngSize(b) {
|
|
145
|
+
if (b.length < 24)
|
|
146
|
+
return null;
|
|
147
|
+
if (b[0] !== 0x89 || b[1] !== 0x50 || b[2] !== 0x4e || b[3] !== 0x47)
|
|
148
|
+
return null;
|
|
149
|
+
const w = ((b[16] << 24) | (b[17] << 16) | (b[18] << 8) | b[19]) >>> 0;
|
|
150
|
+
const h = ((b[20] << 24) | (b[21] << 16) | (b[22] << 8) | b[23]) >>> 0;
|
|
151
|
+
return w && h ? { w, h } : null;
|
|
152
|
+
}
|
|
153
|
+
/** Read the intrinsic size out of the first JPEG start-of-frame marker. */
|
|
154
|
+
function jpegSize(b) {
|
|
155
|
+
if (b.length < 4 || b[0] !== 0xff || b[1] !== 0xd8)
|
|
156
|
+
return null;
|
|
157
|
+
let i = 2;
|
|
158
|
+
while (i + 9 < b.length) {
|
|
159
|
+
if (b[i] !== 0xff) {
|
|
160
|
+
i++;
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
const marker = b[i + 1];
|
|
164
|
+
// Standalone markers carry no length field.
|
|
165
|
+
if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
|
|
166
|
+
i += 2;
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
const len = (b[i + 2] << 8) | b[i + 3];
|
|
170
|
+
// SOF0–SOF15, excluding DHT (c4), JPG (c8) and DAC (cc)
|
|
171
|
+
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
|
|
172
|
+
const h = (b[i + 5] << 8) | b[i + 6];
|
|
173
|
+
const w = (b[i + 7] << 8) | b[i + 8];
|
|
174
|
+
return w && h ? { w, h } : null;
|
|
175
|
+
}
|
|
176
|
+
if (len < 2)
|
|
177
|
+
break;
|
|
178
|
+
i += 2 + len;
|
|
179
|
+
}
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
function parsePx(value) {
|
|
183
|
+
if (!value)
|
|
184
|
+
return 0;
|
|
185
|
+
const m = value.match(/^([\d.]+)px$/);
|
|
186
|
+
return m ? Math.round(parseFloat(m[1])) : 0;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Displayed size of an image in CSS pixels. Prefers the explicit width the
|
|
190
|
+
* editor writes when the user resizes, then the width/height attributes, then
|
|
191
|
+
* the intrinsic size decoded from the image bytes (naturalWidth is unavailable
|
|
192
|
+
* when the DOM is detached or server-side).
|
|
193
|
+
*/
|
|
194
|
+
function displaySize(el, natural) {
|
|
195
|
+
const img = el;
|
|
196
|
+
const nw = natural.w || img.naturalWidth || 0;
|
|
197
|
+
const nh = natural.h || img.naturalHeight || 0;
|
|
198
|
+
const attrW = parseInt(el.getAttribute('width') || '', 10);
|
|
199
|
+
const attrH = parseInt(el.getAttribute('height') || '', 10);
|
|
200
|
+
const w = parsePx(el.style?.width) || (Number.isFinite(attrW) ? attrW : 0) || nw;
|
|
201
|
+
let h = parsePx(el.style?.height) || (Number.isFinite(attrH) ? attrH : 0);
|
|
202
|
+
if (!h)
|
|
203
|
+
h = nw && nh ? Math.round((w * nh) / nw) : nh;
|
|
204
|
+
return { w, h };
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Convert a data-URL <img> into an RTF \pict group. Returns '' for images the
|
|
208
|
+
* RTF format cannot carry (remote URLs, or formats other than PNG/JPEG), so
|
|
209
|
+
* the caller can fall back to a text placeholder.
|
|
210
|
+
*/
|
|
211
|
+
function pictureRtf(el) {
|
|
212
|
+
const src = el.getAttribute('src') || '';
|
|
213
|
+
// Accept any image data URL and let the bytes decide the format — the label
|
|
214
|
+
// is not trustworthy, and a correctly-labelled PNG is not the only way to
|
|
215
|
+
// arrive at PNG bytes.
|
|
216
|
+
const m = src.match(/^data:image\/[a-z0-9.+-]+;base64,([\s\S]+)$/i);
|
|
217
|
+
if (!m)
|
|
218
|
+
return '';
|
|
219
|
+
const bytes = base64ToBytes(m[1]);
|
|
220
|
+
if (!bytes || bytes.length === 0)
|
|
221
|
+
return '';
|
|
222
|
+
const format = sniffBytes(bytes);
|
|
223
|
+
// Neither PNG nor JPEG: RTF cannot carry it, so fall back to the placeholder
|
|
224
|
+
// rather than emitting a blip whose declared type contradicts its content.
|
|
225
|
+
if (!format)
|
|
226
|
+
return '';
|
|
227
|
+
const isPng = format === 'png';
|
|
228
|
+
const natural = (isPng ? pngSize(bytes) : jpegSize(bytes)) ?? { w: 0, h: 0 };
|
|
229
|
+
const size = displaySize(el, natural);
|
|
230
|
+
if (!size.w || !size.h)
|
|
231
|
+
return '';
|
|
232
|
+
const picw = natural.w || size.w;
|
|
233
|
+
const pich = natural.h || size.h;
|
|
234
|
+
// The space after \pichgoal is the control word's delimiter — without it the
|
|
235
|
+
// leading hex digits would be read as part of its numeric parameter.
|
|
236
|
+
return (`{\\pict${isPng ? '\\pngblip' : '\\jpegblip'}` +
|
|
237
|
+
`\\picw${picw}\\pich${pich}` +
|
|
238
|
+
`\\picwgoal${Math.round(size.w * TWIPS_PER_PX)}\\pichgoal${Math.round(size.h * TWIPS_PER_PX)} ` +
|
|
239
|
+
`${bytesToHex(bytes)}}`);
|
|
240
|
+
}
|
|
241
|
+
/** Picture group if the bytes are embeddable, otherwise an italic placeholder. */
|
|
242
|
+
function imageBody(el) {
|
|
243
|
+
const pict = pictureRtf(el);
|
|
244
|
+
if (pict)
|
|
245
|
+
return pict;
|
|
246
|
+
const label = el.getAttribute('alt') || el.getAttribute('src') || 'image';
|
|
247
|
+
return `\\i [Image: ${escapeRtf(label)}]\\i0 `;
|
|
248
|
+
}
|
|
249
|
+
/** \ql / \qc / \qr for a figure's text-align. */
|
|
250
|
+
function alignControl(el) {
|
|
251
|
+
const align = (el.style?.textAlign || '').toLowerCase();
|
|
252
|
+
if (align === 'center')
|
|
253
|
+
return '\\qc';
|
|
254
|
+
if (align === 'right')
|
|
255
|
+
return '\\qr';
|
|
256
|
+
if (align === 'justify')
|
|
257
|
+
return '\\qj';
|
|
258
|
+
return '\\ql';
|
|
259
|
+
}
|
|
260
|
+
/** Decoded byte length of a base64 payload, without decoding it. */
|
|
261
|
+
function base64ByteLength(base64) {
|
|
262
|
+
const padding = base64.endsWith('==') ? 2 : base64.endsWith('=') ? 1 : 0;
|
|
263
|
+
return Math.max(0, Math.floor((base64.length * 3) / 4) - padding);
|
|
264
|
+
}
|
|
265
|
+
/** Bytes of RTF header, font table and colour table that precede the body. */
|
|
266
|
+
const RTF_PREAMBLE_BYTES = 200;
|
|
267
|
+
/** Control words wrapping one \pict group. */
|
|
268
|
+
const PICTURE_OVERHEAD_BYTES = 80;
|
|
269
|
+
/**
|
|
270
|
+
* Control-word bytes the writer wraps around each kind of element — the opening
|
|
271
|
+
* and closing runs measured from walkChildren. Anything not listed costs
|
|
272
|
+
* nothing of its own beyond the text it contains.
|
|
273
|
+
*/
|
|
274
|
+
const BLOCK_OVERHEAD_BYTES = {
|
|
275
|
+
p: 14,
|
|
276
|
+
div: 14,
|
|
277
|
+
h1: 32,
|
|
278
|
+
h2: 32,
|
|
279
|
+
h3: 32,
|
|
280
|
+
li: 35,
|
|
281
|
+
blockquote: 22,
|
|
282
|
+
pre: 27,
|
|
283
|
+
figure: 15,
|
|
284
|
+
figcaption: 29,
|
|
285
|
+
tr: 45,
|
|
286
|
+
td: 55,
|
|
287
|
+
th: 69 // a header cell also carries \clbrdrb\brdrs
|
|
288
|
+
};
|
|
289
|
+
/**
|
|
290
|
+
* Size htmlToRtf would produce, without doing the conversion.
|
|
291
|
+
*
|
|
292
|
+
* Useful for a live indicator — the exact figure requires hex-encoding every
|
|
293
|
+
* picture, which is too much work to repeat on each keystroke.
|
|
294
|
+
*
|
|
295
|
+
* Picture data, which dominates any document that has one, is counted exactly:
|
|
296
|
+
* two characters per byte. Everything else is approximated per element, which
|
|
297
|
+
* puts the error at a bounded number of bytes rather than a proportion — so it
|
|
298
|
+
* is negligible precisely when the number matters, and never more than a few
|
|
299
|
+
* hundred bytes even when it does not. The output is ASCII, so bytes and
|
|
300
|
+
* characters are the same number.
|
|
301
|
+
*/
|
|
302
|
+
export function estimateRtfBytes(editorEl) {
|
|
303
|
+
let total = RTF_PREAMBLE_BYTES;
|
|
304
|
+
for (const img of Array.from(editorEl.querySelectorAll('img'))) {
|
|
305
|
+
const src = img.getAttribute('src') || '';
|
|
306
|
+
const comma = src.indexOf(',');
|
|
307
|
+
// A picture that cannot be embedded costs only its placeholder text.
|
|
308
|
+
if (!src.startsWith('data:image/') || comma < 0)
|
|
309
|
+
continue;
|
|
310
|
+
total += base64ByteLength(src.slice(comma + 1)) * 2 + PICTURE_OVERHEAD_BYTES;
|
|
311
|
+
}
|
|
312
|
+
total += (editorEl.textContent || '').length;
|
|
313
|
+
for (const el of Array.from(editorEl.querySelectorAll('*'))) {
|
|
314
|
+
total += BLOCK_OVERHEAD_BYTES[el.tagName.toLowerCase()] ?? 0;
|
|
315
|
+
}
|
|
316
|
+
return total;
|
|
317
|
+
}
|
|
74
318
|
// ── DOM walker / RTF generator ──
|
|
75
319
|
export function htmlToRtf(editorEl) {
|
|
76
320
|
const colorMap = new Map();
|
|
@@ -93,7 +337,13 @@ export function htmlToRtf(editorEl) {
|
|
|
93
337
|
'{\\f1\\froman\\fcharset0 Georgia;}' +
|
|
94
338
|
'{\\f2\\fmodern\\fcharset0 Courier New;}' +
|
|
95
339
|
'}';
|
|
96
|
-
const body = walkChildren(editorEl, {
|
|
340
|
+
const body = walkChildren(editorEl, {
|
|
341
|
+
colorIndex,
|
|
342
|
+
inPre: false,
|
|
343
|
+
listCounter: 0,
|
|
344
|
+
inTableCell: false,
|
|
345
|
+
root: editorEl
|
|
346
|
+
});
|
|
97
347
|
const rtf = '{\\rtf1\\ansi\\ansicpg1252\\deff0' +
|
|
98
348
|
fontTable +
|
|
99
349
|
colorTable +
|
|
@@ -101,7 +351,11 @@ export function htmlToRtf(editorEl) {
|
|
|
101
351
|
'\\pard\\f0\\fs24 ' +
|
|
102
352
|
body +
|
|
103
353
|
'}';
|
|
104
|
-
|
|
354
|
+
// Backstop for the single-line guarantee. escapeRtf already collapses CR/LF
|
|
355
|
+
// out of every text path, so this should never have anything to do — but the
|
|
356
|
+
// document is worthless to a CR-terminated transport if one slips through,
|
|
357
|
+
// and no newline carries meaning in the output.
|
|
358
|
+
return rtf.replace(/[\r\n]+/g, ' ');
|
|
105
359
|
}
|
|
106
360
|
function addColor(colorStr, colorMap) {
|
|
107
361
|
if (!colorStr)
|
|
@@ -126,7 +380,10 @@ function walkChildren(parent, ctx) {
|
|
|
126
380
|
if (node.nodeType === Node.TEXT_NODE) {
|
|
127
381
|
const text = node.textContent || '';
|
|
128
382
|
if (ctx.inPre) {
|
|
129
|
-
|
|
383
|
+
// Split before escaping — escapeRtf collapses newlines, so the
|
|
384
|
+
// line structure a <pre> block depends on has to become \line
|
|
385
|
+
// first. Handles CR, LF and CRLF alike.
|
|
386
|
+
rtf += text.split(/\r\n|\r|\n/).map(escapeRtf).join('\\line ');
|
|
130
387
|
}
|
|
131
388
|
else {
|
|
132
389
|
rtf += escapeRtf(text);
|
|
@@ -139,13 +396,13 @@ function walkChildren(parent, ctx) {
|
|
|
139
396
|
const tag = el.tagName.toLowerCase();
|
|
140
397
|
switch (tag) {
|
|
141
398
|
case 'h1':
|
|
142
|
-
rtf += `\\pard\\f1\\fs48\\b ${walkChildren(el, ctx)}\\b0\\f0\\fs24\\par
|
|
399
|
+
rtf += `\\pard\\f1\\fs48\\b ${walkChildren(el, ctx)}\\b0\\f0\\fs24\\par `;
|
|
143
400
|
break;
|
|
144
401
|
case 'h2':
|
|
145
|
-
rtf += `\\pard\\f1\\fs36\\b ${walkChildren(el, ctx)}\\b0\\f0\\fs24\\par
|
|
402
|
+
rtf += `\\pard\\f1\\fs36\\b ${walkChildren(el, ctx)}\\b0\\f0\\fs24\\par `;
|
|
146
403
|
break;
|
|
147
404
|
case 'h3':
|
|
148
|
-
rtf += `\\pard\\f0\\fs28\\b ${walkChildren(el, ctx)}\\b0\\fs24\\par
|
|
405
|
+
rtf += `\\pard\\f0\\fs28\\b ${walkChildren(el, ctx)}\\b0\\fs24\\par `;
|
|
149
406
|
break;
|
|
150
407
|
case 'p':
|
|
151
408
|
case 'div': {
|
|
@@ -159,13 +416,13 @@ function walkChildren(parent, ctx) {
|
|
|
159
416
|
const isBlankLine = el.childNodes.length === 0 ||
|
|
160
417
|
(el.childNodes.length === 1 && el.childNodes[0].nodeName === 'BR');
|
|
161
418
|
if (isBlankLine) {
|
|
162
|
-
rtf += '\\par
|
|
419
|
+
rtf += '\\par ';
|
|
163
420
|
break;
|
|
164
421
|
}
|
|
165
422
|
const inlineRtf = walkChildren(el, ctx);
|
|
166
423
|
const colorPrefix = getColorPrefix(el, ctx);
|
|
167
424
|
const colorSuffix = colorPrefix ? '\\cf0 ' : '';
|
|
168
|
-
rtf += `\\pard ${colorPrefix}${inlineRtf}${colorSuffix}\\par
|
|
425
|
+
rtf += `\\pard ${colorPrefix}${inlineRtf}${colorSuffix}\\par `;
|
|
169
426
|
break;
|
|
170
427
|
}
|
|
171
428
|
case 'table': {
|
|
@@ -187,37 +444,37 @@ function walkChildren(parent, ctx) {
|
|
|
187
444
|
}
|
|
188
445
|
cellRights[cellRights.length - 1] = PAGE_WIDTH; // snap last edge to avoid rounding drift
|
|
189
446
|
// Row definition
|
|
190
|
-
let rowRtf = '{
|
|
447
|
+
let rowRtf = '{\\trowd\\trgaph120';
|
|
191
448
|
for (let ci = 0; ci < cells.length; ci++) {
|
|
192
449
|
if (isHeader)
|
|
193
450
|
rowRtf += `\\clbrdrb\\brdrs`;
|
|
194
451
|
rowRtf += `\\cellx${cellRights[ci]}`;
|
|
195
452
|
}
|
|
196
|
-
rowRtf += '
|
|
453
|
+
rowRtf += '\\trkeep\\intbl{';
|
|
197
454
|
// Cell bodies
|
|
198
455
|
const cellCtx = { ...ctx, inTableCell: true };
|
|
199
456
|
for (const cell of cells) {
|
|
200
457
|
const content = walkChildren(cell, cellCtx);
|
|
201
|
-
rowRtf += `{\\pard\\intbl \\f0 \\sa0 \\li0 \\fi0 ${content}\\par}
|
|
458
|
+
rowRtf += `{\\pard\\intbl \\f0 \\sa0 \\li0 \\fi0 ${content}\\par}\\cell`;
|
|
202
459
|
}
|
|
203
|
-
rowRtf += '}
|
|
460
|
+
rowRtf += '}\\intbl\\row}';
|
|
204
461
|
rtf += rowRtf;
|
|
205
462
|
}
|
|
206
463
|
break;
|
|
207
464
|
}
|
|
208
465
|
case 'blockquote':
|
|
209
|
-
rtf += `\\pard\\li720\\i ${walkChildren(el, ctx)}\\i0\\par
|
|
466
|
+
rtf += `\\pard\\li720\\i ${walkChildren(el, ctx)}\\i0\\par `;
|
|
210
467
|
break;
|
|
211
468
|
case 'pre':
|
|
212
|
-
rtf += `\\pard\\f2\\fs20 ${walkChildren(el, { ...ctx, inPre: true })}\\f0\\fs24\\par
|
|
469
|
+
rtf += `\\pard\\f2\\fs20 ${walkChildren(el, { ...ctx, inPre: true })}\\f0\\fs24\\par `;
|
|
213
470
|
break;
|
|
214
471
|
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
|
|
472
|
+
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
473
|
break;
|
|
217
474
|
case 'ul': {
|
|
218
475
|
const items = el.querySelectorAll(':scope > li');
|
|
219
476
|
items.forEach((li) => {
|
|
220
|
-
rtf += `\\pard\\li720\\fi-360\\bullet\\tab ${walkChildren(li, ctx)}\\par
|
|
477
|
+
rtf += `\\pard\\li720\\fi-360\\bullet\\tab ${walkChildren(li, ctx)}\\par `;
|
|
221
478
|
});
|
|
222
479
|
break;
|
|
223
480
|
}
|
|
@@ -225,7 +482,7 @@ function walkChildren(parent, ctx) {
|
|
|
225
482
|
let counter = 1;
|
|
226
483
|
const items = el.querySelectorAll(':scope > li');
|
|
227
484
|
items.forEach((li) => {
|
|
228
|
-
rtf += `\\pard\\li720\\fi-360 ${counter}.\\tab ${walkChildren(li, ctx)}\\par
|
|
485
|
+
rtf += `\\pard\\li720\\fi-360 ${counter}.\\tab ${walkChildren(li, ctx)}\\par `;
|
|
229
486
|
counter++;
|
|
230
487
|
});
|
|
231
488
|
break;
|
|
@@ -274,15 +531,33 @@ function walkChildren(parent, ctx) {
|
|
|
274
531
|
}
|
|
275
532
|
break;
|
|
276
533
|
}
|
|
534
|
+
// <figure><img><figcaption>caption</figcaption></figure> — the shape the
|
|
535
|
+
// editor inserts. The caption is written as its own paragraph below the
|
|
536
|
+
// picture, tagged with an ignorable {\*\inkcap} destination so it can be
|
|
537
|
+
// re-attached to the figure on import. Other RTF readers skip the tag and
|
|
538
|
+
// simply show an italic line under the image.
|
|
539
|
+
case 'figure': {
|
|
540
|
+
const img = el.querySelector('img');
|
|
541
|
+
const caption = (el.querySelector('figcaption')?.textContent || '').trim();
|
|
542
|
+
const align = alignControl(el);
|
|
543
|
+
if (img)
|
|
544
|
+
rtf += `\\pard${align} ${imageBody(img)}\\par `;
|
|
545
|
+
else
|
|
546
|
+
rtf += walkChildren(el, ctx);
|
|
547
|
+
if (caption) {
|
|
548
|
+
rtf += `{\\*\\inkcap}\\pard${align}\\i ${escapeRtf(caption)}\\i0\\par `;
|
|
549
|
+
}
|
|
550
|
+
break;
|
|
551
|
+
}
|
|
277
552
|
case 'img': {
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
const
|
|
281
|
-
rtf += `\\pard
|
|
553
|
+
// A picture directly under the editor root has no paragraph of its
|
|
554
|
+
// own; anywhere else it is inline content of the enclosing block.
|
|
555
|
+
const standalone = el.parentNode === ctx.root;
|
|
556
|
+
rtf += standalone ? `\\pard ${imageBody(el)}\\par ` : imageBody(el);
|
|
282
557
|
break;
|
|
283
558
|
}
|
|
284
559
|
case 'br':
|
|
285
|
-
rtf += '\\line
|
|
560
|
+
rtf += '\\line ';
|
|
286
561
|
break;
|
|
287
562
|
case 'span': {
|
|
288
563
|
const colorPfx = getColorPrefix(el, ctx);
|
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