svelte-rtf-editor 1.1.0 → 1.2.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.
@@ -230,6 +230,12 @@ export function rtfToHtml(rtfString) {
230
230
  let paragraphs = [];
231
231
  let paragraphHasText = false;
232
232
  let paragraphHasListMarker = false;
233
+ // Table state
234
+ let tableRows = [];
235
+ let currentRowCells = [];
236
+ let currentRowIsHeader = false;
237
+ let currentRowCellRights = [];
238
+ let inTableCell = false;
233
239
  // First pass: extract color table and font table from the root group
234
240
  const rootGroup = tree.children[0]?.type === 'group' ? tree.children[0] : tree;
235
241
  for (let i = 0; i < rootGroup.children.length; i++) {
@@ -324,7 +330,32 @@ export function rtfToHtml(rtfString) {
324
330
  'latentstyles', 'datastore', 'mmathPr', 'author', 'operator',
325
331
  'title', 'subject', 'doccomm', 'company', 'category', 'keywords'
326
332
  ]);
333
+ function flushTable() {
334
+ if (tableRows.length === 0)
335
+ return;
336
+ let html = '<table style="border-collapse:collapse;width:100%;margin:4px 0">';
337
+ for (const row of tableRows) {
338
+ html += '<tr>';
339
+ const tag = row.isHeader ? 'th' : 'td';
340
+ const total = row.cellRights[row.cellRights.length - 1] || 1;
341
+ let prevRight = 0;
342
+ for (let ci = 0; ci < row.cells.length; ci++) {
343
+ const right = row.cellRights[ci] ?? total;
344
+ const pct = ((right - prevRight) / total * 100).toFixed(1);
345
+ prevRight = right;
346
+ const style = row.isHeader
347
+ ? `border-bottom:1px solid;padding:2px 6px;text-align:left;width:${pct}%`
348
+ : `padding:2px 6px;width:${pct}%`;
349
+ html += `<${tag} style="${style}">${row.cells[ci] || ''}</${tag}>`;
350
+ }
351
+ html += '</tr>';
352
+ }
353
+ html += '</table>';
354
+ paragraphs.push(html);
355
+ tableRows = [];
356
+ }
327
357
  function flushParagraph() {
358
+ flushTable();
328
359
  const trimmed = currentParagraph.trim();
329
360
  if (!trimmed) {
330
361
  if (!paragraphHasListMarker) {
@@ -414,7 +445,45 @@ export function rtfToHtml(rtfString) {
414
445
  state.fontIndex = p;
415
446
  break;
416
447
  case 'par':
417
- flushParagraph();
448
+ if (!inTableCell)
449
+ flushParagraph();
450
+ break;
451
+ case 'trowd':
452
+ currentRowCells = [];
453
+ currentRowIsHeader = false;
454
+ currentRowCellRights = [];
455
+ break;
456
+ case 'trgaph':
457
+ case 'trkeep':
458
+ case 'trleft':
459
+ case 'trhdr':
460
+ case 'brdrs':
461
+ case 'brdrnil':
462
+ case 'clbrdrr':
463
+ case 'clbrdrl':
464
+ case 'clbrdrt':
465
+ break; // table formatting, ignored
466
+ case 'clbrdrb':
467
+ currentRowIsHeader = true;
468
+ break;
469
+ case 'cellx':
470
+ if (p != null)
471
+ currentRowCellRights.push(p);
472
+ break;
473
+ case 'intbl':
474
+ inTableCell = true;
475
+ break;
476
+ case 'cell':
477
+ currentRowCells.push(currentParagraph.trim());
478
+ currentParagraph = '';
479
+ paragraphHasText = false;
480
+ break;
481
+ case 'row':
482
+ tableRows.push({ cells: currentRowCells, isHeader: currentRowIsHeader, cellRights: currentRowCellRights });
483
+ currentRowCells = [];
484
+ currentRowIsHeader = false;
485
+ currentRowCellRights = [];
486
+ inTableCell = false;
418
487
  break;
419
488
  case 'pard':
420
489
  state.bold = false;
@@ -526,6 +595,7 @@ export function rtfToHtml(rtfString) {
526
595
  }
527
596
  }
528
597
  walk(rootGroup.children);
598
+ flushTable(); // flush any trailing table rows
529
599
  // Only flush if there's actually pending content — the final \par in most RTF
530
600
  // files already flushed the last paragraph, so an unconditional call here
531
601
  // would push a spurious empty paragraph that becomes a trailing <p><br></p>.
@@ -555,6 +625,10 @@ export function rtfToHtml(rtfString) {
555
625
  rendered.push('<hr>');
556
626
  continue;
557
627
  }
628
+ if (trimmed.startsWith('<table')) {
629
+ rendered.push(trimmed);
630
+ continue;
631
+ }
558
632
  const sizeMatch = trimmed.match(/font-size:\s*([\d.]+)pt/);
559
633
  if (sizeMatch) {
560
634
  const pt = parseFloat(sizeMatch[1]);
@@ -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.2.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",