Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,19 @@ the workbook-level caches, and has no write counterpart because
| `WriteSheet.a11y` | authoring metadata. `a11y.summary` is promoted to `properties.description` and does survive; `a11y.headerRow` has no cell to live in |
| `SheetProtection.password`, `workbookProtection.password` | the file holds a one-way digest, never the password. The digest is read; the password cannot be |

### A column with no width comes back with one

A `ColumnDef` that sets a `style`, an outline level or `hidden` but no
`width` is written with the sheet's `defaultColWidth`, or 9.140625 —
Excel's default, 8.43 characters shown — when the sheet has none, and
reads back with that `width`. `customWidth` is not set, so the file still
says it is the default.

The schema lets `width` be left out, but Excel reads a `<col>` without
one as zero wide, which hid the column: `columns: [{ style }]` produced a
column the user could not see. Excel itself always writes a width, even
for a column that only has a format. Fixed in #603.

### Losses inside fields that otherwise round-trip

Three cases where a field is carried but a particular _value_ is not.
Expand Down
5 changes: 4 additions & 1 deletion src/xlsx/stream-writer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import { zipStream, type ZipStreamEntry } from "../zip/stream-writer"
import { writeContentTypes } from "./content-types-writer"
import { writeRootRels, writeWorkbookRels } from "./workbook-writer"
import { createStylesCollector, type StylesCollector } from "./styles-writer"
import { createSharedStrings, writeSharedStringsXml } from "./worksheet-writer"
import { createSharedStrings, DEFAULT_COL_WIDTH, writeSharedStringsXml } from "./worksheet-writer"
import {
cellRef,
hasRowAttributes,
Expand Down Expand Up @@ -412,6 +412,9 @@ function buildSheetPrelude(columns: ColumnDef[] | undefined, freezePane: FreezeP
if (col.width !== undefined) {
colAttrs["width"] = col.width
colAttrs["customWidth"] = true
} else {
// Excel shows a `<col>` without a width zero wide. See #603.
colAttrs["width"] = DEFAULT_COL_WIDTH
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
if (col.hidden) colAttrs["hidden"] = true
if (col.outlineLevel) colAttrs["outlineLevel"] = col.outlineLevel
Expand Down
15 changes: 15 additions & 0 deletions src/xlsx/worksheet-writer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,19 @@ import { hashSheetPassword } from "./password"
import { validateColumnIndex } from "../_validate"
import { toCellValue } from "../_inline-cells"

// ── Column Width ──────────────────────────────────────────────────

/**
* The `<col width>` for a column that has a `<col>` but no width of its
* own: Windows Excel's default for Calibri 11, 8.43 characters shown.
*
* `width` is optional in the schema, but Excel reads a `<col>` without it
* as zero wide, so a column given only a style or an outline level came
* out hidden. Excel itself always writes one, even for a column that only
* has a format. See #603.
*/
export const DEFAULT_COL_WIDTH = 9.140625

// ── Hyperlink Relationship ────────────────────────────────────────

export interface HyperlinkRelationship {
Expand Down Expand Up @@ -484,6 +497,8 @@ export function writeWorksheetXml(
if (effectiveWidth !== undefined) {
colAttrs["width"] = effectiveWidth
colAttrs["customWidth"] = true
} else {
colAttrs["width"] = sheet.defaultColWidth ?? DEFAULT_COL_WIDTH
}
if (columnStyleId !== 0) {
colAttrs["style"] = columnStyleId
Expand Down
83 changes: 83 additions & 0 deletions test/column-and-sheet-defaults.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest"
import { writeXlsx } from "../src/xlsx/writer"
import { writeXlsxStream } from "../src/xlsx/stream-writer"
import { readXlsx } from "../src/xlsx/reader"
import { ZipReader } from "../src/zip/reader"
import { ZipWriter } from "../src/zip/writer"
Expand Down Expand Up @@ -175,3 +176,85 @@ describe("sheet format defaults reach the file", () => {
expect((await readXlsx(bytes)).sheets[0]!.defaultRowHeight).toBeUndefined()
})
})

// ═══════════════════════════════════════════════════════════════════════
// A `<col>` with no `width` is a column Excel shows zero wide — hidden.
// The schema lets `width` be left out, but Excel always writes one itself,
// even for a column that only has a format, and reads a missing one as 0.
// So `columns: [{ style }]` used to produce a column that Excel hid, along
// with any column given only an outline level. Checked in Excel for Mac. See #603.
// ═══════════════════════════════════════════════════════════════════════

/** The attributes of the `<col>` covering 1-based column `n`. */
function colAttrs(xml: string, n: number): Record<string, string> {
const tag = xml.match(new RegExp(`<col min="${n}" max="${n}"[^>]*/>`))
if (!tag) throw new Error(`no <col> for column ${n} in ${xml}`)
return Object.fromEntries([...tag[0].matchAll(/(\w+)="([^"]*)"/g)].map((m) => [m[1], m[2]]))
}

describe("a <col> without a width is not written zero wide", () => {
it("gives a styled column Excel's default width", async () => {
const bytes = await writeXlsx({
sheets: [{ name: "S", rows: [["a", "b"]], columns: [{}, { style: CURRENCY }] }],
})

const col = colAttrs(await part(bytes, "xl/worksheets/sheet1.xml"), 2)

expect(col.width).toBe("9.140625")
expect(col.style).toBeDefined()
// It is the default, not a width the author chose.
expect(col.customWidth).toBeUndefined()
})

it("gives a column with only an outline level the default width", async () => {
const bytes = await writeXlsx({
sheets: [{ name: "S", rows: [["a"]], columns: [{ outlineLevel: 1 }] }],
})

expect(colAttrs(await part(bytes, "xl/worksheets/sheet1.xml"), 1).width).toBe("9.140625")
})

it("uses the sheet's own default column width when it has one", async () => {
const bytes = await writeXlsx({
sheets: [{ name: "S", rows: [["a"]], defaultColWidth: 18, columns: [{ style: CURRENCY }] }],
})

expect(colAttrs(await part(bytes, "xl/worksheets/sheet1.xml"), 1).width).toBe("18")
})

it("leaves a width the author set alone", async () => {
const bytes = await writeXlsx({
sheets: [{ name: "S", rows: [["a"]], columns: [{ width: 12, style: CURRENCY }] }],
})

const col = colAttrs(await part(bytes, "xl/worksheets/sheet1.xml"), 1)

expect(col.width).toBe("12")
expect(col.customWidth).toBe("true")
})

it("keeps the column style through a round trip", async () => {
const bytes = await writeXlsx({
sheets: [{ name: "S", rows: [["a"]], columns: [{ style: CURRENCY }] }],
})

const back = (await readXlsx(bytes, { readStyles: true })).sheets[0]!.columns![0]!

expect(back.style!.numFmt).toBe('"$"#,##0.00')
expect(back.hidden).toBeUndefined()
})

it("does the same in the stream writer", async () => {
const stream = writeXlsxStream([["a"]], { name: "S", columns: [{ outlineLevel: 1 }] })
const chunks: Uint8Array[] = []
for await (const chunk of stream) chunks.push(chunk)
const bytes = new Uint8Array(chunks.reduce((n, c) => n + c.length, 0))
let offset = 0
for (const c of chunks) {
bytes.set(c, offset)
offset += c.length
}

expect(colAttrs(await part(bytes, "xl/worksheets/sheet1.xml"), 1).width).toBe("9.140625")
})
})
5 changes: 3 additions & 2 deletions test/coverage-xlsx-writer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -831,8 +831,9 @@ describe("XlsxStreamWriter freeze panes and column properties", () => {
w.addRow([1, 2])
const xml = await part(await w.finish(), "xl/worksheets/sheet1.xml")

expect(xml).toContain('<col min="1" max="1" hidden="true"/>')
expect(xml).toContain('<col min="2" max="2" outlineLevel="2"/>')
// A width goes out on both: Excel shows a <col> without one zero wide.
expect(xml).toContain('<col min="1" max="1" width="9.140625" hidden="true"/>')
expect(xml).toContain('<col min="2" max="2" width="9.140625" outlineLevel="2"/>')
})
})

Expand Down