diff --git a/BuyerSheetParser.ts b/BuyerSheetParser.ts index 627a7ac..944baa1 100644 --- a/BuyerSheetParser.ts +++ b/BuyerSheetParser.ts @@ -33,15 +33,15 @@ const LABELS = [ ]; export class DeterministicParser { - + public async parsePdf(filePath: string): Promise { const dataBuffer = fs.readFileSync(filePath); const loadingTask = pdfjsLib.getDocument({ data: new Uint8Array(dataBuffer) }); const pdfDocument = await loadingTask.promise; const page = await pdfDocument.getPage(1); - + const textContent = await page.getTextContent(); - + // 1. Textfragmente mit X, Y und Breite (Width) auslesen const mappedItems = textContent.items .filter((item: any) => item.str.trim() !== '') @@ -54,7 +54,7 @@ export class DeterministicParser { .sort((a, b) => b.y - a.y || a.x - b.x); // Y absteigend (oben nach unten) // 2. Zeilen bilden (Y-Toleranz) - const linesGrouped: {y: number, items: any[]}[] = []; + const linesGrouped: { y: number, items: any[] }[] = []; let currentY: number | null = null; let currentItems = []; @@ -78,7 +78,7 @@ export class DeterministicParser { group.items.sort((a, b) => a.x - b.x); let lineStr = ""; let prevEnd = -1; - + for (const item of group.items) { if (prevEnd !== -1) { const gap = item.x - prevEnd; @@ -96,14 +96,14 @@ export class DeterministicParser { // 4. Y-Intervall Parsing (Die "Nutzer-Idee") const rawResults: Record = {}; LABELS.forEach(lbl => rawResults[lbl] = []); - + let currentLabel: string | null = null; // Längste Labels zuerst suchen, damit "EMAIL ADDRESS" vor "ADDRESS" gefunden wird const sortedLabels = [...LABELS].sort((a, b) => b.length - a.length); for (const line of lines) { const textUpper = line.text.toUpperCase().replace(/\s+/g, ''); - + // Boilerplate ignorieren, der Labels enthält ("VERIFICATION OF DOWN PAYMENT") if (textUpper.includes("SELLERMAYREQUIREVERIFICATION")) continue; @@ -118,7 +118,7 @@ export class DeterministicParser { if (foundLabel) { currentLabel = foundLabel; - + // Falls der Wert direkt auf derselben Zeile steht (z.B. "NAME/COMPANY: Gunnar Schultz") if (line.text.includes(':')) { const parts = line.text.split(':'); @@ -130,55 +130,178 @@ export class DeterministicParser { } else if (currentLabel) { // Diese Zeile ist kein Label, gehört also zum aktuellen Bereich! const cleanVal = line.text.replace(/_+/g, '').trim(); - + // Footer und Artefakte ignorieren if (cleanVal.length > 0 && !cleanVal.includes("Doc ID") && !cleanVal.includes("Bizmatch")) { rawResults[currentLabel].push(cleanVal); } } } - - return this.mapToTargetStructure(rawResults, filePath); + // --- NEU: Visuelle Checkbox Erkennung --- + const visualCheckboxResult = await this.detectGraphicCheckbox(page, mappedItems); + return this.mapToTargetStructure(rawResults, filePath, visualCheckboxResult); } - private mapToTargetStructure(raw: Record, filePath: string): BuyerSheetData { + private mapToTargetStructure( + raw: Record, + filePath: string, + visualCheckboxResult: boolean | null // <-- Neuer Parameter + ): BuyerSheetData { const getVal = (label: string) => raw[label] && raw[label].length > 0 ? raw[label].join(' ') : null; - + const nameCompany = getVal("NAME / COMPANY"); - - // Down Payment normalisieren + + // ========================================== + // 1. Telefon & Handy (Cell) separieren + // ========================================== + let phoneRaw = getVal("PHONE"); + let phoneClean: string | null = null; + let cellClean: string | null = null; + + if (phoneRaw) { + // Alles vor "FAX:" oder "CELL:" ist die Telefonnummer + const phoneMatch = phoneRaw.split(/FAX:|CELL:/)[0]; + phoneClean = phoneMatch ? phoneMatch.trim() : null; + + // Alles nach "CELL:" ist die Handynummer + const cellMatch = phoneRaw.match(/CELL:\s*(.*)/); + cellClean = cellMatch && cellMatch[1] ? cellMatch[1].trim() : null; + } + + // ========================================== + // 2. Adresse bereinigen + // ========================================== + let addressClean = getVal("ADDRESS"); + if (addressClean) { + // Den statischen Formulart-Subtext entfernen + addressClean = addressClean.replace(/PO BOX \/ STREET\s+CITY \/ STATE \/ ZIP/g, '').trim(); + if (addressClean === '') addressClean = null; + } + + // ========================================== + // 3. Checkbox Auswertung (Hybrid) + // ========================================== + const interestedRaw = getVal("ARE YOU INTERESTED"); + let interestedInUpdates: boolean | null = null; + let checkboxPending = true; + + if (interestedRaw) { + // 1. Zuerst Text-Prüfung versuchen (wie vorher) + if (/(✔|☑|X|✓)\s*YES/i.test(interestedRaw) || /YES\s*(✔|☑|X|✓)/i.test(interestedRaw)) { + interestedInUpdates = true; + checkboxPending = false; + } else if (/(✔|☑|X|✓)\s*NO/i.test(interestedRaw) || /NO\s*(✔|☑|X|✓)/i.test(interestedRaw)) { + interestedInUpdates = false; + checkboxPending = false; + } + } + + // 2. Wenn Text-Prüfung versagt hat, nutzen wir unser visuelles X-Koordinaten Ergebnis! + if (checkboxPending && visualCheckboxResult !== null) { + interestedInUpdates = visualCheckboxResult; + checkboxPending = false; + } + + // ========================================== + // 4. Down Payment & Datum + // ========================================== const downPaymentRaw = getVal("DOWN PAYMENT")?.replace(/[^0-9,]/g, '') || null; const downPaymentClean = downPaymentRaw ? downPaymentRaw.replace(/,/g, '') : null; - // Datum aus Dateinamen extrahieren let dateOfIntro = null; const dateMatch = filePath.match(/(\d{2})(\d{2})(\d{2})\.pdf$/); if (dateMatch) { dateOfIntro = `20${dateMatch[3]}-${dateMatch[1]}-${dateMatch[2]}`; } + // ========================================== + // 5. JSON Return mit dynamischen Flags + // ========================================== return { - is_buyer_sheet: true, + is_buyer_sheet: true, // Wird in der Praxis dynamisch gesetzt name_company: nameCompany, prospective_buyer: nameCompany ? nameCompany.split('/')[0].trim() : null, company: null, - phone: getVal("PHONE"), - cell: null, + phone: phoneClean, + cell: cellClean, email: getVal("EMAIL ADDRESS"), - address: getVal("ADDRESS"), + address: addressClean, state: null, how_did_you_hear: getVal("HOW DID YOU HEAR"), - interested_in_updates: null, + interested_in_updates: interestedInUpdates, types_of_business_raw: getVal("TYPES OF BUSINESSES"), background_experience: getVal("BACKGROUND"), total_purchase_price: getVal("TOTAL PURCHASE PRICE"), down_payment: downPaymentClean, down_payment_raw: downPaymentRaw, date_of_introduction: dateOfIntro, - _checkbox_pending: true, + _checkbox_pending: checkboxPending, _parser: "deterministic", - _info_page: 1, - _ca_page: 2 + _info_page: 1, // Wird in der Praxis über eine Schleife ermittelt + _ca_page: 2 // Wird in der Praxis über eine Schleife ermittelt }; } -} \ No newline at end of file + private async detectGraphicCheckbox(page: any, textItems: any[]): Promise { + let yesX = null, noX = null, targetY = null; + + // 1. Koordinaten von YES und NO suchen + for (const item of textItems) { + const textUpper = item.text.toUpperCase().trim(); + if (textUpper.includes("YES")) { yesX = item.x; targetY = item.y; } + } + for (const item of textItems) { + const textUpper = item.text.toUpperCase().trim(); + // Wir suchen NO auf derselben Höhe (Toleranz 5px) + if (textUpper.includes("NO") && targetY !== null && Math.abs(item.y - targetY) < 5) { + noX = item.x; + } + } + + if (yesX === null || noX === null || targetY === null) return null; + + // ========================================== + // BILD-ERKENNUNG (Der 16x16 Stempel-Trick) + // ========================================== + const opList = await page.getOperatorList(); + let currentTransform = [1, 0, 0, 1, 0, 0]; + + for (let i = 0; i < opList.fnArray.length; i++) { + const fn = opList.fnArray[i]; + const args = opList.argsArray[i]; + + if (fn === pdfjsLib.OPS.transform) { + currentTransform = args; + } + // Wenn ein Bild auf die Seite gezeichnet wird + else if ( + fn === pdfjsLib.OPS.paintImageXObject || + fn === pdfjsLib.OPS.paintInlineImageXObject || + fn === pdfjsLib.OPS.paintJpegXObject + ) { + const width = Math.abs(currentTransform[0]); + const height = Math.abs(currentTransform[3]); + const imgX = currentTransform[4]; + const imgY = currentTransform[5]; + + // Wir filtern nach "Stempeln" (kleine Bilder unter 40x40 Pixeln) + if (width < 40 && height < 40) { + // Befindet sich der Stempel auf unserer Ziel-Zeile? + // (Wir erlauben 50px Toleranz, da Y=378 vs Y=417) + if (Math.abs(imgY - targetY) < 50) { + const distToYes = Math.abs(imgX - yesX); + const distToNo = Math.abs(imgX - noX); + + // Wenn das Bildchen näher an YES ist + if (distToYes < distToNo) { + return true; + } else { + return false; + } + } + } + } + } + + return null; + } +} diff --git a/find_images.ts b/find_images.ts new file mode 100644 index 0000000..d0393cd --- /dev/null +++ b/find_images.ts @@ -0,0 +1,88 @@ +import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.js'; +import * as fs from 'fs'; + +async function analyzeImages(filePath: string) { + console.log(`\nLese PDF für Bild-Analyse: ${filePath}`); + + const dataBuffer = fs.readFileSync(filePath); + const loadingTask = pdfjsLib.getDocument({ data: new Uint8Array(dataBuffer) }); + const pdfDocument = await loadingTask.promise; + + // Wir analysieren nur Seite 1 + const page = await pdfDocument.getPage(1); + const viewport = page.getViewport({ scale: 1.0 }); + console.log(`Seitengröße: Breite = ${viewport.width.toFixed(2)}, Höhe = ${viewport.height.toFixed(2)}\n`); + + const opList = await page.getOperatorList(); + + // Um die Koordinaten zu berechnen, müssen wir den Grafik-Status (Stack) verfolgen + let transformStack: number[][] = [[1, 0, 0, 1, 0, 0]]; // Standard-Matrix + let currentTransform = [1, 0, 0, 1, 0, 0]; + let imageCount = 0; + + console.log("--- GEFUNDENE BILDER AUF SEITE 1 ---"); + + for (let i = 0; i < opList.fnArray.length; i++) { + const fn = opList.fnArray[i]; + const args = opList.argsArray[i]; + + // Status speichern (q) + if (fn === pdfjsLib.OPS.save) { + transformStack.push([...currentTransform]); + } + // Status wiederherstellen (Q) + else if (fn === pdfjsLib.OPS.restore) { + if (transformStack.length > 0) { + currentTransform = transformStack.pop()!; + } + } + // Transformation anwenden (cm) - In PDFs werden Matrizen multipliziert, + // für die einfache Bildanalyse reicht oft der letzte Transform-Befehl vor dem Bild, + // da dieser die Skalierung (Breite/Höhe) und Position (X/Y) des 1x1 Pixel Objekts setzt. + else if (fn === pdfjsLib.OPS.transform) { + currentTransform = args; + } + // Wenn ein Bild gezeichnet wird! + else if ( + fn === pdfjsLib.OPS.paintImageXObject || + fn === pdfjsLib.OPS.paintInlineImageXObject || + fn === pdfjsLib.OPS.paintJpegXObject + ) { + imageCount++; + + // In der PDF-Matrix: + // currentTransform[0] = Skalierung X (entspricht der Bild-Breite) + // currentTransform[3] = Skalierung Y (entspricht der Bild-Höhe) + // currentTransform[4] = Position X + // currentTransform[5] = Position Y + const width = currentTransform[0]; + const height = currentTransform[3]; + const x = currentTransform[4]; + const y = currentTransform[5]; + + const isPageFilling = (width >= viewport.width - 10 && height >= viewport.height - 10); + const mark = isPageFilling ? " <-- SEITENFÜLLENDER SCAN / HINTERGRUND" : ""; + + console.log(`Bild ${imageCount}:`); + console.log(` Position: X = ${x.toFixed(2)}, Y = ${y.toFixed(2)}`); + console.log(` Größe: Breite = ${width.toFixed(2)}, Höhe = ${height.toFixed(2)}${mark}`); + console.log(` Interner Name: ${args[0]}`); + console.log(`--------------------------------------------------`); + } + } + + if (imageCount === 0) { + console.log("Keine Bilder gefunden. Das Dokument besteht rein aus Vektoren und Text."); + } else { + console.log(`\nInsgesamt ${imageCount} Bild(er) gefunden.`); + } +} + +// Aufruf mit dem Pfad aus den Parametern +const args = process.argv.slice(2); +const pdfArgIndex = args.indexOf('--pdf'); +if (pdfArgIndex !== -1 && args[pdfArgIndex + 1]) { + analyzeImages(args[pdfArgIndex + 1]).catch(console.error); +} else { + console.error("Bitte --pdf Parameter angeben."); +} \ No newline at end of file