diff --git a/src/App.tsx b/src/App.tsx index 0b58f27..bee5e9f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -21,6 +21,7 @@ import { MobileExportView } from './components/MobileExportView'; import { MobileDesignView } from './components/MobileDesignView'; import type { TableCellCoord } from './tableUtils'; import { LayoutPreview } from './components/PreviewGrid'; +import { LabelLayoutSizeCalculator } from './components/LabelLayoutSizeCalculator'; import { CanvasLabelImage } from './components/CanvasLabelImage'; import { renderLabelToDataUrl, resolveReferenceBackgroundForOutput, resolveReferenceBackgroundForPreview } from './labelRenderer'; import { exportLabelsPdfAsync, exportLabelsPdfBlobAsync, type PdfExportProgress } from './pdfExport'; @@ -171,6 +172,7 @@ export default function App() { const [newTmplName, setNewTmplName] = useState(''); const [newTmplW, setNewTmplW] = useState('75'); const [newTmplH, setNewTmplH] = useState('45'); + const [newTmplPaper, setNewTmplPaper] = useState(null); const [editingTemplateId, setEditingTemplateId] = useState(null); const [editTmplName, setEditTmplName] = useState(''); const [templateMenuId, setTemplateMenuId] = useState(null); @@ -407,12 +409,12 @@ export default function App() { } const width = Number(newTmplW); const height = Number(newTmplH); - if (!newTmplW.trim() || Number.isNaN(width) || width < 10 || width > 150) { - await showAlert('请输入合法的标签宽度(10–150 mm)', { variant: 'warning' }); + if (!newTmplW.trim() || Number.isNaN(width) || width < 10) { + await showAlert('请输入合法的标签宽度(>10 mm)', { variant: 'warning' }); return; } - if (!newTmplH.trim() || Number.isNaN(height) || height < 10 || height > 150) { - await showAlert('请输入合法的标签高度(10–150 mm)', { variant: 'warning' }); + if (!newTmplH.trim() || Number.isNaN(height) || height < 10) { + await showAlert('请输入合法的标签高度(>10 mm)', { variant: 'warning' }); return; } const newId = `custom_tmpl_${Date.now()}`; @@ -422,7 +424,7 @@ export default function App() { width, height, elements: [], - paper: DEFAULT_PAPER_CONFIG, + paper: newTmplPaper ?? DEFAULT_PAPER_CONFIG, cutLine: DEFAULT_CUT_LINE_CONFIG, }; @@ -430,6 +432,7 @@ export default function App() { saveTemplatesToStorage(list); handleSelectTemplate(newId); setNewTmplName(''); + setNewTmplPaper(null); setShowAddTmplForm(false); setWorkflowStep('template-design'); }; @@ -439,6 +442,7 @@ export default function App() { setNewTmplName(''); setNewTmplW('75'); setNewTmplH('45'); + setNewTmplPaper(null); }; const openEditTemplateDialog = (tmpl: LabelTemplate) => { @@ -766,13 +770,19 @@ export default function App() { ]); const pdfExportImage = useMemo(() => { + const printMode = workingTemplate.paper?.printColorMode ?? 'rgb'; + const needsLosslessColor = printMode !== 'rgb'; const hasCode = workingTemplate.elements.some( (e) => e.type === 'barcode' || e.type === 'qrcode' ); - return hasCode - ? { imageFormat: 'png' as const, jsPdfFormat: 'PNG' as const, scale: 10 } - : { imageFormat: 'jpeg' as const, jsPdfFormat: 'JPEG' as const, scale: 10 }; - }, [workingTemplate.elements]); + const usePng = needsLosslessColor || hasCode; + return { + imageFormat: usePng ? ('png' as const) : ('jpeg' as const), + jsPdfFormat: usePng ? ('PNG' as const) : ('JPEG' as const), + scale: needsLosslessColor ? 12 : 10, + jpegQuality: 0.95, + }; + }, [workingTemplate.elements, workingTemplate.paper?.printColorMode]); const handleExportImageFileNamePatternChange = (pattern: string) => { if (!activeTemplate) return; @@ -819,7 +829,8 @@ export default function App() { mode: 'output', scale: pdfExportImage.scale, imageFormat: pdfExportImage.imageFormat, - jpegQuality: 0.9, + jpegQuality: pdfExportImage.jpegQuality, + printColorMode: activeTemplate!.paper?.printColorMode ?? 'rgb', ...resolveReferenceBackgroundForOutput(activeTemplate!), }), [activeTemplate, pdfExportImage] @@ -1718,6 +1729,13 @@ export default function App() { className="w-full px-3 py-2 bg-gray-50 border border-gray-200 rounded-lg text-sm focus:border-indigo-400 focus:outline-none" /> + { + setNewTmplW(String(width)); + setNewTmplH(String(height)); + setNewTmplPaper(paper); + }} + />
@@ -1841,6 +1843,7 @@ export const LabelDesigner: React.FC = ({ showBorder={false} transparentBackground variableDefaults={previewDefaults} + printColorMode={printColorMode} renderScale={canvasRenderScale} />
diff --git a/src/components/LabelLayoutSizeCalculator.tsx b/src/components/LabelLayoutSizeCalculator.tsx new file mode 100644 index 0000000..9875549 --- /dev/null +++ b/src/components/LabelLayoutSizeCalculator.tsx @@ -0,0 +1,240 @@ +import React, { useState } from 'react'; +import { Calculator, ChevronDown, ChevronUp } from 'lucide-react'; +import type { PaperConfig, PaperType } from '../types'; +import { computeLabelSizeFromLayout, DEFAULT_PAPER_CONFIG } from '../utils'; +import { CommitInput } from './CommitInput'; +import { FieldSelect } from './PsSelect'; +import { useAppDialog } from './AppDialog'; + +const fieldClass = + 'w-full px-3 py-2 bg-gray-50 border border-gray-200 rounded-lg text-sm font-mono focus:border-indigo-400 focus:outline-none'; +const labelClass = 'block text-[11px] font-semibold text-gray-500 mb-1.5'; + +interface LabelLayoutSizeCalculatorProps { + onCalculated: (result: { width: number; height: number; paper: PaperConfig }) => void; +} + +function handlePaperTypeChange(paper: PaperConfig, type: PaperType): PaperConfig { + let width = 210; + let height = 297; + if (type === 'A5') { + width = 148; + height = 210; + } else if (type === 'A6') { + width = 105; + height = 148; + } else if (type === 'custom') { + width = paper.width; + height = paper.height; + } + return { ...paper, type, width, height }; +} + +export const LabelLayoutSizeCalculator: React.FC = ({ + onCalculated, +}) => { + const { showAlert } = useAppDialog(); + const [open, setOpen] = useState(false); + const [paper, setPaper] = useState(DEFAULT_PAPER_CONFIG); + const [cols, setCols] = useState('3'); + const [rows, setRows] = useState('8'); + + const patchPaper = (patch: Partial) => { + setPaper((prev) => ({ ...prev, ...patch })); + }; + + const handleCalculate = async () => { + const colCount = Math.max(1, Math.min(99, Number(cols) || 0)); + const rowCount = Math.max(1, Math.min(99, Number(rows) || 0)); + if (!cols.trim() || !rows.trim() || colCount < 1 || rowCount < 1) { + await showAlert('请输入有效的排版列数与行数。', { variant: 'warning' }); + return; + } + + const size = computeLabelSizeFromLayout(paper, colCount, rowCount); + if (!size) { + await showAlert( + '无法根据当前纸张、边距与间距计算出有效标签尺寸(单边至少 10 mm)。请调整参数后重试。', + { variant: 'warning' } + ); + return; + } + + onCalculated({ width: size.width, height: size.height, paper }); + setOpen(false); + }; + + return ( +
+ + + {open && ( +
+

+ 设置纸张、页边距与 n×m 排版后,将自动反算单个标签的宽高并填入上方表单。 +

+ +
+
+ + patchPaper(handlePaperTypeChange(paper, e.target.value as PaperType))} + className={fieldClass} + > + + + + + +
+ + {paper.type === 'custom' && ( + <> +
+ + patchPaper({ width: Math.max(10, Number(val) || 10) })} + className={fieldClass} + /> +
+
+ + patchPaper({ height: Math.max(10, Number(val) || 10) })} + className={fieldClass} + /> +
+ + )} + +
+ +
+ + +
+
+ +
+ + setCols(val)} + className={fieldClass} + /> +
+
+ + setRows(val)} + className={fieldClass} + /> +
+ + {( + [ + ['上边距', 'marginTop', paper.marginTop], + ['下边距', 'marginBottom', paper.marginBottom], + ['左边距', 'marginLeft', paper.marginLeft], + ['右边距', 'marginRight', paper.marginRight], + ] as const + ).map(([label, key, val]) => ( +
+ + patchPaper({ [key]: Math.max(0, Number(v) || 0) })} + className={fieldClass} + /> +
+ ))} + +
+ + patchPaper({ columnGap: Math.max(0, Number(val) || 0) })} + className={fieldClass} + /> +
+
+ + patchPaper({ rowGap: Math.max(0, Number(val) || 0) })} + className={fieldClass} + /> +
+
+ +
+ +
+
+ )} +
+ ); +}; diff --git a/src/components/PreviewGrid.tsx b/src/components/PreviewGrid.tsx index d49ded5..ecb9267 100644 --- a/src/components/PreviewGrid.tsx +++ b/src/components/PreviewGrid.tsx @@ -1,6 +1,6 @@ import React, { useMemo, useState, useEffect, useRef } from 'react'; import { createPortal } from 'react-dom'; -import { PaperConfig, LabelTemplate, RecordRow, PaperType, CutLineConfig, ExportRangeConfig } from '../types'; +import { PaperConfig, LabelTemplate, RecordRow, PaperType, CutLineConfig, ExportRangeConfig, PrintColorMode, PRINT_COLOR_MODE_LABELS } from '../types'; import { computeAutoLayoutPaper, DEFAULT_EXPORT_RANGE, @@ -66,7 +66,8 @@ const paperEquals = (a: PaperConfig, b: PaperConfig) => a.marginLeft === b.marginLeft && a.columnGap === b.columnGap && a.rowGap === b.rowGap && - a.flow === b.flow; + a.flow === b.flow && + (a.printColorMode ?? 'rgb') === (b.printColorMode ?? 'rgb'); export const PaperSettingsPanel: React.FC = ({ paper, @@ -519,6 +520,29 @@ export const PaperSettingsPanel: React.FC = ({ +
+

印刷色彩

+
+ + + patchDraftPaper({ printColorMode: e.target.value as PrintColorMode }) + } + className={selectClass} + > + {(Object.keys(PRINT_COLOR_MODE_LABELS) as PrintColorMode[]).map((mode) => ( + + ))} + +

+ 预览与导出时对整页位图统一处理;CMYK 含总墨量限制,色彩模式非 RGB 时强制 PNG 导出以减少损色 +

+
+
+ {autoLayoutDialog} @@ -862,6 +886,7 @@ export const LayoutPreview: React.FC = ({ rowIndex={item.sourceIndex} showBorder={false} mode="output" + printColorMode={paper.printColorMode ?? 'rgb'} {...referenceBackgroundRender} /> diff --git a/src/components/PropSidebar.tsx b/src/components/PropSidebar.tsx index e49404e..50d9436 100644 --- a/src/components/PropSidebar.tsx +++ b/src/components/PropSidebar.tsx @@ -13,6 +13,7 @@ import { import { VisibilityRuleDialog } from './VisibilityRuleDialog'; import { ElementMaskDialog } from './ElementMaskDialog'; import { VisibilityRuleSummary } from './VisibilityRuleSummary'; +import { ColorValueField } from './ColorValueField'; import { ValueExtractFields } from './ValueExtractFields'; import { TextDisplayAffixFields } from './TextDisplayAffixFields'; import { @@ -1358,22 +1359,19 @@ export const PropSidebar: React.FC = ({ {/* Text color */}
- -
- handleElemChange({ ...selectedElem, textColor: e.target.value })} - className="color-input shrink-0" - /> - handleElemChange({ ...selectedElem, textColor: e.target.value })} - className="ps-field font-mono text-[11px] flex-1 min-w-0" - /> -
+ + handleElemChange({ + ...selectedElem, + textColor: val || undefined, + }) + } + variableOptions={templateVariables} + defaultColor="#111827" + placeholder="#111827" + />
@@ -1939,6 +1937,35 @@ export const PropSidebar: React.FC = ({ className="ps-field font-mono text-[11px] flex-1 min-w-0" />
+ {templateVariables.length > 0 && ( +
+ {templateVariables.map((v) => ( + + ))} +
+ )}
不透明度 @@ -2077,35 +2104,20 @@ export const PropSidebar: React.FC = ({
-
- -
- - handleElemChange({ - ...selectedElem, - borderColor: e.target.value, - borderWidth: selectedElem.borderWidth || 0.3, - }) - } - className="color-input shrink-0" - /> - - handleElemChange({ - ...selectedElem, - borderColor: e.target.value, - }) - } - className="ps-field font-mono text-[11px] flex-1 min-w-0" - /> -
-
+ + handleElemChange({ + ...selectedElem, + borderColor: val || undefined, + borderWidth: selectedElem.borderWidth || 0.3, + }) + } + variableOptions={templateVariables} + defaultColor="#111827" + placeholder="#111827" + />
diff --git a/src/components/TableCellTextFields.tsx b/src/components/TableCellTextFields.tsx index 3dabe7b..50a1c49 100644 --- a/src/components/TableCellTextFields.tsx +++ b/src/components/TableCellTextFields.tsx @@ -3,6 +3,7 @@ import { TemplateElement } from '../types'; import { CommitInput } from './CommitInput'; import { FieldSelect } from './PsSelect'; import { ValueExtractFields } from './ValueExtractFields'; +import { ColorValueField } from './ColorValueField'; import { TextDisplayAffixFields } from './TextDisplayAffixFields'; import { applyCellTextElementChange, @@ -173,22 +174,14 @@ export const TableCellTextFields: React.FC = ({
- -
- patchText({ ...textElem, textColor: e.target.value })} - className="color-input shrink-0" - /> - patchText({ ...textElem, textColor: e.target.value })} - className="ps-field font-mono text-[11px] flex-1 min-w-0" - /> -
+ patchText({ ...textElem, textColor: val || undefined })} + variableOptions={templateVariables} + defaultColor="#111827" + placeholder="#111827" + />
diff --git a/src/components/TablePropertyFields.tsx b/src/components/TablePropertyFields.tsx index d5498e7..3303f21 100644 --- a/src/components/TablePropertyFields.tsx +++ b/src/components/TablePropertyFields.tsx @@ -23,6 +23,7 @@ import { } from '../tableUtils'; import { Table2, Merge, SplitSquareHorizontal } from 'lucide-react'; import { TableCellTextFields } from './TableCellTextFields'; +import { ColorValueField } from './ColorValueField'; const PropInput = CommitInput; @@ -167,15 +168,14 @@ export const TablePropertyFields: React.FC = ({ className="ps-field font-mono" />
-
- - onChange({ ...elem, tableBorderColor: e.target.value })} - className="color-input w-full" - /> -
+ onChange({ ...elem, tableBorderColor: val || undefined })} + variableOptions={templateVariables} + defaultColor="#374151" + placeholder="#374151" + /> {normalizedCells.length > 0 && ( diff --git a/src/labelRenderer.ts b/src/labelRenderer.ts index da5573e..69be61f 100644 --- a/src/labelRenderer.ts +++ b/src/labelRenderer.ts @@ -28,6 +28,13 @@ import { getCellRectPx, } from './tableUtils'; import { composeElementLayer, isElementMaskActive } from './elementMask'; +import { + resolveElementColorsForRender, + resolveRenderableColor, + applyPrintColorModeToCanvas, + type RenderColorContext, +} from './colorUtils'; +import type { PrintColorMode } from './types'; const GENERATOR_TRANSPARENT_BG = '#00000000'; @@ -537,10 +544,12 @@ async function drawTableElement( rowData: RecordRow | null, rowIndex: number, variableDefaults: Record | null | undefined, - mode: ContentResolveMode + mode: ContentResolveMode, + colorCtx: RenderColorContext ) { - const radii = resolveCornerRadiiPx(elem, scale, ew, eh); - const sides = resolveBorderSides(elem); + const renderElem = resolveElementColorsForRender(elem, colorCtx); + const radii = resolveCornerRadiiPx(renderElem, scale, ew, eh); + const sides = resolveBorderSides(renderElem); const drawCellContent = async ( targetCtx: CanvasRenderingContext2D, @@ -569,12 +578,16 @@ async function drawTableElement( ); const cellData = getCellData(elem, row, col); - if (cellData?.backgroundColor && cellData.backgroundColor !== 'transparent') { - targetCtx.fillStyle = cellData.backgroundColor; + const cellBg = resolveRenderableColor(cellData?.backgroundColor, colorCtx); + if (cellBg && cellBg !== 'transparent') { + targetCtx.fillStyle = cellBg; targetCtx.fillRect(cellX, cellY, cellW, cellH); } - const textElem = getEffectiveCellStyle(elem, row, col); + const textElem = resolveElementColorsForRender( + getEffectiveCellStyle(elem, row, col), + colorCtx + ); if (!textElem.content.trim()) continue; const cellValue = resolveElementValue(textElem, rowData, rowIndex, variableDefaults, mode); @@ -603,13 +616,13 @@ async function drawTableElement( targetCtx.restore(); }; - await composeElementLayer(ctx, elem, ex, ey, ew, eh, scale, async (layerCtx, ox, oy, ow, oh) => { - drawElementFill(layerCtx, ox, oy, ow, oh, elem, radii); + await composeElementLayer(ctx, renderElem, ex, ey, ew, eh, scale, async (layerCtx, ox, oy, ow, oh) => { + drawElementFill(layerCtx, ox, oy, ow, oh, renderElem, radii); await drawCellContent(layerCtx, ox, oy); - drawElementBorders(layerCtx, ox, oy, ow, oh, radii, sides, elem, scale); + drawElementBorders(layerCtx, ox, oy, ow, oh, radii, sides, renderElem, scale); }); - drawTableGridLines(ctx, elem, ex, ey, scale); + drawTableGridLines(ctx, renderElem, ex, ey, scale); } function drawImagePlaceholder( @@ -655,6 +668,8 @@ export interface RenderLabelOptions { jpegQuality?: number; /** 为 false 时不裁剪到画布边界(用于拖动浮层完整显示元素) */ clipToCanvas?: boolean; + /** 排版/印刷色彩模式 */ + printColorMode?: PrintColorMode; } export type ReferenceBackgroundRenderOptions = Pick< @@ -760,7 +775,8 @@ export async function renderLayoutPageBackgroundDataUrl( paperHeightMm: number, options: LayoutPageBackgroundRenderOptions, scale = 4, - imageFormat: 'png' | 'jpeg' = 'png' + imageFormat: 'png' | 'jpeg' = 'png', + printColorMode: PrintColorMode = 'rgb' ): Promise { const canvas = document.createElement('canvas'); canvas.width = Math.max(1, Math.round(paperWidthMm * scale)); @@ -779,8 +795,12 @@ export async function renderLayoutPageBackgroundDataUrl( drawImageWithFit(ctx, img, 0, 0, canvas.width, canvas.height, options.fit); ctx.restore(); + if (printColorMode !== 'rgb') { + applyPrintColorModeToCanvas(canvas, printColorMode); + } + return imageFormat === 'jpeg' - ? canvas.toDataURL('image/jpeg', 0.92) + ? canvas.toDataURL('image/jpeg', 0.95) : canvas.toDataURL('image/png'); } @@ -803,8 +823,17 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise imageFormat = 'png', jpegQuality = 0.92, clipToCanvas = true, + printColorMode = 'rgb', } = options; + const colorCtx: RenderColorContext = { + printColorMode, + row: rowData, + variableDefaults, + mode, + rowIndex, + }; + const scale = scaleOption; const canvas = document.createElement('canvas'); canvas.width = Math.max(1, Math.round(widthMm * scale)); @@ -861,31 +890,33 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise continue; } + const renderElem = resolveElementColorsForRender(elem, colorCtx); + ctx.save(); applyElementRotation(ctx, ex, ey, ew, eh, elem.rotation); if (elem.type === 'text') { - const radii = resolveCornerRadiiPx(elem, scale, ew, eh); - const sides = resolveBorderSides(elem); + const radii = resolveCornerRadiiPx(renderElem, scale, ew, eh); + const sides = resolveBorderSides(renderElem); - await composeElementLayer(ctx, elem, ex, ey, ew, eh, scale, async (layerCtx, ox, oy, ow, oh) => { - drawElementFill(layerCtx, ox, oy, ow, oh, elem, radii); - drawTextContentInBox(layerCtx, elem, ox, oy, ow, oh, String(value), scale); - drawElementBorders(layerCtx, ox, oy, ow, oh, radii, sides, elem, scale); + await composeElementLayer(ctx, renderElem, ex, ey, ew, eh, scale, async (layerCtx, ox, oy, ow, oh) => { + drawElementFill(layerCtx, ox, oy, ow, oh, renderElem, radii); + drawTextContentInBox(layerCtx, renderElem, ox, oy, ow, oh, String(value), scale); + drawElementBorders(layerCtx, ox, oy, ow, oh, radii, sides, renderElem, scale); }); } else if (elem.type === 'barcode') { try { - const radii = resolveCornerRadiiPx(elem, scale, ew, eh); - const sides = resolveBorderSides(elem); + const radii = resolveCornerRadiiPx(renderElem, scale, ew, eh); + const sides = resolveBorderSides(renderElem); const barcodeValue = value ? String(value).trim() : mode === 'design' ? 'SAMPLE' : ''; - await composeElementLayer(ctx, elem, ex, ey, ew, eh, scale, async (layerCtx, ox, oy, ow, oh) => { - drawElementFill(layerCtx, ox, oy, ow, oh, elem, radii); + await composeElementLayer(ctx, renderElem, ex, ey, ew, eh, scale, async (layerCtx, ox, oy, ow, oh) => { + drawElementFill(layerCtx, ox, oy, ow, oh, renderElem, radii); if (!barcodeValue) { - drawElementBorders(layerCtx, ox, oy, ow, oh, radii, sides, elem, scale); + drawElementBorders(layerCtx, ox, oy, ow, oh, radii, sides, renderElem, scale); return; } - const inner = getPaddedRectPx(ox, oy, ow, oh, elem, scale); + const inner = getPaddedRectPx(ox, oy, ow, oh, renderElem, scale); const tempCanvas = document.createElement('canvas'); JsBarcode(tempCanvas, barcodeValue, { format: elem.barcodeFormat || 'CODE128', @@ -897,12 +928,12 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise background: GENERATOR_TRANSPARENT_BG, }); layerCtx.drawImage(tempCanvas, inner.x, inner.y, inner.w, inner.h); - drawElementBorders(layerCtx, ox, oy, ow, oh, radii, sides, elem, scale); + drawElementBorders(layerCtx, ox, oy, ow, oh, radii, sides, renderElem, scale); }); } catch (error) { console.warn('Barcode drawing failed', error); if (mode === 'design') { - const inner = getPaddedRectPx(ex, ey, ew, eh, elem, scale); + const inner = getPaddedRectPx(ex, ey, ew, eh, renderElem, scale); ctx.fillStyle = '#f3f4f6'; ctx.fillRect(inner.x, inner.y, inner.w, inner.h); ctx.strokeStyle = '#ef4444'; @@ -916,17 +947,17 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise } } else if (elem.type === 'qrcode') { try { - const radii = resolveCornerRadiiPx(elem, scale, ew, eh); - const sides = resolveBorderSides(elem); + const radii = resolveCornerRadiiPx(renderElem, scale, ew, eh); + const sides = resolveBorderSides(renderElem); const qrValue = value ? String(value).trim() : mode === 'design' ? 'SAMPLE QR' : ''; - await composeElementLayer(ctx, elem, ex, ey, ew, eh, scale, async (layerCtx, ox, oy, ow, oh) => { - drawElementFill(layerCtx, ox, oy, ow, oh, elem, radii); + await composeElementLayer(ctx, renderElem, ex, ey, ew, eh, scale, async (layerCtx, ox, oy, ow, oh) => { + drawElementFill(layerCtx, ox, oy, ow, oh, renderElem, radii); if (!qrValue) { - drawElementBorders(layerCtx, ox, oy, ow, oh, radii, sides, elem, scale); + drawElementBorders(layerCtx, ox, oy, ow, oh, radii, sides, renderElem, scale); return; } - const inner = getPaddedRectPx(ox, oy, ow, oh, elem, scale); + const inner = getPaddedRectPx(ox, oy, ow, oh, renderElem, scale); const tempCanvas = document.createElement('canvas'); await QRCode.toCanvas(tempCanvas, qrValue, { width: Math.max(64, inner.w), @@ -937,12 +968,12 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise }, }); layerCtx.drawImage(tempCanvas, inner.x, inner.y, inner.w, inner.h); - drawElementBorders(layerCtx, ox, oy, ow, oh, radii, sides, elem, scale); + drawElementBorders(layerCtx, ox, oy, ow, oh, radii, sides, renderElem, scale); }); } catch (error) { console.warn('QR code drawing failed', error); if (mode === 'design') { - const inner = getPaddedRectPx(ex, ey, ew, eh, elem, scale); + const inner = getPaddedRectPx(ex, ey, ew, eh, renderElem, scale); ctx.fillStyle = '#f3f4f6'; ctx.fillRect(inner.x, inner.y, inner.w, inner.h); ctx.strokeStyle = '#ef4444'; @@ -959,14 +990,14 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise continue; } - const radii = resolveCornerRadiiPx(elem, scale, ew, eh); - const sides = resolveBorderSides(elem); + const radii = resolveCornerRadiiPx(renderElem, scale, ew, eh); + const sides = resolveBorderSides(renderElem); const hasMask = isElementMaskActive(elem.mask); if (hasMask) { - await composeElementLayer(ctx, elem, ex, ey, ew, eh, scale, async (layerCtx, ox, oy, ow, oh) => { - drawElementFill(layerCtx, ox, oy, ow, oh, elem, radii); - const inner = getPaddedRectPx(ox, oy, ow, oh, elem, scale); + await composeElementLayer(ctx, renderElem, ex, ey, ew, eh, scale, async (layerCtx, ox, oy, ow, oh) => { + drawElementFill(layerCtx, ox, oy, ow, oh, renderElem, radii); + const inner = getPaddedRectPx(ox, oy, ow, oh, renderElem, scale); if (img) { drawImageWithFit( layerCtx, @@ -980,11 +1011,11 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise } else { drawImagePlaceholder(layerCtx, inner.x, inner.y, inner.w, inner.h, '图片'); } - drawElementBorders(layerCtx, ox, oy, ow, oh, radii, sides, elem, scale); + drawElementBorders(layerCtx, ox, oy, ow, oh, radii, sides, renderElem, scale); }); } else { - drawElementChrome(ctx, ex, ey, ew, eh, elem, scale); - const inner = getPaddedRectPx(ex, ey, ew, eh, elem, scale); + drawElementChrome(ctx, ex, ey, ew, eh, renderElem, scale); + const inner = getPaddedRectPx(ex, ey, ew, eh, renderElem, scale); if (img) { drawImageWithFit(ctx, img, inner.x, inner.y, inner.w, inner.h, elem.imageFit ?? 'contain'); } else { @@ -1003,7 +1034,8 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise rowData, rowIndex, variableDefaults, - mode + mode, + colorCtx ); } @@ -1012,6 +1044,10 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise ctx.restore(); + if (printColorMode !== 'rgb') { + applyPrintColorModeToCanvas(canvas, printColorMode); + } + return imageFormat === 'jpeg' ? canvas.toDataURL('image/jpeg', jpegQuality) : canvas.toDataURL('image/png'); diff --git a/src/pdfExport.ts b/src/pdfExport.ts index ac8dd20..8bbf821 100644 --- a/src/pdfExport.ts +++ b/src/pdfExport.ts @@ -35,6 +35,7 @@ type PageBackgroundCache = { dataUrl: string | null; key: string | null }; async function addPageBackgroundIfNeeded( pdf: jsPDF, template: LabelTemplate, + paper: PaperConfig, paperW: number, paperH: number, imageFormat: 'PNG' | 'JPEG', @@ -43,7 +44,8 @@ async function addPageBackgroundIfNeeded( const options = resolveLayoutPageBackgroundForOutput(template); if (!options) return; - const key = `${options.background}|${options.opacity}|${options.fit}|${paperW}|${paperH}|${imageFormat}`; + const printColorMode = paper.printColorMode ?? 'rgb'; + const key = `${options.background}|${options.opacity}|${options.fit}|${paperW}|${paperH}|${imageFormat}|${printColorMode}`; if (cache.key !== key) { cache.key = key; cache.dataUrl = await renderLayoutPageBackgroundDataUrl( @@ -51,7 +53,8 @@ async function addPageBackgroundIfNeeded( paperH, options, 4, - imageFormat === 'JPEG' ? 'jpeg' : 'png' + imageFormat === 'JPEG' ? 'jpeg' : 'png', + printColorMode ); } @@ -186,7 +189,7 @@ export async function exportLabelsPdfAsync(options: AsyncExportLabelsPdfOptions) pdf.addPage([paperW, paperH], orientation); } - await addPageBackgroundIfNeeded(pdf, template, paperW, paperH, imageFormat, pageBgCache); + await addPageBackgroundIfNeeded(pdf, template, paper, paperW, paperH, imageFormat, pageBgCache); const pageRows = printablePages[pIdx]; const gridRows = Math.ceil(pageRows.length / gridCols); @@ -290,7 +293,7 @@ export async function exportLabelsPdfBlobAsync( pdf.addPage([paperW, paperH], orientation); } - await addPageBackgroundIfNeeded(pdf, template, paperW, paperH, imageFormat, pageBgCache); + await addPageBackgroundIfNeeded(pdf, template, paper, paperW, paperH, imageFormat, pageBgCache); const pageRows = printablePages[pIdx]; const gridRows = Math.ceil(pageRows.length / gridCols); diff --git a/src/types.ts b/src/types.ts index 16dc982..d9bb71b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -336,6 +336,15 @@ export interface LabelTemplate { export type PaperType = 'A4' | 'A5' | 'A6' | 'custom'; +/** 排版/印刷输出色彩模式 */ +export type PrintColorMode = 'rgb' | 'grayscale' | 'cmyk'; + +export const PRINT_COLOR_MODE_LABELS: Record = { + rgb: '标准 RGB', + grayscale: '黑白(灰度)', + cmyk: 'CMYK(印刷模拟)', +}; + export interface PaperConfig { type: PaperType; width: number; // in mm @@ -348,6 +357,8 @@ export interface PaperConfig { columnGap: number; // in mm (horizontal spacing between labels) rowGap: number; // in mm (vertical spacing between labels) flow: 'top-bottom-left-right' | 'left-right-top-bottom'; // how cells flow + /** 排版预览与导出时的色彩模式,默认 rgb */ + printColorMode?: PrintColorMode; } export interface RecordRow { diff --git a/src/utils.ts b/src/utils.ts index e783826..57c44d3 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -537,8 +537,33 @@ export const DEFAULT_PAPER_CONFIG: PaperConfig = { columnGap: 0, rowGap: 0, flow: 'left-right-top-bottom', + printColorMode: 'rgb', }; +/** 根据纸张排版参数反算单个标签尺寸 (mm) */ +export function computeLabelSizeFromLayout( + paper: PaperConfig, + cols: number, + rows: number +): { width: number; height: number } | null { + if (cols < 1 || rows < 1) return null; + + const paperW = paper.orientation === 'portrait' ? paper.width : paper.height; + const paperH = paper.orientation === 'portrait' ? paper.height : paper.width; + const usableW = paperW - paper.marginLeft - paper.marginRight; + const usableH = paperH - paper.marginTop - paper.marginBottom; + if (usableW <= 0 || usableH <= 0) return null; + + const width = (usableW - (cols - 1) * paper.columnGap) / cols; + const height = (usableH - (rows - 1) * paper.rowGap) / rows; + if (width < 10 || height < 10) return null; + + return { + width: Math.round(width * 10) / 10, + height: Math.round(height * 10) / 10, + }; +} + /** 补全模板默认排版字段,剥离已废弃的数据源字段 */ export function normalizeTemplate(tmpl: LabelTemplate): LabelTemplate { const { ...rest } = tmpl;