This commit is contained in:
iqudoo
2026-06-23 15:17:29 +08:00
parent 3a50e7e33b
commit 1b04e34ff0
14 changed files with 787 additions and 129 deletions

View File

@@ -21,6 +21,7 @@ import { MobileExportView } from './components/MobileExportView';
import { MobileDesignView } from './components/MobileDesignView'; import { MobileDesignView } from './components/MobileDesignView';
import type { TableCellCoord } from './tableUtils'; import type { TableCellCoord } from './tableUtils';
import { LayoutPreview } from './components/PreviewGrid'; import { LayoutPreview } from './components/PreviewGrid';
import { LabelLayoutSizeCalculator } from './components/LabelLayoutSizeCalculator';
import { CanvasLabelImage } from './components/CanvasLabelImage'; import { CanvasLabelImage } from './components/CanvasLabelImage';
import { renderLabelToDataUrl, resolveReferenceBackgroundForOutput, resolveReferenceBackgroundForPreview } from './labelRenderer'; import { renderLabelToDataUrl, resolveReferenceBackgroundForOutput, resolveReferenceBackgroundForPreview } from './labelRenderer';
import { exportLabelsPdfAsync, exportLabelsPdfBlobAsync, type PdfExportProgress } from './pdfExport'; import { exportLabelsPdfAsync, exportLabelsPdfBlobAsync, type PdfExportProgress } from './pdfExport';
@@ -171,6 +172,7 @@ export default function App() {
const [newTmplName, setNewTmplName] = useState<string>(''); const [newTmplName, setNewTmplName] = useState<string>('');
const [newTmplW, setNewTmplW] = useState('75'); const [newTmplW, setNewTmplW] = useState('75');
const [newTmplH, setNewTmplH] = useState('45'); const [newTmplH, setNewTmplH] = useState('45');
const [newTmplPaper, setNewTmplPaper] = useState<PaperConfig | null>(null);
const [editingTemplateId, setEditingTemplateId] = useState<string | null>(null); const [editingTemplateId, setEditingTemplateId] = useState<string | null>(null);
const [editTmplName, setEditTmplName] = useState<string>(''); const [editTmplName, setEditTmplName] = useState<string>('');
const [templateMenuId, setTemplateMenuId] = useState<string | null>(null); const [templateMenuId, setTemplateMenuId] = useState<string | null>(null);
@@ -407,12 +409,12 @@ export default function App() {
} }
const width = Number(newTmplW); const width = Number(newTmplW);
const height = Number(newTmplH); const height = Number(newTmplH);
if (!newTmplW.trim() || Number.isNaN(width) || width < 10 || width > 150) { if (!newTmplW.trim() || Number.isNaN(width) || width < 10) {
await showAlert('请输入合法的标签宽度10150 mm', { variant: 'warning' }); await showAlert('请输入合法的标签宽度(>10 mm', { variant: 'warning' });
return; return;
} }
if (!newTmplH.trim() || Number.isNaN(height) || height < 10 || height > 150) { if (!newTmplH.trim() || Number.isNaN(height) || height < 10) {
await showAlert('请输入合法的标签高度10150 mm', { variant: 'warning' }); await showAlert('请输入合法的标签高度(>10 mm', { variant: 'warning' });
return; return;
} }
const newId = `custom_tmpl_${Date.now()}`; const newId = `custom_tmpl_${Date.now()}`;
@@ -422,7 +424,7 @@ export default function App() {
width, width,
height, height,
elements: [], elements: [],
paper: DEFAULT_PAPER_CONFIG, paper: newTmplPaper ?? DEFAULT_PAPER_CONFIG,
cutLine: DEFAULT_CUT_LINE_CONFIG, cutLine: DEFAULT_CUT_LINE_CONFIG,
}; };
@@ -430,6 +432,7 @@ export default function App() {
saveTemplatesToStorage(list); saveTemplatesToStorage(list);
handleSelectTemplate(newId); handleSelectTemplate(newId);
setNewTmplName(''); setNewTmplName('');
setNewTmplPaper(null);
setShowAddTmplForm(false); setShowAddTmplForm(false);
setWorkflowStep('template-design'); setWorkflowStep('template-design');
}; };
@@ -439,6 +442,7 @@ export default function App() {
setNewTmplName(''); setNewTmplName('');
setNewTmplW('75'); setNewTmplW('75');
setNewTmplH('45'); setNewTmplH('45');
setNewTmplPaper(null);
}; };
const openEditTemplateDialog = (tmpl: LabelTemplate) => { const openEditTemplateDialog = (tmpl: LabelTemplate) => {
@@ -766,13 +770,19 @@ export default function App() {
]); ]);
const pdfExportImage = useMemo(() => { const pdfExportImage = useMemo(() => {
const printMode = workingTemplate.paper?.printColorMode ?? 'rgb';
const needsLosslessColor = printMode !== 'rgb';
const hasCode = workingTemplate.elements.some( const hasCode = workingTemplate.elements.some(
(e) => e.type === 'barcode' || e.type === 'qrcode' (e) => e.type === 'barcode' || e.type === 'qrcode'
); );
return hasCode const usePng = needsLosslessColor || hasCode;
? { imageFormat: 'png' as const, jsPdfFormat: 'PNG' as const, scale: 10 } return {
: { imageFormat: 'jpeg' as const, jsPdfFormat: 'JPEG' as const, scale: 10 }; imageFormat: usePng ? ('png' as const) : ('jpeg' as const),
}, [workingTemplate.elements]); jsPdfFormat: usePng ? ('PNG' as const) : ('JPEG' as const),
scale: needsLosslessColor ? 12 : 10,
jpegQuality: 0.95,
};
}, [workingTemplate.elements, workingTemplate.paper?.printColorMode]);
const handleExportImageFileNamePatternChange = (pattern: string) => { const handleExportImageFileNamePatternChange = (pattern: string) => {
if (!activeTemplate) return; if (!activeTemplate) return;
@@ -819,7 +829,8 @@ export default function App() {
mode: 'output', mode: 'output',
scale: pdfExportImage.scale, scale: pdfExportImage.scale,
imageFormat: pdfExportImage.imageFormat, imageFormat: pdfExportImage.imageFormat,
jpegQuality: 0.9, jpegQuality: pdfExportImage.jpegQuality,
printColorMode: activeTemplate!.paper?.printColorMode ?? 'rgb',
...resolveReferenceBackgroundForOutput(activeTemplate!), ...resolveReferenceBackgroundForOutput(activeTemplate!),
}), }),
[activeTemplate, pdfExportImage] [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" 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"
/> />
</div> </div>
<LabelLayoutSizeCalculator
onCalculated={({ width, height, paper }) => {
setNewTmplW(String(width));
setNewTmplH(String(height));
setNewTmplPaper(paper);
}}
/>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="block text-[11px] font-semibold text-gray-500 mb-1.5"> <label className="block text-[11px] font-semibold text-gray-500 mb-1.5">

213
src/colorUtils.ts Normal file
View File

@@ -0,0 +1,213 @@
import type { PrintColorMode, RecordRow, TemplateElement } from './types';
import { resolveContent, type ContentResolveMode } from './utils';
export interface RenderColorContext {
printColorMode?: PrintColorMode;
row: RecordRow | null;
variableDefaults?: Record<string, string> | null;
mode?: ContentResolveMode;
rowIndex?: number;
}
export function colorValueUsesVariable(value?: string): boolean {
return !!value?.includes('{');
}
export function isTransparentColorValue(value?: string): boolean {
if (!value) return true;
const trimmed = value.trim().toLowerCase();
return trimmed === '' || trimmed === 'transparent';
}
function parseHexColor(color: string): { r: number; g: number; b: number; a: number } | null {
const trimmed = color.trim();
if (!trimmed.startsWith('#')) return null;
let hex = trimmed.slice(1);
if (hex.length === 3) {
hex = hex.split('').map((c) => c + c).join('');
}
if (hex.length === 6) {
return {
r: parseInt(hex.slice(0, 2), 16),
g: parseInt(hex.slice(2, 4), 16),
b: parseInt(hex.slice(4, 6), 16),
a: 1,
};
}
if (hex.length === 8) {
return {
r: parseInt(hex.slice(0, 2), 16),
g: parseInt(hex.slice(2, 4), 16),
b: parseInt(hex.slice(4, 6), 16),
a: parseInt(hex.slice(6, 8), 16) / 255,
};
}
return null;
}
function parseRgbColor(color: string): { r: number; g: number; b: number; a: number } | null {
const rgba = color.match(
/rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)/i
);
if (!rgba) return null;
return {
r: Math.round(Number(rgba[1])),
g: Math.round(Number(rgba[2])),
b: Math.round(Number(rgba[3])),
a: rgba[4] !== undefined ? Math.min(1, Math.max(0, Number(rgba[4]))) : 1,
};
}
export function parseCssColor(color: string): { r: number; g: number; b: number; a: number } | null {
const trimmed = color.trim();
if (!trimmed || trimmed.toLowerCase() === 'transparent') return null;
return parseHexColor(trimmed) ?? parseRgbColor(trimmed);
}
function formatRgba(r: number, g: number, b: number, a = 1): string {
if (a >= 1) return `rgb(${r},${g},${b})`;
return `rgba(${r},${g},${b},${Math.round(a * 1000) / 1000})`;
}
function rgbToCmyk(r: number, g: number, b: number) {
const rn = r / 255;
const gn = g / 255;
const bn = b / 255;
const k = 1 - Math.max(rn, gn, bn);
if (k >= 1) return { c: 0, m: 0, y: 0, k: 1 };
const c = (1 - rn - k) / (1 - k);
const m = (1 - gn - k) / (1 - k);
const y = (1 - bn - k) / (1 - k);
return { c, m, y, k };
}
function cmykToRgb(c: number, m: number, y: number, k: number) {
return {
r: Math.round(255 * (1 - c) * (1 - k)),
g: Math.round(255 * (1 - m) * (1 - k)),
b: Math.round(255 * (1 - y) * (1 - k)),
};
}
/** 总墨量限制,避免过饱和(典型印刷 TAC ≈ 280300% */
function limitTotalInk(c: number, m: number, y: number, k: number, maxInk = 2.85) {
const total = c + m + y + k;
if (total <= maxInk || total <= 0) return { c, m, y, k };
const scale = maxInk / total;
return { c: c * scale, m: m * scale, y: y * scale, k: k * scale };
}
function transformRgbPixel(r: number, g: number, b: number, mode: PrintColorMode): [number, number, number] {
if (mode === 'grayscale') {
const lum = Math.round(0.299 * r + 0.587 * g + 0.114 * b);
return [lum, lum, lum];
}
let { c, m, y, k } = rgbToCmyk(r, g, b);
({ c, m, y, k } = limitTotalInk(c, m, y, k));
const rgb = cmykToRgb(c, m, y, k);
return [rgb.r, rgb.g, rgb.b];
}
export function applyPrintColorModeToImageData(
imageData: ImageData,
mode: PrintColorMode = 'rgb'
): void {
if (mode === 'rgb') return;
const { data } = imageData;
for (let i = 0; i < data.length; i += 4) {
const alpha = data[i + 3];
if (alpha === 0) continue;
const [nr, ng, nb] = transformRgbPixel(data[i], data[i + 1], data[i + 2], mode);
data[i] = nr;
data[i + 1] = ng;
data[i + 2] = nb;
}
}
export function applyPrintColorModeToCanvas(
canvas: HTMLCanvasElement,
mode: PrintColorMode = 'rgb'
): void {
if (mode === 'rgb') return;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) return;
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
applyPrintColorModeToImageData(imageData, mode);
ctx.putImageData(imageData, 0, 0);
}
export function applyPrintColorMode(color: string, mode: PrintColorMode = 'rgb'): string {
if (mode === 'rgb' || isTransparentColorValue(color)) return color;
const parsed = parseCssColor(color);
if (!parsed) return color;
const [r, g, b] = transformRgbPixel(parsed.r, parsed.g, parsed.b, mode);
return formatRgba(r, g, b, parsed.a);
}
/** 解析颜色字面量或 {变量},并应用印刷色彩模式 */
export function resolveRenderableColor(
raw: string | undefined,
ctx: RenderColorContext,
fallback?: string
): string | undefined {
const source = (raw ?? fallback ?? '').trim();
if (!source) return undefined;
if (isTransparentColorValue(source)) return 'transparent';
let resolved = source;
if (colorValueUsesVariable(source)) {
resolved = resolveContent(
source,
ctx.row,
ctx.rowIndex ?? 0,
ctx.variableDefaults,
{ mode: ctx.mode ?? 'design' }
).trim();
}
if (!resolved || isTransparentColorValue(resolved)) return 'transparent';
// 印刷色彩模式在整页画布合成后统一处理,避免逐色 + 全图双重转换
return resolved;
}
/** 为渲染解析元素上的颜色字段(含变量与印刷模式) */
export function resolveElementColorsForRender(
elem: TemplateElement,
ctx: RenderColorContext
): TemplateElement {
const textColor = resolveRenderableColor(elem.textColor, ctx, '#111827');
const backgroundColor = resolveRenderableColor(elem.backgroundColor, ctx);
const borderColor = resolveRenderableColor(elem.borderColor, ctx, '#111827');
const tableBorderColor = resolveRenderableColor(elem.tableBorderColor, ctx, '#374151');
const patchBorderSide = (value?: string) => {
if (!value?.trim()) return value;
return resolveRenderableColor(value, ctx, borderColor ?? '#111827');
};
return {
...elem,
textColor,
backgroundColor,
borderColor,
tableBorderColor,
borderColorTop: patchBorderSide(elem.borderColorTop),
borderColorRight: patchBorderSide(elem.borderColorRight),
borderColorBottom: patchBorderSide(elem.borderColorBottom),
borderColorLeft: patchBorderSide(elem.borderColorLeft),
};
}
/** 颜色选择器可用的预览色(变量色时回退默认色) */
export function colorPickerPreviewValue(value: string | undefined, fallback: string): string {
if (!value || isTransparentColorValue(value) || colorValueUsesVariable(value)) {
return fallback;
}
const parsed = parseCssColor(value);
if (!parsed) return fallback;
if (parsed.a < 1) return fallback;
const hex = (n: number) => n.toString(16).padStart(2, '0');
return `#${hex(parsed.r)}${hex(parsed.g)}${hex(parsed.b)}`;
}

