diff --git a/desktop/src/components/workspace/surfaces/document/spreadsheetEngine.test.ts b/desktop/src/components/workspace/surfaces/document/spreadsheetEngine.test.ts index 876ddade..b4d1c35c 100644 --- a/desktop/src/components/workspace/surfaces/document/spreadsheetEngine.test.ts +++ b/desktop/src/components/workspace/surfaces/document/spreadsheetEngine.test.ts @@ -210,6 +210,60 @@ describe('the shape of a sheet', () => { expect(grid.columnWidths[3]).toBe(72) // not said }) + it('widens a column the file gives no width to fit its numbers, so a value is never cut to "12,000…"', async () => { + const grid = await firstGrid(workbookOf([{ name: 'A', rows: [['项目', '预算(元)'], ['服务器', '1,234,567,890.00'], ['差旅', '8,000.00']] }])) + + // 16 digits/separators at ~6.6px, plus the cell's padding. + expect(grid.columnWidths[1]).toBeGreaterThanOrEqual(120) + // A short CJK label still gets the default floor, not a sliver. + expect(grid.columnWidths[0]).toBeGreaterThanOrEqual(72) + }) + + it('leaves a short column at the default width', async () => { + const grid = await firstGrid(workbookOf([{ name: 'A', rows: [['a', 'b'], ['c', 'd']] }])) + + expect(grid.columnWidths).toEqual([72, 72]) + }) + + it('caps how far a long text can widen a column', async () => { + const grid = await firstGrid(workbookOf([{ name: 'A', rows: [['x'.repeat(500)]] }])) + + expect(grid.columnWidths[0]).toBe(360) + }) + + it('does not widen a column for a merged heading that spills over its neighbours', async () => { + const grid = await firstGrid(workbookOf([{ + name: 'A', + rows: [['Quarterly budget summary for the whole group', '', ''], ['a', 'b', 'c']], + merges: [{ s: { r: 0, c: 0 }, e: { r: 0, c: 2 } }], + }])) + + expect(grid.columnWidths).toEqual([72, 72, 72]) + }) + + it('widens a stated column for a number that would otherwise be cut, as Excel would show ####', async () => { + // 12 characters is what the file asked for; the preview font needs more for this figure. + const grid = await firstGrid(workbookOf([{ + name: 'A', + rows: [['预算', 1234567890.12]], + columns: [{ wpx: 40 }, { wpx: 40 }], + }])) + + expect(grid.columnWidths[1]).toBeGreaterThan(40) + // The text column keeps the width the file set: clipping text is what Excel does too. + expect(grid.columnWidths[0]).toBe(40) + }) + + it('never widens a column the file sets, nor a hidden one', async () => { + const grid = await firstGrid(workbookOf([{ + name: 'A', + rows: [['x'.repeat(100), 'y'.repeat(100)]], + columns: [{ wpx: 90 }, { hidden: true }], + }])) + + expect(grid.columnWidths).toEqual([90, 0]) + }) + it('keeps column widths within what can be drawn', async () => { const grid = await firstGrid(workbookOf([{ name: 'A', rows: [['a', 'b']], columns: [{ wpx: 2 }, { wpx: 9000 }] }])) diff --git a/desktop/src/components/workspace/surfaces/document/spreadsheetEngine.ts b/desktop/src/components/workspace/surfaces/document/spreadsheetEngine.ts index ee4b4d09..7380c6dd 100644 --- a/desktop/src/components/workspace/surfaces/document/spreadsheetEngine.ts +++ b/desktop/src/components/workspace/surfaces/document/spreadsheetEngine.ts @@ -91,6 +91,9 @@ export type SpreadsheetEngineOptions = { const DEFAULT_COLUMN_WIDTH = 72 const MIN_COLUMN_WIDTH = 28 const MAX_COLUMN_WIDTH = 480 +/** A column grows to fit its text or numbers, up to this. */ +const MAX_AUTO_COLUMN_WIDTH = 360 +const CELL_PADDING = 18 const MIN_ROW_HEIGHT = 16 const MAX_ROW_HEIGHT = 400 @@ -120,6 +123,31 @@ function cellAlign(cell: SheetJs.CellObject): CellAlign { return 'left' } +/** Rendered width of cell text at the grid's 12px size; a CJK character is about twice a Latin one. */ +function textWidth(text: string): number { + let width = 0 + for (const char of text) width += (char.codePointAt(0) ?? 0) >= 0x2e80 ? 12 : 6.6 + return width +} + +/** + * How wide a column must be to show its cells whole. Excel would draw a number that does + * not fit as `####`; a preview that cuts it to `12,000…` hides the value just the same, and + * the preview's font is wider than the one the width was chosen for. `numbersOnly` is for a + * column whose width the file states: its text may still be clipped, as Excel clips it, but + * a number may not. A merged cell's text spills over its span, so it takes no part. + */ +function fitColumnWidth(cells: Grid['cells'], column: number, spilling: ReadonlySet, numbersOnly: boolean): number { + let widest = 0 + for (let row = 0; row < cells.length; row += 1) { + const cell = cells[row]?.[column] + if (!cell || spilling.has(`${row}:${column}`)) continue + if (numbersOnly && cell.align !== 'right') continue + widest = Math.max(widest, textWidth(cell.text)) + } + return widest === 0 ? 0 : Math.min(Math.ceil(widest + CELL_PADDING), MAX_AUTO_COLUMN_WIDTH) +} + /** Turn a dense SheetJS worksheet into the grid the viewer draws, and let SheetJS's objects go. */ function toGrid(sheet: SheetJs.WorkSheet, limits: SpreadsheetLimits): Grid { const data = ((sheet as SheetJs.DenseSheet)['!data'] ?? []) as Array | undefined> @@ -153,11 +181,19 @@ function toGrid(sheet: SheetJs.WorkSheet, limits: SpreadsheetLimits): Grid { cells.push(line) } + const spilling = new Set() + for (const range of sheet['!merges'] ?? []) { + if (range.e.c > range.s.c) spilling.add(`${range.s.r}:${range.s.c}`) + } + const columnInfo = sheet['!cols'] ?? [] const columnWidths = Array.from({ length: columns }, (_, column) => { const info = columnInfo[column] if (info?.hidden) return 0 - const width = info?.wpx ?? (info?.wch ? info.wch * 7 + 5 : DEFAULT_COLUMN_WIDTH) + const stated = info?.wpx ?? (info?.wch ? info.wch * 7 + 5 : undefined) + const width = stated === undefined + ? Math.max(DEFAULT_COLUMN_WIDTH, fitColumnWidth(cells, column, spilling, false)) + : Math.max(stated, fitColumnWidth(cells, column, spilling, true)) return clamp(Math.round(width), MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH) })