diff --git a/docs/PARITY.md b/docs/PARITY.md index 6b21f59..6a25e20 100644 --- a/docs/PARITY.md +++ b/docs/PARITY.md @@ -405,6 +405,20 @@ cell.style = mine The same holds for a conditional rule's `style`, which is the workbook's `` record shared by every rule pointing at it. +### A cell font comes back whole + +A cell's `` is complete in itself and does not layer on the +workbook's default. So a cell font that names no face or size, such as +`{ bold: true }`, is written with the `name` and `size` of `defaultFont` +(Calibri 11 when there is none) and reads back with them: +`{ bold: true, size: 10, name: "Arial" }`. When the face is inherited, +the default's `family`, `charset` and `scheme` come with it. Colour and +the other parts do not. See #594. + +A conditional rule's `style` is a differential format, where a part left +out means "keep the cell's own", so it is written and read back exactly +as given. + ### Read and round-trip only — no authoring API These are parsed into the model and preserved through `openXlsx` → diff --git a/src/_types.ts b/src/_types.ts index 8d9591a..2ae50d6 100644 --- a/src/_types.ts +++ b/src/_types.ts @@ -1666,6 +1666,16 @@ export interface WriteOptions { sheets: WriteSheet[] properties?: WorkbookProperties namedRanges?: NamedRange[] + /** + * Default font for the workbook, written as `fonts[0]`. Default: + * Calibri 11. + * + * A cell's font does not layer on it in the file, so a cell font that + * leaves out `name` or `size` is written with this font's. A name taken + * from here brings its `family`, `charset` and `scheme` along; colour and + * the other parts are not inherited. Conditional formatting styles are + * written as given. See #594. + */ defaultFont?: FontStyle dateSystem?: "1900" | "1904" /** Active sheet index (0-based). Default: 0 */ diff --git a/src/xlsx/styles-writer.ts b/src/xlsx/styles-writer.ts index c8f617e..f274f01 100644 --- a/src/xlsx/styles-writer.ts +++ b/src/xlsx/styles-writer.ts @@ -346,6 +346,41 @@ export function createStylesCollector( return id } + // A cell format's `fontId` picks one complete ``; it does not layer + // on fonts[0]. So a font that gave only part of itself — `{ bold: true }` + // — was written without a face or size, and Excel and LibreOffice drew it + // in their own default rather than the workbook's (#594). It takes `name` + // and `size` from the base font here, which is what `defaultFont` promises. + // + // `family`, `charset` and `scheme` describe the base's typeface, so they + // come along only with its name: `scheme="minor"` on a font that names + // Georgia would have Excel draw the theme font instead. Colour and the + // other parts stay as the cell gave them. + // + // Cell formats only. A differential format (`addDxf`) keeps exactly what + // it says, because there a missing part means "keep the cell's own". + // + // This runs once per cell where there is no identity cache, so a font that + // already names both is passed through without a copy. + function inheritBaseFont(font: FontStyle): FontStyle { + if (font.name && font.size !== undefined) return font + const merged: FontStyle = { ...font } + if (merged.size === undefined) merged.size = baseFont.size + if (!merged.name) { + merged.name = baseFont.name + if (merged.family === undefined && baseFont.family !== undefined) { + merged.family = baseFont.family + } + if (merged.charset === undefined && baseFont.charset !== undefined) { + merged.charset = baseFont.charset + } + if (merged.scheme === undefined && baseFont.scheme !== undefined) { + merged.scheme = baseFont.scheme + } + } + return merged + } + function addFill(fill: FillStyle): number { const key = fillKey(fill) const existing = fillMap.get(key) @@ -449,7 +484,7 @@ export function createStylesCollector( if (cached !== undefined) return cached } - const fontId = style.font ? addFont(style.font) : 0 + const fontId = style.font ? addFont(inheritBaseFont(style.font)) : 0 const fillId = style.fill ? addFill(style.fill) : 0 const borderId = style.border ? addBorder(style.border) : 0 const numFmtId = style.numFmt ? addNumFmt(style.numFmt) : 0 diff --git a/test/font-inherits-default.test.ts b/test/font-inherits-default.test.ts new file mode 100644 index 0000000..7ec1661 --- /dev/null +++ b/test/font-inherits-default.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from "vitest" +import { createStylesCollector } from "../src/xlsx/styles-writer" +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 type { CellStyle } from "../src/_types" + +// ═══════════════════════════════════════════════════════════════════════ +// #594 — `defaultFont` is `fonts[0]` in styles.xml, and its doc says every +// cell format inherits from it. A cell font that gave only part of a font, +// `{ bold: true }` say, was written as its own `` with only that part: +// +// +// +// +// `fontId="1"` does not layer on font 0, so the bold cell had no face or +// size at all and LibreOffice drew it in Cambria 11pt. A cell format's font +// now takes `name` and `size` from the default when it leaves them out. +// Differential formats (conditional formatting) keep only what they say: +// there a missing part means "leave the cell's own font alone". +// ═══════════════════════════════════════════════════════════════════════ + +const dec = new TextDecoder() + +async function stylesXml(bytes: Uint8Array): Promise { + return dec.decode(await new ZipReader(bytes).extract("xl/styles.xml")) +} + +function fontsOf(xml: string): string[] { + const fonts = xml.match(/]*>([\s\S]*?)<\/fonts>/)?.[1] ?? "" + return fonts.match(/[\s\S]*?<\/font>/g) ?? [] +} + +async function collect(stream: ReadableStream): Promise { + const chunks: Uint8Array[] = [] + let total = 0 + const reader = stream.getReader() + for (;;) { + const { done, value } = await reader.read() + if (done) break + chunks.push(value) + total += value.length + } + const out = new Uint8Array(total) + let offset = 0 + for (const chunk of chunks) { + out.set(chunk, offset) + offset += chunk.length + } + return out +} + +describe("a cell font inherits name and size from defaultFont (#594)", () => { + it("fills in the default's name and size for a bold-only font", async () => { + const bytes = await writeXlsx({ + defaultFont: { name: "Arial", size: 10 }, + sheets: [ + { + name: "Sheet1", + rows: [ + [{ value: "Bold header", style: { font: { bold: true, color: { rgb: "FF1F3A5F" } } } }], + ], + }, + ], + }) + + expect(fontsOf(await stylesXml(bytes))).toEqual([ + '', + '', + ]) + }) + + it("keeps a name and size the cell font gives itself", () => { + const styles = createStylesCollector({ name: "Arial", size: 10 }) + styles.addStyle({ font: { name: "Georgia", size: 14, italic: true } }) + styles.addStyle({ font: { name: "Georgia" } }) + styles.addStyle({ font: { size: 14 } }) + + expect(fontsOf(styles.toXml()).slice(1)).toEqual([ + '', + '', + '', + ]) + }) + + it("falls back to Calibri 11 when there is no defaultFont", () => { + const styles = createStylesCollector() + styles.addStyle({ font: { bold: true } }) + + expect(fontsOf(styles.toXml())[1]).toBe('') + }) + + it("takes scheme and family only along with the default's name", () => { + // A theme default — what `readXlsx` hands back as `defaultFont` for a + // file Excel wrote. `scheme="minor"` tells Excel to draw the theme's + // body font rather than `name`, so copying it onto a font that names + // its own typeface would replace that typeface. + const styles = createStylesCollector({ name: "Calibri", size: 11, family: 2, scheme: "minor" }) + styles.addStyle({ font: { bold: true } }) + styles.addStyle({ font: { name: "Georgia", bold: true } }) + + const [, inherited, named] = fontsOf(styles.toXml()) + expect(inherited).toBe( + '', + ) + expect(named).toBe('') + }) + + it("does not take the default's colour", () => { + const styles = createStylesCollector({ name: "Arial", size: 10, color: { theme: 1 } }) + styles.addStyle({ font: { bold: true } }) + + expect(fontsOf(styles.toXml())[1]).toBe('') + }) + + it("leaves a conditional formatting font as given", async () => { + const bytes = await writeXlsx({ + defaultFont: { name: "Arial", size: 10 }, + sheets: [ + { + name: "S", + rows: [[1], [2]], + conditionalRules: [ + { + type: "cellIs", + priority: 1, + operator: "greaterThan", + formula: "1", + range: "A1:A2", + style: { font: { bold: true, color: { rgb: "FF9C0006" } } }, + }, + ], + }, + ], + }) + + const dxfs = (await stylesXml(bytes)).match(/]*>([\s\S]*?)<\/dxfs>/)?.[1] + expect(dxfs).toBe('') + }) + + it("does not mutate the caller's style object", async () => { + const style: CellStyle = { font: { bold: true } } + await writeXlsx({ + defaultFont: { name: "Arial", size: 10 }, + sheets: [{ name: "S", rows: [[{ value: "x", style }]] }], + }) + + expect(style).toEqual({ font: { bold: true } }) + }) + + it("applies to the streaming writer too", async () => { + function* rows() { + yield [{ value: "x", style: { font: { bold: true } } }] + } + const bytes = await collect(writeXlsxStream(rows(), { name: "S" })) + + expect(fontsOf(await stylesXml(bytes))[1]).toBe( + '', + ) + }) + + it("reads back with the inherited name and size", async () => { + const bytes = await writeXlsx({ + defaultFont: { name: "Arial", size: 10 }, + sheets: [{ name: "S", rows: [[{ value: "x", style: { font: { bold: true } } }]] }], + }) + + const cell = (await readXlsx(bytes, { readStyles: true })).sheets[0]!.cells?.get("0,0") + expect(cell?.style?.font).toEqual({ bold: true, size: 10, name: "Arial" }) + }) +}) diff --git a/test/xlsx-write-read-parity.test.ts b/test/xlsx-write-read-parity.test.ts index d9a2a21..b6a2e09 100644 --- a/test/xlsx-write-read-parity.test.ts +++ b/test/xlsx-write-read-parity.test.ts @@ -88,12 +88,16 @@ const SHEET_FIELDS: { [K in keyof Required]: Entry } // `header` is data, not column metadata: it is written into row 0 on // the `data[]` path and read back as a cell there. `` carries // width and outline level, and that is what comes back. + // + // The bold font names no face or size, so it is written with the + // workbook's — the `defaultFont` probe below makes that Georgia 13 — + // and reads back with them (#594). expected: { cols: [ { width: 18, outlineLevel: 1 }, { width: 12, outlineLevel: undefined }, ], - bodyStyle: { numFmt: "0.000", font: { bold: true } }, + bodyStyle: { numFmt: "0.000", font: { bold: true, size: 13, name: "Georgia" } }, }, },