diff --git a/src/App.tsx b/src/App.tsx index e704fb6..b166b9b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -22,7 +22,12 @@ import type { TableCellCoord } from './tableUtils'; import { LayoutPreview } from './components/PreviewGrid'; import { CanvasLabelImage } from './components/CanvasLabelImage'; import { renderLabelToDataUrl } from './labelRenderer'; -import { exportLabelsPdfAsync, type PdfExportProgress } from './pdfExport'; +import { exportLabelsPdfAsync, exportLabelsPdfBlobAsync, type PdfExportProgress } from './pdfExport'; +import { + getAvailablePrinters, + saveSelectedPrinterId, + submitLabelsPrint, +} from './labelPrint'; import { useIsMobile } from './hooks/useIsMobile'; import { useUndoRedo } from './hooks/useUndoRedo'; import { useAppDialog } from './components/AppDialog'; @@ -136,6 +141,7 @@ export default function App() { const isMobile = useIsMobile(); const [pdfGenerating, setPdfGenerating] = useState(false); + const [printing, setPrinting] = useState(false); const [pdfProgress, setPdfProgress] = useState({ done: 0, total: 0, @@ -778,6 +784,65 @@ export default function App() { } }; + const printLabels = async (printerId: string) => { + if (!activeTemplate) return; + if (!appliedExportData) { + await showAlert('请先应用数据后再打印。', { variant: 'warning' }); + return; + } + if (appliedLayoutResult.selectedCount === 0) { + await showAlert('当前数据范围内没有可排版的标签,请调整范围设置。', { variant: 'warning' }); + return; + } + + saveSelectedPrinterId(printerId); + setPrinting(true); + + try { + const printers = await getAvailablePrinters(); + const blob = await exportLabelsPdfBlobAsync({ + paper, + template: activeTemplate, + printablePages: appliedLayoutResult.pages, + gridCols: appliedLayoutResult.gridCols, + cutLine, + imageFormat: pdfExportImage.jsPdfFormat, + renderLabel: (label) => + renderLabelToDataUrl({ + widthMm: activeTemplate.width, + heightMm: activeTemplate.height, + elements: activeTemplate.elements, + rowData: label.row, + rowIndex: label.sourceIndex, + showBorder: false, + mode: 'output', + scale: pdfExportImage.scale, + imageFormat: pdfExportImage.imageFormat, + jpegQuality: 0.9, + }), + }); + + const mode = await submitLabelsPrint(blob, printerId, printers); + if (mode === 'direct') { + await showAlert('已提交到所选打印机。', { variant: 'success' }); + } + } catch (e) { + console.error('Print failed', e); + await showAlert('打印失败,请重试。', { variant: 'error' }); + } finally { + setPrinting(false); + } + }; + + const printBlockedReason = + !appliedExportData + ? '请先应用数据' + : appliedLayoutResult.selectedCount === 0 + ? '当前数据范围内无标签' + : previewLayoutStale + ? '排版已变更,请重新绘制预览' + : null; + return (
setWorkflowStep('template-select')} onExportPdf={exportPdf} + onPrint={printLabels} + printDisabled={ + !appliedExportData || + appliedLayoutResult.selectedCount === 0 || + previewLayoutStale || + printing || + pdfGenerating + } + printDisabledReason={printBlockedReason} /> )} @@ -1365,6 +1440,16 @@ export default function App() { onApplyData={handleApplyData} onRedrawPreview={handleRedrawPreview} draftSelectedCount={selectedCount} + printing={printing} + onPrint={printLabels} + printDisabled={ + !appliedExportData || + appliedLayoutResult.selectedCount === 0 || + previewLayoutStale || + printing || + pdfGenerating + } + printDisabledReason={printBlockedReason} />
diff --git a/src/components/ExportSidebar.tsx b/src/components/ExportSidebar.tsx index 5569202..a6390df 100644 --- a/src/components/ExportSidebar.tsx +++ b/src/components/ExportSidebar.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { Database, Layout, Settings } from 'lucide-react'; +import { Database, Layout, Printer, Settings } from 'lucide-react'; import { ImportData, PaperConfig, LabelTemplate, RecordRow, ExportRangeConfig, CutLineConfig } from '../types'; import { DataImporter } from './DataImporter'; import { VariableMappingPanel } from './VariableMappingPanel'; @@ -7,6 +7,7 @@ import { PaperSettingsPanel, PageInput } from './PreviewGrid'; import { CollapsiblePanelSection } from './CollapsiblePanelSection'; import { DataRangeFields } from './DataRangeFields'; import { FieldSelect } from './PsSelect'; +import { PrintModule } from './PrintModule'; interface ExportSidebarProps { importData: ImportData | null; @@ -32,6 +33,10 @@ interface ExportSidebarProps { onApplyData: () => void; onRedrawPreview: () => void; draftSelectedCount: number; + printing: boolean; + onPrint: (printerId: string) => void; + printDisabled: boolean; + printDisabledReason?: string | null; /** 移动端精简:仅数据、映射、数据范围 */ compact?: boolean; } @@ -60,12 +65,17 @@ export const ExportSidebar: React.FC = ({ onApplyData, onRedrawPreview, draftSelectedCount, + printing, + onPrint, + printDisabled, + printDisabledReason = null, compact = false, }) => { - type ExportPanelKey = 'data' | 'layout'; + type ExportPanelKey = 'data' | 'layout' | 'print'; const [activePanel, setActivePanel] = useState('data'); const dataCollapsed = activePanel !== 'data'; const layoutCollapsed = activePanel !== 'layout'; + const printCollapsed = activePanel !== 'print'; const toggleExportPanel = (key: ExportPanelKey) => { setActivePanel((prev) => (prev === key ? null : key)); }; @@ -283,6 +293,22 @@ export const ExportSidebar: React.FC = ({ + + toggleExportPanel('print')} + icon={} + title="打印" + bodyClassName="ps-panel-body p-3 min-h-0 space-y-3" + > + + ); }; diff --git a/src/components/MobileExportView.tsx b/src/components/MobileExportView.tsx index 02fdf66..0200514 100644 --- a/src/components/MobileExportView.tsx +++ b/src/components/MobileExportView.tsx @@ -6,6 +6,7 @@ import { Database, Layout, Download, + Printer, } from 'lucide-react'; import { ImportData, @@ -21,8 +22,9 @@ import { VariableMappingPanel } from './VariableMappingPanel'; import { MobileExportLayoutPanel } from './MobileExportLayoutPanel'; import { DataRangeFields } from './DataRangeFields'; import { LayoutPreview } from './PreviewGrid'; +import { PrintModule } from './PrintModule'; -type MobileExportModule = 'data' | 'layout'; +type MobileExportModule = 'data' | 'layout' | 'print'; const MOBILE_EXPORT_MODULES: { id: MobileExportModule; @@ -31,6 +33,7 @@ const MOBILE_EXPORT_MODULES: { }[] = [ { id: 'data', label: '数据源', icon: }, { id: 'layout', label: '排版', icon: }, + { id: 'print', label: '打印', icon: }, ]; interface MobileExportViewProps { @@ -63,8 +66,12 @@ interface MobileExportViewProps { activeRowIndex: number; onSelectRowIndex: (idx: number) => void; pdfGenerating: boolean; + printing: boolean; onBack: () => void; onExportPdf: () => void; + onPrint: (printerId: string) => void; + printDisabled: boolean; + printDisabledReason?: string | null; } export const MobileExportView: React.FC = ({ @@ -97,8 +104,12 @@ export const MobileExportView: React.FC = ({ activeRowIndex, onSelectRowIndex, pdfGenerating, + printing, onBack, onExportPdf, + onPrint, + printDisabled, + printDisabledReason = null, }) => { const [mobileModule, setMobileModule] = useState('data'); @@ -262,6 +273,17 @@ export const MobileExportView: React.FC = ({ /> )} + {mobileModule === 'print' && ( +
+ +
+ )} diff --git a/src/components/PreviewGrid.tsx b/src/components/PreviewGrid.tsx index 30e45e2..2bf92a4 100644 --- a/src/components/PreviewGrid.tsx +++ b/src/components/PreviewGrid.tsx @@ -227,293 +227,293 @@ export const PaperSettingsPanel: React.FC = ({ const autoLayoutDialog = autoLayoutOpen && typeof document !== 'undefined' ? createPortal( +
e.stopPropagation()} + role="dialog" + aria-modal="true" + aria-labelledby="auto-layout-dialog-title" > -
e.stopPropagation()} - role="dialog" - aria-modal="true" - aria-labelledby="auto-layout-dialog-title" +
+

+ 自动排版 +

+ +
+
{ + e.preventDefault(); + void confirmAutoLayout(); + }} > -
-

- 自动排版 -

- +
- { - e.preventDefault(); - void confirmAutoLayout(); - }} - > -
-

- 将根据当前纸张与标签尺寸,在不低于最小页边距的前提下,自动计算左右、上下对称边距并居中排版。 -

-
- - setAutoLayoutMinMargin(e.target.value)} - className="ps-field font-mono" - /> -
-
-
- - -
-
-
-
, - document.body - ) + +
+ , + document.body + ) : null; return ( <> -
- {!isPs && !isMobile && ( -
-

排版

-
- )} - {isPs && ( -

- 排版参数修改后将自动更新预览。 -

- )} - -
-
-

纸张

-
- - handlePaperTypeChange(e.target.value as PaperType)} - className={selectClass} - > - - - - - +
+ {!isPs && !isMobile && ( +
+

排版

+ )} + {isPs && ( +

+ 排版参数修改后将自动更新预览。 +

+ )} - {draftPaper.type === 'custom' && ( -
-
- - patchDraftPaper({ width: Math.max(10, Number(val) || 10) })} - inputClassName={fieldClass} - /> -
-
- - patchDraftPaper({ height: Math.max(10, Number(val) || 10) })} - inputClassName={fieldClass} - /> -
+
+
+

纸张

+
+ + handlePaperTypeChange(e.target.value as PaperType)} + className={selectClass} + > + + + + +
- )} -
- - {isPs ? ( -
- - -
- ) : isMobile ? ( -
- - -
- ) : ( -
- - + {draftPaper.type === 'custom' && ( +
+
+ + patchDraftPaper({ width: Math.max(10, Number(val) || 10) })} + inputClassName={fieldClass} + /> +
+
+ + patchDraftPaper({ height: Math.max(10, Number(val) || 10) })} + inputClassName={fieldClass} + /> +
)} -
-
-
-

页边距 (mm)

-
- {( - [ - ['上边距', 'marginTop', draftPaper.marginTop], - ['下边距', 'marginBottom', draftPaper.marginBottom], - ['左边距', 'marginLeft', draftPaper.marginLeft], - ['右边距', 'marginRight', draftPaper.marginRight], - ] as const - ).map(([label, key, val]) => ( -
- +
+ + {isPs ? ( +
+ + +
+ ) : isMobile ? ( +
+ + +
+ ) : ( +
+ + +
+ )} +
+
+ +
+

页边距 (mm)

+
+ {( + [ + ['上边距', 'marginTop', draftPaper.marginTop], + ['下边距', 'marginBottom', draftPaper.marginBottom], + ['左边距', 'marginLeft', draftPaper.marginLeft], + ['右边距', 'marginRight', draftPaper.marginRight], + ] as const + ).map(([label, key, val]) => ( +
+ + patchDraftPaper({ [key]: Number(v) || 0 })} + inputClassName={fieldClass} + /> +
+ ))} +
+ + + 上次最小页边距 {lastAutoLayoutMinMargin} mm + +
+
+
+ +
+

标签间距 (mm)

+
+
+ patchDraftPaper({ [key]: Number(v) || 0 })} + max="50" + value={draftPaper.columnGap} + onCommit={(val) => patchDraftPaper({ columnGap: Number(val) || 0 })} + inputClassName={fieldClass} + /> +
+
+ + patchDraftPaper({ rowGap: Number(val) || 0 })} inputClassName={fieldClass} />
- ))} -
- - - 上次最小页边距 {lastAutoLayoutMinMargin} mm - -
-
-
- -
-