View File

@@ -1,5 +1,6 @@
import React, { useState, useEffect, useMemo } from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import { TemplateElement } from '../types'; import { TemplateElement } from '../types';
import type { PrintColorMode } from '../types';
import { ContentResolveMode } from '../utils'; import { ContentResolveMode } from '../utils';
import { renderLabelToDataUrl } from '../labelRenderer'; import { renderLabelToDataUrl } from '../labelRenderer';
@@ -19,6 +20,7 @@ interface CanvasLabelImageProps {
referenceBackgroundFit?: 'contain' | 'cover' | 'fill'; referenceBackgroundFit?: 'contain' | 'cover' | 'fill';
/** 渲染倍率,设计画布应传 MM_TO_PX * zoom 与显示尺寸 1:1 对齐 */ /** 渲染倍率,设计画布应传 MM_TO_PX * zoom 与显示尺寸 1:1 对齐 */
renderScale?: number; renderScale?: number;
printColorMode?: PrintColorMode;
} }
export const CanvasLabelImage: React.FC<CanvasLabelImageProps> = ({ export const CanvasLabelImage: React.FC<CanvasLabelImageProps> = ({
@@ -35,6 +37,7 @@ export const CanvasLabelImage: React.FC<CanvasLabelImageProps> = ({
referenceBackgroundOpacity, referenceBackgroundOpacity,
referenceBackgroundFit, referenceBackgroundFit,
renderScale = 16, renderScale = 16,
printColorMode = 'rgb',
}) => { }) => {
const [dataUrl, setDataUrl] = useState<string>(''); const [dataUrl, setDataUrl] = useState<string>('');
const [initialLoading, setInitialLoading] = useState<boolean>(true); const [initialLoading, setInitialLoading] = useState<boolean>(true);
@@ -63,6 +66,7 @@ export const CanvasLabelImage: React.FC<CanvasLabelImageProps> = ({
referenceBackground, referenceBackground,
referenceBackgroundOpacity, referenceBackgroundOpacity,
referenceBackgroundFit, referenceBackgroundFit,
printColorMode,
}) })
.then((url) => { .then((url) => {
if (!active) return; if (!active) return;
@@ -100,6 +104,7 @@ export const CanvasLabelImage: React.FC<CanvasLabelImageProps> = ({
referenceBackground, referenceBackground,
referenceBackgroundOpacity, referenceBackgroundOpacity,
referenceBackgroundFit, referenceBackgroundFit,
printColorMode,
]); ]);
if (initialLoading && !dataUrl) { if (initialLoading && !dataUrl) {

View File

@@ -0,0 +1,74 @@
import React from 'react';
import { colorPickerPreviewValue, colorValueUsesVariable } from '../colorUtils';
import { CommitInput } from './CommitInput';
interface ColorValueFieldProps {
label?: string;
value: string;
onChange: (value: string) => void;
variableOptions?: string[];
defaultColor?: string;
placeholder?: string;
className?: string;
hint?: string;
}
export const ColorValueField: React.FC<ColorValueFieldProps> = ({
label,
value,
onChange,
variableOptions = [],
defaultColor = '#111827',
placeholder = '#111827',
className = '',
hint,
}) => {
const usesVariable = colorValueUsesVariable(value);
const pickerValue = colorPickerPreviewValue(value, defaultColor);
return (
<div className={className}>
{label && <label className="ps-field-label">{label}</label>}
<div className="flex gap-1 items-center">
<input
type="color"
value={pickerValue}
disabled={usesVariable}
onChange={(e) => onChange(e.target.value)}
className="color-input shrink-0 disabled:opacity-40"
title={usesVariable ? '当前为变量颜色,请使用文本框编辑' : undefined}
/>
<CommitInput
type="text"
value={value}
placeholder={placeholder}
onCommit={onChange}
className="ps-field font-mono text-[11px] flex-1 min-w-0"
/>
</div>
{variableOptions.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1.5">
{variableOptions.map((v) => (
<button
key={v}
type="button"
onClick={() => {
const token = `{${v}}`;
onChange(value.includes(token) ? value : value ? `${value} ${token}` : token);
}}
className="ps-btn text-[9px] font-mono"
>
{`{${v}}`}
</button>
))}
</div>
)}
{hint && <p className="text-[9px] text-[#666] mt-1 leading-relaxed">{hint}</p>}
{!hint && variableOptions.length > 0 && (
<p className="text-[9px] text-[#666] mt-1 leading-relaxed">
hex/rgb/transparent {`{变量名}`}
</p>
)}
</div>
);
};

View File

@@ -236,6 +236,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
() => template.variableDefaults ?? null, () => template.variableDefaults ?? null,
[template.variableDefaults] [template.variableDefaults]
); );
const printColorMode = template.paper?.printColorMode ?? 'rgb';
const showReferencePreview = useMemo( const showReferencePreview = useMemo(
() => () =>
@@ -1817,6 +1818,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
showBorder={false} showBorder={false}
transparentBackground={showReferencePreview} transparentBackground={showReferencePreview}
variableDefaults={previewDefaults} variableDefaults={previewDefaults}
printColorMode={printColorMode}
renderScale={canvasRenderScale} renderScale={canvasRenderScale}
/> />
</div> </div>
@@ -1841,6 +1843,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
showBorder={false} showBorder={false}
transparentBackground transparentBackground
variableDefaults={previewDefaults} variableDefaults={previewDefaults}
printColorMode={printColorMode}
renderScale={canvasRenderScale} renderScale={canvasRenderScale}
/> />
</div> </div>

View File

@@ -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<LabelLayoutSizeCalculatorProps> = ({
onCalculated,
}) => {
const { showAlert } = useAppDialog();
const [open, setOpen] = useState(false);
const [paper, setPaper] = useState<PaperConfig>(DEFAULT_PAPER_CONFIG);
const [cols, setCols] = useState('3');
const [rows, setRows] = useState('8');
const patchPaper = (patch: Partial<PaperConfig>) => {
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 (
<div className="border border-gray-200 rounded-lg overflow-hidden">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="w-full flex items-center justify-between gap-2 px-3 py-2.5 bg-gray-50 hover:bg-gray-100 text-left transition cursor-pointer"
>
<span className="flex items-center gap-2 text-[12px] font-medium text-gray-700">
<Calculator className="w-4 h-4 text-indigo-500" />
</span>
{open ? (
<ChevronUp className="w-4 h-4 text-gray-400 shrink-0" />
) : (
<ChevronDown className="w-4 h-4 text-gray-400 shrink-0" />
)}
</button>
{open && (
<div className="px-3 py-3 space-y-3 border-t border-gray-200 bg-white">
<p className="text-[11px] text-gray-500 leading-relaxed">
n×m
</p>
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2">
<label className={labelClass}></label>
<FieldSelect
value={paper.type}
onChange={(e) => patchPaper(handlePaperTypeChange(paper, e.target.value as PaperType))}
className={fieldClass}
>
<option value="A4">A4 (210×297 mm)</option>
<option value="A5">A5 (148×210 mm)</option>
<option value="A6">A6 (105×148 mm)</option>
<option value="custom"></option>
</FieldSelect>
</div>
{paper.type === 'custom' && (
<>
<div>
<label className={labelClass}> (mm)</label>
<CommitInput
type="number"
min="10"
value={paper.width}
onCommit={(val) => patchPaper({ width: Math.max(10, Number(val) || 10) })}
className={fieldClass}
/>
</div>
<div>
<label className={labelClass}> (mm)</label>
<CommitInput
type="number"
min="10"
value={paper.height}
onCommit={(val) => patchPaper({ height: Math.max(10, Number(val) || 10) })}
className={fieldClass}
/>
</div>
</>
)}
<div className="col-span-2">
<label className={labelClass}></label>
<div className="flex gap-2">
<button
type="button"
onClick={() => patchPaper({ orientation: 'portrait' })}
className={`flex-1 px-3 py-2 rounded-lg text-xs font-medium border cursor-pointer ${
paper.orientation === 'portrait'
? 'bg-indigo-50 border-indigo-300 text-indigo-700'
: 'bg-gray-50 border-gray-200 text-gray-600'
}`}
>
</button>
<button
type="button"
onClick={() => patchPaper({ orientation: 'landscape' })}
className={`flex-1 px-3 py-2 rounded-lg text-xs font-medium border cursor-pointer ${
paper.orientation === 'landscape'
? 'bg-indigo-50 border-indigo-300 text-indigo-700'
: 'bg-gray-50 border-gray-200 text-gray-600'
}`}
>
</button>
</div>
</div>
<div>
<label className={labelClass}> n</label>
<CommitInput
type="number"
min="1"
max="99"
value={cols}
onCommit={(val) => setCols(val)}
className={fieldClass}
/>
</div>
<div>
<label className={labelClass}> m</label>
<CommitInput
type="number"
min="1"
max="99"
value={rows}
onCommit={(val) => setRows(val)}
className={fieldClass}
/>
</div>
{(
[
['上边距', 'marginTop', paper.marginTop],
['下边距', 'marginBottom', paper.marginBottom],
['左边距', 'marginLeft', paper.marginLeft],
['右边距', 'marginRight', paper.marginRight],
] as const
).map(([label, key, val]) => (
<div key={key}>
<label className={labelClass}>{label} (mm)</label>
<CommitInput
type="number"
min="0"
max="100"
value={val}
onCommit={(v) => patchPaper({ [key]: Math.max(0, Number(v) || 0) })}
className={fieldClass}
/>
</div>
))}
<div>
<label className={labelClass}> (mm)</label>
<CommitInput
type="number"
min="0"
max="50"
value={paper.columnGap}
onCommit={(val) => patchPaper({ columnGap: Math.max(0, Number(val) || 0) })}
className={fieldClass}
/>
</div>
<div>
<label className={labelClass}> (mm)</label>
<CommitInput
type="number"
min="0"
max="50"
value={paper.rowGap}
onCommit={(val) => patchPaper({ rowGap: Math.max(0, Number(val) || 0) })}
className={fieldClass}
/>
</div>
</div>
<div className="flex justify-end">
<button
type="button"
onClick={() => void handleCalculate()}
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 text-xs font-semibold cursor-pointer"
>
</button>
</div>
</div>
)}
</div>
);
};

View File

@@ -1,6 +1,6 @@
import React, { useMemo, useState, useEffect, useRef } from 'react'; import React, { useMemo, useState, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom'; 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 { import {
computeAutoLayoutPaper, computeAutoLayoutPaper,
DEFAULT_EXPORT_RANGE, DEFAULT_EXPORT_RANGE,
@@ -66,7 +66,8 @@ const paperEquals = (a: PaperConfig, b: PaperConfig) =>
a.marginLeft === b.marginLeft && a.marginLeft === b.marginLeft &&
a.columnGap === b.columnGap && a.columnGap === b.columnGap &&
a.rowGap === b.rowGap && a.rowGap === b.rowGap &&
a.flow === b.flow; a.flow === b.flow &&
(a.printColorMode ?? 'rgb') === (b.printColorMode ?? 'rgb');
export const PaperSettingsPanel: React.FC<PaperSettingsPanelProps> = ({ export const PaperSettingsPanel: React.FC<PaperSettingsPanelProps> = ({
paper, paper,
@@ -519,6 +520,29 @@ export const PaperSettingsPanel: React.FC<PaperSettingsPanelProps> = ({
</div> </div>
</div> </div>
<div className="space-y-2">
<h4 className={sectionTitleClass}></h4>
<div>
<label className={labelClass}></label>
<FieldSelect
value={draftPaper.printColorMode ?? 'rgb'}
onChange={(e) =>
patchDraftPaper({ printColorMode: e.target.value as PrintColorMode })
}
className={selectClass}
>
{(Object.keys(PRINT_COLOR_MODE_LABELS) as PrintColorMode[]).map((mode) => (
<option key={mode} value={mode}>
{PRINT_COLOR_MODE_LABELS[mode]}
</option>
))}
</FieldSelect>
<p className={`${isPs || isMobile ? 'text-[9px] text-[#666]' : 'text-[10px] text-gray-500'} mt-1 leading-relaxed`}>
CMYK RGB PNG
</p>
</div>
</div>
</div> </div>
</div> </div>
{autoLayoutDialog} {autoLayoutDialog}
@@ -862,6 +886,7 @@ export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
rowIndex={item.sourceIndex} rowIndex={item.sourceIndex}
showBorder={false} showBorder={false}
mode="output" mode="output"
printColorMode={paper.printColorMode ?? 'rgb'}
{...referenceBackgroundRender} {...referenceBackgroundRender}
/> />
</div> </div>

View File

@@ -13,6 +13,7 @@ import {
import { VisibilityRuleDialog } from './VisibilityRuleDialog'; import { VisibilityRuleDialog } from './VisibilityRuleDialog';
import { ElementMaskDialog } from './ElementMaskDialog'; import { ElementMaskDialog } from './ElementMaskDialog';
import { VisibilityRuleSummary } from './VisibilityRuleSummary'; import { VisibilityRuleSummary } from './VisibilityRuleSummary';
import { ColorValueField } from './ColorValueField';
import { ValueExtractFields } from './ValueExtractFields'; import { ValueExtractFields } from './ValueExtractFields';
import { TextDisplayAffixFields } from './TextDisplayAffixFields'; import { TextDisplayAffixFields } from './TextDisplayAffixFields';
import { import {
@@ -1358,23 +1359,20 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
{/* Text color */} {/* Text color */}
<div className="border-t border-[#3a3a3a] pt-2.5"> <div className="border-t border-[#3a3a3a] pt-2.5">
<label className="block text-[10px] font-semibold text-[#777] mb-1"></label> <ColorValueField
<div className="flex gap-1 items-center"> label="文本颜色"
<input
type="color"
value={selectedElem.textColor || '#111827'}
onChange={(e) => handleElemChange({ ...selectedElem, textColor: e.target.value })}
className="color-input shrink-0"
/>
<input
type="text"
placeholder="#111827"
value={selectedElem.textColor || ''} value={selectedElem.textColor || ''}
onChange={(e) => handleElemChange({ ...selectedElem, textColor: e.target.value })} onChange={(val) =>
className="ps-field font-mono text-[11px] flex-1 min-w-0" handleElemChange({
...selectedElem,
textColor: val || undefined,
})
}
variableOptions={templateVariables}
defaultColor="#111827"
placeholder="#111827"
/> />
</div> </div>
</div>
<div className="space-y-2"> <div className="space-y-2">
<label className="ps-field-label mb-0"></label> <label className="ps-field-label mb-0"></label>
@@ -1939,6 +1937,35 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
className="ps-field font-mono text-[11px] flex-1 min-w-0" className="ps-field font-mono text-[11px] flex-1 min-w-0"
/> />
</div> </div>
{templateVariables.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1.5">
{templateVariables.map((v) => (
<button
key={v}
type="button"
onClick={() => {
const token = `{${v}}`;
const current =
selectedElem.backgroundColor === 'transparent'
? ''
: selectedElem.backgroundColor || '';
handleElemChange({
...selectedElem,
backgroundColor: current.includes(token)
? current
: current
? `${current} ${token}`
: token,
backgroundOpacity: 100,
});
}}
className="ps-btn text-[9px] font-mono"
>
{`{${v}}`}
</button>
))}
</div>
)}
<div> <div>
<div className="flex justify-between items-center mb-1"> <div className="flex justify-between items-center mb-1">
<span className="text-[10px] text-[#777]"></span> <span className="text-[10px] text-[#777]"></span>
@@ -2077,35 +2104,20 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
</div> </div>
</div> </div>
<div> <ColorValueField
<label className="ps-field-label"></label> label="边框颜色"
<div className="flex gap-1 items-center"> value={selectedElem.borderColor || ''}
<input onChange={(val) =>
type="color"
value={selectedElem.borderColor || '#111827'}
onChange={(e) =>
handleElemChange({ handleElemChange({
...selectedElem, ...selectedElem,
borderColor: e.target.value, borderColor: val || undefined,
borderWidth: selectedElem.borderWidth || 0.3, borderWidth: selectedElem.borderWidth || 0.3,
}) })
} }
className="color-input shrink-0" variableOptions={templateVariables}
/> defaultColor="#111827"
<input
type="text"
value={selectedElem.borderColor || ''}
placeholder="#111827" placeholder="#111827"
onChange={(e) =>
handleElemChange({
...selectedElem,
borderColor: e.target.value,
})
}
className="ps-field font-mono text-[11px] flex-1 min-w-0"
/> />
</div>
</div>
<div> <div>
<label className="ps-field-label"> (mm0 )</label> <label className="ps-field-label"> (mm0 )</label>

View File

@@ -3,6 +3,7 @@ import { TemplateElement } from '../types';
import { CommitInput } from './CommitInput'; import { CommitInput } from './CommitInput';
import { FieldSelect } from './PsSelect'; import { FieldSelect } from './PsSelect';
import { ValueExtractFields } from './ValueExtractFields'; import { ValueExtractFields } from './ValueExtractFields';
import { ColorValueField } from './ColorValueField';
import { TextDisplayAffixFields } from './TextDisplayAffixFields'; import { TextDisplayAffixFields } from './TextDisplayAffixFields';
import { import {
applyCellTextElementChange, applyCellTextElementChange,
@@ -173,23 +174,15 @@ export const TableCellTextFields: React.FC<TableCellTextFieldsProps> = ({
</div> </div>
<div className="border-t border-[#3a3a3a] pt-2.5"> <div className="border-t border-[#3a3a3a] pt-2.5">
<label className="block text-[10px] font-semibold text-[#777] mb-1"></label> <ColorValueField
<div className="flex gap-1 items-center"> label="文本颜色"
<input
type="color"
value={textElem.textColor || '#111827'}
onChange={(e) => patchText({ ...textElem, textColor: e.target.value })}
className="color-input shrink-0"
/>
<input
type="text"
placeholder="#111827"
value={textElem.textColor || ''} value={textElem.textColor || ''}
onChange={(e) => patchText({ ...textElem, textColor: e.target.value })} onChange={(val) => patchText({ ...textElem, textColor: val || undefined })}
className="ps-field font-mono text-[11px] flex-1 min-w-0" variableOptions={templateVariables}
defaultColor="#111827"
placeholder="#111827"
/> />
</div> </div>
</div>
<div className="space-y-2"> <div className="space-y-2">
<label className="ps-field-label mb-0"></label> <label className="ps-field-label mb-0"></label>

View File

@@ -23,6 +23,7 @@ import {
} from '../tableUtils'; } from '../tableUtils';
import { Table2, Merge, SplitSquareHorizontal } from 'lucide-react'; import { Table2, Merge, SplitSquareHorizontal } from 'lucide-react';
import { TableCellTextFields } from './TableCellTextFields'; import { TableCellTextFields } from './TableCellTextFields';
import { ColorValueField } from './ColorValueField';
const PropInput = CommitInput; const PropInput = CommitInput;
@@ -167,16 +168,15 @@ export const TablePropertyFields: React.FC<TablePropertyFieldsProps> = ({
className="ps-field font-mono" className="ps-field font-mono"
/> />
</div> </div>
<div> <ColorValueField
<label className="ps-field-label">线</label> label="网格线颜色"
<input value={elem.tableBorderColor ?? ''}
type="color" onChange={(val) => onChange({ ...elem, tableBorderColor: val || undefined })}
value={elem.tableBorderColor ?? '#374151'} variableOptions={templateVariables}
onChange={(e) => onChange({ ...elem, tableBorderColor: e.target.value })} defaultColor="#374151"
className="color-input w-full" placeholder="#374151"
/> />
</div> </div>
</div>
{normalizedCells.length > 0 && ( {normalizedCells.length > 0 && (
<div className="bg-[#1e1e1e] border border-[#3a3a3a] p-3 space-y-3"> <div className="bg-[#1e1e1e] border border-[#3a3a3a] p-3 space-y-3">

View File

@@ -28,6 +28,13 @@ import {
getCellRectPx, getCellRectPx,
} from './tableUtils'; } from './tableUtils';
import { composeElementLayer, isElementMaskActive } from './elementMask'; import { composeElementLayer, isElementMaskActive } from './elementMask';
import {
resolveElementColorsForRender,
resolveRenderableColor,
applyPrintColorModeToCanvas,
type RenderColorContext,
} from './colorUtils';
import type { PrintColorMode } from './types';
const GENERATOR_TRANSPARENT_BG = '#00000000'; const GENERATOR_TRANSPARENT_BG = '#00000000';
@@ -537,10 +544,12 @@ async function drawTableElement(
rowData: RecordRow | null, rowData: RecordRow | null,
rowIndex: number, rowIndex: number,
variableDefaults: Record<string, string> | null | undefined, variableDefaults: Record<string, string> | null | undefined,
mode: ContentResolveMode mode: ContentResolveMode,
colorCtx: RenderColorContext
) { ) {
const radii = resolveCornerRadiiPx(elem, scale, ew, eh); const renderElem = resolveElementColorsForRender(elem, colorCtx);
const sides = resolveBorderSides(elem); const radii = resolveCornerRadiiPx(renderElem, scale, ew, eh);
const sides = resolveBorderSides(renderElem);
const drawCellContent = async ( const drawCellContent = async (
targetCtx: CanvasRenderingContext2D, targetCtx: CanvasRenderingContext2D,
@@ -569,12 +578,16 @@ async function drawTableElement(
); );
const cellData = getCellData(elem, row, col); const cellData = getCellData(elem, row, col);
if (cellData?.backgroundColor && cellData.backgroundColor !== 'transparent') { const cellBg = resolveRenderableColor(cellData?.backgroundColor, colorCtx);
targetCtx.fillStyle = cellData.backgroundColor; if (cellBg && cellBg !== 'transparent') {
targetCtx.fillStyle = cellBg;
targetCtx.fillRect(cellX, cellY, cellW, cellH); 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; if (!textElem.content.trim()) continue;
const cellValue = resolveElementValue(textElem, rowData, rowIndex, variableDefaults, mode); const cellValue = resolveElementValue(textElem, rowData, rowIndex, variableDefaults, mode);
@@ -603,13 +616,13 @@ async function drawTableElement(
targetCtx.restore(); targetCtx.restore();
}; };
await composeElementLayer(ctx, elem, ex, ey, ew, eh, scale, async (layerCtx, ox, oy, ow, oh) => { await composeElementLayer(ctx, renderElem, ex, ey, ew, eh, scale, async (layerCtx, ox, oy, ow, oh) => {
drawElementFill(layerCtx, ox, oy, ow, oh, elem, radii); drawElementFill(layerCtx, ox, oy, ow, oh, renderElem, radii);
await drawCellContent(layerCtx, ox, oy); 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( function drawImagePlaceholder(
@@ -655,6 +668,8 @@ export interface RenderLabelOptions {
jpegQuality?: number; jpegQuality?: number;
/** 为 false 时不裁剪到画布边界(用于拖动浮层完整显示元素) */ /** 为 false 时不裁剪到画布边界(用于拖动浮层完整显示元素) */
clipToCanvas?: boolean; clipToCanvas?: boolean;
/** 排版/印刷色彩模式 */
printColorMode?: PrintColorMode;
} }
export type ReferenceBackgroundRenderOptions = Pick< export type ReferenceBackgroundRenderOptions = Pick<
@@ -760,7 +775,8 @@ export async function renderLayoutPageBackgroundDataUrl(
paperHeightMm: number, paperHeightMm: number,
options: LayoutPageBackgroundRenderOptions, options: LayoutPageBackgroundRenderOptions,
scale = 4, scale = 4,
imageFormat: 'png' | 'jpeg' = 'png' imageFormat: 'png' | 'jpeg' = 'png',
printColorMode: PrintColorMode = 'rgb'
): Promise<string | null> { ): Promise<string | null> {
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(paperWidthMm * scale)); 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); drawImageWithFit(ctx, img, 0, 0, canvas.width, canvas.height, options.fit);
ctx.restore(); ctx.restore();
if (printColorMode !== 'rgb') {
applyPrintColorModeToCanvas(canvas, printColorMode);
}
return imageFormat === 'jpeg' return imageFormat === 'jpeg'
? canvas.toDataURL('image/jpeg', 0.92) ? canvas.toDataURL('image/jpeg', 0.95)
: canvas.toDataURL('image/png'); : canvas.toDataURL('image/png');
} }
@@ -803,8 +823,17 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
imageFormat = 'png', imageFormat = 'png',
jpegQuality = 0.92, jpegQuality = 0.92,
clipToCanvas = true, clipToCanvas = true,
printColorMode = 'rgb',
} = options; } = options;
const colorCtx: RenderColorContext = {
printColorMode,
row: rowData,
variableDefaults,
mode,
rowIndex,
};
const scale = scaleOption; const scale = scaleOption;
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(widthMm * scale)); canvas.width = Math.max(1, Math.round(widthMm * scale));
@@ -861,31 +890,33 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
continue; continue;
} }
const renderElem = resolveElementColorsForRender(elem, colorCtx);
ctx.save(); ctx.save();
applyElementRotation(ctx, ex, ey, ew, eh, elem.rotation); applyElementRotation(ctx, ex, ey, ew, eh, elem.rotation);
if (elem.type === 'text') { if (elem.type === 'text') {
const radii = resolveCornerRadiiPx(elem, scale, ew, eh); const radii = resolveCornerRadiiPx(renderElem, scale, ew, eh);
const sides = resolveBorderSides(elem); const sides = resolveBorderSides(renderElem);
await composeElementLayer(ctx, elem, ex, ey, ew, eh, scale, async (layerCtx, ox, oy, ow, oh) => { await composeElementLayer(ctx, renderElem, ex, ey, ew, eh, scale, async (layerCtx, ox, oy, ow, oh) => {
drawElementFill(layerCtx, ox, oy, ow, oh, elem, radii); drawElementFill(layerCtx, ox, oy, ow, oh, renderElem, radii);
drawTextContentInBox(layerCtx, elem, ox, oy, ow, oh, String(value), scale); drawTextContentInBox(layerCtx, renderElem, ox, oy, ow, oh, String(value), scale);
drawElementBorders(layerCtx, ox, oy, ow, oh, radii, sides, elem, scale); drawElementBorders(layerCtx, ox, oy, ow, oh, radii, sides, renderElem, scale);
}); });
} else if (elem.type === 'barcode') { } else if (elem.type === 'barcode') {
try { try {
const radii = resolveCornerRadiiPx(elem, scale, ew, eh); const radii = resolveCornerRadiiPx(renderElem, scale, ew, eh);
const sides = resolveBorderSides(elem); const sides = resolveBorderSides(renderElem);
const barcodeValue = value ? String(value).trim() : mode === 'design' ? 'SAMPLE' : ''; const barcodeValue = value ? String(value).trim() : mode === 'design' ? 'SAMPLE' : '';
await composeElementLayer(ctx, elem, ex, ey, ew, eh, scale, async (layerCtx, ox, oy, ow, oh) => { await composeElementLayer(ctx, renderElem, ex, ey, ew, eh, scale, async (layerCtx, ox, oy, ow, oh) => {
drawElementFill(layerCtx, ox, oy, ow, oh, elem, radii); drawElementFill(layerCtx, ox, oy, ow, oh, renderElem, radii);
if (!barcodeValue) { if (!barcodeValue) {
drawElementBorders(layerCtx, ox, oy, ow, oh, radii, sides, elem, scale); drawElementBorders(layerCtx, ox, oy, ow, oh, radii, sides, renderElem, scale);
return; return;
} }
const inner = getPaddedRectPx(ox, oy, ow, oh, elem, scale); const inner = getPaddedRectPx(ox, oy, ow, oh, renderElem, scale);
const tempCanvas = document.createElement('canvas'); const tempCanvas = document.createElement('canvas');
JsBarcode(tempCanvas, barcodeValue, { JsBarcode(tempCanvas, barcodeValue, {
format: elem.barcodeFormat || 'CODE128', format: elem.barcodeFormat || 'CODE128',
@@ -897,12 +928,12 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
background: GENERATOR_TRANSPARENT_BG, background: GENERATOR_TRANSPARENT_BG,
}); });
layerCtx.drawImage(tempCanvas, inner.x, inner.y, inner.w, inner.h); 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) { } catch (error) {
console.warn('Barcode drawing failed', error); console.warn('Barcode drawing failed', error);
if (mode === 'design') { 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.fillStyle = '#f3f4f6';
ctx.fillRect(inner.x, inner.y, inner.w, inner.h); ctx.fillRect(inner.x, inner.y, inner.w, inner.h);
ctx.strokeStyle = '#ef4444'; ctx.strokeStyle = '#ef4444';
@@ -916,17 +947,17 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
} }
} else if (elem.type === 'qrcode') { } else if (elem.type === 'qrcode') {
try { try {
const radii = resolveCornerRadiiPx(elem, scale, ew, eh); const radii = resolveCornerRadiiPx(renderElem, scale, ew, eh);
const sides = resolveBorderSides(elem); const sides = resolveBorderSides(renderElem);
const qrValue = value ? String(value).trim() : mode === 'design' ? 'SAMPLE QR' : ''; 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) => { await composeElementLayer(ctx, renderElem, ex, ey, ew, eh, scale, async (layerCtx, ox, oy, ow, oh) => {
drawElementFill(layerCtx, ox, oy, ow, oh, elem, radii); drawElementFill(layerCtx, ox, oy, ow, oh, renderElem, radii);
if (!qrValue) { if (!qrValue) {
drawElementBorders(layerCtx, ox, oy, ow, oh, radii, sides, elem, scale); drawElementBorders(layerCtx, ox, oy, ow, oh, radii, sides, renderElem, scale);
return; return;
} }
const inner = getPaddedRectPx(ox, oy, ow, oh, elem, scale); const inner = getPaddedRectPx(ox, oy, ow, oh, renderElem, scale);
const tempCanvas = document.createElement('canvas'); const tempCanvas = document.createElement('canvas');
await QRCode.toCanvas(tempCanvas, qrValue, { await QRCode.toCanvas(tempCanvas, qrValue, {
width: Math.max(64, inner.w), 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); 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) { } catch (error) {
console.warn('QR code drawing failed', error); console.warn('QR code drawing failed', error);
if (mode === 'design') { 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.fillStyle = '#f3f4f6';
ctx.fillRect(inner.x, inner.y, inner.w, inner.h); ctx.fillRect(inner.x, inner.y, inner.w, inner.h);
ctx.strokeStyle = '#ef4444'; ctx.strokeStyle = '#ef4444';
@@ -959,14 +990,14 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
continue; continue;
} }
const radii = resolveCornerRadiiPx(elem, scale, ew, eh); const radii = resolveCornerRadiiPx(renderElem, scale, ew, eh);
const sides = resolveBorderSides(elem); const sides = resolveBorderSides(renderElem);
const hasMask = isElementMaskActive(elem.mask); const hasMask = isElementMaskActive(elem.mask);
if (hasMask) { if (hasMask) {
await composeElementLayer(ctx, elem, ex, ey, ew, eh, scale, async (layerCtx, ox, oy, ow, oh) => { await composeElementLayer(ctx, renderElem, ex, ey, ew, eh, scale, async (layerCtx, ox, oy, ow, oh) => {
drawElementFill(layerCtx, ox, oy, ow, oh, elem, radii); drawElementFill(layerCtx, ox, oy, ow, oh, renderElem, radii);
const inner = getPaddedRectPx(ox, oy, ow, oh, elem, scale); const inner = getPaddedRectPx(ox, oy, ow, oh, renderElem, scale);
if (img) { if (img) {
drawImageWithFit( drawImageWithFit(
layerCtx, layerCtx,
@@ -980,11 +1011,11 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
} else { } else {
drawImagePlaceholder(layerCtx, inner.x, inner.y, inner.w, inner.h, '图片'); 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 { } else {
drawElementChrome(ctx, ex, ey, ew, eh, elem, scale); drawElementChrome(ctx, ex, ey, ew, eh, renderElem, scale);
const inner = getPaddedRectPx(ex, ey, ew, eh, elem, scale); const inner = getPaddedRectPx(ex, ey, ew, eh, renderElem, scale);
if (img) { if (img) {
drawImageWithFit(ctx, img, inner.x, inner.y, inner.w, inner.h, elem.imageFit ?? 'contain'); drawImageWithFit(ctx, img, inner.x, inner.y, inner.w, inner.h, elem.imageFit ?? 'contain');
} else { } else {
@@ -1003,7 +1034,8 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
rowData, rowData,
rowIndex, rowIndex,
variableDefaults, variableDefaults,
mode mode,
colorCtx
); );
} }
@@ -1012,6 +1044,10 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
ctx.restore(); ctx.restore();
if (printColorMode !== 'rgb') {
applyPrintColorModeToCanvas(canvas, printColorMode);
}
return imageFormat === 'jpeg' return imageFormat === 'jpeg'
? canvas.toDataURL('image/jpeg', jpegQuality) ? canvas.toDataURL('image/jpeg', jpegQuality)
: canvas.toDataURL('image/png'); : canvas.toDataURL('image/png');

View File

@@ -35,6 +35,7 @@ type PageBackgroundCache = { dataUrl: string | null; key: string | null };
async function addPageBackgroundIfNeeded( async function addPageBackgroundIfNeeded(
pdf: jsPDF, pdf: jsPDF,
template: LabelTemplate, template: LabelTemplate,
paper: PaperConfig,
paperW: number, paperW: number,
paperH: number, paperH: number,
imageFormat: 'PNG' | 'JPEG', imageFormat: 'PNG' | 'JPEG',
@@ -43,7 +44,8 @@ async function addPageBackgroundIfNeeded(
const options = resolveLayoutPageBackgroundForOutput(template); const options = resolveLayoutPageBackgroundForOutput(template);
if (!options) return; 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) { if (cache.key !== key) {
cache.key = key; cache.key = key;
cache.dataUrl = await renderLayoutPageBackgroundDataUrl( cache.dataUrl = await renderLayoutPageBackgroundDataUrl(
@@ -51,7 +53,8 @@ async function addPageBackgroundIfNeeded(
paperH, paperH,
options, options,
4, 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); 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 pageRows = printablePages[pIdx];
const gridRows = Math.ceil(pageRows.length / gridCols); const gridRows = Math.ceil(pageRows.length / gridCols);
@@ -290,7 +293,7 @@ export async function exportLabelsPdfBlobAsync(
pdf.addPage([paperW, paperH], orientation); 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 pageRows = printablePages[pIdx];
const gridRows = Math.ceil(pageRows.length / gridCols); const gridRows = Math.ceil(pageRows.length / gridCols);

View File

@@ -336,6 +336,15 @@ export interface LabelTemplate {
export type PaperType = 'A4' | 'A5' | 'A6' | 'custom'; export type PaperType = 'A4' | 'A5' | 'A6' | 'custom';
/** 排版/印刷输出色彩模式 */
export type PrintColorMode = 'rgb' | 'grayscale' | 'cmyk';
export const PRINT_COLOR_MODE_LABELS: Record<PrintColorMode, string> = {
rgb: '标准 RGB',
grayscale: '黑白(灰度)',
cmyk: 'CMYK印刷模拟',
};
export interface PaperConfig { export interface PaperConfig {
type: PaperType; type: PaperType;
width: number; // in mm width: number; // in mm
@@ -348,6 +357,8 @@ export interface PaperConfig {
columnGap: number; // in mm (horizontal spacing between labels) columnGap: number; // in mm (horizontal spacing between labels)
rowGap: number; // in mm (vertical spacing between labels) rowGap: number; // in mm (vertical spacing between labels)
flow: 'top-bottom-left-right' | 'left-right-top-bottom'; // how cells flow flow: 'top-bottom-left-right' | 'left-right-top-bottom'; // how cells flow
/** 排版预览与导出时的色彩模式,默认 rgb */
printColorMode?: PrintColorMode;
} }
export interface RecordRow { export interface RecordRow {

View File

@@ -537,8 +537,33 @@ export const DEFAULT_PAPER_CONFIG: PaperConfig = {
columnGap: 0, columnGap: 0,
rowGap: 0, rowGap: 0,
flow: 'left-right-top-bottom', 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 { export function normalizeTemplate(tmpl: LabelTemplate): LabelTemplate {
const { ...rest } = tmpl; const { ...rest } = tmpl;