|
1 | 1 | import { PdfExportAdapter, PdfExportPayload } from '../services/export.services'; |
2 | 2 |
|
| 3 | +const PAGE_SIZES: Record<string, [number, number]> = { |
| 4 | + A4: [595.28, 841.89], |
| 5 | + Letter: [612, 792], |
| 6 | +}; |
| 7 | + |
3 | 8 | export class JsPdfAdapter implements PdfExportAdapter { |
4 | | - async export({ fileName, columns, rows, options }: PdfExportPayload) { |
5 | | - const { jsPDF } = await import('jspdf/dist/jspdf.umd.min.js'); |
6 | | - const autoTable = (await import('jspdf-autotable')).default; |
7 | | - const doc = new jsPDF({ orientation: options?.landscape ? 'landscape' : 'portrait' }); |
8 | | - autoTable(doc, { head: [columns], body: rows.map(r => columns.map(k => r[k])), margin: options?.margins }); |
9 | | - doc.save(`${fileName}.pdf`); |
| 9 | + async export(payload: PdfExportPayload) { |
| 10 | + const pdf = this.createPdf(payload); |
| 11 | + const blob = new Blob([pdf], { type: 'application/pdf' }); |
| 12 | + const url = URL.createObjectURL(blob); |
| 13 | + const link = document.createElement('a'); |
| 14 | + link.href = url; |
| 15 | + link.download = `${payload.fileName}.pdf`; |
| 16 | + link.click(); |
| 17 | + URL.revokeObjectURL(url); |
| 18 | + } |
| 19 | + |
| 20 | + private createPdf({ fileName, columns, rows, options }: PdfExportPayload): string { |
| 21 | + const requestedSize = String(options?.pageSize || 'A4'); |
| 22 | + const baseSize = PAGE_SIZES[requestedSize] || PAGE_SIZES.A4; |
| 23 | + const landscape = Boolean(options?.landscape); |
| 24 | + const [pageWidth, pageHeight] = landscape ? [baseSize[1], baseSize[0]] : baseSize; |
| 25 | + const margin = this.resolveMargin(options?.margins); |
| 26 | + const lineHeight = 14; |
| 27 | + const maxLines = Math.max(1, Math.floor((pageHeight - margin.top - margin.bottom - 52) / lineHeight)); |
| 28 | + const tableLines = this.toTableLines(columns, rows); |
| 29 | + const pages = this.chunk(tableLines, maxLines); |
| 30 | + const objects: string[] = []; |
| 31 | + const pageIds: number[] = []; |
| 32 | + |
| 33 | + objects.push('<< /Type /Catalog /Pages 2 0 R >>'); |
| 34 | + objects.push(''); |
| 35 | + |
| 36 | + for (const [index, lines] of pages.entries()) { |
| 37 | + const pageObjectId = objects.length + 1; |
| 38 | + const contentObjectId = pageObjectId + 1; |
| 39 | + pageIds.push(pageObjectId); |
| 40 | + objects.push( |
| 41 | + `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${pageWidth} ${pageHeight}] /Resources << /Font << /F1 ${pages.length * 2 + 3} 0 R >> >> /Contents ${contentObjectId} 0 R >>` |
| 42 | + ); |
| 43 | + objects.push(this.contentStream(fileName, lines, index + 1, pages.length, pageHeight, margin, lineHeight)); |
| 44 | + } |
| 45 | + |
| 46 | + objects[1] = `<< /Type /Pages /Kids [${pageIds.map(id => `${id} 0 R`).join(' ')}] /Count ${pageIds.length} >>`; |
| 47 | + objects.push('<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>'); |
| 48 | + |
| 49 | + return this.serializePdf(objects); |
| 50 | + } |
| 51 | + |
| 52 | + private resolveMargin(margins?: [number, number, number, number]) { |
| 53 | + if (!Array.isArray(margins) || margins.length !== 4) { |
| 54 | + return { top: 40, right: 40, bottom: 40, left: 40 }; |
| 55 | + } |
| 56 | + const [top, right, bottom, left] = margins.map(value => Math.max(16, Number(value) || 40)); |
| 57 | + return { top, right, bottom, left }; |
| 58 | + } |
| 59 | + |
| 60 | + private toTableLines(columns: string[], rows: any[]): string[] { |
| 61 | + const headers = columns.map(column => this.printable(column)); |
| 62 | + const body = rows.map(row => columns.map(column => this.printable(row?.[column]))); |
| 63 | + const widths = headers.map((header, index) => |
| 64 | + Math.min( |
| 65 | + 24, |
| 66 | + Math.max( |
| 67 | + header.length, |
| 68 | + ...body.map(values => values[index]?.length || 0) |
| 69 | + ) |
| 70 | + ) |
| 71 | + ); |
| 72 | + const format = (values: string[]) => values.map((value, index) => value.padEnd(widths[index]).slice(0, widths[index])).join(' '); |
| 73 | + return [ |
| 74 | + format(headers), |
| 75 | + widths.map(width => '-'.repeat(width)).join(' '), |
| 76 | + ...body.map(format), |
| 77 | + ]; |
| 78 | + } |
| 79 | + |
| 80 | + private contentStream( |
| 81 | + fileName: string, |
| 82 | + lines: string[], |
| 83 | + page: number, |
| 84 | + totalPages: number, |
| 85 | + pageHeight: number, |
| 86 | + margin: { top: number; left: number }, |
| 87 | + lineHeight: number |
| 88 | + ): string { |
| 89 | + const commands = [ |
| 90 | + 'BT', |
| 91 | + `/F1 16 Tf ${margin.left} ${pageHeight - margin.top} Td (${this.pdfText(fileName)}) Tj`, |
| 92 | + `/F1 9 Tf 0 -24 Td (${this.pdfText(`Page ${page} of ${totalPages}`)}) Tj`, |
| 93 | + ...lines.map(line => `0 -${lineHeight} Td (${this.pdfText(line)}) Tj`), |
| 94 | + 'ET', |
| 95 | + ].join('\n'); |
| 96 | + return `<< /Length ${commands.length} >>\nstream\n${commands}\nendstream`; |
| 97 | + } |
| 98 | + |
| 99 | + private chunk<T>(items: T[], size: number): T[][] { |
| 100 | + const pages: T[][] = []; |
| 101 | + for (let index = 0; index < items.length; index += size) { |
| 102 | + pages.push(items.slice(index, index + size)); |
| 103 | + } |
| 104 | + return pages.length ? pages : [[]]; |
| 105 | + } |
| 106 | + |
| 107 | + private printable(value: unknown): string { |
| 108 | + if (value === null || value === undefined) { |
| 109 | + return ''; |
| 110 | + } |
| 111 | + return String(value).replace(/[^\x20-\x7E]/g, '?').replace(/\s+/g, ' ').trim(); |
| 112 | + } |
| 113 | + |
| 114 | + private pdfText(value: string): string { |
| 115 | + return value.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)'); |
| 116 | + } |
| 117 | + |
| 118 | + private serializePdf(objects: string[]): string { |
| 119 | + let pdf = '%PDF-1.4\n'; |
| 120 | + const offsets = [0]; |
| 121 | + for (const [index, object] of objects.entries()) { |
| 122 | + offsets[index + 1] = pdf.length; |
| 123 | + pdf += `${index + 1} 0 obj\n${object}\nendobj\n`; |
| 124 | + } |
| 125 | + const xrefOffset = pdf.length; |
| 126 | + pdf += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`; |
| 127 | + for (let index = 1; index <= objects.length; index++) { |
| 128 | + pdf += `${String(offsets[index]).padStart(10, '0')} 00000 n \n`; |
| 129 | + } |
| 130 | + pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF`; |
| 131 | + return pdf; |
10 | 132 | } |
11 | 133 | } |
0 commit comments