fix(desktop): keep spreadsheet numbers whole in the workspace preview

Columns without a stored width fell back to a fixed 72px, and stored
widths were chosen for Excel's font, which is narrower than the
preview's. Values such as 12,000.00 were clipped to "12,000...".

A column the file gives no width now fits its widest cell, and a number
column never ends up narrower than its numbers. Text in a column whose
width the file sets is still clipped, as Excel clips it, and merged
headings that spill over their span do not widen a column.
This commit is contained in:
程序员阿江(Relakkes)
2026-10-01 15:14:25 +08:00
parent 6dddf94db8
commit 9a29abb83c
2 changed files with 91 additions and 1 deletions
@@ -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 }] }]))
@@ -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<string>, 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<Array<SheetJs.CellObject | undefined> | undefined>
@@ -153,11 +181,19 @@ function toGrid(sheet: SheetJs.WorkSheet, limits: SpreadsheetLimits): Grid {
cells.push(line)
}
const spilling = new Set<string>()
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)
})