diff --git a/src/_types.ts b/src/_types.ts index 8d9591a..2b7e164 100644 --- a/src/_types.ts +++ b/src/_types.ts @@ -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). */ diff --git a/src/xlsx/worksheet-writer.ts b/src/xlsx/worksheet-writer.ts index 381cc64..e70126f 100644 --- a/src/xlsx/worksheet-writer.ts +++ b/src/xlsx/worksheet-writer.ts @@ -153,8 +153,12 @@ 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 `` runs of each rich string, by index */ + getRich(): ReadonlyMap /** Get count */ count(): number } @@ -162,6 +166,11 @@ export interface SharedStringsCollector { export function createSharedStrings(): SharedStringsCollector { const strings: string[] = [] const indexMap = new Map() + // 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() + const richXml = new Map() function add(value: string): number { const existing = indexMap.get(value) @@ -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 { + return richXml + } + function count(): number { return strings.length } - return { add, getAll, count } + return { add, addRich, getAll, getRich, count } } /** Generate xl/sharedStrings.xml */ @@ -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 `` holds the same `` runs as an inline `` + // (§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) @@ -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 = { r: ref, t: "inlineStr" } if (styleIdx !== 0) cellAttrs["s"] = styleIdx const isContent = serializeRichTextRuns(richText) @@ -1527,7 +1561,7 @@ function serializeColorAttrs(color: Color): Record { // ── Rich Text Serialization ────────────────────────────────────────── -/** Serialize an array of RichTextRun into XML elements for an (inline string) block */ +/** Serialize an array of RichTextRun into the `` elements of an `` or a shared `` */ function serializeRichTextRuns(runs: RichTextRun[]): string[] { const elements: string[] = [] diff --git a/test/batch-fixes.test.ts b/test/batch-fixes.test.ts index 9750657..abab47f 100644 --- a/test/batch-fixes.test.ts +++ b/test/batch-fixes.test.ts @@ -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") diff --git a/test/coverage-xlsx-writer.test.ts b/test/coverage-xlsx-writer.test.ts index a2c2cff..1720b52 100644 --- a/test/coverage-xlsx-writer.test.ts +++ b/test/coverage-xlsx-writer.test.ts @@ -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([ [ @@ -125,9 +125,13 @@ describe("styled cells of every value shape", () => { }, ], ]), - }) + } - expect(xml).toMatch(//) + // Shared by default, inline on request — see #602. + expect(await sheetXml(sheet)).toMatch(/0<\/v><\/c>/) + expect(await sheetXml(sheet, { stringMode: "inline" })).toMatch( + //, + ) }) it("keeps the style index on an error cell", async () => { @@ -329,29 +333,34 @@ describe("sheet-level OOXML blocks", () => { describe("rich text run properties", () => { it("writes vertAlign, family, charset and scheme into ", 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 `` (#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('') expect(xml).toContain('') diff --git a/test/one-cell-serializer.test.ts b/test/one-cell-serializer.test.ts index 569bb62..3d6f2c1 100644 --- a/test/one-cell-serializer.test.ts +++ b/test/one-cell-serializer.test.ts @@ -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("") + // Shared string mode, so the runs are in the table (#602). + expect(xml).toContain('0') + expect(sst).toContain("bold") expect(xml).toContain('t="shared"') expect(xml).toContain('si="0"') expect(xml).toMatch(/cm="\d+"/) diff --git a/test/rich-text-shared-strings.test.ts b/test/rich-text-shared-strings.test.ts new file mode 100644 index 0000000..d3ae999 --- /dev/null +++ b/test/rich-text-shared-strings.test.ts @@ -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: +// +// Bold… +// +// 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 `` holding the same `` 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 { + return decoder.decode(await new ZipReader(buf).extract(path)) +} + +const BOLD_RUNS: RichTextRun[] = [{ text: "Bold", font: { bold: true } }, { text: " normal" }] +const BOLD_RUNS_XML = 'Bold normal' + +function write( + cells: Array<[string, Partial]>, + rows: WriteSheet["rows"] = [], + options: Omit = {}, +): Promise { + 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('1') + expect(sheet).not.toContain("inlineStr") + + const sst = await part(buf, "xl/sharedStrings.xml") + expect(sst).toContain('count="2" uniqueCount="2"') + expect(sst).toContain(`Plain${BOLD_RUNS_XML}`) + }) + + 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(/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('0') + // Same text, no runs: a different string as far as the table goes. + expect(sheet).toContain('1') + // Equal runs, separate array: the same entry. + expect(sheet).toContain('0') + // Same text, different formatting: a new entry. + expect(sheet).toContain('2') + + 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(`${BOLD_RUNS_XML}`) + }) + + 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(`${BOLD_RUNS_XML}`) + 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('0') + expect(await part(saved, "xl/sharedStrings.xml")).toContain(`${BOLD_RUNS_XML}`) + + const cell = (await readXlsx(saved)).sheets[0].cells?.get("0,0") + expect(cell?.richText).toEqual(BOLD_RUNS) + }) +}) diff --git a/test/rich-text-write.test.ts b/test/rich-text-write.test.ts index f9bbf7d..f6f8782 100644 --- a/test/rich-text-write.test.ts +++ b/test/rich-text-write.test.ts @@ -20,10 +20,13 @@ function getElementText(el: { children: Array }): string { return el.children.filter((c: unknown) => typeof c === "string").join("") } +// Inline mode, so the runs can be read off the cell's ``. The shared +// form writes the same runs into an `` — 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 } @@ -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") @@ -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("Bold") // Row 3: number const c3 = findChild(rows[2], "c") diff --git a/test/xml-space-preserve.test.ts b/test/xml-space-preserve.test.ts index 2c2226e..77d7a74 100644 --- a/test/xml-space-preserve.test.ts +++ b/test/xml-space-preserve.test.ts @@ -64,11 +64,18 @@ describe("xml:space=preserve on every that needs it", () => { it("declares it on a rich-text run", async () => { const cells = new Map([["0,0", { richText: [{ text: PADDED, font: { bold: true } }] }]]) - const bytes = await writeXlsx({ sheets: [{ name: "S", rows: [[null]], cells }] }) - - const xml = await part(bytes, "xl/worksheets/sheet1.xml") - - expect(xml).toContain(`${PADDED}`) + const sheets = [{ name: "S", rows: [[null]], cells }] + + // Shared mode puts the runs in the string table (#602), inline mode + // in the cell. + const shared = await writeXlsx({ sheets }) + expect(await part(shared, "xl/sharedStrings.xml")).toContain( + `${PADDED}`, + ) + const inline = await writeXlsx({ stringMode: "inline", sheets }) + expect(await part(inline, "xl/worksheets/sheet1.xml")).toContain( + `${PADDED}`, + ) }) it("declares it on a streamed inline string", async () => {