india2actual 0.1.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/.env.example +23 -0
- package/LICENSE +21 -0
- package/README.md +280 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +295 -0
- package/dist/cli.js.map +1 -0
- package/dist/env-file.d.ts +38 -0
- package/dist/env-file.js +40 -0
- package/dist/env-file.js.map +1 -0
- package/dist/extract/csv.d.ts +13 -0
- package/dist/extract/csv.js +60 -0
- package/dist/extract/csv.js.map +1 -0
- package/dist/extract/html-table.d.ts +3 -0
- package/dist/extract/html-table.js +77 -0
- package/dist/extract/html-table.js.map +1 -0
- package/dist/extract/index.d.ts +21 -0
- package/dist/extract/index.js +44 -0
- package/dist/extract/index.js.map +1 -0
- package/dist/extract/pdf.d.ts +47 -0
- package/dist/extract/pdf.js +439 -0
- package/dist/extract/pdf.js.map +1 -0
- package/dist/extract/sniff.d.ts +12 -0
- package/dist/extract/sniff.js +57 -0
- package/dist/extract/sniff.js.map +1 -0
- package/dist/extract/spreadsheetml.d.ts +3 -0
- package/dist/extract/spreadsheetml.js +93 -0
- package/dist/extract/spreadsheetml.js.map +1 -0
- package/dist/extract/types.d.ts +20 -0
- package/dist/extract/types.js +2 -0
- package/dist/extract/types.js.map +1 -0
- package/dist/extract/xlsx.d.ts +2 -0
- package/dist/extract/xlsx.js +64 -0
- package/dist/extract/xlsx.js.map +1 -0
- package/dist/interpret/header.d.ts +24 -0
- package/dist/interpret/header.js +57 -0
- package/dist/interpret/header.js.map +1 -0
- package/dist/interpret/roundtrip.d.ts +29 -0
- package/dist/interpret/roundtrip.js +89 -0
- package/dist/interpret/roundtrip.js.map +1 -0
- package/dist/interpret/rows.d.ts +54 -0
- package/dist/interpret/rows.js +151 -0
- package/dist/interpret/rows.js.map +1 -0
- package/dist/interpret/synonyms.d.ts +27 -0
- package/dist/interpret/synonyms.js +55 -0
- package/dist/interpret/synonyms.js.map +1 -0
- package/dist/interpret/validate.d.ts +27 -0
- package/dist/interpret/validate.js +93 -0
- package/dist/interpret/validate.js.map +1 -0
- package/dist/interpret/values.d.ts +22 -0
- package/dist/interpret/values.js +131 -0
- package/dist/interpret/values.js.map +1 -0
- package/dist/merchants-file.d.ts +14 -0
- package/dist/merchants-file.js +40 -0
- package/dist/merchants-file.js.map +1 -0
- package/dist/narration/merchants.d.ts +46 -0
- package/dist/narration/merchants.js +161 -0
- package/dist/narration/merchants.js.map +1 -0
- package/dist/narration/parse.d.ts +14 -0
- package/dist/narration/parse.js +659 -0
- package/dist/narration/parse.js.map +1 -0
- package/dist/narration/types.d.ts +25 -0
- package/dist/narration/types.js +2 -0
- package/dist/narration/types.js.map +1 -0
- package/dist/out/csv.d.ts +21 -0
- package/dist/out/csv.js +40 -0
- package/dist/out/csv.js.map +1 -0
- package/dist/out/push.d.ts +74 -0
- package/dist/out/push.js +103 -0
- package/dist/out/push.js.map +1 -0
- package/package.json +65 -0
package/dist/env-file.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { env, loadEnvFile } from 'node:process';
|
|
2
|
+
/**
|
|
3
|
+
* A missing file is not an error: the CSV path needs no configuration, so most
|
|
4
|
+
* runs legitimately have no `.env` at all. Anything else — unreadable, a
|
|
5
|
+
* directory, malformed contents — is reported, because it means the user
|
|
6
|
+
* believes they have configured something that is in fact being ignored. That
|
|
7
|
+
* would otherwise surface much later as a confusing "ACTUAL_SERVER_URL is not
|
|
8
|
+
* set".
|
|
9
|
+
*/
|
|
10
|
+
export function loadEnvironmentFile(path) {
|
|
11
|
+
try {
|
|
12
|
+
loadEnvFile(path);
|
|
13
|
+
return { loaded: true, path };
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
if (isMissingFile(error)) {
|
|
17
|
+
return { loaded: false, path, reason: 'missing' };
|
|
18
|
+
}
|
|
19
|
+
throw new Error(`Could not read ${path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Read a setting, treating blank as absent.
|
|
24
|
+
*
|
|
25
|
+
* `.env.example` ships its keys with empty values for the user to fill in, so
|
|
26
|
+
* a copied-but-unedited file leaves `ACTUAL_DATA_DIR=` set to the empty
|
|
27
|
+
* string. The empty string is not nullish, so a `??` default would not fire
|
|
28
|
+
* and the Actual API would be handed `''` as its data directory. Trimming also
|
|
29
|
+
* catches the trailing space left by `ACTUAL_SYNC_ID= ` .
|
|
30
|
+
*/
|
|
31
|
+
export function setting(name) {
|
|
32
|
+
return env[name]?.trim() || undefined;
|
|
33
|
+
}
|
|
34
|
+
function isMissingFile(error) {
|
|
35
|
+
return (typeof error === 'object' &&
|
|
36
|
+
error !== null &&
|
|
37
|
+
'code' in error &&
|
|
38
|
+
error.code === 'ENOENT');
|
|
39
|
+
}
|
|
40
|
+
//# sourceMappingURL=env-file.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"env-file.js","sourceRoot":"","sources":["../src/env-file.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,GAAG,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAkBhD;;;;;;;GAOG;AACH,MAAM,UAAU,mBAAmB,CAAC,IAAY;IAC9C,IAAI,CAAC;QACH,WAAW,CAAC,IAAI,CAAC,CAAC;QAClB,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC;IAChC,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,IAAI,aAAa,CAAC,KAAK,CAAC,EAAE,CAAC;YACzB,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,CAAC;QACpD,CAAC;QAED,MAAM,IAAI,KAAK,CACb,kBAAkB,IAAI,KAAK,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,CACpF,CAAC;IACJ,CAAC;AACH,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,OAAO,CAAC,IAAY;IAClC,OAAO,GAAG,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,IAAI,SAAS,CAAC;AACxC,CAAC;AAED,SAAS,aAAa,CAAC,KAAc;IACnC,OAAO,CACL,OAAO,KAAK,KAAK,QAAQ;QACzB,KAAK,KAAK,IAAI;QACd,MAAM,IAAI,KAAK;QACd,KAA4B,CAAC,IAAI,KAAK,QAAQ,CAChD,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Table } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Guess the delimiter by counting candidates across the first few lines.
|
|
4
|
+
*
|
|
5
|
+
* Counting per-line and taking the most *consistent* candidate rather than the
|
|
6
|
+
* most frequent one matters here: Indian statements carry preamble lines
|
|
7
|
+
* (branch address, account holder name) whose punctuation would otherwise
|
|
8
|
+
* outvote the real delimiter.
|
|
9
|
+
*/
|
|
10
|
+
export declare function sniffDelimiter(contents: string): string;
|
|
11
|
+
export declare function extractCsv(path: string, options?: {
|
|
12
|
+
delimiter?: string;
|
|
13
|
+
}): Promise<Table>;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { parse } from 'csv-parse/sync';
|
|
3
|
+
const CANDIDATE_DELIMITERS = [',', '\t', ';', '|'];
|
|
4
|
+
/**
|
|
5
|
+
* Guess the delimiter by counting candidates across the first few lines.
|
|
6
|
+
*
|
|
7
|
+
* Counting per-line and taking the most *consistent* candidate rather than the
|
|
8
|
+
* most frequent one matters here: Indian statements carry preamble lines
|
|
9
|
+
* (branch address, account holder name) whose punctuation would otherwise
|
|
10
|
+
* outvote the real delimiter.
|
|
11
|
+
*/
|
|
12
|
+
export function sniffDelimiter(contents) {
|
|
13
|
+
const lines = contents
|
|
14
|
+
.split(/\r?\n/)
|
|
15
|
+
.filter(line => line.trim())
|
|
16
|
+
.slice(0, 30);
|
|
17
|
+
let best = ',';
|
|
18
|
+
let bestScore = -1;
|
|
19
|
+
for (const delimiter of CANDIDATE_DELIMITERS) {
|
|
20
|
+
const counts = lines
|
|
21
|
+
.map(line => line.split(delimiter).length - 1)
|
|
22
|
+
.filter(count => count > 0);
|
|
23
|
+
if (counts.length < 2) {
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
// Reward the delimiter that appears the same number of times on the most
|
|
27
|
+
// lines — that is the signature of a real column structure.
|
|
28
|
+
const tally = new Map();
|
|
29
|
+
for (const count of counts) {
|
|
30
|
+
tally.set(count, (tally.get(count) ?? 0) + 1);
|
|
31
|
+
}
|
|
32
|
+
for (const [count, lineCount] of tally) {
|
|
33
|
+
const score = count * lineCount;
|
|
34
|
+
if (score > bestScore) {
|
|
35
|
+
bestScore = score;
|
|
36
|
+
best = delimiter;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return best;
|
|
41
|
+
}
|
|
42
|
+
export async function extractCsv(path, options = {}) {
|
|
43
|
+
const contents = await readFile(path, 'utf8');
|
|
44
|
+
const delimiter = options.delimiter ?? sniffDelimiter(contents);
|
|
45
|
+
const rows = parse(contents, {
|
|
46
|
+
delimiter,
|
|
47
|
+
bom: true,
|
|
48
|
+
// Statement preambles and footers have different column counts than the
|
|
49
|
+
// transaction table, so ragged rows are expected, not an error.
|
|
50
|
+
relax_column_count: true,
|
|
51
|
+
relax_quotes: true,
|
|
52
|
+
skip_empty_lines: true,
|
|
53
|
+
trim: true,
|
|
54
|
+
});
|
|
55
|
+
return {
|
|
56
|
+
rows: rows.map(row => row.map(cell => (cell ?? '').toString().trim())),
|
|
57
|
+
source: { path, format: 'csv' },
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
//# sourceMappingURL=csv.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"csv.js","sourceRoot":"","sources":["../../src/extract/csv.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAE5C,OAAO,EAAE,KAAK,EAAE,MAAM,gBAAgB,CAAC;AAIvC,MAAM,oBAAoB,GAAG,CAAC,GAAG,EAAE,IAAI,EAAE,GAAG,EAAE,GAAG,CAAU,CAAC;AAE5D;;;;;;;GAOG;AACH,MAAM,UAAU,cAAc,CAAC,QAAgB;IAC7C,MAAM,KAAK,GAAG,QAAQ;SACnB,KAAK,CAAC,OAAO,CAAC;SACd,MAAM,CAAC,IAAI,CAAC,EAAE,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC;SAC3B,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;IAEhB,IAAI,IAAI,GAAG,GAAG,CAAC;IACf,IAAI,SAAS,GAAG,CAAC,CAAC,CAAC;IAEnB,KAAK,MAAM,SAAS,IAAI,oBAAoB,EAAE,CAAC;QAC7C,MAAM,MAAM,GAAG,KAAK;aACjB,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC,IAAI,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC;aAC7C,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC,KAAK,GAAG,CAAC,CAAC,CAAC;QAE9B,IAAI,MAAM,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;YACtB,SAAS;QACX,CAAC;QAED,yEAAyE;QACzE,4DAA4D;QAC5D,MAAM,KAAK,GAAG,IAAI,GAAG,EAAkB,CAAC;QACxC,KAAK,MAAM,KAAK,IAAI,MAAM,EAAE,CAAC;YAC3B,KAAK,CAAC,GAAG,CAAC,KAAK,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;QAChD,CAAC;QAED,KAAK,MAAM,CAAC,KAAK,EAAE,SAAS,CAAC,IAAI,KAAK,EAAE,CAAC;YACvC,MAAM,KAAK,GAAG,KAAK,GAAG,SAAS,CAAC;YAChC,IAAI,KAAK,GAAG,SAAS,EAAE,CAAC;gBACtB,SAAS,GAAG,KAAK,CAAC;gBAClB,IAAI,GAAG,SAAS,CAAC;YACnB,CAAC;QACH,CAAC;IACH,CAAC;IAED,OAAO,IAAI,CAAC;AACd,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,UAAU,CAC9B,IAAY,EACZ,UAAkC,EAAE;IAEpC,MAAM,QAAQ,GAAG,MAAM,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;IAC9C,MAAM,SAAS,GAAG,OAAO,CAAC,SAAS,IAAI,cAAc,CAAC,QAAQ,CAAC,CAAC;IAEhE,MAAM,IAAI,GAAG,KAAK,CAAC,QAAQ,EAAE;QAC3B,SAAS;QACT,GAAG,EAAE,IAAI;QACT,wEAAwE;QACxE,gEAAgE;QAChE,kBAAkB,EAAE,IAAI;QACxB,YAAY,EAAE,IAAI;QAClB,gBAAgB,EAAE,IAAI;QACtB,IAAI,EAAE,IAAI;KACX,CAAe,CAAC;IAEjB,OAAO;QACL,IAAI,EAAE,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC,QAAQ,EAAE,CAAC,IAAI,EAAE,CAAC,CAAC;QACtE,MAAM,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE;KAChC,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { parse } from 'node-html-parser';
|
|
3
|
+
const ENTITIES = {
|
|
4
|
+
' ': ' ',
|
|
5
|
+
'&': '&',
|
|
6
|
+
'<': '<',
|
|
7
|
+
'>': '>',
|
|
8
|
+
'"': '"',
|
|
9
|
+
''': "'",
|
|
10
|
+
''': "'",
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Bank exports lean on ` ` for empty cells and `&` in merchant names,
|
|
14
|
+
* and an undecoded ` ` is not whitespace — it would survive a `.trim()`
|
|
15
|
+
* and make an empty cell look populated.
|
|
16
|
+
*/
|
|
17
|
+
function decodeEntities(value) {
|
|
18
|
+
return value
|
|
19
|
+
.replace(/ |&|<|>|"|'|'/g, match => ENTITIES[match] ?? match)
|
|
20
|
+
.replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code)))
|
|
21
|
+
.replace(/&#x([0-9a-f]+);/gi, (_, code) => String.fromCodePoint(Number.parseInt(code, 16)));
|
|
22
|
+
}
|
|
23
|
+
function cellsOf(row) {
|
|
24
|
+
const cells = [];
|
|
25
|
+
for (const node of row.childNodes) {
|
|
26
|
+
const element = node;
|
|
27
|
+
const tag = element.tagName?.toUpperCase();
|
|
28
|
+
if (tag !== 'TD' && tag !== 'TH') {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
const text = decodeEntities(element.textContent ?? '')
|
|
32
|
+
.replace(/\s+/g, ' ')
|
|
33
|
+
.trim();
|
|
34
|
+
cells.push(text);
|
|
35
|
+
// Expand colspan so later columns keep their index. Without this a
|
|
36
|
+
// merged header cell shifts every column after it by one, and amounts get
|
|
37
|
+
// read out of the wrong column.
|
|
38
|
+
const span = Number.parseInt(element.getAttribute('colspan') ?? '1', 10);
|
|
39
|
+
for (let extra = 1; extra < (Number.isFinite(span) ? span : 1); extra += 1) {
|
|
40
|
+
cells.push('');
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return cells;
|
|
44
|
+
}
|
|
45
|
+
function rowsOf(table) {
|
|
46
|
+
return table.querySelectorAll('tr').map(cellsOf);
|
|
47
|
+
}
|
|
48
|
+
/** Rows with enough populated cells to plausibly be transaction data. */
|
|
49
|
+
function density(rows) {
|
|
50
|
+
return rows.filter(row => row.filter(cell => cell.trim()).length >= 3).length;
|
|
51
|
+
}
|
|
52
|
+
export function tableFromHtml(html, path = 'inline') {
|
|
53
|
+
const root = parse(html);
|
|
54
|
+
// Bank exports nest the data table inside layout tables. No special handling
|
|
55
|
+
// is needed for that: `querySelectorAll('tr')` walks descendants, so an
|
|
56
|
+
// outer table's row list already contains the inner table's rows, each
|
|
57
|
+
// parsed from its own cells. Picking the table with the most dense rows
|
|
58
|
+
// therefore lands on a row set containing the real data, and the surrounding
|
|
59
|
+
// layout rows are dropped later by requiring a parseable date and amount.
|
|
60
|
+
const candidates = root.querySelectorAll('table');
|
|
61
|
+
let best = [];
|
|
62
|
+
let bestDensity = -1;
|
|
63
|
+
for (const candidate of candidates) {
|
|
64
|
+
const rows = rowsOf(candidate);
|
|
65
|
+
const score = density(rows);
|
|
66
|
+
if (score > bestDensity) {
|
|
67
|
+
bestDensity = score;
|
|
68
|
+
best = rows;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return { rows: best, source: { path, format: 'xlsx', part: 'html-table' } };
|
|
72
|
+
}
|
|
73
|
+
export async function extractHtmlTable(path) {
|
|
74
|
+
const contents = await readFile(path, 'utf8');
|
|
75
|
+
return tableFromHtml(contents, path);
|
|
76
|
+
}
|
|
77
|
+
//# sourceMappingURL=html-table.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"html-table.js","sourceRoot":"","sources":["../../src/extract/html-table.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { describeFormat, detectFormat } from './sniff.js';
|
|
2
|
+
import type { DetectedFormat } from './sniff.js';
|
|
3
|
+
import type { Table } from './types.js';
|
|
4
|
+
export type ExtractOptions = {
|
|
5
|
+
/** Only used for delimited text. */
|
|
6
|
+
delimiter?: string;
|
|
7
|
+
/** Only used for encrypted PDFs. */
|
|
8
|
+
password?: string;
|
|
9
|
+
};
|
|
10
|
+
export type Extraction = {
|
|
11
|
+
table: Table;
|
|
12
|
+
/** What the file turned out to be, which is often not what it is named. */
|
|
13
|
+
format: DetectedFormat;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Read a statement file into a table, choosing the parser by inspecting the
|
|
17
|
+
* contents rather than the extension.
|
|
18
|
+
*/
|
|
19
|
+
export declare function extractTable(path: string, options?: ExtractOptions): Promise<Extraction>;
|
|
20
|
+
export { describeFormat, detectFormat };
|
|
21
|
+
export type { DetectedFormat, Table };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { extractCsv } from './csv.js';
|
|
3
|
+
import { extractHtmlTable } from './html-table.js';
|
|
4
|
+
import { extractPdf } from './pdf.js';
|
|
5
|
+
import { describeFormat, detectFormat } from './sniff.js';
|
|
6
|
+
import { extractSpreadsheetMl } from './spreadsheetml.js';
|
|
7
|
+
import { extractXlsx } from './xlsx.js';
|
|
8
|
+
/**
|
|
9
|
+
* Read a statement file into a table, choosing the parser by inspecting the
|
|
10
|
+
* contents rather than the extension.
|
|
11
|
+
*/
|
|
12
|
+
export async function extractTable(path, options = {}) {
|
|
13
|
+
const buffer = await readFile(path);
|
|
14
|
+
const format = detectFormat(buffer);
|
|
15
|
+
switch (format) {
|
|
16
|
+
case 'xlsx':
|
|
17
|
+
return { table: await extractXlsx(path), format };
|
|
18
|
+
case 'html':
|
|
19
|
+
return { table: await extractHtmlTable(path), format };
|
|
20
|
+
case 'spreadsheetml':
|
|
21
|
+
return { table: await extractSpreadsheetMl(path), format };
|
|
22
|
+
case 'pdf':
|
|
23
|
+
return {
|
|
24
|
+
table: await extractPdf(path, options.password ? { password: options.password } : {}),
|
|
25
|
+
format,
|
|
26
|
+
};
|
|
27
|
+
case 'biff':
|
|
28
|
+
// Legacy BIFF has no maintained, permissively licensed reader for Node,
|
|
29
|
+
// and re-saving is a one-step fix, so this stays an explicit refusal
|
|
30
|
+
// rather than a half-working parser.
|
|
31
|
+
throw new Error(`${path} is a ${describeFormat(format)}, which is not supported.\n` +
|
|
32
|
+
'Open it in Excel or LibreOffice and "Save As" .xlsx or .csv, then ' +
|
|
33
|
+
'run this again. If your bank offers CSV through internet banking, ' +
|
|
34
|
+
'that is the most reliable option.');
|
|
35
|
+
case 'text':
|
|
36
|
+
default:
|
|
37
|
+
return {
|
|
38
|
+
table: await extractCsv(path, options.delimiter ? { delimiter: options.delimiter } : {}),
|
|
39
|
+
format: 'text',
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export { describeFormat, detectFormat };
|
|
44
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/extract/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAE5C,OAAO,EAAE,UAAU,EAAE,MAAM,UAAU,CAAC;AACtC,OAAO,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AACnD,OAAO,EAAE,UAAU,EAAE,MAAM,UAAU,CAAC;AACtC,OAAO,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAE1D,OAAO,EAAE,oBAAoB,EAAE,MAAM,oBAAoB,CAAC;AAE1D,OAAO,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC;AAexC;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,YAAY,CAChC,IAAY,EACZ,UAA0B,EAAE;IAE5B,MAAM,MAAM,GAAG,MAAM,QAAQ,CAAC,IAAI,CAAC,CAAC;IACpC,MAAM,MAAM,GAAG,YAAY,CAAC,MAAM,CAAC,CAAC;IAEpC,QAAQ,MAAM,EAAE,CAAC;QACf,KAAK,MAAM;YACT,OAAO,EAAE,KAAK,EAAE,MAAM,WAAW,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;QACpD,KAAK,MAAM;YACT,OAAO,EAAE,KAAK,EAAE,MAAM,gBAAgB,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;QACzD,KAAK,eAAe;YAClB,OAAO,EAAE,KAAK,EAAE,MAAM,oBAAoB,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;QAC7D,KAAK,KAAK;YACR,OAAO;gBACL,KAAK,EAAE,MAAM,UAAU,CACrB,IAAI,EACJ,OAAO,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,OAAO,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC,EAAE,CACvD;gBACD,MAAM;aACP,CAAC;QACJ,KAAK,MAAM;YACT,wEAAwE;YACxE,qEAAqE;YACrE,qCAAqC;YACrC,MAAM,IAAI,KAAK,CACb,GAAG,IAAI,SAAS,cAAc,CAAC,MAAM,CAAC,6BAA6B;gBACjE,oEAAoE;gBACpE,oEAAoE;gBACpE,mCAAmC,CACtC,CAAC;QACJ,KAAK,MAAM,CAAC;QACZ;YACE,OAAO;gBACL,KAAK,EAAE,MAAM,UAAU,CACrB,IAAI,EACJ,OAAO,CAAC,SAAS,CAAC,CAAC,CAAC,EAAE,SAAS,EAAE,OAAO,CAAC,SAAS,EAAE,CAAC,CAAC,CAAC,EAAE,CAC1D;gBACD,MAAM,EAAE,MAAM;aACf,CAAC;IACN,CAAC;AACH,CAAC;AAED,OAAO,EAAE,cAAc,EAAE,YAAY,EAAE,CAAC"}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Table } from './types.js';
|
|
2
|
+
export type PdfExtractOptions = {
|
|
3
|
+
password?: string;
|
|
4
|
+
};
|
|
5
|
+
export type Item = {
|
|
6
|
+
x: number;
|
|
7
|
+
right: number;
|
|
8
|
+
y: number;
|
|
9
|
+
text: string;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* `page` matters for more than reporting: y coordinates restart on every page,
|
|
13
|
+
* so two lines' y values are only comparable within the same page.
|
|
14
|
+
*/
|
|
15
|
+
export type Line = {
|
|
16
|
+
y: number;
|
|
17
|
+
page: number;
|
|
18
|
+
items: Item[];
|
|
19
|
+
};
|
|
20
|
+
/** A column, as the horizontal span its text occupies. */
|
|
21
|
+
export type Band = {
|
|
22
|
+
left: number;
|
|
23
|
+
right: number;
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Infer columns from the vertical whitespace that separates them.
|
|
27
|
+
*
|
|
28
|
+
* Every column of a statement table is divided from its neighbour by a gutter
|
|
29
|
+
* that stays empty on every row, so accumulating the horizontal extent of all
|
|
30
|
+
* table text and splitting on runs of emptiness recovers the columns directly.
|
|
31
|
+
*
|
|
32
|
+
* This is markedly more robust than comparing where text starts or merging
|
|
33
|
+
* overlapping spans, both of which were tried first and failed on real files:
|
|
34
|
+
*
|
|
35
|
+
* - Start positions alone split a right-aligned amount from its own header,
|
|
36
|
+
* which can begin 36 points to its left.
|
|
37
|
+
* - Merging overlapping spans bridges columns whenever a header label is wider
|
|
38
|
+
* than the column spacing. Federal packs columns 45 points apart with labels
|
|
39
|
+
* nearly that wide, collapsing `Withdrawals` and `Deposits` into one cell.
|
|
40
|
+
*
|
|
41
|
+
* Only header and dated rows take part. Wrapped narration lines and page
|
|
42
|
+
* furniture would otherwise bridge gutters — ICICI's footer URL spans the gap
|
|
43
|
+
* between the serial-number and date columns.
|
|
44
|
+
*/
|
|
45
|
+
export declare function inferBands(lines: Line[]): Band[];
|
|
46
|
+
export declare function assembleRows(lines: Line[], bands: Band[]): string[][];
|
|
47
|
+
export declare function extractPdf(path: string, options?: PdfExtractOptions): Promise<Table>;
|