svelte-rtf-editor 1.1.0 → 1.3.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/dist/rtf-parser.js +123 -5
- package/dist/rtf-writer.js +43 -1
- package/package.json +2 -1
package/dist/rtf-parser.js
CHANGED
|
@@ -228,8 +228,19 @@ export function rtfToHtml(rtfString) {
|
|
|
228
228
|
let fontTable = new Map();
|
|
229
229
|
let currentParagraph = '';
|
|
230
230
|
let paragraphs = [];
|
|
231
|
+
let paragraphAligns = [];
|
|
231
232
|
let paragraphHasText = false;
|
|
232
233
|
let paragraphHasListMarker = false;
|
|
234
|
+
// Paragraph alignment (reset by \pard, affects cells and paragraphs)
|
|
235
|
+
let currentAlign = 'left';
|
|
236
|
+
// Table state
|
|
237
|
+
let tableRows = [];
|
|
238
|
+
let currentRowCells = [];
|
|
239
|
+
let currentRowIsHeader = false;
|
|
240
|
+
let currentRowCellRights = [];
|
|
241
|
+
let currentRowCellAligns = [];
|
|
242
|
+
let inTableCell = false;
|
|
243
|
+
let pendingBottomBorder = false; // set by \clbrdrb, confirmed by a real border type
|
|
233
244
|
// First pass: extract color table and font table from the root group
|
|
234
245
|
const rootGroup = tree.children[0]?.type === 'group' ? tree.children[0] : tree;
|
|
235
246
|
for (let i = 0; i < rootGroup.children.length; i++) {
|
|
@@ -324,19 +335,50 @@ export function rtfToHtml(rtfString) {
|
|
|
324
335
|
'latentstyles', 'datastore', 'mmathPr', 'author', 'operator',
|
|
325
336
|
'title', 'subject', 'doccomm', 'company', 'category', 'keywords'
|
|
326
337
|
]);
|
|
338
|
+
function flushTable() {
|
|
339
|
+
if (tableRows.length === 0)
|
|
340
|
+
return;
|
|
341
|
+
let html = '<table style="border-collapse:collapse;width:100%;margin:4px 0">';
|
|
342
|
+
for (const row of tableRows) {
|
|
343
|
+
html += '<tr>';
|
|
344
|
+
const tag = row.isHeader ? 'th' : 'td';
|
|
345
|
+
const total = row.cellRights[row.cellRights.length - 1] || 1;
|
|
346
|
+
let prevRight = 0;
|
|
347
|
+
for (let ci = 0; ci < row.cells.length; ci++) {
|
|
348
|
+
const right = row.cellRights[ci] ?? total;
|
|
349
|
+
const pct = ((right - prevRight) / total * 100).toFixed(1);
|
|
350
|
+
prevRight = right;
|
|
351
|
+
const align = row.cellAligns[ci] ?? 'left';
|
|
352
|
+
const alignStyle = align !== 'left' ? `;text-align:${align}` : '';
|
|
353
|
+
const style = row.isHeader
|
|
354
|
+
? `border-bottom:1px solid;padding:2px 6px;text-align:${align};width:${pct}%`
|
|
355
|
+
: `padding:2px 6px;width:${pct}%${alignStyle}`;
|
|
356
|
+
html += `<${tag} style="${style}">${row.cells[ci] || ''}</${tag}>`;
|
|
357
|
+
}
|
|
358
|
+
html += '</tr>';
|
|
359
|
+
}
|
|
360
|
+
html += '</table>';
|
|
361
|
+
paragraphs.push(html);
|
|
362
|
+
paragraphAligns.push('left');
|
|
363
|
+
tableRows = [];
|
|
364
|
+
}
|
|
327
365
|
function flushParagraph() {
|
|
366
|
+
flushTable();
|
|
328
367
|
const trimmed = currentParagraph.trim();
|
|
329
368
|
if (!trimmed) {
|
|
330
369
|
if (!paragraphHasListMarker) {
|
|
331
370
|
paragraphs.push(currentParagraph);
|
|
371
|
+
paragraphAligns.push(currentAlign);
|
|
332
372
|
}
|
|
333
373
|
}
|
|
334
374
|
else {
|
|
335
375
|
paragraphs.push(currentParagraph);
|
|
376
|
+
paragraphAligns.push(currentAlign);
|
|
336
377
|
}
|
|
337
378
|
currentParagraph = '';
|
|
338
379
|
paragraphHasText = false;
|
|
339
380
|
paragraphHasListMarker = false;
|
|
381
|
+
currentAlign = 'left';
|
|
340
382
|
}
|
|
341
383
|
let pendingUnicodeSkip = 0;
|
|
342
384
|
function walk(nodes) {
|
|
@@ -413,14 +455,81 @@ export function rtfToHtml(rtfString) {
|
|
|
413
455
|
if (p != null)
|
|
414
456
|
state.fontIndex = p;
|
|
415
457
|
break;
|
|
458
|
+
case 'ql':
|
|
459
|
+
currentAlign = 'left';
|
|
460
|
+
break;
|
|
461
|
+
case 'qr':
|
|
462
|
+
currentAlign = 'right';
|
|
463
|
+
break;
|
|
464
|
+
case 'qc':
|
|
465
|
+
currentAlign = 'center';
|
|
466
|
+
break;
|
|
467
|
+
case 'qj':
|
|
468
|
+
currentAlign = 'justify';
|
|
469
|
+
break;
|
|
416
470
|
case 'par':
|
|
417
|
-
|
|
471
|
+
if (!inTableCell)
|
|
472
|
+
flushParagraph();
|
|
473
|
+
break;
|
|
474
|
+
case 'trowd':
|
|
475
|
+
currentRowCells = [];
|
|
476
|
+
currentRowIsHeader = false;
|
|
477
|
+
currentRowCellRights = [];
|
|
478
|
+
pendingBottomBorder = false;
|
|
479
|
+
break;
|
|
480
|
+
case 'trgaph':
|
|
481
|
+
case 'trkeep':
|
|
482
|
+
case 'trleft':
|
|
483
|
+
case 'trhdr':
|
|
484
|
+
case 'clbrdrr':
|
|
485
|
+
case 'clbrdrl':
|
|
486
|
+
case 'clbrdrt':
|
|
487
|
+
pendingBottomBorder = false;
|
|
488
|
+
break; // table formatting, ignored
|
|
489
|
+
case 'clbrdrb':
|
|
490
|
+
pendingBottomBorder = true;
|
|
491
|
+
break;
|
|
492
|
+
case 'brdrnone':
|
|
493
|
+
case 'brdrnil':
|
|
494
|
+
pendingBottomBorder = false;
|
|
495
|
+
break;
|
|
496
|
+
case 'brdrs':
|
|
497
|
+
case 'brdrdb':
|
|
498
|
+
case 'brdrth':
|
|
499
|
+
case 'brdrdot':
|
|
500
|
+
case 'brdrdash':
|
|
501
|
+
if (pendingBottomBorder)
|
|
502
|
+
currentRowIsHeader = true;
|
|
503
|
+
pendingBottomBorder = false;
|
|
504
|
+
break;
|
|
505
|
+
case 'cellx':
|
|
506
|
+
if (p != null)
|
|
507
|
+
currentRowCellRights.push(p);
|
|
508
|
+
break;
|
|
509
|
+
case 'intbl':
|
|
510
|
+
inTableCell = true;
|
|
511
|
+
break;
|
|
512
|
+
case 'cell':
|
|
513
|
+
currentRowCells.push(currentParagraph.trim());
|
|
514
|
+
currentRowCellAligns.push(currentAlign);
|
|
515
|
+
currentParagraph = '';
|
|
516
|
+
paragraphHasText = false;
|
|
517
|
+
currentAlign = 'left';
|
|
518
|
+
break;
|
|
519
|
+
case 'row':
|
|
520
|
+
tableRows.push({ cells: currentRowCells, isHeader: currentRowIsHeader, cellRights: currentRowCellRights, cellAligns: currentRowCellAligns });
|
|
521
|
+
currentRowCells = [];
|
|
522
|
+
currentRowIsHeader = false;
|
|
523
|
+
currentRowCellRights = [];
|
|
524
|
+
currentRowCellAligns = [];
|
|
525
|
+
inTableCell = false;
|
|
418
526
|
break;
|
|
419
527
|
case 'pard':
|
|
420
528
|
state.bold = false;
|
|
421
529
|
state.italic = false;
|
|
422
530
|
state.underline = false;
|
|
423
531
|
state.strike = false;
|
|
532
|
+
currentAlign = 'left';
|
|
424
533
|
break;
|
|
425
534
|
case 'line':
|
|
426
535
|
currentParagraph += '<br>';
|
|
@@ -466,6 +575,7 @@ export function rtfToHtml(rtfString) {
|
|
|
466
575
|
case 'page':
|
|
467
576
|
flushParagraph();
|
|
468
577
|
paragraphs.push('<hr>');
|
|
578
|
+
paragraphAligns.push('left');
|
|
469
579
|
break;
|
|
470
580
|
case 'emspace':
|
|
471
581
|
case 'enspace':
|
|
@@ -526,6 +636,7 @@ export function rtfToHtml(rtfString) {
|
|
|
526
636
|
}
|
|
527
637
|
}
|
|
528
638
|
walk(rootGroup.children);
|
|
639
|
+
flushTable(); // flush any trailing table rows
|
|
529
640
|
// Only flush if there's actually pending content — the final \par in most RTF
|
|
530
641
|
// files already flushed the last paragraph, so an unconditional call here
|
|
531
642
|
// would push a spurious empty paragraph that becomes a trailing <p><br></p>.
|
|
@@ -538,6 +649,7 @@ export function rtfToHtml(rtfString) {
|
|
|
538
649
|
const isListLine = (text) => /^[-\u2022]/.test(text.replace(/^ +/g, ''));
|
|
539
650
|
for (let i = 0; i < trimmedParagraphs.length; i++) {
|
|
540
651
|
const trimmed = trimmedParagraphs[i];
|
|
652
|
+
const align = paragraphAligns[i] ?? 'left';
|
|
541
653
|
const prev = i > 0 ? trimmedParagraphs[i - 1] : '';
|
|
542
654
|
const next = i + 1 < trimmedParagraphs.length ? trimmedParagraphs[i + 1] : '';
|
|
543
655
|
if (!trimmed) {
|
|
@@ -555,23 +667,29 @@ export function rtfToHtml(rtfString) {
|
|
|
555
667
|
rendered.push('<hr>');
|
|
556
668
|
continue;
|
|
557
669
|
}
|
|
670
|
+
if (trimmed.startsWith('<table')) {
|
|
671
|
+
rendered.push(trimmed);
|
|
672
|
+
continue;
|
|
673
|
+
}
|
|
558
674
|
const sizeMatch = trimmed.match(/font-size:\s*([\d.]+)pt/);
|
|
559
675
|
if (sizeMatch) {
|
|
560
676
|
const pt = parseFloat(sizeMatch[1]);
|
|
677
|
+
const alignAttr = align !== 'left' ? ` style="text-align:${align}"` : '';
|
|
561
678
|
if (pt >= 24) {
|
|
562
|
-
rendered.push(`<h1>${stripSizeSpan(trimmed)}</h1>`);
|
|
679
|
+
rendered.push(`<h1${alignAttr}>${stripSizeSpan(trimmed)}</h1>`);
|
|
563
680
|
continue;
|
|
564
681
|
}
|
|
565
682
|
if (pt >= 18) {
|
|
566
|
-
rendered.push(`<h2>${stripSizeSpan(trimmed)}</h2>`);
|
|
683
|
+
rendered.push(`<h2${alignAttr}>${stripSizeSpan(trimmed)}</h2>`);
|
|
567
684
|
continue;
|
|
568
685
|
}
|
|
569
686
|
if (pt >= 14) {
|
|
570
|
-
rendered.push(`<h3>${stripSizeSpan(trimmed)}</h3>`);
|
|
687
|
+
rendered.push(`<h3${alignAttr}>${stripSizeSpan(trimmed)}</h3>`);
|
|
571
688
|
continue;
|
|
572
689
|
}
|
|
573
690
|
}
|
|
574
|
-
|
|
691
|
+
const pStyle = align !== 'left' ? ` style="text-align:${align}"` : '';
|
|
692
|
+
rendered.push(`<p${pStyle}>${trimmed}</p>`);
|
|
575
693
|
}
|
|
576
694
|
const output = rendered.join('\n');
|
|
577
695
|
// fontTable is parsed (for future font rendering) but not yet used in output
|
package/dist/rtf-writer.js
CHANGED
|
@@ -93,7 +93,7 @@ export function htmlToRtf(editorEl) {
|
|
|
93
93
|
'{\\f1\\froman\\fcharset0 Georgia;}' +
|
|
94
94
|
'{\\f2\\fmodern\\fcharset0 Courier New;}' +
|
|
95
95
|
'}';
|
|
96
|
-
const body = walkChildren(editorEl, { colorIndex, inPre: false, listCounter: 0 });
|
|
96
|
+
const body = walkChildren(editorEl, { colorIndex, inPre: false, listCounter: 0, inTableCell: false });
|
|
97
97
|
const rtf = '{\\rtf1\\ansi\\ansicpg1252\\deff0' +
|
|
98
98
|
fontTable +
|
|
99
99
|
colorTable +
|
|
@@ -149,6 +149,11 @@ function walkChildren(parent, ctx) {
|
|
|
149
149
|
break;
|
|
150
150
|
case 'p':
|
|
151
151
|
case 'div': {
|
|
152
|
+
// Inside a table cell \pard is already declared; just emit inline content.
|
|
153
|
+
if (ctx.inTableCell) {
|
|
154
|
+
rtf += walkChildren(el, ctx);
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
152
157
|
// A <p> with no children or only a single <br> is a blank line —
|
|
153
158
|
// emit a bare \par rather than \pard \line\par, which adds an extra line.
|
|
154
159
|
const isBlankLine = el.childNodes.length === 0 ||
|
|
@@ -163,6 +168,43 @@ function walkChildren(parent, ctx) {
|
|
|
163
168
|
rtf += `\\pard ${colorPrefix}${inlineRtf}${colorSuffix}\\par\n`;
|
|
164
169
|
break;
|
|
165
170
|
}
|
|
171
|
+
case 'table': {
|
|
172
|
+
const PAGE_WIDTH = 8640; // twips ≈ 6 in (standard letter body width)
|
|
173
|
+
const rows = Array.from(el.querySelectorAll(':scope > tr, :scope > thead > tr, :scope > tbody > tr, :scope > tfoot > tr'));
|
|
174
|
+
for (const row of rows) {
|
|
175
|
+
const cells = Array.from(row.querySelectorAll(':scope > td, :scope > th'));
|
|
176
|
+
if (cells.length === 0)
|
|
177
|
+
continue;
|
|
178
|
+
const isHeader = cells.some(c => c.tagName.toLowerCase() === 'th');
|
|
179
|
+
// Derive right-edge twip positions from the width:X% style set by the parser.
|
|
180
|
+
// Fall back to equal column widths if the style is absent.
|
|
181
|
+
const cellRights = [];
|
|
182
|
+
let cumTwips = 0;
|
|
183
|
+
for (const cell of cells) {
|
|
184
|
+
const pct = parseFloat(cell.style.width) || (100 / cells.length);
|
|
185
|
+
cumTwips += Math.round((pct / 100) * PAGE_WIDTH);
|
|
186
|
+
cellRights.push(cumTwips);
|
|
187
|
+
}
|
|
188
|
+
cellRights[cellRights.length - 1] = PAGE_WIDTH; // snap last edge to avoid rounding drift
|
|
189
|
+
// Row definition
|
|
190
|
+
let rowRtf = '{\n\\trowd \\trgaph120\n';
|
|
191
|
+
for (let ci = 0; ci < cells.length; ci++) {
|
|
192
|
+
if (isHeader)
|
|
193
|
+
rowRtf += `\\clbrdrb\\brdrs`;
|
|
194
|
+
rowRtf += `\\cellx${cellRights[ci]}`;
|
|
195
|
+
}
|
|
196
|
+
rowRtf += '\n\\trkeep\\intbl\n{';
|
|
197
|
+
// Cell bodies
|
|
198
|
+
const cellCtx = { ...ctx, inTableCell: true };
|
|
199
|
+
for (const cell of cells) {
|
|
200
|
+
const content = walkChildren(cell, cellCtx);
|
|
201
|
+
rowRtf += `{\\pard\\intbl \\f0 \\sa0 \\li0 \\fi0 ${content}\\par}\n\\cell`;
|
|
202
|
+
}
|
|
203
|
+
rowRtf += '}\n\\intbl\\row}\n';
|
|
204
|
+
rtf += rowRtf;
|
|
205
|
+
}
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
166
208
|
case 'blockquote':
|
|
167
209
|
rtf += `\\pard\\li720\\i ${walkChildren(el, ctx)}\\i0\\par\n`;
|
|
168
210
|
break;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "svelte-rtf-editor",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "RTF viewer and rich-text editor components for Svelte 5",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://github.com/jcelano/svelte-rtf-editor#readme",
|
|
@@ -49,6 +49,7 @@
|
|
|
49
49
|
"@sveltejs/kit": "^2.53.4",
|
|
50
50
|
"@sveltejs/package": "^2.5.7",
|
|
51
51
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
|
52
|
+
"happy-dom": "^20.8.3",
|
|
52
53
|
"publint": "^0.3.18",
|
|
53
54
|
"svelte": "^5.53.7",
|
|
54
55
|
"svelte-check": "^4.4.4",
|