From 26c98fe5e84f08c179ed7599edbed445ef35c788 Mon Sep 17 00:00:00 2001 From: Sejong Yoo Date: Mon, 14 Sep 2026 11:38:49 +0900 Subject: [PATCH] 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 --- packages/frontend/src/App.vue | 4 +- .../src/components/imageedit/CropEditor.vue | 285 ++++++ packages/frontend/src/config/favicons.ts | 6 + .../src/integrations/imageedit/process.ts | 272 ++++++ packages/frontend/src/router/index.ts | 5 + packages/frontend/src/views/HomeView.vue | 8 +- .../frontend/src/views/ToolsImageEditView.vue | 860 ++++++++++++++++++ .../frontend/src/views/ToolsScannerView.vue | 224 ++++- 8 files changed, 1651 insertions(+), 13 deletions(-) create mode 100644 packages/frontend/src/components/imageedit/CropEditor.vue create mode 100644 packages/frontend/src/integrations/imageedit/process.ts create mode 100644 packages/frontend/src/views/ToolsImageEditView.vue diff --git a/packages/frontend/src/App.vue b/packages/frontend/src/App.vue index 359d18b..1362dbd 100644 --- a/packages/frontend/src/App.vue +++ b/packages/frontend/src/App.vue @@ -28,7 +28,8 @@ const viewKey = computed(() => { route.name === 'illustrator-viewer' || route.name === 'photoshop-viewer' || route.name === 'tools-scanner' || - route.name === 'tools-bg-remove' + route.name === 'tools-bg-remove' || + route.name === 'tools-image-edit' ) { return String(route.name); } @@ -50,6 +51,7 @@ const viewKey = computed(() => { 'PhotoshopViewerView', 'ToolsScannerView', 'ToolsBgRemoveView', + 'ToolsImageEditView', ]" :max="10" > diff --git a/packages/frontend/src/components/imageedit/CropEditor.vue b/packages/frontend/src/components/imageedit/CropEditor.vue new file mode 100644 index 0000000..5be0f96 --- /dev/null +++ b/packages/frontend/src/components/imageedit/CropEditor.vue @@ -0,0 +1,285 @@ + + + + + diff --git a/packages/frontend/src/config/favicons.ts b/packages/frontend/src/config/favicons.ts index 329df25..cdd2b4e 100644 --- a/packages/frontend/src/config/favicons.ts +++ b/packages/frontend/src/config/favicons.ts @@ -22,6 +22,7 @@ export type FaviconKind = | 'photoshop' | 'scanner' | 'bgremove' + | 'imgedit' | 'text' | 'csv' | 'file'; @@ -51,6 +52,9 @@ export const ICON_PATHS: Record = { scanner: 'M4 4H10V6H6V10H4V4M14 4H20V10H18V6H14V4M4 14H6V18H10V20H4V14M18 14H20V20H14V18H18V14M8 8H16V16H8V8Z', bgremove: 'M19.36,2.72L20.78,4.14L15.06,9.85C16.13,11.39 16.28,13.24 15.38,14.44L9.06,8.12C10.26,7.22 12.11,7.37 13.65,8.44L19.36,2.72M5.93,17.57C3.92,15.56 2.69,13.16 2.35,10.92L7.23,8.83L14.67,16.27L12.58,21.15C10.34,20.81 7.94,19.58 5.93,17.57Z', + /** mdi:crop */ + imgedit: + 'M7,17V1H5V5H1V7H5V17A2,2 0 0,0 7,19H17V23H19V19H23V17H19V7H7V17M17,17H7V7H17V17Z', text: 'M6,2A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2H6M6,4H13V9H18V20H6V4M8,12V14H16V12H8M8,16V18H13V16H8Z', csv: 'M14 2H6C4.9 2 4 2.9 4 4V20C4 21.1 4.9 22 6 22H18C19.1 22 20 21.1 20 20V8L14 2M18 20H6V4H13V9H18V20M10 19L12 15H9V10H15V15L13 19H10', file: 'M6,2A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2H6M6,4H13V9H18V20H6V4M8,12V14H16V12H8M8,16V18H13V16H8Z', @@ -91,6 +95,7 @@ export const ICON_COLORS: Record = { photoshop: '#31a8ff', scanner: '#00897b', bgremove: '#5c6bc0', + imgedit: '#c2185b', text: '#607d8b', csv: '#00838f', file: '#546e7a', @@ -153,6 +158,7 @@ const EXT_KIND: Record = { gif: 'scanner', bmp: 'scanner', bgremove: 'bgremove', + imgedit: 'imgedit', txt: 'text', }; diff --git a/packages/frontend/src/integrations/imageedit/process.ts b/packages/frontend/src/integrations/imageedit/process.ts new file mode 100644 index 0000000..63f1e72 --- /dev/null +++ b/packages/frontend/src/integrations/imageedit/process.ts @@ -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((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((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)), + }; +} diff --git a/packages/frontend/src/router/index.ts b/packages/frontend/src/router/index.ts index e675127..4a69b7f 100644 --- a/packages/frontend/src/router/index.ts +++ b/packages/frontend/src/router/index.ts @@ -64,6 +64,11 @@ const router = createRouter({ name: 'tools-bg-remove', component: () => import('@/views/ToolsBgRemoveView.vue'), }, + { + path: '/tools/image-edit', + name: 'tools-image-edit', + component: () => import('@/views/ToolsImageEditView.vue'), + }, { path: '/licenses', name: 'licenses', diff --git a/packages/frontend/src/views/HomeView.vue b/packages/frontend/src/views/HomeView.vue index a384ee5..6f3c1b9 100644 --- a/packages/frontend/src/views/HomeView.vue +++ b/packages/frontend/src/views/HomeView.vue @@ -87,6 +87,12 @@ const toolTypes = [ title: '배경 제거', subtitle: '로고 단색 배경 · 투명 PNG', }, + { + route: 'tools-image-edit', + extension: 'imgedit', + title: '이미지 편집', + subtitle: '크롭 · 축소 · 경량화 · 비율', + }, ] as const; function onDrop(e: DragEvent) { @@ -225,7 +231,7 @@ function onOpenTool(route: string) {

도구

- 브라우저에서 문서를 스캔·보정하고 OCR이 포함된 PDF로 저장합니다. + 브라우저에서 문서를 스캔하고, 배경을 제거하며, 이미지를 크롭·축소·경량화합니다.

diff --git a/packages/frontend/src/views/ToolsImageEditView.vue b/packages/frontend/src/views/ToolsImageEditView.vue new file mode 100644 index 0000000..6f4441f --- /dev/null +++ b/packages/frontend/src/views/ToolsImageEditView.vue @@ -0,0 +1,860 @@ + + + + + diff --git a/packages/frontend/src/views/ToolsScannerView.vue b/packages/frontend/src/views/ToolsScannerView.vue index f34560f..10b5ce0 100644 --- a/packages/frontend/src/views/ToolsScannerView.vue +++ b/packages/frontend/src/views/ToolsScannerView.vue @@ -56,6 +56,9 @@ const coarsePointer = ref(false); const mobilePane = ref<'edit' | 'preview'>('edit'); const queueOpen = ref(false); const settingsOpen = ref(false); +const warpPromptOpen = ref(false); +/** `열기` replaces the queue; `파일 추가`/카메라/드롭은 뒤에 덧붙입니다. */ +const replaceOnSelect = ref(false); const mediaQueryCleanups: Array<() => void> = []; function refreshPointerMode() { @@ -72,7 +75,9 @@ function refreshPointerMode() { function syncBodyScrollLock() { if (typeof document === 'undefined') return; document.body.style.overflow = - queueOpen.value || settingsOpen.value ? 'hidden' : ''; + queueOpen.value || settingsOpen.value || warpPromptOpen.value + ? 'hidden' + : ''; } usePageTitle(filename, { faviconKind: 'scanner' }); @@ -119,7 +124,7 @@ async function pushPageFromSource( sourceWidth: w, sourceHeight: h, corners, - aspect: 'a4-portrait', + aspect: 'auto', scanIntensity: DEFAULT_SCAN_INTENSITY, warpedUrl: null, warpedWidth: 0, @@ -131,13 +136,14 @@ async function pushPageFromSource( filename.value = name; } -async function addFiles(files: File[]) { +async function addFiles(files: File[], replace = false) { const images = files.filter(isScannerImageFile); const pdfs = files.filter(isPdfFile); if (!images.length && !pdfs.length) { error.value = 'PNG, JPEG, WebP 이미지 또는 PDF를 선택해 주세요.'; return; } + if (replace) clearPages(); error.value = ''; busy.value = true; progressLabel.value = '파일 준비 중'; @@ -167,9 +173,10 @@ async function addFiles(files: File[]) { } } + const verb = replace ? '열었습니다' : '추가했습니다'; notice.value = coarsePointer.value - ? `${added}장을 추가했습니다. 화살표로 순서를 바꿀 수 있습니다.` - : `${added}장을 추가했습니다. 드래그로 순서를 바꿀 수 있습니다.`; + ? `${added}장을 ${verb}. 화살표로 순서를 바꿀 수 있습니다.` + : `${added}장을 ${verb}. 드래그로 순서를 바꿀 수 있습니다.`; } catch (reason) { error.value = reason instanceof Error @@ -227,7 +234,7 @@ watch(activePage, (page) => { if (page) filename.value = page.name; }); -watch([queueOpen, settingsOpen], () => { +watch([queueOpen, settingsOpen, warpPromptOpen], () => { syncBodyScrollLock(); }); @@ -237,23 +244,46 @@ watch( if (!count) { queueOpen.value = false; settingsOpen.value = false; + warpPromptOpen.value = false; } }, ); +function clearPages() { + for (const page of pages.value) { + revokeUrl(page.sourceUrl); + revokeUrl(page.warpedUrl); + } + pages.value = []; + activeId.value = null; + filename.value = '문서 스캔'; + mobilePane.value = 'edit'; + warpPromptOpen.value = false; +} + +/** Toolbar `열기`: discards the current queue and starts over. */ function onOpen() { + replaceOnSelect.value = true; + fileInput.value?.click(); +} + +function onAddFiles() { + replaceOnSelect.value = false; fileInput.value?.click(); } function onCamera() { + replaceOnSelect.value = false; cameraInput.value?.click(); } function onFilesSelected(event: Event) { const input = event.target as HTMLInputElement; const selected = Array.from(input.files ?? []); + const replace = replaceOnSelect.value; + replaceOnSelect.value = false; input.value = ''; - if (selected.length) void addFiles(selected); + if (selected.length) void addFiles(selected, replace); } function selectPage(id: string) { @@ -277,6 +307,27 @@ function closeSettings() { settingsOpen.value = false; } +/** Ask before showing a preview that has no correction applied yet. */ +function requestWarpPrompt() { + if (busy.value) return; + if (!activePage.value || activePage.value.warpedUrl) return; + warpPromptOpen.value = true; +} + +function showPreviewPane() { + mobilePane.value = 'preview'; + requestWarpPrompt(); +} + +function closeWarpPrompt() { + warpPromptOpen.value = false; +} + +async function confirmWarpPrompt() { + warpPromptOpen.value = false; + await warpActive(); +} + function removePage(id: string) { const index = pages.value.findIndex((page) => page.id === id); if (index < 0) return; @@ -646,7 +697,7 @@ const progressPercent = computed(() =>
+ +
@@ -1101,7 +1189,16 @@ const progressPercent = computed(() => :src="activePage.warpedUrl" alt="보정된 스캔" /> -

보정 후 미리보기가 표시됩니다.

+

OCR 결과

{{ activePage.ocr.text || '(텍스트 없음)' }}
@@ -1567,6 +1664,111 @@ const progressPercent = computed(() => background: #111; } +.preview-empty { + display: grid; + gap: 0.25rem; + justify-items: start; + width: 100%; + padding: 1.1rem 0.9rem; + border: 1px dashed var(--border); + border-radius: 6px; + background: var(--canvas); + color: var(--ink); + font: inherit; + text-align: left; + cursor: pointer; +} + +.preview-empty:hover:not(:disabled) { + border-color: var(--accent); + background: var(--accent-soft); +} + +.preview-empty:disabled { + opacity: 0.6; + cursor: default; +} + +.preview-empty strong { + font-size: 0.9rem; +} + +.preview-empty span { + color: var(--muted); + font-size: 0.78rem; +} + +.warp-modal { + position: fixed; + inset: 0; + z-index: 95; + display: grid; + place-items: center; + padding: 1rem; +} + +.warp-modal__backdrop { + position: absolute; + inset: 0; + border: 0; + background: color-mix(in srgb, black 45%, transparent); + cursor: pointer; +} + +.warp-modal__panel { + position: relative; + z-index: 1; + width: min(100%, 24rem); + display: grid; + gap: 0.65rem; + padding: 1.1rem 1.15rem 1.15rem; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--surface); + color: var(--ink); + box-shadow: 0 12px 32px color-mix(in srgb, black 28%, transparent); +} + +.warp-modal__panel h2 { + margin: 0; + font-size: 1.05rem; +} + +.warp-modal__panel p { + margin: 0; + color: var(--muted); + font-size: 0.86rem; + line-height: 1.5; +} + +.warp-modal__actions { + display: flex; + gap: 0.45rem; +} + +.warp-modal__actions button { + flex: 1; + min-height: 2.6rem; + border: 1px solid var(--border); + border-radius: 6px; + padding: 0.4rem 0.7rem; + background: var(--canvas); + color: var(--ink); + font: inherit; + cursor: pointer; +} + +.warp-modal__primary { + border-color: var(--accent) !important; + background: var(--accent-soft) !important; + font-weight: 600; +} + +.warp-modal__actions button:disabled { + opacity: 0.5; + cursor: default; +} + .ocr-box { margin-top: 0.75rem; }