UNPKG

gcash-pdf-parser

Version:

Extract GCash transaction data from PDF statements

446 lines (445 loc) 17.8 kB
"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;