import { jsPDF } from 'jspdf'; import type { PaperConfig, LabelTemplate, CutLineConfig } from './types'; import { collectGridCutLinePositions, getCutLineChannel, type PrintableLabel } from './utils'; export interface PdfExportProgress { done: number; total: number; page: number; totalPages: number; phase: 'render' | 'save'; } export interface AsyncExportLabelsPdfOptions { paper: PaperConfig; template: LabelTemplate; printablePages: (PrintableLabel | null)[][]; gridCols: number; cutLine: CutLineConfig; filename?: string; imageFormat: 'PNG' | 'JPEG'; renderLabel: (label: PrintableLabel) => Promise; onProgress?: (progress: PdfExportProgress) => void; } const yieldToMain = () => new Promise((resolve) => setTimeout(resolve, 0)); const PAGE_RENDER_CONCURRENCY = 3; function parseColorToRgb(color: string): [number, number, number] { const c = color.trim(); if (c.startsWith('#')) { const hex = c.slice(1); if (hex.length === 3) { return [ parseInt(hex[0] + hex[0], 16), parseInt(hex[1] + hex[1], 16), parseInt(hex[2] + hex[2], 16), ]; } if (hex.length >= 6) { return [ parseInt(hex.slice(0, 2), 16), parseInt(hex.slice(2, 4), 16), parseInt(hex.slice(4, 6), 16), ]; } } return [204, 204, 204]; } function setupCutLinePen(pdf: jsPDF, cutLine: CutLineConfig) { const [r, g, b] = parseColorToRgb(cutLine.color); pdf.setDrawColor(r, g, b); pdf.setLineWidth(cutLine.width); if (cutLine.style === 'dashed') { const dash = Math.max(0.5, cutLine.width * 4); pdf.setLineDashPattern([dash, dash], 0); } else { pdf.setLineDashPattern([], 0); } } function drawPageGridCutLines( pdf: jsPDF, paper: PaperConfig, labelW: number, labelH: number, gridCols: number, gridRows: number, cutLine: CutLineConfig ) { const originX = paper.marginLeft; const originY = paper.marginTop; const lineW = Math.max(0.02, cutLine.width); const { xs, ys, gridW, gridH } = collectGridCutLinePositions( originX, originY, labelW, labelH, gridCols, gridRows, paper.columnGap, paper.rowGap ); const [r, g, b] = parseColorToRgb(cutLine.color); pdf.setFillColor(r, g, b); if (cutLine.style === 'solid') { for (const y of ys) { pdf.rect(originX, y - lineW / 2, gridW, lineW, 'F'); } for (const x of xs) { pdf.rect(x - lineW / 2, originY, lineW, gridH, 'F'); } return; } setupCutLinePen(pdf, cutLine); for (const y of ys) { pdf.line(originX, y, originX + gridW, y); } for (const x of xs) { pdf.line(x, originY, x, originY + gridH); } pdf.setLineDashPattern([], 0); } /** 按页流式渲染并写入 PDF,避免大批量时内存暴涨与长时间阻塞 */ export async function exportLabelsPdfAsync(options: AsyncExportLabelsPdfOptions): Promise { const { paper, template, printablePages, gridCols, cutLine, filename = 'labels.pdf', imageFormat, renderLabel, onProgress, } = options; const paperW = paper.orientation === 'portrait' ? paper.width : paper.height; const paperH = paper.orientation === 'portrait' ? paper.height : paper.width; const orientation = paperW > paperH ? 'landscape' : 'portrait'; const labelW = template.width; const labelH = template.height; const totalPages = printablePages.length; const totalLabels = printablePages.reduce( (sum, page) => sum + page.filter((item) => item !== null).length, 0 ); const pdf = new jsPDF({ orientation, unit: 'mm', format: [paperW, paperH], compress: true, }); let done = 0; let progressTick = 0; const report = (page: number, phase: PdfExportProgress['phase']) => { onProgress?.({ done, total: totalLabels, page, totalPages, phase }); }; for (let pIdx = 0; pIdx < printablePages.length; pIdx++) { if (pIdx > 0) { pdf.addPage([paperW, paperH], orientation); } const pageRows = printablePages[pIdx]; const gridRows = Math.ceil(pageRows.length / gridCols); const cutChannel = getCutLineChannel(paper.columnGap, paper.rowGap, cutLine); report(pIdx + 1, 'render'); const pageJobs: { cellIdx: number; label: PrintableLabel }[] = []; for (let cellIdx = 0; cellIdx < pageRows.length; cellIdx++) { const item = pageRows[cellIdx]; if (!item) continue; pageJobs.push({ cellIdx, label: item }); } for (let i = 0; i < pageJobs.length; i += PAGE_RENDER_CONCURRENCY) { const batch = pageJobs.slice(i, i + PAGE_RENDER_CONCURRENCY); const rendered = await Promise.all( batch.map(async (job) => ({ ...job, dataUrl: await renderLabel(job.label), })) ); for (const { cellIdx, dataUrl } of rendered) { const col = cellIdx % gridCols; const row = Math.floor(cellIdx / gridCols); const x = paper.marginLeft + col * (labelW + paper.columnGap) + cutChannel.x; const y = paper.marginTop + row * (labelH + paper.rowGap) + cutChannel.y; const drawW = labelW - 2 * cutChannel.x; const drawH = labelH - 2 * cutChannel.y; pdf.addImage(dataUrl, imageFormat, x, y, drawW, drawH, undefined, 'FAST'); done++; } progressTick++; if (progressTick >= 2) { progressTick = 0; report(pIdx + 1, 'render'); await yieldToMain(); } } if (cutLine.enabled && pageRows.length > 0) { drawPageGridCutLines(pdf, paper, labelW, labelH, gridCols, gridRows, cutLine); } report(pIdx + 1, 'render'); await yieldToMain(); } report(totalPages, 'save'); await yieldToMain(); pdf.save(filename); }