@ricsam/excel-to-formula-engine 0.1.1 → 0.2.1

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
  });
@@ -1592,6 +1600,238 @@ function coalesceCellsIntoAreas(sheetName, cells) {
1592
1600
  return areas;
1593
1601
  }
1594
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
+
1595
1835
  // index.ts
1596
1836
  async function excelToFormulaEngine(source, options) {
1597
1837
  const { data } = await convertWorkbook(source, withFileName(source, options));
@@ -1607,5 +1847,57 @@ function withFileName(source, options) {
1607
1847
  const name = typeof File !== "undefined" && source instanceof File ? source.name : undefined;
1608
1848
  return name ? { ...options, fileName: name } : { ...options };
1609
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
+ const fileName = options?.fileName ?? (typeof File !== "undefined" && source instanceof File ? source.name : undefined);
1866
+ if (looksLikeZip(bytes)) {
1867
+ return convertWorkbook(bytes, withFileName(source, options));
1868
+ }
1869
+ if (fileName && /\.(xlsx|xlsm)$/i.test(fileName)) {
1870
+ throw new Error(`${fileName} is named as an Excel workbook but is not one — the file is ` + `corrupt, or it is a legacy .xls saved under the wrong extension.`);
1871
+ }
1872
+ assertLooksLikeText(bytes, fileName);
1873
+ return csvToFormulaEngineDetailed(bytes, withCsvFileName(source, options));
1874
+ }
1875
+ function assertLooksLikeText(bytes, fileName) {
1876
+ const sample = bytes.subarray(0, 8192);
1877
+ if (sample.indexOf(0) !== -1) {
1878
+ throw new Error(`${fileName ?? "The file"} is not a spreadsheet: it contains binary data ` + `rather than delimited text.`);
1879
+ }
1880
+ }
1881
+ function looksLikeZip(bytes) {
1882
+ return bytes.length >= 4 && bytes[0] === 80 && bytes[1] === 75 && bytes[2] === 3 && bytes[3] === 4;
1883
+ }
1884
+ async function toBytes2(source) {
1885
+ if (source instanceof Uint8Array)
1886
+ return source;
1887
+ if (source instanceof ArrayBuffer)
1888
+ return new Uint8Array(source);
1889
+ return new Uint8Array(await source.arrayBuffer());
1890
+ }
1891
+ function withCsvFileName(source, options) {
1892
+ const name = typeof File !== "undefined" && source instanceof File ? source.name : undefined;
1893
+ const fileName = options?.fileName ?? name;
1894
+ if (!fileName)
1895
+ return { ...options };
1896
+ return {
1897
+ ...options,
1898
+ fileName,
1899
+ sheetName: options?.sheetName ?? (fileName.replace(/\.(csv|tsv|tab|txt)$/i, "").trim() || "Sheet1")
1900
+ };
1901
+ }
1610
1902
 
1611
- //# debugId=B8C6694D236575E064756E2164756E21
1903
+ //# debugId=5AA8F8A121F02B2D64756E2164756E21