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.
@@ -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
- flushParagraph();
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(/^&emsp;+/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
- rendered.push(`<p>${trimmed}</p>`);
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
@@ -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.1.0",
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",