优化
This commit is contained in:
17
src/App.tsx
17
src/App.tsx
@@ -770,19 +770,14 @@ export default function App() {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const pdfExportImage = useMemo(() => {
|
const pdfExportImage = useMemo(() => {
|
||||||
const printMode = workingTemplate.paper?.printColorMode ?? 'rgb';
|
const printMode = workingTemplate.paper?.printColorMode ?? 'cmyk';
|
||||||
const needsLosslessColor = printMode !== 'rgb';
|
|
||||||
const hasCode = workingTemplate.elements.some(
|
|
||||||
(e) => e.type === 'barcode' || e.type === 'qrcode'
|
|
||||||
);
|
|
||||||
const usePng = needsLosslessColor || hasCode;
|
|
||||||
return {
|
return {
|
||||||
imageFormat: usePng ? ('png' as const) : ('jpeg' as const),
|
imageFormat: 'png' as const,
|
||||||
jsPdfFormat: usePng ? ('PNG' as const) : ('JPEG' as const),
|
jsPdfFormat: 'PNG' as const,
|
||||||
scale: needsLosslessColor ? 12 : 10,
|
scale: printMode === 'cmyk' ? 14 : 12,
|
||||||
jpegQuality: 0.95,
|
jpegQuality: 0.95,
|
||||||
};
|
};
|
||||||
}, [workingTemplate.elements, workingTemplate.paper?.printColorMode]);
|
}, [workingTemplate.paper?.printColorMode]);
|
||||||
|
|
||||||
const handleExportImageFileNamePatternChange = (pattern: string) => {
|
const handleExportImageFileNamePatternChange = (pattern: string) => {
|
||||||
if (!activeTemplate) return;
|
if (!activeTemplate) return;
|
||||||
@@ -830,7 +825,7 @@ export default function App() {
|
|||||||
scale: pdfExportImage.scale,
|
scale: pdfExportImage.scale,
|
||||||
imageFormat: pdfExportImage.imageFormat,
|
imageFormat: pdfExportImage.imageFormat,
|
||||||
jpegQuality: pdfExportImage.jpegQuality,
|
jpegQuality: pdfExportImage.jpegQuality,
|
||||||
printColorMode: activeTemplate!.paper?.printColorMode ?? 'rgb',
|
printColorMode: activeTemplate!.paper?.printColorMode ?? 'cmyk',
|
||||||
...resolveReferenceBackgroundForOutput(activeTemplate!),
|
...resolveReferenceBackgroundForOutput(activeTemplate!),
|
||||||
}),
|
}),
|
||||||
[activeTemplate, pdfExportImage]
|
[activeTemplate, pdfExportImage]
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ export function isTransparentColorValue(value?: string): boolean {
|
|||||||
return trimmed === '' || trimmed === 'transparent';
|
return trimmed === '' || trimmed === 'transparent';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clampByte(n: number): number {
|
||||||
|
return Math.max(0, Math.min(255, Math.round(n)));
|
||||||
|
}
|
||||||
|
|
||||||
function parseHexColor(color: string): { r: number; g: number; b: number; a: number } | null {
|
function parseHexColor(color: string): { r: number; g: number; b: number; a: number } | null {
|
||||||
const trimmed = color.trim();
|
const trimmed = color.trim();
|
||||||
if (!trimmed.startsWith('#')) return null;
|
if (!trimmed.startsWith('#')) return null;
|
||||||
@@ -83,37 +87,81 @@ function rgbToCmyk(r: number, g: number, b: number) {
|
|||||||
|
|
||||||
function cmykToRgb(c: number, m: number, y: number, k: number) {
|
function cmykToRgb(c: number, m: number, y: number, k: number) {
|
||||||
return {
|
return {
|
||||||
r: Math.round(255 * (1 - c) * (1 - k)),
|
r: 255 * (1 - c) * (1 - k),
|
||||||
g: Math.round(255 * (1 - m) * (1 - k)),
|
g: 255 * (1 - m) * (1 - k),
|
||||||
b: Math.round(255 * (1 - y) * (1 - k)),
|
b: 255 * (1 - y) * (1 - k),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 总墨量限制,避免过饱和(典型印刷 TAC ≈ 280–300%) */
|
/** 灰成分替换(GCR),中性色更多走 K 通道,印刷更稳定 */
|
||||||
function limitTotalInk(c: number, m: number, y: number, k: number, maxInk = 2.85) {
|
function applyGrayComponentReplacement(c: number, m: number, y: number, k: number) {
|
||||||
|
const gray = Math.min(c, m, y);
|
||||||
|
if (gray <= 0.01) return { c, m, y, k };
|
||||||
|
const gcr = gray * 0.85;
|
||||||
|
return {
|
||||||
|
c: c - gcr,
|
||||||
|
m: m - gcr,
|
||||||
|
y: y - gcr,
|
||||||
|
k: Math.min(1, k + gcr),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 总墨量限制(TAC),典型 280–300% */
|
||||||
|
function limitTotalInk(c: number, m: number, y: number, k: number, maxInk = 2.8) {
|
||||||
const total = c + m + y + k;
|
const total = c + m + y + k;
|
||||||
if (total <= maxInk || total <= 0) return { c, m, y, k };
|
if (total <= maxInk || total <= 0) return { c, m, y, k };
|
||||||
const scale = maxInk / total;
|
const scale = maxInk / total;
|
||||||
return { c: c * scale, m: m * scale, y: y * scale, k: k * scale };
|
return { c: c * scale, m: m * scale, y: y * scale, k: k * scale };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 网点扩大 / 纸张吸墨:印刷比屏幕略深 */
|
||||||
|
function applyDotGain(r: number, g: number, b: number, amount: number): [number, number, number] {
|
||||||
|
const factor = 1 - amount;
|
||||||
|
return [clampByte(r * factor), clampByte(g * factor), clampByte(b * factor)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 轻度去饱和,逼近油墨叠色效果 */
|
||||||
|
function applyDesaturation(r: number, g: number, b: number, saturation: number): [number, number, number] {
|
||||||
|
const lum = 0.299 * r + 0.587 * g + 0.114 * b;
|
||||||
|
return [
|
||||||
|
clampByte(lum + (r - lum) * saturation),
|
||||||
|
clampByte(lum + (g - lum) * saturation),
|
||||||
|
clampByte(lum + (b - lum) * saturation),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
function transformRgbPixel(r: number, g: number, b: number, mode: PrintColorMode): [number, number, number] {
|
function transformRgbPixel(r: number, g: number, b: number, mode: PrintColorMode): [number, number, number] {
|
||||||
if (mode === 'grayscale') {
|
if (mode === 'grayscale') {
|
||||||
const lum = Math.round(0.299 * r + 0.587 * g + 0.114 * b);
|
const lum = Math.round(0.299 * r + 0.587 * g + 0.114 * b);
|
||||||
return [lum, lum, lum];
|
return [lum, lum, lum];
|
||||||
}
|
}
|
||||||
|
|
||||||
let { c, m, y, k } = rgbToCmyk(r, g, b);
|
let nr = r;
|
||||||
({ c, m, y, k } = limitTotalInk(c, m, y, k));
|
let ng = g;
|
||||||
const rgb = cmykToRgb(c, m, y, k);
|
let nb = b;
|
||||||
return [rgb.r, rgb.g, rgb.b];
|
|
||||||
|
if (mode === 'cmyk') {
|
||||||
|
let { c, m, y, k } = rgbToCmyk(nr, ng, nb);
|
||||||
|
({ c, m, y, k } = applyGrayComponentReplacement(c, m, y, k));
|
||||||
|
({ c, m, y, k } = limitTotalInk(c, m, y, k));
|
||||||
|
const rgb = cmykToRgb(c, m, y, k);
|
||||||
|
nr = rgb.r;
|
||||||
|
ng = rgb.g;
|
||||||
|
nb = rgb.b;
|
||||||
|
[nr, ng, nb] = applyDotGain(nr, ng, nb, 0.07);
|
||||||
|
return [nr, ng, nb];
|
||||||
|
}
|
||||||
|
|
||||||
|
// rgb:轻度印刷补偿(设计预览与导出一致,缩小与实机色差)
|
||||||
|
[nr, ng, nb] = applyDesaturation(nr, ng, nb, 0.9);
|
||||||
|
[nr, ng, nb] = applyDotGain(nr, ng, nb, 0.04);
|
||||||
|
return [nr, ng, nb];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyPrintColorModeToImageData(
|
export function applyPrintColorModeToImageData(
|
||||||
imageData: ImageData,
|
imageData: ImageData,
|
||||||
mode: PrintColorMode = 'rgb'
|
mode: PrintColorMode = 'rgb'
|
||||||
): void {
|
): void {
|
||||||
if (mode === 'rgb') return;
|
|
||||||
const { data } = imageData;
|
const { data } = imageData;
|
||||||
for (let i = 0; i < data.length; i += 4) {
|
for (let i = 0; i < data.length; i += 4) {
|
||||||
const alpha = data[i + 3];
|
const alpha = data[i + 3];
|
||||||
@@ -129,7 +177,6 @@ export function applyPrintColorModeToCanvas(
|
|||||||
canvas: HTMLCanvasElement,
|
canvas: HTMLCanvasElement,
|
||||||
mode: PrintColorMode = 'rgb'
|
mode: PrintColorMode = 'rgb'
|
||||||
): void {
|
): void {
|
||||||
if (mode === 'rgb') return;
|
|
||||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||||
@@ -138,14 +185,14 @@ export function applyPrintColorModeToCanvas(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function applyPrintColorMode(color: string, mode: PrintColorMode = 'rgb'): string {
|
export function applyPrintColorMode(color: string, mode: PrintColorMode = 'rgb'): string {
|
||||||
if (mode === 'rgb' || isTransparentColorValue(color)) return color;
|
if (isTransparentColorValue(color)) return color;
|
||||||
const parsed = parseCssColor(color);
|
const parsed = parseCssColor(color);
|
||||||
if (!parsed) return color;
|
if (!parsed) return color;
|
||||||
const [r, g, b] = transformRgbPixel(parsed.r, parsed.g, parsed.b, mode);
|
const [r, g, b] = transformRgbPixel(parsed.r, parsed.g, parsed.b, mode);
|
||||||
return formatRgba(r, g, b, parsed.a);
|
return formatRgba(r, g, b, parsed.a);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 解析颜色字面量或 {变量},并应用印刷色彩模式 */
|
/** 解析颜色字面量或 {变量} */
|
||||||
export function resolveRenderableColor(
|
export function resolveRenderableColor(
|
||||||
raw: string | undefined,
|
raw: string | undefined,
|
||||||
ctx: RenderColorContext,
|
ctx: RenderColorContext,
|
||||||
@@ -167,12 +214,10 @@ export function resolveRenderableColor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!resolved || isTransparentColorValue(resolved)) return 'transparent';
|
if (!resolved || isTransparentColorValue(resolved)) return 'transparent';
|
||||||
|
|
||||||
// 印刷色彩模式在整页画布合成后统一处理,避免逐色 + 全图双重转换
|
|
||||||
return resolved;
|
return resolved;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 为渲染解析元素上的颜色字段(含变量与印刷模式) */
|
/** 为渲染解析元素上的颜色字段(含变量) */
|
||||||
export function resolveElementColorsForRender(
|
export function resolveElementColorsForRender(
|
||||||
elem: TemplateElement,
|
elem: TemplateElement,
|
||||||
ctx: RenderColorContext
|
ctx: RenderColorContext
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export const CanvasLabelImage: React.FC<CanvasLabelImageProps> = ({
|
|||||||
referenceBackgroundOpacity,
|
referenceBackgroundOpacity,
|
||||||
referenceBackgroundFit,
|
referenceBackgroundFit,
|
||||||
renderScale = 16,
|
renderScale = 16,
|
||||||
printColorMode = 'rgb',
|
printColorMode = 'cmyk',
|
||||||
}) => {
|
}) => {
|
||||||
const [dataUrl, setDataUrl] = useState<string>('');
|
const [dataUrl, setDataUrl] = useState<string>('');
|
||||||
const [initialLoading, setInitialLoading] = useState<boolean>(true);
|
const [initialLoading, setInitialLoading] = useState<boolean>(true);
|
||||||
|
|||||||
@@ -236,7 +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 printColorMode = template.paper?.printColorMode ?? 'cmyk';
|
||||||
|
|
||||||
const showReferencePreview = useMemo(
|
const showReferencePreview = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ const paperEquals = (a: PaperConfig, b: PaperConfig) =>
|
|||||||
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');
|
(a.printColorMode ?? 'cmyk') === (b.printColorMode ?? 'cmyk');
|
||||||
|
|
||||||
export const PaperSettingsPanel: React.FC<PaperSettingsPanelProps> = ({
|
export const PaperSettingsPanel: React.FC<PaperSettingsPanelProps> = ({
|
||||||
paper,
|
paper,
|
||||||
@@ -525,7 +525,7 @@ export const PaperSettingsPanel: React.FC<PaperSettingsPanelProps> = ({
|
|||||||
<div>
|
<div>
|
||||||
<label className={labelClass}>色彩模式</label>
|
<label className={labelClass}>色彩模式</label>
|
||||||
<FieldSelect
|
<FieldSelect
|
||||||
value={draftPaper.printColorMode ?? 'rgb'}
|
value={draftPaper.printColorMode ?? 'cmyk'}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
patchDraftPaper({ printColorMode: e.target.value as PrintColorMode })
|
patchDraftPaper({ printColorMode: e.target.value as PrintColorMode })
|
||||||
}
|
}
|
||||||
@@ -538,7 +538,7 @@ export const PaperSettingsPanel: React.FC<PaperSettingsPanelProps> = ({
|
|||||||
))}
|
))}
|
||||||
</FieldSelect>
|
</FieldSelect>
|
||||||
<p className={`${isPs || isMobile ? 'text-[9px] text-[#666]' : 'text-[10px] text-gray-500'} mt-1 leading-relaxed`}>
|
<p className={`${isPs || isMobile ? 'text-[9px] text-[#666]' : 'text-[10px] text-gray-500'} mt-1 leading-relaxed`}>
|
||||||
预览与导出时对整页位图统一处理;CMYK 含总墨量限制,色彩模式非 RGB 时强制 PNG 导出以减少损色
|
设计预览、排版预览与导出/打印使用同一套色彩处理;推荐 CMYK 模式。实机仍受打印机驱动影响,请以打样为准
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -886,7 +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'}
|
printColorMode={paper.printColorMode ?? 'cmyk'}
|
||||||
{...referenceBackgroundRender}
|
{...referenceBackgroundRender}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -795,9 +795,7 @@ 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);
|
||||||
applyPrintColorModeToCanvas(canvas, printColorMode);
|
|
||||||
}
|
|
||||||
|
|
||||||
return imageFormat === 'jpeg'
|
return imageFormat === 'jpeg'
|
||||||
? canvas.toDataURL('image/jpeg', 0.95)
|
? canvas.toDataURL('image/jpeg', 0.95)
|
||||||
@@ -1044,9 +1042,7 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
|
|||||||
|
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
|
|
||||||
if (printColorMode !== 'rgb') {
|
applyPrintColorModeToCanvas(canvas, printColorMode);
|
||||||
applyPrintColorModeToCanvas(canvas, printColorMode);
|
|
||||||
}
|
|
||||||
|
|
||||||
return imageFormat === 'jpeg'
|
return imageFormat === 'jpeg'
|
||||||
? canvas.toDataURL('image/jpeg', jpegQuality)
|
? canvas.toDataURL('image/jpeg', jpegQuality)
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ async function addPageBackgroundIfNeeded(
|
|||||||
const options = resolveLayoutPageBackgroundForOutput(template);
|
const options = resolveLayoutPageBackgroundForOutput(template);
|
||||||
if (!options) return;
|
if (!options) return;
|
||||||
|
|
||||||
const printColorMode = paper.printColorMode ?? 'rgb';
|
const printColorMode = paper.printColorMode ?? 'cmyk';
|
||||||
const key = `${options.background}|${options.opacity}|${options.fit}|${paperW}|${paperH}|${imageFormat}|${printColorMode}`;
|
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;
|
||||||
|
|||||||
@@ -340,9 +340,9 @@ export type PaperType = 'A4' | 'A5' | 'A6' | 'custom';
|
|||||||
export type PrintColorMode = 'rgb' | 'grayscale' | 'cmyk';
|
export type PrintColorMode = 'rgb' | 'grayscale' | 'cmyk';
|
||||||
|
|
||||||
export const PRINT_COLOR_MODE_LABELS: Record<PrintColorMode, string> = {
|
export const PRINT_COLOR_MODE_LABELS: Record<PrintColorMode, string> = {
|
||||||
rgb: '标准 RGB',
|
rgb: '标准(轻度印刷补偿)',
|
||||||
grayscale: '黑白(灰度)',
|
grayscale: '黑白(灰度)',
|
||||||
cmyk: 'CMYK(印刷模拟)',
|
cmyk: 'CMYK 印刷模拟(推荐)',
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface PaperConfig {
|
export interface PaperConfig {
|
||||||
|
|||||||
@@ -537,7 +537,7 @@ 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',
|
printColorMode: 'cmyk',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 根据纸张排版参数反算单个标签尺寸 (mm) */
|
/** 根据纸张排版参数反算单个标签尺寸 (mm) */
|
||||||
|
|||||||
Reference in New Issue
Block a user