@ricsam/excel-to-formula-engine 0.1.0 → 0.2.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
@@ -1,6 +1,7 @@
1
1
  # excel-to-formula-engine
2
2
 
3
- Convert an Excel `.xlsx` file into [`@ricsam/formula-engine`](https://github.com/ricsam/formula-engine)
3
+ Convert a spreadsheet file — `.xlsx`, `.csv` or `.tsv` — into
4
+ [`@ricsam/formula-engine`](https://github.com/ricsam/formula-engine)
4
5
  `WorkbookData`, in the browser.
5
6
 
6
7
  ```ts
@@ -22,6 +23,54 @@ external.
22
23
  bun add @ricsam/excel-to-formula-engine @ricsam/formula-engine
23
24
  ```
24
25
 
26
+ ## Delimited text
27
+
28
+ CSV and TSV go through `csvToFormulaEngine`, or through
29
+ `spreadsheetToFormulaEngine` when you do not know which kind of file you have —
30
+ a drop target, typically. That one settles the format from the bytes rather than
31
+ the file name, since `.xlsx` is a ZIP and delimited text is not, so a workbook
32
+ saved with a `.csv` extension still reads correctly.
33
+
34
+ ```ts
35
+ import {
36
+ csvToFormulaEngine,
37
+ spreadsheetToFormulaEngine
38
+ } from "@ricsam/excel-to-formula-engine";
39
+
40
+ // Delimiter is detected: comma, tab, semicolon or pipe.
41
+ const data = await csvToFormulaEngine(file);
42
+
43
+ // Or let it work out which kind of file this is.
44
+ const data = await spreadsheetToFormulaEngine(file);
45
+ ```
46
+
47
+ Parsing follows RFC 4180, so a quoted field may contain the delimiter, doubled
48
+ quotes, and line breaks. The UTF-8 BOM Excel writes on every CSV export is
49
+ stripped — left in, it becomes an invisible part of the first header cell.
50
+
51
+ ### What a bare string becomes
52
+
53
+ A CSV carries no types, so every cell is a judgement call. The rules err toward
54
+ keeping data recoverable:
55
+
56
+ | Field | Becomes | Why |
57
+ | --- | --- | --- |
58
+ | `42`, `-3.5`, `1.50`, `1e3` | number | Plain decimals. |
59
+ | `TRUE` / `false` | boolean | Any case, as in a spreadsheet. |
60
+ | `=B2*C2` | formula | What a spreadsheet does with the same file. |
61
+ | `007`, `01234` | **text** | Part numbers and postcodes — the mangling Excel is notorious for. |
62
+ | `12345678901234567890` | **text** | Beyond float precision; converting would silently change it. |
63
+ | `1,000`, `$5`, `50%` | **text** | Converting would drop the formatting. |
64
+ | `0x1f`, `Infinity` | **text** | `Number()` accepts these; a spreadsheet does not. |
65
+
66
+ Empty fields are left out rather than stored as empty strings, which is what
67
+ keeps a sparse sheet sparse.
68
+
69
+ There is deliberately no option to keep an `=`-leading field as text: the engine
70
+ stores a formula as a string beginning with `=` and has no escape for text that
71
+ merely looks like one, so such an option could only be honoured by altering the
72
+ value.
73
+
25
74
  ## Usage
26
75
 
27
76
  `excelToFormulaEngine` accepts a `Blob` or `File` straight from a file input, or
package/dist/index.cjs CHANGED
@@ -41,7 +41,11 @@ var exports_excel_to_formula_engine = {};
41
41
  __export(exports_excel_to_formula_engine, {
42
42
  columnIndexToLabel: () => columnIndexToLabel,
43
43
  columnLabelToIndex: () => columnLabelToIndex,
44
+ convertDelimitedText: () => convertDelimitedText,
44
45
  convertWorkbook: () => convertWorkbook,
46
+ csvToFormulaEngine: () => csvToFormulaEngine,
47
+ csvToFormulaEngineDetailed: () => csvToFormulaEngineDetailed,
48
+ detectDelimiter: () => detectDelimiter,
45
49
  excelSerialToIso: () => excelSerialToIso,
46
50
  excelToFormulaEngine: () => excelToFormulaEngine,
47
51
  excelToFormulaEngineDetailed: () => excelToFormulaEngineDetailed,
@@ -49,6 +53,10 @@ __export(exports_excel_to_formula_engine, {
49
53
  offsetFormulaReferences: () => offsetFormulaReferences,
50
54
  parseA1: () => parseA1,
51
55
  parseA1Range: () => parseA1Range,
56
+ parseDelimitedText: () => parseDelimitedText,
57
+ spreadsheetToFormulaEngine: () => spreadsheetToFormulaEngine,
58
+ spreadsheetToFormulaEngineDetailed: () => spreadsheetToFormulaEngineDetailed,
59
+ stripBom: () => stripBom,
52
60
  toA1: () => toA1,
53
61
  translateFormula: () => translateFormula
54
62
  });
@@ -961,12 +969,14 @@ function getSupportedFunctions() {
961
969
  return supportedFunctionNames;
962
970
  }
963
971
  var XLFN_PREFIX = /_xlfn\.(_xlws\.)?/g;
964
- var IMPLICIT_INTERSECTION = /(?<![\w$"'\]])@(?=[A-Za-z_\\])/g;
972
+ var IMPLICIT_INTERSECTION = /(?<![\w$"'\]\[])@(?=[A-Za-z_\\])/g;
973
+ var THIS_ROW_REFERENCE = /(?:'((?:[^']|'')+)'|([A-Za-z_\\][\w.\\]*))?\[\[#This ?Row\],\s*\[([^\]]*)\](?:\s*:\s*\[([^\]]*)\])?\]/gi;
965
974
  function translateFormula(rawFormula, options = {}) {
966
975
  const diagnostics = [];
967
976
  let body = rawFormula.startsWith("=") ? rawFormula.slice(1) : rawFormula;
968
977
  body = body.replace(XLFN_PREFIX, "");
969
978
  body = body.replace(IMPLICIT_INTERSECTION, "");
979
+ body = contractThisRowReferences(body, options.containingTableName);
970
980
  for (const name of findUnsupportedFunctions(body)) {
971
981
  diagnostics.push({
972
982
  severity: "warning",
@@ -1079,6 +1089,22 @@ function columnIndexToLabel2(index) {
1079
1089
  }
1080
1090
  return label;
1081
1091
  }
1092
+ function contractThisRowReferences(formulaBody, containingTableName) {
1093
+ return formulaBody.replace(THIS_ROW_REFERENCE, (whole, quotedTable, bareTable, startCol, endCol) => {
1094
+ const tableName = quotedTable ? String(quotedTable).replace(/''/g, "'") : bareTable ? String(bareTable) : undefined;
1095
+ const columns = endCol === undefined ? `[${startCol}]` : `[${startCol}]:[${endCol}]`;
1096
+ const isPlainSingleColumn = endCol === undefined && /^[^[\]@#'"]+$/.test(String(startCol));
1097
+ const reference = isPlainSingleColumn ? `@${startCol}` : `@${columns}`;
1098
+ if (tableName && containingTableName && tableName.toLowerCase() === containingTableName.toLowerCase()) {
1099
+ return `[${reference}]`;
1100
+ }
1101
+ if (!tableName) {
1102
+ return `[${reference}]`;
1103
+ }
1104
+ const quoted = quotedTable ? `'${String(quotedTable)}'` : tableName;
1105
+ return `${quoted}[${reference}]`;
1106
+ });
1107
+ }
1082
1108
 
1083
1109
  // src/translate/conditional-format.ts
1084
1110
  var import_formula_engine2 = require("@ricsam/formula-engine");
@@ -1309,6 +1335,9 @@ async function convertWorkbook(source, options = {}) {
1309
1335
  for (const entry of selected) {
1310
1336
  const worksheet = parseWorksheet(pkg, entry.partPath, sharedStrings);
1311
1337
  importedSheetNames.add(entry.name);
1338
+ const sheetTables = importTables ? parseTables(pkg, entry.partPath) : [];
1339
+ const tableRanges = sheetTables.map((table) => ({ name: table.displayName, range: parseA1Range(table.ref) })).filter((entryTable) => entryTable.range !== undefined);
1340
+ const tableAt = (colIndex, rowIndex) => tableRanges.find(({ range }) => colIndex >= range.start.colIndex && colIndex <= range.end.colIndex && rowIndex >= range.start.rowIndex && rowIndex <= range.end.rowIndex)?.name;
1312
1341
  const content = new Map;
1313
1342
  const cellMetadata = new Map;
1314
1343
  const styleGroups = new Map;
@@ -1318,7 +1347,8 @@ async function convertWorkbook(source, options = {}) {
1318
1347
  convertDates,
1319
1348
  date1904,
1320
1349
  sheetName: entry.name,
1321
- diagnostics
1350
+ diagnostics,
1351
+ containingTableName: tableAt(cell.colIndex, cell.rowIndex)
1322
1352
  });
1323
1353
  if (value !== undefined) {
1324
1354
  content.set(cell.reference, value);
@@ -1373,7 +1403,7 @@ async function convertWorkbook(source, options = {}) {
1373
1403
  }
1374
1404
  }
1375
1405
  if (importTables) {
1376
- for (const table of parseTables(pkg, entry.partPath)) {
1406
+ for (const table of sheetTables) {
1377
1407
  const converted = convertTable(table, entry.name, diagnostics);
1378
1408
  if (converted) {
1379
1409
  tables.push(converted);
@@ -1423,7 +1453,8 @@ function cellValue(cell, format, context) {
1423
1453
  const body = cell.formulaOffset ? offsetFormulaReferences(cell.formula, cell.formulaOffset.cols, cell.formulaOffset.rows) : cell.formula;
1424
1454
  const translated = translateFormula(body, {
1425
1455
  sheetName: context.sheetName,
1426
- cellReference: cell.reference
1456
+ cellReference: cell.reference,
1457
+ containingTableName: context.containingTableName
1427
1458
  });
1428
1459
  context.diagnostics.push(...translated.diagnostics);
1429
1460
  return translated.formula;
@@ -1569,6 +1600,238 @@ function coalesceCellsIntoAreas(sheetName, cells) {
1569
1600
  return areas;
1570
1601
  }
1571
1602
 
1603
+ // src/csv/parse.ts
1604
+ var CANDIDATE_DELIMITERS = [",", "\t", ";", "|"];
1605
+ function parseDelimitedText(text, delimiter) {
1606
+ const rows = [];
1607
+ let row = [];
1608
+ let field = "";
1609
+ let inQuotes = false;
1610
+ let fieldWasQuoted = false;
1611
+ let rowHasContent = false;
1612
+ const endField = () => {
1613
+ row.push(field);
1614
+ field = "";
1615
+ fieldWasQuoted = false;
1616
+ rowHasContent = true;
1617
+ };
1618
+ const endRow = () => {
1619
+ endField();
1620
+ rows.push(row);
1621
+ row = [];
1622
+ rowHasContent = false;
1623
+ };
1624
+ for (let i = 0;i < text.length; i++) {
1625
+ const char = text[i];
1626
+ if (inQuotes) {
1627
+ if (char === '"') {
1628
+ if (text[i + 1] === '"') {
1629
+ field += '"';
1630
+ i++;
1631
+ } else {
1632
+ inQuotes = false;
1633
+ }
1634
+ } else {
1635
+ field += char;
1636
+ }
1637
+ continue;
1638
+ }
1639
+ if (char === '"' && field.length === 0) {
1640
+ inQuotes = true;
1641
+ fieldWasQuoted = true;
1642
+ continue;
1643
+ }
1644
+ if (char === delimiter) {
1645
+ endField();
1646
+ continue;
1647
+ }
1648
+ if (char === "\r") {
1649
+ if (text[i + 1] === `
1650
+ `)
1651
+ i++;
1652
+ endRow();
1653
+ continue;
1654
+ }
1655
+ if (char === `
1656
+ `) {
1657
+ endRow();
1658
+ continue;
1659
+ }
1660
+ field += char;
1661
+ }
1662
+ if (field.length > 0 || fieldWasQuoted || rowHasContent) {
1663
+ endRow();
1664
+ }
1665
+ return rows;
1666
+ }
1667
+ function detectDelimiter(text, candidates = CANDIDATE_DELIMITERS) {
1668
+ const lines = takeSampleLines(text, 20);
1669
+ if (lines.length === 0)
1670
+ return ",";
1671
+ let best;
1672
+ for (const delimiter of candidates) {
1673
+ const counts = lines.map((line) => countOutsideQuotes(line, delimiter));
1674
+ const present = counts.filter((count) => count > 0);
1675
+ if (present.length === 0)
1676
+ continue;
1677
+ const first = counts[0];
1678
+ const consistent = counts.filter((count) => count === first).length;
1679
+ const score = consistent / counts.length + Math.min(first, 50) / 1000;
1680
+ if (!best || score > best.score) {
1681
+ best = { delimiter, score };
1682
+ }
1683
+ }
1684
+ return best?.delimiter ?? ",";
1685
+ }
1686
+ function takeSampleLines(text, limit) {
1687
+ const lines = [];
1688
+ let current = "";
1689
+ let inQuotes = false;
1690
+ for (let i = 0;i < text.length && lines.length < limit; i++) {
1691
+ const char = text[i];
1692
+ if (char === '"') {
1693
+ if (inQuotes && text[i + 1] === '"') {
1694
+ current += '""';
1695
+ i++;
1696
+ continue;
1697
+ }
1698
+ inQuotes = !inQuotes;
1699
+ current += char;
1700
+ continue;
1701
+ }
1702
+ if (!inQuotes && (char === `
1703
+ ` || char === "\r")) {
1704
+ if (char === "\r" && text[i + 1] === `
1705
+ `)
1706
+ i++;
1707
+ if (current.trim().length > 0)
1708
+ lines.push(current);
1709
+ current = "";
1710
+ continue;
1711
+ }
1712
+ current += char;
1713
+ }
1714
+ if (current.trim().length > 0 && lines.length < limit)
1715
+ lines.push(current);
1716
+ return lines;
1717
+ }
1718
+ function countOutsideQuotes(line, delimiter) {
1719
+ let count = 0;
1720
+ let inQuotes = false;
1721
+ for (let i = 0;i < line.length; i++) {
1722
+ const char = line[i];
1723
+ if (char === '"') {
1724
+ if (inQuotes && line[i + 1] === '"') {
1725
+ i++;
1726
+ continue;
1727
+ }
1728
+ inQuotes = !inQuotes;
1729
+ continue;
1730
+ }
1731
+ if (!inQuotes && char === delimiter)
1732
+ count++;
1733
+ }
1734
+ return count;
1735
+ }
1736
+ function stripBom(text) {
1737
+ return text.charCodeAt(0) === 65279 ? text.slice(1) : text;
1738
+ }
1739
+
1740
+ // src/csv/convert.ts
1741
+ var MAX_CELLS = 2000000;
1742
+ function convertDelimitedText(text, options = {}) {
1743
+ const {
1744
+ sheetName = "Sheet1",
1745
+ fileName,
1746
+ parseNumbers = true,
1747
+ parseBooleans = true,
1748
+ trimFields = false
1749
+ } = options;
1750
+ const diagnostics = [];
1751
+ const body = stripBom(text);
1752
+ const delimiter = options.delimiter ?? detectDelimiter(body);
1753
+ const rows = parseDelimitedText(body, delimiter);
1754
+ const widest = rows.reduce((max, row) => Math.max(max, row.length), 0);
1755
+ if (rows.length * widest > MAX_CELLS) {
1756
+ throw new Error(`The file has about ${rows.length * widest} cells, more than the ` + `${MAX_CELLS} this importer will load at once.`);
1757
+ }
1758
+ const content = new Map;
1759
+ for (let rowIndex = 0;rowIndex < rows.length; rowIndex++) {
1760
+ const row = rows[rowIndex];
1761
+ for (let colIndex = 0;colIndex < row.length; colIndex++) {
1762
+ const raw = trimFields ? row[colIndex].trim() : row[colIndex];
1763
+ if (raw.length === 0)
1764
+ continue;
1765
+ const reference = `${columnIndexToLabel(colIndex)}${rowIndex + 1}`;
1766
+ content.set(reference, toCellValue(raw, {
1767
+ parseNumbers,
1768
+ parseBooleans,
1769
+ sheetName,
1770
+ reference,
1771
+ diagnostics
1772
+ }));
1773
+ }
1774
+ }
1775
+ const data = {
1776
+ sheets: [{ name: sheetName, content }],
1777
+ workbookMetadata: {
1778
+ fileName,
1779
+ sheetOrder: [sheetName],
1780
+ diagnostics: diagnostics.length > 0 ? diagnostics : undefined
1781
+ }
1782
+ };
1783
+ return { data, diagnostics };
1784
+ }
1785
+ function toCellValue(raw, context) {
1786
+ if (raw.startsWith("=") && raw.length > 1) {
1787
+ const translated = translateFormula(raw, {
1788
+ sheetName: context.sheetName,
1789
+ cellReference: context.reference
1790
+ });
1791
+ context.diagnostics.push(...translated.diagnostics);
1792
+ return translated.formula;
1793
+ }
1794
+ if (context.parseBooleans) {
1795
+ const upper = raw.toUpperCase();
1796
+ if (upper === "TRUE")
1797
+ return true;
1798
+ if (upper === "FALSE")
1799
+ return false;
1800
+ }
1801
+ if (context.parseNumbers) {
1802
+ const number = toNumber(raw);
1803
+ if (number !== undefined)
1804
+ return number;
1805
+ }
1806
+ return raw;
1807
+ }
1808
+ function toNumber(raw) {
1809
+ const text = raw.trim();
1810
+ if (text.length === 0)
1811
+ return;
1812
+ if (!/^[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?$/.test(text)) {
1813
+ return;
1814
+ }
1815
+ const digits = text.replace(/^[+-]/, "");
1816
+ if (/^0\d/.test(digits))
1817
+ return;
1818
+ const value = Number(text);
1819
+ if (!Number.isFinite(value))
1820
+ return;
1821
+ if (!text.includes(".") && !/[eE]/.test(text) && !Number.isSafeInteger(value)) {
1822
+ return;
1823
+ }
1824
+ return value;
1825
+ }
1826
+ async function readTextFrom(source) {
1827
+ if (typeof source === "string")
1828
+ return source;
1829
+ if (source instanceof Uint8Array || source instanceof ArrayBuffer) {
1830
+ return new TextDecoder("utf-8").decode(source);
1831
+ }
1832
+ return source.text();
1833
+ }
1834
+
1572
1835
  // index.ts
1573
1836
  async function excelToFormulaEngine(source, options) {
1574
1837
  const { data } = await convertWorkbook(source, withFileName(source, options));
@@ -1584,5 +1847,46 @@ function withFileName(source, options) {
1584
1847
  const name = typeof File !== "undefined" && source instanceof File ? source.name : undefined;
1585
1848
  return name ? { ...options, fileName: name } : { ...options };
1586
1849
  }
1850
+ async function csvToFormulaEngine(source, options) {
1851
+ const { data } = await csvToFormulaEngineDetailed(source, options);
1852
+ return data;
1853
+ }
1854
+ async function csvToFormulaEngineDetailed(source, options) {
1855
+ const text = await readTextFrom(source);
1856
+ const resolved = withCsvFileName(source, options);
1857
+ return convertDelimitedText(text, resolved);
1858
+ }
1859
+ async function spreadsheetToFormulaEngine(source, options) {
1860
+ const { data } = await spreadsheetToFormulaEngineDetailed(source, options);
1861
+ return data;
1862
+ }
1863
+ async function spreadsheetToFormulaEngineDetailed(source, options) {
1864
+ const bytes = await toBytes2(source);
1865
+ if (looksLikeZip(bytes)) {
1866
+ return convertWorkbook(bytes, withFileName(source, options));
1867
+ }
1868
+ return csvToFormulaEngineDetailed(bytes, withCsvFileName(source, options));
1869
+ }
1870
+ function looksLikeZip(bytes) {
1871
+ return bytes.length >= 4 && bytes[0] === 80 && bytes[1] === 75 && bytes[2] === 3 && bytes[3] === 4;
1872
+ }
1873
+ async function toBytes2(source) {
1874
+ if (source instanceof Uint8Array)
1875
+ return source;
1876
+ if (source instanceof ArrayBuffer)
1877
+ return new Uint8Array(source);
1878
+ return new Uint8Array(await source.arrayBuffer());
1879
+ }
1880
+ function withCsvFileName(source, options) {
1881
+ const name = typeof File !== "undefined" && source instanceof File ? source.name : undefined;
1882
+ const fileName = options?.fileName ?? name;
1883
+ if (!fileName)
1884
+ return { ...options };
1885
+ return {
1886
+ ...options,
1887
+ fileName,
1888
+ sheetName: options?.sheetName ?? (fileName.replace(/\.(csv|tsv|tab|txt)$/i, "").trim() || "Sheet1")
1889
+ };
1890
+ }
1587
1891
 
1588
- //# debugId=C50C41CAFB8A948064756E2164756E21
1892
+ //# debugId=E62FBA8DCEBAE6FE64756E2164756E21