diff --git a/package-lock.json b/package-lock.json index b216025..98a452c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "mysql2": "^3.14.1", "next": "15.2.4", "nodemailer": "^7.0.3", + "pdf-lib": "^1.17.1", "react": "^19.0.0", "react-dom": "^19.0.0", "react-to-pdf": "^2.0.0", @@ -1555,6 +1556,24 @@ "node": ">=0.10" } }, + "node_modules/@pdf-lib/standard-fonts": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz", + "integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.6" + } + }, + "node_modules/@pdf-lib/upng": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz", + "integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.10" + } + }, "node_modules/@prisma/client": { "version": "6.7.0", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.7.0.tgz", @@ -4120,6 +4139,24 @@ "node": ">=8" } }, + "node_modules/pdf-lib": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz", + "integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==", + "license": "MIT", + "dependencies": { + "@pdf-lib/standard-fonts": "^1.0.0", + "@pdf-lib/upng": "^1.0.1", + "pako": "^1.0.11", + "tslib": "^1.11.1" + } + }, + "node_modules/pdf-lib/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, "node_modules/performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", diff --git a/package.json b/package.json index bfe0627..9ed3d18 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "mysql2": "^3.14.1", "next": "15.2.4", "nodemailer": "^7.0.3", + "pdf-lib": "^1.17.1", "react": "^19.0.0", "react-dom": "^19.0.0", "react-to-pdf": "^2.0.0", diff --git a/src/app/api/suitable/pdf/route.ts b/src/app/api/suitable/pdf/route.ts index 72b20a2..1525cfb 100644 --- a/src/app/api/suitable/pdf/route.ts +++ b/src/app/api/suitable/pdf/route.ts @@ -1,6 +1,7 @@ import React from 'react' import { NextRequest, NextResponse } from 'next/server' import { pdf, Document } from '@react-pdf/renderer' +import { PDFDocument } from 'pdf-lib' import { prisma } from '@/libs/prisma' import { type Suitable } from '@/types/Suitable' import SuitablePdf from '@/components/pdf/SuitablePdf' @@ -65,14 +66,32 @@ export async function POST(request: NextRequest) { // 데이터 조회 const suitable: Suitable[] = await prisma.$queryRawUnsafe(query) - // pdf 생성 - const content = React.createElement(Document, null, React.createElement(SuitablePdf, { data: suitable, fileTitle: fileTitle })) - const pdfBlob = await pdf(content).toBlob() + // pdf 생성 : mainId 100개씩 청크로 나누기 + const CHUNK_SIZE = 100 + const pdfBuffers: Uint8Array[] = [] + for (let i = 0; i < suitable.length; i += CHUNK_SIZE) { + const chunk = suitable.slice(i, i + CHUNK_SIZE) + const content = React.createElement( + Document, + null, + React.createElement(SuitablePdf, { + data: chunk, + fileTitle: fileTitle, + firstPage: i === 0, + }), + ) + const pdfBlob = await pdf(content).toBlob() + const arrayBuffer = await pdfBlob.arrayBuffer() + pdfBuffers.push(new Uint8Array(arrayBuffer)) + } + + // 모든 PDF 버퍼 병합 + const mergedPdfBytes = await mergePdfBuffers(pdfBuffers) const fileName = `${fileTitle.replace(' ', '_')}.pdf` const encodedFileName = encodeURIComponent(fileName) - return new Response(pdfBlob, { + return new NextResponse(Buffer.from(mergedPdfBytes), { headers: { 'Content-Type': 'application/pdf', 'Content-Disposition': `attachment; filename="suitable.pdf"; filename*=UTF-8''${encodedFileName}`, @@ -83,3 +102,16 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: '데이터 조회 중 오류가 발생했습니다' }, { status: 500 }) } } + +async function mergePdfBuffers(buffers: Uint8Array[]) { + const mergedPdf = await PDFDocument.create() + + for (const buf of buffers) { + const pdf = await PDFDocument.load(buf) + const copiedPages = await mergedPdf.copyPages(pdf, pdf.getPageIndices()) + copiedPages.forEach((page) => mergedPdf.addPage(page)) + } + + const mergedPdfBytes = await mergedPdf.save() + return mergedPdfBytes +} diff --git a/src/components/pdf/SuitablePdf.tsx b/src/components/pdf/SuitablePdf.tsx index de89f8c..e27a31f 100644 --- a/src/components/pdf/SuitablePdf.tsx +++ b/src/components/pdf/SuitablePdf.tsx @@ -63,35 +63,42 @@ const formatDateString = () => { return `${year}年${month}月${day}日 ${hours}:${minutes.toString().padStart(2, '0')}` } -export default function SuitablePdf({ data, fileTitle }: { data: Suitable[]; fileTitle: string }) { +export default function SuitablePdf({ data, fileTitle, firstPage }: { data: Suitable[]; fileTitle: string; firstPage: boolean }) { const createTime = formatDateString() return ( {/* Intro Section */} - - ハンファジャパン株式会社 - {fileTitle} - {createTime} - - - - 本適合表は参考資料としてご使用下さい。 - 屋根材製品の形状・仕様はメーカーより変更される場合が御座います。 - - 又、現場環境(働き、勾配、瓦桟木条件等)により本適合表と異なる適合結果となる場合が御座います。予めご了承下さい。 - - 屋根材以外の設置条件(垂木、野地板等の設置基準)も必ずご確認下さい。 - + {firstPage && ( + + + ハンファジャパン株式会社 + {fileTitle} + {createTime} + + + 本適合表は参考資料としてご使用下さい。 + 屋根材製品の形状・仕様はメーカーより変更される場合が御座います。 + + 又、現場環境(働き、勾配、瓦桟木条件等)により本適合表と異なる適合結果となる場合が御座います。予めご了承下さい。 + + 屋根材以外の設置条件(垂木、野地板等の設置基準)も必ずご確認下さい。 + + + )} {/* Cards Section */} {data?.map((item: Suitable) => ( - {item.productName} - {item.manuFtCd} - {item.roofMtCd} + {/* Table Title */} + + {item.productName} + {item.manuFtCd} + {item.roofMtCd} + + {/* Table */} {/* Table Header */} @@ -102,14 +109,16 @@ export default function SuitablePdf({ data, fileTitle }: { data: Suitable[]; fil {/* Table Body */} - {JSON.parse(item.detail)?.map((subItem: SuitableDetail) => ( - - {item.roofShCd} - {subItem.trestleMfpcCd} - {subItem.trestleManufacturerProductName} - {subItem.memo} - - ))} + + {JSON.parse(item.detail)?.map((subItem: SuitableDetail) => ( + + {item.roofShCd} + {subItem.trestleMfpcCd} + {subItem.trestleManufacturerProductName} + {subItem.memo} + + ))} + ))}