diff --git a/packages/workshop-backend/__tests__/format-blueprints.test.ts b/packages/workshop-backend/__tests__/format-blueprints.test.ts index adf7d0e0a..26b074d63 100644 --- a/packages/workshop-backend/__tests__/format-blueprints.test.ts +++ b/packages/workshop-backend/__tests__/format-blueprints.test.ts @@ -109,6 +109,9 @@ describe("bundled format blueprints", () => { ], "format.spreadsheet": [ 'const CSV_FORMAT_PREFIX = "csv:"', + 'id: "xlsx"', + 'label: "Excel Workbook"', + 'contentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"', 'mode: "server"', 'contentType: "text/csv"', ], diff --git a/packages/workshop-backend/__tests__/workspace-sheets-xlsx.test.ts b/packages/workshop-backend/__tests__/workspace-sheets-xlsx.test.ts new file mode 100644 index 000000000..d7c05cfb9 --- /dev/null +++ b/packages/workshop-backend/__tests__/workspace-sheets-xlsx.test.ts @@ -0,0 +1,905 @@ +import { describe, expect, it, vi } from "vitest"; +import { ExportHandler, Gadget } from "../format-blueprints/workspace-sheets/files/server.js"; +import { workbookToXlsx } from "../format-blueprints/workspace-sheets/files/xlsx.js"; +import { createZip, crc32 } from "../format-blueprints/workspace-sheets/files/zip.js"; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +type ZipEntry = { + bytes: Uint8Array; + compressedSize: number; + crc: number; + flags: number; + localOffset: number; + method: number; + uncompressedSize: number; +}; + +async function streamBytes(stream: ReadableStream): Promise { + return new Uint8Array(await new Response(stream).arrayBuffer()); +} + +function uint16(bytes: Uint8Array, offset: number): number { + return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint16(offset, true); +} + +function uint32(bytes: Uint8Array, offset: number): number { + return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(offset, true); +} + +async function inflate(bytes: Uint8Array): Promise { + const input = new Response(bytes).body!.pipeThrough(new DecompressionStream("deflate-raw")); + return new Uint8Array(await new Response(input).arrayBuffer()); +} + +async function readZip(stream: ReadableStream) { + const archive = await streamBytes(stream); + const eocdOffset = archive.byteLength - 22; + expect(uint32(archive, eocdOffset)).toBe(0x06054b50); + expect(uint16(archive, eocdOffset + 4)).toBe(0); + expect(uint16(archive, eocdOffset + 6)).toBe(0); + const entryCount = uint16(archive, eocdOffset + 10); + expect(uint16(archive, eocdOffset + 8)).toBe(entryCount); + const centralSize = uint32(archive, eocdOffset + 12); + const centralOffset = uint32(archive, eocdOffset + 16); + expect(centralOffset + centralSize).toBe(eocdOffset); + + const entries = new Map(); + let offset = centralOffset; + for (let i = 0; i < entryCount; ++i) { + expect(uint32(archive, offset)).toBe(0x02014b50); + const flags = uint16(archive, offset + 8); + const method = uint16(archive, offset + 10); + const crc = uint32(archive, offset + 16); + const compressedSize = uint32(archive, offset + 20); + const uncompressedSize = uint32(archive, offset + 24); + const nameLength = uint16(archive, offset + 28); + const extraLength = uint16(archive, offset + 30); + const commentLength = uint16(archive, offset + 32); + const localOffset = uint32(archive, offset + 42); + const name = decoder.decode(archive.subarray(offset + 46, offset + 46 + nameLength)); + + expect(uint32(archive, localOffset)).toBe(0x04034b50); + expect(uint16(archive, localOffset + 6)).toBe(flags); + expect(uint16(archive, localOffset + 8)).toBe(method); + expect(uint16(archive, localOffset + 10)).toBe(0); + expect(uint16(archive, localOffset + 12)).toBe(33); + expect(uint32(archive, localOffset + 14)).toBe(0); + expect(uint32(archive, localOffset + 18)).toBe(0); + expect(uint32(archive, localOffset + 22)).toBe(0); + const localNameLength = uint16(archive, localOffset + 26); + const localExtraLength = uint16(archive, localOffset + 28); + expect(decoder.decode(archive.subarray(localOffset + 30, localOffset + 30 + localNameLength))).toBe(name); + + const dataOffset = localOffset + 30 + localNameLength + localExtraLength; + const compressed = archive.subarray(dataOffset, dataOffset + compressedSize); + const descriptorOffset = dataOffset + compressedSize; + expect(uint32(archive, descriptorOffset)).toBe(0x08074b50); + expect(uint32(archive, descriptorOffset + 4)).toBe(crc); + expect(uint32(archive, descriptorOffset + 8)).toBe(compressedSize); + expect(uint32(archive, descriptorOffset + 12)).toBe(uncompressedSize); + + const bytes = await inflate(compressed); + expect(bytes.byteLength).toBe(uncompressedSize); + expect(crc32(bytes)).toBe(crc); + entries.set(name, {bytes, compressedSize, crc, flags, localOffset, method, uncompressedSize}); + offset += 46 + nameLength + extraLength + commentLength; + } + expect(offset).toBe(eocdOffset); + return {archive, entries}; +} + +function text(entries: Map, name: string): string { + const entry = entries.get(name); + expect(entry, name).toBeDefined(); + return decoder.decode(entry!.bytes); +} + +function cell(value: unknown, fmt: Record | null = null) { + return {value, fmt, version: 1}; +} + +function sheet(name: string, extra: Record = {}) { + return { + id: name, + name, + rows: 100, + cols: 26, + colWidths: {}, + rowHeights: {}, + frozenRows: 0, + frozenCols: 0, + ...extra, + }; +} + +function cellXml(xml: string, reference: string): string { + const match = new RegExp(`]*?/>|]*>[\\s\\S]*?`).exec(xml); + expect(match, reference).not.toBeNull(); + return match![0]; +} + +function styleId(xml: string, reference: string): string | undefined { + return / s="(\d+)"/.exec(cellXml(xml, reference))?.[1]; +} + +function handler(): ExportHandler { + return Object.create(ExportHandler.prototype) as ExportHandler; +} + +describe("streaming ZIP32", () => { + it("calculates CRC32 and emits valid descriptor-based deflate entries", async () => { + expect(crc32(encoder.encode("123456789"))).toBe(0xcbf43926); + const chunks = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode("streamed ")); + controller.enqueue(encoder.encode("content")); + controller.close(); + }, + }); + + const {entries} = await readZip(createZip([ + {name: "plain.txt", data: "hello"}, + {name: "nested/utf8-\u2603.txt", data: chunks}, + ])); + + expect([...entries.keys()]).toEqual(["plain.txt", "nested/utf8-\u2603.txt"]); + expect(text(entries, "plain.txt")).toBe("hello"); + expect(text(entries, "nested/utf8-\u2603.txt")).toBe("streamed content"); + for (const entry of entries.values()) { + expect(entry.flags).toBe(0x0808); + expect(entry.method).toBe(8); + expect(entry.compressedSize).toBeGreaterThan(0); + } + }); +}); + +describe("Workspace Sheets XLSX", () => { + it("emits the required OOXML package and a valid blank worksheet for malformed state", async () => { + for (const document of [{}, {sheetOrder: ["empty"], sheets: {empty: sheet("")}, cells: {empty: {}}}]) { + const {entries} = await readZip(workbookToXlsx(document)); + expect([...entries.keys()]).toEqual([ + "[Content_Types].xml", + "_rels/.rels", + "xl/workbook.xml", + "xl/_rels/workbook.xml.rels", + "xl/styles.xml", + "xl/worksheets/sheet1.xml", + ]); + expect(text(entries, "[Content_Types].xml")).toContain("spreadsheetml.sheet.main+xml"); + expect(text(entries, "_rels/.rels")).toContain('Target="xl/workbook.xml"'); + expect(text(entries, "xl/_rels/workbook.xml.rels")).toContain('Target="worksheets/sheet1.xml"'); + expect(text(entries, "xl/_rels/workbook.xml.rels")).toContain('Target="styles.xml"'); + expect(text(entries, "xl/workbook.xml")).toContain(''); + expect(text(entries, "xl/worksheets/sheet1.xml")).toContain(""); + } + }); + + it("preserves sheet order, normalizes names, and rewrites recognized formula references", async () => { + const longName = "This worksheet name is substantially longer than Excel permits"; + const document = { + sheetOrder: ["a", "a", "b", "c", "d", "e", "f", "g", "h", "i"], + sheets: { + a: sheet("Sales/Data"), + b: sheet("sales_data"), + c: sheet("Sales/Data"), + d: sheet(longName), + e: sheet(" "), + f: sheet("History"), + g: sheet("O'Brien"), + h: sheet("[Book.xlsx]Data"), + i: sheet("Q[1]"), + }, + cells: { + a: {A1: cell("1")}, + b: {A1: cell("2")}, + c: { + A1: cell('=\'Sales/Data\'!A1+sales_data!$A$1+"Sales/Data!A1"+History!A1+\'O\'\'Brien\'!A1'), + A2: cell("='[Book.xlsx]Data'!A1+[Other.xlsx]'Sales/Data'!A1+'Q[1]'!A1+" + + "'Sales/Data':'History'!A1+'Missing'!A1+'Sales/Data'!NOPE+foo'Sales/Data'!A1"), + }, + d: {}, + e: {}, + f: {A1: cell("3")}, + g: {A1: cell("4")}, + h: {A1: cell("5")}, + i: {A1: cell("6")}, + }, + }; + const {entries} = await readZip(workbookToXlsx(document)); + const workbook = text(entries, "xl/workbook.xml"); + const names = [...workbook.matchAll(/ match[1]); + expect(names).toEqual([ + "Sales_Data", + "sales_data (2)", + "Sales_Data (3)", + longName.slice(0, 31), + "Sheet", + "History_", + "O'Brien", + "_Book.xlsx_Data", + "Q_1_", + ]); + const worksheet = text(entries, "xl/worksheets/sheet3.xml"); + const formulaCell = cellXml(worksheet, "A1"); + expect(formulaCell).toContain('\'Sales_Data\'!A1+\'sales_data (2)\'!$A$1+"Sales/Data!A1"+\'History_\'!A1+\'O\'\'Brien\'!A1'); + expect(formulaCell).not.toContain(""); + expect(cellXml(worksheet, "A2")).toContain( + "'_Book.xlsx_Data'!A1+[Other.xlsx]'Sales/Data'!A1+'Q_1_'!A1+" + + "'Sales/Data':'History'!A1+'Missing'!A1+'Sales/Data'!NOPE+foo'Sales/Data'!A1"); + expect(workbook).toContain(''); + }); + + it("preserves many maximum-length formulas with unmatched apostrophes", async () => { + const formula = "'".repeat(8191); + const cells = Object.fromEntries(Array.from({length: 64}, (_, index) => [ + `A${index + 1}`, + cell("=" + formula), + ])); + const {entries} = await readZip(workbookToXlsx({ + sheetOrder: ["formulas"], + sheets: {formulas: sheet("Formulas", {rows: 64, cols: 1})}, + cells: {formulas: cells}, + })); + const worksheet = text(entries, "xl/worksheets/sheet1.xml"); + + expect(worksheet.split(`${formula}`)).toHaveLength(65); + }); + + it("does not lengthen maximum-size formulas when unquoted sheet names are unchanged", async () => { + const value = "=data!A1" + "+0".repeat(4092); + expect(value).toHaveLength(8192); + const {entries} = await readZip(workbookToXlsx({ + sheetOrder: ["data", "formulas"], + sheets: {data: sheet("Data"), formulas: sheet("Formulas")}, + cells: {data: {A1: cell("1")}, formulas: {A1: cell(value)}}, + })); + + expect(cellXml(text(entries, "xl/worksheets/sheet2.xml"), "A1")) + .toContain(`${value.slice(1)}`); + }); + + it("exports formulas as text when required rewrites exceed Excel's length limit", async () => { + const renamed = "=A_B!A10" + "+0".repeat(4092); + const future = "=IFS(TRUE,1)" + "+0".repeat(4090); + expect(renamed).toHaveLength(8192); + expect(future).toHaveLength(8192); + const {entries} = await readZip(workbookToXlsx({ + sheetOrder: ["invalid", "collision", "formulas"], + sheets: { + invalid: sheet("A/B"), + collision: sheet("A_B"), + formulas: sheet("Formulas"), + }, + cells: {invalid: {}, collision: {}, formulas: {A1: cell(renamed), A2: cell(future)}}, + })); + const worksheet = text(entries, "xl/worksheets/sheet3.xml"); + + expect(cellXml(worksheet, "A1")) + .toBe(`${renamed}`); + expect(cellXml(worksheet, "A2")) + .toBe(`${future}`); + }); + + it("prefixes OOXML future functions without changing strings, sheet references, or existing prefixes", async () => { + const calls = [ + "IFS(TRUE,1)", "IFNA(A1,0)", "XOR(TRUE,FALSE)", "SWITCH(1,1,1)", + 'CONCAT("a","b")', 'TEXTJOIN(",",TRUE,A1)', "UNICHAR(65)", "UNICODE(A1)", "DAYS(2,1)", + ]; + const suffix = '+"CONCAT("+CONCAT!A1+_xlfn.CONCAT(A1)+Table1[IFS(A1)]+Table1[CONCAT!A1]'; + const {entries} = await readZip(workbookToXlsx({ + sheetOrder: ["concat", "formulas"], + sheets: {concat: sheet("CONCAT"), formulas: sheet("Formulas")}, + cells: {concat: {A1: cell("value")}, formulas: {A1: cell("=" + calls.join("+") + suffix)}}, + })); + const expected = calls.map(call => "_xlfn." + call).join("+") + suffix; + + expect(cellXml(text(entries, "xl/worksheets/sheet2.xml"), "A1")) + .toContain(`${expected}`); + }); + + it("translates ERRORTYPE function tokens to Excel's ERROR.TYPE name", async () => { + const value = '=ERRORTYPE(NA())+"ERRORTYPE("+ERRORTYPE!A1+Table1[ERRORTYPE(A1)]+' + + "'ERRORTYPE'!ERRORTYPE(A1)+[Book.xlsx]Sheet1!ERRORTYPE(A1)+ERROR.TYPE(NA())"; + const {entries} = await readZip(workbookToXlsx({ + sheetOrder: ["errorType", "formulas"], + sheets: {errorType: sheet("ERRORTYPE"), formulas: sheet("Formulas")}, + cells: {errorType: {A1: cell("value")}, formulas: {A1: cell(value)}}, + })); + + expect(cellXml(text(entries, "xl/worksheets/sheet2.xml"), "A1")).toContain( + 'ERROR.TYPE(NA())+"ERRORTYPE("+ERRORTYPE!A1+Table1[ERRORTYPE(A1)]+' + + "'ERRORTYPE'!ERRORTYPE(A1)+[Book.xlsx]Sheet1!ERRORTYPE(A1)+ERROR.TYPE(NA())"); + }); + + it("accepts an exactly maximum-size rewritten formula and rejects the next character", async () => { + const exact = "=+ERRORTYPE(NA())" + "+0".repeat(4087); + const overflow = "=ERRORTYPE(NA())" + "+0".repeat(4088); + expect(exact).toHaveLength(8191); + expect(overflow).toHaveLength(8192); + const {entries} = await readZip(workbookToXlsx({ + sheetOrder: ["data"], + sheets: {data: sheet("Data")}, + cells: {data: {A1: cell(exact), A2: cell(overflow)}}, + })); + const worksheet = text(entries, "xl/worksheets/sheet1.xml"); + const exactXml = cellXml(worksheet, "A1"); + + expect(exactXml).toContain("+ERROR.TYPE(NA())"); + expect(exactXml).not.toContain("inlineStr"); + expect(cellXml(worksheet, "A2")) + .toBe(`${overflow}`); + }); + + it("tracks apostrophe-escaped brackets in structured references", async () => { + const value = "=Table1[[A'[B]]+IFS(TRUE,1)+Table1[[A']B]]+CONCAT(A1)+Table1[[A'']]+XOR(TRUE,FALSE)"; + const {entries} = await readZip(workbookToXlsx({ + sheetOrder: ["data"], + sheets: {data: sheet("Data")}, + cells: {data: {A1: cell(value)}}, + })); + + expect(cellXml(text(entries, "xl/worksheets/sheet1.xml"), "A1")) + .toContain("Table1[[A'[B]]+_xlfn.IFS(TRUE,1)+Table1[[A']B]]+_xlfn.CONCAT(A1)+Table1[[A'']]+_xlfn.XOR(TRUE,FALSE)"); + }); + + it("prepares large duplicate sheet-name lists without quadratic suffix searches", async () => { + const sheetIds = Array.from({length: 15000}, (_, index) => `sheet-${index}`); + const metadata = Object.fromEntries(sheetIds.map((id, index) => [ + id, + sheet("x".repeat(27) + Math.floor(index / 2).toString(36).padStart(4, "0")), + ])); + const stream = workbookToXlsx({sheetOrder: sheetIds, sheets: metadata, cells: {}}); + + await stream.cancel(); + }); + + it("exports sparse typed cells safely and preserves dimensions and a combined frozen pane", async () => { + const unusual = "_x0041_" + String.fromCharCode(1, 0xd800, 13) + String.fromCodePoint(0x1f642); + const document = { + sheetOrder: ["data"], + sheets: { + data: sheet("Data", { + rows: 10, + cols: 12, + colWidths: {0: 100, 10: 2000, 11: 92, 12: 150}, + rowHeights: {1: 40, 9: 2000, 10: 80}, + frozenRows: 2, + frozenCols: 3, + }), + }, + cells: { + data: { + A1: cell(' <&>" \t\n'), + B1: cell(unusual), + C1: cell('=HYPERLINK("https://example.com","Example")'), + D1: cell("'001"), + E1: cell(" true "), + F1: cell("FALSE"), + G1: cell("+$1,234.50"), + H1: cell("-12.5%"), + I1: cell("42", {nf: "text"}), + J1: cell("https://example.com"), + K1: cell("==A1"), + L1: cell("2026-09-02", {nf: "date"}), + A10: cell('="_x0041_"'), + B10: cell("=A1", {nf: "text"}), + C10: cell("last"), + D10: cell("=[Book.xlsx]Data!A1+Jan:Data!A1"), + E10: cell(" "), + Z1: cell("outside declared columns"), + M1: cell("'=A1"), + N1: cell("TRUE", {nf: "text"}), + A11: cell("outside declared rows"), + XFD1048576: cell("last Excel cell"), + A0: cell("bad"), + a1: cell("bad"), + XFE1: cell("outside Excel"), + A1048577: cell("outside Excel"), + }, + }, + }; + const {entries} = await readZip(workbookToXlsx(document)); + const xml = text(entries, "xl/worksheets/sheet1.xml"); + + expect(xml).toContain(''); + expect(text(entries, "xl/styles.xml")).toContain('numFmtId="49"'); + expect(xml).toContain(''); + expect(xml).toContain(''); + expect(xml).toContain(''); + expect(xml).toContain(''); + expect(xml).toContain(''); + expect(xml).toContain(''); + expect(cellXml(xml, "A1")).toContain(' <&>" \t\n'); + expect(cellXml(xml, "B1")).toContain("_x005F_x0041__x0001__xFFFD__x000D_"); + expect(cellXml(xml, "B1")).toContain(String.fromCodePoint(0x1f642)); + expect(cellXml(xml, "C1")).toContain('HYPERLINK("https://example.com","Example")'); + expect(cellXml(xml, "D1")).toContain(">001"); + expect(cellXml(xml, "E1")).toContain('t="b">1'); + expect(cellXml(xml, "F1")).toContain('t="b">0'); + expect(cellXml(xml, "G1")).toContain("1234.5"); + expect(cellXml(xml, "H1")).toContain("-0.125"); + expect(cellXml(xml, "I1")).toContain("42"); + expect(cellXml(xml, "J1")).toContain('t="inlineStr"'); + expect(cellXml(xml, "K1")).toContain("=A1"); + expect(cellXml(xml, "L1")).toContain('t="inlineStr"'); + expect(cellXml(xml, "A10")).toContain('"_x0041_"'); + expect(cellXml(xml, "B10")).toContain("A1"); + expect(styleId(xml, "B10")).toBe(styleId(xml, "I1")); + expect(cellXml(xml, "D10")).toContain("[Book.xlsx]Data!A1+Jan:Data!A1"); + expect(cellXml(xml, "E10")).toBe(''); + expect(cellXml(xml, "Z1")).toContain("outside declared columns"); + expect(cellXml(xml, "M1")).toContain(">=A1"); + expect(cellXml(xml, "N1")).toContain('t="b">1'); + expect(styleId(xml, "N1")).toBe(styleId(xml, "I1")); + expect(cellXml(xml, "A11")).toContain("outside declared rows"); + expect(cellXml(xml, "XFD1048576")).toContain("last Excel cell"); + for (const reference of ["A0", "a1", "XFE1", "A1048577"]) expect(xml).not.toContain(`r="${reference}"`); + }); + + it("batches worksheet XML while exporting the maximum stored cell count", async () => { + const cells: Record> = {}; + for (let index = 0; index < 200000; ++index) { + cells[String.fromCharCode(65 + index % 4) + (Math.floor(index / 4) + 1)] = cell("1"); + } + const {entries} = await readZip(workbookToXlsx({ + sheetOrder: ["dense"], + sheets: {dense: sheet("Dense", {rows: 50000, cols: 4})}, + cells: {dense: cells}, + })); + const worksheet = text(entries, "xl/worksheets/sheet1.xml"); + + expect(worksheet).toContain(''); + expect(cellXml(worksheet, "D50000")).toContain("1"); + }); + + it("deduplicates styles while supporting every format field and number-format category", async () => { + const formats = { + A1: {b: true}, B1: {i: true}, C1: {u: true}, D1: {s: true}, + E1: {c: "#abc"}, F1: {bg: "#1234"}, G1: {a: "c"}, H1: {nf: "number", d: 3}, + I1: {fs: 18}, J1: {wrap: true}, K1: {nf: "text"}, L1: {nf: "integer"}, + M1: {nf: "currency"}, N1: {nf: "percent"}, O1: {nf: "scientific"}, + P1: {nf: "date"}, Q1: {nf: "time"}, R1: {nf: "datetime"}, + S1: {nf: "unknown"}, T1: {d: 4}, + }; + const cells: Record> = {}; + for (const [reference, fmt] of Object.entries(formats)) cells[reference] = cell("1", fmt); + const repeated = {b: true, bg: "#112233", a: "r"}; + cells.A2 = cell("same", repeated); + cells.B2 = cell("same", {...repeated}); + cells.C2 = cell("", {...repeated}); + cells.D2 = cell("eight digit", {c: "#abcdef12"}); + + const {entries} = await readZip(workbookToXlsx({ + sheetOrder: ["styles"], + sheets: {styles: sheet("Styles", {rows: 2, cols: 20})}, + cells: {styles: cells}, + })); + const worksheet = text(entries, "xl/worksheets/sheet1.xml"); + const styles = text(entries, "xl/styles.xml"); + + expect(styles).toContain(""); + expect(styles).toContain(""); + expect(styles).toContain(""); + expect(styles).toContain(""); + expect(styles).toContain('rgb="FFAABBCC"'); + expect(styles).toContain('rgb="44112233"'); + expect(styles).toContain('rgb="FF112233"'); + expect(styles).toContain('rgb="12ABCDEF"'); + expect(styles).toContain('horizontal="center"'); + expect(styles).toContain('horizontal="right"'); + expect(styles).toContain('wrapText="1"'); + expect(styles).toContain(''); + for (const code of [ + "#,##0.000", "#,##0", '"$"#,##0.00;-"$"#,##0.00', + "#,##0.00%", "0.00E+00", "mm/dd/yyyy", "h:mm:ss AM/PM", + "mm/dd/yyyy h:mm:ss AM/PM", "0.0000", + ]) expect(styles).toContain(`formatCode="${code}"`); + expect(styleId(worksheet, "S1")).toBeUndefined(); + expect(styleId(worksheet, "A2")).toBe(styleId(worksheet, "B2")); + expect(styleId(worksheet, "B2")).toBe(styleId(worksheet, "C2")); + expect(cellXml(worksheet, "C2")).toMatch(/^$/); + }); + + it("writes row-only, column-only, and combined frozen panes", async () => { + const {entries} = await readZip(workbookToXlsx({ + sheetOrder: ["rows", "columns", "both"], + sheets: { + rows: sheet("Rows", {frozenRows: 2}), + columns: sheet("Columns", {frozenCols: 3}), + both: sheet("Both", {frozenRows: 4, frozenCols: 5}), + }, + cells: {rows: {}, columns: {}, both: {}}, + })); + expect(text(entries, "xl/worksheets/sheet1.xml")).toContain('ySplit="2" topLeftCell="A3" activePane="bottomLeft"'); + expect(text(entries, "xl/worksheets/sheet2.xml")).toContain('xSplit="3" topLeftCell="D1" activePane="topRight"'); + expect(text(entries, "xl/worksheets/sheet3.xml")).toContain('xSplit="5" ySplit="4" topLeftCell="F5" activePane="bottomRight"'); + }); + + it("fails clearly before generating more fill styles than Excel supports", () => { + const cells: Record> = {}; + for (let index = 0; index < 255; ++index) { + const reference = String.fromCharCode(65 + index % 26) + (Math.floor(index / 26) + 1); + cells[reference] = cell("", {bg: `#${index.toString(16).padStart(6, "0")}`}); + } + expect(() => workbookToXlsx({ + sheetOrder: ["styles"], + sheets: {styles: sheet("Styles", {rows: 10, cols: 26})}, + cells: {styles: cells}, + })).toThrow("XLSX fill count exceeds Excel's limit of 256"); + }); + + it("ignores v5-only metadata while exporting ordinary and materialized pivot cells", async () => { + const document = { + sheetOrder: ["v5"], + sheets: { + v5: { + ...sheet("V5"), + filter: {range: "A1:B4"}, + charts: [{type: "bar"}], + comments: {A1: "note"}, + pivot: {source: "A1:B4", destination: "D1"}, + }, + }, + cells: { + v5: { + A1: {...cell("ordinary"), comment: "ignored"}, + D1: cell("Pivot total", {b: true}), + D2: cell("125"), + }, + }, + filter: {}, + charts: [], + comments: {}, + pivot: {}, + }; + const {entries} = await readZip(workbookToXlsx(document)); + const worksheet = text(entries, "xl/worksheets/sheet1.xml"); + expect(cellXml(worksheet, "A1")).toContain("ordinary"); + expect(cellXml(worksheet, "D1")).toContain("Pivot total"); + expect(cellXml(worksheet, "D2")).toContain("125"); + expect(worksheet).not.toContain("autoFilter"); + expect([...entries.keys()].some(name => /chart|comment|pivot/i.test(name))).toBe(false); + }); +}); + +describe("Workspace Sheets document snapshots", () => { + it("completes a queued document read before beginning the next mutation", async () => { + let releaseRead!: () => void; + let markReadStarted!: () => void; + const readReleased = new Promise(resolve => { releaseRead = resolve; }); + const readStarted = new Promise(resolve => { markReadStarted = resolve; }); + const order: string[] = []; + const fixture = Object.assign(Object.create(Gadget.prototype), { + mutationQueue: Promise.resolve(), + loadMeta: vi.fn(async () => ({revision: 1})), + assembleDocument: vi.fn(async () => { + order.push("read started"); + markReadStarted(); + await readReleased; + order.push("read completed"); + return {revision: 1}; + }), + applyOperationLocked: vi.fn(async () => { + order.push("write started"); + order.push("write completed"); + return {status: "applied"}; + }), + }); + + const read = fixture.getDocument(); + await readStarted; + const write = fixture.applyOperation({}); + await Promise.resolve(); + expect(fixture.applyOperationLocked).not.toHaveBeenCalled(); + + releaseRead(); + await expect(read).resolves.toEqual({revision: 1}); + await expect(write).resolves.toEqual({status: "applied"}); + expect(order).toEqual(["read started", "read completed", "write started", "write completed"]); + }); + + it("allows subscriber callbacks to read the committed document without deadlocking", async () => { + const backgroundTasks: Promise[] = []; + const stored = new Map([ + ["meta", { + revision: 0, + title: "Test", + sheetOrder: ["sheet"], + sheets: {sheet: sheet("Sheet")}, + lastModified: 0, + }], + ["cells:sheet", {}], + ]); + const fixture = Object.assign(Object.create(Gadget.prototype), { + ctx: { + waitUntil: (task: Promise) => { backgroundTasks.push(task); }, + storage: { + get: async (key: string) => stored.get(key), + put: async (key: string, value: unknown) => { stored.set(key, value); }, + delete: async (key: string) => stored.delete(key), + }, + }, + mutationQueue: Promise.resolve(), + broadcastQueue: Promise.resolve(), + subscribers: new Map(), + }); + let callbackDocument: any; + const subscriber = { + operation: vi.fn(async () => { callbackDocument = await fixture.getDocument(); }), + }; + fixture.subscribers.set(subscriber, {}); + + const result = await fixture.applyOperation({ + senderId: "test", + cellOps: [{sheetId: "sheet", ref: "A1", value: "committed", fmt: null, baseVersion: 0}], + }); + await Promise.all(backgroundTasks); + + expect(result.status).toBe("applied"); + expect(subscriber.operation).toHaveBeenCalledOnce(); + expect(callbackDocument.revision).toBe(1); + expect(callbackDocument.cells.sheet.A1.value).toBe("committed"); + }); + + it("serializes broadcasts without holding operation responses", async () => { + let releaseFirst!: () => void; + let markFirstStarted!: () => void; + const firstReleased = new Promise(resolve => { releaseFirst = resolve; }); + const firstStarted = new Promise(resolve => { markFirstStarted = resolve; }); + const order: string[] = []; + const backgroundTasks: Promise[] = []; + let revision = 0; + const fixture = Object.assign(Object.create(Gadget.prototype), { + ctx: {waitUntil: vi.fn((task: Promise) => { backgroundTasks.push(task); })}, + mutationQueue: Promise.resolve(), + broadcastQueue: Promise.resolve(), + subscribers: new Map(), + applyOperationLocked: vi.fn(async () => ({ + status: "applied", + type: "operation", + revision: ++revision, + conflicts: [], + })), + broadcast: vi.fn(async (event: {revision: number}) => { + order.push(`broadcast ${event.revision} started`); + if (event.revision === 1) { + markFirstStarted(); + await firstReleased; + } + order.push(`broadcast ${event.revision} completed`); + }), + }); + + const first = fixture.applyOperation({}); + await firstStarted; + const second = fixture.applyOperation({}); + await Promise.all([first, second]); + expect(order).toEqual(["broadcast 1 started"]); + + releaseFirst(); + await Promise.all(backgroundTasks); + expect(order).toEqual([ + "broadcast 1 started", + "broadcast 1 completed", + "broadcast 2 started", + "broadcast 2 completed", + ]); + expect(fixture.ctx.waitUntil).toHaveBeenCalledTimes(2); + for (const [event] of fixture.broadcast.mock.calls) { + expect(event).not.toHaveProperty("status"); + expect(event).not.toHaveProperty("conflicts"); + } + }); + + it("allows subscriber callbacks to apply another operation without deadlocking", async () => { + let markNestedBroadcast!: () => void; + const nestedBroadcast = new Promise(resolve => { markNestedBroadcast = resolve; }); + let revision = 0; + const fixture = Object.assign(Object.create(Gadget.prototype), { + ctx: {waitUntil: vi.fn()}, + mutationQueue: Promise.resolve(), + broadcastQueue: Promise.resolve(), + subscribers: new Map(), + applyOperationLocked: vi.fn(async () => ({ + status: "applied", + type: "operation", + revision: ++revision, + conflicts: [], + })), + broadcast: vi.fn(async (event: {revision: number}) => { + if (event.revision === 1) await fixture.applyOperation({nested: true}); + else markNestedBroadcast(); + }), + }); + + await fixture.applyOperation({}); + await nestedBroadcast; + expect(fixture.ctx.waitUntil).toHaveBeenCalledTimes(2); + expect(fixture.broadcast.mock.calls.map(([event]: [{revision: number}]) => event.revision)) + .toEqual([1, 2]); + }); + + it("captures broadcast recipients before later subscriptions", async () => { + let releaseBroadcasts!: () => void; + const broadcastsReleased = new Promise(resolve => { releaseBroadcasts = resolve; }); + const originalSubscriber = {operation: vi.fn()}; + const laterSubscriber = {operation: vi.fn()}; + const fixture = Object.assign(Object.create(Gadget.prototype), { + ctx: {waitUntil: vi.fn()}, + mutationQueue: Promise.resolve(), + broadcastQueue: broadcastsReleased, + subscribers: new Map([[originalSubscriber, {}]]), + applyOperationLocked: vi.fn(async () => ({ + status: "applied", + type: "operation", + revision: 1, + conflicts: [], + })), + broadcast: vi.fn(), + }); + + await fixture.applyOperation({}); + fixture.subscribers.set(laterSubscriber, {}); + releaseBroadcasts(); + await fixture.broadcastQueue; + + expect(fixture.broadcast).toHaveBeenCalledWith(expect.objectContaining({revision: 1}), [originalSubscriber]); + }); + + it("evicts a stalled subscriber once across queued broadcasts", async () => { + vi.useFakeTimers(); + try { + const dispose = vi.fn(); + const stalled = { + operation: vi.fn(() => new Promise(() => {})), + presence: vi.fn(), + [Symbol.dispose]: dispose, + }; + const healthy = {operation: vi.fn(), presence: vi.fn()}; + let revision = 0; + const fixture = Object.assign(Object.create(Gadget.prototype), { + ctx: {waitUntil: vi.fn()}, + mutationQueue: Promise.resolve(), + broadcastQueue: Promise.resolve(), + subscribers: new Map([[stalled, {clientId: "stalled"}], [healthy, {clientId: "healthy"}]]), + applyOperationLocked: vi.fn(async () => ({ + status: "applied", + type: "operation", + revision: ++revision, + conflicts: [], + })), + }); + + await Promise.all([ + fixture.applyOperation({}), + fixture.applyOperation({}), + fixture.applyOperation({}), + ]); + await vi.advanceTimersByTimeAsync(10000); + await fixture.broadcastQueue; + + expect(fixture.subscribers.has(stalled)).toBe(false); + expect(stalled.operation).toHaveBeenCalledOnce(); + expect(healthy.operation).toHaveBeenCalledTimes(3); + expect(healthy.presence).toHaveBeenCalledWith(expect.objectContaining({type: "leave", clientId: "stalled"})); + expect(dispose).toHaveBeenCalledOnce(); + } finally { + vi.useRealTimers(); + } + }); + + it("evicts a subscriber stalled during presence replay without announcing a join", async () => { + vi.useFakeTimers(); + try { + const backgroundTasks: Promise[] = []; + const existing = {operation: vi.fn(), presence: vi.fn()}; + const dispose = vi.fn(); + const newcomer = { + operation: vi.fn(), + presence: vi.fn(() => new Promise(() => {})), + onRpcBroken: vi.fn(), + [Symbol.dispose]: dispose, + }; + const fixture = Object.assign(Object.create(Gadget.prototype), { + ctx: {waitUntil: (task: Promise) => { backgroundTasks.push(task); }}, + mutationQueue: Promise.resolve(), + subscribers: new Map([[existing, {clientId: "existing", name: "Existing", color: "blue"}]]), + loadMeta: vi.fn(async () => ({revision: 1})), + assembleDocument: vi.fn(async () => ({revision: 1})), + }); + + await fixture.subscribe({dup: () => newcomer}, {clientId: "newcomer"}); + await vi.advanceTimersByTimeAsync(10000); + await Promise.all(backgroundTasks); + + expect(fixture.subscribers.has(newcomer)).toBe(false); + expect(dispose).toHaveBeenCalledOnce(); + expect(existing.presence).toHaveBeenCalledWith(expect.objectContaining({type: "leave", clientId: "newcomer"})); + expect(existing.presence).not.toHaveBeenCalledWith(expect.objectContaining({type: "join", clientId: "newcomer"})); + } finally { + vi.useRealTimers(); + } + }); + + it("disposes a subscriber when its initial snapshot fails", async () => { + const dispose = vi.fn(); + const newcomer = { + presence: vi.fn(), + onRpcBroken: vi.fn(), + [Symbol.dispose]: dispose, + }; + const fixture = Object.assign(Object.create(Gadget.prototype), { + mutationQueue: Promise.resolve(), + subscribers: new Map(), + loadMeta: vi.fn(async () => ({revision: 1})), + assembleDocument: vi.fn(async () => { throw new Error("storage failed"); }), + }); + + await expect(fixture.subscribe({dup: () => newcomer}, {clientId: "newcomer"})) + .rejects.toThrow("storage failed"); + expect(fixture.subscribers.has(newcomer)).toBe(false); + expect(newcomer.onRpcBroken).not.toHaveBeenCalled(); + expect(newcomer.presence).not.toHaveBeenCalled(); + expect(dispose).toHaveBeenCalledOnce(); + }); + + it("continues broadcasting after a subscriber throws synchronously", async () => { + const failed = {operation: vi.fn(() => { throw new Error("broken"); })}; + const healthy = {operation: vi.fn(), presence: vi.fn()}; + const fixture = Object.assign(Object.create(Gadget.prototype), { + ctx: {waitUntil: vi.fn()}, + subscribers: new Map([[failed, {clientId: "failed"}], [healthy, {clientId: "healthy"}]]), + }); + + await fixture.broadcast({revision: 1}); + expect(fixture.subscribers.has(failed)).toBe(false); + expect(healthy.operation).toHaveBeenCalledWith({revision: 1}); + }); +}); + +describe("Workspace Sheets export formats", () => { + it("reserves one of 32 slots for XLSX and applies the same CSV eligibility rules at export", async () => { + const ids = Array.from({length: 40}, (_, index) => `sheet-${index}`); + const longId = "x".repeat(125); + const sheetOrder = [ids[0], ids[0], "missing", longId, ...ids.slice(1)]; + const sheets = Object.fromEntries(ids.map(id => [id, sheet(id)])); + const document = {sheetOrder, sheets, cells: Object.fromEntries(ids.map(id => [id, {}]))}; + const gadget = {getDocument: vi.fn(async () => document)}; + + const formats = await handler().getExportFormats(gadget as never); + expect(formats).toHaveLength(32); + expect(formats[0]).toEqual({ + id: "xlsx", + label: "Excel Workbook", + mode: "server", + contentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + fileExtension: ".xlsx", + }); + expect(new Set(formats.map(format => format.id)).size).toBe(32); + expect(formats.some(format => format.id === "csv:missing" || format.id === `csv:${longId}`)).toBe(false); + await expect(handler().export(gadget as never, "csv:missing")).rejects.toThrow("unavailable"); + }); + + it("retains raw-value CSV behavior and materializes state before returning an XLSX stream", async () => { + const document = { + sheetOrder: ["one"], + sheets: {one: sheet("One", {rows: 2, cols: 3})}, + cells: {one: { + A1: cell("a,b"), + C1: cell('say "hi"'), + B2: cell("=SUM(A1:A2)"), + }}, + }; + const gadget = {getDocument: vi.fn(async () => document)}; + const csv = await handler().export(gadget as never, "csv:one"); + expect(await new Response(csv).text()).toBe('"a,b",,"say ""hi"""\r\n,=SUM(A1:A2),\r\n'); + + const xlsx = await handler().export(gadget as never, "xlsx"); + expect(gadget.getDocument).toHaveBeenCalledTimes(2); + gadget.getDocument.mockImplementation(async () => { throw new Error("borrowed capability reused"); }); + const {entries} = await readZip(xlsx); + expect(cellXml(text(entries, "xl/worksheets/sheet1.xml"), "B2")).toContain("SUM(A1:A2)"); + }); +}); diff --git a/packages/workshop-backend/format-blueprints/workspace-sheets/files/README.md b/packages/workshop-backend/format-blueprints/workspace-sheets/files/README.md index cc8e2ed19..cb09e07f1 100644 --- a/packages/workshop-backend/format-blueprints/workspace-sheets/files/README.md +++ b/packages/workshop-backend/format-blueprints/workspace-sheets/files/README.md @@ -16,6 +16,7 @@ A lightweight, persistent spreadsheet Gadget with a familiar grid interface, for - Range sorting, AutoSum, copy/paste via TSV, and local undo/redo for cell edits - Automatic persistent saving with optimistic per-cell conflict detection - Real-time operation and presence synchronization in the server architecture +- Excel workbook and per-sheet CSV export ## Using the spreadsheet @@ -178,6 +179,14 @@ Exports the Durable Object class `Gadget`, which is the authoritative persistenc - Uses last-writer-wins semantics for document structure - Broadcasts operations and presence events to subscribed clients - Sanitizes titles, dimensions, cell contents, references, and formatting +- Advertises and produces the server-side workbook and CSV exports + +### `xlsx.js` and `zip.js` + +`xlsx.js` converts a complete document snapshot into a streaming XLSX workbook. It writes sparse +worksheet XML with inline strings, deduplicated styles, frozen panes, and ordinary static cells for +all materialized data. `zip.js` packages those parts with a dependency-free streaming ZIP32 writer, +using raw DEFLATE, incremental CRC32 calculation, and data descriptors. ## Storage model @@ -202,7 +211,39 @@ The server and client synchronization code support multiple connected clients an ## CSV export -Each worksheet is exposed as its own **CSV** export option. CSV files contain the stored cell values -through the worksheet's used range. Formula cells are exported as their raw formulas (for example, -`=SUM(A1:A10)`), not as browser-computed display values. Fields use standard CSV quoting and CRLF -line endings. +Up to 31 eligible worksheets are exposed as individual **CSV** export options, leaving one of the +platform's 32 format slots for XLSX. CSV files contain the stored cell values through the worksheet's +used range. Formula cells are exported as their raw formulas (for example, `=SUM(A1:A10)`), not as +browser-computed display values. Fields use standard CSV quoting and CRLF line endings. + +## XLSX export + +**Excel Workbook** exports one XLSX file containing the worksheets in workbook order. Sparse and +style-only cells are preserved, along with the existing font, color, fill, alignment, number-format, +decimal-place, wrapping, column-width, row-height, and frozen-pane metadata. Pixel dimensions are +converted deterministically to points and approximate Excel character widths; they are not expected +to be pixel-perfect across spreadsheet applications and font environments. + +XLSX literal conversion uses a leading apostrophe to force text and hide the apostrophe. Otherwise, +values beginning with `=`, including `HYPERLINK()` formulas, are written as formulas even when the +cell uses plain-text number formatting. Number formats affect display without changing a literal's +underlying type. Booleans are recognized case-insensitively, and supported numeric literals include +signs, commas, a leading dollar sign, and a trailing percent sign. Date- or time-looking text is not +parsed; an existing numeric serial receives the requested date/time number format. Plain URLs remain +text. Formulas have no cached result or server-side evaluation. The workbook requests automatic +full recalculation when opened. Supported future-function tokens receive Excel's required `_xlfn.` +prefix, and `ERRORTYPE()` is translated to `ERROR.TYPE()`. If these compatibility rewrites or +worksheet-name rewriting would exceed Excel's 8,192-character formula limit, the original stored +formula is exported as text instead. Formula support is not claimed to be fully compatible with +Excel. + +Worksheet names are made Excel-safe during export: invalid characters are replaced, blank names use +`Sheet`, names are limited to 31 characters, and case-insensitive collisions receive numeric +suffixes. Recognizable quoted and unquoted cross-sheet references outside formula string literals are +rewritten to those exported names. When source names are duplicated, references continue to resolve +to the first matching worksheet, matching the grid's current behavior. + +The optional v5 `filter`, `charts`, `comments`, and `pivot` metadata is ignored safely. Materialized +pivot results already present in the cell map export as ordinary static cells. Native Excel filters, +filtered-row visibility, charts, comments or notes, native or refreshable pivot tables, and external +hyperlink relationships for plain URLs are intentionally deferred. diff --git a/packages/workshop-backend/format-blueprints/workspace-sheets/files/server.js b/packages/workshop-backend/format-blueprints/workspace-sheets/files/server.js index 1f200bf43..8afc0fbcd 100644 --- a/packages/workshop-backend/format-blueprints/workspace-sheets/files/server.js +++ b/packages/workshop-backend/format-blueprints/workspace-sheets/files/server.js @@ -1,8 +1,20 @@ import { DurableObject, WorkerEntrypoint } from "cloudflare:workers"; +import { workbookToXlsx } from "./xlsx.js"; const DEFAULT_TITLE = "Untitled spreadsheet"; const DEFAULT_ROWS = 100; const DEFAULT_COLS = 26; +const SUBSCRIBER_CALLBACK_TIMEOUT_MS = 10000; + +function subscriberCall(callback) { + let timeout; + return Promise.race([ + Promise.resolve().then(callback), + new Promise((_, reject) => { + timeout = setTimeout(() => reject(new Error("Subscriber callback timed out.")), SUBSCRIBER_CALLBACK_TIMEOUT_MS); + }), + ]).finally(() => clearTimeout(timeout)); +} // --------------------------------------------------------------------------- // Sheets — authoritative collaboration coordinator. @@ -23,6 +35,7 @@ export class Gadget extends DurableObject { // Overlapping RPC calls are serialized so each observes/commits one // authoritative state in strict order. this.mutationQueue = Promise.resolve(); + this.broadcastQueue = Promise.resolve(); } enqueueMutation(fn) { @@ -71,11 +84,26 @@ export class Gadget extends DurableObject { } async getDocument() { - return this.assembleDocument(await this.loadMeta()); + return this.enqueueMutation(async () => + this.assembleDocument(await this.loadMeta())); } - applyOperation(operation) { - return this.enqueueMutation(() => this.applyOperationLocked(operation)); + async applyOperation(operation) { + let recipients = []; + const result = await this.enqueueMutation(async () => { + const applied = await this.applyOperationLocked(operation); + if (applied.type === "operation") recipients = Array.from(this.subscribers.keys()); + return applied; + }); + if (result.type === "operation") { + const event = {...result}; + delete event.status; + delete event.conflicts; + const broadcast = this.broadcastQueue.then(() => this.broadcast(event, recipients)); + this.broadcastQueue = broadcast.catch(() => {}); + this.ctx.waitUntil(this.broadcastQueue); + } + return result; } async applyOperationLocked(operation) { @@ -193,35 +221,51 @@ export class Gadget extends DurableObject { event.replacedCells = {}; for (const id of event.replacedSheets) event.replacedCells[id] = await this.loadCells(id); } - await this.broadcast(event); - return { status: conflicts.length ? "conflict" : "applied", ...event, conflicts }; } // --- Presence & subscription ------------------------------------------ + removeSubscriber(stub) { + const info = this.subscribers.get(stub); + if (!info) return; + this.subscribers.delete(stub); + if (typeof stub[Symbol.dispose] === "function") stub[Symbol.dispose](); + this.ctx.waitUntil(this.broadcastPresence({ type: "leave", clientId: info.clientId, at: Date.now() })); + } + async subscribe(callback, client = {}) { const dup = callback.dup(); - const existing = Array.from(this.subscribers.values()); - const info = { - callback: dup, - clientId: String(client.clientId || ""), - name: String(client.name || "Guest").slice(0, 40), - color: String(client.color || "#e1632e"), - }; - this.subscribers.set(dup, info); - dup.onRpcBroken(() => { - this.subscribers.delete(dup); - this.broadcastPresence({ type: "leave", clientId: info.clientId }); - }); - queueMicrotask(async () => { - for (const person of existing) { - try { - await dup.presence({ type: "join", clientId: person.clientId, name: person.name, color: person.color }); - } catch (e) { break; } - } - await this.broadcastPresence({ type: "join", clientId: info.clientId, name: info.name, color: info.color }); - }); - return this.assembleDocument(await this.loadMeta()); + try { + return await this.enqueueMutation(async () => { + const document = await this.assembleDocument(await this.loadMeta()); + const existing = Array.from(this.subscribers.values()); + const info = { + callback: dup, + clientId: String(client.clientId || ""), + name: String(client.name || "Guest").slice(0, 40), + color: String(client.color || "#e1632e"), + }; + this.subscribers.set(dup, info); + dup.onRpcBroken(() => this.removeSubscriber(dup)); + queueMicrotask(async () => { + for (const person of existing) { + try { + await subscriberCall(() => dup.presence({ type: "join", clientId: person.clientId, name: person.name, color: person.color })); + } catch (e) { + this.removeSubscriber(dup); + return; + } + } + if (!this.subscribers.has(dup)) return; + await this.broadcastPresence({ type: "join", clientId: info.clientId, name: info.name, color: info.color }); + }); + return document; + }); + } catch (error) { + if (this.subscribers.has(dup)) this.removeSubscriber(dup); + else if (typeof dup[Symbol.dispose] === "function") dup[Symbol.dispose](); + throw error; + } } async updatePresence(presence) { @@ -241,10 +285,11 @@ export class Gadget extends DurableObject { await this.broadcastPresence({ type: "leave", clientId: String(clientId || ""), at: Date.now() }); } - async broadcast(event) { + async broadcast(event, recipients = this.subscribers.keys()) { const calls = []; - for (const [stub] of this.subscribers) { - calls.push(Promise.resolve(stub.operation(event)).catch(() => this.subscribers.delete(stub))); + for (const stub of recipients) { + if (!this.subscribers.has(stub)) continue; + calls.push(subscriberCall(() => stub.operation(event)).catch(() => this.removeSubscriber(stub))); } await Promise.all(calls); } @@ -252,7 +297,7 @@ export class Gadget extends DurableObject { async broadcastPresence(event) { const calls = []; for (const [stub] of this.subscribers) { - calls.push(Promise.resolve(stub.presence(event)).catch(() => this.subscribers.delete(stub))); + calls.push(subscriberCall(() => stub.presence(event)).catch(() => this.removeSubscriber(stub))); } await Promise.all(calls); } @@ -324,28 +369,57 @@ function sanitizeCellMap(map) { const CSV_FORMAT_PREFIX = "csv:"; -const MAX_CSV_SHEETS = 32; +const MAX_CSV_SHEETS = 31; +const MAX_EXPORT_ID_LENGTH = 128; +const XLSX_FORMAT = { + id: "xlsx", + label: "Excel Workbook", + mode: "server", + contentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + fileExtension: ".xlsx", +}; + +function eligibleCsvSheetIds(document) { + const result = []; + const seen = new Set(); + const order = Array.isArray(document?.sheetOrder) ? document.sheetOrder : []; + const sheets = document?.sheets && typeof document.sheets === "object" ? document.sheets : {}; + for (const rawId of order) { + const sheetId = String(rawId); + if (seen.has(sheetId)) continue; + seen.add(sheetId); + if (!Object.hasOwn(sheets, sheetId) || !sheets[sheetId] || typeof sheets[sheetId] !== "object") continue; + if ((CSV_FORMAT_PREFIX + sheetId).length > MAX_EXPORT_ID_LENGTH) continue; + result.push(sheetId); + if (result.length === MAX_CSV_SHEETS) break; + } + return result; +} export class ExportHandler extends WorkerEntrypoint { async getExportFormats(gadget) { const document = await gadget.getDocument(); - const sheetIds = document.sheetOrder.slice(0, MAX_CSV_SHEETS); - return sheetIds.map((sheetId) => ({ + const sheetIds = eligibleCsvSheetIds(document); + return [XLSX_FORMAT, ...sheetIds.map((sheetId) => ({ id: CSV_FORMAT_PREFIX + sheetId, - label: sheetIds.length === 1 ? "CSV" : "CSV (" + document.sheets[sheetId].name + ")", + label: sheetIds.length === 1 ? "CSV" : "CSV (" + String(document.sheets[sheetId].name || "Sheet").slice(0, 122) + ")", mode: "server", contentType: "text/csv", fileExtension: ".csv", - })); + }))]; } async export(gadget, id) { + if (id === XLSX_FORMAT.id) { + const document = await gadget.getDocument(); + return workbookToXlsx(document); + } if (!id.startsWith(CSV_FORMAT_PREFIX)) { throw new Error("Unsupported spreadsheet export format: " + id); } const document = await gadget.getDocument(); const sheetId = id.slice(CSV_FORMAT_PREFIX.length); - if (!document.sheetOrder.slice(0, MAX_CSV_SHEETS).includes(sheetId)) { + if (!eligibleCsvSheetIds(document).includes(sheetId)) { throw new Error("The selected worksheet is unavailable for CSV export."); } return new Response(workbookSheetToCsv(document, sheetId)).body; @@ -353,7 +427,7 @@ export class ExportHandler extends WorkerEntrypoint { } function workbookSheetToCsv(document, sheetId) { - const cells = document.cells[sheetId] || {}; + const cells = document.cells?.[sheetId] || {}; let maxRow = -1; let maxColumn = -1; for (const [ref, cell] of Object.entries(cells)) { diff --git a/packages/workshop-backend/format-blueprints/workspace-sheets/files/xlsx.js b/packages/workshop-backend/format-blueprints/workspace-sheets/files/xlsx.js new file mode 100644 index 000000000..b59e1e41a --- /dev/null +++ b/packages/workshop-backend/format-blueprints/workspace-sheets/files/xlsx.js @@ -0,0 +1,701 @@ +import { createZip } from "./zip.js"; + +const encoder = new TextEncoder(); +const MAIN_NS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"; +const REL_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; +const PACKAGE_REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships"; +const MAX_ROWS = 1048576; +const MAX_COLUMNS = 16384; +const DEFAULT_ROWS = 100; +const DEFAULT_COLUMNS = 26; +const DEFAULT_ROW_PIXELS = 24; +const DEFAULT_COLUMN_PIXELS = 92; +const MAX_FONTS = 512; +const MAX_FILLS = 256; +const MAX_CELL_FORMATS = 65490; +const MAX_FORMULA_CHARACTERS = 8192; +const TEXT_CHUNK_SIZE = 64 * 1024; +const FUTURE_FUNCTIONS = new Set([ + "CONCAT", "DAYS", "IFNA", "IFS", "SWITCH", "TEXTJOIN", "UNICHAR", "UNICODE", "XOR", +]); + +function spreadsheetXml(value, attribute = false) { + const input = String(value).replace(/_x[0-9a-f]{4}_/gi, (match) => "_x005F_" + match.slice(1)); + let clean = ""; + for (let i = 0; i < input.length; ++i) { + const code = input.charCodeAt(i); + if (code >= 0xd800 && code <= 0xdbff) { + const low = input.charCodeAt(i + 1); + if (low >= 0xdc00 && low <= 0xdfff) clean += input[i] + input[++i]; + else clean += "_xFFFD_"; + } else if (code >= 0xdc00 && code <= 0xdfff) { + clean += "_xFFFD_"; + } else if (code === 13) { + clean += "_x000D_"; + } else if (code === 9 || code === 10 || + (code >= 0x20 && code <= 0xd7ff) || (code >= 0xe000 && code <= 0xfffd)) { + clean += input[i]; + } else { + clean += `_x${code.toString(16).toUpperCase().padStart(4, "0")}_`; + } + } + clean = clean.replace(/&/g, "&").replace(//g, ">"); + if (attribute) clean = clean.replace(/"/g, """).replace(/'/g, "'"); + return clean; +} + +function formulaXml(value) { + const input = String(value); + let clean = ""; + for (let i = 0; i < input.length; ++i) { + const code = input.charCodeAt(i); + if (code >= 0xd800 && code <= 0xdbff) { + const low = input.charCodeAt(i + 1); + if (low >= 0xdc00 && low <= 0xdfff) clean += input[i] + input[++i]; + else clean += String.fromCharCode(0xfffd); + } else if (code >= 0xdc00 && code <= 0xdfff) { + clean += String.fromCharCode(0xfffd); + } else if (code === 9 || code === 10 || code === 13 || + (code >= 0x20 && code <= 0xd7ff) || (code >= 0xe000 && code <= 0xfffd)) { + clean += input[i]; + } else { + clean += String.fromCharCode(0xfffd); + } + } + return clean.replace(/&/g, "&").replace(//g, ">") + .replace(/\r/g, " "); +} + +function xmlAttribute(value) { + return String(value).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); +} + +function textStream(iterable) { + const iterator = iterable[Symbol.iterator](); + return new ReadableStream({ + pull(controller) { + const parts = []; + let length = 0; + while (length < TEXT_CHUNK_SIZE) { + const result = iterator.next(); + if (result.done) { + if (parts.length) controller.enqueue(encoder.encode(parts.join(""))); + controller.close(); + return; + } + parts.push(result.value); + length += result.value.length; + } + controller.enqueue(encoder.encode(parts.join(""))); + }, + cancel(reason) { + if (iterator.return) iterator.return(reason); + }, + }); +} + +function count(value, fallback, maximum) { + const number = Math.round(Number(value)); + if (!Number.isFinite(number)) return fallback; + return Math.max(1, Math.min(maximum, number)); +} + +function frozenCount(value, maximum) { + const number = Math.round(Number(value)); + if (!Number.isFinite(number)) return 0; + return Math.max(0, Math.min(50, maximum, number)); +} + +function truncateSheetName(value, length) { + const input = value.slice(0, length); + let result = ""; + for (let i = 0; i < input.length; ++i) { + const code = input.charCodeAt(i); + if (code < 32 || (code >= 127 && code <= 159)) { + result += "_"; + } else if (code >= 0xd800 && code <= 0xdbff) { + const low = input.charCodeAt(i + 1); + if (low >= 0xdc00 && low <= 0xdfff) result += input[i] + input[++i]; + else result += "_"; + } else if (code >= 0xdc00 && code <= 0xdfff) { + result += "_"; + } else { + result += input[i]; + } + } + return result; +} + +function safeSheetName(value) { + let name = String(value ?? "").replace(/[:\\/?*\[\]]/g, "_").trim(); + name = truncateSheetName(name, 31); + if (name.startsWith("'")) name = "_" + name.slice(1); + if (name.endsWith("'")) name = name.slice(0, -1) + "_"; + if (name.toLowerCase() === "history") name += "_"; + return name || "Sheet"; +} + +function assignSheetNames(sheets) { + const used = new Set(); + const nextSuffixes = new Map(); + for (const sheet of sheets) { + const base = safeSheetName(sheet.sourceName); + let name = base; + let suffix = 2; + while (used.has(name.toLowerCase())) { + const digits = String(suffix).length; + const stem = truncateSheetName(base, 28 - digits); + const key = `${stem.toLowerCase()}|${digits}`; + const next = nextSuffixes.get(key); + if (next != null && next > suffix) { + suffix = next; + continue; + } + const ending = ` (${suffix})`; + name = stem + ending; + nextSuffixes.set(key, suffix + 1); + ++suffix; + } + used.add(name.toLowerCase()); + sheet.name = name; + } +} + +function parseCellReference(reference) { + const match = /^([A-Z]+)([1-9]\d*)$/.exec(reference); + if (!match) return null; + let column = 0; + for (const character of match[1]) { + column = column * 26 + character.charCodeAt(0) - 64; + if (column > MAX_COLUMNS) return null; + } + const row = Number(match[2]); + if (!Number.isSafeInteger(row) || row > MAX_ROWS) return null; + return {row, column}; +} + +function columnName(column) { + let name = ""; + for (let value = column; value > 0; value = Math.floor((value - 1) / 26)) { + name = String.fromCharCode(65 + (value - 1) % 26) + name; + } + return name; +} + +function pixelDimension(value) { + const pixels = Math.round(Number(value)); + return Number.isFinite(pixels) && pixels >= 8 && pixels <= 2000 ? pixels : null; +} + +function rowPoints(pixels) { + return String(Math.min(409, Math.round(pixels * 75) / 100)); +} + +function columnWidth(pixels) { + return String(Math.min(255, Math.round(Math.max(0, (pixels - 5) / 7) * 256) / 256)); +} + +function dimensions(source, maximum, convert) { + const result = []; + if (!source || typeof source !== "object") return result; + for (const [key, value] of Object.entries(source)) { + if (!/^(0|[1-9]\d*)$/.test(key)) continue; + const index = Number(key); + const pixels = pixelDimension(value); + if (!Number.isSafeInteger(index) || index < 0 || index >= maximum || pixels == null) continue; + result.push({index, value: convert(pixels)}); + } + result.sort((a, b) => a.index - b.index); + return result; +} + +function xlsxColor(value) { + if (typeof value !== "string") return null; + const hex = value.slice(1); + if (!value.startsWith("#") || ![3, 4, 6, 8].includes(hex.length) || !/^[0-9a-f]+$/i.test(hex)) return null; + if (hex.length === 3) return "FF" + Array.from(hex, character => character + character).join("").toUpperCase(); + if (hex.length === 4) { + const [r, g, b, a] = Array.from(hex, character => character + character); + return (a + r + g + b).toUpperCase(); + } + if (hex.length === 6) return "FF" + hex.toUpperCase(); + return (hex.slice(6) + hex.slice(0, 6)).toUpperCase(); +} + +function decimals(fmt) { + if (fmt?.d == null) return null; + const value = Math.round(Number(fmt?.d)); + return Number.isFinite(value) && value >= 0 && value <= 10 ? value : null; +} + +function decimalPattern(value) { + return value ? "." + "0".repeat(value) : ""; +} + +class Styles { + constructor() { + this.fonts = [{size: 11}]; + this.fontIds = new Map(); + this.fills = [null, {gray125: true}]; + this.fillIds = new Map(); + this.numberFormats = []; + this.numberFormatIds = new Map(); + this.alignments = [null]; + this.alignmentIds = new Map(); + this.cellFormats = [{fontId: 0, fillId: 0, numberFormatId: 0, alignmentId: 0}]; + this.cellFormatIds = new Map(); + } + + font(fmt) { + const color = xlsxColor(fmt?.c); + const sizeValue = Math.round(Number(fmt?.fs)); + const size = Number.isFinite(sizeValue) && sizeValue >= 6 && sizeValue <= 96 ? sizeValue : null; + const font = { + bold: Boolean(fmt?.b), italic: Boolean(fmt?.i), underline: Boolean(fmt?.u), + strike: Boolean(fmt?.s), color, size, + }; + if (!font.bold && !font.italic && !font.underline && !font.strike && !font.color && !font.size) return 0; + const key = JSON.stringify(font); + let id = this.fontIds.get(key); + if (id == null) { + if (this.fonts.length >= MAX_FONTS) throw new Error("XLSX font count exceeds Excel's limit of 512."); + id = this.fonts.length; + this.fontIds.set(key, id); + this.fonts.push(font); + } + return id; + } + + fill(fmt) { + const color = xlsxColor(fmt?.bg); + if (!color) return 0; + let id = this.fillIds.get(color); + if (id == null) { + if (this.fills.length >= MAX_FILLS) throw new Error("XLSX fill count exceeds Excel's limit of 256."); + id = this.fills.length; + this.fillIds.set(color, id); + this.fills.push({color}); + } + return id; + } + + customNumberFormat(code) { + let id = this.numberFormatIds.get(code); + if (id == null) { + if (164 + this.numberFormats.length > 0xffff) { + throw new Error("XLSX number format count exceeds the format ID limit of 65,535."); + } + id = 164 + this.numberFormats.length; + this.numberFormatIds.set(code, id); + this.numberFormats.push({id, code}); + } + return id; + } + + numberFormat(fmt) { + const places = decimals(fmt); + const name = fmt?.nf; + if (name === "text") return 49; + if (name === "integer") return this.customNumberFormat("#,##0"); + if (name === "number") return this.customNumberFormat("#,##0" + decimalPattern(places ?? 2)); + if (name === "currency") { + const pattern = '"$"#,##0' + decimalPattern(places ?? 2); + return this.customNumberFormat(pattern + ";-" + pattern); + } + if (name === "percent") return this.customNumberFormat("#,##0" + decimalPattern(places ?? 2) + "%"); + if (name === "scientific") return this.customNumberFormat("0" + decimalPattern(places ?? 2) + "E+00"); + if (name === "date") return this.customNumberFormat("mm/dd/yyyy"); + if (name === "time") return this.customNumberFormat("h:mm:ss AM/PM"); + if (name === "datetime") return this.customNumberFormat("mm/dd/yyyy h:mm:ss AM/PM"); + if (name != null) return 0; + return places == null ? 0 : this.customNumberFormat("0" + decimalPattern(places)); + } + + alignment(fmt) { + const horizontal = fmt?.a === "l" ? "left" : fmt?.a === "c" ? "center" : fmt?.a === "r" ? "right" : null; + const wrap = Boolean(fmt?.wrap); + if (!horizontal && !wrap) return 0; + const key = `${horizontal || ""}|${wrap}`; + let id = this.alignmentIds.get(key); + if (id == null) { + id = this.alignments.length; + this.alignmentIds.set(key, id); + this.alignments.push({horizontal, wrap}); + } + return id; + } + + style(fmt) { + if (!fmt || typeof fmt !== "object") return 0; + const cellFormat = { + fontId: this.font(fmt), fillId: this.fill(fmt), numberFormatId: this.numberFormat(fmt), + alignmentId: this.alignment(fmt), + }; + if (!cellFormat.fontId && !cellFormat.fillId && !cellFormat.numberFormatId && !cellFormat.alignmentId) return 0; + const key = `${cellFormat.fontId}|${cellFormat.fillId}|${cellFormat.numberFormatId}|${cellFormat.alignmentId}`; + let id = this.cellFormatIds.get(key); + if (id == null) { + if (this.cellFormats.length >= MAX_CELL_FORMATS) { + throw new Error("XLSX cell format count exceeds Excel's limit of 65,490."); + } + id = this.cellFormats.length; + this.cellFormatIds.set(key, id); + this.cellFormats.push(cellFormat); + } + return id; + } +} + +function sourceSheets(document) { + const result = []; + const seen = new Set(); + const order = Array.isArray(document?.sheetOrder) ? document.sheetOrder : []; + const sheetMap = document?.sheets && typeof document.sheets === "object" ? document.sheets : {}; + const cellMap = document?.cells && typeof document.cells === "object" ? document.cells : {}; + for (const rawId of order) { + const id = String(rawId); + if (seen.has(id)) continue; + seen.add(id); + const metadata = sheetMap[id]; + if (!metadata || typeof metadata !== "object") continue; + result.push({ + id, + sourceName: typeof metadata.name === "string" ? metadata.name : "Sheet", + metadata, + sourceCells: cellMap[id] && typeof cellMap[id] === "object" ? cellMap[id] : {}, + }); + } + if (!result.length) result.push({id: "", sourceName: "Sheet", metadata: {}, sourceCells: {}}); + assignSheetNames(result); + return result; +} + +function prepareWorkbook(document) { + const sheets = sourceSheets(document); + const formulaNames = new Map(); + for (const sheet of sheets) { + const key = sheet.sourceName.toLowerCase(); + if (!formulaNames.has(key)) formulaNames.set(key, sheet.name); + } + const styles = new Styles(); + for (const sheet of sheets) { + sheet.rows = count(sheet.metadata.rows, DEFAULT_ROWS, MAX_ROWS); + sheet.columns = count(sheet.metadata.cols, DEFAULT_COLUMNS, MAX_COLUMNS); + sheet.frozenRows = frozenCount(sheet.metadata.frozenRows, sheet.rows); + sheet.frozenColumns = frozenCount(sheet.metadata.frozenCols, sheet.columns); + sheet.columnWidths = dimensions(sheet.metadata.colWidths, sheet.columns, columnWidth); + sheet.rowHeights = dimensions(sheet.metadata.rowHeights, sheet.rows, rowPoints); + sheet.cells = []; + for (const [reference, sourceCell] of Object.entries(sheet.sourceCells)) { + const position = parseCellReference(reference); + if (!position || !sourceCell || typeof sourceCell !== "object") continue; + const fmt = sourceCell.fmt && typeof sourceCell.fmt === "object" ? sourceCell.fmt : null; + const style = styles.style(fmt); + const hasValue = sourceCell.value != null && String(sourceCell.value) !== ""; + if (!hasValue && !style) continue; + sheet.cells.push({reference, ...position, value: sourceCell.value == null ? "" : String(sourceCell.value), fmt, style}); + } + delete sheet.sourceCells; + sheet.cells.sort((a, b) => a.row - b.row || a.column - b.column); + } + return {sheets, styles, formulaNames}; +} + +function formulaReferenceAt(formula, offset) { + const match = /^\$?([A-Za-z]{1,3})\$?([1-9]\d*)/.exec(formula.slice(offset)); + if (!match) return false; + let column = 0; + for (const character of match[1].toUpperCase()) column = column * 26 + character.charCodeAt(0) - 64; + if (column > MAX_COLUMNS || Number(match[2]) > MAX_ROWS) return false; + const next = formula[offset + match[0].length]; + return !next || !/[A-Za-z0-9_$]/.test(next); +} + +function quotedSheetReference(formula, offset, names) { + const nameParts = []; + for (let i = offset + 1; i < formula.length; ++i) { + if (formula[i] !== "'") { + nameParts.push(formula[i]); + continue; + } + if (formula[i + 1] === "'") { + nameParts.push("'"); + ++i; + continue; + } + const quoteEnd = i + 1; + const hasBang = formula[quoteEnd] === "!"; + const end = quoteEnd + (hasBang ? 1 : 0); + const text = formula.slice(offset, end); + const name = nameParts.join(""); + const normalized = names.get(name.toLowerCase()); + const malformed = offset > 0 && /[A-Za-z0-9_.$]/.test(formula[offset - 1]); + const external = formula[offset - 1] === "]" || (!normalized && /\[[^\]]*\]/.test(name)); + if (!hasBang || !formulaReferenceAt(formula, end) || malformed || external || + isThreeDimensionalReference(formula, offset)) return {end, text}; + return normalized + ? {end, text: `'${normalized.replace(/'/g, "''")}'!`} + : {end, text}; + } + return {end: formula.length, text: formula.slice(offset)}; +} + +function unquotedSheetReference(formula, offset, names) { + if (!/[A-Za-z_$]/.test(formula[offset]) || + (offset > 0 && /[A-Za-z0-9_.$]/.test(formula[offset - 1])) || + formula[offset - 1] === "]" || isThreeDimensionalReference(formula, offset)) return null; + let end = offset + 1; + while (end < formula.length && /[A-Za-z0-9_.$]/.test(formula[end])) ++end; + if (formula[end] !== "!" || !formulaReferenceAt(formula, end + 1)) return null; + const name = formula.slice(offset, end); + const normalized = names.get(name.toLowerCase()); + if (!normalized) return null; + if (normalized.toLowerCase() === name.toLowerCase()) { + return {end: end + 1, text: formula.slice(offset, end + 1)}; + } + return {end: end + 1, text: `'${normalized.replace(/'/g, "''")}'!`}; +} + +function isThreeDimensionalReference(formula, offset) { + if (formula[offset - 1] !== ":") return false; + let start = offset - 2; + while (start >= 0 && /[A-Za-z0-9_$]/.test(formula[start])) --start; + const preceding = formula.slice(start + 1, offset - 1); + return !/^\$?[A-Za-z]{1,3}\$?[1-9]\d*$/.test(preceding); +} + +function formulaFunctionAt(formula, offset) { + if (!/[A-Za-z_]/.test(formula[offset]) || + (offset > 0 && /[A-Za-z0-9_.$!]/.test(formula[offset - 1]))) return null; + let end = offset + 1; + while (end < formula.length && /[A-Za-z0-9_.]/.test(formula[end])) ++end; + const name = formula.slice(offset, end).toUpperCase(); + let parenthesis = end; + while (/\s/.test(formula[parenthesis])) ++parenthesis; + if (formula[parenthesis] !== "(") return null; + if (FUTURE_FUNCTIONS.has(name)) return {end, text: "_xlfn." + name}; + return name === "ERRORTYPE" ? {end, text: "ERROR.TYPE"} : null; +} + +function rewriteFormula(formula, names) { + const result = []; + let stringLiteral = false; + let structuredReferenceDepth = 0; + for (let i = 0; i < formula.length;) { + const character = formula[i]; + if (character === '"') { + result.push(character); + if (stringLiteral && formula[i + 1] === '"') { + result.push(formula[i + 1]); + i += 2; + continue; + } + stringLiteral = !stringLiteral; + ++i; + continue; + } + if (!stringLiteral) { + let apostrophes = 0; + if (structuredReferenceDepth && (character === "[" || character === "]")) { + for (let j = i - 1; formula[j] === "'"; --j) ++apostrophes; + } + const escapedBracket = apostrophes % 2 === 1; + if (character === "[" && !escapedBracket) ++structuredReferenceDepth; + else if (character === "]" && structuredReferenceDepth && !escapedBracket) --structuredReferenceDepth; + const reference = !structuredReferenceDepth && (formulaFunctionAt(formula, i) || (character === "'" + ? quotedSheetReference(formula, i, names) + : unquotedSheetReference(formula, i, names))); + if (reference) { + result.push(reference.text); + i = reference.end; + continue; + } + } + result.push(character); + ++i; + } + return result.join(""); +} + +function parsedCellValue(value, formulaNames) { + if (value[0] === "'") return {type: "text", value: value.slice(1)}; + if (value[0] === "=") { + const formula = rewriteFormula(value.slice(1), formulaNames); + return formula.length + 1 <= MAX_FORMULA_CHARACTERS + ? {type: "formula", value: formula} + : {type: "text", value}; + } + const trimmed = value.trim(); + if (trimmed === "") return {type: "blank", value: ""}; + if (/^(TRUE|FALSE)$/i.test(trimmed)) return {type: "boolean", value: /^true$/i.test(trimmed)}; + if (/^[-+]?\$?[\d,]*\.?\d+%?$/.test(trimmed) && /\d/.test(trimmed)) { + const negative = trimmed.startsWith("-"); + const cleaned = trimmed.replace(/[$,+%-]/g, ""); + let number = Number(cleaned); + if (Number.isFinite(number)) { + if (trimmed.endsWith("%")) number /= 100; + return {type: "number", value: negative ? -number : number}; + } + } + return {type: "text", value}; +} + +function cellXml(cell, formulaNames) { + const style = cell.style ? ` s="${cell.style}"` : ""; + if (cell.value === "") return ``; + const parsed = parsedCellValue(cell.value, formulaNames); + if (parsed.type === "blank") return ``; + if (parsed.type === "formula") return `${formulaXml(parsed.value)}`; + if (parsed.type === "boolean") return `${parsed.value ? 1 : 0}`; + if (parsed.type === "number") return `${String(parsed.value)}`; + return `${spreadsheetXml(parsed.value)}`; +} + +function frozenPane(sheet) { + const rows = sheet.frozenRows; + const columns = sheet.frozenColumns; + if (!rows && !columns) return ""; + const attributes = []; + if (columns) attributes.push(`xSplit="${columns}"`); + if (rows) attributes.push(`ySplit="${rows}"`); + attributes.push(`topLeftCell="${columnName(columns + 1)}${rows + 1}"`); + attributes.push(`activePane="${rows && columns ? "bottomRight" : rows ? "bottomLeft" : "topRight"}"`); + attributes.push('state="frozen"'); + return ``; +} + +function worksheetDimension(cells) { + if (!cells.length) return "A1"; + let minRow = MAX_ROWS, minColumn = MAX_COLUMNS, maxRow = 1, maxColumn = 1; + for (const cell of cells) { + minRow = Math.min(minRow, cell.row); + minColumn = Math.min(minColumn, cell.column); + maxRow = Math.max(maxRow, cell.row); + maxColumn = Math.max(maxColumn, cell.column); + } + const first = columnName(minColumn) + minRow; + const last = columnName(maxColumn) + maxRow; + return first === last ? first : first + ":" + last; +} + +function* worksheetXml(sheet, formulaNames) { + yield ``; + yield ``; + yield `${frozenPane(sheet)}`; + yield ``; + if (sheet.columnWidths.length) { + yield ""; + for (const width of sheet.columnWidths) { + yield ``; + } + yield ""; + } + yield ""; + let cellIndex = 0; + let heightIndex = 0; + while (cellIndex < sheet.cells.length || heightIndex < sheet.rowHeights.length) { + const cellRow = sheet.cells[cellIndex]?.row ?? Infinity; + const heightRow = (sheet.rowHeights[heightIndex]?.index ?? Infinity) + 1; + const row = Math.min(cellRow, heightRow); + const height = heightRow === row ? sheet.rowHeights[heightIndex++] : null; + yield ``; + while (sheet.cells[cellIndex]?.row === row) yield cellXml(sheet.cells[cellIndex++], formulaNames); + yield ""; + } + yield ""; +} + +function* stylesXml(styles) { + yield ``; + if (styles.numberFormats.length) { + yield ``; + for (const format of styles.numberFormats) yield ``; + yield ""; + } + yield ``; + for (const font of styles.fonts) { + yield ""; + if (font.bold) yield ""; + if (font.italic) yield ""; + if (font.underline) yield ""; + if (font.strike) yield ""; + yield ``; + if (font.color) yield ``; + yield ''; + } + yield ""; + yield ``; + for (let i = 2; i < styles.fills.length; ++i) { + yield ``; + } + yield ""; + yield ''; + yield ``; + for (const format of styles.cellFormats) { + const alignment = styles.alignments[format.alignmentId]; + let attributes = `numFmtId="${format.numberFormatId}" fontId="${format.fontId}" fillId="${format.fillId}" borderId="0" xfId="0"`; + if (format.numberFormatId) attributes += ' applyNumberFormat="1"'; + if (format.fontId) attributes += ' applyFont="1"'; + if (format.fillId) attributes += ' applyFill="1"'; + if (alignment) attributes += ' applyAlignment="1"'; + if (!alignment) { + yield ``; + continue; + } + const alignmentAttributes = []; + if (alignment.horizontal) alignmentAttributes.push(`horizontal="${alignment.horizontal}"`); + if (alignment.wrap) alignmentAttributes.push('wrapText="1"'); + yield ``; + } + yield ''; +} + +function contentTypes(sheetCount) { + let xml = ''; + xml += ''; + xml += ''; + xml += ''; + xml += ''; + xml += ''; + for (let i = 1; i <= sheetCount; ++i) { + xml += ``; + } + return xml + ""; +} + +function workbookXml(sheets) { + let xml = ``; + xml += ""; + for (let i = 0; i < sheets.length; ++i) { + xml += ``; + } + return xml + ''; +} + +function workbookRelationships(sheetCount) { + let xml = ``; + for (let i = 1; i <= sheetCount; ++i) { + xml += ``; + } + return xml + ``; +} + +export function workbookToXlsx(document) { + const workbook = prepareWorkbook(document); + const entries = [ + {name: "[Content_Types].xml", data: contentTypes(workbook.sheets.length)}, + {name: "_rels/.rels", data: ``}, + {name: "xl/workbook.xml", data: workbookXml(workbook.sheets)}, + {name: "xl/_rels/workbook.xml.rels", data: workbookRelationships(workbook.sheets.length)}, + {name: "xl/styles.xml", data: () => textStream(stylesXml(workbook.styles))}, + ]; + for (let i = 0; i < workbook.sheets.length; ++i) { + const sheet = workbook.sheets[i]; + entries.push({ + name: `xl/worksheets/sheet${i + 1}.xml`, + data: () => textStream(worksheetXml(sheet, workbook.formulaNames)), + }); + } + return createZip(entries); +} diff --git a/packages/workshop-backend/format-blueprints/workspace-sheets/files/zip.js b/packages/workshop-backend/format-blueprints/workspace-sheets/files/zip.js new file mode 100644 index 000000000..695c0ca3c --- /dev/null +++ b/packages/workshop-backend/format-blueprints/workspace-sheets/files/zip.js @@ -0,0 +1,198 @@ +const encoder = new TextEncoder(); +const ZIP32_MAX = 0xffffffff; +const ZIP32_MAX_ENTRIES = 0xffff; +const UTF8_DATA_DESCRIPTOR_FLAGS = 0x0808; +const DEFLATE_METHOD = 8; +const DOS_TIME = 0; +const DOS_DATE = 33; // 1980-01-01 + +const CRC32_TABLE = new Uint32Array(256); +for (let i = 0; i < CRC32_TABLE.length; ++i) { + let value = i; + for (let bit = 0; bit < 8; ++bit) { + value = (value & 1) ? 0xedb88320 ^ (value >>> 1) : value >>> 1; + } + CRC32_TABLE[i] = value >>> 0; +} + +export function crc32(bytes, previous = 0) { + let value = (previous ^ 0xffffffff) >>> 0; + for (const byte of bytes) value = CRC32_TABLE[(value ^ byte) & 0xff] ^ (value >>> 8); + return (value ^ 0xffffffff) >>> 0; +} + +function record(size, write) { + const bytes = new Uint8Array(size); + write(new DataView(bytes.buffer)); + return bytes; +} + +function localHeader(nameLength) { + return record(30, (view) => { + view.setUint32(0, 0x04034b50, true); + view.setUint16(4, 20, true); + view.setUint16(6, UTF8_DATA_DESCRIPTOR_FLAGS, true); + view.setUint16(8, DEFLATE_METHOD, true); + view.setUint16(10, DOS_TIME, true); + view.setUint16(12, DOS_DATE, true); + view.setUint16(26, nameLength, true); + }); +} + +function dataDescriptor(crc, compressedSize, uncompressedSize) { + return record(16, (view) => { + view.setUint32(0, 0x08074b50, true); + view.setUint32(4, crc, true); + view.setUint32(8, compressedSize, true); + view.setUint32(12, uncompressedSize, true); + }); +} + +function centralHeader(entry) { + return record(46, (view) => { + view.setUint32(0, 0x02014b50, true); + view.setUint16(4, 20, true); + view.setUint16(6, 20, true); + view.setUint16(8, UTF8_DATA_DESCRIPTOR_FLAGS, true); + view.setUint16(10, DEFLATE_METHOD, true); + view.setUint16(12, DOS_TIME, true); + view.setUint16(14, DOS_DATE, true); + view.setUint32(16, entry.crc, true); + view.setUint32(20, entry.compressedSize, true); + view.setUint32(24, entry.uncompressedSize, true); + view.setUint16(28, entry.name.length, true); + view.setUint32(42, entry.localOffset, true); + }); +} + +function endOfCentralDirectory(entryCount, centralSize, centralOffset) { + return record(22, (view) => { + view.setUint32(0, 0x06054b50, true); + view.setUint16(8, entryCount, true); + view.setUint16(10, entryCount, true); + view.setUint32(12, centralSize, true); + view.setUint32(16, centralOffset, true); + }); +} + +function byteStream(value) { + if (value && typeof value.getReader === "function") return value; + let bytes; + if (typeof value === "string") bytes = encoder.encode(value); + else if (value instanceof Uint8Array) bytes = value; + else if (value instanceof ArrayBuffer) bytes = new Uint8Array(value); + else if (ArrayBuffer.isView(value)) { + bytes = new Uint8Array(value.buffer, value.byteOffset, value.byteLength); + } else { + throw new TypeError("ZIP entry data must be text, bytes, or a byte stream."); + } + return new ReadableStream({ + start(controller) { + controller.enqueue(bytes); + controller.close(); + }, + }); +} + +function asBytes(chunk) { + if (chunk instanceof Uint8Array) return chunk; + if (chunk instanceof ArrayBuffer) return new Uint8Array(chunk); + if (ArrayBuffer.isView(chunk)) { + return new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength); + } + throw new TypeError("ZIP entry streams must contain byte chunks."); +} + +function checkedSize(value, label) { + if (value > ZIP32_MAX) throw new Error(`ZIP32 ${label} exceeds 4 GiB.`); + return value; +} + +async function* generateZip(entries) { + const centralEntries = []; + let offset = 0; + + const emit = (bytes) => { + checkedSize(offset + bytes.byteLength, "archive size"); + offset += bytes.byteLength; + return bytes; + }; + + for (const sourceEntry of entries) { + if (centralEntries.length >= ZIP32_MAX_ENTRIES) { + throw new Error("ZIP32 entry count exceeds 65,535."); + } + const name = encoder.encode(String(sourceEntry.name)); + if (name.byteLength === 0) throw new Error("ZIP entry names must not be empty."); + if (name.byteLength > ZIP32_MAX_ENTRIES) { + throw new Error("ZIP entry name exceeds 65,535 UTF-8 bytes."); + } + + const localOffset = offset; + yield emit(localHeader(name.byteLength)); + yield emit(name); + + let crc = 0; + let compressedSize = 0; + let uncompressedSize = 0; + const rawData = typeof sourceEntry.data === "function" + ? await sourceEntry.data() + : sourceEntry.data; + const measured = byteStream(rawData).pipeThrough(new TransformStream({ + transform(chunk, controller) { + const bytes = asBytes(chunk); + uncompressedSize = checkedSize(uncompressedSize + bytes.byteLength, "entry size"); + crc = crc32(bytes, crc); + controller.enqueue(bytes); + }, + })); + const compressed = measured.pipeThrough(new CompressionStream("deflate-raw")); + const reader = compressed.getReader(); + let completed = false; + try { + while (true) { + const result = await reader.read(); + if (result.done) { + completed = true; + break; + } + const bytes = asBytes(result.value); + compressedSize = checkedSize(compressedSize + bytes.byteLength, "compressed entry size"); + yield emit(bytes); + } + } finally { + if (!completed) await reader.cancel(); + reader.releaseLock(); + } + + yield emit(dataDescriptor(crc, compressedSize, uncompressedSize)); + centralEntries.push({name, crc, compressedSize, uncompressedSize, localOffset}); + } + + const centralOffset = offset; + for (const entry of centralEntries) { + yield emit(centralHeader(entry)); + yield emit(entry.name); + } + const centralSize = offset - centralOffset; + checkedSize(centralSize, "central directory size"); + yield emit(endOfCentralDirectory(centralEntries.length, centralSize, centralOffset)); +} + +export function createZip(entries) { + const iterator = generateZip(entries); + return new ReadableStream({ + async pull(controller) { + try { + const result = await iterator.next(); + if (result.done) controller.close(); + else controller.enqueue(result.value); + } catch (error) { + controller.error(error); + } + }, + cancel(reason) { + return iterator.return(reason); + }, + }); +}