打印
This commit is contained in:
38
src/App.tsx
38
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<string>('');
|
||||
const [newTmplW, setNewTmplW] = useState('75');
|
||||
const [newTmplH, setNewTmplH] = useState('45');
|
||||
const [newTmplPaper, setNewTmplPaper] = useState<PaperConfig | null>(null);
|
||||
const [editingTemplateId, setEditingTemplateId] = useState<string | null>(null);
|
||||
const [editTmplName, setEditTmplName] = useState<string>('');
|
||||
const [templateMenuId, setTemplateMenuId] = useState<string | null>(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"
|
||||
/>
|
||||
</div>
|
||||
<LabelLayoutSizeCalculator
|
||||
onCalculated={({ width, height, paper }) => {
|
||||
setNewTmplW(String(width));
|
||||
setNewTmplH(String(height));
|
||||
setNewTmplPaper(paper);
|
||||
}}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-gray-500 mb-1.5">
|
||||
|
||||
213
src/colorUtils.ts
Normal file
213
src/colorUtils.ts
Normal 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 ≈ 280–300%) */
|
||||
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)}`;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { TemplateElement } from '../types';
|
||||
import type { PrintColorMode } from '../types';
|
||||
import { ContentResolveMode } from '../utils';
|
||||
import { renderLabelToDataUrl } from '../labelRenderer';
|
||||
|
||||
@@ -19,6 +20,7 @@ interface CanvasLabelImageProps {
|
||||
referenceBackgroundFit?: 'contain' | 'cover' | 'fill';
|
||||
/** 渲染倍率,设计画布应传 MM_TO_PX * zoom 与显示尺寸 1:1 对齐 */
|
||||
renderScale?: number;
|
||||
printColorMode?: PrintColorMode;
|
||||
}
|
||||
|
||||
export const CanvasLabelImage: React.FC<CanvasLabelImageProps> = ({
|
||||
@@ -35,6 +37,7 @@ export const CanvasLabelImage: React.FC<CanvasLabelImageProps> = ({
|
||||
referenceBackgroundOpacity,
|
||||
referenceBackgroundFit,
|
||||
renderScale = 16,
|
||||
printColorMode = 'rgb',
|
||||
}) => {
|
||||
const [dataUrl, setDataUrl] = useState<string>('');
|
||||
const [initialLoading, setInitialLoading] = useState<boolean>(true);
|
||||
@@ -63,6 +66,7 @@ export const CanvasLabelImage: React.FC<CanvasLabelImageProps> = ({
|
||||
referenceBackground,
|
||||
referenceBackgroundOpacity,
|
||||
referenceBackgroundFit,
|
||||
printColorMode,
|
||||
})
|
||||
.then((url) => {
|
||||
if (!active) return;
|
||||
@@ -100,6 +104,7 @@ export const CanvasLabelImage: React.FC<CanvasLabelImageProps> = ({
|
||||
referenceBackground,
|
||||
referenceBackgroundOpacity,
|
||||
referenceBackgroundFit,
|
||||
printColorMode,
|
||||
]);
|
||||
|
||||
if (initialLoading && !dataUrl) {
|
||||
|
||||
74
src/components/ColorValueField.tsx
Normal file
74
src/components/ColorValueField.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -236,6 +236,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
||||
() => template.variableDefaults ?? null,
|
||||
[template.variableDefaults]
|
||||
);
|
||||
const printColorMode = template.paper?.printColorMode ?? 'rgb';
|
||||
|
||||
const showReferencePreview = useMemo(
|
||||
() =>
|
||||
@@ -1817,6 +1818,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
||||
showBorder={false}
|
||||
transparentBackground={showReferencePreview}
|
||||
variableDefaults={previewDefaults}
|
||||
printColorMode={printColorMode}
|
||||
renderScale={canvasRenderScale}
|
||||
/>
|
||||
</div>
|
||||
@@ -1841,6 +1843,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
||||
showBorder={false}
|
||||
transparentBackground
|
||||
variableDefaults={previewDefaults}
|
||||
printColorMode={printColorMode}
|
||||
renderScale={canvasRenderScale}
|
||||
/>
|
||||
</div>
|
||||
|
||||
240
src/components/LabelLayoutSizeCalculator.tsx
Normal file
240
src/components/LabelLayoutSizeCalculator.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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<PaperSettingsPanelProps> = ({
|
||||
paper,
|
||||
@@ -519,6 +520,29 @@ export const PaperSettingsPanel: React.FC<PaperSettingsPanelProps> = ({
|
||||
</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>
|
||||
{autoLayoutDialog}
|
||||
@@ -862,6 +886,7 @@ export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
|
||||
rowIndex={item.sourceIndex}
|
||||
showBorder={false}
|
||||
mode="output"
|
||||
printColorMode={paper.printColorMode ?? 'rgb'}
|
||||
{...referenceBackgroundRender}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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<PropSidebarProps> = ({
|
||||
|
||||
{/* Text color */}
|
||||
<div className="border-t border-[#3a3a3a] pt-2.5">
|
||||
<label className="block text-[10px] font-semibold text-[#777] mb-1">文本颜色</label>
|
||||
<div className="flex gap-1 items-center">
|
||||
<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 || ''}
|
||||
onChange={(e) => handleElemChange({ ...selectedElem, textColor: e.target.value })}
|
||||
className="ps-field font-mono text-[11px] flex-1 min-w-0"
|
||||
/>
|
||||
</div>
|
||||
<ColorValueField
|
||||
label="文本颜色"
|
||||
value={selectedElem.textColor || ''}
|
||||
onChange={(val) =>
|
||||
handleElemChange({
|
||||
...selectedElem,
|
||||
textColor: val || undefined,
|
||||
})
|
||||
}
|
||||
variableOptions={templateVariables}
|
||||
defaultColor="#111827"
|
||||
placeholder="#111827"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
@@ -1939,6 +1937,35 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
||||
className="ps-field font-mono text-[11px] flex-1 min-w-0"
|
||||
/>
|
||||
</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 className="flex justify-between items-center mb-1">
|
||||
<span className="text-[10px] text-[#777]">不透明度</span>
|
||||
@@ -2077,35 +2104,20 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="ps-field-label">边框颜色</label>
|
||||
<div className="flex gap-1 items-center">
|
||||
<input
|
||||
type="color"
|
||||
value={selectedElem.borderColor || '#111827'}
|
||||
onChange={(e) =>
|
||||
handleElemChange({
|
||||
...selectedElem,
|
||||
borderColor: e.target.value,
|
||||
borderWidth: selectedElem.borderWidth || 0.3,
|
||||
})
|
||||
}
|
||||
className="color-input shrink-0"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={selectedElem.borderColor || ''}
|
||||
placeholder="#111827"
|
||||
onChange={(e) =>
|
||||
handleElemChange({
|
||||
...selectedElem,
|
||||
borderColor: e.target.value,
|
||||
})
|
||||
}
|
||||
className="ps-field font-mono text-[11px] flex-1 min-w-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ColorValueField
|
||||
label="边框颜色"
|
||||
value={selectedElem.borderColor || ''}
|
||||
onChange={(val) =>
|
||||
handleElemChange({
|
||||
...selectedElem,
|
||||
borderColor: val || undefined,
|
||||
borderWidth: selectedElem.borderWidth || 0.3,
|
||||
})
|
||||
}
|
||||
variableOptions={templateVariables}
|
||||
defaultColor="#111827"
|
||||
placeholder="#111827"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="ps-field-label">各边独立边框宽度 (mm,留空用统一值,0 为不显示)</label>
|
||||
|
||||
@@ -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<TableCellTextFieldsProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[#3a3a3a] pt-2.5">
|
||||
<label className="block text-[10px] font-semibold text-[#777] mb-1">文本颜色</label>
|
||||
<div className="flex gap-1 items-center">
|
||||
<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 || ''}
|
||||
onChange={(e) => patchText({ ...textElem, textColor: e.target.value })}
|
||||
className="ps-field font-mono text-[11px] flex-1 min-w-0"
|
||||
/>
|
||||
</div>
|
||||
<ColorValueField
|
||||
label="文本颜色"
|
||||
value={textElem.textColor || ''}
|
||||
onChange={(val) => patchText({ ...textElem, textColor: val || undefined })}
|
||||
variableOptions={templateVariables}
|
||||
defaultColor="#111827"
|
||||
placeholder="#111827"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -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<TablePropertyFieldsProps> = ({
|
||||
className="ps-field font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="ps-field-label">网格线颜色</label>
|
||||
<input
|
||||
type="color"
|
||||
value={elem.tableBorderColor ?? '#374151'}
|
||||
onChange={(e) => onChange({ ...elem, tableBorderColor: e.target.value })}
|
||||
className="color-input w-full"
|
||||
/>
|
||||
</div>
|
||||
<ColorValueField
|
||||
label="网格线颜色"
|
||||
value={elem.tableBorderColor ?? ''}
|
||||
onChange={(val) => onChange({ ...elem, tableBorderColor: val || undefined })}
|
||||
variableOptions={templateVariables}
|
||||
defaultColor="#374151"
|
||||
placeholder="#374151"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{normalizedCells.length > 0 && (
|
||||
|
||||
@@ -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<string, string> | 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<string | null> {
|
||||
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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
11
src/types.ts
11
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<PrintColorMode, string> = {
|
||||
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 {
|
||||
|
||||
25
src/utils.ts
25
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;
|
||||
|
||||
Reference in New Issue
Block a user