Full document, spreadsheet, slideshow, and diagram tooling
1// @vitest-environment jsdom
2import { describe, it, expect } from 'vitest';
3import {
4 parseClipboardHtml,
5 parseClipboardTsv,
6 parseInlineStyles,
7} from '../src/sheets/clipboard-paste.js';
8
9// ============================================================
10// parseInlineStyles — extract CSS into CellStyle
11// ============================================================
12
13describe('parseInlineStyles', () => {
14 it('returns empty object for empty string', () => {
15 expect(parseInlineStyles('')).toEqual({});
16 });
17
18 it('returns empty object for null/undefined', () => {
19 expect(parseInlineStyles(null)).toEqual({});
20 expect(parseInlineStyles(undefined)).toEqual({});
21 });
22
23 it('parses background-color hex', () => {
24 const style = parseInlineStyles('background-color: #ff0000');
25 expect(style.bg).toBe('#ff0000');
26 });
27
28 it('parses background shorthand', () => {
29 const style = parseInlineStyles('background: #00ff00');
30 expect(style.bg).toBe('#00ff00');
31 });
32
33 it('parses background-color rgb', () => {
34 const style = parseInlineStyles('background-color: rgb(255, 0, 128)');
35 expect(style.bg).toBe('#ff0080');
36 });
37
38 it('parses text color', () => {
39 const style = parseInlineStyles('color: #336699');
40 expect(style.color).toBe('#336699');
41 });
42
43 it('parses color as rgb()', () => {
44 const style = parseInlineStyles('color: rgb(0, 0, 0)');
45 expect(style.color).toBe('#000000');
46 });
47
48 it('parses font-weight:bold', () => {
49 const style = parseInlineStyles('font-weight: bold');
50 expect(style.bold).toBe(true);
51 });
52
53 it('parses font-weight:700', () => {
54 const style = parseInlineStyles('font-weight: 700');
55 expect(style.bold).toBe(true);
56 });
57
58 it('does not set bold for normal weight', () => {
59 const style = parseInlineStyles('font-weight: normal');
60 expect(style.bold).toBeUndefined();
61 });
62
63 it('parses font-style:italic', () => {
64 const style = parseInlineStyles('font-style: italic');
65 expect(style.italic).toBe(true);
66 });
67
68 it('does not set italic for normal', () => {
69 const style = parseInlineStyles('font-style: normal');
70 expect(style.italic).toBeUndefined();
71 });
72
73 it('parses text-decoration:underline', () => {
74 const style = parseInlineStyles('text-decoration: underline');
75 expect(style.underline).toBe(true);
76 });
77
78 it('parses text-decoration:line-through', () => {
79 const style = parseInlineStyles('text-decoration: line-through');
80 expect(style.strikethrough).toBe(true);
81 });
82
83 it('parses combined text-decoration (underline + line-through)', () => {
84 const style = parseInlineStyles('text-decoration: underline line-through');
85 expect(style.underline).toBe(true);
86 expect(style.strikethrough).toBe(true);
87 });
88
89 it('parses font-size in pt', () => {
90 const style = parseInlineStyles('font-size: 12pt');
91 expect(style.fontSize).toBe(12);
92 });
93
94 it('parses font-size with decimal pt', () => {
95 const style = parseInlineStyles('font-size: 10.5pt');
96 expect(style.fontSize).toBe(10.5);
97 });
98
99 it('ignores font-size in px (not pt)', () => {
100 const style = parseInlineStyles('font-size: 16px');
101 expect(style.fontSize).toBeUndefined();
102 });
103
104 it('parses text-align', () => {
105 expect(parseInlineStyles('text-align: center').align).toBe('center');
106 expect(parseInlineStyles('text-align: left').align).toBe('left');
107 expect(parseInlineStyles('text-align: right').align).toBe('right');
108 });
109
110 it('ignores unsupported text-align values', () => {
111 const style = parseInlineStyles('text-align: justify');
112 expect(style.align).toBeUndefined();
113 });
114
115 it('parses multiple properties', () => {
116 const style = parseInlineStyles(
117 'background-color: #eee; color: #333; font-weight: bold; font-style: italic; font-size: 14pt; text-align: right'
118 );
119 expect(style.bg).toBe('#eeeeee');
120 expect(style.color).toBe('#333333');
121 expect(style.bold).toBe(true);
122 expect(style.italic).toBe(true);
123 expect(style.fontSize).toBe(14);
124 expect(style.align).toBe('right');
125 });
126
127 it('normalizes 3-char hex to 6-char', () => {
128 const style = parseInlineStyles('color: #abc');
129 expect(style.color).toBe('#aabbcc');
130 });
131
132 it('strips alpha from 8-char hex', () => {
133 const style = parseInlineStyles('background-color: #ff000080');
134 expect(style.bg).toBe('#ff0000');
135 });
136});
137
138// ============================================================
139// parseClipboardHtml — parse HTML tables
140// ============================================================
141
142describe('parseClipboardHtml', () => {
143 it('returns null for empty/null input', () => {
144 expect(parseClipboardHtml(null)).toBeNull();
145 expect(parseClipboardHtml('')).toBeNull();
146 expect(parseClipboardHtml(undefined)).toBeNull();
147 });
148
149 it('returns null for HTML without a table', () => {
150 expect(parseClipboardHtml('<p>just text</p>')).toBeNull();
151 });
152
153 it('parses a simple 2x2 table', () => {
154 const html = '<table><tr><td>A</td><td>B</td></tr><tr><td>C</td><td>D</td></tr></table>';
155 const result = parseClipboardHtml(html);
156 expect(result).not.toBeNull();
157 expect(result.rows.length).toBe(2);
158 expect(result.rows[0].length).toBe(2);
159 expect(result.rows[0][0].value).toBe('A');
160 expect(result.rows[0][1].value).toBe('B');
161 expect(result.rows[1][0].value).toBe('C');
162 expect(result.rows[1][1].value).toBe('D');
163 });
164
165 it('parses numeric values as numbers', () => {
166 const html = '<table><tr><td>42</td><td>3.14</td></tr></table>';
167 const result = parseClipboardHtml(html);
168 expect(result.rows[0][0].value).toBe(42);
169 expect(result.rows[0][1].value).toBe(3.14);
170 });
171
172 it('keeps non-numeric strings as strings', () => {
173 const html = '<table><tr><td>hello</td><td>world</td></tr></table>';
174 const result = parseClipboardHtml(html);
175 expect(result.rows[0][0].value).toBe('hello');
176 expect(result.rows[0][1].value).toBe('world');
177 });
178
179 it('extracts inline styles from cells', () => {
180 const html = '<table><tr><td style="background-color:#ff0;font-weight:bold;color:#000">A</td></tr></table>';
181 const result = parseClipboardHtml(html);
182 expect(result.rows[0][0].style.bg).toBe('#ffff00');
183 expect(result.rows[0][0].style.bold).toBe(true);
184 expect(result.rows[0][0].style.color).toBe('#000000');
185 });
186
187 it('detects Google Sheets as source', () => {
188 const html = '<meta name="google-sheets-html-origin"><table><tr><td>1</td></tr></table>';
189 const result = parseClipboardHtml(html);
190 expect(result.sourceApp).toBe('google-sheets');
191 });
192
193 it('detects Excel as source', () => {
194 const html = '<html xmlns:x="urn:schemas-microsoft-com:office:excel"><table><tr><td>1</td></tr></table></html>';
195 const result = parseClipboardHtml(html);
196 expect(result.sourceApp).toBe('excel');
197 });
198
199 it('returns null sourceApp for unknown source', () => {
200 const html = '<table><tr><td>1</td></tr></table>';
201 const result = parseClipboardHtml(html);
202 expect(result.sourceApp).toBeNull();
203 });
204
205 it('handles single cell table', () => {
206 const html = '<table><tr><td>only</td></tr></table>';
207 const result = parseClipboardHtml(html);
208 expect(result.rows.length).toBe(1);
209 expect(result.rows[0].length).toBe(1);
210 expect(result.rows[0][0].value).toBe('only');
211 });
212
213 it('handles empty cells', () => {
214 const html = '<table><tr><td></td><td>B</td></tr></table>';
215 const result = parseClipboardHtml(html);
216 expect(result.rows[0][0].value).toBe('');
217 expect(result.rows[0][1].value).toBe('B');
218 });
219
220 it('handles colspan by filling extra cells', () => {
221 const html = '<table><tr><td colspan="3">merged</td></tr></table>';
222 const result = parseClipboardHtml(html);
223 expect(result.rows[0].length).toBe(3);
224 expect(result.rows[0][0].value).toBe('merged');
225 expect(result.rows[0][1].value).toBe('');
226 expect(result.rows[0][2].value).toBe('');
227 });
228
229 it('handles th elements the same as td', () => {
230 const html = '<table><tr><th>Header</th><td>Data</td></tr></table>';
231 const result = parseClipboardHtml(html);
232 expect(result.rows[0][0].value).toBe('Header');
233 expect(result.rows[0][1].value).toBe('Data');
234 });
235
236 it('detects formula from data-formula attribute', () => {
237 const html = '<table><tr><td data-formula="=A1+B1">3</td></tr></table>';
238 const result = parseClipboardHtml(html);
239 expect(result.rows[0][0].formula).toBe('A1+B1');
240 expect(result.rows[0][0].value).toBe('3');
241 });
242
243 it('detects formula from value starting with =', () => {
244 const html = '<table><tr><td>=SUM(A1:A5)</td></tr></table>';
245 const result = parseClipboardHtml(html);
246 expect(result.rows[0][0].formula).toBe('SUM(A1:A5)');
247 });
248
249 it('parses Google Sheets HTML with rich formatting', () => {
250 const html = `
251 <meta name="google-sheets-html-origin">
252 <table>
253 <tr>
254 <td style="background-color:#4a86e8;color:#ffffff;font-weight:bold;font-size:12pt;text-align:center">Revenue</td>
255 <td style="font-style:italic;text-decoration:underline">Notes</td>
256 </tr>
257 <tr>
258 <td>1000</td>
259 <td style="text-decoration:line-through">old data</td>
260 </tr>
261 </table>
262 `;
263 const result = parseClipboardHtml(html);
264 expect(result.sourceApp).toBe('google-sheets');
265 expect(result.rows.length).toBe(2);
266
267 const header = result.rows[0][0];
268 expect(header.value).toBe('Revenue');
269 expect(header.style.bg).toBe('#4a86e8');
270 expect(header.style.color).toBe('#ffffff');
271 expect(header.style.bold).toBe(true);
272 expect(header.style.fontSize).toBe(12);
273 expect(header.style.align).toBe('center');
274
275 const notes = result.rows[0][1];
276 expect(notes.style.italic).toBe(true);
277 expect(notes.style.underline).toBe(true);
278
279 const oldData = result.rows[1][1];
280 expect(oldData.style.strikethrough).toBe(true);
281
282 expect(result.rows[1][0].value).toBe(1000);
283 });
284
285 it('parses Excel HTML with rich formatting', () => {
286 const html = `
287 <html xmlns:x="urn:schemas-microsoft-com:office:excel">
288 <table>
289 <tr>
290 <td style="background-color:rgb(255,255,0);font-weight:700;text-align:right">Total</td>
291 <td style="color:rgb(255,0,0)">-50</td>
292 </tr>
293 </table>
294 </html>
295 `;
296 const result = parseClipboardHtml(html);
297 expect(result.sourceApp).toBe('excel');
298
299 const total = result.rows[0][0];
300 expect(total.value).toBe('Total');
301 expect(total.style.bg).toBe('#ffff00');
302 expect(total.style.bold).toBe(true);
303 expect(total.style.align).toBe('right');
304
305 const neg = result.rows[0][1];
306 expect(neg.value).toBe(-50);
307 expect(neg.style.color).toBe('#ff0000');
308 });
309
310 it('returns null for table with no rows', () => {
311 const html = '<table></table>';
312 const result = parseClipboardHtml(html);
313 expect(result).toBeNull();
314 });
315
316 it('handles data-formula without leading =', () => {
317 const html = '<table><tr><td data-formula="SUM(A1:A5)">15</td></tr></table>';
318 const result = parseClipboardHtml(html);
319 expect(result.rows[0][0].formula).toBe('SUM(A1:A5)');
320 });
321});
322
323// ============================================================
324// parseClipboardTsv — parse tab-separated values
325// ============================================================
326
327describe('parseClipboardTsv', () => {
328 it('returns null for empty/null input', () => {
329 expect(parseClipboardTsv(null)).toBeNull();
330 expect(parseClipboardTsv('')).toBeNull();
331 expect(parseClipboardTsv(undefined)).toBeNull();
332 });
333
334 it('parses a single value', () => {
335 const result = parseClipboardTsv('hello');
336 expect(result.rows.length).toBe(1);
337 expect(result.rows[0].length).toBe(1);
338 expect(result.rows[0][0].value).toBe('hello');
339 });
340
341 it('parses tab-separated columns', () => {
342 const result = parseClipboardTsv('A\tB\tC');
343 expect(result.rows[0].length).toBe(3);
344 expect(result.rows[0][0].value).toBe('A');
345 expect(result.rows[0][1].value).toBe('B');
346 expect(result.rows[0][2].value).toBe('C');
347 });
348
349 it('parses multiple rows', () => {
350 const result = parseClipboardTsv('1\t2\n3\t4');
351 expect(result.rows.length).toBe(2);
352 expect(result.rows[0][0].value).toBe(1);
353 expect(result.rows[0][1].value).toBe(2);
354 expect(result.rows[1][0].value).toBe(3);
355 expect(result.rows[1][1].value).toBe(4);
356 });
357
358 it('parses numbers', () => {
359 const result = parseClipboardTsv('42\t3.14\t-7');
360 expect(result.rows[0][0].value).toBe(42);
361 expect(result.rows[0][1].value).toBe(3.14);
362 expect(result.rows[0][2].value).toBe(-7);
363 });
364
365 it('keeps non-numeric strings', () => {
366 const result = parseClipboardTsv('abc\t123abc');
367 expect(result.rows[0][0].value).toBe('abc');
368 expect(result.rows[0][1].value).toBe('123abc');
369 });
370
371 it('handles empty cells', () => {
372 const result = parseClipboardTsv('\tB\t\n\t\t');
373 expect(result.rows[0][0].value).toBe('');
374 expect(result.rows[0][1].value).toBe('B');
375 expect(result.rows[0][2].value).toBe('');
376 expect(result.rows[1][0].value).toBe('');
377 });
378
379 it('detects formulas starting with =', () => {
380 const result = parseClipboardTsv('=SUM(A1:A5)\t=B1+B2');
381 expect(result.rows[0][0].formula).toBe('SUM(A1:A5)');
382 expect(result.rows[0][1].formula).toBe('B1+B2');
383 });
384
385 it('handles Windows-style line endings (\\r\\n)', () => {
386 const result = parseClipboardTsv('A\tB\r\nC\tD');
387 expect(result.rows.length).toBe(2);
388 expect(result.rows[0][0].value).toBe('A');
389 expect(result.rows[1][0].value).toBe('C');
390 });
391
392 it('strips trailing empty line', () => {
393 const result = parseClipboardTsv('A\tB\n');
394 expect(result.rows.length).toBe(1);
395 expect(result.rows[0][0].value).toBe('A');
396 expect(result.rows[0][1].value).toBe('B');
397 });
398
399 it('returns null sourceApp', () => {
400 const result = parseClipboardTsv('test');
401 expect(result.sourceApp).toBeNull();
402 });
403
404 it('styles are always empty objects', () => {
405 const result = parseClipboardTsv('A\tB');
406 expect(result.rows[0][0].style).toEqual({});
407 expect(result.rows[0][1].style).toEqual({});
408 });
409
410 it('treats single newline as one row with one empty cell', () => {
411 const result = parseClipboardTsv('\n');
412 // Trailing newline is stripped, leaving one line with empty value
413 expect(result).not.toBeNull();
414 expect(result.rows.length).toBe(1);
415 expect(result.rows[0][0].value).toBe('');
416 });
417});