import { PDFDocument, PDFDict, PDFName, PDFRawStream, PDFArray, PDFString, pako } from '../../../plugins.js'; /** * Base class for PDF XML extractors with common functionality */ export abstract class BaseXMLExtractor { /** * Known XML file names for different invoice formats */ protected readonly knownFileNames = [ 'factur-x.xml', 'zugferd-invoice.xml', 'ZUGFeRD-invoice.xml', 'xrechnung.xml', 'ubl-invoice.xml', 'invoice.xml', 'metadata.xml' ]; /** * Known XML formats to validate extracted content */ protected readonly knownFormats = [ 'CrossIndustryInvoice', 'CrossIndustryDocument', 'Invoice', 'CreditNote', 'ubl:Invoice', 'ubl:CreditNote', 'rsm:CrossIndustryInvoice', 'rsm:CrossIndustryDocument', 'ram:CrossIndustryDocument', 'urn:un:unece:uncefact', 'urn:ferd:CrossIndustryDocument', 'urn:zugferd', 'urn:factur-x', 'factur-x.eu', 'ZUGFeRD', 'FatturaElettronica' ]; /** * Known XML end tags for extracting content from strings */ protected readonly knownEndTags = [ '', '', '', '', '', '', '', '', '', '' ]; /** * Extract XML from a PDF buffer * @param pdfBuffer PDF buffer * @returns XML content or null if not found */ public abstract extractXml(pdfBuffer: Uint8Array | Buffer): Promise; /** * Check if an XML string is valid * @param xmlString XML string to check * @returns True if the XML is valid */ protected isValidXml(xmlString: string): boolean { try { // Basic checks for XML validity if (!xmlString || typeof xmlString !== 'string') { return false; } // Check if it starts with XML declaration or a valid element if (!xmlString.includes('')) || (xmlString.match(/<[^>]+>/) !== null && xmlString.match(/<\/[^>]+>/) !== null) ); } /** * Check if the XML string contains binary data * @param xmlString XML string to check * @returns True if the XML contains binary data */ protected hasBinaryData(xmlString: string): boolean { // Check for common binary data indicators const binaryChars = ['\u0000', '\u0001', '\u0002', '\u0003', '\u0004', '\u0005']; const consecutiveNulls = '\u0000\u0000\u0000'; // Check for control characters that shouldn't be in XML if (binaryChars.some(char => xmlString.includes(char))) { return true; } // Check for consecutive null bytes which indicate binary data if (xmlString.includes(consecutiveNulls)) { return true; } // Check for high concentration of non-printable characters const nonPrintableCount = (xmlString.match(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g) || []).length; if (nonPrintableCount > xmlString.length * 0.05) { // More than 5% non-printable return true; } return false; } /** * Extract XML from a string * @param text Text to extract XML from * @param startIndex Index to start extraction from * @returns XML content or null if not found */ protected extractXmlFromString(text: string, startIndex: number = 0): string | null { try { // Find the start of the XML document let xmlStartIndex = text.indexOf(']+>(?!.*<\/[^>]+>)/); if (lastClosingTagMatch && lastClosingTagMatch.index !== undefined) { xmlEndIndex = xmlStartIndex + lastClosingTagMatch.index + lastClosingTagMatch[0].length; } else { return null; } } // Extract the XML content const xmlContent = text.substring(xmlStartIndex, xmlEndIndex); // Validate the extracted content if (this.isValidXml(xmlContent)) { return xmlContent; } return null; } catch (error) { console.error('Error extracting XML from string:', error); return null; } } /** * Decompress and decode XML content from a PDF stream * @param stream PDF stream containing XML data * @param fileName Name of the file (for logging) * @returns XML content or null if not valid */ protected async extractXmlFromStream(stream: PDFRawStream, fileName: string): Promise { try { // Get the raw bytes from the stream const rawBytes = stream.getContents(); // First try without decompression (in case the content is not compressed) let xmlContent = this.tryDecodeBuffer(rawBytes); if (xmlContent && this.isValidXml(xmlContent)) { console.log(`Successfully extracted uncompressed XML from PDF file. File name: ${fileName}`); return xmlContent; } // Try with decompression try { const decompressedBytes = this.tryDecompress(rawBytes); if (decompressedBytes) { xmlContent = this.tryDecodeBuffer(decompressedBytes); if (xmlContent && this.isValidXml(xmlContent)) { console.log(`Successfully extracted decompressed XML from PDF file. File name: ${fileName}`); return xmlContent; } } } catch (decompressError) { console.log(`Decompression failed for ${fileName}: ${decompressError}`); } return null; } catch (error) { console.error('Error extracting XML from stream:', error); return null; } } /** * Try to decompress a buffer using different methods * @param buffer Buffer to decompress * @returns Decompressed buffer or null if decompression failed */ protected tryDecompress(buffer: Uint8Array): Uint8Array | null { try { // Try pako inflate (for deflate/zlib compression) return pako.inflate(buffer); } catch (error) { // If pako fails, try other methods if needed console.warn('Pako decompression failed, might be uncompressed or using a different algorithm'); return null; } } /** * Try to decode a buffer to a string using different encodings * @param buffer Buffer to decode * @returns Decoded string or null if decoding failed */ protected tryDecodeBuffer(buffer: Uint8Array): string | null { try { // Try UTF-8 first let content = new TextDecoder('utf-8').decode(buffer); if (this.isPlausibleXml(content)) { return content; } // Try ISO-8859-1 (Latin1) content = this.decodeLatin1(buffer); if (this.isPlausibleXml(content)) { return content; } return null; } catch (error) { console.warn('Error decoding buffer:', error); return null; } } /** * Decode a buffer using ISO-8859-1 (Latin1) encoding * @param buffer Buffer to decode * @returns Decoded string */ protected decodeLatin1(buffer: Uint8Array): string { return Array.from(buffer) .map(byte => String.fromCharCode(byte)) .join(''); } /** * Check if a string is plausibly XML (quick check before validation) * @param content String to check * @returns True if the string is plausibly XML */ protected isPlausibleXml(content: string): boolean { return content.includes('<') && content.includes('>') && (content.includes(' content.includes(format))); } }