Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request 'feat(sheets): cell formatting, XLSX import styles, format painter' (#52) from feat/cell-formatting into main

scott 1492e4ab 994bcf41

+882
+22
src/css/app.css
··· 2153 2153 } 2154 2154 2155 2155 2156 + /* --- Format Painter (#active state) --- */ 2157 + .tb-btn.format-painter-active, 2158 + .tb-btn#tb-format-painter.active { 2159 + color: var(--color-accent); 2160 + background: oklch(0.88 0.04 240); 2161 + } 2162 + 2163 + [data-theme="dark"] .tb-btn#tb-format-painter.active { 2164 + background: oklch(0.25 0.03 240); 2165 + } 2166 + 2167 + @media (prefers-color-scheme: dark) { 2168 + :root:not([data-theme="light"]) .tb-btn#tb-format-painter.active { 2169 + background: oklch(0.25 0.03 240); 2170 + } 2171 + } 2172 + 2173 + body.format-painter-active, 2174 + body.format-painter-active .sheet-grid td { 2175 + cursor: crosshair !important; 2176 + } 2177 + 2156 2178 /* --- Responsive --- */ 2157 2179 @media (max-width: 640px) { 2158 2180 .create-actions { flex-direction: column; }
+5
src/sheets/format-painter.js
··· 9 9 export const FORMAT_PROPERTIES = [ 10 10 'bold', 11 11 'italic', 12 + 'underline', 13 + 'strikethrough', 12 14 'align', 15 + 'verticalAlign', 13 16 'color', 14 17 'bg', 15 18 'borders', 16 19 'format', 17 20 'wrap', 21 + 'fontSize', 22 + 'fontFamily', 18 23 ]; 19 24 20 25 /**
+39
src/sheets/index.html
··· 69 69 </select> 70 70 <span class="toolbar-sep toolbar-mobile-hide"></span> 71 71 72 + <!-- Group: Font family & size --> 73 + <select class="tb-select toolbar-mobile-hide" id="tb-font-family" title="Font family" aria-label="Font family"> 74 + <option value="sans-serif">Sans-serif</option> 75 + <option value="serif">Serif</option> 76 + <option value="monospace">Monospace</option> 77 + </select> 78 + <select class="tb-select tb-select-narrow toolbar-mobile-hide" id="tb-font-size" title="Font size" aria-label="Font size"> 79 + <option value="8">8</option> 80 + <option value="9">9</option> 81 + <option value="10">10</option> 82 + <option value="11" selected>11</option> 83 + <option value="12">12</option> 84 + <option value="14">14</option> 85 + <option value="16">16</option> 86 + <option value="18">18</option> 87 + <option value="20">20</option> 88 + <option value="24">24</option> 89 + </select> 90 + <span class="toolbar-sep toolbar-mobile-hide"></span> 91 + 72 92 <!-- Group: Cell formatting --> 73 93 <button class="tb-btn" id="tb-bold" title="Bold (Cmd+B)" aria-label="Bold"><svg class="tb-icon" viewBox="0 0 16 16"><path d="M4.5 2.5h4.5a2.5 2.5 0 0 1 0 5h-4.5zM4.5 7.5h5a3 3 0 0 1 0 6h-5z" stroke-width="2"/></svg></button> 74 94 <button class="tb-btn" id="tb-italic" title="Italic (Cmd+I)" aria-label="Italic"><svg class="tb-icon" viewBox="0 0 16 16"><line x1="10" y1="2.5" x2="6" y2="13.5"/><line x1="7" y1="2.5" x2="11" y2="2.5"/><line x1="5" y1="13.5" x2="9" y2="13.5"/></svg></button> 95 + <button class="tb-btn tb-btn-text tb-btn-underline" id="tb-underline" title="Underline (Cmd+U)" aria-label="Underline">U</button> 96 + <button class="tb-btn tb-btn-text tb-btn-strike" id="tb-strikethrough" title="Strikethrough (Cmd+Shift+X)" aria-label="Strikethrough">S</button> 75 97 <div class="tb-color-wrap toolbar-mobile-hide"> 76 98 <input type="color" class="tb-color" id="tb-text-color" value="#1a1815" title="Text color" aria-label="Text color"> 77 99 <span class="tb-color-label">A</span> ··· 129 151 </button> 130 152 </div> 131 153 </div> 154 + <div class="toolbar-dropdown toolbar-mobile-hide" id="dd-valign"> 155 + <button class="toolbar-dropdown-toggle" id="tb-valign-toggle" title="Vertical alignment" aria-label="Vertical alignment" aria-expanded="false" aria-haspopup="true"> 156 + <span class="dd-icon"><svg class="tb-icon" viewBox="0 0 16 16"><line x1="2" y1="2" x2="14" y2="2" stroke-width="2"/><line x1="8" y1="4" x2="8" y2="14"/><path d="M5.5 6.5L8 4l2.5 2.5"/></svg></span><span class="caret">&#9662;</span> 157 + </button> 158 + <div class="toolbar-dropdown-menu" role="menu"> 159 + <button class="toolbar-dropdown-item" data-valign="top" title="Align top" role="menuitem"> 160 + <span class="item-icon"><svg class="tb-icon" viewBox="0 0 16 16"><line x1="2" y1="2" x2="14" y2="2" stroke-width="2"/><line x1="8" y1="4" x2="8" y2="14"/><path d="M5.5 6.5L8 4l2.5 2.5"/></svg></span><span class="item-label">Align top</span> 161 + </button> 162 + <button class="toolbar-dropdown-item" data-valign="middle" title="Align middle" role="menuitem"> 163 + <span class="item-icon"><svg class="tb-icon" viewBox="0 0 16 16"><line x1="2" y1="8" x2="14" y2="8" stroke-width="2"/><path d="M5.5 5.5L8 3l2.5 2.5"/><path d="M5.5 10.5L8 13l2.5-2.5"/></svg></span><span class="item-label">Align middle</span> 164 + </button> 165 + <button class="toolbar-dropdown-item" data-valign="bottom" title="Align bottom" role="menuitem"> 166 + <span class="item-icon"><svg class="tb-icon" viewBox="0 0 16 16"><line x1="2" y1="14" x2="14" y2="14" stroke-width="2"/><line x1="8" y1="2" x2="8" y2="12"/><path d="M5.5 9.5L8 12l2.5-2.5"/></svg></span><span class="item-label">Align bottom</span> 167 + </button> 168 + </div> 169 + </div> 132 170 <button class="tb-btn toolbar-mobile-hide" id="tb-wrap" title="Wrap text" aria-label="Wrap text"><svg class="tb-icon" viewBox="0 0 16 16"><path d="M2 3h12M2 7h9a2 2 0 0 1 0 4H9"/><path d="M10 9.5l-1.5 1.5L10 12.5"/><line x1="2" y1="13" x2="7" y2="13"/></svg></button> 133 171 <button class="tb-btn toolbar-mobile-hide" id="tb-merge" title="Merge/Unmerge cells" aria-label="Merge cells"><svg class="tb-icon" viewBox="0 0 16 16"><rect x="2" y="2" width="12" height="12" rx="1"/><path d="M5 8h6"/><path d="M6.5 6L5 8l1.5 2"/><path d="M9.5 6l1.5 2-1.5 2"/></svg></button> 172 + <button class="tb-btn toolbar-mobile-hide" id="tb-format-painter" title="Format painter (click: one-shot, double-click: sticky, Esc: cancel)" aria-label="Format painter"><svg class="tb-icon" viewBox="0 0 16 16"><path d="M10.5 1.5l2 2-6 6H4.5v-2z"/><path d="M3 11l-1 4 4-1"/><path d="M8.5 3.5l2 2"/></svg></button> 134 173 <span class="toolbar-sep toolbar-mobile-hide"></span> 135 174 136 175 <!-- Group: Sort, Filter, Chart -->
+181
src/sheets/main.js
··· 23 23 import { tokenizeForHighlighting, renderHighlightedFormula } from './formula-highlighter.js'; 24 24 import { extractFormulaRanges, assignRangeColors, renderGridHighlights, clearGridHighlights } from './range-highlight.js'; 25 25 import { detectCurrentFunction, renderTooltip, hideTooltip } from './formula-tooltip.js'; 26 + import { extractFormat, applyFormat } from './format-painter.js'; 26 27 27 28 // --- Constants --- 28 29 const DEFAULT_ROWS = 100; ··· 180 181 let selectionRange = null; 181 182 let editingCell = null; 182 183 let isSelecting = false; 184 + let formatPainterFormat = null; 185 + let formatPainterSticky = false; 183 186 184 187 // --- Merge helpers (#11) --- 185 188 function getMerges() { ··· 506 509 if (s.bg) style += 'background:' + s.bg + ';'; 507 510 if (s.bold) style += 'font-weight:600;'; 508 511 if (s.italic) style += 'font-style:italic;'; 512 + if (s.fontSize) style += 'font-size:' + s.fontSize + 'pt;'; 513 + if (s.fontFamily) { 514 + const families = { 515 + 'sans-serif': 'system-ui, sans-serif', 516 + 'serif': 'Charter, Georgia, serif', 517 + 'monospace': 'ui-monospace, "SF Mono", monospace', 518 + }; 519 + style += 'font-family:' + (families[s.fontFamily] || 'system-ui, sans-serif') + ';'; 520 + } 521 + // text-decoration: combine underline + strikethrough 522 + const decorations = []; 523 + if (s.underline) decorations.push('underline'); 524 + if (s.strikethrough) decorations.push('line-through'); 525 + if (decorations.length > 0) style += 'text-decoration:' + decorations.join(' ') + ';'; 509 526 if (s.align) style += 'justify-content:' + (s.align === 'left' ? 'flex-start' : s.align === 'right' ? 'flex-end' : 'center') + ';'; 527 + if (s.verticalAlign) { 528 + const vaMap = { top: 'flex-start', middle: 'center', bottom: 'flex-end' }; 529 + style += 'align-items:' + (vaMap[s.verticalAlign] || 'flex-start') + ';'; 530 + } 510 531 if (s.borders) style += buildBorderStyle(s.borders); 511 532 if (s.wrap) style += 'white-space:normal;word-wrap:break-word;overflow-wrap:break-word;'; 512 533 } ··· 667 688 const row = parseInt(td.dataset.row); 668 689 if (editingCell) commitEdit(); 669 690 691 + // Format painter: apply format to clicked cell 692 + if (formatPainterFormat) { 693 + const targetId = cellId(col, row); 694 + const existing = getCellData(targetId); 695 + const newStyle = applyFormat(existing?.s, formatPainterFormat); 696 + setCellData(targetId, { s: newStyle }); 697 + refreshVisibleCells(); 698 + if (!formatPainterSticky) { 699 + deactivateFormatPainter(); 700 + } 701 + return; 702 + } 703 + 670 704 if (e.shiftKey) { 671 705 selectionRange = { startCol: selectedCell.col, startRow: selectedCell.row, endCol: col, endRow: row }; 672 706 } else { ··· 794 828 if ((e.metaKey || e.ctrlKey) && key === 'c') { e.preventDefault(); copySelection(); } 795 829 if ((e.metaKey || e.ctrlKey) && key === 'b') { e.preventDefault(); const id = cellId(selectedCell.col, selectedCell.row); applyStyleToSelection('bold', !getCellData(id)?.s?.bold); } 796 830 if ((e.metaKey || e.ctrlKey) && key === 'i') { e.preventDefault(); const id = cellId(selectedCell.col, selectedCell.row); applyStyleToSelection('italic', !getCellData(id)?.s?.italic); } 831 + if ((e.metaKey || e.ctrlKey) && key === 'u') { e.preventDefault(); const id = cellId(selectedCell.col, selectedCell.row); applyStyleToSelection('underline', !getCellData(id)?.s?.underline); updateUnderlineButtonState(); } 832 + if ((e.metaKey || e.ctrlKey) && e.shiftKey && (key === 'x' || key === 'X')) { e.preventDefault(); const id = cellId(selectedCell.col, selectedCell.row); applyStyleToSelection('strikethrough', !getCellData(id)?.s?.strikethrough); updateStrikethroughButtonState(); } 797 833 // Undo: Cmd+Z (Mac) / Ctrl+Z 798 834 if ((e.metaKey || e.ctrlKey) && key === 'z' && !e.shiftKey) { 799 835 e.preventDefault(); ··· 824 860 updateFormulaBar(); 825 861 updateMergeButtonState(); 826 862 updateWrapButtonState(); 863 + updateUnderlineButtonState(); 864 + updateStrikethroughButtonState(); 865 + updateFontSizeSelect(); 866 + updateFontFamilySelect(); 867 + updateVerticalAlignButton(); 827 868 scrollCellIntoView(newCol, newRow); 828 869 } 829 870 ··· 1128 1169 const id = cellId(selectedCell.col, selectedCell.row); 1129 1170 const current = getCellData(id)?.s?.italic; 1130 1171 applyStyleToSelection('italic', !current); 1172 + }); 1173 + 1174 + // Underline button 1175 + document.getElementById('tb-underline').addEventListener('click', () => { 1176 + const id = cellId(selectedCell.col, selectedCell.row); 1177 + const current = getCellData(id)?.s?.underline; 1178 + applyStyleToSelection('underline', !current); 1179 + updateUnderlineButtonState(); 1180 + }); 1181 + 1182 + // Strikethrough button 1183 + document.getElementById('tb-strikethrough').addEventListener('click', () => { 1184 + const id = cellId(selectedCell.col, selectedCell.row); 1185 + const current = getCellData(id)?.s?.strikethrough; 1186 + applyStyleToSelection('strikethrough', !current); 1187 + updateStrikethroughButtonState(); 1188 + }); 1189 + 1190 + // Font size select 1191 + document.getElementById('tb-font-size').addEventListener('change', (e) => { 1192 + const size = parseInt(e.target.value); 1193 + if (size) applyStyleToSelection('fontSize', size); 1194 + }); 1195 + 1196 + // Font family select 1197 + document.getElementById('tb-font-family').addEventListener('change', (e) => { 1198 + applyStyleToSelection('fontFamily', e.target.value || undefined); 1199 + }); 1200 + 1201 + // --- Vertical align dropdown --- 1202 + const vAlignDropdown = document.getElementById('dd-valign'); 1203 + const vAlignToggle = document.getElementById('tb-valign-toggle'); 1204 + const VALIGN_SVGS = { 1205 + top: '<svg class="tb-icon" viewBox="0 0 16 16"><line x1="2" y1="2" x2="14" y2="2" stroke-width="2"/><line x1="8" y1="4" x2="8" y2="14"/><path d="M5.5 6.5L8 4l2.5 2.5"/></svg>', 1206 + middle: '<svg class="tb-icon" viewBox="0 0 16 16"><line x1="2" y1="8" x2="14" y2="8" stroke-width="2"/><path d="M5.5 5.5L8 3l2.5 2.5"/><path d="M5.5 10.5L8 13l2.5-2.5"/></svg>', 1207 + bottom: '<svg class="tb-icon" viewBox="0 0 16 16"><line x1="2" y1="14" x2="14" y2="14" stroke-width="2"/><line x1="8" y1="2" x2="8" y2="12"/><path d="M5.5 9.5L8 12l2.5-2.5"/></svg>', 1208 + }; 1209 + 1210 + vAlignToggle.addEventListener('click', (e) => { 1211 + e.stopPropagation(); 1212 + toggleDropdown(vAlignDropdown); 1213 + }); 1214 + 1215 + vAlignDropdown.querySelectorAll('[data-valign]').forEach(btn => { 1216 + btn.addEventListener('click', (e) => { 1217 + e.stopPropagation(); 1218 + const va = btn.dataset.valign; 1219 + applyStyleToSelection('verticalAlign', va); 1220 + vAlignToggle.querySelector('.dd-icon').innerHTML = VALIGN_SVGS[va] || VALIGN_SVGS.top; 1221 + closeAllDropdowns(); 1222 + }); 1223 + }); 1224 + 1225 + // --- Format painter --- 1226 + let formatPainterClickTimeout = null; 1227 + const formatPainterBtn = document.getElementById('tb-format-painter'); 1228 + 1229 + formatPainterBtn.addEventListener('click', () => { 1230 + // Double-click detection for sticky mode 1231 + if (formatPainterClickTimeout) { 1232 + clearTimeout(formatPainterClickTimeout); 1233 + formatPainterClickTimeout = null; 1234 + // Double click: enable sticky mode 1235 + formatPainterSticky = true; 1236 + activateFormatPainter(); 1237 + return; 1238 + } 1239 + formatPainterClickTimeout = setTimeout(() => { 1240 + formatPainterClickTimeout = null; 1241 + if (formatPainterFormat) { 1242 + // Already active, deactivate 1243 + deactivateFormatPainter(); 1244 + } else { 1245 + // Single click: one-shot mode 1246 + formatPainterSticky = false; 1247 + activateFormatPainter(); 1248 + } 1249 + }, 250); 1250 + }); 1251 + 1252 + function activateFormatPainter() { 1253 + const id = cellId(selectedCell.col, selectedCell.row); 1254 + const cellData = getCellData(id); 1255 + formatPainterFormat = extractFormat(cellData); 1256 + formatPainterBtn.classList.add('active'); 1257 + document.body.classList.add('format-painter-active'); 1258 + } 1259 + 1260 + function deactivateFormatPainter() { 1261 + formatPainterFormat = null; 1262 + formatPainterSticky = false; 1263 + formatPainterBtn.classList.remove('active'); 1264 + document.body.classList.remove('format-painter-active'); 1265 + } 1266 + 1267 + // Escape key cancels format painter 1268 + document.addEventListener('keydown', (e) => { 1269 + if (e.key === 'Escape' && formatPainterFormat) { 1270 + deactivateFormatPainter(); 1271 + } 1131 1272 }); 1132 1273 1133 1274 // --- Alignment dropdown --- ··· 2490 2631 2491 2632 function updateStripedButtonState() { 2492 2633 document.getElementById('tb-striped').classList.toggle('active', getStripedRows()); 2634 + } 2635 + 2636 + function updateUnderlineButtonState() { 2637 + const id = cellId(selectedCell.col, selectedCell.row); 2638 + const isUnderline = getCellData(id)?.s?.underline; 2639 + document.getElementById('tb-underline').classList.toggle('active', !!isUnderline); 2640 + } 2641 + 2642 + function updateStrikethroughButtonState() { 2643 + const id = cellId(selectedCell.col, selectedCell.row); 2644 + const isStrike = getCellData(id)?.s?.strikethrough; 2645 + document.getElementById('tb-strikethrough').classList.toggle('active', !!isStrike); 2646 + } 2647 + 2648 + function updateFontSizeSelect() { 2649 + const id = cellId(selectedCell.col, selectedCell.row); 2650 + const fontSize = getCellData(id)?.s?.fontSize || 11; 2651 + const sel = document.getElementById('tb-font-size'); 2652 + if (sel) sel.value = String(fontSize); 2653 + } 2654 + 2655 + function updateFontFamilySelect() { 2656 + const id = cellId(selectedCell.col, selectedCell.row); 2657 + const fontFamily = getCellData(id)?.s?.fontFamily || 'sans-serif'; 2658 + const sel = document.getElementById('tb-font-family'); 2659 + if (sel) sel.value = fontFamily; 2660 + } 2661 + 2662 + function updateVerticalAlignButton() { 2663 + const id = cellId(selectedCell.col, selectedCell.row); 2664 + const va = getCellData(id)?.s?.verticalAlign || 'top'; 2665 + const toggle = document.getElementById('tb-valign-toggle'); 2666 + if (toggle) { 2667 + const VALIGN_SVGS = { 2668 + top: '<svg class="tb-icon" viewBox="0 0 16 16"><line x1="2" y1="2" x2="14" y2="2" stroke-width="2"/><line x1="8" y1="4" x2="8" y2="14"/><path d="M5.5 6.5L8 4l2.5 2.5"/></svg>', 2669 + middle: '<svg class="tb-icon" viewBox="0 0 16 16"><line x1="2" y1="8" x2="14" y2="8" stroke-width="2"/><path d="M5.5 5.5L8 3l2.5 2.5"/><path d="M5.5 10.5L8 13l2.5-2.5"/></svg>', 2670 + bottom: '<svg class="tb-icon" viewBox="0 0 16 16"><line x1="2" y1="14" x2="14" y2="14" stroke-width="2"/><line x1="8" y1="2" x2="8" y2="12"/><path d="M5.5 9.5L8 12l2.5-2.5"/></svg>', 2671 + }; 2672 + toggle.querySelector('.dd-icon').innerHTML = VALIGN_SVGS[va] || VALIGN_SVGS.top; 2673 + } 2493 2674 } 2494 2675 2495 2676 // ========================================================
+33
src/sheets/xlsx-import.js
··· 97 97 if (cell.s.font && cell.s.font.bold) { 98 98 data.s.bold = true; 99 99 } 100 + if (cell.s.font && cell.s.font.italic) { 101 + data.s.italic = true; 102 + } 103 + if (cell.s.font && cell.s.font.underline) { 104 + data.s.underline = true; 105 + } 106 + if (cell.s.font && cell.s.font.strike) { 107 + data.s.strikethrough = true; 108 + } 109 + if (cell.s.font && cell.s.font.sz) { 110 + data.s.fontSize = cell.s.font.sz; 111 + } 112 + if (cell.s.font && cell.s.font.color && cell.s.font.color.rgb) { 113 + data.s.color = '#' + cell.s.font.color.rgb.slice(-6); 114 + } 115 + if (cell.s.fill && cell.s.fill.fgColor && cell.s.fill.fgColor.rgb) { 116 + data.s.bg = '#' + cell.s.fill.fgColor.rgb.slice(-6); 117 + } 118 + if (cell.s.alignment && cell.s.alignment.horizontal) { 119 + const align = cell.s.alignment.horizontal; 120 + if (['left', 'center', 'right'].includes(align)) { 121 + data.s.align = align; 122 + } 123 + } 124 + if (cell.s.alignment && cell.s.alignment.vertical) { 125 + const va = cell.s.alignment.vertical; 126 + if (va === 'top') data.s.verticalAlign = 'top'; 127 + else if (va === 'center') data.s.verticalAlign = 'middle'; 128 + else if (va === 'bottom') data.s.verticalAlign = 'bottom'; 129 + } 130 + if (cell.s.alignment && cell.s.alignment.wrapText) { 131 + data.s.wrap = true; 132 + } 100 133 } 101 134 102 135 // Extract number format
+247
tests/cell-formatting.test.js
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { buildBorderStyle, getWrapStyle } from '../src/sheets/cell-styles.js'; 3 + 4 + /** 5 + * getCellStyle lives in main.js and is tightly coupled to the DOM, 6 + * so we test the style-building logic via a pure reimplementation 7 + * that mirrors the production code. This keeps tests fast and DOM-free. 8 + * 9 + * The getCellStylePure function below MUST be kept in sync with the 10 + * getCellStyle function in src/sheets/main.js. 11 + */ 12 + function getCellStylePure(cellData, cfStyleStr) { 13 + let style = ''; 14 + if (cellData?.s) { 15 + const s = cellData.s; 16 + if (s.color) style += 'color:' + s.color + ';'; 17 + if (s.bg) style += 'background:' + s.bg + ';'; 18 + if (s.bold) style += 'font-weight:600;'; 19 + if (s.italic) style += 'font-style:italic;'; 20 + if (s.fontSize) style += 'font-size:' + s.fontSize + 'pt;'; 21 + if (s.fontFamily) { 22 + const families = { 23 + 'sans-serif': 'system-ui, sans-serif', 24 + 'serif': 'Charter, Georgia, serif', 25 + 'monospace': 'ui-monospace, "SF Mono", monospace', 26 + }; 27 + style += 'font-family:' + (families[s.fontFamily] || 'system-ui, sans-serif') + ';'; 28 + } 29 + // text-decoration: combine underline + strikethrough 30 + const decorations = []; 31 + if (s.underline) decorations.push('underline'); 32 + if (s.strikethrough) decorations.push('line-through'); 33 + if (decorations.length > 0) style += 'text-decoration:' + decorations.join(' ') + ';'; 34 + if (s.align) style += 'justify-content:' + (s.align === 'left' ? 'flex-start' : s.align === 'right' ? 'flex-end' : 'center') + ';'; 35 + if (s.verticalAlign) { 36 + const vaMap = { top: 'flex-start', middle: 'center', bottom: 'flex-end' }; 37 + style += 'align-items:' + (vaMap[s.verticalAlign] || 'flex-start') + ';'; 38 + } 39 + if (s.borders) style += buildBorderStyle(s.borders); 40 + if (s.wrap) style += 'white-space:normal;word-wrap:break-word;overflow-wrap:break-word;'; 41 + } 42 + // Conditional formatting 43 + if (cfStyleStr && (!cellData?.s?.bg && !cellData?.s?.color)) { 44 + style += cfStyleStr; 45 + } else if (cfStyleStr) { 46 + if (!cellData?.s?.bg && cfStyleStr.includes('background:')) { 47 + const bgMatch = cfStyleStr.match(/background:[^;]+;/); 48 + if (bgMatch) style += bgMatch[0]; 49 + } 50 + if (!cellData?.s?.color && cfStyleStr.includes('color:')) { 51 + const colorMatch = cfStyleStr.match(/(?:^|;)(color:[^;]+;)/); 52 + if (colorMatch) style += colorMatch[1]; 53 + } 54 + } 55 + return style; 56 + } 57 + 58 + // ============================================================ 59 + // fontSize 60 + // ============================================================ 61 + 62 + describe('getCellStyle — fontSize', () => { 63 + it('applies font-size in pt units', () => { 64 + const result = getCellStylePure({ s: { fontSize: 14 } }); 65 + expect(result).toContain('font-size:14pt;'); 66 + }); 67 + 68 + it('does not include font-size when not set', () => { 69 + const result = getCellStylePure({ s: { bold: true } }); 70 + expect(result).not.toContain('font-size'); 71 + }); 72 + 73 + it('handles small font size', () => { 74 + const result = getCellStylePure({ s: { fontSize: 8 } }); 75 + expect(result).toContain('font-size:8pt;'); 76 + }); 77 + 78 + it('handles large font size', () => { 79 + const result = getCellStylePure({ s: { fontSize: 24 } }); 80 + expect(result).toContain('font-size:24pt;'); 81 + }); 82 + }); 83 + 84 + // ============================================================ 85 + // fontFamily 86 + // ============================================================ 87 + 88 + describe('getCellStyle — fontFamily', () => { 89 + it('maps sans-serif to system-ui, sans-serif', () => { 90 + const result = getCellStylePure({ s: { fontFamily: 'sans-serif' } }); 91 + expect(result).toContain('font-family:system-ui, sans-serif;'); 92 + }); 93 + 94 + it('maps serif to Charter, Georgia, serif', () => { 95 + const result = getCellStylePure({ s: { fontFamily: 'serif' } }); 96 + expect(result).toContain('font-family:Charter, Georgia, serif;'); 97 + }); 98 + 99 + it('maps monospace to ui-monospace, "SF Mono", monospace', () => { 100 + const result = getCellStylePure({ s: { fontFamily: 'monospace' } }); 101 + expect(result).toContain('font-family:ui-monospace, "SF Mono", monospace;'); 102 + }); 103 + 104 + it('does not include font-family when not set', () => { 105 + const result = getCellStylePure({ s: { bold: true } }); 106 + expect(result).not.toContain('font-family'); 107 + }); 108 + }); 109 + 110 + // ============================================================ 111 + // underline 112 + // ============================================================ 113 + 114 + describe('getCellStyle — underline', () => { 115 + it('applies text-decoration: underline', () => { 116 + const result = getCellStylePure({ s: { underline: true } }); 117 + expect(result).toContain('text-decoration:underline;'); 118 + }); 119 + 120 + it('does not include text-decoration when underline is false', () => { 121 + const result = getCellStylePure({ s: { underline: false } }); 122 + expect(result).not.toContain('text-decoration'); 123 + }); 124 + }); 125 + 126 + // ============================================================ 127 + // strikethrough 128 + // ============================================================ 129 + 130 + describe('getCellStyle — strikethrough', () => { 131 + it('applies text-decoration: line-through', () => { 132 + const result = getCellStylePure({ s: { strikethrough: true } }); 133 + expect(result).toContain('text-decoration:line-through;'); 134 + }); 135 + 136 + it('does not include text-decoration when strikethrough is false', () => { 137 + const result = getCellStylePure({ s: { strikethrough: false } }); 138 + expect(result).not.toContain('text-decoration'); 139 + }); 140 + }); 141 + 142 + // ============================================================ 143 + // underline + strikethrough combined 144 + // ============================================================ 145 + 146 + describe('getCellStyle — combined underline + strikethrough', () => { 147 + it('combines both decorations into a single text-decoration value', () => { 148 + const result = getCellStylePure({ s: { underline: true, strikethrough: true } }); 149 + expect(result).toContain('text-decoration:underline line-through;'); 150 + }); 151 + 152 + it('only underline when strikethrough is false', () => { 153 + const result = getCellStylePure({ s: { underline: true, strikethrough: false } }); 154 + expect(result).toContain('text-decoration:underline;'); 155 + expect(result).not.toContain('line-through'); 156 + }); 157 + }); 158 + 159 + // ============================================================ 160 + // verticalAlign 161 + // ============================================================ 162 + 163 + describe('getCellStyle — verticalAlign', () => { 164 + it('maps top to align-items: flex-start', () => { 165 + const result = getCellStylePure({ s: { verticalAlign: 'top' } }); 166 + expect(result).toContain('align-items:flex-start;'); 167 + }); 168 + 169 + it('maps middle to align-items: center', () => { 170 + const result = getCellStylePure({ s: { verticalAlign: 'middle' } }); 171 + expect(result).toContain('align-items:center;'); 172 + }); 173 + 174 + it('maps bottom to align-items: flex-end', () => { 175 + const result = getCellStylePure({ s: { verticalAlign: 'bottom' } }); 176 + expect(result).toContain('align-items:flex-end;'); 177 + }); 178 + 179 + it('does not include align-items when verticalAlign is not set', () => { 180 + const result = getCellStylePure({ s: { bold: true } }); 181 + expect(result).not.toContain('align-items'); 182 + }); 183 + }); 184 + 185 + // ============================================================ 186 + // Combinations — all new properties together 187 + // ============================================================ 188 + 189 + describe('getCellStyle — combined new properties', () => { 190 + it('includes all new properties in the output', () => { 191 + const result = getCellStylePure({ 192 + s: { 193 + bold: true, 194 + italic: true, 195 + fontSize: 16, 196 + fontFamily: 'serif', 197 + underline: true, 198 + strikethrough: true, 199 + verticalAlign: 'bottom', 200 + color: '#ff0000', 201 + bg: '#eeeeee', 202 + }, 203 + }); 204 + expect(result).toContain('font-weight:600;'); 205 + expect(result).toContain('font-style:italic;'); 206 + expect(result).toContain('font-size:16pt;'); 207 + expect(result).toContain('font-family:Charter, Georgia, serif;'); 208 + expect(result).toContain('text-decoration:underline line-through;'); 209 + expect(result).toContain('align-items:flex-end;'); 210 + expect(result).toContain('color:#ff0000;'); 211 + expect(result).toContain('background:#eeeeee;'); 212 + }); 213 + }); 214 + 215 + // ============================================================ 216 + // Default / edge cases 217 + // ============================================================ 218 + 219 + describe('getCellStyle — defaults and edge cases', () => { 220 + it('returns empty string for null cellData', () => { 221 + const result = getCellStylePure(null); 222 + expect(result).toBe(''); 223 + }); 224 + 225 + it('returns empty string for cellData with no style', () => { 226 + const result = getCellStylePure({ v: 'hello' }); 227 + expect(result).toBe(''); 228 + }); 229 + 230 + it('returns empty string for cellData with empty style object', () => { 231 + const result = getCellStylePure({ s: {} }); 232 + expect(result).toBe(''); 233 + }); 234 + 235 + it('preserves existing properties alongside new ones', () => { 236 + const result = getCellStylePure({ 237 + s: { 238 + bold: true, 239 + fontSize: 12, 240 + borders: { top: '1px solid #999' }, 241 + }, 242 + }); 243 + expect(result).toContain('font-weight:600;'); 244 + expect(result).toContain('font-size:12pt;'); 245 + expect(result).toContain('border-top:1px solid #999;'); 246 + }); 247 + });
+52
tests/format-painter.test.js
··· 20 20 expect(FORMAT_PROPERTIES).toContain('format'); 21 21 expect(FORMAT_PROPERTIES).toContain('wrap'); 22 22 }); 23 + 24 + it('includes new formatting properties (underline, strikethrough, verticalAlign, fontSize, fontFamily)', () => { 25 + expect(FORMAT_PROPERTIES).toContain('underline'); 26 + expect(FORMAT_PROPERTIES).toContain('strikethrough'); 27 + expect(FORMAT_PROPERTIES).toContain('verticalAlign'); 28 + expect(FORMAT_PROPERTIES).toContain('fontSize'); 29 + expect(FORMAT_PROPERTIES).toContain('fontFamily'); 30 + }); 31 + }); 32 + 33 + // ============================================================ 34 + // extractFormat — new properties 35 + // ============================================================ 36 + 37 + describe('extractFormat — new properties', () => { 38 + it('extracts underline and strikethrough', () => { 39 + const cellData = { v: 'x', f: '', s: { underline: true, strikethrough: true } }; 40 + const result = extractFormat(cellData); 41 + expect(result.underline).toBe(true); 42 + expect(result.strikethrough).toBe(true); 43 + }); 44 + 45 + it('extracts fontSize and fontFamily', () => { 46 + const cellData = { v: 'x', f: '', s: { fontSize: 14, fontFamily: 'serif' } }; 47 + const result = extractFormat(cellData); 48 + expect(result.fontSize).toBe(14); 49 + expect(result.fontFamily).toBe('serif'); 50 + }); 51 + 52 + it('extracts verticalAlign', () => { 53 + const cellData = { v: 'x', f: '', s: { verticalAlign: 'bottom' } }; 54 + const result = extractFormat(cellData); 55 + expect(result.verticalAlign).toBe('bottom'); 56 + }); 57 + }); 58 + 59 + // ============================================================ 60 + // applyFormat — new properties 61 + // ============================================================ 62 + 63 + describe('applyFormat — new properties', () => { 64 + it('applies new properties to a cell', () => { 65 + const existing = { bold: true }; 66 + const format = { underline: true, fontSize: 16, fontFamily: 'monospace', verticalAlign: 'middle', strikethrough: true }; 67 + const result = applyFormat(existing, format); 68 + expect(result.bold).toBe(true); 69 + expect(result.underline).toBe(true); 70 + expect(result.fontSize).toBe(16); 71 + expect(result.fontFamily).toBe('monospace'); 72 + expect(result.verticalAlign).toBe('middle'); 73 + expect(result.strikethrough).toBe(true); 74 + }); 23 75 }); 24 76 25 77 // ============================================================
+303
tests/xlsx-import-styles.test.js
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { parseXlsxWithLib } from '../src/sheets/xlsx-import.js'; 3 + 4 + /** 5 + * Tests for expanded XLSX style import. 6 + * 7 + * Since SheetJS (xlsx) does not reliably round-trip cell styles through 8 + * aoa_to_sheet → write → read, we mock the XLSX library to inject 9 + * cell objects with known style properties. 10 + */ 11 + 12 + /** 13 + * Create a mock XLSX library that returns a workbook with the given cells. 14 + * Each cell is { addr: 'A1', v: value, f: formula, s: styleObj, z: format }. 15 + */ 16 + function mockXLSX(cells, sheetName = 'TestSheet', ref = 'A1:A1') { 17 + const worksheet = {}; 18 + for (const cell of cells) { 19 + const entry = {}; 20 + if (cell.v !== undefined) entry.v = cell.v; 21 + if (cell.t) entry.t = cell.t; 22 + if (cell.f) entry.f = cell.f; 23 + if (cell.s) entry.s = cell.s; 24 + if (cell.z) entry.z = cell.z; 25 + worksheet[cell.addr] = entry; 26 + } 27 + worksheet['!ref'] = ref; 28 + 29 + return { 30 + read: () => ({ 31 + SheetNames: [sheetName], 32 + Sheets: { [sheetName]: worksheet }, 33 + }), 34 + utils: { 35 + decode_range: (ref) => { 36 + // Simple A1:B2 parser 37 + const parts = ref.split(':'); 38 + const start = parseAddr(parts[0]); 39 + const end = parts[1] ? parseAddr(parts[1]) : start; 40 + return { s: start, e: end }; 41 + }, 42 + encode_cell: ({ r, c }) => { 43 + let s = ''; 44 + let n = c + 1; 45 + while (n > 0) { n--; s = String.fromCharCode(65 + (n % 26)) + s; n = Math.floor(n / 26); } 46 + return s + (r + 1); 47 + }, 48 + }, 49 + }; 50 + } 51 + 52 + function parseAddr(addr) { 53 + const match = addr.match(/^([A-Z]+)(\d+)$/); 54 + if (!match) return { r: 0, c: 0 }; 55 + let c = 0; 56 + for (const ch of match[1]) { c = c * 26 + (ch.charCodeAt(0) - 64); } 57 + return { r: parseInt(match[2]) - 1, c: c - 1 }; 58 + } 59 + 60 + // ============================================================ 61 + // Italic extraction 62 + // ============================================================ 63 + 64 + describe('XLSX import — italic', () => { 65 + it('extracts italic from cell.s.font.italic', () => { 66 + const XLSX = mockXLSX([ 67 + { addr: 'A1', v: 'test', s: { font: { italic: true } } }, 68 + ]); 69 + const result = parseXlsxWithLib(new ArrayBuffer(0), XLSX); 70 + expect(result.cells.get('A1').s.italic).toBe(true); 71 + }); 72 + 73 + it('does not set italic when font.italic is absent', () => { 74 + const XLSX = mockXLSX([ 75 + { addr: 'A1', v: 'test', s: { font: { bold: true } } }, 76 + ]); 77 + const result = parseXlsxWithLib(new ArrayBuffer(0), XLSX); 78 + expect(result.cells.get('A1').s.italic).toBeUndefined(); 79 + }); 80 + }); 81 + 82 + // ============================================================ 83 + // Underline extraction 84 + // ============================================================ 85 + 86 + describe('XLSX import — underline', () => { 87 + it('extracts underline from cell.s.font.underline', () => { 88 + const XLSX = mockXLSX([ 89 + { addr: 'A1', v: 'test', s: { font: { underline: true } } }, 90 + ]); 91 + const result = parseXlsxWithLib(new ArrayBuffer(0), XLSX); 92 + expect(result.cells.get('A1').s.underline).toBe(true); 93 + }); 94 + }); 95 + 96 + // ============================================================ 97 + // Strikethrough extraction 98 + // ============================================================ 99 + 100 + describe('XLSX import — strikethrough', () => { 101 + it('extracts strikethrough from cell.s.font.strike', () => { 102 + const XLSX = mockXLSX([ 103 + { addr: 'A1', v: 'test', s: { font: { strike: true } } }, 104 + ]); 105 + const result = parseXlsxWithLib(new ArrayBuffer(0), XLSX); 106 + expect(result.cells.get('A1').s.strikethrough).toBe(true); 107 + }); 108 + }); 109 + 110 + // ============================================================ 111 + // Font size extraction 112 + // ============================================================ 113 + 114 + describe('XLSX import — fontSize', () => { 115 + it('extracts font size from cell.s.font.sz', () => { 116 + const XLSX = mockXLSX([ 117 + { addr: 'A1', v: 'test', s: { font: { sz: 14 } } }, 118 + ]); 119 + const result = parseXlsxWithLib(new ArrayBuffer(0), XLSX); 120 + expect(result.cells.get('A1').s.fontSize).toBe(14); 121 + }); 122 + }); 123 + 124 + // ============================================================ 125 + // Text color extraction 126 + // ============================================================ 127 + 128 + describe('XLSX import — text color', () => { 129 + it('extracts text color from cell.s.font.color.rgb', () => { 130 + const XLSX = mockXLSX([ 131 + { addr: 'A1', v: 'test', s: { font: { color: { rgb: 'FF0000' } } } }, 132 + ]); 133 + const result = parseXlsxWithLib(new ArrayBuffer(0), XLSX); 134 + expect(result.cells.get('A1').s.color).toBe('#FF0000'); 135 + }); 136 + 137 + it('handles ARGB format (strips alpha prefix)', () => { 138 + const XLSX = mockXLSX([ 139 + { addr: 'A1', v: 'test', s: { font: { color: { rgb: 'FF00FF00' } } } }, 140 + ]); 141 + const result = parseXlsxWithLib(new ArrayBuffer(0), XLSX); 142 + expect(result.cells.get('A1').s.color).toBe('#00FF00'); 143 + }); 144 + }); 145 + 146 + // ============================================================ 147 + // Background color extraction 148 + // ============================================================ 149 + 150 + describe('XLSX import — background color', () => { 151 + it('extracts bg from cell.s.fill.fgColor.rgb', () => { 152 + const XLSX = mockXLSX([ 153 + { addr: 'A1', v: 'test', s: { fill: { fgColor: { rgb: 'FFFF00' } } } }, 154 + ]); 155 + const result = parseXlsxWithLib(new ArrayBuffer(0), XLSX); 156 + expect(result.cells.get('A1').s.bg).toBe('#FFFF00'); 157 + }); 158 + 159 + it('handles ARGB format for background', () => { 160 + const XLSX = mockXLSX([ 161 + { addr: 'A1', v: 'test', s: { fill: { fgColor: { rgb: 'FF0000FF' } } } }, 162 + ]); 163 + const result = parseXlsxWithLib(new ArrayBuffer(0), XLSX); 164 + expect(result.cells.get('A1').s.bg).toBe('#0000FF'); 165 + }); 166 + }); 167 + 168 + // ============================================================ 169 + // Horizontal alignment extraction 170 + // ============================================================ 171 + 172 + describe('XLSX import — horizontal alignment', () => { 173 + it('extracts left alignment', () => { 174 + const XLSX = mockXLSX([ 175 + { addr: 'A1', v: 'test', s: { alignment: { horizontal: 'left' } } }, 176 + ]); 177 + const result = parseXlsxWithLib(new ArrayBuffer(0), XLSX); 178 + expect(result.cells.get('A1').s.align).toBe('left'); 179 + }); 180 + 181 + it('extracts center alignment', () => { 182 + const XLSX = mockXLSX([ 183 + { addr: 'A1', v: 'test', s: { alignment: { horizontal: 'center' } } }, 184 + ]); 185 + const result = parseXlsxWithLib(new ArrayBuffer(0), XLSX); 186 + expect(result.cells.get('A1').s.align).toBe('center'); 187 + }); 188 + 189 + it('extracts right alignment', () => { 190 + const XLSX = mockXLSX([ 191 + { addr: 'A1', v: 'test', s: { alignment: { horizontal: 'right' } } }, 192 + ]); 193 + const result = parseXlsxWithLib(new ArrayBuffer(0), XLSX); 194 + expect(result.cells.get('A1').s.align).toBe('right'); 195 + }); 196 + 197 + it('ignores unsupported alignment values', () => { 198 + const XLSX = mockXLSX([ 199 + { addr: 'A1', v: 'test', s: { alignment: { horizontal: 'justify' } } }, 200 + ]); 201 + const result = parseXlsxWithLib(new ArrayBuffer(0), XLSX); 202 + expect(result.cells.get('A1').s.align).toBeUndefined(); 203 + }); 204 + }); 205 + 206 + // ============================================================ 207 + // Vertical alignment extraction 208 + // ============================================================ 209 + 210 + describe('XLSX import — vertical alignment', () => { 211 + it('extracts top vertical alignment', () => { 212 + const XLSX = mockXLSX([ 213 + { addr: 'A1', v: 'test', s: { alignment: { vertical: 'top' } } }, 214 + ]); 215 + const result = parseXlsxWithLib(new ArrayBuffer(0), XLSX); 216 + expect(result.cells.get('A1').s.verticalAlign).toBe('top'); 217 + }); 218 + 219 + it('maps center vertical alignment to middle', () => { 220 + const XLSX = mockXLSX([ 221 + { addr: 'A1', v: 'test', s: { alignment: { vertical: 'center' } } }, 222 + ]); 223 + const result = parseXlsxWithLib(new ArrayBuffer(0), XLSX); 224 + expect(result.cells.get('A1').s.verticalAlign).toBe('middle'); 225 + }); 226 + 227 + it('extracts bottom vertical alignment', () => { 228 + const XLSX = mockXLSX([ 229 + { addr: 'A1', v: 'test', s: { alignment: { vertical: 'bottom' } } }, 230 + ]); 231 + const result = parseXlsxWithLib(new ArrayBuffer(0), XLSX); 232 + expect(result.cells.get('A1').s.verticalAlign).toBe('bottom'); 233 + }); 234 + }); 235 + 236 + // ============================================================ 237 + // Text wrap extraction 238 + // ============================================================ 239 + 240 + describe('XLSX import — wrap text', () => { 241 + it('extracts wrapText as wrap', () => { 242 + const XLSX = mockXLSX([ 243 + { addr: 'A1', v: 'test', s: { alignment: { wrapText: true } } }, 244 + ]); 245 + const result = parseXlsxWithLib(new ArrayBuffer(0), XLSX); 246 + expect(result.cells.get('A1').s.wrap).toBe(true); 247 + }); 248 + }); 249 + 250 + // ============================================================ 251 + // Combined style extraction 252 + // ============================================================ 253 + 254 + describe('XLSX import — combined styles', () => { 255 + it('extracts all style properties from a fully styled cell', () => { 256 + const XLSX = mockXLSX([ 257 + { 258 + addr: 'A1', 259 + v: 'styled', 260 + s: { 261 + font: { 262 + bold: true, 263 + italic: true, 264 + underline: true, 265 + strike: true, 266 + sz: 16, 267 + color: { rgb: 'FF0000' }, 268 + }, 269 + fill: { 270 + fgColor: { rgb: 'FFFF00' }, 271 + }, 272 + alignment: { 273 + horizontal: 'center', 274 + vertical: 'bottom', 275 + wrapText: true, 276 + }, 277 + }, 278 + }, 279 + ]); 280 + const result = parseXlsxWithLib(new ArrayBuffer(0), XLSX); 281 + const s = result.cells.get('A1').s; 282 + expect(s.bold).toBe(true); 283 + expect(s.italic).toBe(true); 284 + expect(s.underline).toBe(true); 285 + expect(s.strikethrough).toBe(true); 286 + expect(s.fontSize).toBe(16); 287 + expect(s.color).toBe('#FF0000'); 288 + expect(s.bg).toBe('#FFFF00'); 289 + expect(s.align).toBe('center'); 290 + expect(s.verticalAlign).toBe('bottom'); 291 + expect(s.wrap).toBe(true); 292 + }); 293 + 294 + it('handles cell with no style object', () => { 295 + const XLSX = mockXLSX([ 296 + { addr: 'A1', v: 'plain' }, 297 + ]); 298 + const result = parseXlsxWithLib(new ArrayBuffer(0), XLSX); 299 + expect(result.cells.get('A1').s).toBeDefined(); 300 + expect(result.cells.get('A1').s.bold).toBeUndefined(); 301 + expect(result.cells.get('A1').s.italic).toBeUndefined(); 302 + }); 303 + });