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
+353 -112

No files matched your search

@@ -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,
@@ -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;
@@ -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<Blob>((resolve, reject) => {
canvas.toBlob(
(value) =>
value
? resolve(value)
: reject(new Error('보정 이미지를 만들지 못했습니다.')),
'image/png',
type,
0.92,
);
});
return {
@@ -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로 저장
이미지로 저장
</button>
<button
type="button"
@@ -984,6 +1009,22 @@ const progressPercent = computed(() =>
<option value="auto">자동 비율</option>
</select>
</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">
<span class="settings-field__row">
스캔 강도
@@ -1052,6 +1093,22 @@ const progressPercent = computed(() =>
<option value="auto">자동 비율</option>
</select>
</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">
<span class="settings-field__row">
스캔 강도
@@ -1073,7 +1130,7 @@ const progressPercent = computed(() =>
@change="void commitScanIntensity()"
/>
<span class="settings-field__hint">
0 원본 · 중간 대비 · 100 문서형
0 은은하게 · 100 글자 진하고 선명하게
</span>
</label>
<label class="settings-check">
@@ -1105,7 +1162,7 @@ const progressPercent = computed(() =>
void exportActivePng();
"
>
PNG로 저장
이미지로 저장
</button>
</div>
</div>