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('') }) })