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
14 changes: 14 additions & 0 deletions docs/PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,20 @@ cell.style = mine
The same holds for a conditional rule's `style`, which is the workbook's
`<dxf>` record shared by every rule pointing at it.

### A cell font comes back whole

A cell's `<font>` 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` →
Expand Down
10 changes: 10 additions & 0 deletions src/_types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
37 changes: 36 additions & 1 deletion src/xlsx/styles-writer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,41 @@ export function createStylesCollector(
return id
}

// A cell format's `fontId` picks one complete `<font>`; 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)
Expand Down Expand Up @@ -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
Expand Down
172 changes: 172 additions & 0 deletions test/font-inherits-default.test.ts
Original file line number Diff line number Diff line change
@@ -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 `<font>` with only that part:
//
// <font><sz val="10"/><name val="Arial"/></font>
// <font><b/><color rgb="FF1F3A5F"/></font>
//
// `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<string> {
return dec.decode(await new ZipReader(bytes).extract("xl/styles.xml"))
}

function fontsOf(xml: string): string[] {
const fonts = xml.match(/<fonts\b[^>]*>([\s\S]*?)<\/fonts>/)?.[1] ?? ""
return fonts.match(/<font>[\s\S]*?<\/font>/g) ?? []
}

async function collect(stream: ReadableStream<Uint8Array>): Promise<Uint8Array> {
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([
'<font><sz val="10"/><name val="Arial"/></font>',
'<font><b/><color rgb="FF1F3A5F"/><sz val="10"/><name val="Arial"/></font>',
])
})

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([
'<font><i/><sz val="14"/><name val="Georgia"/></font>',
'<font><sz val="10"/><name val="Georgia"/></font>',
'<font><sz val="14"/><name val="Arial"/></font>',
])
})

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('<font><b/><sz val="11"/><name val="Calibri"/></font>')
})

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(
'<font><b/><family val="2"/><scheme val="minor"/><sz val="11"/><name val="Calibri"/></font>',
)
expect(named).toBe('<font><b/><sz val="11"/><name val="Georgia"/></font>')
})

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('<font><b/><sz val="10"/><name val="Arial"/></font>')
})

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(/<dxfs\b[^>]*>([\s\S]*?)<\/dxfs>/)?.[1]
expect(dxfs).toBe('<dxf><font><b/><color rgb="FF9C0006"/></font></dxf>')
})

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(
'<font><b/><sz val="11"/><name val="Calibri"/></font>',
)
})

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" })
})
})
6 changes: 5 additions & 1 deletion test/xlsx-write-read-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,12 +88,16 @@ const SHEET_FIELDS: { [K in keyof Required<WriteSheet>]: Entry<WriteSheet[K]> }
// `header` is data, not column metadata: it is written into row 0 on
// the `data[]` path and read back as a cell there. `<cols>` 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" } },
},
},

Expand Down