diff --git a/docs/PARITY.md b/docs/PARITY.md
index 6b21f59..434c57f 100644
--- a/docs/PARITY.md
+++ b/docs/PARITY.md
@@ -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 `
` 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.
diff --git a/src/xlsx/stream-writer.ts b/src/xlsx/stream-writer.ts
index 88cde8e..abdf253 100644
--- a/src/xlsx/stream-writer.ts
+++ b/src/xlsx/stream-writer.ts
@@ -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,
@@ -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 `` without a width zero wide. See #603.
+ colAttrs["width"] = DEFAULT_COL_WIDTH
}
if (col.hidden) colAttrs["hidden"] = true
if (col.outlineLevel) colAttrs["outlineLevel"] = col.outlineLevel
diff --git a/src/xlsx/worksheet-writer.ts b/src/xlsx/worksheet-writer.ts
index 381cc64..87eef5e 100644
--- a/src/xlsx/worksheet-writer.ts
+++ b/src/xlsx/worksheet-writer.ts
@@ -34,6 +34,19 @@ import { hashSheetPassword } from "./password"
import { validateColumnIndex } from "../_validate"
import { toCellValue } from "../_inline-cells"
+// ── Column Width ──────────────────────────────────────────────────
+
+/**
+ * The `` for a column that has a `` 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 `` 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 {
@@ -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
diff --git a/test/column-and-sheet-defaults.test.ts b/test/column-and-sheet-defaults.test.ts
index a48eafc..1a8933c 100644
--- a/test/column-and-sheet-defaults.test.ts
+++ b/test/column-and-sheet-defaults.test.ts
@@ -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"
@@ -175,3 +176,85 @@ describe("sheet format defaults reach the file", () => {
expect((await readXlsx(bytes)).sheets[0]!.defaultRowHeight).toBeUndefined()
})
})
+
+// ═══════════════════════════════════════════════════════════════════════
+// A `` 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 `` covering 1-based column `n`. */
+function colAttrs(xml: string, n: number): Record {
+ const tag = xml.match(new RegExp(`]*/>`))
+ if (!tag) throw new Error(`no for column ${n} in ${xml}`)
+ return Object.fromEntries([...tag[0].matchAll(/(\w+)="([^"]*)"/g)].map((m) => [m[1], m[2]]))
+}
+
+describe("a 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")
+ })
+})
diff --git a/test/coverage-xlsx-writer.test.ts b/test/coverage-xlsx-writer.test.ts
index a2c2cff..c9e3347 100644
--- a/test/coverage-xlsx-writer.test.ts
+++ b/test/coverage-xlsx-writer.test.ts
@@ -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('')
- expect(xml).toContain('')
+ // A width goes out on both: Excel shows a without one zero wide.
+ expect(xml).toContain('')
+ expect(xml).toContain('')
})
})