ocr 및 스캐너 기능 구현
This commit is contained in:
1 parent
1dbc73312b
commit
2c64369694
24 files changed
+3556
-8
No files matched your search
@@ -2,6 +2,8 @@ node_modules
|
|||||||
**/node_modules
|
**/node_modules
|
||||||
**/dist
|
**/dist
|
||||||
packages/backend/public
|
packages/backend/public
|
||||||
|
packages/frontend/public/assets
|
||||||
|
packages/frontend/public/viewer-assets
|
||||||
.git
|
.git
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ packages/frontend/public/assets/dxf-parser-worker.js
|
|||||||
packages/frontend/public/assets/libredwg-parser-worker.js
|
packages/frontend/public/assets/libredwg-parser-worker.js
|
||||||
packages/frontend/public/assets/mtext-renderer-worker.js
|
packages/frontend/public/assets/mtext-renderer-worker.js
|
||||||
packages/frontend/public/viewer-assets/
|
packages/frontend/public/viewer-assets/
|
||||||
|
packages/frontend/.cache/
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
Generated
+1432
-4
File diff suppressed because it is too large.
Load diff
@@ -40,12 +40,23 @@ async function main() {
|
|||||||
app.use('/api/documents', documentsRouter);
|
app.use('/api/documents', documentsRouter);
|
||||||
|
|
||||||
if (fs.existsSync(config.publicDir)) {
|
if (fs.existsSync(config.publicDir)) {
|
||||||
app.use(express.static(config.publicDir));
|
app.use(express.static(config.publicDir, {
|
||||||
|
setHeaders(res, filePath) {
|
||||||
|
if (filePath.endsWith('.wasm')) {
|
||||||
|
res.setHeader('Content-Type', 'application/wasm');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
app.get('*', (req, res, next) => {
|
app.get('*', (req, res, next) => {
|
||||||
if (req.path.startsWith('/api')) {
|
if (req.path.startsWith('/api')) {
|
||||||
next();
|
next();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Do not SPA-fallback missing static assets (wasm, workers, traineddata).
|
||||||
|
if (/\.[a-z0-9]+$/i.test(req.path)) {
|
||||||
|
res.status(404).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
res.sendFile(path.join(config.publicDir, 'index.html'));
|
res.sendFile(path.join(config.publicDir, 'index.html'));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,15 +23,21 @@
|
|||||||
"@mlightcad/cad-viewer": "^1.5.8",
|
"@mlightcad/cad-viewer": "^1.5.8",
|
||||||
"@mlightcad/data-model": "^1.11.2",
|
"@mlightcad/data-model": "^1.11.2",
|
||||||
"@mlightcad/dxf-json-converter": "^1.11.2",
|
"@mlightcad/dxf-json-converter": "^1.11.2",
|
||||||
|
"@pdf-lib/fontkit": "^1.1.1",
|
||||||
"@rhwp/editor": "^0.7.18",
|
"@rhwp/editor": "^0.7.18",
|
||||||
"@vueuse/core": "^11.3.0",
|
"@vueuse/core": "^11.3.0",
|
||||||
"draco3d": "^1.5.7",
|
"draco3d": "^1.5.7",
|
||||||
"element-plus": "^2.14.3",
|
"element-plus": "^2.14.3",
|
||||||
"fflate": "^0.8.2",
|
"fflate": "^0.8.2",
|
||||||
|
"jscanify": "^1.4.3",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"mermaid": "^11.16.0",
|
"mermaid": "^11.16.0",
|
||||||
"occt-import-js": "^0.0.22",
|
"occt-import-js": "^0.0.22",
|
||||||
"online-3d-viewer": "^0.18.0",
|
"online-3d-viewer": "^0.18.0",
|
||||||
|
"onnxruntime-web": "^1.27.0",
|
||||||
|
"pdf-lib": "^1.17.1",
|
||||||
|
"pdfjs-dist": "^6.1.200",
|
||||||
|
"ppu-paddle-ocr": "^6.1.2",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"rhino3dm": "^8.17.0",
|
"rhino3dm": "^8.17.0",
|
||||||
@@ -43,6 +49,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@fontsource-variable/noto-sans-kr": "^5.3.0",
|
"@fontsource-variable/noto-sans-kr": "^5.3.0",
|
||||||
|
"@fontsource/noto-sans-kr": "^5.3.0",
|
||||||
"@noto-pdf-ts/fonts-kr": "^0.0.1-alpha.0",
|
"@noto-pdf-ts/fonts-kr": "^0.0.1-alpha.0",
|
||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
|||||||
@@ -193,6 +193,149 @@ await writeFile(
|
|||||||
].join('\n'),
|
].join('\n'),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const scannerAssets = [
|
||||||
|
[resolvePackage('jscanify/src/opencv.js'), 'viewer-assets/scanner/opencv.js'],
|
||||||
|
[resolvePackage('jscanify/src/jscanify.js'), 'viewer-assets/scanner/jscanify.js'],
|
||||||
|
[resolvePackage('jscanify/LICENSE'), 'viewer-assets/scanner/licenses/jscanify.LICENSE'],
|
||||||
|
[
|
||||||
|
resolvePackage('ppu-paddle-ocr/LICENSE'),
|
||||||
|
'viewer-assets/scanner/licenses/ppu-paddle-ocr.LICENSE',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
resolvePackage('pdfjs-dist/build/pdf.worker.min.mjs'),
|
||||||
|
'viewer-assets/scanner/pdfjs/pdf.worker.min.mjs',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
resolvePackage('pdfjs-dist/LICENSE'),
|
||||||
|
'viewer-assets/scanner/licenses/pdfjs-dist.LICENSE',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
scannerAssets.map(([sources, destination]) =>
|
||||||
|
copyFirst(sources, destination),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
{
|
||||||
|
const { access, readdir, rm } = await import('node:fs/promises');
|
||||||
|
const legacyTesseract = path.join(publicDir, 'viewer-assets/scanner/tesseract');
|
||||||
|
try {
|
||||||
|
await rm(legacyTesseract, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
const ortDest = path.join(publicDir, 'viewer-assets/scanner/ort');
|
||||||
|
await mkdir(ortDest, { recursive: true });
|
||||||
|
const ortNames = [
|
||||||
|
'ort-wasm-simd-threaded.wasm',
|
||||||
|
'ort-wasm-simd-threaded.mjs',
|
||||||
|
'ort-wasm-simd-threaded.jsep.wasm',
|
||||||
|
'ort-wasm-simd-threaded.jsep.mjs',
|
||||||
|
];
|
||||||
|
let ortCopied = false;
|
||||||
|
for (const candidate of resolvePackage('onnxruntime-web/dist')) {
|
||||||
|
try {
|
||||||
|
const entries = await readdir(candidate);
|
||||||
|
const wanted = ortNames.filter((name) => entries.includes(name));
|
||||||
|
if (wanted.length < ortNames.length) continue;
|
||||||
|
await Promise.all(
|
||||||
|
wanted.map((name) =>
|
||||||
|
cp(path.join(candidate, name), path.join(ortDest, name)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
ortCopied = true;
|
||||||
|
break;
|
||||||
|
} catch {
|
||||||
|
// try next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!ortCopied) {
|
||||||
|
throw new Error('Missing onnxruntime-web WASM assets');
|
||||||
|
}
|
||||||
|
|
||||||
|
const paddleDest = path.join(publicDir, 'viewer-assets/scanner/paddle');
|
||||||
|
await mkdir(paddleDest, { recursive: true });
|
||||||
|
const modelCacheDir = path.join(frontendDir, '.cache', 'paddle-ocr-models');
|
||||||
|
await mkdir(modelCacheDir, { recursive: true });
|
||||||
|
|
||||||
|
const MODEL_BASE =
|
||||||
|
'https://media.githubusercontent.com/media/PT-Perkasa-Pilar-Utama/ppu-paddle-ocr-models/main';
|
||||||
|
const DICT_BASE =
|
||||||
|
'https://raw.githubusercontent.com/PT-Perkasa-Pilar-Utama/ppu-paddle-ocr-models/main';
|
||||||
|
const paddleFiles = [
|
||||||
|
{
|
||||||
|
url: `${MODEL_BASE}/detection/PP-OCRv5_mobile_det_infer.onnx`,
|
||||||
|
name: 'PP-OCRv5_mobile_det_infer.onnx',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: `${MODEL_BASE}/recognition/multi/korean/v5/korean_PP-OCRv5_mobile_rec_infer.onnx`,
|
||||||
|
name: 'korean_PP-OCRv5_mobile_rec_infer.onnx',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: `${DICT_BASE}/recognition/multi/korean/v5/ppocrv5_korean_dict.txt`,
|
||||||
|
name: 'ppocrv5_korean_dict.txt',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
async function downloadToCache(url, name) {
|
||||||
|
const cached = path.join(modelCacheDir, name);
|
||||||
|
try {
|
||||||
|
await access(cached);
|
||||||
|
return cached;
|
||||||
|
} catch {
|
||||||
|
// download
|
||||||
|
}
|
||||||
|
console.log(`downloading paddle model: ${name}`);
|
||||||
|
let lastError;
|
||||||
|
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status} for ${url}`);
|
||||||
|
}
|
||||||
|
const buffer = Buffer.from(await response.arrayBuffer());
|
||||||
|
if (buffer.byteLength < 64) {
|
||||||
|
throw new Error(`Unexpectedly small download for ${name}`);
|
||||||
|
}
|
||||||
|
await writeFile(cached, buffer);
|
||||||
|
return cached;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
console.warn(`retry ${attempt}/3 failed for ${name}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError ?? new Error(`Failed to download ${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of paddleFiles) {
|
||||||
|
const cachedPath = await downloadToCache(file.url, file.name);
|
||||||
|
await cp(cachedPath, path.join(paddleDest, file.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeFile(
|
||||||
|
path.join(publicDir, 'viewer-assets/scanner/licenses/paddle-ocr-models.NOTICE.txt'),
|
||||||
|
[
|
||||||
|
'PP-OCRv5 ONNX models are sourced from:',
|
||||||
|
'https://github.com/PT-Perkasa-Pilar-Utama/ppu-paddle-ocr-models',
|
||||||
|
'upstream: https://www.paddleocr.ai/ (Apache-2.0)',
|
||||||
|
'',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeFile(
|
||||||
|
path.join(publicDir, 'viewer-assets/scanner/README.txt'),
|
||||||
|
[
|
||||||
|
'Self-hosted document scanner libraries (OpenCV.js, jscanify, ppu-paddle-ocr, pdf.js).',
|
||||||
|
'OCR: PP-OCRv5 Korean mobile (det + rec + dict) under paddle/.',
|
||||||
|
'ONNX Runtime Web WASM under ort/.',
|
||||||
|
'PDF.js worker under pdfjs/.',
|
||||||
|
'',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
|
||||||
const O3DV_BASE = '/viewer-assets/o3dv/';
|
const O3DV_BASE = '/viewer-assets/o3dv/';
|
||||||
const replacements = [
|
const replacements = [
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ const viewKey = computed(() => {
|
|||||||
route.name === 'mermaid-editor' ||
|
route.name === 'mermaid-editor' ||
|
||||||
route.name === 'drawio-editor' ||
|
route.name === 'drawio-editor' ||
|
||||||
route.name === 'cad-viewer' ||
|
route.name === 'cad-viewer' ||
|
||||||
route.name === 'model3d-viewer'
|
route.name === 'model3d-viewer' ||
|
||||||
|
route.name === 'tools-scanner'
|
||||||
) {
|
) {
|
||||||
return String(route.name);
|
return String(route.name);
|
||||||
}
|
}
|
||||||
@@ -39,6 +40,7 @@ const viewKey = computed(() => {
|
|||||||
'DrawioEditorView',
|
'DrawioEditorView',
|
||||||
'CadViewerView',
|
'CadViewerView',
|
||||||
'Model3DViewerView',
|
'Model3DViewerView',
|
||||||
|
'ToolsScannerView',
|
||||||
]"
|
]"
|
||||||
:max="10"
|
:max="10"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
|
import type { CornerPoints, Point2D } from '@/integrations/scanner/types';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
imageUrl: string;
|
||||||
|
corners: CornerPoints;
|
||||||
|
disabled?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:corners': [value: CornerPoints];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const host = ref<HTMLElement | null>(null);
|
||||||
|
const imageEl = ref<HTMLImageElement | null>(null);
|
||||||
|
const display = ref({ width: 0, height: 0, naturalWidth: 1, naturalHeight: 1 });
|
||||||
|
let dragging: keyof CornerPoints | null = null;
|
||||||
|
|
||||||
|
const scaleX = computed(
|
||||||
|
() => display.value.width / Math.max(1, display.value.naturalWidth),
|
||||||
|
);
|
||||||
|
const scaleY = computed(
|
||||||
|
() => display.value.height / Math.max(1, display.value.naturalHeight),
|
||||||
|
);
|
||||||
|
|
||||||
|
const handles = computed(() =>
|
||||||
|
(Object.keys(props.corners) as (keyof CornerPoints)[]).map((key) => {
|
||||||
|
const point = props.corners[key];
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
left: point.x * scaleX.value,
|
||||||
|
top: point.y * scaleY.value,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const polygonPoints = computed(() => {
|
||||||
|
const order: (keyof CornerPoints)[] = [
|
||||||
|
'topLeftCorner',
|
||||||
|
'topRightCorner',
|
||||||
|
'bottomRightCorner',
|
||||||
|
'bottomLeftCorner',
|
||||||
|
];
|
||||||
|
return order
|
||||||
|
.map((key) => {
|
||||||
|
const point = props.corners[key];
|
||||||
|
return `${point.x * scaleX.value},${point.y * scaleY.value}`;
|
||||||
|
})
|
||||||
|
.join(' ');
|
||||||
|
});
|
||||||
|
|
||||||
|
function measure() {
|
||||||
|
if (!host.value || !imageEl.value) return;
|
||||||
|
const rect = imageEl.value.getBoundingClientRect();
|
||||||
|
display.value = {
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height,
|
||||||
|
naturalWidth: imageEl.value.naturalWidth || 1,
|
||||||
|
naturalHeight: imageEl.value.naturalHeight || 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(point: Point2D): Point2D {
|
||||||
|
return {
|
||||||
|
x: Math.min(display.value.naturalWidth, Math.max(0, point.x)),
|
||||||
|
y: Math.min(display.value.naturalHeight, Math.max(0, point.y)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerDown(key: keyof CornerPoints, event: PointerEvent) {
|
||||||
|
if (props.disabled) return;
|
||||||
|
dragging = key;
|
||||||
|
(event.target as HTMLElement).setPointerCapture?.(event.pointerId);
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerMove(event: PointerEvent) {
|
||||||
|
if (!dragging || !imageEl.value) return;
|
||||||
|
const rect = imageEl.value.getBoundingClientRect();
|
||||||
|
const nextPoint = clamp({
|
||||||
|
x: (event.clientX - rect.left) / scaleX.value,
|
||||||
|
y: (event.clientY - rect.top) / scaleY.value,
|
||||||
|
});
|
||||||
|
emit('update:corners', {
|
||||||
|
...props.corners,
|
||||||
|
[dragging]: nextPoint,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerUp() {
|
||||||
|
dragging = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
measure();
|
||||||
|
window.addEventListener('pointermove', onPointerMove);
|
||||||
|
window.addEventListener('pointerup', onPointerUp);
|
||||||
|
window.addEventListener('resize', measure);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('pointermove', onPointerMove);
|
||||||
|
window.removeEventListener('pointerup', onPointerUp);
|
||||||
|
window.removeEventListener('resize', measure);
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.imageUrl,
|
||||||
|
async () => {
|
||||||
|
await new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
|
||||||
|
measure();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="host" class="corner-editor">
|
||||||
|
<img
|
||||||
|
ref="imageEl"
|
||||||
|
class="corner-editor__image"
|
||||||
|
:src="imageUrl"
|
||||||
|
alt="스캔 원본"
|
||||||
|
draggable="false"
|
||||||
|
@load="measure"
|
||||||
|
/>
|
||||||
|
<svg class="corner-editor__overlay" aria-hidden="true">
|
||||||
|
<polygon :points="polygonPoints" />
|
||||||
|
</svg>
|
||||||
|
<button
|
||||||
|
v-for="handle in handles"
|
||||||
|
:key="handle.key"
|
||||||
|
type="button"
|
||||||
|
class="corner-editor__handle"
|
||||||
|
:style="{ left: `${handle.left}px`, top: `${handle.top}px` }"
|
||||||
|
:disabled="disabled"
|
||||||
|
:aria-label="handle.key"
|
||||||
|
@pointerdown="onPointerDown(handle.key, $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.corner-editor {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
touch-action: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.corner-editor__image {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: min(62vh, 720px);
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.corner-editor__overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.corner-editor__overlay polygon {
|
||||||
|
fill: color-mix(in srgb, var(--accent) 18%, transparent);
|
||||||
|
stroke: var(--accent);
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.corner-editor__handle {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 2;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
margin: -9px 0 0 -9px;
|
||||||
|
border: 2px solid white;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
box-shadow: 0 0 0 1px color-mix(in srgb, black 25%, transparent);
|
||||||
|
cursor: grab;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.corner-editor__handle:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.corner-editor__handle:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
let pending: File[] = [];
|
||||||
|
|
||||||
|
export function stageScannerFiles(files: readonly File[]) {
|
||||||
|
pending = [...files];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function consumeScannerFiles(): File[] {
|
||||||
|
const files = pending;
|
||||||
|
pending = [];
|
||||||
|
return files;
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
diagramKindFor,
|
diagramKindFor,
|
||||||
isHwp,
|
isHwp,
|
||||||
isOffice,
|
isOffice,
|
||||||
|
toolKindFor,
|
||||||
viewerKindFor,
|
viewerKindFor,
|
||||||
} from '@/config/app';
|
} from '@/config/app';
|
||||||
import {
|
import {
|
||||||
@@ -18,6 +19,7 @@ import {
|
|||||||
storeHwpFile,
|
storeHwpFile,
|
||||||
} from '@/composables/useSessionLifecycle';
|
} from '@/composables/useSessionLifecycle';
|
||||||
import { stageDiagramFile } from '@/composables/diagramSession';
|
import { stageDiagramFile } from '@/composables/diagramSession';
|
||||||
|
import { stageScannerFiles } from '@/composables/scannerSession';
|
||||||
import { stageViewerFiles } from '@/composables/viewerSession';
|
import { stageViewerFiles } from '@/composables/viewerSession';
|
||||||
|
|
||||||
export function useOpenDocument() {
|
export function useOpenDocument() {
|
||||||
@@ -37,6 +39,7 @@ export function useOpenDocument() {
|
|||||||
files.find(
|
files.find(
|
||||||
(candidate) =>
|
(candidate) =>
|
||||||
viewerKindFor(candidate.name) ||
|
viewerKindFor(candidate.name) ||
|
||||||
|
toolKindFor(candidate.name) ||
|
||||||
diagramKindFor(candidate.name) ||
|
diagramKindFor(candidate.name) ||
|
||||||
isHwp(candidate.name) ||
|
isHwp(candidate.name) ||
|
||||||
isOffice(candidate.name),
|
isOffice(candidate.name),
|
||||||
@@ -53,6 +56,14 @@ export function useOpenDocument() {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toolKind = toolKindFor(file.name);
|
||||||
|
if (toolKind === 'scanner') {
|
||||||
|
const images = files.filter((candidate) => toolKindFor(candidate.name) === 'scanner');
|
||||||
|
stageScannerFiles(images.length ? images : [file]);
|
||||||
|
await router.push({ name: 'tools-scanner' });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
const diagramKind = diagramKindFor(file.name);
|
const diagramKind = diagramKindFor(file.name);
|
||||||
if (diagramKind) {
|
if (diagramKind) {
|
||||||
stageDiagramFile(diagramKind, file);
|
stageDiagramFile(diagramKind, file);
|
||||||
@@ -69,7 +80,7 @@ export function useOpenDocument() {
|
|||||||
|
|
||||||
if (!isOffice(file.name)) {
|
if (!isOffice(file.name)) {
|
||||||
error.value =
|
error.value =
|
||||||
'지원하지 않는 형식입니다. 오피스, HWP 또는 다이어그램 파일을 선택해 주세요.';
|
'지원하지 않는 형식입니다. 오피스, HWP, 다이어그램, CAD/3D 또는 이미지를 선택해 주세요.';
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export const APP_DISPLAY_NAME = "Sejong Office";
|
|||||||
|
|
||||||
export type DiagramKind = "excalidraw" | "mermaid" | "drawio";
|
export type DiagramKind = "excalidraw" | "mermaid" | "drawio";
|
||||||
export type ViewerKind = "cad" | "model3d";
|
export type ViewerKind = "cad" | "model3d";
|
||||||
|
export type ToolKind = "scanner";
|
||||||
|
|
||||||
const DIAGRAM_EXTENSION_KIND: Record<string, DiagramKind> = {
|
const DIAGRAM_EXTENSION_KIND: Record<string, DiagramKind> = {
|
||||||
excalidraw: "excalidraw",
|
excalidraw: "excalidraw",
|
||||||
@@ -16,6 +17,25 @@ const DIAGRAM_EXTENSION_KIND: Record<string, DiagramKind> = {
|
|||||||
xml: "drawio",
|
xml: "drawio",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Image files opened in the document scanner tool. */
|
||||||
|
export const SCANNER_EXTENSIONS = new Set([
|
||||||
|
"png",
|
||||||
|
"jpg",
|
||||||
|
"jpeg",
|
||||||
|
"webp",
|
||||||
|
"gif",
|
||||||
|
"bmp",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const TOOL_EXTENSION_KIND: Record<string, ToolKind> = {
|
||||||
|
png: "scanner",
|
||||||
|
jpg: "scanner",
|
||||||
|
jpeg: "scanner",
|
||||||
|
webp: "scanner",
|
||||||
|
gif: "scanner",
|
||||||
|
bmp: "scanner",
|
||||||
|
};
|
||||||
|
|
||||||
/** Online3DViewer import formats (+ zip container). */
|
/** Online3DViewer import formats (+ zip container). */
|
||||||
export const MODEL3D_EXTENSIONS = [
|
export const MODEL3D_EXTENSIONS = [
|
||||||
"3dm",
|
"3dm",
|
||||||
@@ -95,3 +115,11 @@ export function diagramKindFor(filename: string): DiagramKind | null {
|
|||||||
export function viewerKindFor(filename: string): ViewerKind | null {
|
export function viewerKindFor(filename: string): ViewerKind | null {
|
||||||
return VIEWER_EXTENSION_KIND[extensionOf(filename)] ?? null;
|
return VIEWER_EXTENSION_KIND[extensionOf(filename)] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function toolKindFor(filename: string): ToolKind | null {
|
||||||
|
return TOOL_EXTENSION_KIND[extensionOf(filename)] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isScannerImage(filename: string): boolean {
|
||||||
|
return SCANNER_EXTENSIONS.has(extensionOf(filename));
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ export type FaviconKind =
|
|||||||
| 'diagram'
|
| 'diagram'
|
||||||
| 'cad'
|
| 'cad'
|
||||||
| 'model3d'
|
| 'model3d'
|
||||||
|
| 'scanner'
|
||||||
| 'text'
|
| 'text'
|
||||||
| 'csv'
|
| 'csv'
|
||||||
| 'file';
|
| 'file';
|
||||||
@@ -35,6 +36,7 @@ export const ICON_PATHS: Record<FaviconKind, string> = {
|
|||||||
diagram: 'M4 3H10V9H8V11H16V9H14V3H20V9H18V13H13V15H16V21H8V15H11V13H6V9H4V3M6 5V7H8V5H6M16 5V7H18V5H16M10 17V19H14V17H10Z',
|
diagram: 'M4 3H10V9H8V11H16V9H14V3H20V9H18V13H13V15H16V21H8V15H11V13H6V9H4V3M6 5V7H8V5H6M16 5V7H18V5H16M10 17V19H14V17H10Z',
|
||||||
cad: 'M3 3H21V21H3V3M5 5V19H19V5H5M7 17V15H9V13H11V11H13V9H15V7H17V17H7Z',
|
cad: 'M3 3H21V21H3V3M5 5V19H19V5H5M7 17V15H9V13H11V11H13V9H15V7H17V17H7Z',
|
||||||
model3d: 'M12 2L21 7V17L12 22L3 17V7L12 2M12 4.3L5.4 8L12 11.7L18.6 8L12 4.3M5 9.7V15.8L11 19.1V13.1L5 9.7M13 19.1L19 15.8V9.7L13 13.1V19.1Z',
|
model3d: 'M12 2L21 7V17L12 22L3 17V7L12 2M12 4.3L5.4 8L12 11.7L18.6 8L12 4.3M5 9.7V15.8L11 19.1V13.1L5 9.7M13 19.1L19 15.8V9.7L13 13.1V19.1Z',
|
||||||
|
scanner: 'M4 4H10V6H6V10H4V4M14 4H20V10H18V6H14V4M4 14H6V18H10V20H4V14M18 14H20V20H14V18H18V14M8 8H16V16H8V8Z',
|
||||||
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',
|
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',
|
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',
|
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',
|
||||||
@@ -52,6 +54,7 @@ export const ICON_COLORS: Record<FaviconKind, string> = {
|
|||||||
diagram: '#2e7d8a',
|
diagram: '#2e7d8a',
|
||||||
cad: '#5c6bc0',
|
cad: '#5c6bc0',
|
||||||
model3d: '#6d4c41',
|
model3d: '#6d4c41',
|
||||||
|
scanner: '#00897b',
|
||||||
text: '#607d8b',
|
text: '#607d8b',
|
||||||
csv: '#00838f',
|
csv: '#00838f',
|
||||||
file: '#546e7a',
|
file: '#546e7a',
|
||||||
@@ -105,6 +108,12 @@ const EXT_KIND: Record<string, FaviconKind> = {
|
|||||||
ply: 'model3d',
|
ply: 'model3d',
|
||||||
wrl: 'model3d',
|
wrl: 'model3d',
|
||||||
zip: 'model3d',
|
zip: 'model3d',
|
||||||
|
png: 'scanner',
|
||||||
|
jpg: 'scanner',
|
||||||
|
jpeg: 'scanner',
|
||||||
|
webp: 'scanner',
|
||||||
|
gif: 'scanner',
|
||||||
|
bmp: 'scanner',
|
||||||
txt: 'text',
|
txt: 'text',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import fontkit from '@pdf-lib/fontkit';
|
||||||
|
import { PDFDocument, rgb, StandardFonts } from 'pdf-lib';
|
||||||
|
import type { OcrResult } from './types';
|
||||||
|
|
||||||
|
export interface PdfPageInput {
|
||||||
|
imageBytes: ArrayBuffer;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
ocr?: OcrResult | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCjkFont(pdf: PDFDocument) {
|
||||||
|
pdf.registerFontkit(fontkit);
|
||||||
|
const response = await fetch(
|
||||||
|
'/viewer-assets/cad-resources/fonts/NotoSansKR-VF.ttf',
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('PDF용 한글 글꼴을 불러오지 못했습니다.');
|
||||||
|
}
|
||||||
|
const bytes = await response.arrayBuffer();
|
||||||
|
return pdf.embedFont(bytes, { subset: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildSearchablePdf(pages: PdfPageInput[]) {
|
||||||
|
if (!pages.length) {
|
||||||
|
throw new Error('PDF로 내보낼 페이지가 없습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const pdf = await PDFDocument.create();
|
||||||
|
let cjkFont = null as Awaited<ReturnType<typeof loadCjkFont>> | null;
|
||||||
|
let latinFont = await pdf.embedFont(StandardFonts.Helvetica);
|
||||||
|
|
||||||
|
const needsCjk = pages.some((page) =>
|
||||||
|
page.ocr?.words.some((word) => /[^\u0000-\u007f]/.test(word.text)),
|
||||||
|
);
|
||||||
|
if (needsCjk || pages.some((page) => page.ocr?.text)) {
|
||||||
|
try {
|
||||||
|
cjkFont = await loadCjkFont(pdf);
|
||||||
|
} catch {
|
||||||
|
cjkFont = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const page of pages) {
|
||||||
|
const image = await pdf.embedPng(page.imageBytes);
|
||||||
|
const pdfPage = pdf.addPage([page.width, page.height]);
|
||||||
|
pdfPage.drawImage(image, {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: page.width,
|
||||||
|
height: page.height,
|
||||||
|
});
|
||||||
|
|
||||||
|
const font = cjkFont ?? latinFont;
|
||||||
|
if (page.ocr?.words.length) {
|
||||||
|
for (const word of page.ocr.words) {
|
||||||
|
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;
|
||||||
|
try {
|
||||||
|
pdfPage.drawText(text, {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
size: fontSize,
|
||||||
|
font,
|
||||||
|
color: rgb(0, 0, 0),
|
||||||
|
opacity: 0,
|
||||||
|
maxWidth: boxWidth,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Skip glyphs the embedded font cannot encode.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pdf.save();
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import type { CornerPoints, Point2D } from './types';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
cv?: {
|
||||||
|
Mat: new () => unknown;
|
||||||
|
imread: (el: HTMLCanvasElement | HTMLImageElement) => {
|
||||||
|
delete: () => void;
|
||||||
|
cols: number;
|
||||||
|
rows: number;
|
||||||
|
};
|
||||||
|
onRuntimeInitialized?: () => void;
|
||||||
|
};
|
||||||
|
jscanify?: new () => JscanifyInstance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JscanifyInstance {
|
||||||
|
findPaperContour: (mat: unknown) => unknown | null;
|
||||||
|
getCornerPoints: (contour: unknown) => Partial<CornerPoints>;
|
||||||
|
extractPaper: (
|
||||||
|
image: HTMLCanvasElement | HTMLImageElement,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
corners?: CornerPoints,
|
||||||
|
) => HTMLCanvasElement | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const OPENCV_URL = '/viewer-assets/scanner/opencv.js';
|
||||||
|
const JSCANIFY_URL = '/viewer-assets/scanner/jscanify.js';
|
||||||
|
|
||||||
|
let opencvReady: Promise<void> | null = null;
|
||||||
|
let jscanifyReady: Promise<void> | null = null;
|
||||||
|
|
||||||
|
function loadScript(src: string) {
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
const existing = document.querySelector<HTMLScriptElement>(
|
||||||
|
`script[data-scanner-src="${src}"]`,
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
if (existing.dataset.loaded === '1') {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
existing.addEventListener('load', () => resolve(), { once: true });
|
||||||
|
existing.addEventListener(
|
||||||
|
'error',
|
||||||
|
() => reject(new Error(`스크립트를 불러오지 못했습니다: ${src}`)),
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.src = src;
|
||||||
|
script.async = true;
|
||||||
|
script.dataset.scannerSrc = src;
|
||||||
|
script.onload = () => {
|
||||||
|
script.dataset.loaded = '1';
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
script.onerror = () =>
|
||||||
|
reject(new Error(`스크립트를 불러오지 못했습니다: ${src}`));
|
||||||
|
document.head.appendChild(script);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureOpenCv() {
|
||||||
|
if (typeof window.cv?.Mat === 'function') return;
|
||||||
|
if (!opencvReady) {
|
||||||
|
opencvReady = (async () => {
|
||||||
|
await loadScript(OPENCV_URL);
|
||||||
|
const cv = window.cv;
|
||||||
|
if (!cv) throw new Error('OpenCV.js를 초기화하지 못했습니다.');
|
||||||
|
if (typeof cv.Mat === 'function') return;
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const timeout = window.setTimeout(() => {
|
||||||
|
reject(new Error('OpenCV.js 초기화 시간이 초과되었습니다.'));
|
||||||
|
}, 60_000);
|
||||||
|
const finish = () => {
|
||||||
|
window.clearTimeout(timeout);
|
||||||
|
window.clearInterval(poll);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
const previous = cv.onRuntimeInitialized;
|
||||||
|
cv.onRuntimeInitialized = () => {
|
||||||
|
previous?.();
|
||||||
|
finish();
|
||||||
|
};
|
||||||
|
const poll = window.setInterval(() => {
|
||||||
|
if (typeof cv.Mat === 'function') finish();
|
||||||
|
}, 50);
|
||||||
|
});
|
||||||
|
})().catch((error) => {
|
||||||
|
opencvReady = null;
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await opencvReady;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureJscanify() {
|
||||||
|
await ensureOpenCv();
|
||||||
|
if (window.jscanify) return;
|
||||||
|
if (!jscanifyReady) {
|
||||||
|
jscanifyReady = loadScript(JSCANIFY_URL);
|
||||||
|
}
|
||||||
|
await jscanifyReady;
|
||||||
|
if (!window.jscanify) {
|
||||||
|
throw new Error('jscanify를 불러오지 못했습니다.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createScanner() {
|
||||||
|
if (!window.jscanify) {
|
||||||
|
throw new Error('jscanify가 준비되지 않았습니다.');
|
||||||
|
}
|
||||||
|
return new window.jscanify();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fullFrameCorners(
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
): CornerPoints {
|
||||||
|
return {
|
||||||
|
topLeftCorner: { x: 0, y: 0 },
|
||||||
|
topRightCorner: { x: width, y: 0 },
|
||||||
|
bottomRightCorner: { x: width, y: height },
|
||||||
|
bottomLeftCorner: { x: 0, y: height },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidPoint(point: Point2D | undefined): point is Point2D {
|
||||||
|
return (
|
||||||
|
!!point &&
|
||||||
|
Number.isFinite(point.x) &&
|
||||||
|
Number.isFinite(point.y)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeCorners(
|
||||||
|
corners: Partial<CornerPoints> | null | undefined,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
): CornerPoints {
|
||||||
|
const fallback = fullFrameCorners(width, height);
|
||||||
|
if (!corners) return fallback;
|
||||||
|
const next: CornerPoints = {
|
||||||
|
topLeftCorner: isValidPoint(corners.topLeftCorner)
|
||||||
|
? corners.topLeftCorner
|
||||||
|
: fallback.topLeftCorner,
|
||||||
|
topRightCorner: isValidPoint(corners.topRightCorner)
|
||||||
|
? corners.topRightCorner
|
||||||
|
: fallback.topRightCorner,
|
||||||
|
bottomRightCorner: isValidPoint(corners.bottomRightCorner)
|
||||||
|
? corners.bottomRightCorner
|
||||||
|
: fallback.bottomRightCorner,
|
||||||
|
bottomLeftCorner: isValidPoint(corners.bottomLeftCorner)
|
||||||
|
? corners.bottomLeftCorner
|
||||||
|
: fallback.bottomLeftCorner,
|
||||||
|
};
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detectCornersFromImage(
|
||||||
|
image: HTMLImageElement | HTMLCanvasElement,
|
||||||
|
): CornerPoints {
|
||||||
|
const scanner = createScanner();
|
||||||
|
const cv = window.cv!;
|
||||||
|
const mat = cv.imread(image);
|
||||||
|
try {
|
||||||
|
const contour = scanner.findPaperContour(mat);
|
||||||
|
if (!contour) {
|
||||||
|
return fullFrameCorners(image.width, image.height);
|
||||||
|
}
|
||||||
|
const corners = scanner.getCornerPoints(contour);
|
||||||
|
if (
|
||||||
|
typeof (contour as { delete?: () => void }).delete === 'function'
|
||||||
|
) {
|
||||||
|
(contour as { delete: () => void }).delete();
|
||||||
|
}
|
||||||
|
return normalizeCorners(corners, image.width, image.height);
|
||||||
|
} finally {
|
||||||
|
mat.delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadImageFromUrl(url: string) {
|
||||||
|
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||||
|
const image = new Image();
|
||||||
|
image.onload = () => resolve(image);
|
||||||
|
image.onerror = () => reject(new Error('이미지를 불러오지 못했습니다.'));
|
||||||
|
image.src = url;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import * as ort from 'onnxruntime-web';
|
||||||
|
import {
|
||||||
|
PaddleOcrService,
|
||||||
|
V5_KOREAN_MOBILE_MODEL,
|
||||||
|
} from 'ppu-paddle-ocr/web';
|
||||||
|
import type { OcrResult, OcrWord } from './types';
|
||||||
|
|
||||||
|
const ORT_WASM_PATH = '/viewer-assets/scanner/ort/';
|
||||||
|
const PADDLE_BASE = '/viewer-assets/scanner/paddle';
|
||||||
|
|
||||||
|
let servicePromise: Promise<PaddleOcrService> | null = null;
|
||||||
|
|
||||||
|
async function toCanvas(
|
||||||
|
source: string | Blob | HTMLCanvasElement,
|
||||||
|
): Promise<HTMLCanvasElement> {
|
||||||
|
if (source instanceof HTMLCanvasElement) return source;
|
||||||
|
|
||||||
|
const objectUrl =
|
||||||
|
typeof source === 'string' ? null : URL.createObjectURL(source);
|
||||||
|
const src = typeof source === 'string' ? source : objectUrl!;
|
||||||
|
try {
|
||||||
|
const img = await new Promise<HTMLImageElement>((resolve, reject) => {
|
||||||
|
const image = new Image();
|
||||||
|
image.onload = () => resolve(image);
|
||||||
|
image.onerror = () =>
|
||||||
|
reject(new Error('OCR용 이미지를 불러오지 못했습니다.'));
|
||||||
|
image.src = src;
|
||||||
|
});
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = img.naturalWidth || img.width;
|
||||||
|
canvas.height = img.naturalHeight || img.height;
|
||||||
|
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||||
|
if (!ctx) throw new Error('Canvas를 초기화하지 못했습니다.');
|
||||||
|
ctx.drawImage(img, 0, 0);
|
||||||
|
return canvas;
|
||||||
|
} finally {
|
||||||
|
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getService(
|
||||||
|
onProgress?: (value: number, label: string) => void,
|
||||||
|
): Promise<PaddleOcrService> {
|
||||||
|
if (!servicePromise) {
|
||||||
|
servicePromise = (async () => {
|
||||||
|
onProgress?.(0.05, 'OCR 모델 준비 중');
|
||||||
|
ort.env.wasm.wasmPaths = ORT_WASM_PATH;
|
||||||
|
const service = new PaddleOcrService({
|
||||||
|
model: {
|
||||||
|
...V5_KOREAN_MOBILE_MODEL,
|
||||||
|
detection: `${PADDLE_BASE}/PP-OCRv5_mobile_det_infer.onnx`,
|
||||||
|
recognition: `${PADDLE_BASE}/korean_PP-OCRv5_mobile_rec_infer.onnx`,
|
||||||
|
charactersDictionary: `${PADDLE_BASE}/ppocrv5_korean_dict.txt`,
|
||||||
|
},
|
||||||
|
processing: { engine: 'canvas-native' },
|
||||||
|
debugging: { verbose: false, debug: false },
|
||||||
|
});
|
||||||
|
await service.initialize();
|
||||||
|
onProgress?.(0.35, 'OCR 모델 준비 완료');
|
||||||
|
return service;
|
||||||
|
})().catch((error) => {
|
||||||
|
servicePromise = null;
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return servicePromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recognizeImage(
|
||||||
|
source: string | Blob | HTMLCanvasElement,
|
||||||
|
onProgress?: (value: number, label: string) => void,
|
||||||
|
): Promise<OcrResult> {
|
||||||
|
const service = await getService(onProgress);
|
||||||
|
onProgress?.(0.4, '문자 인식 중');
|
||||||
|
const canvas = await toCanvas(source);
|
||||||
|
const result = await service.recognize(canvas, {
|
||||||
|
flatten: true,
|
||||||
|
strategy: 'per-box',
|
||||||
|
});
|
||||||
|
const words: OcrWord[] = result.results
|
||||||
|
.filter((item) => item.text?.trim())
|
||||||
|
.map((item) => ({
|
||||||
|
text: item.text,
|
||||||
|
confidence: Math.round((item.confidence ?? 0) * 100),
|
||||||
|
bbox: {
|
||||||
|
x0: item.box.x,
|
||||||
|
y0: item.box.y,
|
||||||
|
x1: item.box.x + item.box.width,
|
||||||
|
y1: item.box.y + item.box.height,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
onProgress?.(1, 'OCR 완료');
|
||||||
|
return {
|
||||||
|
text: result.text?.trim() ?? words.map((word) => word.text).join(' '),
|
||||||
|
words,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function terminateOcrWorker() {
|
||||||
|
if (!servicePromise) return;
|
||||||
|
const service = await servicePromise;
|
||||||
|
servicePromise = null;
|
||||||
|
await service.destroy();
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { GlobalWorkerOptions, getDocument } from 'pdfjs-dist';
|
||||||
|
|
||||||
|
const WORKER_SRC = '/viewer-assets/scanner/pdfjs/pdf.worker.min.mjs';
|
||||||
|
const MAX_SIDE = 1800;
|
||||||
|
|
||||||
|
let workerConfigured = false;
|
||||||
|
|
||||||
|
function ensureWorker() {
|
||||||
|
if (workerConfigured) return;
|
||||||
|
GlobalWorkerOptions.workerSrc = WORKER_SRC;
|
||||||
|
workerConfigured = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canvasToPngBlob(canvas: HTMLCanvasElement): Promise<Blob> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
canvas.toBlob(
|
||||||
|
(blob) => {
|
||||||
|
if (!blob) {
|
||||||
|
reject(new Error('PDF 페이지를 PNG로 변환하지 못했습니다.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(blob);
|
||||||
|
},
|
||||||
|
'image/png',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PdfRenderedPage {
|
||||||
|
name: string;
|
||||||
|
blob: Blob;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderPdfToImages(
|
||||||
|
file: File,
|
||||||
|
onProgress?: (done: number, total: number) => void,
|
||||||
|
): Promise<PdfRenderedPage[]> {
|
||||||
|
ensureWorker();
|
||||||
|
const data = await file.arrayBuffer();
|
||||||
|
const pdf = await getDocument({ data }).promise;
|
||||||
|
const base = file.name.replace(/\.pdf$/i, '') || 'document';
|
||||||
|
const pages: PdfRenderedPage[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber += 1) {
|
||||||
|
onProgress?.(pageNumber - 1, pdf.numPages);
|
||||||
|
const page = await pdf.getPage(pageNumber);
|
||||||
|
const unscaled = page.getViewport({ scale: 1 });
|
||||||
|
const longest = Math.max(unscaled.width, unscaled.height);
|
||||||
|
const scale = longest > 0 ? Math.min(2.5, MAX_SIDE / longest) : 1;
|
||||||
|
const viewport = page.getViewport({ scale });
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = Math.max(1, Math.floor(viewport.width));
|
||||||
|
canvas.height = Math.max(1, Math.floor(viewport.height));
|
||||||
|
const context = canvas.getContext('2d', { willReadFrequently: true });
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('PDF 렌더용 Canvas를 만들지 못했습니다.');
|
||||||
|
}
|
||||||
|
await page.render({ canvasContext: context, viewport, canvas }).promise;
|
||||||
|
const blob = await canvasToPngBlob(canvas);
|
||||||
|
pages.push({
|
||||||
|
name: `${base}-p${pageNumber}.png`,
|
||||||
|
blob,
|
||||||
|
width: canvas.width,
|
||||||
|
height: canvas.height,
|
||||||
|
});
|
||||||
|
page.cleanup();
|
||||||
|
}
|
||||||
|
onProgress?.(pdf.numPages, pdf.numPages);
|
||||||
|
} finally {
|
||||||
|
await pdf.cleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
return pages;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPdfFile(file: File): boolean {
|
||||||
|
return (
|
||||||
|
file.type === 'application/pdf' || /\.pdf$/i.test(file.name)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isScannerImageFile(file: File): boolean {
|
||||||
|
return (
|
||||||
|
/^image\/(png|jpe?g|webp|gif|bmp)$/i.test(file.type) ||
|
||||||
|
/\.(png|jpe?g|webp|gif|bmp)$/i.test(file.name)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
export interface Point2D {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CornerPoints {
|
||||||
|
topLeftCorner: Point2D;
|
||||||
|
topRightCorner: Point2D;
|
||||||
|
bottomLeftCorner: Point2D;
|
||||||
|
bottomRightCorner: Point2D;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PaperAspect = 'a4-portrait' | 'a4-landscape' | 'square' | 'auto';
|
||||||
|
|
||||||
|
export interface OcrWord {
|
||||||
|
text: string;
|
||||||
|
/** Pixel bbox on the warped image: x0,y0,x1,y1 */
|
||||||
|
bbox: { x0: number; y0: number; x1: number; y1: number };
|
||||||
|
confidence: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OcrResult {
|
||||||
|
text: string;
|
||||||
|
words: OcrWord[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScannerPage {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
sourceUrl: string;
|
||||||
|
sourceWidth: number;
|
||||||
|
sourceHeight: number;
|
||||||
|
corners: CornerPoints;
|
||||||
|
aspect: PaperAspect;
|
||||||
|
warpedUrl: string | null;
|
||||||
|
warpedWidth: number;
|
||||||
|
warpedHeight: number;
|
||||||
|
ocr: OcrResult | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import {
|
||||||
|
createScanner,
|
||||||
|
ensureJscanify,
|
||||||
|
loadImageFromUrl,
|
||||||
|
} from './detect';
|
||||||
|
import type { CornerPoints, PaperAspect } from './types';
|
||||||
|
|
||||||
|
export function outputSizeForAspect(
|
||||||
|
aspect: PaperAspect,
|
||||||
|
corners: CornerPoints,
|
||||||
|
) {
|
||||||
|
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 };
|
||||||
|
|
||||||
|
const widthTop = Math.hypot(
|
||||||
|
corners.topRightCorner.x - corners.topLeftCorner.x,
|
||||||
|
corners.topRightCorner.y - corners.topLeftCorner.y,
|
||||||
|
);
|
||||||
|
const widthBottom = Math.hypot(
|
||||||
|
corners.bottomRightCorner.x - corners.bottomLeftCorner.x,
|
||||||
|
corners.bottomRightCorner.y - corners.bottomLeftCorner.y,
|
||||||
|
);
|
||||||
|
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);
|
||||||
|
return {
|
||||||
|
width: Math.max(1, Math.round(width * scale)),
|
||||||
|
height: Math.max(1, Math.round(height * scale)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function warpImage(
|
||||||
|
sourceUrl: string,
|
||||||
|
corners: CornerPoints,
|
||||||
|
aspect: PaperAspect,
|
||||||
|
) {
|
||||||
|
await ensureJscanify();
|
||||||
|
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 blob = await new Promise<Blob>((resolve, reject) => {
|
||||||
|
canvas.toBlob(
|
||||||
|
(value) =>
|
||||||
|
value
|
||||||
|
? resolve(value)
|
||||||
|
: reject(new Error('보정 이미지를 만들지 못했습니다.')),
|
||||||
|
'image/png',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
blob,
|
||||||
|
width: canvas.width,
|
||||||
|
height: canvas.height,
|
||||||
|
url: URL.createObjectURL(blob),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -44,6 +44,11 @@ const router = createRouter({
|
|||||||
name: 'model3d-viewer',
|
name: 'model3d-viewer',
|
||||||
component: () => import('@/views/Model3DViewerView.vue'),
|
component: () => import('@/views/Model3DViewerView.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/tools/scanner',
|
||||||
|
name: 'tools-scanner',
|
||||||
|
component: () => import('@/views/ToolsScannerView.vue'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/licenses',
|
path: '/licenses',
|
||||||
name: 'licenses',
|
name: 'licenses',
|
||||||
|
|||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
declare module '@pdf-lib/fontkit' {
|
||||||
|
import type { Fontkit } from 'pdf-lib';
|
||||||
|
const fontkit: Fontkit;
|
||||||
|
export default fontkit;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module 'jscanify/client' {
|
||||||
|
const jscanify: unknown;
|
||||||
|
export default jscanify;
|
||||||
|
}
|
||||||
@@ -62,6 +62,15 @@ const viewerTypes = [
|
|||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
const toolTypes = [
|
||||||
|
{
|
||||||
|
route: 'tools-scanner',
|
||||||
|
extension: 'png',
|
||||||
|
title: '문서 스캐너',
|
||||||
|
subtitle: '코너 보정 · OCR · PDF',
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
function onDrop(e: DragEvent) {
|
function onDrop(e: DragEvent) {
|
||||||
dragging.value = false;
|
dragging.value = false;
|
||||||
void openDocument(e.dataTransfer?.files);
|
void openDocument(e.dataTransfer?.files);
|
||||||
@@ -116,7 +125,7 @@ function onOpenTool(route: string) {
|
|||||||
{{ busy ? '여는 중…' : '파일을 끌어다 놓거나 클릭하여 선택' }}
|
{{ busy ? '여는 중…' : '파일을 끌어다 놓거나 클릭하여 선택' }}
|
||||||
</span>
|
</span>
|
||||||
<span class="drop__hint">
|
<span class="drop__hint">
|
||||||
오피스·PDF · HWP/HWPX · 다이어그램 · CAD · 3D 모델(ZIP 포함)
|
오피스·PDF · HWP/HWPX · 다이어그램 · CAD · 3D · 이미지(스캐너)
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@@ -193,6 +202,30 @@ function onOpenTool(route: string) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<h2 class="section__title">도구</h2>
|
||||||
|
<p class="section__lead">
|
||||||
|
브라우저에서 문서를 스캔·보정하고 OCR이 포함된 PDF로 저장합니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<button
|
||||||
|
v-for="item in toolTypes"
|
||||||
|
:key="item.route"
|
||||||
|
type="button"
|
||||||
|
class="tile"
|
||||||
|
:disabled="busy"
|
||||||
|
@click="onOpenTool(item.route)"
|
||||||
|
>
|
||||||
|
<FileTypeIcon :extension="item.extension" :size="36" />
|
||||||
|
<span class="tile__text">
|
||||||
|
<strong>{{ item.title }}</strong>
|
||||||
|
<span>{{ item.subtitle }}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -51,6 +51,41 @@ const thirdParty = [
|
|||||||
license: 'MIT',
|
license: 'MIT',
|
||||||
url: 'https://github.com/101arrowz/fflate',
|
url: 'https://github.com/101arrowz/fflate',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'jscanify',
|
||||||
|
license: 'MIT',
|
||||||
|
url: 'https://github.com/puffinsoft/jscanify',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'OpenCV.js',
|
||||||
|
license: 'Apache-2.0',
|
||||||
|
url: 'https://opencv.org/',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ppu-paddle-ocr',
|
||||||
|
license: 'MIT',
|
||||||
|
url: 'https://github.com/PT-Perkasa-Pilar-Utama/ppu-paddle-ocr',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ONNX Runtime Web',
|
||||||
|
license: 'MIT',
|
||||||
|
url: 'https://github.com/microsoft/onnxruntime',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'PaddleOCR models (PP-OCRv5)',
|
||||||
|
license: 'Apache-2.0',
|
||||||
|
url: 'https://github.com/PaddlePaddle/PaddleOCR',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'pdf-lib',
|
||||||
|
license: 'MIT',
|
||||||
|
url: 'https://github.com/Hopding/pdf-lib',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'PDF.js',
|
||||||
|
license: 'Apache-2.0',
|
||||||
|
url: 'https://github.com/mozilla/pdf.js',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'Noto Sans KR',
|
name: 'Noto Sans KR',
|
||||||
license: 'SIL OFL 1.1',
|
license: 'SIL OFL 1.1',
|
||||||
@@ -149,6 +184,42 @@ const thirdParty = [
|
|||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>문서 스캐너</h2>
|
||||||
|
<p>
|
||||||
|
문서 코너 검출·퍼스펙티브 보정은
|
||||||
|
<a
|
||||||
|
href="https://github.com/puffinsoft/jscanify"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>jscanify</a
|
||||||
|
>
|
||||||
|
와 OpenCV.js(Apache-2.0)를 사용합니다. OCR은
|
||||||
|
<a
|
||||||
|
href="https://github.com/PT-Perkasa-Pilar-Utama/ppu-paddle-ocr"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>ppu-paddle-ocr</a
|
||||||
|
>
|
||||||
|
(MIT)와 ONNX Runtime Web(MIT)으로 PP-OCRv5 한국어 모델을 브라우저에서
|
||||||
|
실행하며, PDF 페이지 래스터화는
|
||||||
|
<a
|
||||||
|
href="https://github.com/mozilla/pdf.js"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>PDF.js</a
|
||||||
|
>
|
||||||
|
(Apache-2.0), PDF 생성은 pdf-lib(MIT)입니다. OpenCV·ORT WASM·ONNX
|
||||||
|
모델·사전·PDF.js worker는
|
||||||
|
<code>/viewer-assets/scanner/</code>에 자체 호스팅됩니다.
|
||||||
|
</p>
|
||||||
|
<p class="muted">
|
||||||
|
스캔 이미지와 OCR 결과는 브라우저에서만 처리하며 서버로 업로드하지
|
||||||
|
않습니다. OCR을 켠 PDF에는 검색·복사용 투명 텍스트 레이어가
|
||||||
|
포함됩니다.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h2>Euro-Office Document Server</h2>
|
<h2>Euro-Office Document Server</h2>
|
||||||
<p>
|
<p>
|
||||||
|
|||||||
@@ -0,0 +1,992 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
computed,
|
||||||
|
onActivated,
|
||||||
|
onBeforeUnmount,
|
||||||
|
onMounted,
|
||||||
|
ref,
|
||||||
|
watch,
|
||||||
|
} from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import AppShell from '@/components/AppShell.vue';
|
||||||
|
import EditorToolbar from '@/components/EditorToolbar.vue';
|
||||||
|
import CornerEditor from '@/components/scanner/CornerEditor.vue';
|
||||||
|
import { consumeScannerFiles } from '@/composables/scannerSession';
|
||||||
|
import { usePageTitle } from '@/composables/usePageTitle';
|
||||||
|
import {
|
||||||
|
detectCornersFromImage,
|
||||||
|
ensureJscanify,
|
||||||
|
fullFrameCorners,
|
||||||
|
loadImageFromUrl,
|
||||||
|
} from '@/integrations/scanner/detect';
|
||||||
|
import { buildSearchablePdf } from '@/integrations/scanner/buildPdf';
|
||||||
|
import { recognizeImage, terminateOcrWorker } from '@/integrations/scanner/ocr';
|
||||||
|
import {
|
||||||
|
isPdfFile,
|
||||||
|
isScannerImageFile,
|
||||||
|
renderPdfToImages,
|
||||||
|
} from '@/integrations/scanner/pdfPages';
|
||||||
|
import type {
|
||||||
|
CornerPoints,
|
||||||
|
PaperAspect,
|
||||||
|
ScannerPage,
|
||||||
|
} from '@/integrations/scanner/types';
|
||||||
|
import { warpImage } from '@/integrations/scanner/warp';
|
||||||
|
import { downloadBlob } from '@/utils/downloadBlob';
|
||||||
|
|
||||||
|
defineOptions({ name: 'ToolsScannerView' });
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const fileInput = ref<HTMLInputElement | null>(null);
|
||||||
|
const cameraInput = ref<HTMLInputElement | null>(null);
|
||||||
|
const pages = ref<ScannerPage[]>([]);
|
||||||
|
const activeId = ref<string | null>(null);
|
||||||
|
const filename = ref('문서 스캔');
|
||||||
|
const busy = ref(false);
|
||||||
|
const progress = ref(0);
|
||||||
|
const progressLabel = ref('');
|
||||||
|
const error = ref('');
|
||||||
|
const notice = ref('');
|
||||||
|
const engineReady = ref(false);
|
||||||
|
const enableOcr = ref(true);
|
||||||
|
const fileDropActive = ref(false);
|
||||||
|
const dragPageId = ref<string | null>(null);
|
||||||
|
const dropTargetId = ref<string | null>(null);
|
||||||
|
|
||||||
|
usePageTitle(filename);
|
||||||
|
|
||||||
|
const activePage = computed(
|
||||||
|
() => pages.value.find((page) => page.id === activeId.value) ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const warpedCount = computed(
|
||||||
|
() => pages.value.filter((page) => page.warpedUrl).length,
|
||||||
|
);
|
||||||
|
|
||||||
|
function revokeUrl(url: string | null) {
|
||||||
|
if (url?.startsWith('blob:')) URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createId() {
|
||||||
|
return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pushPageFromSource(
|
||||||
|
name: string,
|
||||||
|
sourceUrl: string,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
) {
|
||||||
|
const image = await loadImageFromUrl(sourceUrl);
|
||||||
|
const w = width || image.naturalWidth || image.width;
|
||||||
|
const h = height || image.naturalHeight || image.height;
|
||||||
|
let corners: CornerPoints;
|
||||||
|
try {
|
||||||
|
corners = detectCornersFromImage(image);
|
||||||
|
} catch {
|
||||||
|
corners = fullFrameCorners(w, h);
|
||||||
|
}
|
||||||
|
const page: ScannerPage = {
|
||||||
|
id: createId(),
|
||||||
|
name,
|
||||||
|
sourceUrl,
|
||||||
|
sourceWidth: w,
|
||||||
|
sourceHeight: h,
|
||||||
|
corners,
|
||||||
|
aspect: 'a4-portrait',
|
||||||
|
warpedUrl: null,
|
||||||
|
warpedWidth: 0,
|
||||||
|
warpedHeight: 0,
|
||||||
|
ocr: null,
|
||||||
|
};
|
||||||
|
pages.value.push(page);
|
||||||
|
if (!activeId.value) activeId.value = page.id;
|
||||||
|
filename.value = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addFiles(files: File[]) {
|
||||||
|
const images = files.filter(isScannerImageFile);
|
||||||
|
const pdfs = files.filter(isPdfFile);
|
||||||
|
if (!images.length && !pdfs.length) {
|
||||||
|
error.value = 'PNG, JPEG, WebP 이미지 또는 PDF를 선택해 주세요.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
error.value = '';
|
||||||
|
busy.value = true;
|
||||||
|
progressLabel.value = '파일 준비 중';
|
||||||
|
let added = 0;
|
||||||
|
try {
|
||||||
|
await ensureJscanify();
|
||||||
|
engineReady.value = true;
|
||||||
|
|
||||||
|
for (const file of images) {
|
||||||
|
progressLabel.value = `${file.name} 분석 중`;
|
||||||
|
const sourceUrl = URL.createObjectURL(file);
|
||||||
|
await pushPageFromSource(file.name, sourceUrl, 0, 0);
|
||||||
|
added += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of pdfs) {
|
||||||
|
progressLabel.value = `${file.name} PDF 변환 중`;
|
||||||
|
const rendered = await renderPdfToImages(file, (done, total) => {
|
||||||
|
progress.value = total ? done / total : 0;
|
||||||
|
progressLabel.value = `${file.name} 페이지 ${done}/${total}`;
|
||||||
|
});
|
||||||
|
for (const page of rendered) {
|
||||||
|
progressLabel.value = `${page.name} 분석 중`;
|
||||||
|
const sourceUrl = URL.createObjectURL(page.blob);
|
||||||
|
await pushPageFromSource(page.name, sourceUrl, page.width, page.height);
|
||||||
|
added += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
notice.value = `${added}장을 추가했습니다. 드래그로 순서를 바꿀 수 있습니다.`;
|
||||||
|
} catch (reason) {
|
||||||
|
error.value =
|
||||||
|
reason instanceof Error
|
||||||
|
? reason.message
|
||||||
|
: '스캐너를 초기화하지 못했습니다.';
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
progress.value = 0;
|
||||||
|
progressLabel.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function consumePending() {
|
||||||
|
const pending = consumeScannerFiles();
|
||||||
|
if (pending.length) void addFiles(pending);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void ensureJscanify()
|
||||||
|
.then(() => {
|
||||||
|
engineReady.value = true;
|
||||||
|
})
|
||||||
|
.catch((reason) => {
|
||||||
|
error.value =
|
||||||
|
reason instanceof Error
|
||||||
|
? reason.message
|
||||||
|
: 'OpenCV/jscanify를 불러오지 못했습니다.';
|
||||||
|
});
|
||||||
|
consumePending();
|
||||||
|
});
|
||||||
|
|
||||||
|
onActivated(consumePending);
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
for (const page of pages.value) {
|
||||||
|
revokeUrl(page.sourceUrl);
|
||||||
|
revokeUrl(page.warpedUrl);
|
||||||
|
}
|
||||||
|
void terminateOcrWorker();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(activePage, (page) => {
|
||||||
|
if (page) filename.value = page.name;
|
||||||
|
});
|
||||||
|
|
||||||
|
function onOpen() {
|
||||||
|
fileInput.value?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCamera() {
|
||||||
|
cameraInput.value?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFilesSelected(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const selected = Array.from(input.files ?? []);
|
||||||
|
input.value = '';
|
||||||
|
if (selected.length) void addFiles(selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectPage(id: string) {
|
||||||
|
activeId.value = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removePage(id: string) {
|
||||||
|
const index = pages.value.findIndex((page) => page.id === id);
|
||||||
|
if (index < 0) return;
|
||||||
|
const [removed] = pages.value.splice(index, 1);
|
||||||
|
revokeUrl(removed.sourceUrl);
|
||||||
|
revokeUrl(removed.warpedUrl);
|
||||||
|
if (activeId.value === id) {
|
||||||
|
activeId.value = pages.value[index]?.id ?? pages.value[index - 1]?.id ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function movePage(fromId: string, toId: string) {
|
||||||
|
if (fromId === toId) return;
|
||||||
|
const from = pages.value.findIndex((page) => page.id === fromId);
|
||||||
|
const to = pages.value.findIndex((page) => page.id === toId);
|
||||||
|
if (from < 0 || to < 0) return;
|
||||||
|
const [item] = pages.value.splice(from, 1);
|
||||||
|
pages.value.splice(to, 0, item);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPageDragStart(event: DragEvent, id: string) {
|
||||||
|
if (busy.value) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dragPageId.value = id;
|
||||||
|
event.dataTransfer?.setData('text/page-id', id);
|
||||||
|
if (event.dataTransfer) {
|
||||||
|
event.dataTransfer.effectAllowed = 'move';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPageDragOver(event: DragEvent, id: string) {
|
||||||
|
if (!dragPageId.value || dragPageId.value === id) return;
|
||||||
|
event.preventDefault();
|
||||||
|
dropTargetId.value = id;
|
||||||
|
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPageDrop(event: DragEvent, id: string) {
|
||||||
|
event.preventDefault();
|
||||||
|
const fromId =
|
||||||
|
dragPageId.value || event.dataTransfer?.getData('text/page-id') || '';
|
||||||
|
if (fromId) movePage(fromId, id);
|
||||||
|
dragPageId.value = null;
|
||||||
|
dropTargetId.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPageDragEnd() {
|
||||||
|
dragPageId.value = null;
|
||||||
|
dropTargetId.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFileDragOver(event: DragEvent) {
|
||||||
|
if (busy.value) return;
|
||||||
|
if (![...((event.dataTransfer?.types as string[]) ?? [])].includes('Files')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
fileDropActive.value = true;
|
||||||
|
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFileDragLeave(event: DragEvent) {
|
||||||
|
const related = event.relatedTarget as Node | null;
|
||||||
|
if (related && (event.currentTarget as Node).contains(related)) return;
|
||||||
|
fileDropActive.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFileDrop(event: DragEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
fileDropActive.value = false;
|
||||||
|
if (busy.value) return;
|
||||||
|
const files = Array.from(event.dataTransfer?.files ?? []);
|
||||||
|
if (files.length) void addFiles(files);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCorners(corners: CornerPoints) {
|
||||||
|
if (!activePage.value) return;
|
||||||
|
activePage.value.corners = corners;
|
||||||
|
revokeUrl(activePage.value.warpedUrl);
|
||||||
|
activePage.value.warpedUrl = null;
|
||||||
|
activePage.value.ocr = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAspect(aspect: PaperAspect) {
|
||||||
|
if (!activePage.value) return;
|
||||||
|
activePage.value.aspect = aspect;
|
||||||
|
revokeUrl(activePage.value.warpedUrl);
|
||||||
|
activePage.value.warpedUrl = null;
|
||||||
|
activePage.value.ocr = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function redetectActive() {
|
||||||
|
if (!activePage.value) return;
|
||||||
|
busy.value = true;
|
||||||
|
try {
|
||||||
|
await ensureJscanify();
|
||||||
|
const image = await loadImageFromUrl(activePage.value.sourceUrl);
|
||||||
|
activePage.value.corners = detectCornersFromImage(image);
|
||||||
|
revokeUrl(activePage.value.warpedUrl);
|
||||||
|
activePage.value.warpedUrl = null;
|
||||||
|
activePage.value.ocr = null;
|
||||||
|
} catch (reason) {
|
||||||
|
error.value =
|
||||||
|
reason instanceof Error ? reason.message : '코너 검출에 실패했습니다.';
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function useFullFrame() {
|
||||||
|
if (!activePage.value) return;
|
||||||
|
activePage.value.corners = fullFrameCorners(
|
||||||
|
activePage.value.sourceWidth,
|
||||||
|
activePage.value.sourceHeight,
|
||||||
|
);
|
||||||
|
revokeUrl(activePage.value.warpedUrl);
|
||||||
|
activePage.value.warpedUrl = null;
|
||||||
|
activePage.value.ocr = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function warpActive() {
|
||||||
|
if (!activePage.value) return;
|
||||||
|
busy.value = true;
|
||||||
|
progressLabel.value = '원근 보정 중';
|
||||||
|
try {
|
||||||
|
const result = await warpImage(
|
||||||
|
activePage.value.sourceUrl,
|
||||||
|
activePage.value.corners,
|
||||||
|
activePage.value.aspect,
|
||||||
|
);
|
||||||
|
revokeUrl(activePage.value.warpedUrl);
|
||||||
|
activePage.value.warpedUrl = result.url;
|
||||||
|
activePage.value.warpedWidth = result.width;
|
||||||
|
activePage.value.warpedHeight = result.height;
|
||||||
|
activePage.value.ocr = null;
|
||||||
|
notice.value = '보정을 적용했습니다.';
|
||||||
|
} catch (reason) {
|
||||||
|
error.value =
|
||||||
|
reason instanceof Error ? reason.message : '보정에 실패했습니다.';
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
progressLabel.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function warpAll() {
|
||||||
|
busy.value = true;
|
||||||
|
error.value = '';
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < pages.value.length; i += 1) {
|
||||||
|
const page = pages.value[i];
|
||||||
|
progress.value = i / pages.value.length;
|
||||||
|
progressLabel.value = `${page.name} 보정 중`;
|
||||||
|
const result = await warpImage(page.sourceUrl, page.corners, page.aspect);
|
||||||
|
revokeUrl(page.warpedUrl);
|
||||||
|
page.warpedUrl = result.url;
|
||||||
|
page.warpedWidth = result.width;
|
||||||
|
page.warpedHeight = result.height;
|
||||||
|
page.ocr = null;
|
||||||
|
}
|
||||||
|
notice.value = '모든 페이지를 보정했습니다.';
|
||||||
|
} catch (reason) {
|
||||||
|
error.value =
|
||||||
|
reason instanceof Error ? reason.message : '일괄 보정에 실패했습니다.';
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
progress.value = 0;
|
||||||
|
progressLabel.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runOcrOnPage(page: ScannerPage) {
|
||||||
|
if (!page.warpedUrl) {
|
||||||
|
const result = await warpImage(page.sourceUrl, page.corners, page.aspect);
|
||||||
|
revokeUrl(page.warpedUrl);
|
||||||
|
page.warpedUrl = result.url;
|
||||||
|
page.warpedWidth = result.width;
|
||||||
|
page.warpedHeight = result.height;
|
||||||
|
}
|
||||||
|
page.ocr = await recognizeImage(page.warpedUrl!, (value, label) => {
|
||||||
|
progress.value = value;
|
||||||
|
progressLabel.value = `${page.name}: ${label}`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runOcrActive() {
|
||||||
|
if (!activePage.value) return;
|
||||||
|
busy.value = true;
|
||||||
|
error.value = '';
|
||||||
|
try {
|
||||||
|
await runOcrOnPage(activePage.value);
|
||||||
|
notice.value = 'OCR을 완료했습니다.';
|
||||||
|
} catch (reason) {
|
||||||
|
error.value =
|
||||||
|
reason instanceof Error ? reason.message : 'OCR에 실패했습니다.';
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
progress.value = 0;
|
||||||
|
progressLabel.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runOcrAll() {
|
||||||
|
busy.value = true;
|
||||||
|
error.value = '';
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < pages.value.length; i += 1) {
|
||||||
|
progressLabel.value = `OCR ${i + 1}/${pages.value.length}`;
|
||||||
|
await runOcrOnPage(pages.value[i]);
|
||||||
|
}
|
||||||
|
notice.value = '모든 페이지 OCR을 완료했습니다.';
|
||||||
|
} catch (reason) {
|
||||||
|
error.value =
|
||||||
|
reason instanceof Error ? reason.message : 'OCR에 실패했습니다.';
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
progress.value = 0;
|
||||||
|
progressLabel.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function blobFromUrl(url: string) {
|
||||||
|
const response = await fetch(url);
|
||||||
|
return response.arrayBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportPdf() {
|
||||||
|
if (!pages.value.length) return;
|
||||||
|
busy.value = true;
|
||||||
|
error.value = '';
|
||||||
|
try {
|
||||||
|
const inputs = [];
|
||||||
|
for (let i = 0; i < pages.value.length; i += 1) {
|
||||||
|
const page = pages.value[i];
|
||||||
|
progress.value = i / pages.value.length;
|
||||||
|
progressLabel.value = `${page.name} PDF 준비`;
|
||||||
|
if (!page.warpedUrl) {
|
||||||
|
const warped = await warpImage(
|
||||||
|
page.sourceUrl,
|
||||||
|
page.corners,
|
||||||
|
page.aspect,
|
||||||
|
);
|
||||||
|
revokeUrl(page.warpedUrl);
|
||||||
|
page.warpedUrl = warped.url;
|
||||||
|
page.warpedWidth = warped.width;
|
||||||
|
page.warpedHeight = warped.height;
|
||||||
|
}
|
||||||
|
if (enableOcr.value && !page.ocr) {
|
||||||
|
await runOcrOnPage(page);
|
||||||
|
}
|
||||||
|
inputs.push({
|
||||||
|
imageBytes: await blobFromUrl(page.warpedUrl!),
|
||||||
|
width: page.warpedWidth,
|
||||||
|
height: page.warpedHeight,
|
||||||
|
ocr: enableOcr.value ? page.ocr : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
progressLabel.value = 'PDF 생성 중';
|
||||||
|
const bytes = await buildSearchablePdf(inputs);
|
||||||
|
downloadBlob(
|
||||||
|
new Blob([Uint8Array.from(bytes)], { type: 'application/pdf' }),
|
||||||
|
`${baseName()}.pdf`,
|
||||||
|
);
|
||||||
|
notice.value = enableOcr.value
|
||||||
|
? '검색 가능한 OCR PDF를 저장했습니다.'
|
||||||
|
: '스캔 PDF를 저장했습니다.';
|
||||||
|
} catch (reason) {
|
||||||
|
error.value =
|
||||||
|
reason instanceof Error ? reason.message : 'PDF 저장에 실패했습니다.';
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
progress.value = 0;
|
||||||
|
progressLabel.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportActivePng() {
|
||||||
|
if (!activePage.value) return;
|
||||||
|
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`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseName(name = filename.value) {
|
||||||
|
return name.replace(/\.[^.]+$/, '') || 'scan';
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClose() {
|
||||||
|
void router.push({ name: 'home' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const progressPercent = computed(() =>
|
||||||
|
Math.round(Math.min(1, Math.max(0, progress.value)) * 100),
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AppShell :editor="true">
|
||||||
|
<div class="scan-page">
|
||||||
|
<EditorToolbar
|
||||||
|
:filename="filename"
|
||||||
|
save-label="PDF 저장"
|
||||||
|
:show-save="pages.length > 0"
|
||||||
|
:busy="busy"
|
||||||
|
@open="onOpen"
|
||||||
|
@save="exportPdf"
|
||||||
|
@close="onClose"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
ref="fileInput"
|
||||||
|
class="sr-only"
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/webp,image/gif,image/bmp,.png,.jpg,.jpeg,.webp,.pdf,application/pdf"
|
||||||
|
multiple
|
||||||
|
@change="onFilesSelected"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
ref="cameraInput"
|
||||||
|
class="sr-only"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
capture="environment"
|
||||||
|
@change="onFilesSelected"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p v-if="error" class="viewer-message viewer-message--error" role="alert">
|
||||||
|
{{ error }}
|
||||||
|
</p>
|
||||||
|
<p v-else-if="notice" class="viewer-message">{{ notice }}</p>
|
||||||
|
|
||||||
|
<div class="layout">
|
||||||
|
<aside class="queue">
|
||||||
|
<div class="queue__actions">
|
||||||
|
<button type="button" :disabled="busy" @click="onOpen">
|
||||||
|
파일 추가
|
||||||
|
</button>
|
||||||
|
<button type="button" :disabled="busy" @click="onCamera">
|
||||||
|
카메라
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<ul v-if="pages.length" class="queue__list">
|
||||||
|
<li
|
||||||
|
v-for="(page, index) in pages"
|
||||||
|
:key="page.id"
|
||||||
|
:class="{
|
||||||
|
active: page.id === activeId,
|
||||||
|
'is-drop-target': dropTargetId === page.id,
|
||||||
|
'is-dragging': dragPageId === page.id,
|
||||||
|
}"
|
||||||
|
draggable="true"
|
||||||
|
@dragstart="onPageDragStart($event, page.id)"
|
||||||
|
@dragover="onPageDragOver($event, page.id)"
|
||||||
|
@drop="onPageDrop($event, page.id)"
|
||||||
|
@dragend="onPageDragEnd"
|
||||||
|
>
|
||||||
|
<button type="button" class="queue__item" @click="selectPage(page.id)">
|
||||||
|
<strong>{{ index + 1 }}. {{ page.name }}</strong>
|
||||||
|
<span>
|
||||||
|
{{ page.warpedUrl ? '보정됨' : '원본' }}
|
||||||
|
<template v-if="page.ocr"> · OCR</template>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="queue__remove"
|
||||||
|
:disabled="busy"
|
||||||
|
@click="removePage(page.id)"
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p v-else class="muted">이미지·PDF를 추가하거나 아래에 놓으세요.</p>
|
||||||
|
<div
|
||||||
|
class="queue__drop"
|
||||||
|
:class="{ 'is-active': fileDropActive }"
|
||||||
|
@dragenter="onFileDragOver"
|
||||||
|
@dragover="onFileDragOver"
|
||||||
|
@dragleave="onFileDragLeave"
|
||||||
|
@drop="onFileDrop"
|
||||||
|
>
|
||||||
|
여기에 놓아 추가
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main
|
||||||
|
class="workspace"
|
||||||
|
@dragenter="onFileDragOver"
|
||||||
|
@dragover="onFileDragOver"
|
||||||
|
@dragleave="onFileDragLeave"
|
||||||
|
@drop="onFileDrop"
|
||||||
|
>
|
||||||
|
<div v-if="!activePage" class="empty" :class="{ 'is-drop-active': fileDropActive }">
|
||||||
|
<strong>문서 스캐너</strong>
|
||||||
|
<span>
|
||||||
|
이미지 또는 PDF를 올리면 페이지를 목록에 추가합니다. 드래그로 순서를
|
||||||
|
바꾼 뒤 보정·OCR·검색 가능 PDF로 저장할 수 있습니다. 처리는 모두
|
||||||
|
브라우저에서 수행됩니다.
|
||||||
|
</span>
|
||||||
|
<div class="empty__actions">
|
||||||
|
<button type="button" :disabled="!engineReady || busy" @click="onOpen">
|
||||||
|
파일 열기
|
||||||
|
</button>
|
||||||
|
<button type="button" :disabled="!engineReady || busy" @click="onCamera">
|
||||||
|
카메라 촬영
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="toolbar-row">
|
||||||
|
<label>
|
||||||
|
용지
|
||||||
|
<select
|
||||||
|
:value="activePage.aspect"
|
||||||
|
:disabled="busy"
|
||||||
|
@change="
|
||||||
|
updateAspect(
|
||||||
|
($event.target as HTMLSelectElement).value as any,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<option value="a4-portrait">A4 세로</option>
|
||||||
|
<option value="a4-landscape">A4 가로</option>
|
||||||
|
<option value="square">정사각</option>
|
||||||
|
<option value="auto">자동 비율</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button type="button" :disabled="busy" @click="redetectActive">
|
||||||
|
다시 검출
|
||||||
|
</button>
|
||||||
|
<button type="button" :disabled="busy" @click="useFullFrame">
|
||||||
|
전체 이미지
|
||||||
|
</button>
|
||||||
|
<button type="button" :disabled="busy" @click="warpActive">
|
||||||
|
이 장 보정
|
||||||
|
</button>
|
||||||
|
<button type="button" :disabled="busy" @click="warpAll">
|
||||||
|
모두 보정
|
||||||
|
</button>
|
||||||
|
<label class="check">
|
||||||
|
<input v-model="enableOcr" type="checkbox" :disabled="busy" />
|
||||||
|
PDF에 OCR 텍스트 포함
|
||||||
|
</label>
|
||||||
|
<button type="button" :disabled="busy" @click="runOcrActive">
|
||||||
|
이 장 OCR
|
||||||
|
</button>
|
||||||
|
<button type="button" :disabled="busy" @click="runOcrAll">
|
||||||
|
모두 OCR
|
||||||
|
</button>
|
||||||
|
<button type="button" :disabled="busy" @click="exportActivePng">
|
||||||
|
PNG로 저장
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panes">
|
||||||
|
<section class="pane">
|
||||||
|
<h2>코너 편집</h2>
|
||||||
|
<CornerEditor
|
||||||
|
:image-url="activePage.sourceUrl"
|
||||||
|
:corners="activePage.corners"
|
||||||
|
:disabled="busy"
|
||||||
|
@update:corners="updateCorners"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
<section class="pane">
|
||||||
|
<h2>보정 미리보기</h2>
|
||||||
|
<img
|
||||||
|
v-if="activePage.warpedUrl"
|
||||||
|
class="preview"
|
||||||
|
:src="activePage.warpedUrl"
|
||||||
|
alt="보정된 스캔"
|
||||||
|
/>
|
||||||
|
<p v-else class="muted">보정 후 미리보기가 표시됩니다.</p>
|
||||||
|
<div v-if="activePage.ocr" class="ocr-box">
|
||||||
|
<h3>OCR 결과</h3>
|
||||||
|
<pre>{{ activePage.ocr.text || '(텍스트 없음)' }}</pre>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-if="busy && progressLabel" class="progress" aria-live="polite">
|
||||||
|
<span>{{ progressLabel }}</span>
|
||||||
|
<progress :value="progressPercent" max="100" />
|
||||||
|
<small>{{ progressPercent }}% · {{ warpedCount }}/{{ pages.length }}</small>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AppShell>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.scan-page {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--canvas);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 220px minmax(0, 1fr);
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 0.85rem;
|
||||||
|
overflow: auto;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue__actions,
|
||||||
|
.empty__actions,
|
||||||
|
.toolbar-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue__actions button,
|
||||||
|
.empty__actions button,
|
||||||
|
.toolbar-row button,
|
||||||
|
.toolbar-row select {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.35rem 0.55rem;
|
||||||
|
background: var(--canvas);
|
||||||
|
color: var(--ink);
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue__list {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue__list li {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue__list li.is-drop-target .queue__item {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue__list li.is-dragging {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue__item {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.15rem;
|
||||||
|
padding: 0.45rem 0.5rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
|
font: inherit;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue__list li.active .queue__item {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue__item strong {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue__item span,
|
||||||
|
.muted {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue__remove {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue__drop {
|
||||||
|
margin-top: auto;
|
||||||
|
padding: 0.75rem 0.5rem;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-align: center;
|
||||||
|
transition:
|
||||||
|
border-color 0.15s ease,
|
||||||
|
background 0.15s ease,
|
||||||
|
color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue__drop.is-active {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace {
|
||||||
|
position: relative;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
height: 100%;
|
||||||
|
display: grid;
|
||||||
|
place-content: center;
|
||||||
|
justify-items: center;
|
||||||
|
gap: 0.7rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--ink);
|
||||||
|
border: 1px dashed transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty.is-drop-active {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty span {
|
||||||
|
max-width: 36rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-row {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-row label {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 0.35rem;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check {
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panes {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane h2,
|
||||||
|
.pane h3 {
|
||||||
|
margin: 0 0 0.65rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: min(62vh, 720px);
|
||||||
|
object-fit: contain;
|
||||||
|
background: #111;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-box {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-box pre {
|
||||||
|
margin: 0;
|
||||||
|
max-height: 14rem;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 0.65rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--canvas);
|
||||||
|
color: var(--ink);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress {
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0.5rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
gap: 0.35rem 0.65rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
padding: 0.65rem 0.75rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 5px;
|
||||||
|
background: color-mix(in srgb, var(--surface) 92%, transparent);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress progress {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
width: 100%;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-message {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.45rem 0.75rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-message--error {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue {
|
||||||
|
border-right: 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
max-height: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panes {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -9,6 +9,9 @@ export default defineConfig({
|
|||||||
'@': path.resolve(__dirname, 'src'),
|
'@': path.resolve(__dirname, 'src'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
optimizeDeps: {
|
||||||
|
exclude: ['ppu-paddle-ocr', 'onnxruntime-web'],
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
@@ -22,4 +25,5 @@ export default defineConfig({
|
|||||||
outDir: path.resolve(__dirname, '../backend/public'),
|
outDir: path.resolve(__dirname, '../backend/public'),
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
},
|
},
|
||||||
|
assetsInclude: ['**/*.onnx', '**/*.ort', '**/*.wasm'],
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user