From 2cb0c75092aed5df7f46ceaf58c9348145e55001 Mon Sep 17 00:00:00 2001 From: ysj7705 Date: Sun, 4 Oct 2026 11:55:12 +0900 Subject: [PATCH] Make scanner output look like a real scan. - Estimate the true paper aspect from the perspective of the four corners (Zhang & He) and snap to A-series/Letter, instead of stretching to the longest edges. - Keep native resolution (1754-3508px long side) and warp with cubic interpolation, trimming a thin border. - Replace the global contrast tweak with OpenCV scan filters: illumination flattening (background division) plus ink levels and sharpening for color/gray, adaptive threshold for B&W, and an original mode. - Export color pages as JPEG, size PDF pages to A4 and scale OCR text. Co-Authored-By: Claude Opus 5.5 --- .../src/integrations/scanner/buildPdf.ts | 32 +- .../src/integrations/scanner/types.ts | 6 +- .../frontend/src/integrations/scanner/warp.ts | 360 +++++++++++++----- .../frontend/src/views/ToolsScannerView.vue | 67 +++- 4 files changed, 353 insertions(+), 112 deletions(-) diff --git a/packages/frontend/src/integrations/scanner/buildPdf.ts b/packages/frontend/src/integrations/scanner/buildPdf.ts index f3c7df0..868e940 100644 --- a/packages/frontend/src/integrations/scanner/buildPdf.ts +++ b/packages/frontend/src/integrations/scanner/buildPdf.ts @@ -21,6 +21,14 @@ async function loadCjkFont(pdf: PDFDocument) { return pdf.embedFont(bytes, { subset: true }); } +/** 스캔 페이지의 긴 변을 A4 긴 변(297mm = 841.89pt)으로 맞춘다 — 픽셀 수를 그대로 pt로 쓰면 종이가 수십 cm가 된다 */ +const PAGE_LONG_PT = 841.89; + +const isJpeg = (bytes: ArrayBuffer) => { + const b = new Uint8Array(bytes, 0, 2); + return b[0] === 0xff && b[1] === 0xd8; +}; + export async function buildSearchablePdf(pages: PdfPageInput[]) { if (!pages.length) { throw new Error('PDF로 내보낼 페이지가 없습니다.'); @@ -42,13 +50,18 @@ export async function buildSearchablePdf(pages: PdfPageInput[]) { } for (const page of pages) { - const image = await pdf.embedPng(page.imageBytes); - const pdfPage = pdf.addPage([page.width, page.height]); + const image = isJpeg(page.imageBytes) + ? await pdf.embedJpg(page.imageBytes) + : await pdf.embedPng(page.imageBytes); + const k = PAGE_LONG_PT / Math.max(page.width, page.height); + const pageWidth = page.width * k; + const pageHeight = page.height * k; + const pdfPage = pdf.addPage([pageWidth, pageHeight]); pdfPage.drawImage(image, { x: 0, y: 0, - width: page.width, - height: page.height, + width: pageWidth, + height: pageHeight, }); const font = cjkFont ?? latinFont; @@ -57,11 +70,12 @@ export async function buildSearchablePdf(pages: PdfPageInput[]) { const text = word.text.trim(); if (!text) continue; if (!cjkFont && /[^\u0000-\u007f]/.test(text)) continue; - const boxWidth = Math.max(1, word.bbox.x1 - word.bbox.x0); - const boxHeight = Math.max(1, word.bbox.y1 - word.bbox.y0); - const fontSize = Math.max(4, Math.min(boxHeight * 0.9, boxWidth)); - const x = word.bbox.x0; - const y = page.height - word.bbox.y1; + // OCR 좌표는 스캔 이미지 픽셀 기준 → pt로 환산 + const boxWidth = Math.max(1, (word.bbox.x1 - word.bbox.x0) * k); + const boxHeight = Math.max(1, (word.bbox.y1 - word.bbox.y0) * k); + const fontSize = Math.max(2, Math.min(boxHeight * 0.9, boxWidth)); + const x = word.bbox.x0 * k; + const y = pageHeight - word.bbox.y1 * k; try { pdfPage.drawText(text, { x, diff --git a/packages/frontend/src/integrations/scanner/types.ts b/packages/frontend/src/integrations/scanner/types.ts index 47ad5fe..1c1a6cd 100644 --- a/packages/frontend/src/integrations/scanner/types.ts +++ b/packages/frontend/src/integrations/scanner/types.ts @@ -12,6 +12,9 @@ export interface CornerPoints { export type PaperAspect = 'a4-portrait' | 'a4-landscape' | 'square' | 'auto'; +/** 원본: 원근 보정만 · 컬러/회색조 문서: 조명 평탄화·글자 보정 · 흑백 문서: 이진화 */ +export type ScanMode = 'original' | 'color' | 'gray' | 'bw'; + export interface OcrWord { text: string; /** Pixel bbox on the warped image: x0,y0,x1,y1 */ @@ -32,7 +35,8 @@ export interface ScannerPage { sourceHeight: number; corners: CornerPoints; aspect: PaperAspect; - /** 0 = 원본 색감, 100 = 강한 문서 스캔 보정 */ + scanMode: ScanMode; + /** 글자 진하기·선명도 0–100 (흑백은 이진화 문턱) */ scanIntensity: number; warpedUrl: string | null; warpedWidth: number; diff --git a/packages/frontend/src/integrations/scanner/warp.ts b/packages/frontend/src/integrations/scanner/warp.ts index 5ec196f..88a8ccf 100644 --- a/packages/frontend/src/integrations/scanner/warp.ts +++ b/packages/frontend/src/integrations/scanner/warp.ts @@ -1,103 +1,251 @@ -import { - createScanner, - ensureJscanify, - loadImageFromUrl, -} from './detect'; -import type { CornerPoints, PaperAspect } from './types'; +import { ensureOpenCv, loadImageFromUrl } from './detect'; +import type { CornerPoints, PaperAspect, ScanMode } from './types'; -export const DEFAULT_SCAN_INTENSITY = 55; +/** 0 = 은은하게 · 100 = 글자를 아주 진하고 선명하게 (조명 평탄화는 강도와 무관하게 항상) */ +export const DEFAULT_SCAN_INTENSITY = 60; +export const DEFAULT_SCAN_MODE: ScanMode = 'color'; -function clampByte(value: number) { - return Math.min(255, Math.max(0, Math.round(value))); +const A_RATIO = Math.SQRT2; // A 계열 용지 (A4 등) +const LETTER_RATIO = 11 / 8.5; +/** 원본이 이보다 크면 줄여서 처리 (48MP 사진 등에서 WASM 메모리 부족 방지) */ +const MAX_SOURCE_SIDE = 4096; + +// jscanify에 들어 있는 OpenCV.js(4.7)에는 타입 정의가 없다 +type Cv = any; +type Mat = any; + +const dist = (a: { x: number; y: number }, b: { x: number; y: number }) => + Math.hypot(a.x - b.x, a.y - b.y); +const odd = (n: number) => Math.round(n) | 1; + +function edgeAspect(c: CornerPoints) { + const w = + (dist(c.topLeftCorner, c.topRightCorner) + + dist(c.bottomLeftCorner, c.bottomRightCorner)) / + 2; + const h = + (dist(c.topLeftCorner, c.bottomLeftCorner) + + dist(c.topRightCorner, c.bottomRightCorner)) / + 2; + return w / Math.max(h, 1); } -/** 스캔 강도(0–100): 대비·배경 미백·문서형 흑백 혼합 */ -export function applyScanIntensity( - canvas: HTMLCanvasElement, - intensity: number, +/** + * 원근을 고려한 실제 용지 가로/세로 비. + * 비스듬히 찍으면 먼 쪽 변이 짧아져 변 길이만으로는 비율이 크게 틀어진다(늘어난 듯한 결과). + * Zhang & He, "Whiteboard scanning and image enhancement"(2007) — 주점은 사진 중앙으로 가정. + * 정면에 가깝거나 값이 불안정하면 변 길이 평균으로 대신한다. + */ +export function estimateAspect( + c: CornerPoints, + imageWidth: number, + imageHeight: number, ) { - const amount = Math.min(100, Math.max(0, intensity)) / 100; - if (amount <= 0.001) return canvas; - - const ctx = canvas.getContext('2d', { willReadFrequently: true }); - if (!ctx) return canvas; - - const image = ctx.getImageData(0, 0, canvas.width, canvas.height); - const { data } = image; - const contrast = 1 + amount * 1.45; - const grayMix = amount * 0.9; - const whitePush = amount * 0.42; - const inkMix = Math.max(0, (amount - 0.55) / 0.45); - - for (let i = 0; i < data.length; i += 4) { - let r = data[i]!; - let g = data[i + 1]!; - let b = data[i + 2]!; - const y = 0.299 * r + 0.587 * g + 0.114 * b; - r = r * (1 - grayMix) + y * grayMix; - g = g * (1 - grayMix) + y * grayMix; - b = b * (1 - grayMix) + y * grayMix; - - r = (r - 128) * contrast + 128; - g = (g - 128) * contrast + 128; - b = (b - 128) * contrast + 128; - - if (whitePush > 0) { - const lift = (v: number) => - v > 155 ? v + (255 - v) * whitePush * ((v - 155) / 100) : v; - r = lift(r); - g = lift(g); - b = lift(b); - } - - if (inkMix > 0) { - const y2 = 0.299 * r + 0.587 * g + 0.114 * b; - const ink = Math.max(0, Math.min(1, (175 - y2) / 85)); - const doc = 255 * (1 - ink) + 18 * ink; - r = r * (1 - inkMix) + doc * inkMix; - g = g * (1 - inkMix) + doc * inkMix; - b = b * (1 - inkMix) + doc * inkMix; - } - - data[i] = clampByte(r); - data[i + 1] = clampByte(g); - data[i + 2] = clampByte(b); + type V3 = [number, number, number]; + const u0 = imageWidth / 2; + const v0 = imageHeight / 2; + const p = (pt: { x: number; y: number }): V3 => [pt.x - u0, pt.y - v0, 1]; + const cross = (a: V3, b: V3): V3 => [ + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0], + ]; + const dot = (a: V3, b: V3) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; + const m1 = p(c.topLeftCorner); + const m2 = p(c.topRightCorner); + const m3 = p(c.bottomLeftCorner); + const m4 = p(c.bottomRightCorner); + const c14 = cross(m1, m4); + const k2 = dot(c14, m3) / dot(cross(m2, m4), m3); + const k3 = dot(c14, m2) / dot(cross(m3, m4), m2); + const n2 = m2.map((v, i) => k2 * v - m1[i]!) as V3; + const n3 = m3.map((v, i) => k3 * v - m1[i]!) as V3; + const denom = n2[2] * n3[2]; + if (!Number.isFinite(k2) || !Number.isFinite(k3) || Math.abs(denom) < 1e-9) { + return edgeAspect(c); } - - ctx.putImageData(image, 0, 0); - return canvas; + const f2 = -(n2[0] * n3[0] + n2[1] * n3[1]) / denom; + const maxDim = Math.max(imageWidth, imageHeight); + if (!(f2 > 0) || Math.sqrt(f2) < maxDim * 0.3 || Math.sqrt(f2) > maxDim * 6) { + return edgeAspect(c); + } + const ratio = Math.sqrt( + (n2[0] ** 2 + n2[1] ** 2 + n2[2] ** 2 * f2) / + (n3[0] ** 2 + n3[1] ** 2 + n3[2] ** 2 * f2), + ); + return Number.isFinite(ratio) && ratio > 0.15 && ratio < 7 + ? ratio + : edgeAspect(c); } +/** 흔한 용지 비율(A 계열, Letter)과 4% 안쪽이면 그 비율로 맞춘다 */ +function snapAspect(ratio: number) { + for (const target of [A_RATIO, LETTER_RATIO]) { + for (const t of [target, 1 / target]) { + if (Math.abs(ratio / t - 1) < 0.04) return t; + } + } + return ratio; +} + +/** 결과 크기: 비율은 용지 설정(자동이면 원근 추정), 긴 변은 원본에서 문서가 차지한 픽셀 수 기준 + * (1754–3508px = A4 150–300dpi, 원본보다 크게 늘리지 않음) */ export function outputSizeForAspect( aspect: PaperAspect, corners: CornerPoints, + imageWidth: number, + imageHeight: number, ) { - if (aspect === 'a4-portrait') return { width: 1240, height: 1754 }; - if (aspect === 'a4-landscape') return { width: 1754, height: 1240 }; - if (aspect === 'square') return { width: 1400, height: 1400 }; + let ratio: number; + if (aspect === 'a4-portrait') ratio = 1 / A_RATIO; + else if (aspect === 'a4-landscape') ratio = A_RATIO; + else if (aspect === 'square') ratio = 1; + else ratio = snapAspect(estimateAspect(corners, imageWidth, imageHeight)); + const sourceLong = Math.max( + dist(corners.topLeftCorner, corners.topRightCorner), + dist(corners.bottomLeftCorner, corners.bottomRightCorner), + dist(corners.topLeftCorner, corners.bottomLeftCorner), + dist(corners.topRightCorner, corners.bottomRightCorner), + ); + const long = Math.round(Math.min(3508, Math.max(1754, sourceLong * 1.1))); + return ratio >= 1 + ? { width: long, height: Math.max(1, Math.round(long / ratio)) } + : { width: Math.max(1, Math.round(long * ratio)), height: long }; +} - const widthTop = Math.hypot( - corners.topRightCorner.x - corners.topLeftCorner.x, - corners.topRightCorner.y - corners.topLeftCorner.y, +/** 원근 보정 (RGB → RGB). 가장자리는 늘려 채워 검은 테두리가 생기지 않게 한다 */ +function warpMat( + cv: Cv, + src: Mat, + c: CornerPoints, + size: { width: number; height: number }, +) { + const from = cv.matFromArray(4, 1, cv.CV_32FC2, [ + c.topLeftCorner.x, c.topLeftCorner.y, + c.topRightCorner.x, c.topRightCorner.y, + c.bottomRightCorner.x, c.bottomRightCorner.y, + c.bottomLeftCorner.x, c.bottomLeftCorner.y, + ]); + // 가장자리를 0.4% 안쪽으로 잘라 코너 오차로 섞여 든 책상·그림자 테두리를 없앤다 + const m = Math.round(Math.max(size.width, size.height) * 0.004); + const to = cv.matFromArray(4, 1, cv.CV_32FC2, [ + -m, -m, size.width + m, -m, size.width + m, size.height + m, -m, size.height + m, + ]); + const transform = cv.getPerspectiveTransform(from, to); + const out = new cv.Mat(); + cv.warpPerspective( + src, out, transform, new cv.Size(size.width, size.height), + cv.INTER_CUBIC, cv.BORDER_REPLICATE, ); - const widthBottom = Math.hypot( - corners.bottomRightCorner.x - corners.bottomLeftCorner.x, - corners.bottomRightCorner.y - corners.bottomLeftCorner.y, + from.delete(); + to.delete(); + transform.delete(); + return out; +} + +/** + * 조명 평탄화: 글자를 지운 종이 배경(밝은 쪽 팽창 + 메디안 블러)을 추정해 원본을 나눈다. + * 그림자·밝기 차이·누런 조명이 사라지고 종이는 흰색, 도장·서명 같은 색은 남는다. + */ +function flatten(cv: Cv, src: Mat) { + const scale = Math.min(1, 640 / Math.max(src.cols, src.rows)); + const small = new cv.Mat(); + cv.resize( + src, small, + new cv.Size(Math.round(src.cols * scale), Math.round(src.rows * scale)), + 0, 0, cv.INTER_AREA, ); - const heightLeft = Math.hypot( - corners.bottomLeftCorner.x - corners.topLeftCorner.x, - corners.bottomLeftCorner.y - corners.topLeftCorner.y, - ); - const heightRight = Math.hypot( - corners.bottomRightCorner.x - corners.topRightCorner.x, - corners.bottomRightCorner.y - corners.topRightCorner.y, - ); - const width = Math.max(widthTop, widthBottom, 1); - const height = Math.max(heightLeft, heightRight, 1); - const scale = Math.min(1800 / width, 1800 / height, 1); + const kernel = cv.getStructuringElement(cv.MORPH_ELLIPSE, new cv.Size(9, 9)); + cv.dilate(small, small, kernel); + cv.medianBlur(small, small, 31); + cv.GaussianBlur(small, small, new cv.Size(0, 0), 3); + const background = new cv.Mat(); + cv.resize(small, background, new cv.Size(src.cols, src.rows), 0, 0, cv.INTER_LINEAR); + const out = new cv.Mat(); + cv.divide(src, background, out, 255); + small.delete(); + kernel.delete(); + background.delete(); + return out; +} + +/** 레벨 보정: 잉크 쪽 하위 blackPct를 0으로 당기고 감마로 글자를 진하게 (이 빌드에는 cv.LUT가 없다) */ +function levels(mat: Mat, blackPct: number, gamma: number, whiteAt = 245) { + const data = mat.data as Uint8Array; + const hist = new Uint32Array(256); + for (let i = 0; i < data.length; i += 1) hist[data[i]!] += 1; + const target = data.length * blackPct; + let acc = 0; + let black = 0; + for (let v = 0; v < 256; v += 1) { + acc += hist[v]!; + if (acc >= target) { + black = v; + break; + } + } + black = Math.min(black, whiteAt - 40); + const lut = new Uint8Array(256); + for (let v = 0; v < 256; v += 1) { + const t = Math.min(1, Math.max(0, (v - black) / (whiteAt - black))); + lut[v] = Math.round(255 * t ** gamma); + } + for (let i = 0; i < data.length; i += 1) data[i] = lut[data[i]!]!; +} + +function sharpen(cv: Cv, mat: Mat, amount: number) { + const blur = new cv.Mat(); + cv.GaussianBlur(mat, blur, new cv.Size(0, 0), 1.1); + cv.addWeighted(mat, 1 + amount, blur, -amount, 0, mat); + blur.delete(); +} + +/** 스캔 필터. 원본: 원근 보정만 · 컬러/회색조: 조명 평탄화 + 글자 진하게 + 선명 · 흑백: 적응형 이진화 */ +function enhance(cv: Cv, src: Mat, mode: ScanMode, intensity: number) { + const a = Math.min(100, Math.max(0, intensity)) / 100; + if (mode === 'original') return src.clone(); + let base = src; + if (mode === 'gray' || mode === 'bw') { + base = new cv.Mat(); + cv.cvtColor(src, base, cv.COLOR_RGB2GRAY); + } + const flat = flatten(cv, base); + if (base !== src) base.delete(); + if (mode === 'bw') { + const out = new cv.Mat(); + cv.adaptiveThreshold( + flat, out, 255, cv.ADAPTIVE_THRESH_GAUSSIAN_C, cv.THRESH_BINARY, + odd(Math.max(flat.cols, flat.rows) / 70), 6 + 14 * a, + ); + flat.delete(); + return out; + } + levels(flat, 0.002 + 0.02 * a, 1 + 0.6 * a); + sharpen(cv, flat, 0.3 + 0.5 * a); + return flat; +} + +/** 원본을 캔버스에 그린다. 너무 크면 줄이고 그 배율을 돌려준다 */ +function sourceCanvas(image: HTMLImageElement) { + const w = image.naturalWidth || image.width; + const h = image.naturalHeight || image.height; + const scale = Math.min(1, MAX_SOURCE_SIDE / Math.max(w, h)); + const canvas = document.createElement('canvas'); + canvas.width = Math.max(1, Math.round(w * scale)); + canvas.height = Math.max(1, Math.round(h * scale)); + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + if (!ctx) throw new Error('Canvas를 초기화하지 못했습니다.'); + ctx.drawImage(image, 0, 0, canvas.width, canvas.height); + return { canvas, scale }; +} + +function scaleCorners(c: CornerPoints, s: number): CornerPoints { + const f = (p: { x: number; y: number }) => ({ x: p.x * s, y: p.y * s }); return { - width: Math.max(1, Math.round(width * scale)), - height: Math.max(1, Math.round(height * scale)), + topLeftCorner: f(c.topLeftCorner), + topRightCorner: f(c.topRightCorner), + bottomRightCorner: f(c.bottomRightCorner), + bottomLeftCorner: f(c.bottomLeftCorner), }; } @@ -106,28 +254,46 @@ export async function warpImage( corners: CornerPoints, aspect: PaperAspect, scanIntensity: number = DEFAULT_SCAN_INTENSITY, + scanMode: ScanMode = DEFAULT_SCAN_MODE, ) { - await ensureJscanify(); + await ensureOpenCv(); + const cv: Cv = window.cv; const image = await loadImageFromUrl(sourceUrl); - const size = outputSizeForAspect(aspect, corners); - const scanner = createScanner(); - const canvas = scanner.extractPaper( - image, - size.width, - size.height, - corners, - ); - if (!canvas) { - throw new Error('문서 영역을 추출하지 못했습니다.'); + const { canvas: source, scale } = sourceCanvas(image); + const c = scaleCorners(corners, scale); + const size = outputSizeForAspect(aspect, c, source.width, source.height); + + const rgba = cv.imread(source); + const rgb = new cv.Mat(); + cv.cvtColor(rgba, rgb, cv.COLOR_RGBA2RGB); + rgba.delete(); + let warped: Mat = null; + let result: Mat = null; + const canvas = document.createElement('canvas'); + try { + warped = warpMat(cv, rgb, c, size); + result = enhance(cv, warped, scanMode, scanIntensity); + cv.imshow(canvas, result); + } catch (reason) { + throw new Error( + `문서 보정에 실패했습니다: ${reason instanceof Error ? reason.message : String(reason)}`, + ); + } finally { + rgb.delete(); + warped?.delete(); + result?.delete(); } - applyScanIntensity(canvas, scanIntensity); + + // 흑백은 PNG가 작고 선명하다. 컬러·회색조는 JPEG(PNG면 페이지당 10MB 이상) + const type = scanMode === 'bw' ? 'image/png' : 'image/jpeg'; const blob = await new Promise((resolve, reject) => { canvas.toBlob( (value) => value ? resolve(value) : reject(new Error('보정 이미지를 만들지 못했습니다.')), - 'image/png', + type, + 0.92, ); }); return { diff --git a/packages/frontend/src/views/ToolsScannerView.vue b/packages/frontend/src/views/ToolsScannerView.vue index 10b5ce0..17bcdad 100644 --- a/packages/frontend/src/views/ToolsScannerView.vue +++ b/packages/frontend/src/views/ToolsScannerView.vue @@ -29,9 +29,14 @@ import { import type { CornerPoints, PaperAspect, + ScanMode, ScannerPage, } from '@/integrations/scanner/types'; -import { warpImage, DEFAULT_SCAN_INTENSITY } from '@/integrations/scanner/warp'; +import { + warpImage, + DEFAULT_SCAN_INTENSITY, + DEFAULT_SCAN_MODE, +} from '@/integrations/scanner/warp'; import { downloadBlob } from '@/utils/downloadBlob'; defineOptions({ name: 'ToolsScannerView' }); @@ -125,6 +130,7 @@ async function pushPageFromSource( sourceHeight: h, corners, aspect: 'auto', + scanMode: DEFAULT_SCAN_MODE, scanIntensity: DEFAULT_SCAN_INTENSITY, warpedUrl: null, warpedWidth: 0, @@ -438,6 +444,19 @@ function setScanIntensity(value: number) { ); } +const SCAN_MODES: { value: ScanMode; label: string }[] = [ + { value: 'color', label: '컬러 문서' }, + { value: 'gray', label: '회색조 문서' }, + { value: 'bw', label: '흑백 문서' }, + { value: 'original', label: '원본 (원근만)' }, +]; + +async function updateScanMode(mode: ScanMode) { + if (!activePage.value || busy.value) return; + activePage.value.scanMode = mode; + await warpActive(); +} + async function commitScanIntensity() { if (!activePage.value || busy.value) return; await warpActive(); @@ -482,6 +501,7 @@ async function warpActive() { activePage.value.corners, activePage.value.aspect, activePage.value.scanIntensity, + activePage.value.scanMode, ); revokeUrl(activePage.value.warpedUrl); activePage.value.warpedUrl = result.url; @@ -511,6 +531,7 @@ async function warpAll() { page.corners, page.aspect, page.scanIntensity, + page.scanMode, ); revokeUrl(page.warpedUrl); page.warpedUrl = result.url; @@ -536,6 +557,7 @@ async function runOcrOnPage(page: ScannerPage) { page.corners, page.aspect, page.scanIntensity, + page.scanMode, ); revokeUrl(page.warpedUrl); page.warpedUrl = result.url; @@ -605,6 +627,7 @@ async function exportPdf() { page.corners, page.aspect, page.scanIntensity, + page.scanMode, ); revokeUrl(page.warpedUrl); page.warpedUrl = warped.url; @@ -645,7 +668,9 @@ async function exportActivePng() { if (!activePage.value.warpedUrl) await warpActive(); if (!activePage.value?.warpedUrl) return; const response = await fetch(activePage.value.warpedUrl); - downloadBlob(await response.blob(), `${baseName(activePage.value.name)}.png`); + const blob = await response.blob(); + const ext = blob.type === 'image/jpeg' ? 'jpg' : 'png'; + downloadBlob(blob, `${baseName(activePage.value.name)}.${ext}`); } function baseName(name = filename.value) { @@ -954,7 +979,7 @@ const progressPercent = computed(() => :disabled="busy" @click="void exportActivePng()" > - PNG로 저장 + 이미지로 저장