Files
label-designer/src/pdfExport.ts
2026-06-12 20:07:49 +08:00

208 lines
5.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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);
}