const crypto = require("node:crypto"); const { PDFDocument } = require("pdf-lib"); const sharp = require("sharp"); const FIXED_PDF_DATE = new Date("2000-01-01T00:00:00.000Z"); const PDF_CREATOR = "hyb-oa"; const PDF_PRODUCER = "hyb-oa reimbursement material merger"; function materialCollectionHash(materials, mergerVersion) { const hash = crypto.createHash("sha256"); hash.update(String(mergerVersion)); for (const material of materials) { hash.update("\0"); hash.update([ material.materialType, material.id, material.fileSha256 ?? "", material.mimeType, String(material.fileSize), ].join("\u001f")); } return hash.digest("hex"); } async function normalizeImageForPdf(source, limits) { const image = sharp(source, { failOn: "error", limitInputPixels: limits.maxImagePixels }).rotate(); const { data: normalized, info } = await image .flatten({ background: "#ffffff" }) .jpeg({ quality: 90, chromaSubsampling: "4:4:4", mozjpeg: false }) .toBuffer({ resolveWithObject: true }); if (!info.width || !info.height || info.width * info.height > limits.maxImagePixels) { throw new Error("material_preview_image_invalid"); } return { buffer: normalized, width: info.width, height: info.height }; } function fitPage(width, height) { const maxSide = 842; const scale = Math.min(1, maxSide / Math.max(width, height)); return { width: Math.max(1, width * scale), height: Math.max(1, height * scale) }; } async function mergeMaterialsToPdf(materials, limits) { const output = await PDFDocument.create(); output.setCreationDate(FIXED_PDF_DATE); output.setModificationDate(FIXED_PDF_DATE); output.setCreator(PDF_CREATOR); output.setProducer(PDF_PRODUCER); output.setTitle("Reimbursement Material Preview"); output.setSubject("Derived preview; originals retained separately"); let pageCount = 0; for (const material of materials) { const source = Buffer.from(material.source); if (material.mimeType === "application/pdf") { let input; try { input = await PDFDocument.load(source, { updateMetadata: false }); } catch { throw new Error("material_preview_pdf_invalid"); } const indices = input.getPageIndices(); if (!indices.length || pageCount + indices.length > limits.maxPages) { throw new Error(indices.length ? "material_preview_page_limit" : "material_preview_pdf_empty"); } const pages = await output.copyPages(input, indices); pages.forEach((page) => output.addPage(page)); pageCount += pages.length; continue; } if (pageCount + 1 > limits.maxPages) throw new Error("material_preview_page_limit"); let normalized; try { normalized = await normalizeImageForPdf(source, limits); } catch (error) { if (error?.message === "material_preview_image_invalid") throw error; throw new Error("material_preview_image_invalid"); } const embedded = await output.embedJpg(normalized.buffer); const size = fitPage(normalized.width, normalized.height); const page = output.addPage([size.width, size.height]); page.drawImage(embedded, { x: 0, y: 0, width: size.width, height: size.height }); pageCount += 1; } if (!pageCount) throw new Error("material_preview_empty"); const bytes = await output.save({ useObjectStreams: true, addDefaultPage: false, objectsPerTick: 50, updateFieldAppearances: false, }); const buffer = Buffer.from(bytes); if (buffer.length > limits.maxOutputBytes) throw new Error("material_preview_output_size_limit"); return { buffer, pageCount }; } module.exports = { materialCollectionHash, mergeMaterialsToPdf };