208 lines
5.6 KiB
TypeScript
208 lines
5.6 KiB
TypeScript
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<string>;
|
||
onProgress?: (progress: PdfExportProgress) => void;
|
||
}
|
||
|
||
const yieldToMain = () => new Promise<void>((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<void> {
|
||
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);
|
||
}
|