import { createHash } from 'node:crypto'; import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; interface ZipEntry { name: string; data: Buffer; } interface ReportWordInput { code: string; title: string; generatedAt: Date; frozenSha256: string; frozenSnapshot: Record; executiveSummary?: string | null; reportDescription?: string | null; companyName?: string | null; areaName?: string | null; scopeName?: string | null; photos?: Array<{ id: string; findingId?: string; assetId?: string; title?: string; sha256: string; buffer: Buffer }>; } const crcTable = (() => { const table = new Uint32Array(256); for (let n = 0; n < 256; n += 1) { let value = n; for (let k = 0; k < 8; k += 1) { value = (value & 1) ? 0xedb88320 ^ (value >>> 1) : value >>> 1; } table[n] = value >>> 0; } return table; })(); function crc32(data: Buffer): number { let crc = 0xffffffff; for (const byte of data) crc = crcTable[(crc ^ byte) & 0xff]! ^ (crc >>> 8); return (crc ^ 0xffffffff) >>> 0; } function xmlEscape(value: unknown): string { return String(value ?? '') .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } function asRecord(value: unknown): Record { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; } function asArray(value: unknown): Array> { return Array.isArray(value) ? value.map(asRecord) : []; } function text(value: unknown, fallback = '—'): string { const normalized = String(value ?? '').trim(); return normalized || fallback; } function isoDate(value: unknown): string { const parsed = new Date(String(value ?? '')); return Number.isFinite(parsed.getTime()) ? parsed.toLocaleDateString('es-AR') : '—'; } function paragraph(value: string, style?: 'Title' | 'Heading1' | 'Heading2'): string { const styleXml = style ? `` : ''; return `${styleXml}${xmlEscape(value)}`; } function labelValue(label: string, value: string): string { return `${xmlEscape(label)}: ${xmlEscape(value)}`; } function table(headers: string[], rows: string[][]): string { const cell = (value: string, bold = false) => `${bold ? '' : ''}${xmlEscape(value)}`; const header = `${headers.map((item) => cell(item, true)).join('')}`; const body = rows.map((row) => `${row.map((item) => cell(item)).join('')}`).join(''); return `${header}${body}`; } function f4Snapshot(input: ReportWordInput) { const source = asRecord(input.frozenSnapshot.source); const sealedAct = asRecord(input.frozenSnapshot.sealedAct ?? input.frozenSnapshot.actClosure); const locked = asRecord(sealedAct.lockedSnapshot ?? sealedAct.preparedSnapshot); const act = asRecord(locked.act); const inspection = asRecord(act.inspection ?? act.visit); const responsible = asRecord(locked.responsible); const inventories = asArray(locked.inventories ?? locked.assets); const findings = asArray(locked.findings); const signatures = asArray(sealedAct.signatures); return { source, sealedAct, locked, act, inspection, responsible, inventories, findings, signatures }; } function urgency(value: unknown): string { return text(value, '') === 'URGENT' ? 'Urgente' : 'No urgente'; } function deadline(act: Record): string { const type = text(act.deadlineDayType, '') === 'CALENDAR' ? 'días corridos' : 'días hábiles'; if (act.deadlineAt) return `${isoDate(act.deadlineAt)} · ${text(act.deadlineDays)} ${type}`; if (text(act.deadlineBasis, '') === 'GEDO_DATE') return `${text(act.deadlineDays)} ${type} desde la fecha de oficialización GEDO`; return '—'; } function imageDimensions(buffer: Buffer): { width: number; height: number } { if (buffer.subarray(0, 4).toString('hex') === '89504e47') return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) }; let offset = 2; while (offset + 9 < buffer.length) { if (buffer[offset] !== 0xff) break; const marker = buffer[offset + 1]!; if ([0xc0,0xc1,0xc2,0xc3,0xc5,0xc6,0xc7,0xc9,0xca,0xcb,0xcd,0xce,0xcf].includes(marker)) return { height: buffer.readUInt16BE(offset + 5), width: buffer.readUInt16BE(offset + 7) }; const size = buffer.readUInt16BE(offset + 2); if (size < 2) break; offset += size + 2; } return { width: 800, height: 500 }; } function drawing(relationship: number, image: Buffer, name: string, maxWidth = 4572000, maxHeight = 2057400, right = false): string { const dimensions = imageDimensions(image); const scale = Math.min(maxWidth / dimensions.width, maxHeight / dimensions.height); const cx = Math.round(dimensions.width * scale); const cy = Math.round(dimensions.height * scale); const alt = xmlEscape(name); return `${right ? '' : ''}`; } function documentXml(input: ReportWordInput, logo: Buffer): string { const snapshot = f4Snapshot(input); const photos = input.photos ?? []; const authors = snapshot.signatures.filter((item) => text(item.signerType, '') === 'INSPECTOR').map((item) => text(item.signerName)).join(', '); const legalBasis = [...new Set(snapshot.findings.map((item) => text(item.legalBasis, '')).filter(Boolean))]; const quantity = `${snapshot.findings.length} ${snapshot.findings.length === 1 ? 'hallazgo' : 'hallazgos'}`; const entries: string[] = [ paragraph('MINISTERIO DE ENERGÍA Y AMBIENTE'), paragraph('DIRECCIÓN DE HIDROCARBUROS'), drawing(2, logo, 'Identidad institucional Mendoza', 594360, 1005840, true), paragraph(`Mendoza, ${input.generatedAt.toLocaleDateString('es-AR')}`), paragraph(`INFORME TÉCNICO ${input.code}`, 'Title'), paragraph('Sr. Director de Hidrocarburos'), labelValue('Referencia', text(input.scopeName ?? snapshot.inventories[0]?.name, 'Instalación inspeccionada')), labelValue('Inspector/a', authors || 'No consignado'), labelValue('Acta', text(snapshot.source.actCode ?? snapshot.act.code)), labelValue('Inspección', text(snapshot.source.inspectionCode ?? snapshot.inspection.code)), labelValue('Área o Yacimiento', text(input.scopeName ?? input.areaName)), labelValue('Empresa inspeccionada', text(input.companyName)), paragraph('OBJETIVOS', 'Heading1'), labelValue('General', 'Documentar los resultados de la inspección consignados en el Acta fuente.'), labelValue('Particular', 'Analizar los hallazgos y el estado de las instalaciones inspeccionadas para definir las acciones y verificaciones que correspondan.'), paragraph('MARCO LEGAL', 'Heading1'), ...(legalBasis.length ? legalBasis.map((basis) => paragraph(basis)) : [paragraph('No se consignó normativa específica en los hallazgos del Acta fuente.')]), paragraph('DESCRIPCIÓN Y ANÁLISIS TÉCNICO', 'Heading1'), paragraph(input.reportDescription?.trim() || `Según el Acta ${text(snapshot.act.code)}, se documentaron ${quantity} durante la inspección. Se detallan las observaciones y evidencias consignadas a continuación.`), paragraph('FOTOS Y HALLAZGOS', 'Heading1'), ]; if (!snapshot.findings.length) entries.push(paragraph('El Acta fuente no registra hallazgos.')); const shownPhotos = new Set(); for (const finding of snapshot.findings) { entries.push(paragraph(`${text(finding.code)} ${text(finding.title)}`, 'Heading2')); entries.push(labelValue('Instalación', text(snapshot.inventories.find((item) => text(item.id) === text(finding.assetId))?.name))); entries.push(paragraph(text(finding.description))); if (finding.legalBasis) entries.push(labelValue('Normativa', text(finding.legalBasis))); if (finding.severity != null) entries.push(labelValue('Gravedad', `${text(finding.severity)}/10`)); for (let index = 0; index < photos.length; index++) { const photo = photos[index]!; if (photo.findingId !== text(finding.id) && photo.assetId !== text(finding.assetId)) continue; shownPhotos.add(index); entries.push(drawing(index + 3, photo.buffer, photo.title || text(finding.title))); entries.push(paragraph(`Fotografía vinculada · SHA-256 ${photo.sha256}`)); } } const otherPhotos = photos.map((photo, index) => ({ photo, index })) .filter(({ photo, index }) => photo.assetId && !shownPhotos.has(index)); if (otherPhotos.length) { entries.push(paragraph('OTRAS INSTALACIONES INSPECCIONADAS', 'Heading1')); for (const { photo, index } of otherPhotos) { entries.push(labelValue('Instalación', text(photo.title))); entries.push(drawing(index + 3, photo.buffer, photo.title || 'Fotografía de instalación')); entries.push(paragraph(`Fotografía de inventario · SHA-256 ${photo.sha256}`)); } } entries.push( paragraph('CONCLUSIONES', 'Heading1'), paragraph(input.executiveSummary?.trim() || `La inspección registró ${quantity}. Su seguimiento y la respuesta de la empresa se documentan en el Informe.`), paragraph('ACTA FUENTE E INTEGRIDAD', 'Heading1'), labelValue('Acta sellada', text(snapshot.source.actCode ?? snapshot.act.code)), labelValue('SHA-256 del cierre del Acta', text(snapshot.source.actClosureSha256 ?? snapshot.sealedAct.finalSha256)), labelValue('SHA-256 de la fuente del Informe', input.frozenSha256), ); const body = entries.join(''); return `${body}`; } function buildZip(entries: ZipEntry[]): Buffer { const locals: Buffer[] = []; const centrals: Buffer[] = []; let offset = 0; const dosDate = 33; const dosTime = 0; for (const entry of entries) { const name = Buffer.from(entry.name, 'utf8'); const crc = crc32(entry.data); const local = Buffer.alloc(30); local.writeUInt32LE(0x04034b50, 0); local.writeUInt16LE(20, 4); local.writeUInt16LE(0, 6); local.writeUInt16LE(0, 8); local.writeUInt16LE(dosTime, 10); local.writeUInt16LE(dosDate, 12); local.writeUInt32LE(crc, 14); local.writeUInt32LE(entry.data.length, 18); local.writeUInt32LE(entry.data.length, 22); local.writeUInt16LE(name.length, 26); local.writeUInt16LE(0, 28); locals.push(local, name, entry.data); const central = Buffer.alloc(46); central.writeUInt32LE(0x02014b50, 0); central.writeUInt16LE(20, 4); central.writeUInt16LE(20, 6); central.writeUInt16LE(0, 8); central.writeUInt16LE(0, 10); central.writeUInt16LE(dosTime, 12); central.writeUInt16LE(dosDate, 14); central.writeUInt32LE(crc, 16); central.writeUInt32LE(entry.data.length, 20); central.writeUInt32LE(entry.data.length, 24); central.writeUInt16LE(name.length, 28); central.writeUInt16LE(0, 30); central.writeUInt16LE(0, 32); central.writeUInt16LE(0, 34); central.writeUInt16LE(0, 36); central.writeUInt32LE(0, 38); central.writeUInt32LE(offset, 42); centrals.push(central, name); offset += local.length + name.length + entry.data.length; } const centralData = Buffer.concat(centrals); const end = Buffer.alloc(22); end.writeUInt32LE(0x06054b50, 0); end.writeUInt16LE(0, 4); end.writeUInt16LE(0, 6); end.writeUInt16LE(entries.length, 8); end.writeUInt16LE(entries.length, 10); end.writeUInt32LE(centralData.length, 12); end.writeUInt32LE(offset, 16); end.writeUInt16LE(0, 20); return Buffer.concat([...locals, centralData, end]); } export function buildInspectionReportWord(input: ReportWordInput): { buffer: Buffer; sha256: string } { const logo = readFileSync(resolve(process.cwd(), 'assets/logo-mendoza.png')); const photos = input.photos ?? []; const mediaRelationships = photos.map((photo, index) => ``).join(''); const entries: ZipEntry[] = [ { name: '[Content_Types].xml', data: Buffer.from('', 'utf8'), }, { name: '_rels/.rels', data: Buffer.from('', 'utf8'), }, { name: 'word/_rels/document.xml.rels', data: Buffer.from(`${mediaRelationships}`, 'utf8'), }, { name: 'word/styles.xml', data: Buffer.from('', 'utf8'), }, { name: 'word/document.xml', data: Buffer.from(documentXml(input, logo), 'utf8') }, { name: 'word/media/logo-mendoza.png', data: logo }, ...photos.map((photo, index) => ({ name: `word/media/photo-${index + 1}.${photo.buffer.subarray(0,4).toString('hex') === '89504e47' ? 'png' : 'jpg'}`, data: photo.buffer })), { name: 'docProps/core.xml', data: Buffer.from(`${xmlEscape(input.title)}DH InspecciónDH Inspección${input.generatedAt.toISOString()}`, 'utf8'), }, { name: 'docProps/app.xml', data: Buffer.from('DH Inspección', 'utf8'), }, ]; const buffer = buildZip(entries); return { buffer, sha256: createHash('sha256').update(buffer).digest('hex') }; }