Make scanner output look like a real scan.
office.sejong.dev/pipeline/head This commit looks good
ysj7705/sejong-office/pipeline/head This commit looks good

- 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 <noreply@anthropic.com>
This commit is contained in:
ysj7705andClaude Opus 5.5 committed 2026-10-04 11:55:12 +09:00
1 parent c4ae7495dd
commit 2cb0c75092
4 files changed
+348 -107

No files matched your search

@@ -21,6 +21,14 @@ async function loadCjkFont(pdf: PDFDocument) {
return pdf.embedFont(bytes, { subset: true }); 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[]) { export async function buildSearchablePdf(pages: PdfPageInput[]) {
if (!pages.length) { if (!pages.length) {
throw new Error('PDF로 내보낼 페이지가 없습니다.'); throw new Error('PDF로 내보낼 페이지가 없습니다.');
@@ -42,13 +50,18 @@ export async function buildSearchablePdf(pages: PdfPageInput[]) {
} }
for (const page of pages) { for (const page of pages) {
const image = await pdf.embedPng(page.imageBytes); const image = isJpeg(page.imageBytes)
const pdfPage = pdf.addPage([page.width, page.height]); ? 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, { pdfPage.drawImage(image, {
x: 0, x: 0,
y: 0, y: 0,
width: page.width, width: pageWidth,
height: page.height, height: pageHeight,
}); });
const font = cjkFont ?? latinFont; const font = cjkFont ?? latinFont;
@@ -57,11 +70,12 @@ export async function buildSearchablePdf(pages: PdfPageInput[]) {
const text = word.text.trim(); const text = word.text.trim();
if (!text) continue; if (!text) continue;
if (!cjkFont && /[^\u0000-\u007f]/.test(text)) continue; if (!cjkFont && /[^\u0000-\u007f]/.test(text)) continue;
const boxWidth = Math.max(1, word.bbox.x1 - word.bbox.x0); // OCR 좌표는 스캔 이미지 픽셀 기준 → pt로 환산
const boxHeight = Math.max(1, word.bbox.y1 - word.bbox.y0); const boxWidth = Math.max(1, (word.bbox.x1 - word.bbox.x0) * k);
const fontSize = Math.max(4, Math.min(boxHeight * 0.9, boxWidth)); const boxHeight = Math.max(1, (word.bbox.y1 - word.bbox.y0) * k);
const x = word.bbox.x0; const fontSize = Math.max(2, Math.min(boxHeight * 0.9, boxWidth));
const y = page.height - word.bbox.y1; const x = word.bbox.x0 * k;
const y = pageHeight - word.bbox.y1 * k;
try { try {
pdfPage.drawText(text, { pdfPage.drawText(text, {
x, x,
@@ -12,6 +12,9 @@ export interface CornerPoints {
export type PaperAspect = 'a4-portrait' | 'a4-landscape' | 'square' | 'auto'; export type PaperAspect = 'a4-portrait' | 'a4-landscape' | 'square' | 'auto';
/** 원본: 원근 보정만 · 컬러/회색조 문서: 조명 평탄화·글자 보정 · 흑백 문서: 이진화 */
export type ScanMode = 'original' | 'color' | 'gray' | 'bw';
export interface OcrWord { export interface OcrWord {
text: string; text: string;
/** Pixel bbox on the warped image: x0,y0,x1,y1 */ /** Pixel bbox on the warped image: x0,y0,x1,y1 */
@@ -32,7 +35,8 @@ export interface ScannerPage {
sourceHeight: number; sourceHeight: number;
corners: CornerPoints; corners: CornerPoints;
aspect: PaperAspect; aspect: PaperAspect;
/** 0 = 원본 색감, 100 = 강한 문서 스캔 보정 */ scanMode: ScanMode;
/** 글자 진하기·선명도 0–100 (흑백은 이진화 문턱) */
scanIntensity: number; scanIntensity: number;
warpedUrl: string | null; warpedUrl: string | null;
warpedWidth: number; warpedWidth: number;
@@ -1,103 +1,251 @@
import { import { ensureOpenCv, loadImageFromUrl } from './detect';
createScanner, import type { CornerPoints, PaperAspect, ScanMode } from './types';
ensureJscanify,
loadImageFromUrl,
} from './detect';
import type { CornerPoints, PaperAspect } 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) { const A_RATIO = Math.SQRT2; // A 계열 용지 (A4 등)
return Math.min(255, Math.max(0, Math.round(value))); 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; type V3 = [number, number, number];
if (amount <= 0.001) return canvas; const u0 = imageWidth / 2;
const v0 = imageHeight / 2;
const ctx = canvas.getContext('2d', { willReadFrequently: true }); const p = (pt: { x: number; y: number }): V3 => [pt.x - u0, pt.y - v0, 1];
if (!ctx) return canvas; const cross = (a: V3, b: V3): V3 => [
a[1] * b[2] - a[2] * b[1],
const image = ctx.getImageData(0, 0, canvas.width, canvas.height); a[2] * b[0] - a[0] * b[2],
const { data } = image; a[0] * b[1] - a[1] * b[0],
const contrast = 1 + amount * 1.45; ];
const grayMix = amount * 0.9; const dot = (a: V3, b: V3) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
const whitePush = amount * 0.42; const m1 = p(c.topLeftCorner);
const inkMix = Math.max(0, (amount - 0.55) / 0.45); const m2 = p(c.topRightCorner);
const m3 = p(c.bottomLeftCorner);
for (let i = 0; i < data.length; i += 4) { const m4 = p(c.bottomRightCorner);
let r = data[i]!; const c14 = cross(m1, m4);
let g = data[i + 1]!; const k2 = dot(c14, m3) / dot(cross(m2, m4), m3);
let b = data[i + 2]!; const k3 = dot(c14, m2) / dot(cross(m3, m4), m2);
const y = 0.299 * r + 0.587 * g + 0.114 * b; const n2 = m2.map((v, i) => k2 * v - m1[i]!) as V3;
r = r * (1 - grayMix) + y * grayMix; const n3 = m3.map((v, i) => k3 * v - m1[i]!) as V3;
g = g * (1 - grayMix) + y * grayMix; const denom = n2[2] * n3[2];
b = b * (1 - grayMix) + y * grayMix; if (!Number.isFinite(k2) || !Number.isFinite(k3) || Math.abs(denom) < 1e-9) {
return edgeAspect(c);
r = (r - 128) * contrast + 128; }
g = (g - 128) * contrast + 128; const f2 = -(n2[0] * n3[0] + n2[1] * n3[1]) / denom;
b = (b - 128) * contrast + 128; const maxDim = Math.max(imageWidth, imageHeight);
if (!(f2 > 0) || Math.sqrt(f2) < maxDim * 0.3 || Math.sqrt(f2) > maxDim * 6) {
if (whitePush > 0) { return edgeAspect(c);
const lift = (v: number) => }
v > 155 ? v + (255 - v) * whitePush * ((v - 155) / 100) : v; const ratio = Math.sqrt(
r = lift(r); (n2[0] ** 2 + n2[1] ** 2 + n2[2] ** 2 * f2) /
g = lift(g); (n3[0] ** 2 + n3[1] ** 2 + n3[2] ** 2 * f2),
b = lift(b); );
return Number.isFinite(ratio) && ratio > 0.15 && ratio < 7
? ratio
: edgeAspect(c);
} }
if (inkMix > 0) { /** 흔한 용지 비율(A 계열, Letter)과 4% 안쪽이면 그 비율로 맞춘다 */
const y2 = 0.299 * r + 0.587 * g + 0.114 * b; function snapAspect(ratio: number) {
const ink = Math.max(0, Math.min(1, (175 - y2) / 85)); for (const target of [A_RATIO, LETTER_RATIO]) {
const doc = 255 * (1 - ink) + 18 * ink; for (const t of [target, 1 / target]) {
r = r * (1 - inkMix) + doc * inkMix; if (Math.abs(ratio / t - 1) < 0.04) return t;
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);
} }
return ratio;
ctx.putImageData(image, 0, 0);
return canvas;
} }
/** 결과 크기: 비율은 용지 설정(자동이면 원근 추정), 긴 변은 원본에서 문서가 차지한 픽셀 수 기준
* (1754–3508px = A4 150–300dpi, 원본보다 크게 늘리지 않음) */
export function outputSizeForAspect( export function outputSizeForAspect(
aspect: PaperAspect, aspect: PaperAspect,
corners: CornerPoints, corners: CornerPoints,
imageWidth: number,
imageHeight: number,
) { ) {
if (aspect === 'a4-portrait') return { width: 1240, height: 1754 }; let ratio: number;
if (aspect === 'a4-landscape') return { width: 1754, height: 1240 }; if (aspect === 'a4-portrait') ratio = 1 / A_RATIO;
if (aspect === 'square') return { width: 1400, height: 1400 }; 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( /** 원근 보정 (RGB → RGB). 가장자리는 늘려 채워 검은 테두리가 생기지 않게 한다 */
corners.topRightCorner.x - corners.topLeftCorner.x, function warpMat(
corners.topRightCorner.y - corners.topLeftCorner.y, 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( from.delete();
corners.bottomRightCorner.x - corners.bottomLeftCorner.x, to.delete();
corners.bottomRightCorner.y - corners.bottomLeftCorner.y, 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( const kernel = cv.getStructuringElement(cv.MORPH_ELLIPSE, new cv.Size(9, 9));
corners.bottomLeftCorner.x - corners.topLeftCorner.x, cv.dilate(small, small, kernel);
corners.bottomLeftCorner.y - corners.topLeftCorner.y, 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,
); );
const heightRight = Math.hypot( flat.delete();
corners.bottomRightCorner.x - corners.topRightCorner.x, return out;
corners.bottomRightCorner.y - corners.topRightCorner.y, }
); levels(flat, 0.002 + 0.02 * a, 1 + 0.6 * a);
const width = Math.max(widthTop, widthBottom, 1); sharpen(cv, flat, 0.3 + 0.5 * a);
const height = Math.max(heightLeft, heightRight, 1); return flat;
const scale = Math.min(1800 / width, 1800 / height, 1); }
/** 원본을 캔버스에 그린다. 너무 크면 줄이고 그 배율을 돌려준다 */
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 { return {
width: Math.max(1, Math.round(width * scale)), topLeftCorner: f(c.topLeftCorner),
height: Math.max(1, Math.round(height * scale)), topRightCorner: f(c.topRightCorner),
bottomRightCorner: f(c.bottomRightCorner),
bottomLeftCorner: f(c.bottomLeftCorner),
}; };
} }
@@ -106,28 +254,46 @@ export async function warpImage(
corners: CornerPoints, corners: CornerPoints,
aspect: PaperAspect, aspect: PaperAspect,
scanIntensity: number = DEFAULT_SCAN_INTENSITY, 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 image = await loadImageFromUrl(sourceUrl);
const size = outputSizeForAspect(aspect, corners); const { canvas: source, scale } = sourceCanvas(image);
const scanner = createScanner(); const c = scaleCorners(corners, scale);
const canvas = scanner.extractPaper( const size = outputSizeForAspect(aspect, c, source.width, source.height);
image,
size.width, const rgba = cv.imread(source);
size.height, const rgb = new cv.Mat();
corners, 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)}`,
); );
if (!canvas) { } finally {
throw new Error('문서 영역을 추출하지 못했습니다.'); 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<Blob>((resolve, reject) => { const blob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob( canvas.toBlob(
(value) => (value) =>
value value
? resolve(value) ? resolve(value)
: reject(new Error('보정 이미지를 만들지 못했습니다.')), : reject(new Error('보정 이미지를 만들지 못했습니다.')),
'image/png', type,
0.92,
); );
}); });
return { return {
@@ -29,9 +29,14 @@ import {
import type { import type {
CornerPoints, CornerPoints,
PaperAspect, PaperAspect,
ScanMode,
ScannerPage, ScannerPage,
} from '@/integrations/scanner/types'; } 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'; import { downloadBlob } from '@/utils/downloadBlob';
defineOptions({ name: 'ToolsScannerView' }); defineOptions({ name: 'ToolsScannerView' });
@@ -125,6 +130,7 @@ async function pushPageFromSource(
sourceHeight: h, sourceHeight: h,
corners, corners,
aspect: 'auto', aspect: 'auto',
scanMode: DEFAULT_SCAN_MODE,
scanIntensity: DEFAULT_SCAN_INTENSITY, scanIntensity: DEFAULT_SCAN_INTENSITY,
warpedUrl: null, warpedUrl: null,
warpedWidth: 0, 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() { async function commitScanIntensity() {
if (!activePage.value || busy.value) return; if (!activePage.value || busy.value) return;
await warpActive(); await warpActive();
@@ -482,6 +501,7 @@ async function warpActive() {
activePage.value.corners, activePage.value.corners,
activePage.value.aspect, activePage.value.aspect,
activePage.value.scanIntensity, activePage.value.scanIntensity,
activePage.value.scanMode,
); );
revokeUrl(activePage.value.warpedUrl); revokeUrl(activePage.value.warpedUrl);
activePage.value.warpedUrl = result.url; activePage.value.warpedUrl = result.url;
@@ -511,6 +531,7 @@ async function warpAll() {
page.corners, page.corners,
page.aspect, page.aspect,
page.scanIntensity, page.scanIntensity,
page.scanMode,
); );
revokeUrl(page.warpedUrl); revokeUrl(page.warpedUrl);
page.warpedUrl = result.url; page.warpedUrl = result.url;
@@ -536,6 +557,7 @@ async function runOcrOnPage(page: ScannerPage) {
page.corners, page.corners,
page.aspect, page.aspect,
page.scanIntensity, page.scanIntensity,
page.scanMode,
); );
revokeUrl(page.warpedUrl); revokeUrl(page.warpedUrl);
page.warpedUrl = result.url; page.warpedUrl = result.url;
@@ -605,6 +627,7 @@ async function exportPdf() {
page.corners, page.corners,
page.aspect, page.aspect,
page.scanIntensity, page.scanIntensity,
page.scanMode,
); );
revokeUrl(page.warpedUrl); revokeUrl(page.warpedUrl);
page.warpedUrl = warped.url; page.warpedUrl = warped.url;
@@ -645,7 +668,9 @@ async function exportActivePng() {
if (!activePage.value.warpedUrl) await warpActive(); if (!activePage.value.warpedUrl) await warpActive();
if (!activePage.value?.warpedUrl) return; if (!activePage.value?.warpedUrl) return;
const response = await fetch(activePage.value.warpedUrl); 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) { function baseName(name = filename.value) {
@@ -954,7 +979,7 @@ const progressPercent = computed(() =>
:disabled="busy" :disabled="busy"
@click="void exportActivePng()" @click="void exportActivePng()"
> >
PNG로 저장 이미지로 저장
</button> </button>
<button <button
type="button" type="button"
@@ -984,6 +1009,22 @@ const progressPercent = computed(() =>
<option value="auto">자동 비율</option> <option value="auto">자동 비율</option>
</select> </select>
</label> </label>
<label class="settings-field settings-field--inline">
<span>필터</span>
<select
:value="activePage.scanMode"
:disabled="busy"
@change="
void updateScanMode(
($event.target as HTMLSelectElement).value as ScanMode,
)
"
>
<option v-for="m in SCAN_MODES" :key="m.value" :value="m.value">
{{ m.label }}
</option>
</select>
</label>
<label class="settings-field settings-field--intensity"> <label class="settings-field settings-field--intensity">
<span class="settings-field__row"> <span class="settings-field__row">
스캔 강도 스캔 강도
@@ -1052,6 +1093,22 @@ const progressPercent = computed(() =>
<option value="auto">자동 비율</option> <option value="auto">자동 비율</option>
</select> </select>
</label> </label>
<label class="settings-field">
<span>필터</span>
<select
:value="activePage.scanMode"
:disabled="busy"
@change="
void updateScanMode(
($event.target as HTMLSelectElement).value as ScanMode,
)
"
>
<option v-for="m in SCAN_MODES" :key="m.value" :value="m.value">
{{ m.label }}
</option>
</select>
</label>
<label class="settings-field"> <label class="settings-field">
<span class="settings-field__row"> <span class="settings-field__row">
스캔 강도 스캔 강도
@@ -1073,7 +1130,7 @@ const progressPercent = computed(() =>
@change="void commitScanIntensity()" @change="void commitScanIntensity()"
/> />
<span class="settings-field__hint"> <span class="settings-field__hint">
0 원본 · 중간 대비 · 100 문서형 0 은은하게 · 100 글자 진하고 선명하게
</span> </span>
</label> </label>
<label class="settings-check"> <label class="settings-check">
@@ -1105,7 +1162,7 @@ const progressPercent = computed(() =>
void exportActivePng(); void exportActivePng();
" "
> >
PNG로 저장 이미지로 저장
</button> </button>
</div> </div>
</div> </div>