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
2 changes: 2 additions & 0 deletions src/_types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1679,6 +1679,8 @@ export interface WriteOptions {
/** String storage mode. Default: "shared"
* - "shared": shared string table (smaller files with repeated strings)
* - "inline": inline strings per cell (faster write, larger files)
*
* Rich text follows the same choice (#602).
*/
stringMode?: "shared" | "inline"
/** VBA project binary (vbaProject.bin) to embed. Output becomes macro-enabled (.xlsm). */
Expand Down
46 changes: 40 additions & 6 deletions src/xlsx/worksheet-writer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,15 +153,24 @@ export function rangeRef(
export interface SharedStringsCollector {
/** Get or add a string, return its index */
add(value: string): number
/** Get all strings in order */
/** Get or add a rich string, return its index */
addRich(runs: RichTextRun[]): number
/** Get all strings in order. A rich string is its runs' text joined. */
getAll(): string[]
/** The `<r>` runs of each rich string, by index */
getRich(): ReadonlyMap<number, string>
/** Get count */
count(): number
}

export function createSharedStrings(): SharedStringsCollector {
const strings: string[] = []
const indexMap = new Map<string, number>()
// Rich strings are keyed by their serialized runs, in a map of their
// own: "Bold normal" in bold and the plain "Bold normal" are different
// entries, while two cells with equal runs share one. See #602.
const richIndexMap = new Map<string, number>()
const richXml = new Map<number, string>()

function add(value: string): number {
const existing = indexMap.get(value)
Expand All @@ -173,15 +182,31 @@ export function createSharedStrings(): SharedStringsCollector {
return idx
}

function addRich(runs: RichTextRun[]): number {
const xml = serializeRichTextRuns(runs).join("")
const existing = richIndexMap.get(xml)
if (existing !== undefined) return existing

const idx = strings.length
strings.push(runs.map((run) => run.text).join(""))
richIndexMap.set(xml, idx)
richXml.set(idx, xml)
return idx
}

function getAll(): string[] {
return strings
}

function getRich(): ReadonlyMap<number, string> {
return richXml
}

function count(): number {
return strings.length
}

return { add, getAll, count }
return { add, addRich, getAll, getRich, count }
}

/** Generate xl/sharedStrings.xml */
Expand All @@ -193,9 +218,13 @@ export function writeSharedStringsXml(sharedStrings: SharedStringsCollector): st
return xmlDocument("sst", { xmlns: NS_SPREADSHEET, count: 0, uniqueCount: 0 }, "")
}

const rich = sharedStrings.getRich()
const children: string[] = []
for (const str of strings) {
children.push(xmlElement("si", undefined, [xmlTextElement(str)]))
for (let i = 0; i < count; i++) {
// A rich `<si>` holds the same `<r>` runs as an inline `<is>`
// (§18.4.8, §18.3.1.53).
const runs = rich.get(i)
children.push(xmlElement("si", undefined, runs ?? [xmlTextElement(strings[i])]))
}

return xmlDocument("sst", { xmlns: NS_SPREADSHEET, count, uniqueCount: count }, children)
Expand Down Expand Up @@ -961,8 +990,13 @@ export function serializeCell(

const ref = cellRef(row, col)

// Rich text cell (inline string)
// Rich text cell. It follows `stringMode` like plain text: it used to be
// inline whatever the mode, so tools that only read the shared string
// table never saw it, and Excel itself puts rich text there. See #602.
if (richText && richText.length > 0) {
if (!inlineStrings) {
return simpleCell(ref, styleIdx, "s", String(sharedStrings.addRich(richText)))
}
const cellAttrs: Record<string, string | number> = { r: ref, t: "inlineStr" }
if (styleIdx !== 0) cellAttrs["s"] = styleIdx
const isContent = serializeRichTextRuns(richText)
Expand Down Expand Up @@ -1527,7 +1561,7 @@ function serializeColorAttrs(color: Color): Record<string, string | number> {

// ── Rich Text Serialization ──────────────────────────────────────────

/** Serialize an array of RichTextRun into XML elements for an <is> (inline string) block */
/** Serialize an array of RichTextRun into the `<r>` elements of an `<is>` or a shared `<si>` */
function serializeRichTextRuns(runs: RichTextRun[]): string[] {
const elements: string[] = []

Expand Down
10 changes: 9 additions & 1 deletion test/batch-fixes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,15 @@ describe("#89: Rich text writing", () => {
]),
}

const xml = writeXml(sheet)
// The inline form; rich text in the shared string table is #602.
const xml = writeWorksheetXml(
sheet,
createStylesCollector(),
createSharedStrings(),
undefined,
undefined,
true,
).xml
const doc = parseXml(xml)

const sheetData = findChild(doc, "sheetData")
Expand Down
59 changes: 34 additions & 25 deletions test/coverage-xlsx-writer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ describe("styled cells of every value shape", () => {
const styled: CellStyle = { font: { bold: true } }

it("keeps the style index on a rich-text cell", async () => {
const xml = await sheetXml({
const sheet: WriteSheet = {
name: "S",
cells: cellMap([
[
Expand All @@ -125,9 +125,13 @@ describe("styled cells of every value shape", () => {
},
],
]),
})
}

expect(xml).toMatch(/<c r="A1" t="inlineStr" s="\d+">/)
// Shared by default, inline on request — see #602.
expect(await sheetXml(sheet)).toMatch(/<c r="A1" t="s" s="\d+"><v>0<\/v><\/c>/)
expect(await sheetXml(sheet, { stringMode: "inline" })).toMatch(
/<c r="A1" t="inlineStr" s="\d+">/,
)
})

it("keeps the style index on an error cell", async () => {
Expand Down Expand Up @@ -329,29 +333,34 @@ describe("sheet-level OOXML blocks", () => {

describe("rich text run properties", () => {
it("writes vertAlign, family, charset and scheme into <rPr>", async () => {
const xml = await sheetXml({
name: "S",
cells: cellMap([
[
"0,0",
{
richText: [
{ text: "E=mc" },
{
text: "2",
font: {
vertAlign: "superscript",
family: 2,
charset: 204,
scheme: "minor",
color: { indexed: 10 },
// Inline, so the runs are in the sheet; the shared form is the same
// `serializeRichTextRuns` output inside an `<si>` (#602).
const xml = await sheetXml(
{
name: "S",
cells: cellMap([
[
"0,0",
{
richText: [
{ text: "E=mc" },
{
text: "2",
font: {
vertAlign: "superscript",
family: 2,
charset: 204,
scheme: "minor",
color: { indexed: 10 },
},
},
},
],
},
],
]),
})
],
},
],
]),
},
{ stringMode: "inline" },
)

expect(xml).toContain('<vertAlign val="superscript"/>')
expect(xml).toContain('<family val="2"/>')
Expand Down
5 changes: 4 additions & 1 deletion test/one-cell-serializer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,8 +146,11 @@ describe("the authoring path did not regress", () => {
})

const xml = await sheetXml(bytes)
const sst = new TextDecoder().decode(await new ZipReader(bytes).extract("xl/sharedStrings.xml"))

expect(xml).toContain("<is>")
// Shared string mode, so the runs are in the table (#602).
expect(xml).toContain('<c r="A1" t="s"><v>0</v></c>')
expect(sst).toContain("<si><r><rPr><b/></rPr><t>bold</t></r></si>")
expect(xml).toContain('t="shared"')
expect(xml).toContain('si="0"')
expect(xml).toMatch(/cm="\d+"/)
Expand Down
121 changes: 121 additions & 0 deletions test/rich-text-shared-strings.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { describe, expect, it } from "vitest"
import { writeXlsx } from "../src/xlsx/writer"
import { readXlsx } from "../src/xlsx/reader"
import { openXlsx, saveXlsx } from "../src/xlsx/roundtrip"
import { ZipReader } from "../src/zip/reader"
import type { Cell, RichTextRun, WriteOptions, WriteSheet } from "../src/_types"

// ═══════════════════════════════════════════════════════════════════════
// Rich text ignored `stringMode`. Plain text went to xl/sharedStrings.xml
// in the default "shared" mode, but a rich text cell was always written
// inline:
//
// <c r="A2" t="inlineStr"><is><r><rPr><b/></rPr><t>Bold</t></r>…</is></c>
//
// That is valid, but anything that only reads the shared string table —
// template engines, find/replace, string indexing — never saw the rich
// cells, and it is not what Excel writes. In shared mode a rich string is
// now an `<si>` holding the same `<r>` runs (ECMA-376 Part 1 §18.4.8) and
// the cell is `t="s"`. Inline mode is unchanged. See #602.
// ═══════════════════════════════════════════════════════════════════════

const decoder = new TextDecoder("utf-8")

async function part(buf: Uint8Array, path: string): Promise<string> {
return decoder.decode(await new ZipReader(buf).extract(path))
}

const BOLD_RUNS: RichTextRun[] = [{ text: "Bold", font: { bold: true } }, { text: " normal" }]
const BOLD_RUNS_XML = '<r><rPr><b/></rPr><t>Bold</t></r><r><t xml:space="preserve"> normal</t></r>'

function write(
cells: Array<[string, Partial<Cell>]>,
rows: WriteSheet["rows"] = [],
options: Omit<WriteOptions, "sheets"> = {},
): Promise<Uint8Array> {
return writeXlsx({ sheets: [{ name: "S", rows, cells: new Map(cells) }], ...options })
}

describe("rich text in shared string mode (#602)", () => {
it('writes the runs into the shared string table and the cell as t="s"', async () => {
const buf = await write([["1,0", { richText: BOLD_RUNS }]], [["Plain"]])

const sheet = await part(buf, "xl/worksheets/sheet1.xml")
expect(sheet).toContain('<c r="A2" t="s"><v>1</v></c>')
expect(sheet).not.toContain("inlineStr")

const sst = await part(buf, "xl/sharedStrings.xml")
expect(sst).toContain('count="2" uniqueCount="2"')
expect(sst).toContain(`<si><t>Plain</t></si><si>${BOLD_RUNS_XML}</si>`)
})

it("keeps the style index on a shared rich text cell", async () => {
const buf = await write([["0,0", { richText: BOLD_RUNS, style: { font: { italic: true } } }]])

const sheet = await part(buf, "xl/worksheets/sheet1.xml")
expect(sheet).toMatch(/<c r="A1" t="s" s="[1-9]\d*"><v>0<\/v><\/c>/)
})

it("shares one entry between identical rich strings, apart from plain text", async () => {
const buf = await write([
["0,0", { richText: BOLD_RUNS }],
["0,1", { value: "Bold normal" }],
["0,2", { richText: [{ text: "Bold", font: { bold: true } }, { text: " normal" }] }],
["0,3", { richText: [{ text: "Bold", font: { italic: true } }, { text: " normal" }] }],
])

const sheet = await part(buf, "xl/worksheets/sheet1.xml")
expect(sheet).toContain('<c r="A1" t="s"><v>0</v></c>')
// Same text, no runs: a different string as far as the table goes.
expect(sheet).toContain('<c r="B1" t="s"><v>1</v></c>')
// Equal runs, separate array: the same entry.
expect(sheet).toContain('<c r="C1" t="s"><v>0</v></c>')
// Same text, different formatting: a new entry.
expect(sheet).toContain('<c r="D1" t="s"><v>2</v></c>')

const sst = await part(buf, "xl/sharedStrings.xml")
expect(sst).toContain('count="3" uniqueCount="3"')
})

it("writes the shared string part when rich text is the only text", async () => {
const buf = await write([["0,0", { richText: BOLD_RUNS }]])
const zip = new ZipReader(buf)

expect(zip.has("xl/sharedStrings.xml")).toBe(true)
expect(await part(buf, "[Content_Types].xml")).toContain('PartName="/xl/sharedStrings.xml"')
expect(await part(buf, "xl/_rels/workbook.xml.rels")).toContain('Target="sharedStrings.xml"')
expect(await part(buf, "xl/sharedStrings.xml")).toContain(`<si>${BOLD_RUNS_XML}</si>`)
})

it("reads the rich text back from the shared string table", async () => {
const buf = await write([["0,0", { richText: BOLD_RUNS }]])

const cell = (await readXlsx(buf)).sheets[0].cells?.get("0,0")
expect(cell?.type).toBe("richText")
expect(cell?.value).toBe("Bold normal")
expect(cell?.richText).toEqual(BOLD_RUNS)
})

it("leaves inline mode as it was", async () => {
const buf = await write([["1,0", { richText: BOLD_RUNS }]], [["Plain"]], {
stringMode: "inline",
})

const sheet = await part(buf, "xl/worksheets/sheet1.xml")
expect(sheet).toContain(`<c r="A2" t="inlineStr"><is>${BOLD_RUNS_XML}</is></c>`)
expect(new ZipReader(buf).has("xl/sharedStrings.xml")).toBe(false)
})

it("saveXlsx writes rich text into the shared string table too", async () => {
// saveXlsx always writes shared strings, so a rich cell read from an
// inline-only file moves into the table.
const original = await write([["0,0", { richText: BOLD_RUNS }]], [], { stringMode: "inline" })
const saved = await saveXlsx(await openXlsx(original))

expect(await part(saved, "xl/worksheets/sheet1.xml")).toContain('<c r="A1" t="s"><v>0</v></c>')
expect(await part(saved, "xl/sharedStrings.xml")).toContain(`<si>${BOLD_RUNS_XML}</si>`)

const cell = (await readXlsx(saved)).sheets[0].cells?.get("0,0")
expect(cell?.richText).toEqual(BOLD_RUNS)
})
})
17 changes: 13 additions & 4 deletions test/rich-text-write.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,13 @@ function getElementText(el: { children: Array<unknown> }): string {
return el.children.filter((c: unknown) => typeof c === "string").join("")
}

// Inline mode, so the runs can be read off the cell's `<is>`. The shared
// form writes the same runs into an `<si>` — see
// rich-text-shared-strings.test.ts (#602).
function writeXml(sheet: WriteSheet): string {
const styles = createStylesCollector()
const ss = createSharedStrings()
const result = writeWorksheetXml(sheet, styles, ss)
const result = writeWorksheetXml(sheet, styles, ss, undefined, undefined, true)
return result.xml
}

Expand Down Expand Up @@ -308,7 +311,9 @@ describe("rich text write — mixed sheet", () => {
cells,
}

const xml = writeXml(sheet)
// Shared mode, the default: both strings go to the table (#602).
const ss = createSharedStrings()
const xml = writeWorksheetXml(sheet, createStylesCollector(), ss).xml
const doc = parseSheet(xml)
const sheetData = findChild(doc, "sheetData")
const rows = findChildren(sheetData, "row")
Expand All @@ -317,9 +322,13 @@ describe("rich text write — mixed sheet", () => {
const c1 = findChild(rows[0], "c")
expect(c1.attrs["t"]).toBe("s")

// Row 2: rich text (inlineStr)
// Row 2: rich text, a separate shared string carrying its runs
const c2 = findChild(rows[1], "c")
expect(c2.attrs["t"]).toBe("inlineStr")
expect(c2.attrs["t"]).toBe("s")
expect(getElementText(findChild(c2, "v"))).toBe("1")
expect(ss.getAll()).toEqual(["Plain string", "Bold text"])
expect(ss.getRich().get(0)).toBeUndefined()
expect(ss.getRich().get(1)).toContain("<r><rPr><b/></rPr><t>Bold</t></r>")

// Row 3: number
const c3 = findChild(rows[2], "c")
Expand Down
Loading