/** * Extract plain text from brandbook source uploads (txt/md/csv/json/pdf/docx). * No heavy native deps — PDF via FlateDecode streams; DOCX via zip + document.xml. */ import { inflateRawSync, inflateSync } from 'zlib'; const MAX_CHARS = 200_000; function decodePdfStringLiteral(raw: string): string { // PDF literal strings: \( \) \\ \n \r \t \b \f \ddd octal let out = ''; for (let i = 0; i < raw.length; i++) { const c = raw[i]; if (c !== '\\') { out += c; continue; } const n = raw[i + 1]; if (n === 'n') { out += '\n'; i++; } else if (n === 'r') { out += '\r'; i++; } else if (n === 't') { out += '\t'; i++; } else if (n === 'b' || n === 'f') { i++; } else if (n === '(' || n === ')' || n === '\\') { out += n; i++; } else if (n && /[0-7]/.test(n)) { let oct = n; i++; if (raw[i + 1] && /[0-7]/.test(raw[i + 1])) { oct += raw[i + 1]; i++; } if (raw[i + 1] && /[0-7]/.test(raw[i + 1])) { oct += raw[i + 1]; i++; } out += String.fromCharCode(parseInt(oct, 8)); } else if (n) { out += n; i++; } } return out; } function decodePdfHexString(hex: string): string { const clean = hex.replace(/[^0-9a-fA-F]/g, ''); let out = ''; for (let i = 0; i + 1 < clean.length; i += 2) { const code = parseInt(clean.slice(i, i + 2), 16); if (code >= 32 && code !== 127) out += String.fromCharCode(code); else if (code === 10 || code === 13) out += '\n'; } return out; } function extractTextFromPdfContent(content: string): string { const parts: string[] = []; // Tj / ' / " operators with literal or hex strings const re = /(\((?:\\.|[^\\)])*\))\s*Tj|(\((?:\\.|[^\\)])*\))\s*'|\[((?:[^\[\]]|\[[^\]]*\])*)\]\s*TJ|<([0-9A-Fa-f\s]+)>\s*Tj/g; let m: RegExpExecArray | null; while ((m = re.exec(content))) { if (m[1] || m[2]) { const lit = (m[1] || m[2]).slice(1, -1); parts.push(decodePdfStringLiteral(lit)); } else if (m[3]) { const arr = m[3]; const inner = /\((?:\\.|[^\\)])*\)|<([0-9A-Fa-f\s]+)>/g; let im: RegExpExecArray | null; while ((im = inner.exec(arr))) { if (im[0].startsWith('(')) { parts.push(decodePdfStringLiteral(im[0].slice(1, -1))); } else if (im[1]) { parts.push(decodePdfHexString(im[1])); } } parts.push(' '); } else if (m[4]) { parts.push(decodePdfHexString(m[4])); } } return parts.join(''); } function inflatePdfStream(data: Buffer): Buffer | null { try { return inflateSync(data); } catch { try { return inflateRawSync(data); } catch { return null; } } } /** Best-effort text extraction from PDF bytes. */ export function extractTextFromPdf(buffer: Buffer): string { const latin = buffer.toString('latin1'); const chunks: string[] = []; // Stream objects: ... stream\nDATA\nendstream const streamRe = /stream\r?\n([\s\S]*?)endstream/g; let sm: RegExpExecArray | null; while ((sm = streamRe.exec(latin))) { const before = latin.slice(Math.max(0, sm.index - 400), sm.index); const isFlate = /\/Filter\s*\/FlateDecode|\/Filter\s*\[\s*\/FlateDecode/i.test( before ); const raw = Buffer.from(sm[1], 'latin1'); // Trim possible trailing \r\n that belongs to endstream delimiter let payload = raw; if (payload.length >= 2 && payload[payload.length - 2] === 0x0d) { payload = payload.subarray(0, payload.length - 2); } else if (payload.length >= 1 && payload[payload.length - 1] === 0x0a) { payload = payload.subarray(0, payload.length - 1); } let content = ''; if (isFlate) { const inflated = inflatePdfStream(payload); if (!inflated) continue; content = inflated.toString('latin1'); } else { content = payload.toString('latin1'); } const text = extractTextFromPdfContent(content); if (text.trim()) chunks.push(text); } // Also scan uncompressed page content outside streams (rare) const direct = extractTextFromPdfContent(latin); if (direct.trim().length > 40) chunks.push(direct); const joined = chunks .join('\n') .replace(/\r/g, '\n') .replace(/[ \t]+\n/g, '\n') .replace(/\n{3,}/g, '\n\n') .replace(/[^\S\n]{2,}/g, ' ') .trim(); return joined.slice(0, MAX_CHARS); } /** Minimal ZIP local-file reader (enough for DOCX document.xml). */ function zipReadFile(buffer: Buffer, pathInZip: string): Buffer | null { let offset = 0; while (offset + 30 <= buffer.length) { if (buffer.readUInt32LE(offset) !== 0x04034b50) break; const method = buffer.readUInt16LE(offset + 8); const compSize = buffer.readUInt32LE(offset + 18); const nameLen = buffer.readUInt16LE(offset + 26); const extraLen = buffer.readUInt16LE(offset + 28); const name = buffer .subarray(offset + 30, offset + 30 + nameLen) .toString('utf8'); const dataStart = offset + 30 + nameLen + extraLen; const data = buffer.subarray(dataStart, dataStart + compSize); offset = dataStart + compSize; if (name !== pathInZip) continue; if (method === 0) return Buffer.from(data); if (method === 8) { try { return inflateRawSync(data); } catch { return null; } } return null; } return null; } export function extractTextFromDocx(buffer: Buffer): string { const xml = zipReadFile(buffer, 'word/document.xml'); if (!xml) { throw new Error( 'No se pudo leer el DOCX (¿archivo corrupto o protegido?). Prueba a pegar el texto.' ); } const raw = xml.toString('utf8'); const text = raw .replace(//g, '\t') .replace(//g, '\n') .replace(/<\/w:p>/g, '\n\n') .replace(/<[^>]+>/g, '') .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n))) .replace(/[ \t]+\n/g, '\n') .replace(/\n{3,}/g, '\n\n') .trim(); return text.slice(0, MAX_CHARS); } export type ExtractableExt = 'txt' | 'md' | 'csv' | 'json' | 'pdf' | 'docx'; export function detectSourceExt(filename: string, mime?: string): ExtractableExt | null { const lower = filename.toLowerCase(); if (lower.endsWith('.pdf') || mime === 'application/pdf') return 'pdf'; if ( lower.endsWith('.docx') || mime === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ) return 'docx'; if (lower.endsWith('.txt') || mime === 'text/plain') return 'txt'; if (lower.endsWith('.md') || mime === 'text/markdown') return 'md'; if (lower.endsWith('.csv') || mime === 'text/csv') return 'csv'; if (lower.endsWith('.json') || mime === 'application/json') return 'json'; return null; } export async function extractSourceText(opts: { filename: string; mime?: string; buffer?: Buffer; textFallback?: string; }): Promise<{ text: string; filename: string }> { const ext = detectSourceExt(opts.filename, opts.mime); const filename = opts.filename || `fuente.${ext || 'txt'}`; if (!ext || ext === 'txt' || ext === 'md' || ext === 'csv' || ext === 'json') { if (opts.textFallback != null) { return { text: opts.textFallback.slice(0, MAX_CHARS), filename }; } if (opts.buffer) { return { text: opts.buffer.toString('utf8').slice(0, MAX_CHARS), filename, }; } throw new Error('Sin texto'); } if (!opts.buffer) throw new Error('Falta el archivo'); if (ext === 'pdf') { const text = extractTextFromPdf(opts.buffer); if (!text.trim() || text.trim().length < 20) { throw new Error( 'No se pudo extraer texto del PDF (puede ser escaneado o protegido). Copia el texto y pégalo en el campo.' ); } return { text, filename }; } if (ext === 'docx') { const text = extractTextFromDocx(opts.buffer); if (!text.trim()) { throw new Error( 'El DOCX no contiene texto legible. Copia el contenido y pégalo en el campo.' ); } return { text, filename }; } throw new Error('Formato no soportado'); }