This commit is contained in:
iqudoo
2026-06-23 15:26:51 +08:00
parent 1b04e34ff0
commit 33d3e5537c
9 changed files with 79 additions and 43 deletions

View File

@@ -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]

View File

@@ -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 ≈ 280300% */ /** 灰成分替换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典型 280300% */
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

View File

@@ -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);

View File

@@ -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(
() => () =>

View File

@@ -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>

View File

@@ -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)

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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) */