@openjsxl/core 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,8 +5,9 @@
5
5
  [![license: MIT](https://img.shields.io/npm/l/@openjsxl/core?color=blue)](./LICENSE)
6
6
 
7
7
  The zero-dependency OOXML engine behind [`openjsxl`](https://www.npmjs.com/package/openjsxl):
8
- the `zip → xml → ooxml → reader` layers that turn an `.xlsx` into typed cells, built only on
9
- platform Web APIs (`DecompressionStream`, `TextDecoder`, …). No runtime dependencies.
8
+ the `zip → xml → ooxml → reader` layers that turn an `.xlsx` into typed cells and the mirror
9
+ `writer` layer that turns plain data back into `.xlsx` bytes. Built only on platform Web APIs
10
+ (`DecompressionStream`, `CompressionStream`, `TextDecoder`, …). No runtime dependencies.
10
11
 
11
12
  **Most users should install [`openjsxl`](https://www.npmjs.com/package/openjsxl) instead** — it
12
13
  re-exports everything here and is the stable public surface. Install `@openjsxl/core` directly
@@ -39,9 +40,30 @@ Malformed input throws a typed `XlsxError` with a discriminating `.code`
39
40
  (`'not-a-zip' | 'not-xlsx' | 'missing-part' | 'corrupt-zip' | 'part-too-large' | …`), never a
40
41
  bare `TypeError` from a corrupt file.
41
42
 
43
+ ## Writing
44
+
45
+ ```ts
46
+ import { writeXlsx, workbookToInput } from '@openjsxl/core'
47
+
48
+ // Author from plain data — cell types inferred from the JS values.
49
+ const bytes = await writeXlsx({
50
+ sheets: [{ name: 'Report', rows: [['Item', 'Added'], ['Apples', new Date('2024-01-15')]] }],
51
+ })
52
+
53
+ // Or read → modify → write.
54
+ const input = await workbookToInput(await openXlsx(bytes))
55
+ input.sheets[0].rows.push(['Pears', new Date('2024-02-01')])
56
+ const updated = await writeXlsx(input)
57
+ ```
58
+
59
+ Output is deterministic; unrepresentable input (no sheets, bad/duplicate sheet name, non-finite
60
+ number, invalid `Date`, XML-illegal characters) throws `XlsxError` with `code: 'invalid-input'`.
61
+ The round trip is lossless for values, types, and sheet names/order.
62
+
42
63
  ## Exports
43
64
 
44
65
  - **Reader:** `openXlsx`, `streamSheetRows`, `Workbook`, `Worksheet`, `ReadOptions`
66
+ - **Writer:** `writeXlsx`, `workbookToInput`, `WorkbookInput`, `SheetInput`, `CellValue`, `WriteOptions`
45
67
  - **Errors:** `XlsxError`, `XlsxErrorCode`
46
68
  - **Types:** `Row`, `Cell`, `CellType`, `Comment`, `Hyperlink`, `SheetInfo`, `CellRef`
47
69
  - **A1 & dates:** `columnToIndex`, `indexToColumn`, `parseRef`, `formatRef`, `serialToDate`
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- type XlsxErrorCode = 'not-a-zip' | 'not-xlsx' | 'missing-part' | 'corrupt-zip' | 'unsupported' | 'no-such-sheet' | 'part-too-large';
1
+ type XlsxErrorCode = 'not-a-zip' | 'not-xlsx' | 'missing-part' | 'corrupt-zip' | 'unsupported' | 'no-such-sheet' | 'part-too-large' | 'invalid-input';
2
2
  declare class XlsxError extends Error {
3
3
  /** Machine-readable discriminant; branch on this rather than the message. */
4
4
  readonly code: XlsxErrorCode;
@@ -19,6 +19,7 @@ declare function parseRef(ref: string): CellRef;
19
19
  declare function formatRef(ref: CellRef): string;
20
20
 
21
21
  declare function serialToDate(serial: number, date1904?: boolean): Date;
22
+ declare function dateToSerial(date: Date, date1904?: boolean): number;
22
23
 
23
24
  type CellType = 'empty' | 'string' | 'number' | 'boolean' | 'date' | 'error';
24
25
  interface CellBase {
@@ -178,4 +179,41 @@ declare function openXlsx(source: Uint8Array | ArrayBuffer, options?: ReadOption
178
179
  */
179
180
  declare function streamSheetRows(source: Uint8Array | ArrayBuffer, sheetName?: string, options?: ReadOptions): AsyncGenerator<Row>;
180
181
 
181
- export { type Cell, type CellRef, type CellType, type Comment, type Hyperlink, type ReadOptions, type Row, type SheetInfo, Workbook, Worksheet, XlsxError, type XlsxErrorCode, columnToIndex, formatRef, indexToColumn, openXlsx, parseRef, serialToDate, streamSheetRows };
182
+ /**
183
+ * A value a cell can hold when writing. The OOXML cell type is inferred from it:
184
+ * `string` → inline string, `number` → numeric, `boolean` → `b`, `Date` → date-styled serial.
185
+ * `null` / `undefined` (including array holes) → an empty cell, omitted from the output.
186
+ */
187
+ type CellValue = string | number | boolean | Date | null | undefined;
188
+ interface SheetInput {
189
+ /** Tab name. 1–31 chars, unique (case-insensitively), free of `\ / ? * [ ] :`. */
190
+ readonly name: string;
191
+ /** Rows top-to-bottom; each a left-to-right array of cell values. `rows[0][0]` is A1. */
192
+ readonly rows: readonly (readonly CellValue[])[];
193
+ }
194
+ interface WorkbookInput {
195
+ /** At least one sheet, in tab order. */
196
+ readonly sheets: readonly SheetInput[];
197
+ }
198
+ interface WriteOptions {
199
+ /** Use the 1904 date epoch (legacy Mac) instead of the default 1900 system. */
200
+ readonly date1904?: boolean;
201
+ }
202
+
203
+ /**
204
+ * Convert an open {@link Workbook} into {@link WorkbookInput} for {@link writeXlsx}. Each populated
205
+ * cell is placed at its own A1 reference, preserving sheet names and tab order. Rows/columns are
206
+ * left sparse (array holes) — the writer treats a hole as an empty cell — so a workbook with a few
207
+ * far-apart cells does not materialize a dense grid.
208
+ */
209
+ declare function workbookToInput(workbook: Workbook): Promise<WorkbookInput>;
210
+
211
+ /**
212
+ * Serialize a workbook to `.xlsx` bytes. Async because compression runs on the platform's
213
+ * CompressionStream. Throws {@link XlsxError} with code `invalid-input` for anything that can't be
214
+ * represented — no sheets, a bad sheet name, or a cell value that isn't a string, finite number,
215
+ * boolean, Date, or null.
216
+ */
217
+ declare function writeXlsx(workbook: WorkbookInput, options?: WriteOptions): Promise<Uint8Array>;
218
+
219
+ export { type Cell, type CellRef, type CellType, type CellValue, type Comment, type Hyperlink, type ReadOptions, type Row, type SheetInfo, type SheetInput, Workbook, type WorkbookInput, Worksheet, type WriteOptions, XlsxError, type XlsxErrorCode, columnToIndex, dateToSerial, formatRef, indexToColumn, openXlsx, parseRef, serialToDate, streamSheetRows, workbookToInput, writeXlsx };
package/dist/index.js CHANGED
@@ -62,6 +62,10 @@ function serialToDate(serial, date1904 = false) {
62
62
  const epoch = date1904 ? EPOCH_1904_UTC : EPOCH_1900_UTC;
63
63
  return new Date(epoch + Math.round(serial * MS_PER_DAY));
64
64
  }
65
+ function dateToSerial(date, date1904 = false) {
66
+ const epoch = date1904 ? EPOCH_1904_UTC : EPOCH_1900_UTC;
67
+ return (date.getTime() - epoch) / MS_PER_DAY;
68
+ }
65
69
 
66
70
  // src/ooxml/cell.ts
67
71
  function decodeCell(raw, ctx) {
@@ -84,7 +88,8 @@ function decodeCell(raw, ctx) {
84
88
  const num = Number(value);
85
89
  if (!Number.isFinite(num)) return { ref, type: "empty", value: null };
86
90
  if (ctx.styles?.isDateStyle(raw.style)) {
87
- return { ref, type: "date", value: serialToDate(num, ctx.date1904 ?? false) };
91
+ const date = serialToDate(num, ctx.date1904 ?? false);
92
+ if (!Number.isNaN(date.getTime())) return { ref, type: "date", value: date };
88
93
  }
89
94
  return { ref, type: "number", value: num };
90
95
  }
@@ -1257,4 +1262,434 @@ async function* streamSheetRows(source, sheetName, options) {
1257
1262
  yield* streamRows(zip.readStream(path), context);
1258
1263
  }
1259
1264
 
1260
- export { Workbook, Worksheet, XlsxError, columnToIndex, formatRef, indexToColumn, openXlsx, parseRef, serialToDate, streamSheetRows };
1265
+ // src/writer/crc32.ts
1266
+ var CRC_TABLE = (() => {
1267
+ const table = new Uint32Array(256);
1268
+ for (let n = 0; n < 256; n++) {
1269
+ let c = n;
1270
+ for (let k = 0; k < 8; k++) c = (c & 1) !== 0 ? 3988292384 ^ c >>> 1 : c >>> 1;
1271
+ table[n] = c >>> 0;
1272
+ }
1273
+ return table;
1274
+ })();
1275
+ function crc32(bytes) {
1276
+ let crc = 4294967295;
1277
+ for (let i = 0; i < bytes.length; i++) {
1278
+ crc = (CRC_TABLE[(crc ^ bytes[i]) & 255] ^ crc >>> 8) >>> 0;
1279
+ }
1280
+ return (crc ^ 4294967295) >>> 0;
1281
+ }
1282
+
1283
+ // src/writer/deflate.ts
1284
+ async function deflateRaw(data) {
1285
+ const blob = new Blob([data]);
1286
+ const reader = blob.stream().pipeThrough(new CompressionStream("deflate-raw")).getReader();
1287
+ const chunks = [];
1288
+ let total = 0;
1289
+ for (; ; ) {
1290
+ const { done, value } = await reader.read();
1291
+ if (done) break;
1292
+ total += value.byteLength;
1293
+ chunks.push(value);
1294
+ }
1295
+ const out = new Uint8Array(total);
1296
+ let offset = 0;
1297
+ for (const chunk of chunks) {
1298
+ out.set(chunk, offset);
1299
+ offset += chunk.byteLength;
1300
+ }
1301
+ return out;
1302
+ }
1303
+
1304
+ // src/writer/from-workbook.ts
1305
+ function cellToValue(cell) {
1306
+ return cell.value;
1307
+ }
1308
+ async function workbookToInput(workbook) {
1309
+ const sheets = [];
1310
+ for (const info of workbook.sheets) {
1311
+ const worksheet = workbook.sheet(info.name);
1312
+ const rows = [];
1313
+ for await (const row of worksheet.rows()) {
1314
+ for (const cell of row.cells) {
1315
+ const { col, row: rowNum } = parseRef(cell.ref);
1316
+ let rowArr = rows[rowNum - 1];
1317
+ if (rowArr === void 0) {
1318
+ rowArr = [];
1319
+ rows[rowNum - 1] = rowArr;
1320
+ }
1321
+ rowArr[col - 1] = cellToValue(cell);
1322
+ }
1323
+ }
1324
+ sheets.push({ name: info.name, rows });
1325
+ }
1326
+ return { sheets };
1327
+ }
1328
+
1329
+ // src/writer/xml.ts
1330
+ function escapeText(s) {
1331
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1332
+ }
1333
+ function escapeAttr(s) {
1334
+ return escapeText(s).replace(/"/g, "&quot;");
1335
+ }
1336
+ function isXmlSafe(s) {
1337
+ for (let i = 0; i < s.length; i++) {
1338
+ const c = s.charCodeAt(i);
1339
+ if (c < 32) {
1340
+ if (c !== 9 && c !== 10 && c !== 13) return false;
1341
+ } else if (c === 65534 || c === 65535) {
1342
+ return false;
1343
+ } else if (c >= 55296 && c <= 56319) {
1344
+ const next = s.charCodeAt(i + 1);
1345
+ if (!(next >= 56320 && next <= 57343)) return false;
1346
+ i++;
1347
+ } else if (c >= 56320 && c <= 57343) {
1348
+ return false;
1349
+ }
1350
+ }
1351
+ return true;
1352
+ }
1353
+ function needsPreserve(s) {
1354
+ return s !== s.trim();
1355
+ }
1356
+ function preserveAttr(s) {
1357
+ return needsPreserve(s) ? ' xml:space="preserve"' : "";
1358
+ }
1359
+
1360
+ // src/writer/sheet.ts
1361
+ var XML_DECL = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
1362
+ var NS_MAIN = "http://schemas.openxmlformats.org/spreadsheetml/2006/main";
1363
+ var DATE_STYLE_INDEX = 1;
1364
+ function numberToXml(n) {
1365
+ return String(n);
1366
+ }
1367
+ function renderCell(col, row, value, date1904) {
1368
+ if (value === null || value === void 0) return void 0;
1369
+ const ref = formatRef({ col, row });
1370
+ if (typeof value === "string") {
1371
+ if (!isXmlSafe(value)) {
1372
+ throw new XlsxError(
1373
+ "invalid-input",
1374
+ `cell ${ref}: string contains a character not allowed in XML (a control character or lone surrogate)`
1375
+ );
1376
+ }
1377
+ return {
1378
+ xml: `<c r="${ref}" t="inlineStr"><is><t${preserveAttr(value)}>${escapeText(value)}</t></is></c>`,
1379
+ isDate: false
1380
+ };
1381
+ }
1382
+ if (typeof value === "boolean") {
1383
+ return { xml: `<c r="${ref}" t="b"><v>${value ? 1 : 0}</v></c>`, isDate: false };
1384
+ }
1385
+ if (typeof value === "number") {
1386
+ if (!Number.isFinite(value)) {
1387
+ throw new XlsxError("invalid-input", `cell ${ref}: ${value} is not a finite number`);
1388
+ }
1389
+ return { xml: `<c r="${ref}"><v>${numberToXml(value)}</v></c>`, isDate: false };
1390
+ }
1391
+ if (value instanceof Date) {
1392
+ const serial = dateToSerial(value, date1904);
1393
+ if (!Number.isFinite(serial)) {
1394
+ throw new XlsxError("invalid-input", `cell ${ref}: invalid Date`);
1395
+ }
1396
+ return {
1397
+ xml: `<c r="${ref}" s="${DATE_STYLE_INDEX}"><v>${numberToXml(serial)}</v></c>`,
1398
+ isDate: true
1399
+ };
1400
+ }
1401
+ throw new XlsxError("invalid-input", `cell ${ref}: unsupported cell value type`);
1402
+ }
1403
+ function worksheetXml(rows, date1904) {
1404
+ let usesDate = false;
1405
+ let minRow = 0;
1406
+ let maxRow = 0;
1407
+ let minCol = 0;
1408
+ let maxCol = 0;
1409
+ const rowXmls = [];
1410
+ for (let r = 0; r < rows.length; r++) {
1411
+ const cells = rows[r];
1412
+ if (cells === void 0) continue;
1413
+ if (!Array.isArray(cells)) {
1414
+ throw new XlsxError(
1415
+ "invalid-input",
1416
+ `sheet row ${r + 1}: a row must be an array of cell values`
1417
+ );
1418
+ }
1419
+ if (cells.length === 0) continue;
1420
+ const rowNum = r + 1;
1421
+ const cellXmls = [];
1422
+ for (let c = 0; c < cells.length; c++) {
1423
+ const colNum = c + 1;
1424
+ const rendered = renderCell(colNum, rowNum, cells[c], date1904);
1425
+ if (rendered === void 0) continue;
1426
+ if (rendered.isDate) usesDate = true;
1427
+ if (minRow === 0 || rowNum < minRow) minRow = rowNum;
1428
+ if (rowNum > maxRow) maxRow = rowNum;
1429
+ if (minCol === 0 || colNum < minCol) minCol = colNum;
1430
+ if (colNum > maxCol) maxCol = colNum;
1431
+ cellXmls.push(rendered.xml);
1432
+ }
1433
+ if (cellXmls.length > 0) rowXmls.push(`<row r="${rowNum}">${cellXmls.join("")}</row>`);
1434
+ }
1435
+ const dimension = minRow === 0 ? "A1" : minRow === maxRow && minCol === maxCol ? formatRef({ col: minCol, row: minRow }) : `${formatRef({ col: minCol, row: minRow })}:${formatRef({ col: maxCol, row: maxRow })}`;
1436
+ const xml = `${XML_DECL}
1437
+ <worksheet xmlns="${NS_MAIN}"><dimension ref="${dimension}"/><sheetData>${rowXmls.join(
1438
+ ""
1439
+ )}</sheetData></worksheet>`;
1440
+ return { xml, usesDate };
1441
+ }
1442
+
1443
+ // src/writer/zip.ts
1444
+ var encoder = new TextEncoder();
1445
+ var SIG_LOCAL2 = 67324752;
1446
+ var SIG_CENTRAL2 = 33639248;
1447
+ var SIG_EOCD2 = 101010256;
1448
+ var METHOD_STORE = 0;
1449
+ var METHOD_DEFLATE = 8;
1450
+ var VERSION = 20;
1451
+ var U32_CEILING = 4294967295;
1452
+ var MAX_ENTRIES = 65535;
1453
+ var DOS_TIME = 0;
1454
+ var DOS_DATE = 33;
1455
+ var u16 = (n) => Uint8Array.from([n & 255, n >>> 8 & 255]);
1456
+ var u32 = (n) => Uint8Array.from([n & 255, n >>> 8 & 255, n >>> 16 & 255, n >>> 24 & 255]);
1457
+ function concat(parts) {
1458
+ let total = 0;
1459
+ for (const part of parts) total += part.length;
1460
+ const out = new Uint8Array(total);
1461
+ let offset = 0;
1462
+ for (const part of parts) {
1463
+ out.set(part, offset);
1464
+ offset += part.length;
1465
+ }
1466
+ return out;
1467
+ }
1468
+ async function writeZip(entries) {
1469
+ if (entries.length >= MAX_ENTRIES) {
1470
+ throw new XlsxError(
1471
+ "unsupported",
1472
+ `too many zip entries (${entries.length}); would require ZIP64, which is not supported`
1473
+ );
1474
+ }
1475
+ const seen = /* @__PURE__ */ new Set();
1476
+ const local = [];
1477
+ const central = [];
1478
+ let offset = 0;
1479
+ for (const entry of entries) {
1480
+ if (entry.name.endsWith("/")) {
1481
+ throw new Error(`invalid zip entry name (directory placeholder): ${entry.name}`);
1482
+ }
1483
+ if (seen.has(entry.name)) throw new Error(`duplicate zip entry name: ${entry.name}`);
1484
+ seen.add(entry.name);
1485
+ const name = encoder.encode(entry.name);
1486
+ if (name.length > 65535) {
1487
+ throw new XlsxError(
1488
+ "unsupported",
1489
+ `zip entry name too long (${name.length} bytes); exceeds the classic-ZIP name-length field`
1490
+ );
1491
+ }
1492
+ const data = entry.data;
1493
+ const crc = crc32(data);
1494
+ const uncompressedSize = data.length;
1495
+ const deflated = await deflateRaw(data);
1496
+ const useDeflate = deflated.length < data.length;
1497
+ const method = useDeflate ? METHOD_DEFLATE : METHOD_STORE;
1498
+ const payload = useDeflate ? deflated : data;
1499
+ const compressedSize = payload.length;
1500
+ if (uncompressedSize >= U32_CEILING || compressedSize >= U32_CEILING || offset >= U32_CEILING) {
1501
+ throw new XlsxError(
1502
+ "unsupported",
1503
+ `zip entry ${entry.name} too large for a classic (ZIP64-free) archive`
1504
+ );
1505
+ }
1506
+ const header = concat([
1507
+ u32(SIG_LOCAL2),
1508
+ u16(VERSION),
1509
+ // version needed to extract
1510
+ u16(0),
1511
+ // general-purpose flags: none (sizes/CRC are in the header, no data descriptor)
1512
+ u16(method),
1513
+ u16(DOS_TIME),
1514
+ u16(DOS_DATE),
1515
+ u32(crc),
1516
+ u32(compressedSize),
1517
+ u32(uncompressedSize),
1518
+ u16(name.length),
1519
+ u16(0),
1520
+ // extra field length
1521
+ name
1522
+ ]);
1523
+ local.push(header, payload);
1524
+ central.push(
1525
+ concat([
1526
+ u32(SIG_CENTRAL2),
1527
+ u16(VERSION),
1528
+ // version made by
1529
+ u16(VERSION),
1530
+ // version needed to extract
1531
+ u16(0),
1532
+ // flags
1533
+ u16(method),
1534
+ u16(DOS_TIME),
1535
+ u16(DOS_DATE),
1536
+ u32(crc),
1537
+ u32(compressedSize),
1538
+ u32(uncompressedSize),
1539
+ u16(name.length),
1540
+ u16(0),
1541
+ // extra field length
1542
+ u16(0),
1543
+ // file comment length
1544
+ u16(0),
1545
+ // disk number start
1546
+ u16(0),
1547
+ // internal file attributes
1548
+ u32(0),
1549
+ // external file attributes
1550
+ u32(offset),
1551
+ // relative offset of the local header
1552
+ name
1553
+ ])
1554
+ );
1555
+ offset += header.length + payload.length;
1556
+ }
1557
+ const directory = concat(central);
1558
+ if (offset >= U32_CEILING || directory.length >= U32_CEILING) {
1559
+ throw new XlsxError(
1560
+ "unsupported",
1561
+ "zip archive too large for a classic (ZIP64-free) archive"
1562
+ );
1563
+ }
1564
+ const eocd = concat([
1565
+ u32(SIG_EOCD2),
1566
+ u16(0),
1567
+ // number of this disk
1568
+ u16(0),
1569
+ // disk where the central directory starts
1570
+ u16(entries.length),
1571
+ // central-directory entries on this disk
1572
+ u16(entries.length),
1573
+ // total central-directory entries
1574
+ u32(directory.length),
1575
+ // size of the central directory
1576
+ u32(offset),
1577
+ // offset of the central directory from the start of the archive
1578
+ u16(0)
1579
+ // comment length
1580
+ ]);
1581
+ return concat([...local, directory, eocd]);
1582
+ }
1583
+
1584
+ // src/writer/workbook.ts
1585
+ var encoder2 = new TextEncoder();
1586
+ var XML_DECL2 = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
1587
+ var NS_MAIN2 = "http://schemas.openxmlformats.org/spreadsheetml/2006/main";
1588
+ var NS_REL = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
1589
+ var NS_PKG_REL = "http://schemas.openxmlformats.org/package/2006/relationships";
1590
+ var NS_CT = "http://schemas.openxmlformats.org/package/2006/content-types";
1591
+ var CT_BASE = "application/vnd.openxmlformats-officedocument.spreadsheetml";
1592
+ var CT_RELS = "application/vnd.openxmlformats-package.relationships+xml";
1593
+ var MAX_SHEET_NAME = 31;
1594
+ var FORBIDDEN_SHEET_NAME = /[\\/?*[\]:]/;
1595
+ var STYLES_XML = `${XML_DECL2}
1596
+ <styleSheet xmlns="${NS_MAIN2}"><fonts count="1"><font><sz val="11"/><name val="Calibri"/></font></fonts><fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills><borders count="1"><border/></borders><cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs><cellXfs count="2"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/><xf numFmtId="14" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/></cellXfs><cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles></styleSheet>`;
1597
+ function validate(workbook) {
1598
+ const sheets = workbook?.sheets;
1599
+ if (!Array.isArray(sheets) || sheets.length === 0) {
1600
+ throw new XlsxError("invalid-input", "a workbook needs at least one sheet");
1601
+ }
1602
+ const seen = /* @__PURE__ */ new Set();
1603
+ for (const sheet of sheets) {
1604
+ const name = sheet?.name;
1605
+ if (typeof name !== "string" || name.length === 0) {
1606
+ throw new XlsxError("invalid-input", "a sheet name must be a non-empty string");
1607
+ }
1608
+ if (name.length > MAX_SHEET_NAME) {
1609
+ throw new XlsxError(
1610
+ "invalid-input",
1611
+ `sheet name "${name}" exceeds ${MAX_SHEET_NAME} characters`
1612
+ );
1613
+ }
1614
+ if (FORBIDDEN_SHEET_NAME.test(name)) {
1615
+ throw new XlsxError(
1616
+ "invalid-input",
1617
+ `sheet name "${name}" contains a forbidden character (\\ / ? * [ ] :)`
1618
+ );
1619
+ }
1620
+ if (!isXmlSafe(name)) {
1621
+ throw new XlsxError(
1622
+ "invalid-input",
1623
+ `sheet name "${name}" contains a character not allowed in XML`
1624
+ );
1625
+ }
1626
+ const key = name.toLowerCase();
1627
+ if (seen.has(key)) {
1628
+ throw new XlsxError(
1629
+ "invalid-input",
1630
+ `duplicate sheet name "${name}" (case-insensitive)`
1631
+ );
1632
+ }
1633
+ seen.add(key);
1634
+ if (!Array.isArray(sheet.rows)) {
1635
+ throw new XlsxError("invalid-input", `sheet "${name}": rows must be an array`);
1636
+ }
1637
+ }
1638
+ }
1639
+ async function writeXlsx(workbook, options) {
1640
+ validate(workbook);
1641
+ const date1904 = options?.date1904 === true;
1642
+ const sheets = workbook.sheets;
1643
+ const worksheets = sheets.map((sheet) => worksheetXml(sheet.rows, date1904));
1644
+ const anyDate = worksheets.some((w) => w.usesDate);
1645
+ const parts = [];
1646
+ const add = (name, xml) => {
1647
+ parts.push({ name, data: encoder2.encode(xml) });
1648
+ };
1649
+ const overrides = [
1650
+ `<Override PartName="/xl/workbook.xml" ContentType="${CT_BASE}.sheet.main+xml"/>`,
1651
+ ...sheets.map(
1652
+ (_, i) => `<Override PartName="/xl/worksheets/sheet${i + 1}.xml" ContentType="${CT_BASE}.worksheet+xml"/>`
1653
+ ),
1654
+ ...anyDate ? [`<Override PartName="/xl/styles.xml" ContentType="${CT_BASE}.styles+xml"/>`] : []
1655
+ ].join("");
1656
+ add(
1657
+ "[Content_Types].xml",
1658
+ `${XML_DECL2}
1659
+ <Types xmlns="${NS_CT}"><Default Extension="rels" ContentType="${CT_RELS}"/><Default Extension="xml" ContentType="application/xml"/>${overrides}</Types>`
1660
+ );
1661
+ add(
1662
+ "_rels/.rels",
1663
+ `${XML_DECL2}
1664
+ <Relationships xmlns="${NS_PKG_REL}"><Relationship Id="rId1" Type="${NS_REL}/officeDocument" Target="xl/workbook.xml"/></Relationships>`
1665
+ );
1666
+ const workbookPr = date1904 ? '<workbookPr date1904="1"/>' : "";
1667
+ const sheetsXml = sheets.map(
1668
+ (sheet, i) => `<sheet name="${escapeAttr(sheet.name)}" sheetId="${i + 1}" r:id="rId${i + 1}"/>`
1669
+ ).join("");
1670
+ add(
1671
+ "xl/workbook.xml",
1672
+ `${XML_DECL2}
1673
+ <workbook xmlns="${NS_MAIN2}" xmlns:r="${NS_REL}">${workbookPr}<sheets>${sheetsXml}</sheets></workbook>`
1674
+ );
1675
+ const relItems = [
1676
+ ...sheets.map(
1677
+ (_, i) => `<Relationship Id="rId${i + 1}" Type="${NS_REL}/worksheet" Target="worksheets/sheet${i + 1}.xml"/>`
1678
+ ),
1679
+ ...anyDate ? [
1680
+ `<Relationship Id="rId${sheets.length + 1}" Type="${NS_REL}/styles" Target="styles.xml"/>`
1681
+ ] : []
1682
+ ].join("");
1683
+ add(
1684
+ "xl/_rels/workbook.xml.rels",
1685
+ `${XML_DECL2}
1686
+ <Relationships xmlns="${NS_PKG_REL}">${relItems}</Relationships>`
1687
+ );
1688
+ if (anyDate) add("xl/styles.xml", STYLES_XML);
1689
+ worksheets.forEach((w, i) => {
1690
+ add(`xl/worksheets/sheet${i + 1}.xml`, w.xml);
1691
+ });
1692
+ return writeZip(parts);
1693
+ }
1694
+
1695
+ export { Workbook, Worksheet, XlsxError, columnToIndex, dateToSerial, formatRef, indexToColumn, openXlsx, parseRef, serialToDate, streamSheetRows, workbookToInput, writeXlsx };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@openjsxl/core",
3
- "version": "0.2.1",
4
- "description": "Core OOXML engine for openjsxl — zero-dependency .xlsx primitives (zip, xml, cell typing).",
3
+ "version": "0.3.0",
4
+ "description": "Core OOXML engine for openjsxl — zero-dependency .xlsx primitives (zip, xml, cell typing, read + write).",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "joaquimserafim (https://github.com/joaquimserafim)",