const fs = require('fs'); const path = require('path'); const LOGO_PATH = path.join(__dirname, '..', 'client', 'assets', 'images', 'snh-oasis.jpg'); const { escapePdf } = require('./pdfDocument'); const wrap = (txt, max = 82) => { const out = []; for (const raw of String(txt || '').split('\n')) { let line = raw; while (line.length > max) { let cut = line.lastIndexOf(' ', max); if (cut <= 0) cut = max; out.push(line.slice(0, cut)); line = line.slice(cut).replace(/^\s+/, ''); } out.push(line); } return out; }; const readJpegDims = (buf) => { let i = 2; while (i < buf.length) { if (buf[i] !== 0xFF) return null; const marker = buf[i + 1]; if (marker === 0xD8 || marker === 0xD9) { i += 2; continue; } const len = buf.readUInt16BE(i + 2); if (marker >= 0xC0 && marker <= 0xCF && marker !== 0xC4 && marker !== 0xC8 && marker !== 0xCC) { const h = buf.readUInt16BE(i + 5); const w = buf.readUInt16BE(i + 7); const c = buf[i + 9]; return { w, h, c }; } i += 2 + len; } return null; }; function buildSmartContractPdf({ transfer, block, viewerId }) { const pageW = 612; const pageH = 792; const marginX = 50; const headerH = 90; const footerH = 40; const bodyTop = pageH - headerH - 24; const bodyBottom = footerH + 10; const lineH = 14; let logoBuf = null; let logoDims = null; try { logoBuf = fs.readFileSync(LOGO_PATH); logoDims = readJpegDims(logoBuf); } catch (_) {} const t = transfer || {}; const b = block || {}; const fmt = v => (v === undefined || v === null) ? '' : String(v); const fmtAmount = () => { const cat = String(t.category || 'ECONOMIC').toUpperCase(); const unit = cat === 'TIME' ? 'h' : cat === 'TRUST' ? 'trust' : 'ECO'; return `${Number(t.amount || 0).toFixed(6)} ${unit}`; }; const fmtDate = v => v ? new Date(v).toISOString().replace('T', ' ').slice(0, 19) + ' UTC' : ''; const confirmedBy = Array.isArray(t.confirmedBy) ? t.confirmedBy : []; const required = t.from === t.to ? 1 : 2; const confirmedCount = confirmedBy.length; const tags = Array.isArray(t.tags) ? t.tags.join(', ') : ''; const sections = []; sections.push({ kind: 'title', text: `Concept: ${t.concept || '-'}` }); sections.push({ kind: 'blank' }); sections.push({ kind: 'section', text: 'OASIS IDs' }); sections.push({ kind: 'kv', label: 'From', value: fmt(t.from) }); sections.push({ kind: 'kv', label: 'To', value: fmt(t.to) }); sections.push({ kind: 'blank' }); sections.push({ kind: 'section', text: 'TERMS' }); sections.push({ kind: 'kv', label: 'Category', value: String(t.category || 'ECONOMIC').toUpperCase() }); if (String(t.category || '').toUpperCase() !== 'TRUST') sections.push({ kind: 'kv', label: 'Amount', value: fmtAmount() }); sections.push({ kind: 'kv', label: 'Status', value: fmt(t.status) }); if (t.deadline) sections.push({ kind: 'kv', label: 'Deadline', value: fmtDate(t.deadline) }); if (tags) sections.push({ kind: 'kv', label: 'Tags', value: tags }); sections.push({ kind: 'blank' }); sections.push({ kind: 'section', text: 'CONFIRMATIONS' }); sections.push({ kind: 'kv', label: 'Required', value: String(required) }); sections.push({ kind: 'kv', label: 'Confirmed', value: String(confirmedCount) }); if (confirmedBy.length) { for (const f of confirmedBy) sections.push({ kind: 'kv', label: 'Signed by', value: fmt(f) }); } sections.push({ kind: 'blank' }); sections.push({ kind: 'section', text: 'BLOCK VERIFICATION' }); if (b && b.id) { sections.push({ kind: 'kv', label: 'Block ID', value: fmt(b.id) }); if (b.ts) sections.push({ kind: 'kv', label: 'Block Timestamp', value: fmtDate(b.ts) }); if (b.type) sections.push({ kind: 'kv', label: 'Block Type', value: String(b.type).toUpperCase() }); if (b.author) sections.push({ kind: 'kv', label: 'Block Author', value: fmt(b.author) }); if (b.size) sections.push({ kind: 'kv', label: 'Block Size', value: `${b.size} bytes` }); } else { sections.push({ kind: 'kv', label: 'Block ID', value: fmt(t.id) }); } sections.push({ kind: 'blank' }); sections.push({ kind: 'section', text: 'METADATA' }); sections.push({ kind: 'kv', label: 'Transfer ID', value: fmt(t.id) }); if (t.createdAt) sections.push({ kind: 'kv', label: 'Created At', value: fmtDate(t.createdAt) }); if (t.updatedAt) sections.push({ kind: 'kv', label: 'Updated At', value: fmtDate(t.updatedAt) }); const lines = []; for (const s of sections) { if (s.kind === 'kv') { const txt = `${s.label}: ${s.value}`; for (const w of wrap(txt, 82)) lines.push({ kind: 'kv', text: w }); } else { lines.push(s); } } const maxBodyLines = Math.floor((bodyTop - bodyBottom) / lineH); const pages = []; for (let i = 0; i < lines.length; i += maxBodyLines) pages.push(lines.slice(i, i + maxBodyLines)); if (!pages.length) pages.push([]); const objects = []; const addObj = body => { objects.push(body); return objects.length; }; const catalogId = addObj(null); const pagesId = addObj(null); const fontId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier /Encoding /WinAnsiEncoding >>'); const fontBoldId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier-Bold /Encoding /WinAnsiEncoding >>'); let logoXObjId = null; if (logoBuf && logoDims) { const cs = logoDims.c === 1 ? '/DeviceGray' : '/DeviceRGB'; const dict = `<< /Type /XObject /Subtype /Image /Width ${logoDims.w} /Height ${logoDims.h} /ColorSpace ${cs} /BitsPerComponent 8 /Filter /DCTDecode /Length ${logoBuf.length} >>`; logoXObjId = addObj({ dict, stream: logoBuf }); } const exportDate = new Date().toISOString().replace('T', ' ').slice(0, 19) + ' UTC'; const footerLeft = `Generated: ${exportDate}`; const pageIds = []; const contentIds = []; pages.forEach((pg, pgIdx) => { const parts = []; if (logoXObjId) { const logoH = 60; const logoW = Math.round((logoDims.w / logoDims.h) * logoH); parts.push(`q\n${logoW} 0 0 ${logoH} ${marginX} ${pageH - headerH + 15} cm\n/Logo Do\nQ`); } const titleX = (logoXObjId ? marginX + 80 : marginX); const titleY = pageH - 45; parts.push(`BT\n/F2 16 Tf\n${titleX} ${titleY} Td\n(${escapePdf('OASIS - Smart Contract')}) Tj\nET`); const prefix = 'Issued to: '; const prefixW = prefix.length * 5.4; parts.push(`BT\n/F1 9 Tf\n${titleX} ${titleY - 16} Td\n(${escapePdf(prefix)}) Tj\nET`); parts.push(`BT\n/F2 9 Tf\n${titleX + prefixW} ${titleY - 16} Td\n(${escapePdf(String(viewerId || ''))}) Tj\nET`); parts.push(`q\n0.6 0.6 0.6 RG\n0.5 w\n${marginX} ${pageH - headerH} m\n${pageW - marginX} ${pageH - headerH} l\nS\nQ`); let y = bodyTop; for (const ln of pg) { if (ln.kind === 'title') { parts.push(`BT\n/F2 14 Tf\n0 0 0 rg\n${marginX} ${y} Td\n(${escapePdf(ln.text)}) Tj\nET`); } else if (ln.kind === 'subtitle') { parts.push(`BT\n/F1 11 Tf\n0.2 0.2 0.2 rg\n${marginX} ${y} Td\n(${escapePdf(ln.text)}) Tj\nET`); } else if (ln.kind === 'section') { parts.push(`BT\n/F2 11 Tf\n0 0 0 rg\n${marginX} ${y} Td\n(${escapePdf(ln.text)}) Tj\nET`); parts.push(`q\n0 0 0 RG\n0.5 w\n${marginX} ${y - 3} m\n${pageW - marginX} ${y - 3} l\nS\nQ`); } else if (ln.kind === 'kv') { parts.push(`BT\n/F1 10 Tf\n0 0 0 rg\n${marginX} ${y} Td\n(${escapePdf(ln.text)}) Tj\nET`); } y -= lineH; } parts.push(`q\n0.6 0.6 0.6 RG\n0.5 w\n${marginX} ${footerH + 5} m\n${pageW - marginX} ${footerH + 5} l\nS\nQ`); parts.push(`BT\n/F1 8 Tf\n${marginX} ${footerH - 10} Td\n(${escapePdf(footerLeft)}) Tj\nET`); const pageLabel = `Page ${pgIdx + 1} of ${pages.length}`; const pageLabelW = pageLabel.length * 4.8; parts.push(`BT\n/F1 8 Tf\n${pageW - marginX - pageLabelW} ${footerH - 10} Td\n(${escapePdf(pageLabel)}) Tj\nET`); const content = parts.join('\n'); const stream = `<< /Length ${Buffer.byteLength(content, 'latin1')} >>\nstream\n${content}\nendstream`; const cid = addObj(stream); contentIds.push(cid); const pid = addObj(null); pageIds.push(pid); }); for (let i = 0; i < pageIds.length; i++) { const resources = logoXObjId ? `<< /Font << /F1 ${fontId} 0 R /F2 ${fontBoldId} 0 R >> /XObject << /Logo ${logoXObjId} 0 R >> >>` : `<< /Font << /F1 ${fontId} 0 R /F2 ${fontBoldId} 0 R >> >>`; objects[pageIds[i] - 1] = `<< /Type /Page /Parent ${pagesId} 0 R /MediaBox [0 0 ${pageW} ${pageH}] /Contents ${contentIds[i]} 0 R /Resources ${resources} >>`; } objects[catalogId - 1] = `<< /Type /Catalog /Pages ${pagesId} 0 R >>`; objects[pagesId - 1] = `<< /Type /Pages /Kids [${pageIds.map(id => `${id} 0 R`).join(' ')}] /Count ${pageIds.length} >>`; const chunks = []; const offsets = [0]; let byteLen = 0; const push = (buf) => { chunks.push(buf); byteLen += buf.length; }; push(Buffer.from('%PDF-1.4\n%\xE2\xE3\xCF\xD3\n', 'binary')); for (let i = 0; i < objects.length; i++) { offsets.push(byteLen); const obj = objects[i]; if (obj && typeof obj === 'object' && obj.dict && obj.stream) { push(Buffer.from(`${i + 1} 0 obj\n${obj.dict}\nstream\n`, 'binary')); push(obj.stream); push(Buffer.from('\nendstream\nendobj\n', 'binary')); } else { push(Buffer.from(`${i + 1} 0 obj\n${obj}\nendobj\n`, 'binary')); } } const xrefStart = byteLen; let xref = `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`; for (let i = 1; i <= objects.length; i++) xref += `${String(offsets[i]).padStart(10, '0')} 00000 n \n`; xref += `trailer\n<< /Size ${objects.length + 1} /Root ${catalogId} 0 R >>\nstartxref\n${xrefStart}\n%%EOF`; push(Buffer.from(xref, 'binary')); return Buffer.concat(chunks); } module.exports = { buildSmartContractPdf };