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