783 lines
22 KiB
TypeScript
783 lines
22 KiB
TypeScript
import PdfPrinter from "pdfmake/src/Printer";
|
|
import { readFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
|
|
export interface QuoteLineItem {
|
|
qty: number;
|
|
description: string;
|
|
unitPrice: number;
|
|
narrative?: string;
|
|
}
|
|
|
|
export interface CustomerInfo {
|
|
name: string;
|
|
company?: string;
|
|
attention?: string;
|
|
address: string[];
|
|
}
|
|
|
|
export interface CustomerContact {
|
|
email?: string;
|
|
phone?: string;
|
|
}
|
|
|
|
export interface QuoteDetails {
|
|
quoteNumber: string;
|
|
date: string;
|
|
description: string;
|
|
}
|
|
|
|
export interface TaxConfig {
|
|
rate: number;
|
|
label: string;
|
|
}
|
|
|
|
export interface SalesRepInfo {
|
|
name: string;
|
|
email?: string;
|
|
}
|
|
|
|
export interface QuoteMetadata {
|
|
quoteId?: string;
|
|
createdById?: string;
|
|
createdByName?: string;
|
|
createdByEmail?: string;
|
|
createdAt?: string;
|
|
downloadedAt?: string;
|
|
downloadedById?: string;
|
|
downloadedByName?: string;
|
|
downloadedByEmail?: string;
|
|
}
|
|
|
|
export interface QuoteData {
|
|
customer: CustomerInfo;
|
|
contact: CustomerContact;
|
|
quote: QuoteDetails;
|
|
lineItems: QuoteLineItem[];
|
|
tax: TaxConfig;
|
|
salesRep?: SalesRepInfo;
|
|
quoteNarrative?: string;
|
|
isPreview?: boolean;
|
|
showLineItemPricing?: boolean;
|
|
metadata?: QuoteMetadata;
|
|
}
|
|
|
|
export interface QuoteTheme {
|
|
brandPrimary: string;
|
|
brandDark: string;
|
|
brandLight: string;
|
|
accent: string;
|
|
headerBg: string;
|
|
footerBg: string;
|
|
}
|
|
|
|
const DEFAULT_THEME: QuoteTheme = {
|
|
brandPrimary: "#8B5E0B",
|
|
brandDark: "#5C3D07",
|
|
brandLight: "#F5EDE0",
|
|
accent: "#C67F17",
|
|
headerBg: "#2D2317",
|
|
footerBg: "#F5EDE0",
|
|
};
|
|
|
|
const SLATE = "#3A3A3A";
|
|
const SLATE_MID = "#636363";
|
|
const SLATE_LIGHT = "#8E8E8E";
|
|
const WHITE = "#FFFFFF";
|
|
const ROW_ALT = "#FAF7F2";
|
|
const DIVIDER = "#D4C5A9";
|
|
|
|
const PAGE_H = 792;
|
|
const PAGE_W = 612;
|
|
const MARGIN_L = 40;
|
|
const MARGIN_R = 40;
|
|
const MARGIN_TOP = 26;
|
|
const MARGIN_BOTTOM = 65;
|
|
const CONTENT_W = PAGE_W - MARGIN_L - MARGIN_R;
|
|
|
|
const DEFAULT_DISCLAIMER =
|
|
"Prices valid for 30 days from quote date. Taxes invoiced per jurisdiction regardless of presence on this quote.";
|
|
|
|
const COMPANY = {
|
|
name: "Total Tech Solutions LLC",
|
|
contactPerson: "Courtney Stevens",
|
|
address: ["PO Box 331", "Murray, KY 42071"],
|
|
phone: "(270) 761-8324",
|
|
email: "courtney.stevens@totaltech.net",
|
|
licenseInfo: "Licensed in Kentucky & Tennessee · TN License #2173",
|
|
} as const;
|
|
|
|
const DEFAULT_LOGO_PATH = join(process.cwd(), "logo.png");
|
|
|
|
const fontDir = join(process.cwd(), "node_modules/pdfmake/build/fonts/Roboto");
|
|
const fonts = {
|
|
Roboto: {
|
|
normal: join(fontDir, "Roboto-Regular.ttf"),
|
|
bold: join(fontDir, "Roboto-Medium.ttf"),
|
|
italics: join(fontDir, "Roboto-Italic.ttf"),
|
|
bolditalics: join(fontDir, "Roboto-MediumItalic.ttf"),
|
|
},
|
|
};
|
|
|
|
const printer = new PdfPrinter(fonts as never);
|
|
|
|
const fmt = (n: number) =>
|
|
"$" + n.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
|
|
|
const hr = (color = DIVIDER, weight = 0.75) => ({
|
|
canvas: [
|
|
{
|
|
type: "line",
|
|
x1: 0,
|
|
y1: 0,
|
|
x2: CONTENT_W,
|
|
y2: 0,
|
|
lineWidth: weight,
|
|
lineColor: color,
|
|
},
|
|
],
|
|
});
|
|
|
|
function loadLogoDataUrl(logoPath: string): string | null {
|
|
try {
|
|
const raw = readFileSync(logoPath);
|
|
const ext = logoPath.toLowerCase().endsWith(".png") ? "png" : "jpeg";
|
|
return `data:image/${ext};base64,${raw.toString("base64")}`;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function generateQuote(
|
|
data: QuoteData,
|
|
theme: Partial<QuoteTheme> = {},
|
|
logoPath = DEFAULT_LOGO_PATH,
|
|
): Promise<Buffer> {
|
|
const t: QuoteTheme = { ...DEFAULT_THEME, ...theme };
|
|
const subTotal = data.lineItems.reduce(
|
|
(sum, item) => sum + item.qty * item.unitPrice,
|
|
0,
|
|
);
|
|
const taxAmount = subTotal * data.tax.rate;
|
|
const total = subTotal + taxAmount;
|
|
const logoDataUrl = loadLogoDataUrl(logoPath);
|
|
|
|
const showPricing = data.showLineItemPricing ?? false;
|
|
|
|
const tableHeader = [
|
|
{ text: "Qty", style: "thCell", alignment: "center" },
|
|
{ text: "Description", style: "thCell" },
|
|
...(showPricing
|
|
? [
|
|
{ text: "Unit Price", style: "thCell", alignment: "right" },
|
|
{ text: "Total", style: "thCell", alignment: "right" },
|
|
]
|
|
: []),
|
|
];
|
|
|
|
const colCount = showPricing ? 4 : 2;
|
|
|
|
const tableRows: Record<string, unknown>[][] = [];
|
|
for (const item of data.lineItems) {
|
|
// Build the description cell — stack description + narrative so they
|
|
// are a single cell and pdfmake never splits them across pages.
|
|
const descriptionCell: Record<string, unknown> = item.narrative
|
|
? {
|
|
stack: [
|
|
{ text: item.description, style: "tdCell" },
|
|
{
|
|
text: item.narrative,
|
|
style: "narrative",
|
|
margin: [0, 2, 8, 0],
|
|
},
|
|
],
|
|
}
|
|
: { text: item.description, style: "tdCell" };
|
|
|
|
tableRows.push([
|
|
{ text: String(item.qty), style: "tdCell", alignment: "center" },
|
|
descriptionCell,
|
|
...(showPricing
|
|
? [
|
|
{
|
|
text: fmt(item.unitPrice),
|
|
style: "tdCell",
|
|
alignment: "right",
|
|
noWrap: true,
|
|
},
|
|
{
|
|
text: fmt(item.qty * item.unitPrice),
|
|
style: "tdCell",
|
|
alignment: "right",
|
|
noWrap: true,
|
|
},
|
|
]
|
|
: []),
|
|
]);
|
|
}
|
|
|
|
const headerImage = logoDataUrl
|
|
? { image: logoDataUrl, width: 200 }
|
|
: {
|
|
stack: [{ text: COMPANY.name, style: "companyName" }],
|
|
width: 200,
|
|
};
|
|
|
|
const docDefinition = {
|
|
pageSize: "LETTER" as const,
|
|
pageMargins: [MARGIN_L, MARGIN_TOP, MARGIN_R, MARGIN_BOTTOM] as [
|
|
number,
|
|
number,
|
|
number,
|
|
number,
|
|
],
|
|
|
|
info: {
|
|
title: `Quote ${data.quote.quoteNumber}`,
|
|
author: data.metadata?.createdByName ?? COMPANY.name,
|
|
subject: data.quote.description,
|
|
creator: COMPANY.name,
|
|
producer: COMPANY.name,
|
|
keywords: [
|
|
data.metadata?.quoteId ? `quoteId:${data.metadata.quoteId}` : null,
|
|
data.metadata?.createdById
|
|
? `createdById:${data.metadata.createdById}`
|
|
: null,
|
|
data.metadata?.createdByEmail
|
|
? `createdByEmail:${data.metadata.createdByEmail}`
|
|
: null,
|
|
data.metadata?.createdAt
|
|
? `createdAt:${data.metadata.createdAt}`
|
|
: null,
|
|
data.metadata?.downloadedAt
|
|
? `downloadedAt:${data.metadata.downloadedAt}`
|
|
: null,
|
|
data.metadata?.downloadedById
|
|
? `downloadedById:${data.metadata.downloadedById}`
|
|
: null,
|
|
data.metadata?.downloadedByName
|
|
? `downloadedByName:${data.metadata.downloadedByName}`
|
|
: null,
|
|
data.metadata?.downloadedByEmail
|
|
? `downloadedByEmail:${data.metadata.downloadedByEmail}`
|
|
: null,
|
|
data.isPreview ? "preview:true" : null,
|
|
]
|
|
.filter(Boolean)
|
|
.join("; "),
|
|
},
|
|
|
|
defaultStyle: {
|
|
font: "Roboto",
|
|
fontSize: 9.5,
|
|
color: SLATE,
|
|
lineHeight: 1.3,
|
|
},
|
|
|
|
styles: {
|
|
companyName: { fontSize: 18, bold: true, color: t.brandDark },
|
|
quoteLabel: { fontSize: 24, color: t.accent, bold: true, opacity: 0.12 },
|
|
sectionTitle: {
|
|
fontSize: 8.5,
|
|
bold: true,
|
|
color: t.brandPrimary,
|
|
characterSpacing: 1.2,
|
|
},
|
|
sectionBody: { fontSize: 9, color: SLATE },
|
|
sectionMuted: { fontSize: 8.5, color: SLATE_MID },
|
|
infoLabel: {
|
|
fontSize: 8,
|
|
bold: true,
|
|
color: SLATE_LIGHT,
|
|
characterSpacing: 0.5,
|
|
},
|
|
infoValue: { fontSize: 10, bold: true, color: t.brandDark },
|
|
contactLabel: { fontSize: 8, bold: true, color: SLATE_LIGHT },
|
|
contactValue: { fontSize: 9, color: SLATE },
|
|
thCell: {
|
|
fontSize: 8.5,
|
|
bold: true,
|
|
color: WHITE,
|
|
characterSpacing: 0.5,
|
|
},
|
|
tdCell: { fontSize: 9, color: SLATE },
|
|
narrative: {
|
|
fontSize: 8,
|
|
color: SLATE_MID,
|
|
italics: true,
|
|
lineHeight: 1.2,
|
|
},
|
|
totalsLabel: { fontSize: 9, color: SLATE_MID },
|
|
totalsValue: { fontSize: 9, color: SLATE, bold: true },
|
|
totalFinalLabel: { fontSize: 11, bold: true, color: WHITE },
|
|
totalFinalValue: { fontSize: 12, bold: true, color: t.brandDark },
|
|
footerText: { fontSize: 7.5, color: SLATE_MID },
|
|
footerBold: { fontSize: 7.5, color: t.brandPrimary, bold: true },
|
|
disclaimer: { fontSize: 7, color: SLATE_LIGHT, italics: true },
|
|
},
|
|
|
|
...(data.isPreview
|
|
? {
|
|
watermark: {
|
|
text: "PREVIEW",
|
|
color: t.brandDark,
|
|
opacity: 0.15,
|
|
bold: true,
|
|
},
|
|
}
|
|
: {}),
|
|
|
|
background: () => ({
|
|
canvas: [
|
|
{ type: "rect", x: 0, y: 0, w: PAGE_W, h: 6, color: t.accent },
|
|
{ type: "rect", x: 0, y: 6, w: 4, h: 786, color: t.brandLight },
|
|
],
|
|
}),
|
|
|
|
content: [
|
|
{
|
|
margin: [0, 4, 0, 0],
|
|
columns: [
|
|
headerImage,
|
|
{
|
|
stack: [
|
|
{ text: COMPANY.name, style: "companyName", alignment: "right" },
|
|
{
|
|
text: "QUOTE",
|
|
style: "quoteLabel",
|
|
alignment: "right",
|
|
margin: [0, -4, 0, 0],
|
|
},
|
|
],
|
|
width: "*",
|
|
},
|
|
],
|
|
},
|
|
|
|
{ ...hr(t.accent, 1.5), margin: [0, 8, 0, 0] },
|
|
|
|
{
|
|
margin: [0, 7, 0, 7],
|
|
columns: [
|
|
{
|
|
width: "auto",
|
|
stack: [
|
|
{ text: "QUOTE NUMBER", style: "infoLabel" },
|
|
{
|
|
text: data.quote.quoteNumber,
|
|
style: "infoValue",
|
|
margin: [0, 2, 0, 0],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
width: "auto",
|
|
margin: [30, 0, 0, 0],
|
|
stack: [
|
|
{ text: "DATE", style: "infoLabel" },
|
|
{
|
|
text: data.quote.date,
|
|
style: "infoValue",
|
|
margin: [0, 2, 0, 0],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
width: "*",
|
|
margin: [30, 0, 0, 0],
|
|
stack: [
|
|
{ text: "DESCRIPTION", style: "infoLabel" },
|
|
{
|
|
text: data.quote.description,
|
|
style: "infoValue",
|
|
margin: [0, 2, 0, 0],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
|
|
{ ...hr(), margin: [0, 0, 0, 10] },
|
|
|
|
{
|
|
columns: [
|
|
{
|
|
width: 155,
|
|
stack: [
|
|
{ text: "FROM", style: "sectionTitle", margin: [0, 0, 0, 6] },
|
|
{
|
|
text: data.salesRep?.name ?? COMPANY.contactPerson,
|
|
style: "sectionBody",
|
|
bold: true,
|
|
},
|
|
{
|
|
text: COMPANY.name,
|
|
style: "sectionMuted",
|
|
margin: [0, 2, 0, 0],
|
|
},
|
|
...COMPANY.address.map((line) => ({
|
|
text: line,
|
|
style: "sectionMuted",
|
|
})),
|
|
{
|
|
text: COMPANY.phone,
|
|
style: "sectionBody",
|
|
margin: [0, 4, 0, 0],
|
|
},
|
|
{
|
|
text: data.salesRep?.email ?? COMPANY.email,
|
|
style: "sectionMuted",
|
|
margin: [0, 1, 0, 0],
|
|
},
|
|
],
|
|
},
|
|
|
|
{
|
|
width: 175,
|
|
margin: [25, 0, 0, 0],
|
|
stack: [
|
|
{
|
|
text: "PREPARED FOR",
|
|
style: "sectionTitle",
|
|
margin: [0, 0, 0, 6],
|
|
},
|
|
{ text: data.customer.name, style: "sectionBody", bold: true },
|
|
...(data.customer.company
|
|
? [
|
|
{
|
|
text: data.customer.company,
|
|
style: "sectionMuted",
|
|
margin: [0, 2, 0, 0],
|
|
},
|
|
]
|
|
: []),
|
|
...(data.customer.attention
|
|
? [{ text: data.customer.attention, style: "sectionMuted" }]
|
|
: []),
|
|
...data.customer.address.map((line) => ({
|
|
text: line,
|
|
style: "sectionMuted",
|
|
})),
|
|
],
|
|
},
|
|
|
|
...(data.contact.email || data.contact.phone
|
|
? [
|
|
{
|
|
width: "*" as const,
|
|
margin: [20, 0, 0, 0] as [number, number, number, number],
|
|
stack: [
|
|
{
|
|
text: "CONTACT",
|
|
style: "sectionTitle",
|
|
margin: [0, 0, 0, 6],
|
|
},
|
|
...(data.contact.email
|
|
? [
|
|
{
|
|
columns: [
|
|
{
|
|
text: "Email",
|
|
style: "contactLabel",
|
|
width: 40,
|
|
},
|
|
{
|
|
text: data.contact.email,
|
|
style: "contactValue",
|
|
width: "*",
|
|
},
|
|
],
|
|
},
|
|
]
|
|
: []),
|
|
...(data.contact.phone
|
|
? [
|
|
{
|
|
columns: [
|
|
{
|
|
text: "Mobile",
|
|
style: "contactLabel",
|
|
width: 40,
|
|
},
|
|
{
|
|
text: data.contact.phone,
|
|
style: "contactValue",
|
|
width: "*",
|
|
},
|
|
],
|
|
margin: [0, 4, 0, 0],
|
|
},
|
|
]
|
|
: []),
|
|
],
|
|
},
|
|
]
|
|
: []),
|
|
],
|
|
},
|
|
|
|
{ ...hr(), margin: [0, 10, 0, 0] },
|
|
|
|
...(data.quoteNarrative
|
|
? [
|
|
{
|
|
margin: [0, 8, 0, 6] as [number, number, number, number],
|
|
table: {
|
|
widths: [2, "*"],
|
|
body: [
|
|
[
|
|
{
|
|
text: "",
|
|
fillColor: t.accent,
|
|
border: [false, false, false, false],
|
|
},
|
|
{
|
|
text: data.quoteNarrative,
|
|
fontSize: 9,
|
|
color: SLATE_MID,
|
|
italics: true,
|
|
lineHeight: 1.4,
|
|
margin: [8, 6, 8, 6],
|
|
fillColor: ROW_ALT,
|
|
border: [false, false, false, false],
|
|
},
|
|
],
|
|
],
|
|
},
|
|
layout: {
|
|
hLineWidth: () => 0,
|
|
vLineWidth: () => 0,
|
|
paddingLeft: () => 0,
|
|
paddingRight: () => 0,
|
|
paddingTop: () => 0,
|
|
paddingBottom: () => 0,
|
|
},
|
|
},
|
|
]
|
|
: []),
|
|
|
|
{
|
|
margin: [0, 10, 0, 0],
|
|
table: {
|
|
headerRows: 1,
|
|
dontBreakRows: true,
|
|
widths: showPricing ? [40, "*", 75, 75] : [40, "*"],
|
|
body: [tableHeader, ...tableRows],
|
|
},
|
|
layout: {
|
|
fillColor: (rowIndex: number) => {
|
|
if (rowIndex === 0) return t.headerBg;
|
|
return rowIndex % 2 === 0 ? ROW_ALT : null;
|
|
},
|
|
hLineWidth: (i: number, node: { table: { body: unknown[] } }) => {
|
|
if (i === 0 || i === 1) return 0;
|
|
if (i === node.table.body.length) return 1;
|
|
return 0.5;
|
|
},
|
|
vLineWidth: () => 0,
|
|
hLineColor: (i: number, node: { table: { body: unknown[] } }) =>
|
|
i === node.table.body.length ? t.headerBg : "#E8E0D0",
|
|
paddingLeft: (col: number) => (col === 0 ? 6 : 8),
|
|
paddingRight: () => 8,
|
|
paddingTop: () => 4,
|
|
paddingBottom: () => 4,
|
|
},
|
|
},
|
|
|
|
{
|
|
unbreakable: true,
|
|
stack: [
|
|
{
|
|
margin: [0, 6, 0, 0],
|
|
columns: [
|
|
{ width: "*", text: "" },
|
|
{
|
|
width: 250,
|
|
table: {
|
|
widths: ["*", 110],
|
|
body: [
|
|
[
|
|
{
|
|
text: "Subtotal",
|
|
style: "totalsLabel",
|
|
margin: [0, 5, 0, 5],
|
|
border: [false, false, false, true],
|
|
},
|
|
{
|
|
text: fmt(subTotal),
|
|
style: "totalsValue",
|
|
alignment: "right",
|
|
noWrap: true,
|
|
margin: [0, 5, 0, 5],
|
|
border: [false, false, false, true],
|
|
},
|
|
],
|
|
[
|
|
{
|
|
text: data.tax.label,
|
|
style: "totalsLabel",
|
|
margin: [0, 5, 0, 5],
|
|
border: [false, false, false, true],
|
|
},
|
|
{
|
|
text: fmt(taxAmount),
|
|
style: "totalsValue",
|
|
alignment: "right",
|
|
noWrap: true,
|
|
margin: [0, 5, 0, 5],
|
|
border: [false, false, false, true],
|
|
},
|
|
],
|
|
[
|
|
{
|
|
text: "TOTAL",
|
|
style: "totalFinalLabel",
|
|
fillColor: t.headerBg,
|
|
margin: [10, 8, 6, 8],
|
|
border: [false, false, false, false],
|
|
},
|
|
{
|
|
text: fmt(total),
|
|
style: "totalFinalValue",
|
|
alignment: "right",
|
|
noWrap: true,
|
|
fillColor: t.brandLight,
|
|
margin: [6, 7, 8, 7],
|
|
border: [false, false, false, false],
|
|
},
|
|
],
|
|
],
|
|
},
|
|
layout: {
|
|
hLineWidth: (i: number) => (i >= 1 && i <= 2 ? 0.5 : 0),
|
|
vLineWidth: () => 0,
|
|
hLineColor: () => "#E0D6C6",
|
|
},
|
|
},
|
|
],
|
|
},
|
|
|
|
{
|
|
margin: [0, 40, 0, 0],
|
|
columns: [
|
|
{
|
|
width: "50%",
|
|
stack: [
|
|
{
|
|
canvas: [
|
|
{
|
|
type: "line",
|
|
x1: 0,
|
|
y1: 0,
|
|
x2: 220,
|
|
y2: 0,
|
|
lineWidth: 0.75,
|
|
lineColor: "#999",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
text: "Authorized Signature",
|
|
fontSize: 7,
|
|
color: "#888",
|
|
margin: [0, 3, 0, 0],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
width: "50%",
|
|
stack: [
|
|
{
|
|
canvas: [
|
|
{
|
|
type: "line",
|
|
x1: 0,
|
|
y1: 0,
|
|
x2: 160,
|
|
y2: 0,
|
|
lineWidth: 0.75,
|
|
lineColor: "#999",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
text: "Date",
|
|
fontSize: 7,
|
|
color: "#888",
|
|
margin: [0, 3, 0, 0],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
|
|
footer: (currentPage: number, pageCount: number) => ({
|
|
margin: [0, 0, 0, 0],
|
|
stack: [
|
|
{
|
|
canvas: [
|
|
{ type: "rect", x: 0, y: 0, w: PAGE_W, h: 44, color: t.footerBg },
|
|
],
|
|
},
|
|
{
|
|
margin: [MARGIN_L, -38, MARGIN_R, 0],
|
|
columns: [
|
|
{
|
|
width: "*",
|
|
stack: [
|
|
{
|
|
text: [
|
|
{ text: COMPANY.name, style: "footerBold" },
|
|
{
|
|
text: ` · ${COMPANY.licenseInfo}`,
|
|
style: "footerText",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
width: "auto",
|
|
text: `Page ${currentPage} of ${pageCount}`,
|
|
style: "footerText",
|
|
alignment: "right",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
margin: [MARGIN_L, 4, MARGIN_R, 0],
|
|
text: DEFAULT_DISCLAIMER,
|
|
style: "disclaimer",
|
|
},
|
|
],
|
|
}),
|
|
};
|
|
|
|
const maybeDoc = printer.createPdfKitDocument(docDefinition as never) as any;
|
|
const pdfDoc =
|
|
maybeDoc && typeof maybeDoc.then === "function" ? await maybeDoc : maybeDoc;
|
|
|
|
if (!pdfDoc || typeof pdfDoc.on !== "function") {
|
|
throw new Error("Failed to initialize PDF document stream");
|
|
}
|
|
|
|
return await new Promise<Buffer>((resolve, reject) => {
|
|
try {
|
|
const chunks: Buffer[] = [];
|
|
pdfDoc.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
pdfDoc.on("end", () => resolve(Buffer.concat(chunks)));
|
|
pdfDoc.on("error", reject);
|
|
if (typeof pdfDoc.end === "function") {
|
|
pdfDoc.end();
|
|
} else {
|
|
reject(new Error("PDF document stream does not support end()"));
|
|
}
|
|
} catch (err) {
|
|
reject(err);
|
|
}
|
|
});
|
|
}
|