100 行
3.6 KiB
JavaScript
100 行
3.6 KiB
JavaScript
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 };
|