@ricsam/excel-to-formula-engine 0.1.1 → 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 +50 -1
- package/dist/index.cjs +282 -1
- package/dist/index.cjs.map +6 -4
- package/dist/index.mjs +282 -1
- package/dist/index.mjs.map +6 -4
- package/dist/types/index.d.ts +27 -2
- package/dist/types/src/csv/convert.d.ts +31 -0
- package/dist/types/src/csv/parse.d.ts +40 -0
- package/dist/types/src/types.d.ts +42 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# excel-to-formula-engine
|
|
2
2
|
|
|
3
|
-
Convert
|
|
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,46 @@ 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
|
+
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
|
+
}
|
|
1610
1891
|
|
|
1611
|
-
//# debugId=
|
|
1892
|
+
//# debugId=E62FBA8DCEBAE6FE64756E2164756E21
|