Add a browser image editor and split scanner Open from Add files.
office.sejong.dev/pipeline/head This commit looks good
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>
This commit is contained in:
1 parent
194eb318d1
commit
26c98fe5e8
8 files changed
+1651
-13
No files matched your search
@@ -0,0 +1,272 @@
|
||||
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)),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user