标签间距 (mm)

-
-
- - patchDraftPaper({ columnGap: Number(val) || 0 })} - inputClassName={fieldClass} - />
- - patchDraftPaper({ rowGap: Number(val) || 0 })} - inputClassName={fieldClass} - /> + + patchDraftPaper({ flow: e.target.value as PaperConfig['flow'] })} + className={selectClass} + > + + +
-
- - patchDraftPaper({ flow: e.target.value as PaperConfig['flow'] })} - className={selectClass} - > - - - -
-
+
-
- {autoLayoutDialog} + {autoLayoutDialog} ); }; @@ -576,6 +576,8 @@ export const LayoutPreview: React.FC = ({ const currentPaperW = paper.orientation === 'portrait' ? paper.width : paper.height; const currentPaperH = paper.orientation === 'portrait' ? paper.height : paper.width; + const pageCardWidthPx = Math.ceil(currentPaperW * previewScale); + const previewPageGapPx = isMobileVariant ? 16 : 32; const colGapPx = Math.max(0, paper.columnGap * previewScale); const rowGapPx = Math.max(0, paper.rowGap * previewScale); const labelWPx = template.width * previewScale; @@ -665,13 +667,6 @@ export const LayoutPreview: React.FC = ({ > + - @@ -693,9 +688,8 @@ export const LayoutPreview: React.FC = ({
{!previewReady ? (
@@ -708,28 +702,38 @@ export const LayoutPreview: React.FC = ({
) : (
{visiblePages.map((pageRows, pageIdx) => (
第 {pageIdx + 1} 页 / 共 {totalPages} 页 - + {/* {currentPaperW} × {currentPaperH} mm · {paper.orientation === 'portrait' ? '纵向' : '横向'} - + */}
void; + variant?: 'desktop' | 'mobile'; +} + +export const PrintModule: React.FC = ({ + disabled, + disabledReason, + printing, + onPrint, + variant = 'desktop', +}) => { + const [printers, setPrinters] = useState([ + { id: DEFAULT_PRINTER_ID, name: '系统默认(打印对话框)' }, + ]); + const [selectedPrinterId, setSelectedPrinterId] = useState(loadSelectedPrinterId); + const [loadingPrinters, setLoadingPrinters] = useState(false); + const [customPrinterName, setCustomPrinterName] = useState(''); + + const refreshPrinters = useCallback(async () => { + setLoadingPrinters(true); + try { + const list = await getAvailablePrinters(); + setPrinters(list); + const saved = loadSelectedPrinterId(); + if (list.some((p) => p.id === saved)) { + setSelectedPrinterId(saved); + } else { + setSelectedPrinterId(DEFAULT_PRINTER_ID); + } + } finally { + setLoadingPrinters(false); + } + }, []); + + useEffect(() => { + void refreshPrinters(); + }, [refreshPrinters]); + + const handlePrint = () => { + saveSelectedPrinterId(selectedPrinterId); + onPrint(selectedPrinterId); + }; + + const handleAddCustomPrinter = () => { + const trimmed = customPrinterName.trim(); + if (!trimmed) return; + const added = addCustomPrinter(trimmed); + setCustomPrinterName(''); + void refreshPrinters().then(() => { + setSelectedPrinterId(added.id); + saveSelectedPrinterId(added.id); + }); + }; + + const directAvailable = supportsDirectPrinting(printers); + const selectedPrinter = printers.find((p) => p.id === selectedPrinterId); + const usesDirect = !!selectedPrinter?.direct; + + return ( +
+
+ + +
+ setSelectedPrinterId(e.target.value)} + className="ps-field text-[11px]" + disabled={loadingPrinters || printing} + > + {printers.map((printer) => ( + + ))} + + + {!directAvailable && ( +
+ +
+ + +
+
+ )} + +

+ {usesDirect + ? '将直接提交到所选打印机。' + : directAvailable + ? '所选打印机将通过系统打印对话框确认后打印。' + : '当前浏览器不支持枚举打印机;打印时将打开系统对话框,请在对话框中选择对应打印机。已保存的名称便于下次快速识别。'} +

+ + {disabledReason && disabled && ( +

{disabledReason}

+ )} + + +
+ ); +}; diff --git a/src/components/TablePropertyFields.tsx b/src/components/TablePropertyFields.tsx index 6365b9e..4e5c9d9 100644 --- a/src/components/TablePropertyFields.tsx +++ b/src/components/TablePropertyFields.tsx @@ -16,6 +16,7 @@ import { updateTableRowHeight, getTableColWidths, getTableRowHeights, + getCellRawContent, type TableCellCoord, } from '../tableUtils'; import { Table2, Merge, SplitSquareHorizontal } from 'lucide-react'; diff --git a/src/labelPrint.ts b/src/labelPrint.ts new file mode 100644 index 0000000..e351001 --- /dev/null +++ b/src/labelPrint.ts @@ -0,0 +1,224 @@ +const SELECTED_PRINTER_KEY = 'label-designer.selected-printer-id'; +const CUSTOM_PRINTERS_KEY = 'label-designer.custom-printers'; + +export const DEFAULT_PRINTER_ID = '__system_default__'; + +export interface PrinterOption { + id: string; + name: string; + /** 是否来自 Web Printing API,可直接提交打印任务 */ + direct?: boolean; + native?: WebPrintingNativePrinter; +} + +interface WebPrintingNativePrinter { + printerId?: string; + printerName: string; + submitPrintJob(jobName: string, documentData: Blob, templateAttributes?: unknown): Promise; +} + +interface NavigatorWithPrinting extends Navigator { + printing?: { + getPrinters(): Promise; + }; +} + +function loadCustomPrinters(): PrinterOption[] { + try { + const raw = localStorage.getItem(CUSTOM_PRINTERS_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) return []; + return parsed + .filter((item): item is { id: string; name: string } => { + return ( + !!item && + typeof item === 'object' && + typeof (item as { id?: string }).id === 'string' && + typeof (item as { name?: string }).name === 'string' + ); + }) + .map((item) => ({ id: item.id, name: item.name })); + } catch { + return []; + } +} + +export function saveCustomPrinters(printers: PrinterOption[]) { + const custom = printers.filter((p) => p.id !== DEFAULT_PRINTER_ID && !p.direct); + localStorage.setItem(CUSTOM_PRINTERS_KEY, JSON.stringify(custom)); +} + +export function loadSelectedPrinterId(): string { + return localStorage.getItem(SELECTED_PRINTER_KEY) || DEFAULT_PRINTER_ID; +} + +export function saveSelectedPrinterId(printerId: string) { + localStorage.setItem(SELECTED_PRINTER_KEY, printerId); +} + +export async function getAvailablePrinters(): Promise { + const options: PrinterOption[] = [ + { id: DEFAULT_PRINTER_ID, name: '系统默认(打印对话框)' }, + ]; + const seen = new Set([DEFAULT_PRINTER_ID]); + + const printing = (navigator as NavigatorWithPrinting).printing; + if (printing?.getPrinters) { + try { + const nativePrinters = await printing.getPrinters(); + for (const printer of nativePrinters) { + const id = printer.printerId || printer.printerName; + if (!id || seen.has(id)) continue; + seen.add(id); + options.push({ + id, + name: printer.printerName, + direct: true, + native: printer, + }); + } + } catch (err) { + console.warn('getPrinters failed', err); + } + } + + for (const custom of loadCustomPrinters()) { + if (seen.has(custom.id)) continue; + seen.add(custom.id); + options.push(custom); + } + + return options; +} + +export function addCustomPrinter(name: string): PrinterOption { + const trimmed = name.trim(); + const id = `custom:${trimmed}`; + const next: PrinterOption = { id, name: trimmed }; + const existing = loadCustomPrinters().filter((p) => p.id !== id); + saveCustomPrinters([...existing, next]); + return next; +} + +interface PrintSurface { + win: Window; + dispose: () => void; +} + +function createPdfPrintSurface(url: string): PrintSurface | null { + const popup = window.open(url, '_blank'); + if (popup) { + return { + win: popup, + dispose: () => { + try { + popup.close(); + } catch { + /* ignore */ + } + }, + }; + } + + const iframe = document.createElement('iframe'); + iframe.setAttribute('title', 'label-print'); + Object.assign(iframe.style, { + position: 'fixed', + top: '0', + left: '0', + width: '100%', + height: '100%', + border: 'none', + opacity: '0', + pointerEvents: 'none', + zIndex: '2147483646', + }); + iframe.src = url; + document.body.appendChild(iframe); + + const iframeWin = iframe.contentWindow; + if (!iframeWin) { + iframe.remove(); + return null; + } + + return { + win: iframeWin, + dispose: () => iframe.remove(), + }; +} + +function schedulePdfPrint(surface: PrintSurface, onDone: () => void) { + let finished = false; + const finish = () => { + if (finished) return; + finished = true; + onDone(); + }; + + const trigger = () => { + try { + surface.win.focus(); + surface.win.print(); + finish(); + } catch (err) { + console.warn('print() failed', err); + } + }; + + surface.win.addEventListener('load', () => window.setTimeout(trigger, 800)); + window.setTimeout(trigger, 1500); + window.setTimeout(trigger, 2800); + window.setTimeout(finish, 4000); +} + +function printPdfBlob(blob: Blob): Promise { + return new Promise((resolve, reject) => { + if (!blob || blob.size === 0) { + reject(new Error('Empty PDF')); + return; + } + + const url = URL.createObjectURL(blob); + const surface = createPdfPrintSurface(url); + if (!surface) { + URL.revokeObjectURL(url); + reject(new Error('无法打开打印窗口,请允许弹出窗口后重试')); + return; + } + + let settled = false; + const cleanup = () => { + window.setTimeout(() => { + surface.dispose(); + URL.revokeObjectURL(url); + }, 60_000); + }; + + schedulePdfPrint(surface, () => { + if (settled) return; + settled = true; + cleanup(); + resolve(); + }); + }); +} + +export async function submitLabelsPrint( + pdfBlob: Blob, + printerId: string, + printers: PrinterOption[] +): Promise<'direct' | 'dialog'> { + const printer = printers.find((p) => p.id === printerId); + if (printer?.direct && printer.native) { + await printer.native.submitPrintJob('labels', pdfBlob); + return 'direct'; + } + await printPdfBlob(pdfBlob); + return 'dialog'; +} + +export function supportsDirectPrinting(printers: PrinterOption[]): boolean { + return printers.some((p) => p.direct); +} diff --git a/src/pdfExport.ts b/src/pdfExport.ts index 4ab38af..435afa6 100644 --- a/src/pdfExport.ts +++ b/src/pdfExport.ts @@ -205,3 +205,104 @@ export async function exportLabelsPdfAsync(options: AsyncExportLabelsPdfOptions) await yieldToMain(); pdf.save(filename); } + +/** 与 exportLabelsPdfAsync 相同渲染流程,返回 PDF Blob(用于浏览器打印) */ +export async function exportLabelsPdfBlobAsync( + options: Omit +): Promise { + const { + paper, + template, + printablePages, + gridCols, + cutLine, + 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(); + return pdf.output('blob'); +}