office.sejong.dev/pipeline/head This commit looks good
Crop, shrink, compress, and change aspect locally, and prompt for warp before showing an uncorrected preview. New pages default to auto paper ratio. Co-authored-by: Cursor <cursoragent@cursor.com>
273 lines
6.8 KiB
TypeScript
273 lines
6.8 KiB
TypeScript
export type PixelRect = {
|
|
x: number;
|
|
y: number;
|
|
width: number;
|
|
height: number;
|
|
};
|
|
|
|
export type ExportFormat = 'image/jpeg' | 'image/webp' | 'image/png';
|
|
|
|
export type RatioFit = 'crop' | 'pad';
|
|
|
|
const MIN_CROP = 8;
|
|
|
|
export function loadImageFromUrl(url: string) {
|
|
return new Promise<HTMLImageElement>((resolve, reject) => {
|
|
const image = new Image();
|
|
image.decoding = 'async';
|
|
image.onload = () => resolve(image);
|
|
image.onerror = () => reject(new Error('이미지를 불러오지 못했습니다.'));
|
|
image.src = url;
|
|
});
|
|
}
|
|
|
|
export function clampCrop(
|
|
crop: PixelRect,
|
|
imageWidth: number,
|
|
imageHeight: number,
|
|
): PixelRect {
|
|
const maxW = Math.max(1, Math.floor(imageWidth));
|
|
const maxH = Math.max(1, Math.floor(imageHeight));
|
|
const width = Math.min(Math.max(MIN_CROP, Math.round(crop.width)), maxW);
|
|
const height = Math.min(Math.max(MIN_CROP, Math.round(crop.height)), maxH);
|
|
const x = Math.min(Math.max(0, Math.round(crop.x)), maxW - width);
|
|
const y = Math.min(Math.max(0, Math.round(crop.y)), maxH - height);
|
|
return { x, y, width, height };
|
|
}
|
|
|
|
export function fullImageCrop(imageWidth: number, imageHeight: number): PixelRect {
|
|
return clampCrop(
|
|
{ x: 0, y: 0, width: imageWidth, height: imageHeight },
|
|
imageWidth,
|
|
imageHeight,
|
|
);
|
|
}
|
|
|
|
/** Largest rectangle of `aspect` (width/height) that fits in the image. */
|
|
export function maxCropForAspect(
|
|
imageWidth: number,
|
|
imageHeight: number,
|
|
aspect: number,
|
|
center?: { x: number; y: number },
|
|
): PixelRect {
|
|
if (!(aspect > 0) || !Number.isFinite(aspect)) {
|
|
return fullImageCrop(imageWidth, imageHeight);
|
|
}
|
|
let width = imageWidth;
|
|
let height = width / aspect;
|
|
if (height > imageHeight) {
|
|
height = imageHeight;
|
|
width = height * aspect;
|
|
}
|
|
const cx = center?.x ?? imageWidth / 2;
|
|
const cy = center?.y ?? imageHeight / 2;
|
|
return clampCrop(
|
|
{ x: cx - width / 2, y: cy - height / 2, width, height },
|
|
imageWidth,
|
|
imageHeight,
|
|
);
|
|
}
|
|
|
|
export function formatBytes(bytes: number) {
|
|
if (!Number.isFinite(bytes) || bytes < 0) return '—';
|
|
if (bytes < 1024) return `${bytes} B`;
|
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
|
}
|
|
|
|
export function extensionForFormat(format: ExportFormat) {
|
|
if (format === 'image/jpeg') return 'jpg';
|
|
if (format === 'image/webp') return 'webp';
|
|
return 'png';
|
|
}
|
|
|
|
function outputSize(
|
|
srcWidth: number,
|
|
srcHeight: number,
|
|
scale: number,
|
|
maxEdge: number | null,
|
|
padAspect: number | null,
|
|
) {
|
|
let width = Math.max(1, srcWidth);
|
|
let height = Math.max(1, srcHeight);
|
|
|
|
if (padAspect && padAspect > 0) {
|
|
const current = width / height;
|
|
if (current < padAspect) {
|
|
width = height * padAspect;
|
|
} else if (current > padAspect) {
|
|
height = width / padAspect;
|
|
}
|
|
}
|
|
|
|
const factor = Math.min(1, Math.max(0.01, scale));
|
|
width *= factor;
|
|
height *= factor;
|
|
|
|
if (maxEdge && maxEdge > 0) {
|
|
const long = Math.max(width, height);
|
|
if (long > maxEdge) {
|
|
const shrink = maxEdge / long;
|
|
width *= shrink;
|
|
height *= shrink;
|
|
}
|
|
}
|
|
|
|
return {
|
|
width: Math.max(1, Math.round(width)),
|
|
height: Math.max(1, Math.round(height)),
|
|
};
|
|
}
|
|
|
|
export function previewOutputSize(options: {
|
|
crop: PixelRect;
|
|
scale: number;
|
|
maxEdge: number | null;
|
|
padAspect: number | null;
|
|
fit: RatioFit;
|
|
}) {
|
|
const padAspect = options.fit === 'pad' ? options.padAspect : null;
|
|
return outputSize(
|
|
options.crop.width,
|
|
options.crop.height,
|
|
options.scale,
|
|
options.maxEdge,
|
|
padAspect,
|
|
);
|
|
}
|
|
|
|
function canvasToBlob(
|
|
canvas: HTMLCanvasElement,
|
|
format: ExportFormat,
|
|
quality: number,
|
|
) {
|
|
return new Promise<Blob>((resolve, reject) => {
|
|
const q = format === 'image/png' ? undefined : Math.min(1, Math.max(0.01, quality));
|
|
canvas.toBlob(
|
|
(blob) => {
|
|
if (!blob) {
|
|
reject(new Error('이미지를 인코딩하지 못했습니다.'));
|
|
return;
|
|
}
|
|
resolve(blob);
|
|
},
|
|
format,
|
|
q,
|
|
);
|
|
});
|
|
}
|
|
|
|
function parseCssColor(color: string): { r: number; g: number; b: number } {
|
|
const hex = color.trim();
|
|
const short = /^#([0-9a-f]{3})$/i.exec(hex);
|
|
if (short) {
|
|
const n = short[1];
|
|
return {
|
|
r: Number.parseInt(n[0] + n[0], 16),
|
|
g: Number.parseInt(n[1] + n[1], 16),
|
|
b: Number.parseInt(n[2] + n[2], 16),
|
|
};
|
|
}
|
|
const full = /^#([0-9a-f]{6})$/i.exec(hex);
|
|
if (full) {
|
|
const n = full[1];
|
|
return {
|
|
r: Number.parseInt(n.slice(0, 2), 16),
|
|
g: Number.parseInt(n.slice(2, 4), 16),
|
|
b: Number.parseInt(n.slice(4, 6), 16),
|
|
};
|
|
}
|
|
return { r: 255, g: 255, b: 255 };
|
|
}
|
|
|
|
export async function exportEditedImage(options: {
|
|
image: HTMLImageElement;
|
|
crop: PixelRect;
|
|
scale: number;
|
|
maxEdge: number | null;
|
|
format: ExportFormat;
|
|
quality: number;
|
|
fit: RatioFit;
|
|
padAspect: number | null;
|
|
padColor: string;
|
|
}): Promise<{ blob: Blob; width: number; height: number }> {
|
|
const crop = clampCrop(
|
|
options.crop,
|
|
options.image.naturalWidth || options.image.width,
|
|
options.image.naturalHeight || options.image.height,
|
|
);
|
|
const padAspect =
|
|
options.fit === 'pad' && options.padAspect && options.padAspect > 0
|
|
? options.padAspect
|
|
: null;
|
|
const size = outputSize(
|
|
crop.width,
|
|
crop.height,
|
|
options.scale,
|
|
options.maxEdge,
|
|
padAspect,
|
|
);
|
|
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = size.width;
|
|
canvas.height = size.height;
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) throw new Error('캔버스를 만들 수 없습니다.');
|
|
|
|
if (options.format === 'image/jpeg' || padAspect) {
|
|
const { r, g, b } = parseCssColor(options.padColor);
|
|
ctx.fillStyle = `rgb(${r}, ${g}, ${b})`;
|
|
ctx.fillRect(0, 0, size.width, size.height);
|
|
} else {
|
|
ctx.clearRect(0, 0, size.width, size.height);
|
|
}
|
|
|
|
ctx.imageSmoothingEnabled = true;
|
|
ctx.imageSmoothingQuality = 'high';
|
|
|
|
if (padAspect) {
|
|
const fitted = containSize(crop.width, crop.height, size.width, size.height);
|
|
const dx = Math.round((size.width - fitted.width) / 2);
|
|
const dy = Math.round((size.height - fitted.height) / 2);
|
|
ctx.drawImage(
|
|
options.image,
|
|
crop.x,
|
|
crop.y,
|
|
crop.width,
|
|
crop.height,
|
|
dx,
|
|
dy,
|
|
fitted.width,
|
|
fitted.height,
|
|
);
|
|
} else {
|
|
ctx.drawImage(
|
|
options.image,
|
|
crop.x,
|
|
crop.y,
|
|
crop.width,
|
|
crop.height,
|
|
0,
|
|
0,
|
|
size.width,
|
|
size.height,
|
|
);
|
|
}
|
|
|
|
const blob = await canvasToBlob(canvas, options.format, options.quality);
|
|
return { blob, width: size.width, height: size.height };
|
|
}
|
|
|
|
function containSize(
|
|
srcW: number,
|
|
srcH: number,
|
|
boxW: number,
|
|
boxH: number,
|
|
) {
|
|
const scale = Math.min(boxW / srcW, boxH / srcH);
|
|
return {
|
|
width: Math.max(1, Math.round(srcW * scale)),
|
|
height: Math.max(1, Math.round(srcH * scale)),
|
|
};
|
|
}
|