Files
sejong-office/packages/frontend/src/integrations/imageedit/process.ts
T
ysj7705andCursor 26c98fe5e8
office.sejong.dev/pipeline/head This commit looks good
Add a browser image editor and split scanner Open from Add files.
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>
2026-09-14 11:38:49 +09:00

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)),
};
}