svelte-rtf-editor 1.2.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 +237 -28
- 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-parser.js
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* Parses RTF control words and groups into an HTML string.
|
|
5
5
|
* Supports: bold, italic, underline, strikethrough, super/subscript,
|
|
6
6
|
* font size, foreground color (\cf), highlight background color (\highlight),
|
|
7
|
-
* color table, font table, paragraphs, bullet/numbered lists,
|
|
7
|
+
* color table, font table, paragraphs, bullet/numbered lists, tables,
|
|
8
|
+
* embedded pictures (\pict PNG/JPEG blips, with captions),
|
|
8
9
|
* Unicode characters, hex escapes, and nested groups.
|
|
9
10
|
*
|
|
10
11
|
* \cb (character background) is silently ignored on import.
|
|
@@ -28,6 +29,125 @@ const HIGHLIGHT_COLORS = {
|
|
|
28
29
|
15: '#808080', // Dark Gray
|
|
29
30
|
16: '#c0c0c0', // Light Gray
|
|
30
31
|
};
|
|
32
|
+
// ── Picture helpers ──
|
|
33
|
+
const B64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
34
|
+
/** 1 px at 96 dpi = 15 twips. */
|
|
35
|
+
const TWIPS_PER_PX = 15;
|
|
36
|
+
/** A paragraph made up of nothing but pictures is rendered as a <figure>. */
|
|
37
|
+
const IMAGE_ONLY_PARAGRAPH = /^(?:<img\b[^>]*>\s*)+$/;
|
|
38
|
+
/** Nibble value of each hex digit's char code; -1 for anything else. */
|
|
39
|
+
const HEX_NIBBLE = (() => {
|
|
40
|
+
const table = new Int8Array(128).fill(-1);
|
|
41
|
+
for (let i = 0; i < 10; i++)
|
|
42
|
+
table[48 + i] = i; // '0'–'9'
|
|
43
|
+
for (let i = 0; i < 6; i++) {
|
|
44
|
+
table[97 + i] = 10 + i; // 'a'–'f'
|
|
45
|
+
table[65 + i] = 10 + i; // 'A'–'F'
|
|
46
|
+
}
|
|
47
|
+
return table;
|
|
48
|
+
})();
|
|
49
|
+
/** Hex picture data → base64, without depending on btoa/Buffer. */
|
|
50
|
+
function hexToBase64(hex) {
|
|
51
|
+
const byteCount = hex.length >> 1;
|
|
52
|
+
// A multi-MB picture runs to millions of digits, so this reads char codes
|
|
53
|
+
// against a lookup table rather than slicing and parsing per byte, and
|
|
54
|
+
// collects into an array rather than concatenating per output character.
|
|
55
|
+
const out = new Array(Math.ceil(byteCount / 3) * 4);
|
|
56
|
+
let written = 0;
|
|
57
|
+
let buffer = 0;
|
|
58
|
+
let bits = 0;
|
|
59
|
+
for (let i = 0; i < byteCount; i++) {
|
|
60
|
+
const hi = HEX_NIBBLE[hex.charCodeAt(i * 2)] ?? -1;
|
|
61
|
+
const lo = HEX_NIBBLE[hex.charCodeAt(i * 2 + 1)] ?? -1;
|
|
62
|
+
if (hi < 0 || lo < 0)
|
|
63
|
+
return '';
|
|
64
|
+
buffer = (buffer << 8) | ((hi << 4) | lo);
|
|
65
|
+
bits += 8;
|
|
66
|
+
while (bits >= 6) {
|
|
67
|
+
bits -= 6;
|
|
68
|
+
out[written++] = B64_ALPHABET[(buffer >> bits) & 0x3f];
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (bits > 0) {
|
|
72
|
+
out[written++] = B64_ALPHABET[(buffer << (6 - bits)) & 0x3f];
|
|
73
|
+
}
|
|
74
|
+
while (written % 4 !== 0)
|
|
75
|
+
out[written++] = '=';
|
|
76
|
+
out.length = written;
|
|
77
|
+
return out.join('');
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Render a {\pict …} group as an <img> with a data URL. Returns '' for picture
|
|
81
|
+
* formats a browser cannot display (metafiles, device-dependent bitmaps).
|
|
82
|
+
*
|
|
83
|
+
* Only hexadecimal picture data is read. RTF also allows \binN, where the next
|
|
84
|
+
* N bytes are raw binary — rare outside of Word's own output, and it cannot
|
|
85
|
+
* survive readRtfFile() reading the document as text anyway. Such a picture is
|
|
86
|
+
* skipped rather than mis-decoded: \bin is not a recognised control word here,
|
|
87
|
+
* so its bytes fall through the tokenizer and the hex filter discards them.
|
|
88
|
+
*/
|
|
89
|
+
function renderPicture(group) {
|
|
90
|
+
let mime = '';
|
|
91
|
+
let picw = 0, pich = 0, wgoal = 0, hgoal = 0;
|
|
92
|
+
let scaleX = 100, scaleY = 100;
|
|
93
|
+
const hexParts = [];
|
|
94
|
+
for (const node of group.children) {
|
|
95
|
+
if (node.type === 'control') {
|
|
96
|
+
switch (node.word) {
|
|
97
|
+
case 'pngblip':
|
|
98
|
+
mime = 'image/png';
|
|
99
|
+
break;
|
|
100
|
+
case 'jpegblip':
|
|
101
|
+
mime = 'image/jpeg';
|
|
102
|
+
break;
|
|
103
|
+
case 'picw':
|
|
104
|
+
picw = node.param ?? 0;
|
|
105
|
+
break;
|
|
106
|
+
case 'pich':
|
|
107
|
+
pich = node.param ?? 0;
|
|
108
|
+
break;
|
|
109
|
+
case 'picwgoal':
|
|
110
|
+
wgoal = node.param ?? 0;
|
|
111
|
+
break;
|
|
112
|
+
case 'pichgoal':
|
|
113
|
+
hgoal = node.param ?? 0;
|
|
114
|
+
break;
|
|
115
|
+
case 'picscalex':
|
|
116
|
+
scaleX = node.param ?? 100;
|
|
117
|
+
break;
|
|
118
|
+
case 'picscaley':
|
|
119
|
+
scaleY = node.param ?? 100;
|
|
120
|
+
break;
|
|
121
|
+
default: break;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
else if (node.type === 'text') {
|
|
125
|
+
hexParts.push(node.value);
|
|
126
|
+
}
|
|
127
|
+
// Nested groups such as {\*\blipuid …} carry no picture data.
|
|
128
|
+
}
|
|
129
|
+
if (!mime)
|
|
130
|
+
return '';
|
|
131
|
+
const hex = hexParts.join('').replace(/[^0-9a-fA-F]/g, '');
|
|
132
|
+
if (hex.length < 8)
|
|
133
|
+
return '';
|
|
134
|
+
const base64 = hexToBase64(hex);
|
|
135
|
+
if (!base64)
|
|
136
|
+
return '';
|
|
137
|
+
const widthPx = wgoal
|
|
138
|
+
? Math.round(wgoal / TWIPS_PER_PX)
|
|
139
|
+
: Math.round((picw * scaleX) / 100);
|
|
140
|
+
const heightPx = hgoal
|
|
141
|
+
? Math.round(hgoal / TWIPS_PER_PX)
|
|
142
|
+
: Math.round((pich * scaleY) / 100);
|
|
143
|
+
const styles = [];
|
|
144
|
+
if (widthPx > 0)
|
|
145
|
+
styles.push(`width:${widthPx}px`);
|
|
146
|
+
if (widthPx > 0 && heightPx > 0)
|
|
147
|
+
styles.push('height:auto');
|
|
148
|
+
const style = styles.length ? ` style="${styles.join(';')}"` : '';
|
|
149
|
+
return `<img src="data:${mime};base64,${base64}"${style} alt="">`;
|
|
150
|
+
}
|
|
31
151
|
// ── Tokenizer ──
|
|
32
152
|
function tokenize(rtf) {
|
|
33
153
|
const tokens = [];
|
|
@@ -119,14 +239,18 @@ function tokenize(rtf) {
|
|
|
119
239
|
i++;
|
|
120
240
|
}
|
|
121
241
|
else {
|
|
122
|
-
// Plain text
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
242
|
+
// Plain text — slice up to the next special char. Slicing rather than
|
|
243
|
+
// appending char-by-char keeps embedded picture data (which arrives as
|
|
244
|
+
// one very long text run) from being quadratic.
|
|
245
|
+
const start = i;
|
|
246
|
+
while (i < len) {
|
|
247
|
+
const c = rtf[i];
|
|
248
|
+
if (c === '\\' || c === '{' || c === '}' || c === '\r' || c === '\n')
|
|
249
|
+
break;
|
|
126
250
|
i++;
|
|
127
251
|
}
|
|
128
|
-
if (
|
|
129
|
-
tokens.push({ type: 'text', value:
|
|
252
|
+
if (i > start) {
|
|
253
|
+
tokens.push({ type: 'text', value: rtf.substring(start, i) });
|
|
130
254
|
}
|
|
131
255
|
}
|
|
132
256
|
}
|
|
@@ -227,15 +351,28 @@ export function rtfToHtml(rtfString) {
|
|
|
227
351
|
let colorTable = [''];
|
|
228
352
|
let fontTable = new Map();
|
|
229
353
|
let currentParagraph = '';
|
|
230
|
-
|
|
354
|
+
const paragraphs = [];
|
|
355
|
+
const paragraphAligns = [];
|
|
356
|
+
const paragraphCaptions = [];
|
|
231
357
|
let paragraphHasText = false;
|
|
232
358
|
let paragraphHasListMarker = false;
|
|
359
|
+
let paragraphIsCaption = false;
|
|
360
|
+
/** Append a finished paragraph, keeping the parallel metadata arrays in sync. */
|
|
361
|
+
function pushParagraph(html, align, isCaption = false) {
|
|
362
|
+
paragraphs.push(html);
|
|
363
|
+
paragraphAligns.push(align);
|
|
364
|
+
paragraphCaptions.push(isCaption);
|
|
365
|
+
}
|
|
366
|
+
// Paragraph alignment (reset by \pard, affects cells and paragraphs)
|
|
367
|
+
let currentAlign = 'left';
|
|
233
368
|
// Table state
|
|
234
369
|
let tableRows = [];
|
|
235
370
|
let currentRowCells = [];
|
|
236
371
|
let currentRowIsHeader = false;
|
|
237
372
|
let currentRowCellRights = [];
|
|
373
|
+
let currentRowCellAligns = [];
|
|
238
374
|
let inTableCell = false;
|
|
375
|
+
let pendingBottomBorder = false; // set by \clbrdrb, confirmed by a real border type
|
|
239
376
|
// First pass: extract color table and font table from the root group
|
|
240
377
|
const rootGroup = tree.children[0]?.type === 'group' ? tree.children[0] : tree;
|
|
241
378
|
for (let i = 0; i < rootGroup.children.length; i++) {
|
|
@@ -325,7 +462,9 @@ export function rtfToHtml(rtfString) {
|
|
|
325
462
|
const destinationWords = new Set([
|
|
326
463
|
'fonttbl', 'colortbl', 'stylesheet', 'info', 'header', 'footer',
|
|
327
464
|
'headerl', 'headerr', 'headerf', 'footerl', 'footerr', 'footerf',
|
|
328
|
-
|
|
465
|
+
// \nonshppict duplicates the picture already carried by \*\shppict
|
|
466
|
+
'nonshppict',
|
|
467
|
+
'object', 'fldinst', 'xmlnstbl', 'listtable', 'listoverridetable',
|
|
329
468
|
'rsidtbl', 'generator', 'datafield', 'themedata', 'colorschememapping',
|
|
330
469
|
'latentstyles', 'datastore', 'mmathPr', 'author', 'operator',
|
|
331
470
|
'title', 'subject', 'doccomm', 'company', 'category', 'keywords'
|
|
@@ -343,31 +482,30 @@ export function rtfToHtml(rtfString) {
|
|
|
343
482
|
const right = row.cellRights[ci] ?? total;
|
|
344
483
|
const pct = ((right - prevRight) / total * 100).toFixed(1);
|
|
345
484
|
prevRight = right;
|
|
485
|
+
const align = row.cellAligns[ci] ?? 'left';
|
|
486
|
+
const alignStyle = align !== 'left' ? `;text-align:${align}` : '';
|
|
346
487
|
const style = row.isHeader
|
|
347
|
-
? `border-bottom:1px solid;padding:2px 6px;text-align
|
|
348
|
-
: `padding:2px 6px;width:${pct}
|
|
488
|
+
? `border-bottom:1px solid;padding:2px 6px;text-align:${align};width:${pct}%`
|
|
489
|
+
: `padding:2px 6px;width:${pct}%${alignStyle}`;
|
|
349
490
|
html += `<${tag} style="${style}">${row.cells[ci] || ''}</${tag}>`;
|
|
350
491
|
}
|
|
351
492
|
html += '</tr>';
|
|
352
493
|
}
|
|
353
494
|
html += '</table>';
|
|
354
|
-
|
|
495
|
+
pushParagraph(html, 'left');
|
|
355
496
|
tableRows = [];
|
|
356
497
|
}
|
|
357
498
|
function flushParagraph() {
|
|
358
499
|
flushTable();
|
|
359
500
|
const trimmed = currentParagraph.trim();
|
|
360
|
-
if (!
|
|
361
|
-
|
|
362
|
-
paragraphs.push(currentParagraph);
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
else {
|
|
366
|
-
paragraphs.push(currentParagraph);
|
|
501
|
+
if (trimmed || !paragraphHasListMarker) {
|
|
502
|
+
pushParagraph(currentParagraph, currentAlign, paragraphIsCaption);
|
|
367
503
|
}
|
|
368
504
|
currentParagraph = '';
|
|
369
505
|
paragraphHasText = false;
|
|
370
506
|
paragraphHasListMarker = false;
|
|
507
|
+
paragraphIsCaption = false;
|
|
508
|
+
currentAlign = 'left';
|
|
371
509
|
}
|
|
372
510
|
let pendingUnicodeSkip = 0;
|
|
373
511
|
function walk(nodes) {
|
|
@@ -384,12 +522,32 @@ export function rtfToHtml(rtfString) {
|
|
|
384
522
|
continue;
|
|
385
523
|
}
|
|
386
524
|
const firstCtrl = node.children.find((n) => n.type === 'control');
|
|
525
|
+
if (firstCtrl?.word === 'pict') {
|
|
526
|
+
const img = renderPicture(node);
|
|
527
|
+
if (img) {
|
|
528
|
+
currentParagraph += img;
|
|
529
|
+
paragraphHasText = true;
|
|
530
|
+
}
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
387
533
|
if (firstCtrl && destinationWords.has(firstCtrl.word)) {
|
|
388
534
|
continue;
|
|
389
535
|
}
|
|
390
536
|
const hasStarDest = node.children.length >= 2 &&
|
|
391
537
|
node.children[0].type === 'control' && node.children[0].word === '*';
|
|
392
538
|
if (hasStarDest) {
|
|
539
|
+
const dest = node.children.find((n) => n.type === 'control' && n.word !== '*');
|
|
540
|
+
// Word wraps pictures in {\*\shppict{\pict …}} — descend into it
|
|
541
|
+
// rather than dropping the picture with the rest of the ignorables.
|
|
542
|
+
if (dest?.word === 'shppict') {
|
|
543
|
+
stateStack.push(cloneState());
|
|
544
|
+
walk(node.children);
|
|
545
|
+
stateStack.pop();
|
|
546
|
+
}
|
|
547
|
+
else if (dest?.word === 'inkcap') {
|
|
548
|
+
// Marks the paragraph that follows as a picture caption.
|
|
549
|
+
paragraphIsCaption = true;
|
|
550
|
+
}
|
|
393
551
|
continue;
|
|
394
552
|
}
|
|
395
553
|
stateStack.push(cloneState());
|
|
@@ -444,6 +602,18 @@ export function rtfToHtml(rtfString) {
|
|
|
444
602
|
if (p != null)
|
|
445
603
|
state.fontIndex = p;
|
|
446
604
|
break;
|
|
605
|
+
case 'ql':
|
|
606
|
+
currentAlign = 'left';
|
|
607
|
+
break;
|
|
608
|
+
case 'qr':
|
|
609
|
+
currentAlign = 'right';
|
|
610
|
+
break;
|
|
611
|
+
case 'qc':
|
|
612
|
+
currentAlign = 'center';
|
|
613
|
+
break;
|
|
614
|
+
case 'qj':
|
|
615
|
+
currentAlign = 'justify';
|
|
616
|
+
break;
|
|
447
617
|
case 'par':
|
|
448
618
|
if (!inTableCell)
|
|
449
619
|
flushParagraph();
|
|
@@ -452,19 +622,32 @@ export function rtfToHtml(rtfString) {
|
|
|
452
622
|
currentRowCells = [];
|
|
453
623
|
currentRowIsHeader = false;
|
|
454
624
|
currentRowCellRights = [];
|
|
625
|
+
pendingBottomBorder = false;
|
|
455
626
|
break;
|
|
456
627
|
case 'trgaph':
|
|
457
628
|
case 'trkeep':
|
|
458
629
|
case 'trleft':
|
|
459
630
|
case 'trhdr':
|
|
460
|
-
case 'brdrs':
|
|
461
|
-
case 'brdrnil':
|
|
462
631
|
case 'clbrdrr':
|
|
463
632
|
case 'clbrdrl':
|
|
464
633
|
case 'clbrdrt':
|
|
634
|
+
pendingBottomBorder = false;
|
|
465
635
|
break; // table formatting, ignored
|
|
466
636
|
case 'clbrdrb':
|
|
467
|
-
|
|
637
|
+
pendingBottomBorder = true;
|
|
638
|
+
break;
|
|
639
|
+
case 'brdrnone':
|
|
640
|
+
case 'brdrnil':
|
|
641
|
+
pendingBottomBorder = false;
|
|
642
|
+
break;
|
|
643
|
+
case 'brdrs':
|
|
644
|
+
case 'brdrdb':
|
|
645
|
+
case 'brdrth':
|
|
646
|
+
case 'brdrdot':
|
|
647
|
+
case 'brdrdash':
|
|
648
|
+
if (pendingBottomBorder)
|
|
649
|
+
currentRowIsHeader = true;
|
|
650
|
+
pendingBottomBorder = false;
|
|
468
651
|
break;
|
|
469
652
|
case 'cellx':
|
|
470
653
|
if (p != null)
|
|
@@ -475,14 +658,17 @@ export function rtfToHtml(rtfString) {
|
|
|
475
658
|
break;
|
|
476
659
|
case 'cell':
|
|
477
660
|
currentRowCells.push(currentParagraph.trim());
|
|
661
|
+
currentRowCellAligns.push(currentAlign);
|
|
478
662
|
currentParagraph = '';
|
|
479
663
|
paragraphHasText = false;
|
|
664
|
+
currentAlign = 'left';
|
|
480
665
|
break;
|
|
481
666
|
case 'row':
|
|
482
|
-
tableRows.push({ cells: currentRowCells, isHeader: currentRowIsHeader, cellRights: currentRowCellRights });
|
|
667
|
+
tableRows.push({ cells: currentRowCells, isHeader: currentRowIsHeader, cellRights: currentRowCellRights, cellAligns: currentRowCellAligns });
|
|
483
668
|
currentRowCells = [];
|
|
484
669
|
currentRowIsHeader = false;
|
|
485
670
|
currentRowCellRights = [];
|
|
671
|
+
currentRowCellAligns = [];
|
|
486
672
|
inTableCell = false;
|
|
487
673
|
break;
|
|
488
674
|
case 'pard':
|
|
@@ -490,6 +676,7 @@ export function rtfToHtml(rtfString) {
|
|
|
490
676
|
state.italic = false;
|
|
491
677
|
state.underline = false;
|
|
492
678
|
state.strike = false;
|
|
679
|
+
currentAlign = 'left';
|
|
493
680
|
break;
|
|
494
681
|
case 'line':
|
|
495
682
|
currentParagraph += '<br>';
|
|
@@ -534,7 +721,7 @@ export function rtfToHtml(rtfString) {
|
|
|
534
721
|
break;
|
|
535
722
|
case 'page':
|
|
536
723
|
flushParagraph();
|
|
537
|
-
|
|
724
|
+
pushParagraph('<hr>', 'left');
|
|
538
725
|
break;
|
|
539
726
|
case 'emspace':
|
|
540
727
|
case 'enspace':
|
|
@@ -608,6 +795,7 @@ export function rtfToHtml(rtfString) {
|
|
|
608
795
|
const isListLine = (text) => /^[-\u2022]/.test(text.replace(/^ +/g, ''));
|
|
609
796
|
for (let i = 0; i < trimmedParagraphs.length; i++) {
|
|
610
797
|
const trimmed = trimmedParagraphs[i];
|
|
798
|
+
const align = paragraphAligns[i] ?? 'left';
|
|
611
799
|
const prev = i > 0 ? trimmedParagraphs[i - 1] : '';
|
|
612
800
|
const next = i + 1 < trimmedParagraphs.length ? trimmedParagraphs[i + 1] : '';
|
|
613
801
|
if (!trimmed) {
|
|
@@ -629,23 +817,40 @@ export function rtfToHtml(rtfString) {
|
|
|
629
817
|
rendered.push(trimmed);
|
|
630
818
|
continue;
|
|
631
819
|
}
|
|
820
|
+
// A paragraph tagged by {\*\inkcap} is the description of the figure above it.
|
|
821
|
+
if (paragraphCaptions[i]) {
|
|
822
|
+
const last = rendered.length - 1;
|
|
823
|
+
if (last >= 0 && rendered[last].startsWith('<figure') && !rendered[last].includes('<figcaption')) {
|
|
824
|
+
rendered[last] = rendered[last].replace('</figure>', `<figcaption>${stripWrappingEm(trimmed)}</figcaption></figure>`);
|
|
825
|
+
continue;
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
// A paragraph holding nothing but pictures becomes a figure, so it can carry
|
|
829
|
+
// a caption and be aligned/resized as a unit in the editor.
|
|
830
|
+
if (IMAGE_ONLY_PARAGRAPH.test(trimmed)) {
|
|
831
|
+
const alignAttr = align !== 'left' ? ` style="text-align:${align}"` : '';
|
|
832
|
+
rendered.push(`<figure${alignAttr}>${trimmed}</figure>`);
|
|
833
|
+
continue;
|
|
834
|
+
}
|
|
632
835
|
const sizeMatch = trimmed.match(/font-size:\s*([\d.]+)pt/);
|
|
633
836
|
if (sizeMatch) {
|
|
634
837
|
const pt = parseFloat(sizeMatch[1]);
|
|
838
|
+
const alignAttr = align !== 'left' ? ` style="text-align:${align}"` : '';
|
|
635
839
|
if (pt >= 24) {
|
|
636
|
-
rendered.push(`<h1>${stripSizeSpan(trimmed)}</h1>`);
|
|
840
|
+
rendered.push(`<h1${alignAttr}>${stripSizeSpan(trimmed)}</h1>`);
|
|
637
841
|
continue;
|
|
638
842
|
}
|
|
639
843
|
if (pt >= 18) {
|
|
640
|
-
rendered.push(`<h2>${stripSizeSpan(trimmed)}</h2>`);
|
|
844
|
+
rendered.push(`<h2${alignAttr}>${stripSizeSpan(trimmed)}</h2>`);
|
|
641
845
|
continue;
|
|
642
846
|
}
|
|
643
847
|
if (pt >= 14) {
|
|
644
|
-
rendered.push(`<h3>${stripSizeSpan(trimmed)}</h3>`);
|
|
848
|
+
rendered.push(`<h3${alignAttr}>${stripSizeSpan(trimmed)}</h3>`);
|
|
645
849
|
continue;
|
|
646
850
|
}
|
|
647
851
|
}
|
|
648
|
-
|
|
852
|
+
const pStyle = align !== 'left' ? ` style="text-align:${align}"` : '';
|
|
853
|
+
rendered.push(`<p${pStyle}>${trimmed}</p>`);
|
|
649
854
|
}
|
|
650
855
|
const output = rendered.join('\n');
|
|
651
856
|
// fontTable is parsed (for future font rendering) but not yet used in output
|
|
@@ -655,6 +860,10 @@ export function rtfToHtml(rtfString) {
|
|
|
655
860
|
function stripSizeSpan(html) {
|
|
656
861
|
return html.replace(/^<span style="[^"]*font-size:[^"]*">([\s\S]*)<\/span>$/, '$1');
|
|
657
862
|
}
|
|
863
|
+
/** Captions are written as italic text; <figcaption> supplies its own styling. */
|
|
864
|
+
function stripWrappingEm(html) {
|
|
865
|
+
return html.replace(/^<em>([\s\S]*)<\/em>$/, '$1');
|
|
866
|
+
}
|
|
658
867
|
export function readRtfFile(file) {
|
|
659
868
|
return new Promise((resolve, reject) => {
|
|
660
869
|
const reader = new FileReader();
|
package/dist/rtf-writer.d.ts
CHANGED
|
@@ -4,10 +4,24 @@
|
|
|
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
|
+
/**
|
|
14
|
+
* Size htmlToRtf would produce, without doing the conversion.
|
|
15
|
+
*
|
|
16
|
+
* Useful for a live indicator — the exact figure requires hex-encoding every
|
|
17
|
+
* picture, which is too much work to repeat on each keystroke.
|
|
18
|
+
*
|
|
19
|
+
* Picture data, which dominates any document that has one, is counted exactly:
|
|
20
|
+
* two characters per byte. Everything else is approximated per element, which
|
|
21
|
+
* puts the error at a bounded number of bytes rather than a proportion — so it
|
|
22
|
+
* is negligible precisely when the number matters, and never more than a few
|
|
23
|
+
* hundred bytes even when it does not. The output is ASCII, so bytes and
|
|
24
|
+
* characters are the same number.
|
|
25
|
+
*/
|
|
26
|
+
export declare function estimateRtfBytes(editorEl: HTMLElement): number;
|
|
13
27
|
export declare function htmlToRtf(editorEl: HTMLElement): string;
|