gcash-pdf-parser
Version:
Extract GCash transaction data from PDF statements
446 lines (445 loc) • 17.8 kB
JavaScript
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.GCashPDFParser = void 0;
// Dynamic import approach for Node.js modules
let fs = null;
let path = null;
// Check if we're in a browser environment
const isBrowser = typeof window !== "undefined" && typeof window.document !== "undefined";
// Only import these modules in Node.js environment
if (!isBrowser) {
try {
// Using dynamic require to avoid breaking browser builds
fs = eval("require")("fs");
path = eval("require")("path");
}
catch (e) {
// Silently fail - we'll check for null before using these
console.warn("fs and path modules could not be loaded (browser environment)");
}
}
const pdfjsLib = __importStar(require("pdfjs-dist/legacy/build/pdf.mjs"));
/**
* Parser for GCash PDF statements that extracts transactions into a structured format
*/
class GCashPDFParser {
/**
* Creates a new GCashPDFParser instance
*
* @param pdfData - The PDF file data as an ArrayBuffer
* @param password - The password to decrypt the PDF
* @param options - Configuration options
*/
constructor(pdfData, password, options) {
this.transactions = [];
this.pageTexts = [];
this.columnPositions = [];
this.numericEntries = [];
this.pdfData = pdfData;
this.password = password;
this.outputDir = (options === null || options === void 0 ? void 0 : options.outputDir) || "output";
this.debug = (options === null || options === void 0 ? void 0 : options.debug) || false;
if ((this.debug || (options === null || options === void 0 ? void 0 : options.outputDir)) && !isBrowser) {
this.ensureDirectoriesExist();
}
}
/**
* Creates necessary output directories if they don't exist
*/
ensureDirectoriesExist() {
if (isBrowser || !fs)
return; // Skip in browser environment
if (!fs.existsSync(this.outputDir)) {
fs.mkdirSync(this.outputDir, { recursive: true });
}
const formats = ["csv", "txt"];
if (this.debug) {
formats.push("debug");
}
formats.forEach((format) => {
const formatDir = path.join(this.outputDir, format);
if (!fs.existsSync(formatDir)) {
fs.mkdirSync(formatDir, { recursive: true });
}
});
}
/**
* Parses the PDF and extracts transaction data
*
* @returns Promise resolving to an array of transactions
*/
parse() {
return __awaiter(this, void 0, void 0, function* () {
try {
const loadingTask = pdfjsLib.getDocument({
data: this.pdfData,
password: this.password,
});
const pdf = yield loadingTask.promise;
this.transactions = [];
this.pageTexts = [];
this.numericEntries = [];
for (let i = 1; i <= pdf.numPages; i++) {
const page = yield pdf.getPage(i);
const content = yield page.getTextContent();
const items = content.items.map((item) => {
const transform = item.transform;
return {
str: item.str,
dir: item.dir,
transform: transform,
width: item.width,
height: item.height,
fontName: item.fontName,
x: transform[4],
y: transform[5],
};
});
if (this.debug) {
this.saveRawItems(i, items);
}
if (i === 1) {
this.identifyColumnPositions(items);
this.computeColumnBoundaries();
}
const pageText = items.map((item) => item.str).join(" ");
this.pageTexts.push(pageText);
if (this.debug) {
this.savePageText(i, pageText);
}
this.extractNumericEntriesFromLineItems(items);
}
if (this.debug) {
this.saveAllPageTexts();
}
const headerEntries = this.extractHeaderEntriesFromPageTexts();
const mergedTransactions = [];
const entryCount = Math.min(headerEntries.length, this.numericEntries.length);
for (let i = 0; i < entryCount; i++) {
mergedTransactions.push({
dateTime: headerEntries[i].dateTime,
description: headerEntries[i].description,
referenceNo: headerEntries[i].referenceNo,
debit: this.numericEntries[i].debit,
credit: this.numericEntries[i].credit,
balance: this.numericEntries[i].balance,
});
}
if (headerEntries.length !== this.numericEntries.length && this.debug) {
console.warn("Warning: The number of header entries does not match the number of numeric entries.");
}
this.transactions = mergedTransactions;
return this.transactions;
}
catch (error) {
throw new Error(`Failed to parse PDF: ${error}`);
}
});
}
/**
* Identifies the column positions from the table header
*
* @param items - Text items from the PDF page
*/
identifyColumnPositions(items) {
const headerRowIndex = items.findIndex((item) => item.str.includes("Date and Time") ||
item.str.includes("Reference No") ||
item.str.includes("Debit") ||
item.str.includes("Credit") ||
item.str.includes("Balance"));
if (headerRowIndex !== -1) {
const headerItems = items.filter((item) => {
return (Math.abs(item.y - items[headerRowIndex].y) < 2 &&
[
"Date and Time",
"Description",
"Reference No",
"Debit",
"Credit",
"Balance",
].some((header) => item.str.includes(header)));
});
if (this.debug) {
console.log("Identified header items:", headerItems.map((i) => ({ str: i.str, x: i.x })));
}
this.columnPositions = headerItems.map((item) => ({
name: item.str.trim(),
x: item.x,
width: item.width || 0,
}));
}
else {
if (this.debug) {
console.warn("Could not identify column header row. Using default positions.");
}
this.columnPositions = [
{ name: "Date and Time", x: 0, width: 150 },
{ name: "Description", x: 150, width: 250 },
{ name: "Reference No", x: 400, width: 100 },
{ name: "Debit", x: 500, width: 80 },
{ name: "Credit", x: 580, width: 80 },
{ name: "Balance", x: 660, width: 80 },
];
}
}
/**
* Computes column boundaries for accurate text extraction
*/
computeColumnBoundaries() {
this.columnPositions.sort((a, b) => a.x - b.x);
const boundaries = [];
for (let i = 0; i < this.columnPositions.length - 1; i++) {
const current = this.columnPositions[i];
const next = this.columnPositions[i + 1];
const boundary = current.x + (next.x - current.x) / 2;
boundaries.push(boundary);
}
this.columnPositions.forEach((col, idx) => {
const minX = idx === 0 ? 0 : boundaries[idx - 1];
const maxX = idx === this.columnPositions.length - 1
? Number.POSITIVE_INFINITY
: boundaries[idx];
col.minX = minX;
col.maxX = maxX;
});
if (this.debug) {
fs.writeFileSync(path.join(this.outputDir, "debug", "column_positions.json"), JSON.stringify(this.columnPositions, null, 2));
}
}
/**
* Saves raw PDF text items for debugging
*
* @param pageNum - Page number
* @param items - Text items from the page
*/
saveRawItems(pageNum, items) {
if (!this.debug || isBrowser || !fs || !path)
return;
const debugDir = path.join(this.outputDir, "debug");
const filePath = path.join(debugDir, `page_${pageNum}_raw_items.json`);
fs.writeFileSync(filePath, JSON.stringify(items, null, 2));
}
/**
* Saves extracted page text for debugging
*
* @param pageNum - Page number
* @param text - Extracted text from the page
*/
savePageText(pageNum, text) {
if (!this.debug || isBrowser || !fs || !path)
return;
const debugDir = path.join(this.outputDir, "debug");
const filePath = path.join(debugDir, `page_${pageNum}.txt`);
fs.writeFileSync(filePath, text);
}
/**
* Saves all extracted page texts combined
*/
saveAllPageTexts() {
if (!this.debug || isBrowser || !fs || !path)
return;
const debugDir = path.join(this.outputDir, "debug");
const filePath = path.join(debugDir, "all_pages.txt");
fs.writeFileSync(filePath, this.pageTexts.join("\n\n--- PAGE BREAK ---\n\n"));
}
/**
* Extracts numeric entries (debit, credit, balance) from text items
*
* @param items - Text items from a PDF page
*/
extractNumericEntriesFromLineItems(items) {
const sortedItems = items.sort((a, b) => b.y - a.y);
const tolerance = 5;
const clusters = [];
sortedItems.forEach((item) => {
let added = false;
for (const cluster of clusters) {
if (Math.abs(item.y - cluster[0].y) <= tolerance) {
cluster.push(item);
added = true;
break;
}
}
if (!added) {
clusters.push([item]);
}
});
clusters.forEach((cluster) => {
cluster.sort((a, b) => a.x - b.x);
});
let numericEntryCount = this.numericEntries.length;
for (const line of clusters) {
const lineText = line.map((item) => item.str).join(" ");
if (lineText.includes("Date and Time") ||
lineText.includes("STARTING BALANCE") ||
lineText.includes("ENDING BALANCE") ||
lineText.includes("Total Debit") ||
lineText.includes("Total Credit") ||
!lineText.trim()) {
continue;
}
const dateTimePattern = /\d{4}-\d{2}-\d{2}\s+\d{1,2}:\d{2}\s+[AP]M/;
if (!dateTimePattern.test(lineText)) {
continue;
}
const debitColumn = this.columnPositions.find((col) => col.name === "Debit");
const debitX = (debitColumn === null || debitColumn === void 0 ? void 0 : debitColumn.minX) || 500;
const frontItems = [];
const backItems = [];
line.forEach((item) => {
if (item.x < debitX) {
frontItems.push(item);
}
else {
backItems.push(item);
}
});
const colValues = {
Debit: [],
Credit: [],
Balance: [],
};
backItems.forEach((item) => {
const xPos = item.x;
const relevantColumns = this.columnPositions.filter((col) => ["Debit", "Credit", "Balance"].includes(col.name));
const col = relevantColumns.find((col) => xPos >= col.minX && xPos < col.maxX);
if (col && colValues[col.name]) {
colValues[col.name].push(item.str);
}
});
const debitStr = (colValues["Debit"] || []).join(" ").trim();
const creditStr = (colValues["Credit"] || []).join(" ").trim();
const balanceStr = (colValues["Balance"] || []).join(" ").trim();
if (debitStr || creditStr || balanceStr) {
numericEntryCount++;
this.numericEntries.push({
entryNo: numericEntryCount,
debit: debitStr,
credit: creditStr,
balance: balanceStr,
});
}
}
}
/**
* Extracts header entries from page texts using regex pattern matching
*
* @returns Array of header entries containing date/time, description, and reference number
*/
extractHeaderEntriesFromPageTexts() {
const combinedText = this.pageTexts.join("\n");
const headerEntries = [];
const pattern = /(\d{4}-\d{2}-\d{2}\s+\d{1,2}:\d{2}\s+[AP]M)\s{3}(.+?)\s{3}(\d{13})/g;
let match;
let entryNo = 0;
while ((match = pattern.exec(combinedText)) !== null) {
entryNo++;
headerEntries.push({
entryNo,
dateTime: match[1].trim(),
description: match[2].trim(),
referenceNo: match[3].trim(),
});
}
if (this.debug) {
console.log(`Extracted ${headerEntries.length} header entries from page texts.`);
}
return headerEntries;
}
/**
* Gets the extracted transactions
*
* @returns Array of transactions
*/
getTransactions() {
return this.transactions;
}
/**
* Converts transactions to CSV format
*
* @returns CSV string of transactions
*/
toCSV() {
if (this.transactions.length === 0) {
return "No transactions found";
}
let csv = "Date and Time,Description,Reference No,Debit,Credit,Balance\n";
this.transactions.forEach((transaction) => {
csv += `"${transaction.dateTime}","${transaction.description}","${transaction.referenceNo}","${transaction.debit}","${transaction.credit}","${transaction.balance}"\n`;
});
return csv.trim();
}
/**
* Saves transactions as a CSV file
*
* @param filename - Output filename (default: "transactions.csv")
* @returns Full path to the saved CSV file
*/
saveCSV(filename = "transactions.csv") {
const csvContent = this.toCSV();
// In browser environment, just return the filename
if (isBrowser || !fs || !path) {
console.warn("File system operations not available in browser environment");
return filename;
}
const csvDir = path.join(this.outputDir, "csv");
// Ensure the directory exists
if (!fs.existsSync(csvDir)) {
fs.mkdirSync(csvDir, { recursive: true });
}
const filePath = path.join(csvDir, filename);
fs.writeFileSync(filePath, csvContent);
return filePath;
}
/**
* Gets the extracted text from all pages
*
* @returns Array of page texts
*/
getPageTexts() {
return this.pageTexts;
}
}
exports.GCashPDFParser = GCashPDFParser;