文件
hyb-oa/server/src/services/reimbursementMaterialPdf.js
T

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 };