Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cb0c75092 | ||
|
|
c4ae7495dd | ||
|
|
545a2802e2 | ||
|
|
eadbfb0f86 | ||
|
|
26c98fe5e8 | ||
|
|
194eb318d1 | ||
|
|
4bffd65b32 | ||
|
|
34d5aa547f | ||
|
|
f4f98704be | ||
|
|
b0a6fc326c | ||
|
|
46acb08b93 | ||
|
|
db1341e40f | ||
|
|
73e4e73980 | ||
|
|
816bf636de | ||
|
|
5211b0d7ef | ||
|
|
2967548ff3 | ||
|
|
f5d880caf5 | ||
|
|
80eae206b6 | ||
|
|
309193a3a7 | ||
|
|
18a19acf55 | ||
|
|
00dfb36126 | ||
|
|
b9dfb468e7 | ||
|
|
2beabc610b | ||
|
|
453fca6152 | ||
|
|
2c64369694 |
No files matched your search
@@ -2,6 +2,8 @@ node_modules
|
||||
**/node_modules
|
||||
**/dist
|
||||
packages/backend/public
|
||||
packages/frontend/public/assets
|
||||
packages/frontend/public/viewer-assets
|
||||
.git
|
||||
.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/mtext-renderer-worker.js
|
||||
packages/frontend/public/viewer-assets/
|
||||
packages/frontend/.cache/
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
|
||||
Vendored
+103
@@ -0,0 +1,103 @@
|
||||
pipeline {
|
||||
agent any
|
||||
|
||||
environment {
|
||||
NODE_OPTIONS = '--max-old-space-size=8192'
|
||||
NODE_IMAGE = 'node:22-bookworm'
|
||||
REGISTRY = 'docker.sejong.dev'
|
||||
IMAGE = 'docker.sejong.dev/sejong-office'
|
||||
SSH_CONFIG_NAME = 'SEJONG-NAS'
|
||||
DEPLOY_DIR = '/volume1/docker/sejong-office'
|
||||
}
|
||||
|
||||
options {
|
||||
timestamps()
|
||||
buildDiscarder(logRotator(numToKeepStr: '20'))
|
||||
}
|
||||
|
||||
stages {
|
||||
stage('Type-check & Build') {
|
||||
steps {
|
||||
sh '''
|
||||
set -eu
|
||||
# DooD: docker.sock talks to the host daemon, so -v "$PWD" looks for
|
||||
# that path on the NAS and fails. Reuse the agent container mounts.
|
||||
if docker inspect "$(hostname)" >/dev/null 2>&1; then
|
||||
set -- --volumes-from "$(hostname)" -w "$PWD"
|
||||
else
|
||||
set -- -v "$PWD":/app -w /app
|
||||
fi
|
||||
docker run --rm \
|
||||
-u "$(id -u):$(id -g)" \
|
||||
-e HOME=/tmp \
|
||||
-e NODE_OPTIONS \
|
||||
"$@" \
|
||||
"$NODE_IMAGE" \
|
||||
bash -lc 'npm ci && npm run build'
|
||||
'''
|
||||
}
|
||||
}
|
||||
|
||||
stage('Release') {
|
||||
when {
|
||||
allOf {
|
||||
not { changeRequest() }
|
||||
anyOf {
|
||||
branch 'main'
|
||||
expression {
|
||||
def branch = env.GIT_BRANCH ?: env.BRANCH_NAME ?: ''
|
||||
branch == 'main' || branch == 'origin/main' || branch == 'refs/heads/main'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
stages {
|
||||
stage('Build & Push image') {
|
||||
steps {
|
||||
withCredentials([usernamePassword(
|
||||
credentialsId: 'docker-sejong-dev',
|
||||
usernameVariable: 'REGISTRY_USERNAME',
|
||||
passwordVariable: 'REGISTRY_PASSWORD'
|
||||
)]) {
|
||||
sh '''
|
||||
set -eu
|
||||
echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY" -u "$REGISTRY_USERNAME" --password-stdin
|
||||
docker build -t "$IMAGE:latest" -t "$IMAGE:sha-${GIT_COMMIT}" .
|
||||
docker push "$IMAGE:latest"
|
||||
docker push "$IMAGE:sha-${GIT_COMMIT}"
|
||||
'''
|
||||
}
|
||||
}
|
||||
post {
|
||||
always {
|
||||
sh 'docker logout "$REGISTRY" || true'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Deploy') {
|
||||
steps {
|
||||
sshPublisher(
|
||||
failOnError: true,
|
||||
publishers: [
|
||||
sshPublisherDesc(
|
||||
configName: env.SSH_CONFIG_NAME,
|
||||
verbose: true,
|
||||
transfers: [
|
||||
sshTransfer(
|
||||
execTimeout: 1800000,
|
||||
usePty: true,
|
||||
execCommand: """
|
||||
sudo -n su -c 'set -eu; export PATH=/usr/local/bin:/usr/bin:/bin; cd "${env.DEPLOY_DIR}"; docker compose pull; docker compose up -d'
|
||||
""".stripIndent().trim()
|
||||
)
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,22 @@
|
||||
# sejong-office
|
||||
|
||||
임시 세션 기반 문서 편집 웹앱입니다.
|
||||
임시 세션 기반 문서·도면·스캔 웹앱입니다.
|
||||
|
||||
- **오피스 / PDF** → [Euro-Office Document Server](https://github.com/Euro-Office/DocumentServer)
|
||||
- **HWP / HWPX** → [rhwp](https://github.com/edwardkim/rhwp) (`@rhwp/editor`)
|
||||
- **다이어그램** → Excalidraw · Mermaid · draw.io (브라우저)
|
||||
- **CAD** → [MLightCAD](https://github.com/mlightcad/cad-viewer) (DXF/DWG, LibreDWG)
|
||||
- **3D** → [Online 3D Viewer](https://github.com/kovacsv/Online3DViewer) (glTF, OBJ, STEP, IFC, STL, 3DM 등)
|
||||
- **문서 스캐너** → jscanify + OpenCV.js · [ppu-paddle-ocr](https://github.com/PT-Perkasa-Pilar-Utama/ppu-paddle-ocr) (PP-OCRv5 한국어) · PDF.js
|
||||
|
||||
서버에 문서를 **영구 저장하지 않습니다**. 오피스·PDF는 세션 동안만 tmpfs에 두고, 브라우저 종료·TTL로 삭제합니다. HWP/HWPX는 기본적으로 브라우저 메모리에서만 다룹니다. 편집 결과는 **다운로드**로만 가져가세요.
|
||||
서버에 문서를 **영구 저장하지 않습니다**. 오피스·PDF는 세션 동안만 tmpfs에 두고, 브라우저 종료·TTL로 삭제합니다. HWP/HWPX·CAD·3D·스캐너는 기본적으로 브라우저 메모리에서만 다룹니다. 결과는 **다운로드**로만 가져가세요.
|
||||
|
||||
## 스택
|
||||
|
||||
- Vue 3 + Vite + TypeScript
|
||||
- Express + TypeScript
|
||||
- Docker Compose
|
||||
- Vue 3 + Vite + TypeScript (프론트)
|
||||
- Express + TypeScript (API·정적 서빙)
|
||||
- Docker Compose (앱 + Document Server)
|
||||
- 뷰어·OCR·PDF.js worker 등은 `/viewer-assets/`에 **자체 호스팅** (CDN 런타임 fetch 없음)
|
||||
|
||||
## 빠른 시작 (Docker)
|
||||
|
||||
@@ -19,13 +24,148 @@
|
||||
cp .env.example .env
|
||||
# JWT_SECRET 을 긴 임의 문자열로 바꾸세요
|
||||
|
||||
docker compose up --build
|
||||
# 로컬에서 이미지 빌드 (레지스트리 없이 테스트할 때)
|
||||
docker build -t docker.sejong.dev/sejong-office:latest .
|
||||
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
프로덕션(레지스트리 pull):
|
||||
|
||||
```bash
|
||||
# 이미지: docker.sejong.dev/sejong-office:latest (호스트 고정)
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
- 앱: http://localhost:3000
|
||||
- Document Server: http://localhost:8080
|
||||
|
||||
첫 Document Server 기동은 수 분 걸릴 수 있습니다.
|
||||
첫 Document Server 기동은 수 분 걸릴 수 있습니다. 프론트 빌드 시 Paddle OCR 모델·ORT WASM을 내려받으므로 네트워크와 디스크 여유가 필요합니다.
|
||||
|
||||
### docker-compose 예시
|
||||
|
||||
[`docker-compose.yml`](docker-compose.yml) — backend는 `docker.sejong.dev/sejong-office:latest`를 pull합니다. (아래는 값 고정 예시)
|
||||
|
||||
```yaml
|
||||
services:
|
||||
backend:
|
||||
# 고정: 레지스트리 호스트 docker.sejong.dev, 이미지 sejong-office:latest
|
||||
image: docker.sejong.dev/sejong-office:latest
|
||||
ports:
|
||||
# 변경: 호스트 포트 (왼쪽). 컨테이너는 3000 고정
|
||||
- '3000:3000'
|
||||
environment:
|
||||
# 변경(필수): backend ↔ Document Server 공유 JWT. 긴 임의 문자열
|
||||
JWT_SECRET: change-me-to-a-long-random-secret
|
||||
# 변경: 브라우저가 접근하는 앱 공개 URL
|
||||
PUBLIC_URL: https://office.example.com
|
||||
# 보통 유지: compose 네트워크 안에서 backend 컨테이너 주소
|
||||
INTERNAL_URL: http://backend:3000
|
||||
# 변경: 브라우저가 접근하는 Document Server 공개 URL
|
||||
ONLYOFFICE_URL: https://office.example.com/ds
|
||||
# 보통 유지: compose 네트워크 안 Document Server 주소
|
||||
ONLYOFFICE_INTERNAL_URL: http://documentserver
|
||||
# 변경 가능: 세션 TTL(ms). 기본 15분
|
||||
SESSION_TTL_MS: '900000'
|
||||
# 유지: 컨테이너 내 임시 문서 경로 (아래 tmpfs와 맞출 것)
|
||||
TEMP_DIR: /tmp/sejong
|
||||
# 유지: 컨테이너 리스닝 포트
|
||||
PORT: 3000
|
||||
# 변경 가능: 업로드 최대 크기(MB)
|
||||
MAX_UPLOAD_MB: '50'
|
||||
tmpfs:
|
||||
# 변경 가능: 임시 저장 용량. TEMP_DIR 과 경로 일치
|
||||
- /tmp/sejong:size=512m,mode=1777
|
||||
depends_on:
|
||||
- documentserver
|
||||
restart: unless-stopped
|
||||
|
||||
documentserver:
|
||||
# 유지 권장: 버전 태그 pin
|
||||
image: ghcr.io/euro-office/documentserver:v9.3.2
|
||||
ports:
|
||||
# 변경: 호스트 포트 (왼쪽). 프록시만 쓸 거면 공개 안 해도 됨
|
||||
- '8080:80'
|
||||
environment:
|
||||
JWT_ENABLED: 'true'
|
||||
# 변경(필수): backend 의 JWT_SECRET 과 반드시 동일
|
||||
JWT_SECRET: change-me-to-a-long-random-secret
|
||||
JWT_HEADER: Authorization
|
||||
JWT_IN_BODY: 'true'
|
||||
volumes:
|
||||
- documentserver_data:/var/www/onlyoffice/Data
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
documentserver_data:
|
||||
```
|
||||
|
||||
## CI/CD (Jenkins)
|
||||
|
||||
[`Jenkinsfile`](Jenkinsfile) 이 `main` PR/브랜치에서 `npm ci` + `npm run build`를 돌리고, **`main` 브랜치 빌드**(PR 제외)에서만 이미지를 push한 뒤 서버에 SSH로 배포합니다.
|
||||
|
||||
에이전트에 Docker가 있어야 합니다. Node 22는 `node:22-bookworm` 컨테이너로 돌리며, Vite 힙 한도는 `NODE_OPTIONS=--max-old-space-size=8192`입니다.
|
||||
|
||||
푸시 태그: `docker.sejong.dev/sejong-office:latest`, `docker.sejong.dev/sejong-office:sha-<commit>`
|
||||
|
||||
배포는 [Publish Over SSH](https://plugins.jenkins.io/publish-over-ssh/)로 파일을 올리지 않고, 서버에 이미 있는 `docker-compose.yml` 디렉터리에서 `docker compose pull && docker compose up -d`만 실행합니다.
|
||||
|
||||
### Jenkins Credentials
|
||||
|
||||
Username/password 자격 증명 ID: `docker-sejong-dev`
|
||||
|
||||
| 필드 | 설명 |
|
||||
|------|------|
|
||||
| Username | push 전용 계정 |
|
||||
| Password | push 전용 토큰 (write) |
|
||||
|
||||
레지스트리 호스트는 `docker.sejong.dev`로 고정입니다. PR 빌드는 이 자격 증명을 쓰지 않습니다. 에이전트에 상시 `docker login` 하지 마세요.
|
||||
|
||||
Multibranch Pipeline(또는 Gitea/Git 브랜치 소스)로 이 레포를 연결하면 PR과 `main`이 각각 잡으로 뜹니다.
|
||||
|
||||
### Publish Over SSH
|
||||
|
||||
1. Jenkins에 **Publish Over SSH** 플러그인 설치
|
||||
2. **Manage Jenkins → System → Publish over SSH** 에 배포 서버 등록 (호스트, 사용자, 키 또는 비밀번호)
|
||||
3. 등록한 **Name**이 Jenkinsfile의 `SSH_CONFIG_NAME`과 같아야 함 (`SEJONG-NAS`)
|
||||
4. 배포 명령은 SSH 접속 후 `sudo -n su -c '...'` 로 root에서 실행함. 대화형 `sudo su`와 비밀번호 입력은 CI에서 불가
|
||||
5. Publish Over SSH에 넣은 **그 계정**으로 서버에 로그인한 뒤, 한 번만 NOPASSWD를 설정:
|
||||
|
||||
```bash
|
||||
sudo visudo -f /etc/sudoers.d/sejong-office-deploy
|
||||
```
|
||||
|
||||
파일 내용 (SSH 사용자 이름으로 바꿀 것):
|
||||
|
||||
```
|
||||
youruser ALL=(ALL) NOPASSWD: /bin/su, /usr/bin/su
|
||||
```
|
||||
|
||||
저장 후 비밀번호 없이 root가 되는지 확인:
|
||||
|
||||
```bash
|
||||
sudo -n su -c 'whoami'
|
||||
# root
|
||||
```
|
||||
|
||||
`sudo -n`은 비밀번호를 묻지 않고, 설정이 안 되어 있으면 바로 실패합니다. Jenkinsfile에 비밀번호를 넣지 마세요.
|
||||
|
||||
비로그인 SSH의 PATH에는 `/usr/local/bin`이 없어서 시놀로지의 `docker`를 못 찾습니다. 그래서 배포 명령이 PATH를 직접 지정합니다. 다음이 같은 결과여야 합니다:
|
||||
|
||||
```bash
|
||||
ssh -T youruser@nas "sudo -n su -c 'export PATH=/usr/local/bin:/usr/bin:/bin; command -v docker'"
|
||||
# /usr/local/bin/docker
|
||||
```
|
||||
|
||||
서버 compose 경로는 Jenkinsfile `DEPLOY_DIR` (`/volume1/docker/sejong-office`)입니다. 이름이나 경로가 다르면 Jenkinsfile을 수정하면 됩니다.
|
||||
|
||||
### 서버 배포 체크리스트
|
||||
|
||||
1. 배포 디렉터리에 `docker-compose.yml`과 `.env`를 두고, **pull 전용** 토큰으로 `docker login docker.sejong.dev` (CI push 토큰과 분리)
|
||||
2. `.env`에 `JWT_SECRET`, URL 설정
|
||||
3. `main` 배포 잡이 이미지 push 후 그 디렉터리에서 compose pull/up 실행
|
||||
4. `JWT_SECRET`은 Git·이미지·Jenkins Credential에 넣지 않음 (서버 `.env`만)
|
||||
|
||||
## 로컬 개발
|
||||
|
||||
@@ -37,6 +177,7 @@ cp .env.example .env
|
||||
npm run dev:backend
|
||||
|
||||
# 터미널 2 — Vite (포트 5173, /api 프록시)
|
||||
# predev 가 viewer-assets(CAD·3D·스캐너·OCR 모델)를 복사합니다
|
||||
npm run dev:frontend
|
||||
```
|
||||
|
||||
@@ -46,23 +187,40 @@ Euro-Office Document Server는 Docker로만 띄워도 됩니다.
|
||||
docker compose up documentserver
|
||||
```
|
||||
|
||||
## 문서 스캐너 (`/tools/scanner`)
|
||||
|
||||
브라우저에서만 동작합니다.
|
||||
|
||||
- **입력**: PNG/JPEG/WebP 등 이미지, 카메라, **다페이지 PDF**(PDF.js로 페이지 래스터화)
|
||||
- **보정**: 문서 코너 검출·드래그 수정·원근 보정 (jscanify / OpenCV.js)
|
||||
- **OCR**: PP-OCRv5 Korean (한글·영문), ONNX Runtime Web (WebGPU 우선)
|
||||
- **목록**: 하단 드롭으로 추가, 드래그로 **순서 변경**
|
||||
- **저장**: 목록 순서대로 검색 가능 PDF(투명 텍스트 레이어), 또는 PNG로 저장
|
||||
|
||||
## 소스 저장소 링크
|
||||
|
||||
프론트 [`packages/frontend/src/config/app.ts`](packages/frontend/src/config/app.ts) 의 `APP_GIT_URL` 에 Git URL을 넣으면 푸터에 **소스 코드** 링크가 표시됩니다.
|
||||
|
||||
## 라이선스 고지
|
||||
|
||||
앱 내 [/licenses](http://localhost:3000/licenses) 페이지에서 Euro-Office(AGPL), rhwp(MIT), 주요 OSS 고지를 볼 수 있습니다.
|
||||
앱 내 [/licenses](http://localhost:3000/licenses) 에서 상세 고지를 볼 수 있습니다.
|
||||
|
||||
- 본 앱: MIT
|
||||
- **본 앱: GPL-3.0-only** (LibreDWG / MLightCAD DWG 경로 포함)
|
||||
- Euro-Office Document Server: AGPL-3.0 (배포 시 의무 확인)
|
||||
- rhwp: MIT — Hancom 상표와 무관한 독립 프로젝트
|
||||
- Online 3D Viewer · ppu-paddle-ocr · pdf-lib 등: 각 패키지 라이선스(MIT/Apache-2.0 등)
|
||||
- PDF.js: Apache-2.0 · PaddleOCR 모델: Apache-2.0
|
||||
|
||||
## 포맷 안내
|
||||
|
||||
| 확장자 | 엔진 | 저장 |
|
||||
| 확장자 | 엔진 | 비고 |
|
||||
|--------|------|------|
|
||||
| hwp, hwpx | rhwp | 원본 확장자로 export |
|
||||
| pdf, docx, xlsx, pptx 등 | Euro-Office | 원본 확장자 유지 시도 (`forcesave` + ConvertService) |
|
||||
| pdf, docx, xlsx, pptx 등 | Euro-Office | 세션 tmpfs · `forcesave` / ConvertService |
|
||||
| dxf, dwg | MLightCAD | 클라이언트 뷰어 · 한글 폴백 글꼴(Noto Sans KR) |
|
||||
| glTF, OBJ, STEP, IFC, STL, 3DM 등 | Online 3D Viewer | `/viewer-assets/o3dv/` 자체 호스팅 |
|
||||
| png, jpg, webp, … / pdf(스캐너) | 문서 스캐너 | OCR PDF·PNG 다운로드 |
|
||||
|
||||
홈에서 PDF를 열면 Document Server 편집으로 가고, 스캐너에서 PDF를 열면 **페이지 이미지로 변환 후 OCR·재저장**합니다.
|
||||
|
||||
rhwp는 v0.7.x 기준이며, 복잡한 공공문서에서 한컴과 조판이 다를 수 있습니다.
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
backend:
|
||||
build: .
|
||||
image: docker.sejong.dev/sejong-office:latest
|
||||
ports:
|
||||
- '3000:3000'
|
||||
environment:
|
||||
|
||||
Generated
+1467
-9
File diff suppressed because it is too large.
Load diff
@@ -4,6 +4,7 @@ import cookieParser from 'cookie-parser';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import { config } from './config.js';
|
||||
import { logger, requestLogger } from './logger.js';
|
||||
import { sessionsRouter } from './routes/sessions.js';
|
||||
import { documentsRouter } from './routes/documents.js';
|
||||
import {
|
||||
@@ -18,6 +19,7 @@ async function main() {
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use(requestLogger);
|
||||
app.use(
|
||||
cors({
|
||||
origin: true,
|
||||
@@ -40,26 +42,44 @@ async function main() {
|
||||
app.use('/api/documents', documentsRouter);
|
||||
|
||||
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) => {
|
||||
if (req.path.startsWith('/api')) {
|
||||
next();
|
||||
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'));
|
||||
});
|
||||
}
|
||||
|
||||
app.use((err: unknown, req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
logger.error('Unhandled request error', { err, path: req.path });
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: 'internal_server_error' });
|
||||
}
|
||||
});
|
||||
|
||||
startSweeper();
|
||||
|
||||
app.listen(config.port, () => {
|
||||
console.log(`[sejong-office] listening on :${config.port}`);
|
||||
console.log(`[sejong-office] tempDir=${config.tempDir}`);
|
||||
console.log(`[sejong-office] publicUrl=${config.publicUrl}`);
|
||||
logger.info(`Backend listening on :${config.port}`);
|
||||
logger.info(`Temp directory: ${config.tempDir}`);
|
||||
logger.info(`Public URL: ${config.publicUrl}`);
|
||||
});
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
logger.error('Backend failed to start', { err });
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { RequestHandler } from 'express';
|
||||
|
||||
type LogLevel = 'INFO' | 'WARN' | 'ERROR';
|
||||
type LogDetails = Record<string, unknown>;
|
||||
|
||||
function formatDetails(details?: LogDetails): string {
|
||||
if (!details) return '';
|
||||
const normalized = Object.fromEntries(
|
||||
Object.entries(details).map(([key, value]) => [
|
||||
key,
|
||||
value instanceof Error ? value.stack ?? value.message : value,
|
||||
]),
|
||||
);
|
||||
return ` ${JSON.stringify(normalized)}`;
|
||||
}
|
||||
|
||||
function write(level: LogLevel, message: string, details?: LogDetails) {
|
||||
const line = `[${new Date().toISOString()}] [${level}] ${message}${formatDetails(details)}`;
|
||||
if (level === 'ERROR') {
|
||||
console.error(line);
|
||||
} else if (level === 'WARN') {
|
||||
console.warn(line);
|
||||
} else {
|
||||
console.log(line);
|
||||
}
|
||||
}
|
||||
|
||||
export const logger = {
|
||||
info: (message: string, details?: LogDetails) =>
|
||||
write('INFO', message, details),
|
||||
warn: (message: string, details?: LogDetails) =>
|
||||
write('WARN', message, details),
|
||||
error: (message: string, details?: LogDetails) =>
|
||||
write('ERROR', message, details),
|
||||
};
|
||||
|
||||
function browserSummary(userAgent = ''): string {
|
||||
const browsers: [RegExp, string][] = [
|
||||
[/Edg\/([\d.]+)/, 'Edge'],
|
||||
[/OPR\/([\d.]+)/, 'Opera'],
|
||||
[/Chrome\/([\d.]+)/, 'Chrome'],
|
||||
[/Firefox\/([\d.]+)/, 'Firefox'],
|
||||
[/Version\/([\d.]+).*Safari\//, 'Safari'],
|
||||
[/curl\/([\d.]+)/, 'curl'],
|
||||
];
|
||||
const browser = browsers
|
||||
.map(([pattern, name]) => {
|
||||
const match = userAgent.match(pattern);
|
||||
return match ? `${name} ${match[1]}` : null;
|
||||
})
|
||||
.find(Boolean) ?? 'Unknown';
|
||||
|
||||
const os = /Windows NT/.test(userAgent)
|
||||
? 'Windows'
|
||||
: /Android/.test(userAgent)
|
||||
? 'Android'
|
||||
: /iPhone|iPad/.test(userAgent)
|
||||
? 'iOS'
|
||||
: /Mac OS X/.test(userAgent)
|
||||
? 'macOS'
|
||||
: /Linux/.test(userAgent)
|
||||
? 'Linux'
|
||||
: 'Unknown';
|
||||
|
||||
return `${browser} / ${os}`;
|
||||
}
|
||||
|
||||
function requestIp(forwardedFor: string | string[] | undefined, remote?: string) {
|
||||
const forwarded = Array.isArray(forwardedFor)
|
||||
? forwardedFor[0]
|
||||
: forwardedFor?.split(',')[0]?.trim();
|
||||
return (forwarded || remote || '-').replace(/^::ffff:/, '');
|
||||
}
|
||||
|
||||
export const requestLogger: RequestHandler = (req, res, next) => {
|
||||
const startedAt = performance.now();
|
||||
res.on('finish', () => {
|
||||
const durationMs = Math.round(performance.now() - startedAt);
|
||||
const ip = requestIp(
|
||||
req.headers['x-forwarded-for'],
|
||||
req.socket.remoteAddress,
|
||||
);
|
||||
const browser = browserSummary(req.headers['user-agent']);
|
||||
const message =
|
||||
`${req.method} ${req.originalUrl} ${res.statusCode} ${durationMs}ms` +
|
||||
` ip=${ip} browser="${browser}"`;
|
||||
if (res.statusCode >= 500) logger.error(message);
|
||||
else if (res.statusCode >= 400) logger.warn(message);
|
||||
else logger.info(message);
|
||||
});
|
||||
next();
|
||||
};
|
||||
@@ -2,16 +2,16 @@ import { Router } from 'express';
|
||||
import multer from 'multer';
|
||||
import fs from 'node:fs/promises';
|
||||
import { config, OFFICE_EXTENSIONS } from '../config.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { requireSession } from '../middleware/session.js';
|
||||
import {
|
||||
addDocument,
|
||||
deleteDocument,
|
||||
getDocument,
|
||||
overwriteDocument,
|
||||
} from '../services/sessionStore.js';
|
||||
import {
|
||||
buildEditorConfig,
|
||||
convertToOriginalFormat,
|
||||
persistDocumentFromUrl,
|
||||
} from '../services/onlyOffice.js';
|
||||
import {
|
||||
createBlankDocument,
|
||||
@@ -51,7 +51,7 @@ documentsRouter.post('/new', requireSession, async (req, res) => {
|
||||
token: doc.token,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
logger.error('Failed to create blank document', { err });
|
||||
res.status(500).json({ error: 'create_failed' });
|
||||
}
|
||||
});
|
||||
@@ -97,7 +97,7 @@ documentsRouter.post(
|
||||
documentType: doc.documentType,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
logger.error('Failed to upload document', { err });
|
||||
res.status(500).json({ error: 'upload_failed' });
|
||||
}
|
||||
},
|
||||
@@ -148,7 +148,7 @@ documentsRouter.post('/:docId/callback', async (req, res) => {
|
||||
verifyOnlyOfficeToken(token);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[callback] jwt invalid', err);
|
||||
logger.warn('Invalid callback JWT', { err, docId: req.params.docId });
|
||||
res.status(403).json({ error: 1 });
|
||||
return;
|
||||
}
|
||||
@@ -187,35 +187,12 @@ documentsRouter.post('/:docId/callback', async (req, res) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const downloaded = await fetch(fileUrl);
|
||||
if (!downloaded.ok) {
|
||||
console.error('[callback] download failed', downloaded.status);
|
||||
res.json({ error: 1 });
|
||||
return;
|
||||
}
|
||||
|
||||
let buffer: Buffer = Buffer.from(await downloaded.arrayBuffer());
|
||||
const returnedType = (filetype ?? doc.fileType).toLowerCase();
|
||||
|
||||
if (returnedType !== doc.fileType) {
|
||||
const converted = await convertToOriginalFormat(
|
||||
fileUrl,
|
||||
returnedType,
|
||||
doc.fileType,
|
||||
);
|
||||
if (!converted) {
|
||||
console.error(
|
||||
`[callback] refused save: got ${returnedType}, need ${doc.fileType}`,
|
||||
);
|
||||
res.json({ error: 1 });
|
||||
return;
|
||||
}
|
||||
buffer = Buffer.from(converted);
|
||||
}
|
||||
|
||||
await overwriteDocument(doc, buffer, doc.fileType);
|
||||
await persistDocumentFromUrl(doc, fileUrl, filetype);
|
||||
} catch (err) {
|
||||
console.error('[callback] save error', err);
|
||||
logger.error('Failed to save callback document', {
|
||||
err,
|
||||
docId: req.params.docId,
|
||||
});
|
||||
res.json({ error: 1 });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { config } from '../config.js';
|
||||
import { logger } from '../logger.js';
|
||||
import type { SessionDoc } from '../types/session.js';
|
||||
import { overwriteDocument } from './sessionStore.js';
|
||||
import { signOnlyOfficePayload } from '../utils/jwt.js';
|
||||
|
||||
export function buildEditorConfig(doc: SessionDoc, userId: string) {
|
||||
@@ -49,6 +51,59 @@ export function buildEditorConfig(doc: SessionDoc, userId: string) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Prefer the internal Document Server base when the backend fetches the file. */
|
||||
export function toInternalOnlyOfficeUrl(raw: string): string {
|
||||
try {
|
||||
const target = new URL(raw);
|
||||
const publicBase = new URL(config.onlyOfficeUrl);
|
||||
const internalBase = new URL(config.onlyOfficeInternalUrl);
|
||||
if (
|
||||
target.host === publicBase.host ||
|
||||
target.host === internalBase.host
|
||||
) {
|
||||
target.protocol = internalBase.protocol;
|
||||
target.host = internalBase.host;
|
||||
}
|
||||
return target.toString();
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download an OnlyOffice-produced file URL and overwrite the session document
|
||||
* (converting back to the original extension when needed).
|
||||
*/
|
||||
export async function persistDocumentFromUrl(
|
||||
doc: SessionDoc,
|
||||
fileUrl: string,
|
||||
filetype?: string,
|
||||
): Promise<Buffer> {
|
||||
const fetchUrl = toInternalOnlyOfficeUrl(fileUrl);
|
||||
const downloaded = await fetch(fetchUrl);
|
||||
if (!downloaded.ok) {
|
||||
throw new Error(`download_failed_${downloaded.status}`);
|
||||
}
|
||||
|
||||
let buffer: Buffer = Buffer.from(await downloaded.arrayBuffer());
|
||||
const returnedType = (filetype ?? doc.fileType).toLowerCase();
|
||||
|
||||
if (returnedType !== doc.fileType) {
|
||||
const converted = await convertToOriginalFormat(
|
||||
fetchUrl,
|
||||
returnedType,
|
||||
doc.fileType,
|
||||
);
|
||||
if (!converted) {
|
||||
throw new Error(`convert_failed:${returnedType}->${doc.fileType}`);
|
||||
}
|
||||
buffer = Buffer.from(converted);
|
||||
}
|
||||
|
||||
await overwriteDocument(doc, buffer, doc.fileType);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
export async function convertToOriginalFormat(
|
||||
sourceUrl: string,
|
||||
fromExt: string,
|
||||
@@ -80,7 +135,9 @@ export async function convertToOriginalFormat(
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
console.error('[convert] HTTP', res.status);
|
||||
logger.error('OnlyOffice conversion HTTP error', {
|
||||
statusCode: res.status,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -91,7 +148,10 @@ export async function convertToOriginalFormat(
|
||||
};
|
||||
|
||||
if (data.error || !data.fileUrl) {
|
||||
console.error('[convert] failed', data);
|
||||
logger.error('OnlyOffice conversion failed', {
|
||||
errorCode: data.error,
|
||||
hasFileUrl: Boolean(data.fileUrl),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -99,7 +159,7 @@ export async function convertToOriginalFormat(
|
||||
if (!fileRes.ok) return null;
|
||||
return Buffer.from(await fileRes.arrayBuffer());
|
||||
} catch (err) {
|
||||
console.error('[convert] error', err);
|
||||
logger.error('OnlyOffice conversion error', { err });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import path from 'node:path';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { config } from '../config.js';
|
||||
import { logger } from '../logger.js';
|
||||
import type { Session, SessionDoc } from '../types/session.js';
|
||||
import { documentTypeFor, mimeFor } from '../utils/formats.js';
|
||||
|
||||
@@ -151,10 +152,14 @@ export async function sweepExpired(): Promise<number> {
|
||||
|
||||
export function startSweeper(intervalMs = 60_000): NodeJS.Timeout {
|
||||
return setInterval(() => {
|
||||
void sweepExpired().then((n) => {
|
||||
void sweepExpired()
|
||||
.then((n) => {
|
||||
if (n > 0) {
|
||||
console.log(`[session] swept ${n} expired session(s)`);
|
||||
logger.info(`Swept ${n} expired session(s)`);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error('Failed to sweep expired sessions', { err });
|
||||
});
|
||||
}, intervalMs);
|
||||
}
|
||||
@@ -10,11 +10,11 @@
|
||||
type="image/svg+xml"
|
||||
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%230f6cbd' d='M9,13H15V19H18V10L12,5.5L6,10V19H9V13M4,21V9L12,3L20,9V21H4Z'/%3E%3C/svg%3E"
|
||||
/>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+KR:wght@400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
as="style"
|
||||
crossorigin
|
||||
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable.min.css"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -23,15 +23,24 @@
|
||||
"@mlightcad/cad-viewer": "^1.5.8",
|
||||
"@mlightcad/data-model": "^1.11.2",
|
||||
"@mlightcad/dxf-json-converter": "^1.11.2",
|
||||
"@pdf-lib/fontkit": "^1.1.1",
|
||||
"@rhwp/editor": "^0.7.18",
|
||||
"@vueuse/core": "^11.3.0",
|
||||
"ag-psd": "^31.0.2",
|
||||
"dompurify": "^3.4.15",
|
||||
"draco3d": "^1.5.7",
|
||||
"element-plus": "^2.14.3",
|
||||
"fflate": "^0.8.2",
|
||||
"jscanify": "^1.4.3",
|
||||
"lodash-es": "^4.17.21",
|
||||
"marked": "^16.4.2",
|
||||
"mermaid": "^11.16.0",
|
||||
"occt-import-js": "^0.0.22",
|
||||
"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-dom": "^18.3.1",
|
||||
"rhino3dm": "^8.17.0",
|
||||
@@ -43,7 +52,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/three": "^0.172.0",
|
||||
|
||||
@@ -193,6 +193,149 @@ await writeFile(
|
||||
].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 replacements = [
|
||||
[
|
||||
@@ -213,6 +356,46 @@ const replacements = [
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* CDN builds load OCCT via a blob Worker + importScripts. That fails for
|
||||
* self-hosted absolute paths in some browsers. Use a same-origin Worker URL.
|
||||
*/
|
||||
function patchCreateOcctWorker(source) {
|
||||
const moduleRe =
|
||||
/function CreateOcctWorker\s*\([^)]*\)\s*\{[\s\S]*?URL\.createObjectURL\s*\(\s*blob\s*\)[\s\S]*?\}\s*(?=\s*function LoadExternalLibrary)/;
|
||||
const moduleNext = `function CreateOcctWorker (worker)
|
||||
{
|
||||
return new Promise ((resolve, reject) => {
|
||||
try {
|
||||
if (occtWorkerUrl === null) {
|
||||
occtWorkerUrl = '${O3DV_BASE}occt-import-js-worker.js';
|
||||
}
|
||||
resolve (new Worker (occtWorkerUrl));
|
||||
} catch (error) {
|
||||
reject (error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
`;
|
||||
|
||||
const minRe =
|
||||
/function Wo\(r\)\{return new Promise\(\(e,t\)=>\{if\(vu!==null\)\{e\(new Worker\(vu\)\);return\}[\s\S]*?URL\.createObjectURL\(s\),e\(new Worker\(vu\)\)\}\)\.catch\(t\)\}\)\}/;
|
||||
const minNext = `function Wo(r){return new Promise((e,t)=>{try{vu===null&&(vu="${O3DV_BASE}occt-import-js-worker.js"),e(new Worker(vu))}catch(n){t(n)}})}`;
|
||||
|
||||
let next = source;
|
||||
let changed = false;
|
||||
if (moduleRe.test(next)) {
|
||||
next = next.replace(moduleRe, moduleNext);
|
||||
changed = true;
|
||||
}
|
||||
if (minRe.test(next)) {
|
||||
next = next.replace(minRe, minNext);
|
||||
changed = true;
|
||||
}
|
||||
return { source: next, changed };
|
||||
}
|
||||
|
||||
async function patchO3dvFile(relativePath) {
|
||||
for (const candidate of resolvePackage(relativePath)) {
|
||||
try {
|
||||
@@ -224,17 +407,46 @@ async function patchO3dvFile(relativePath) {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
const occt = patchCreateOcctWorker(source);
|
||||
source = occt.source;
|
||||
changed = changed || occt.changed;
|
||||
if (changed) {
|
||||
await writeFile(candidate, source);
|
||||
console.log(`patched ${candidate}`);
|
||||
} else if (!source.includes(`${O3DV_BASE}occt-import-js-worker.js`)) {
|
||||
throw new Error(
|
||||
`OCCT worker patch did not apply for ${candidate}`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (error && /OCCT worker patch/.test(String(error.message))) throw error;
|
||||
// try next path
|
||||
}
|
||||
}
|
||||
throw new Error(`Could not patch ${relativePath}`);
|
||||
}
|
||||
|
||||
await writeFile(
|
||||
path.join(publicDir, 'viewer-assets/o3dv/occt-import-js-worker.js'),
|
||||
[
|
||||
`importScripts('${O3DV_BASE}occt-import-js.js');`,
|
||||
'',
|
||||
'onmessage = async function (ev)',
|
||||
'{',
|
||||
'\tlet modulOverrides = {',
|
||||
'\t\tlocateFile: function (path) {',
|
||||
`\t\t\treturn '${O3DV_BASE}' + String(path).split('/').pop();`,
|
||||
'\t\t}',
|
||||
'\t};',
|
||||
'\tlet occt = await occtimportjs (modulOverrides);',
|
||||
'\tlet result = occt.ReadFile (ev.data.format, ev.data.buffer, ev.data.params);',
|
||||
'\tpostMessage (result);',
|
||||
'};',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
console.log('wrote self-hosted occt-import-js-worker.js');
|
||||
|
||||
await patchO3dvFile('online-3d-viewer/build/engine/o3dv.module.js');
|
||||
await patchO3dvFile('online-3d-viewer/build/engine/o3dv.min.js');
|
||||
@@ -1,7 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { bindSessionPageHideOnce } from '@/composables/useSessionLifecycle';
|
||||
import {
|
||||
bindSessionPageHideOnce,
|
||||
hwpOpenToken,
|
||||
} from '@/composables/useSessionLifecycle';
|
||||
|
||||
bindSessionPageHideOnce();
|
||||
|
||||
@@ -12,15 +15,22 @@ const viewKey = computed(() => {
|
||||
if (route.name === 'onlyoffice-editor') {
|
||||
return `onlyoffice-${route.params.docId}`;
|
||||
}
|
||||
// Token changes on every staged HWP open so the view remounts with the new file.
|
||||
if (route.name === 'rhwp-editor') {
|
||||
return 'rhwp-editor';
|
||||
return `rhwp-${hwpOpenToken()}`;
|
||||
}
|
||||
if (
|
||||
route.name === 'excalidraw-editor' ||
|
||||
route.name === 'mermaid-editor' ||
|
||||
route.name === 'drawio-editor' ||
|
||||
route.name === 'cad-viewer' ||
|
||||
route.name === 'model3d-viewer'
|
||||
route.name === 'model3d-viewer' ||
|
||||
route.name === 'illustrator-viewer' ||
|
||||
route.name === 'photoshop-viewer' ||
|
||||
route.name === 'markdown-viewer' ||
|
||||
route.name === 'tools-scanner' ||
|
||||
route.name === 'tools-bg-remove' ||
|
||||
route.name === 'tools-image-edit'
|
||||
) {
|
||||
return String(route.name);
|
||||
}
|
||||
@@ -33,12 +43,17 @@ const viewKey = computed(() => {
|
||||
<KeepAlive
|
||||
:include="[
|
||||
'OnlyOfficeEditorView',
|
||||
'RhwpEditorView',
|
||||
'ExcalidrawEditorView',
|
||||
'MermaidEditorView',
|
||||
'DrawioEditorView',
|
||||
'CadViewerView',
|
||||
'Model3DViewerView',
|
||||
'IllustratorViewerView',
|
||||
'PhotoshopViewerView',
|
||||
'MarkdownViewerView',
|
||||
'ToolsScannerView',
|
||||
'ToolsBgRemoveView',
|
||||
'ToolsImageEditView',
|
||||
]"
|
||||
:max="10"
|
||||
>
|
||||
|
||||
@@ -35,12 +35,20 @@ export async function createSession(): Promise<{
|
||||
}
|
||||
|
||||
export async function heartbeat(): Promise<void> {
|
||||
await fetch('/api/sessions/heartbeat', {
|
||||
const res = await fetch('/api/sessions/heartbeat', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new ApiError(
|
||||
res.status,
|
||||
'session_required',
|
||||
'세션이 만료되었거나 없습니다.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export async function endSession(): Promise<void> {
|
||||
await fetch('/api/sessions', {
|
||||
method: 'DELETE',
|
||||
@@ -116,7 +124,3 @@ export async function fetchEditorConfig(
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function downloadDocumentUrl(docId: string): string {
|
||||
return `/api/documents/${docId}/download`;
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
@@ -0,0 +1,156 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, watch } from 'vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
open?: boolean;
|
||||
title?: string;
|
||||
lead?: string;
|
||||
steps?: string[];
|
||||
pathHint?: string;
|
||||
}>(),
|
||||
{
|
||||
open: false,
|
||||
title: '다운로드 방법',
|
||||
lead: '',
|
||||
steps: () => [],
|
||||
pathHint: '',
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [];
|
||||
}>();
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (!props.open) return;
|
||||
if (e.key === 'Escape') emit('close');
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', onKeydown);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', onKeydown);
|
||||
if (typeof document !== 'undefined') {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (typeof document === 'undefined') return;
|
||||
document.body.style.overflow = open ? 'hidden' : '';
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="open"
|
||||
class="guide"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="download-guide-title"
|
||||
@click.self="emit('close')"
|
||||
>
|
||||
<div class="guide__panel">
|
||||
<h2 id="download-guide-title" class="guide__title">{{ title }}</h2>
|
||||
<p v-if="lead" class="guide__lead">{{ lead }}</p>
|
||||
<ol v-if="steps.length" class="guide__steps">
|
||||
<li v-for="(step, i) in steps" :key="i">{{ step }}</li>
|
||||
</ol>
|
||||
<p v-if="pathHint" class="guide__path" aria-hidden="true">
|
||||
{{ pathHint }}
|
||||
</p>
|
||||
<div class="guide__actions">
|
||||
<button type="button" class="guide__btn" @click="emit('close')">
|
||||
확인
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.guide {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1.25rem;
|
||||
background: rgb(0 0 0 / 45%);
|
||||
}
|
||||
|
||||
.guide__panel {
|
||||
width: min(420px, 100%);
|
||||
padding: 1.25rem 1.35rem 1.1rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
box-shadow: 0 12px 40px rgb(0 0 0 / 18%);
|
||||
}
|
||||
|
||||
.guide__title {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.guide__lead {
|
||||
margin: 0 0 0.85rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--ink-soft);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.guide__steps {
|
||||
margin: 0 0 0.85rem;
|
||||
padding-left: 1.2rem;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.55;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.guide__steps li + li {
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.guide__path {
|
||||
margin: 0 0 1.1rem;
|
||||
padding: 0.55rem 0.7rem;
|
||||
border-radius: 6px;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.guide__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.guide__btn {
|
||||
font: inherit;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
padding: 0.45rem 1rem;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.guide__btn:hover {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
</style>
|
||||
@@ -9,7 +9,7 @@ withDefaults(
|
||||
busy?: boolean;
|
||||
}>(),
|
||||
{
|
||||
saveLabel: '저장',
|
||||
saveLabel: '다운로드',
|
||||
showSave: true,
|
||||
showExportActions: false,
|
||||
showFilename: true,
|
||||
@@ -163,10 +163,21 @@ defineEmits<{
|
||||
@media (max-width: 640px) {
|
||||
.toolbar {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
min-height: 48px;
|
||||
padding: 0.4rem 0.55rem;
|
||||
}
|
||||
|
||||
.toolbar__title {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.toolbar__link {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.toolbar__btn {
|
||||
min-height: 2.5rem;
|
||||
padding: 0.4rem 0.75rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import {
|
||||
HWP_ICON_URL,
|
||||
ICON_COLORS,
|
||||
ICON_MARK_PATHS,
|
||||
ICON_PATHS,
|
||||
iconKindForExtension,
|
||||
} from '@/config/favicons';
|
||||
@@ -11,28 +13,65 @@ const props = withDefaults(
|
||||
extension: string;
|
||||
size?: number;
|
||||
}>(),
|
||||
{ size: 40 },
|
||||
{ size: 36 },
|
||||
);
|
||||
|
||||
const kind = computed(() => iconKindForExtension(props.extension.toLowerCase()));
|
||||
const mark = computed(() => ICON_MARK_PATHS[kind.value]);
|
||||
const boxStyle = computed(() => ({
|
||||
width: `${props.size}px`,
|
||||
height: `${props.size}px`,
|
||||
}));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="ft-icon" :style="boxStyle" aria-hidden="true">
|
||||
<img
|
||||
v-if="kind === 'hwp'"
|
||||
class="ft-icon__img"
|
||||
:src="HWP_ICON_URL"
|
||||
alt=""
|
||||
draggable="false"
|
||||
/>
|
||||
<svg
|
||||
class="ft-icon"
|
||||
v-else
|
||||
class="ft-icon__svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
:width="size"
|
||||
:height="size"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path :fill="ICON_COLORS[kind]" :d="ICON_PATHS[kind]" />
|
||||
<path
|
||||
v-if="mark"
|
||||
fill="#ffffff"
|
||||
fill-rule="evenodd"
|
||||
:d="mark"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ft-icon {
|
||||
flex-shrink: 0;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.ft-icon__svg,
|
||||
.ft-icon__img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Raster HWP logo is full-bleed; match SVG document inset (~3/24). */
|
||||
.ft-icon__img {
|
||||
object-fit: contain;
|
||||
padding: 12.5%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,285 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount } from 'vue';
|
||||
import {
|
||||
clampCrop,
|
||||
type PixelRect,
|
||||
} from '@/integrations/imageedit/process';
|
||||
|
||||
defineOptions({ name: 'CropEditor' });
|
||||
|
||||
const props = defineProps<{
|
||||
src: string;
|
||||
crop: PixelRect;
|
||||
imageWidth: number;
|
||||
imageHeight: number;
|
||||
aspect: number | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:crop': [PixelRect];
|
||||
}>();
|
||||
|
||||
type Handle = 'move' | 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw';
|
||||
|
||||
const handles = computed<Handle[]>(() =>
|
||||
props.aspect
|
||||
? ['nw', 'ne', 'se', 'sw']
|
||||
: ['n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw'],
|
||||
);
|
||||
|
||||
const boxStyle = computed(() => {
|
||||
const w = props.imageWidth || 1;
|
||||
const h = props.imageHeight || 1;
|
||||
return {
|
||||
left: `${(props.crop.x / w) * 100}%`,
|
||||
top: `${(props.crop.y / h) * 100}%`,
|
||||
width: `${(props.crop.width / w) * 100}%`,
|
||||
height: `${(props.crop.height / h) * 100}%`,
|
||||
};
|
||||
});
|
||||
|
||||
let drag: {
|
||||
handle: Handle;
|
||||
pointerId: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
start: PixelRect;
|
||||
displayScale: number;
|
||||
} | null = null;
|
||||
|
||||
function displayScaleFromEvent(event: PointerEvent) {
|
||||
const stage = (event.currentTarget as HTMLElement).closest('.crop');
|
||||
const img = stage?.querySelector('img');
|
||||
if (!img || !props.imageWidth) return 1;
|
||||
return img.clientWidth / props.imageWidth;
|
||||
}
|
||||
|
||||
function bindWindow(on: boolean) {
|
||||
if (on) {
|
||||
window.addEventListener('pointermove', onPointerMove);
|
||||
window.addEventListener('pointerup', endDrag);
|
||||
window.addEventListener('pointercancel', endDrag);
|
||||
} else {
|
||||
window.removeEventListener('pointermove', onPointerMove);
|
||||
window.removeEventListener('pointerup', endDrag);
|
||||
window.removeEventListener('pointercancel', endDrag);
|
||||
}
|
||||
}
|
||||
|
||||
function beginDrag(handle: Handle, event: PointerEvent) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
|
||||
bindWindow(false);
|
||||
drag = {
|
||||
handle,
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
start: { ...props.crop },
|
||||
displayScale: displayScaleFromEvent(event),
|
||||
};
|
||||
bindWindow(true);
|
||||
}
|
||||
|
||||
function applyAspectFromFixedCorner(
|
||||
next: PixelRect,
|
||||
fixed: { x: number; y: number },
|
||||
preferWidth: boolean,
|
||||
) {
|
||||
const aspect = props.aspect;
|
||||
if (!aspect) return next;
|
||||
if (preferWidth) {
|
||||
next.height = next.width / aspect;
|
||||
} else {
|
||||
next.width = next.height * aspect;
|
||||
}
|
||||
next.x = fixed.x < next.x + next.width ? fixed.x : fixed.x - next.width;
|
||||
next.y = fixed.y < next.y + next.height ? fixed.y : fixed.y - next.height;
|
||||
if (next.width < 0) {
|
||||
next.x += next.width;
|
||||
next.width = Math.abs(next.width);
|
||||
}
|
||||
if (next.height < 0) {
|
||||
next.y += next.height;
|
||||
next.height = Math.abs(next.height);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function onPointerMove(event: PointerEvent) {
|
||||
if (!drag || event.pointerId !== drag.pointerId) return;
|
||||
const scale = drag.displayScale || 1;
|
||||
const dx = (event.clientX - drag.startX) / scale;
|
||||
const dy = (event.clientY - drag.startY) / scale;
|
||||
const start = drag.start;
|
||||
let next: PixelRect = { ...start };
|
||||
const handle = drag.handle;
|
||||
const aspect = props.aspect;
|
||||
|
||||
if (handle === 'move') {
|
||||
next.x = start.x + dx;
|
||||
next.y = start.y + dy;
|
||||
emit(
|
||||
'update:crop',
|
||||
clampCrop(next, props.imageWidth, props.imageHeight),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const se = { x: start.x + start.width, y: start.y + start.height };
|
||||
const nw = { x: start.x, y: start.y };
|
||||
|
||||
if (handle.includes('e') || handle === 'e') next.width = start.width + dx;
|
||||
if (handle.includes('s') || handle === 's') next.height = start.height + dy;
|
||||
if (handle.includes('w') || handle === 'w') {
|
||||
next.x = start.x + dx;
|
||||
next.width = start.width - dx;
|
||||
}
|
||||
if (handle.includes('n') || handle === 'n') {
|
||||
next.y = start.y + dy;
|
||||
next.height = start.height - dy;
|
||||
}
|
||||
|
||||
if (aspect) {
|
||||
if (handle === 'se') {
|
||||
next = applyAspectFromFixedCorner(next, nw, Math.abs(dx) >= Math.abs(dy));
|
||||
next.x = nw.x;
|
||||
next.y = nw.y;
|
||||
} else if (handle === 'nw') {
|
||||
next = applyAspectFromFixedCorner(next, se, Math.abs(dx) >= Math.abs(dy));
|
||||
next.x = se.x - next.width;
|
||||
next.y = se.y - next.height;
|
||||
} else if (handle === 'ne') {
|
||||
const fixed = { x: start.x, y: se.y };
|
||||
next = applyAspectFromFixedCorner(next, fixed, Math.abs(dx) >= Math.abs(dy));
|
||||
next.x = start.x;
|
||||
next.y = se.y - next.height;
|
||||
} else if (handle === 'sw') {
|
||||
const fixed = { x: se.x, y: start.y };
|
||||
next = applyAspectFromFixedCorner(next, fixed, Math.abs(dx) >= Math.abs(dy));
|
||||
next.x = se.x - next.width;
|
||||
next.y = start.y;
|
||||
}
|
||||
}
|
||||
|
||||
emit('update:crop', clampCrop(next, props.imageWidth, props.imageHeight));
|
||||
}
|
||||
|
||||
function endDrag(event: PointerEvent) {
|
||||
if (!drag || event.pointerId !== drag.pointerId) return;
|
||||
drag = null;
|
||||
bindWindow(false);
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => bindWindow(false));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="crop">
|
||||
<img :src="src" alt="편집할 이미지" draggable="false" />
|
||||
<div class="crop__shade" aria-hidden="true" />
|
||||
<div
|
||||
class="crop__box"
|
||||
:style="boxStyle"
|
||||
@pointerdown="beginDrag('move', $event)"
|
||||
>
|
||||
<span
|
||||
v-for="handle in handles"
|
||||
:key="handle"
|
||||
class="crop__handle"
|
||||
:class="`crop__handle--${handle}`"
|
||||
@pointerdown="beginDrag(handle, $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.crop {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
line-height: 0;
|
||||
overflow: hidden;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.crop img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: min(70vh, 44rem);
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.crop__shade {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.crop__box {
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
border: 2px solid var(--accent);
|
||||
box-shadow: 0 0 0 9999px rgb(0 0 0 / 42%);
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.crop__handle {
|
||||
position: absolute;
|
||||
width: 0.8rem;
|
||||
height: 0.8rem;
|
||||
background: var(--surface);
|
||||
border: 2px solid var(--accent);
|
||||
border-radius: 1px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.crop__handle--nw {
|
||||
left: -0.4rem;
|
||||
top: -0.4rem;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
.crop__handle--ne {
|
||||
right: -0.4rem;
|
||||
top: -0.4rem;
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
.crop__handle--se {
|
||||
right: -0.4rem;
|
||||
bottom: -0.4rem;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
.crop__handle--sw {
|
||||
left: -0.4rem;
|
||||
bottom: -0.4rem;
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
.crop__handle--n {
|
||||
left: 50%;
|
||||
top: -0.4rem;
|
||||
transform: translateX(-50%);
|
||||
cursor: ns-resize;
|
||||
}
|
||||
.crop__handle--s {
|
||||
left: 50%;
|
||||
bottom: -0.4rem;
|
||||
transform: translateX(-50%);
|
||||
cursor: ns-resize;
|
||||
}
|
||||
.crop__handle--e {
|
||||
right: -0.4rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
cursor: ew-resize;
|
||||
}
|
||||
.crop__handle--w {
|
||||
left: -0.4rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
cursor: ew-resize;
|
||||
}
|
||||
</style>
|
||||
@@ -35,6 +35,11 @@ import {
|
||||
meshInstancePropertyGroups,
|
||||
modelPropertyGroups,
|
||||
} from '@/integrations/model3d/o3dv';
|
||||
import {
|
||||
createPointCloudFromPly,
|
||||
isNoFacesImportError,
|
||||
isPlyFileName,
|
||||
} from '@/integrations/model3d/pointCloudPly';
|
||||
|
||||
const props = defineProps<{
|
||||
files: readonly File[];
|
||||
@@ -67,6 +72,8 @@ const host = ref<HTMLElement | null>(null);
|
||||
let viewer: OV.Viewer | null = null;
|
||||
let loader: OV.ThreeModelLoader | null = null;
|
||||
let model: OV.Model | null = null;
|
||||
let pointCloudMode = false;
|
||||
let pointCloudGroups: ModelPropertyGroup[] = [];
|
||||
let canvas: HTMLCanvasElement | null = null;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
let loadSequence = 0;
|
||||
@@ -98,7 +105,7 @@ function applyEnvironment() {
|
||||
}
|
||||
|
||||
function applyEdges(enabled: boolean) {
|
||||
if (!viewer) return;
|
||||
if (!viewer || pointCloudMode) return;
|
||||
viewer.SetEdgeSettings(
|
||||
new OV.EdgeSettings(enabled, new OV.RGBColor(0, 0, 0), 15),
|
||||
);
|
||||
@@ -119,7 +126,7 @@ function applyUp(axis: 'y' | 'z') {
|
||||
}
|
||||
|
||||
function fitCamera() {
|
||||
if (!viewer || !model) return;
|
||||
if (!viewer || (!model && !pointCloudMode)) return;
|
||||
const sphere = viewer.GetBoundingSphere(() => true);
|
||||
if (!sphere) return;
|
||||
viewer.AdjustClippingPlanesToSphere(sphere);
|
||||
@@ -159,7 +166,7 @@ function addMeasureLine(a: Vector3, b: Vector3) {
|
||||
}
|
||||
|
||||
function applyVisibility() {
|
||||
if (!viewer) return;
|
||||
if (!viewer || pointCloudMode) return;
|
||||
viewer.SetMeshesVisibility((userData: {
|
||||
originalMeshInstance?: OV.MeshInstance;
|
||||
}) => {
|
||||
@@ -170,7 +177,7 @@ function applyVisibility() {
|
||||
}
|
||||
|
||||
function applyHighlight() {
|
||||
if (!viewer) return;
|
||||
if (!viewer || pointCloudMode) return;
|
||||
viewer.SetMeshesHighlight(new OV.RGBColor(255, 200, 0), (userData: {
|
||||
originalMeshInstance?: OV.MeshInstance;
|
||||
}) => {
|
||||
@@ -180,6 +187,10 @@ function applyHighlight() {
|
||||
}
|
||||
|
||||
function selectByKey(key: string | null) {
|
||||
if (pointCloudMode) {
|
||||
emit('select', null, pointCloudGroups);
|
||||
return;
|
||||
}
|
||||
if (!model) {
|
||||
emit('select', null, []);
|
||||
return;
|
||||
@@ -191,9 +202,10 @@ function selectByKey(key: string | null) {
|
||||
}
|
||||
|
||||
function onMouseClick(_button: number, mouseCoords: { x: number; y: number }) {
|
||||
if (!viewer || !model) return;
|
||||
if (!viewer || (!model && !pointCloudMode)) return;
|
||||
|
||||
if (props.measureMode) {
|
||||
if (pointCloudMode) return;
|
||||
const hit = viewer.GetMeshIntersectionUnderMouse(
|
||||
OV.IntersectionMode.MeshOnly,
|
||||
mouseCoords,
|
||||
@@ -224,6 +236,11 @@ function onMouseClick(_button: number, mouseCoords: { x: number; y: number }) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pointCloudMode) {
|
||||
selectByKey(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const userData = viewer.GetMeshUserDataUnderMouse(
|
||||
OV.IntersectionMode.MeshOnly,
|
||||
mouseCoords,
|
||||
@@ -232,11 +249,33 @@ function onMouseClick(_button: number, mouseCoords: { x: number; y: number }) {
|
||||
selectByKey(key);
|
||||
}
|
||||
|
||||
async function loadPointCloudPly(file: File, sequence: number) {
|
||||
emit('progress', 0.4, '포인트 클라우드 읽는 중');
|
||||
const result = await createPointCloudFromPly(file);
|
||||
if (sequence !== loadSequence || !viewer) return;
|
||||
pointCloudMode = true;
|
||||
pointCloudGroups = result.groups;
|
||||
model = null;
|
||||
viewer.Clear();
|
||||
viewer.SetMainObject(result.object);
|
||||
applyProjection(props.projection);
|
||||
applyUp(props.upAxis);
|
||||
void nextTick(() => {
|
||||
if (sequence !== loadSequence) return;
|
||||
fitCamera();
|
||||
emit('progress', 1, '포인트 클라우드 준비 완료');
|
||||
emit('loaded', result.stats, result.tree, result.groups, []);
|
||||
selectByKey(null);
|
||||
});
|
||||
}
|
||||
|
||||
async function load(files: readonly File[]) {
|
||||
const sequence = ++loadSequence;
|
||||
if (!viewer || !loader) return;
|
||||
clearMeasure();
|
||||
model = null;
|
||||
pointCloudMode = false;
|
||||
pointCloudGroups = [];
|
||||
viewer.Clear();
|
||||
if (!files.length) return;
|
||||
|
||||
@@ -263,6 +302,8 @@ async function load(files: readonly File[]) {
|
||||
onTextureLoaded: () => viewer?.Render(),
|
||||
onModelFinished: (importResult: OV.ImportResult, threeObject: unknown) => {
|
||||
if (sequence !== loadSequence) return;
|
||||
pointCloudMode = false;
|
||||
pointCloudGroups = [];
|
||||
viewer?.SetMainObject(threeObject);
|
||||
model = importResult.model;
|
||||
applyVisibility();
|
||||
@@ -272,6 +313,7 @@ async function load(files: readonly File[]) {
|
||||
applyUp(props.upAxis);
|
||||
void nextTick(() => {
|
||||
fitCamera();
|
||||
emit('progress', 1, '모델 준비 완료');
|
||||
emit(
|
||||
'loaded',
|
||||
summarizeModel(importResult.model),
|
||||
@@ -279,13 +321,26 @@ async function load(files: readonly File[]) {
|
||||
modelPropertyGroups(importResult.model),
|
||||
importResult.missingFiles ?? [],
|
||||
);
|
||||
emit('progress', 1, '모델 준비 완료');
|
||||
selectByKey(null);
|
||||
});
|
||||
},
|
||||
onLoadError: (importError: OV.ImportError) => {
|
||||
if (sequence !== loadSequence) return;
|
||||
emit('error', importErrorMessage(importError));
|
||||
const message = importErrorMessage(importError);
|
||||
const ply = files.find((file) => isPlyFileName(file.name));
|
||||
if (ply && isNoFacesImportError(message)) {
|
||||
void loadPointCloudPly(ply, sequence).catch((reason) => {
|
||||
if (sequence !== loadSequence) return;
|
||||
emit(
|
||||
'error',
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: '포인트 클라우드 PLY를 열지 못했습니다.',
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
emit('error', message);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
<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 LOUPE_SIZE = 128;
|
||||
const LOUPE_ZOOM = 2.8;
|
||||
|
||||
/** 드래그 중인 코너 반대편에 루페를 두어 손가락에 가리지 않게 함 */
|
||||
const LOUPE_CORNER: Record<
|
||||
keyof CornerPoints,
|
||||
{ x: 'left' | 'right'; y: 'top' | 'bottom' }
|
||||
> = {
|
||||
topLeftCorner: { x: 'right', y: 'bottom' },
|
||||
topRightCorner: { x: 'left', y: 'bottom' },
|
||||
bottomRightCorner: { x: 'left', y: 'top' },
|
||||
bottomLeftCorner: { x: 'right', y: 'top' },
|
||||
};
|
||||
|
||||
const host = ref<HTMLElement | null>(null);
|
||||
const imageEl = ref<HTMLImageElement | null>(null);
|
||||
const display = ref({ width: 0, height: 0, naturalWidth: 1, naturalHeight: 1 });
|
||||
const dragging = ref<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(' ');
|
||||
});
|
||||
|
||||
const loupeStyle = computed(() => {
|
||||
const key = dragging.value;
|
||||
if (!key || display.value.width < 8) return null;
|
||||
|
||||
const point = props.corners[key];
|
||||
const anchor = LOUPE_CORNER[key];
|
||||
const margin = 10;
|
||||
const left =
|
||||
anchor.x === 'left' ? margin : display.value.width - LOUPE_SIZE - margin;
|
||||
const top =
|
||||
anchor.y === 'top' ? margin : display.value.height - LOUPE_SIZE - margin;
|
||||
|
||||
return {
|
||||
width: `${LOUPE_SIZE}px`,
|
||||
height: `${LOUPE_SIZE}px`,
|
||||
left: `${left}px`,
|
||||
top: `${top}px`,
|
||||
backgroundImage: `url(${props.imageUrl})`,
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundSize: `${display.value.naturalWidth * LOUPE_ZOOM}px ${display.value.naturalHeight * LOUPE_ZOOM}px`,
|
||||
backgroundPosition: `${LOUPE_SIZE / 2 - point.x * LOUPE_ZOOM}px ${LOUPE_SIZE / 2 - point.y * LOUPE_ZOOM}px`,
|
||||
};
|
||||
});
|
||||
|
||||
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.value = key;
|
||||
(event.target as HTMLElement).setPointerCapture?.(event.pointerId);
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
function onPointerMove(event: PointerEvent) {
|
||||
if (!dragging.value || !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.value]: nextPoint,
|
||||
});
|
||||
}
|
||||
|
||||
function onPointerUp() {
|
||||
dragging.value = 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"
|
||||
:class="{ 'is-active': dragging === handle.key }"
|
||||
:style="{ left: `${handle.left}px`, top: `${handle.top}px` }"
|
||||
:disabled="disabled"
|
||||
:aria-label="handle.key"
|
||||
@pointerdown="onPointerDown(handle.key, $event)"
|
||||
/>
|
||||
<div
|
||||
v-if="loupeStyle"
|
||||
class="corner-editor__loupe"
|
||||
:style="loupeStyle"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span class="corner-editor__loupe-cross" />
|
||||
</div>
|
||||
</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.is-active {
|
||||
transform: scale(1.15);
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.corner-editor__handle:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.corner-editor__loupe {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
box-sizing: border-box;
|
||||
border: 2px solid white;
|
||||
border-radius: 50%;
|
||||
box-shadow:
|
||||
0 0 0 1px color-mix(in srgb, black 35%, transparent),
|
||||
0 10px 28px color-mix(in srgb, black 28%, transparent);
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
background-color: color-mix(in srgb, var(--bg, #111) 80%, black);
|
||||
}
|
||||
|
||||
.corner-editor__loupe-cross {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.corner-editor__loupe-cross::before,
|
||||
.corner-editor__loupe-cross::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
background: color-mix(in srgb, white 88%, transparent);
|
||||
box-shadow: 0 0 0 0.5px color-mix(in srgb, black 40%, transparent);
|
||||
}
|
||||
|
||||
.corner-editor__loupe-cross::before {
|
||||
left: 50%;
|
||||
top: 18%;
|
||||
bottom: 18%;
|
||||
width: 1.5px;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.corner-editor__loupe-cross::after {
|
||||
top: 50%;
|
||||
left: 18%;
|
||||
right: 18%;
|
||||
height: 1.5px;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.corner-editor__image {
|
||||
max-height: min(52vh, 520px);
|
||||
}
|
||||
|
||||
.corner-editor__handle {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin: -14px 0 0 -14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,11 @@
|
||||
let pending: File | null = null;
|
||||
|
||||
export function stageImageEditFile(file: File) {
|
||||
pending = file;
|
||||
}
|
||||
|
||||
export function consumeImageEditFile(): File | null {
|
||||
const file = pending;
|
||||
pending = null;
|
||||
return file;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { ref } from 'vue';
|
||||
|
||||
/** Nested dragenter/leave safe file drop handlers for a page root. */
|
||||
export function useFileDropZone(onFiles: (files: File[]) => void) {
|
||||
const dropActive = ref(false);
|
||||
let depth = 0;
|
||||
|
||||
function onDragEnter(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
depth += 1;
|
||||
dropActive.value = true;
|
||||
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
|
||||
}
|
||||
|
||||
function onDragOver(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
|
||||
}
|
||||
|
||||
function onDragLeave(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
depth = Math.max(0, depth - 1);
|
||||
if (depth === 0) dropActive.value = false;
|
||||
}
|
||||
|
||||
function onDrop(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
depth = 0;
|
||||
dropActive.value = false;
|
||||
const files = Array.from(event.dataTransfer?.files ?? []);
|
||||
if (files.length) onFiles(files);
|
||||
}
|
||||
|
||||
return {
|
||||
dropActive,
|
||||
onDragEnter,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onDrop,
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
diagramKindFor,
|
||||
isHwp,
|
||||
isOffice,
|
||||
toolKindFor,
|
||||
viewerKindFor,
|
||||
} from '@/config/app';
|
||||
import {
|
||||
@@ -18,6 +19,8 @@ import {
|
||||
storeHwpFile,
|
||||
} from '@/composables/useSessionLifecycle';
|
||||
import { stageDiagramFile } from '@/composables/diagramSession';
|
||||
import { stageImageEditFile } from '@/composables/imageEditSession';
|
||||
import { stageScannerFiles } from '@/composables/scannerSession';
|
||||
import { stageViewerFiles } from '@/composables/viewerSession';
|
||||
|
||||
export function useOpenDocument() {
|
||||
@@ -37,6 +40,7 @@ export function useOpenDocument() {
|
||||
files.find(
|
||||
(candidate) =>
|
||||
viewerKindFor(candidate.name) ||
|
||||
toolKindFor(candidate.name) ||
|
||||
diagramKindFor(candidate.name) ||
|
||||
isHwp(candidate.name) ||
|
||||
isOffice(candidate.name),
|
||||
@@ -53,6 +57,19 @@ export function useOpenDocument() {
|
||||
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;
|
||||
}
|
||||
if (toolKind === 'imageedit') {
|
||||
stageImageEditFile(file);
|
||||
await router.push({ name: 'tools-image-edit' });
|
||||
return true;
|
||||
}
|
||||
|
||||
const diagramKind = diagramKindFor(file.name);
|
||||
if (diagramKind) {
|
||||
stageDiagramFile(diagramKind, file);
|
||||
@@ -69,7 +86,7 @@ export function useOpenDocument() {
|
||||
|
||||
if (!isOffice(file.name)) {
|
||||
error.value =
|
||||
'지원하지 않는 형식입니다. 오피스, HWP 또는 다이어그램 파일을 선택해 주세요.';
|
||||
'지원하지 않는 형식입니다. 오피스, HWP, 다이어그램, CAD/3D 또는 이미지를 선택해 주세요.';
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,27 +6,44 @@ import {
|
||||
type MaybeRefOrGetter,
|
||||
} from 'vue';
|
||||
import { APP_DISPLAY_NAME } from '@/config/app';
|
||||
import { faviconHrefFor, setFavicon } from '@/config/favicons';
|
||||
import {
|
||||
faviconHrefFor,
|
||||
faviconHrefForKind,
|
||||
setFavicon,
|
||||
type FaviconKind,
|
||||
} from '@/config/favicons';
|
||||
|
||||
export function formatPageTitle(filename?: string | null): string {
|
||||
const name = filename?.trim();
|
||||
return name ? `${name} - ${APP_DISPLAY_NAME}` : APP_DISPLAY_NAME;
|
||||
}
|
||||
|
||||
export type UsePageTitleOptions = {
|
||||
/** Force favicon kind (tools whose file extensions collide with other tools). */
|
||||
faviconKind?: MaybeRefOrGetter<FaviconKind | null | undefined>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sets `document.title` to `filename - Sejong Office` (or just the app name)
|
||||
* and swaps the favicon by file extension (home icon when no file).
|
||||
* Pass `faviconKind` when the route should keep a fixed tool/viewer icon.
|
||||
*/
|
||||
export function usePageTitle(
|
||||
filename: MaybeRefOrGetter<string | null | undefined> = null,
|
||||
options?: UsePageTitleOptions,
|
||||
) {
|
||||
function apply() {
|
||||
const name = toValue(filename);
|
||||
document.title = formatPageTitle(name);
|
||||
setFavicon(faviconHrefFor(name));
|
||||
const kind = options?.faviconKind != null ? toValue(options.faviconKind) : null;
|
||||
setFavicon(kind ? faviconHrefForKind(kind) : faviconHrefFor(name));
|
||||
}
|
||||
|
||||
watch(() => toValue(filename), apply, { immediate: true });
|
||||
watch(
|
||||
() => [toValue(filename), toValue(options?.faviconKind)] as const,
|
||||
apply,
|
||||
{ immediate: true },
|
||||
);
|
||||
onMounted(apply);
|
||||
onActivated(apply);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { onMounted, onUnmounted } from 'vue';
|
||||
import { createSession, endSession, heartbeat } from '@/api/client';
|
||||
|
||||
const HWP_FILE_KEY = 'sejong_hwp_file';
|
||||
const HWP_OPEN_TOKEN_KEY = 'sejong_hwp_open_token';
|
||||
|
||||
let pageHideBound = false;
|
||||
|
||||
@@ -31,6 +32,16 @@ export function storeHwpFile(file: File): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
/** Bumps whenever a HWP/HWPX is staged for the editor (forces a fresh view). */
|
||||
export function hwpOpenToken(): string {
|
||||
if (typeof sessionStorage === 'undefined') return '0';
|
||||
return sessionStorage.getItem(HWP_OPEN_TOKEN_KEY) ?? '0';
|
||||
}
|
||||
|
||||
function bumpHwpOpenToken(): void {
|
||||
sessionStorage.setItem(HWP_OPEN_TOKEN_KEY, `${Date.now()}`);
|
||||
}
|
||||
|
||||
export function storeHwpBuffer(name: string, buffer: ArrayBuffer): void {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = '';
|
||||
@@ -45,6 +56,7 @@ export function storeHwpBuffer(name: string, buffer: ArrayBuffer): void {
|
||||
base64: btoa(binary),
|
||||
}),
|
||||
);
|
||||
bumpHwpOpenToken();
|
||||
}
|
||||
|
||||
export function loadHwpFile(): { name: string; buffer: ArrayBuffer } | null {
|
||||
@@ -68,6 +80,7 @@ export function loadHwpFile(): { name: string; buffer: ArrayBuffer } | null {
|
||||
|
||||
export function clearHwpFile(): void {
|
||||
sessionStorage.removeItem(HWP_FILE_KEY);
|
||||
sessionStorage.removeItem(HWP_OPEN_TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function useSessionLifecycle(options?: { enableHeartbeat?: boolean }) {
|
||||
|
||||
@@ -6,7 +6,8 @@ export const APP_NAME = "sejong-office";
|
||||
export const APP_DISPLAY_NAME = "Sejong Office";
|
||||
|
||||
export type DiagramKind = "excalidraw" | "mermaid" | "drawio";
|
||||
export type ViewerKind = "cad" | "model3d";
|
||||
export type ViewerKind = "cad" | "model3d" | "illustrator" | "photoshop" | "markdown";
|
||||
export type ToolKind = "scanner" | "imageedit";
|
||||
|
||||
const DIAGRAM_EXTENSION_KIND: Record<string, DiagramKind> = {
|
||||
excalidraw: "excalidraw",
|
||||
@@ -16,6 +17,26 @@ const DIAGRAM_EXTENSION_KIND: Record<string, DiagramKind> = {
|
||||
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",
|
||||
ico: "imageedit",
|
||||
};
|
||||
|
||||
/** Online3DViewer import formats (+ zip container). */
|
||||
export const MODEL3D_EXTENSIONS = [
|
||||
"3dm",
|
||||
@@ -46,6 +67,11 @@ export const MODEL3D_EXTENSIONS = [
|
||||
const VIEWER_EXTENSION_KIND: Record<string, ViewerKind> = {
|
||||
dxf: "cad",
|
||||
dwg: "cad",
|
||||
ai: "illustrator",
|
||||
psd: "photoshop",
|
||||
md: "markdown",
|
||||
markdown: "markdown",
|
||||
mdown: "markdown",
|
||||
...Object.fromEntries(
|
||||
MODEL3D_EXTENSIONS.map((ext) => [ext, "model3d" as const]),
|
||||
),
|
||||
@@ -95,3 +121,11 @@ export function diagramKindFor(filename: string): DiagramKind | null {
|
||||
export function viewerKindFor(filename: string): ViewerKind | 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));
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { extensionOf } from '@/config/app';
|
||||
import hwpIconUrl from '@/assets/icons/hwp.png';
|
||||
|
||||
export type FaviconKind =
|
||||
| 'home'
|
||||
@@ -17,41 +18,89 @@ export type FaviconKind =
|
||||
| 'diagram'
|
||||
| 'cad'
|
||||
| 'model3d'
|
||||
| 'illustrator'
|
||||
| 'photoshop'
|
||||
| 'scanner'
|
||||
| 'bgremove'
|
||||
| 'imgedit'
|
||||
| 'markdown'
|
||||
| 'text'
|
||||
| 'csv'
|
||||
| 'file';
|
||||
|
||||
/** Shared filled document plate for letter-mark office icons. */
|
||||
export const DOC_SHELL_PATH =
|
||||
'M19,3H5C3.89,3 3,3.89 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5C21,3.89 20.1,3 19,3Z';
|
||||
|
||||
export const ICON_PATHS: Record<FaviconKind, string> = {
|
||||
home: 'M9,13H15V19H18V10L12,5.5L6,10V19H9V13M4,21V9L12,3L20,9V21H4Z',
|
||||
pdf: 'M19 3H5C3.9 3 3 3.9 3 5V19C3 20.1 3.9 21 5 21H19C20.1 21 21 20.1 21 19V5C21 3.9 20.1 3 19 3M9.5 11.5C9.5 12.3 8.8 13 8 13H7V15H5.5V9H8C8.8 9 9.5 9.7 9.5 10.5V11.5M14.5 13.5C14.5 14.3 13.8 15 13 15H10.5V9H13C13.8 9 14.5 9.7 14.5 10.5V13.5M18.5 10.5H17V11.5H18.5V13H17V15H15.5V9H18.5V10.5M12 10.5H13V13.5H12V10.5M7 10.5H8V11.5H7V10.5Z',
|
||||
word: 'M15.5,17H14L12,9.5L10,17H8.5L6.1,7H7.8L9.34,14.5L11.3,7H12.7L14.67,14.5L16.2,7H17.9M19,3H5C3.89,3 3,3.89 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5C21,3.89 20.1,3 19,3Z',
|
||||
excel:
|
||||
'M16.2,17H14.2L12,13.2L9.8,17H7.8L11,12L7.8,7H9.8L12,10.8L14.2,7H16.2L13,12M19,3H5C3.89,3 3,3.89 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5C21,3.89 20.1,3 19,3Z',
|
||||
powerpoint:
|
||||
'M9.8,13.4H12.3C13.8,13.4 14.46,13.12 15.1,12.58C15.74,12.03 16,11.25 16,10.23C16,9.26 15.75,8.5 15.1,7.88C14.45,7.29 13.83,7 12.3,7H8V17H9.8V13.4M19,3A2,2 0 0,1 21,5V19A2,2 0 0,1 19,21H5A2,2 0 0,1 3,19V5C3,3.89 3.9,3 5,3H19M9.8,12V8.4H12.1C12.76,8.4 13.27,8.65 13.6,9C13.93,9.35 14.1,9.72 14.1,10.24C14.1,10.8 13.92,11.19 13.6,11.5C13.28,11.81 12.9,12 12.22,12H9.8Z',
|
||||
hwp: 'M17,4V10L15,8L13,10V4H9V20H19V4H17M3,7V5H5V4C5,2.89 5.9,2 7,2H19C20.05,2 21,2.95 21,4V20C21,21.05 20.05,22 19,22H7C5.95,22 5,21.05 5,20V19H3V17H5V13H3V11H5V7H3M5,5V7H7V5H5M5,19H7V17H5V19M5,13H7V11H5V13Z',
|
||||
pdf: DOC_SHELL_PATH,
|
||||
word: DOC_SHELL_PATH,
|
||||
excel: DOC_SHELL_PATH,
|
||||
powerpoint: DOC_SHELL_PATH,
|
||||
hwp: DOC_SHELL_PATH,
|
||||
sketch: 'M3 17.25V21H6.75L17.81 9.94L14.06 6.19L3 17.25M20.71 7.04C21.1 6.65 21.1 6 20.71 5.63L18.37 3.29C18 2.9 17.35 2.9 16.96 3.29L15.13 5.12L18.88 8.87L20.71 7.04Z',
|
||||
code: 'M8 3L6.59 4.41L10.17 8L6.59 11.59L8 13L13 8L8 3M14 11V13H22V11H14M5 19H19V17H5V19Z',
|
||||
diagram: 'M4 3H10V9H8V11H16V9H14V3H20V9H18V13H13V15H16V21H8V15H11V13H6V9H4V3M6 5V7H8V5H6M16 5V7H18V5H16M10 17V19H14V17H10Z',
|
||||
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',
|
||||
/** mdi:vector-polyline — Illustrator-style mark */
|
||||
illustrator:
|
||||
'M2,9V15H6.5L9.5,19H14.5L17.5,15H22V9H17.5L14.5,5H9.5L6.5,9H2M9.5,7.5L12,11L14.5,7.5H9.5M6.5,10.5L8.5,13.5H5V10.5H6.5M18.5,13.5L16.5,10.5H19V13.5H18.5M12,12.5L9.5,16.5H14.5L12,12.5Z',
|
||||
/** mdi:image — Photoshop-style mark */
|
||||
photoshop:
|
||||
'M19,19H5V5H19M19,3H5A2,2 0 0,0 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5A2,2 0 0,0 19,3M13.96,12.29L11.21,15.83L9.25,13.47L6.5,17H17.5L13.96,12.29Z',
|
||||
scanner: 'M4 4H10V6H6V10H4V4M14 4H20V10H18V6H14V4M4 14H6V18H10V20H4V14M18 14H20V20H14V18H18V14M8 8H16V16H8V8Z',
|
||||
bgremove:
|
||||
'M19.36,2.72L20.78,4.14L15.06,9.85C16.13,11.39 16.28,13.24 15.38,14.44L9.06,8.12C10.26,7.22 12.11,7.37 13.65,8.44L19.36,2.72M5.93,17.57C3.92,15.56 2.69,13.16 2.35,10.92L7.23,8.83L14.67,16.27L12.58,21.15C10.34,20.81 7.94,19.58 5.93,17.57Z',
|
||||
/** mdi:crop */
|
||||
imgedit:
|
||||
'M7,17V1H5V5H1V7H5V17A2,2 0 0,0 7,19H17V23H19V19H23V17H19V7H7V17M17,17H7V7H17V17Z',
|
||||
/** mdi:language-markdown */
|
||||
markdown:
|
||||
'M20.56 18H3.44C2.65 18 2 17.37 2 16.59V7.41C2 6.63 2.65 6 3.44 6H20.56C21.35 6 22 6.63 22 7.41V16.59C22 17.37 21.35 18 20.56 18M6.81 15.19V11.53L8.73 13.88L10.66 11.53V15.19H12.31V8.81H10.66L8.73 11.16L6.81 8.81H5.16V15.19H6.81M15.19 15.19H19.5V13.84H16.84V12.5H19.5V11.16H16.84V9.81H19.5V8.81H15.19V15.19Z',
|
||||
text: 'M6,2A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2H6M6,4H13V9H18V20H6V4M8,12V14H16V12H8M8,16V18H13V16H8Z',
|
||||
csv: 'M14 2H6C4.9 2 4 2.9 4 4V20C4 21.1 4.9 22 6 22H18C19.1 22 20 21.1 20 20V8L14 2M18 20H6V4H13V9H18V20M10 19L12 15H9V10H15V15L13 19H10',
|
||||
file: 'M6,2A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2H6M6,4H13V9H18V20H6V4M8,12V14H16V12H8M8,16V18H13V16H8Z',
|
||||
};
|
||||
|
||||
/**
|
||||
* White glyphs drawn on `DOC_SHELL_PATH` (evenodd for letter counters).
|
||||
* Paths adapted from Material Design Icons (@mdi/svg), Apache-2.0.
|
||||
*/
|
||||
export const ICON_MARK_PATHS: Partial<Record<FaviconKind, string>> = {
|
||||
pdf:
|
||||
'M9.5,11.5C9.5,12.3 8.8,13 8,13H7V15H5.5V9H8C8.8,9 9.5,9.7 9.5,10.5V11.5M7,10.5H8V11.5H7V10.5Z' +
|
||||
'M14.5,13.5C14.5,14.3 13.8,15 13,15H10.5V9H13C13.8,9 14.5,9.7 14.5,10.5V13.5M12,10.5H13V13.5H12V10.5Z' +
|
||||
'M18.5,10.5H17V11.5H18.5V13H17V15H15.5V9H18.5V10.5Z',
|
||||
word: 'M15.5,17H14L12,9.5L10,17H8.5L6.1,7H7.8L9.34,14.5L11.3,7H12.7L14.67,14.5L16.2,7H17.9Z',
|
||||
excel:
|
||||
'M16.2,17H14.2L12,13.2L9.8,17H7.8L11,12L7.8,7H9.8L12,10.8L14.2,7H16.2L13,12Z',
|
||||
powerpoint:
|
||||
'M9.8,13.4H12.3C13.8,13.4 14.46,13.12 15.1,12.58C15.74,12.03 16,11.25 16,10.23C16,9.26 15.75,8.5 15.1,7.88C14.45,7.29 13.83,7 12.3,7H8V17H9.8V13.4M9.8,12V8.4H12.1C12.76,8.4 13.27,8.65 13.6,9C13.93,9.35 14.1,9.72 14.1,10.24C14.1,10.8 13.92,11.19 13.6,11.5C13.28,11.81 12.9,12 12.22,12H9.8Z',
|
||||
};
|
||||
|
||||
/** Raster logo for Hangul (HWP) documents. */
|
||||
export const HWP_ICON_URL = hwpIconUrl;
|
||||
|
||||
export const ICON_COLORS: Record<FaviconKind, string> = {
|
||||
home: '#0f6cbd',
|
||||
pdf: '#e53935',
|
||||
word: '#1e88e5',
|
||||
excel: '#43a047',
|
||||
powerpoint: '#fb8c00',
|
||||
hwp: '#00897b',
|
||||
hwp: '#34aee7',
|
||||
sketch: '#7b61a8',
|
||||
code: '#d97706',
|
||||
diagram: '#2e7d8a',
|
||||
cad: '#5c6bc0',
|
||||
model3d: '#6d4c41',
|
||||
illustrator: '#f57c00',
|
||||
photoshop: '#31a8ff',
|
||||
scanner: '#00897b',
|
||||
bgremove: '#5c6bc0',
|
||||
imgedit: '#c2185b',
|
||||
markdown: '#6f42c1',
|
||||
text: '#607d8b',
|
||||
csv: '#00838f',
|
||||
file: '#546e7a',
|
||||
@@ -82,6 +131,8 @@ const EXT_KIND: Record<string, FaviconKind> = {
|
||||
xml: 'diagram',
|
||||
dxf: 'cad',
|
||||
dwg: 'cad',
|
||||
ai: 'illustrator',
|
||||
psd: 'photoshop',
|
||||
'3dm': 'model3d',
|
||||
'3ds': 'model3d',
|
||||
'3mf': 'model3d',
|
||||
@@ -105,11 +156,35 @@ const EXT_KIND: Record<string, FaviconKind> = {
|
||||
ply: 'model3d',
|
||||
wrl: 'model3d',
|
||||
zip: 'model3d',
|
||||
png: 'scanner',
|
||||
jpg: 'scanner',
|
||||
jpeg: 'scanner',
|
||||
webp: 'scanner',
|
||||
gif: 'scanner',
|
||||
bmp: 'scanner',
|
||||
bgremove: 'bgremove',
|
||||
imgedit: 'imgedit',
|
||||
ico: 'imgedit',
|
||||
md: 'markdown',
|
||||
markdown: 'markdown',
|
||||
mdown: 'markdown',
|
||||
txt: 'text',
|
||||
};
|
||||
|
||||
export function iconSvgInner(kind: FaviconKind): string {
|
||||
const color = ICON_COLORS[kind];
|
||||
const mark = ICON_MARK_PATHS[kind];
|
||||
if (mark) {
|
||||
return (
|
||||
`<path fill="${color}" d="${ICON_PATHS[kind]}"/>` +
|
||||
`<path fill="#ffffff" fill-rule="evenodd" d="${mark}"/>`
|
||||
);
|
||||
}
|
||||
return `<path fill="${color}" d="${ICON_PATHS[kind]}"/>`;
|
||||
}
|
||||
|
||||
function toDataUri(kind: FaviconKind): string {
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="${ICON_COLORS[kind]}" d="${ICON_PATHS[kind]}"/></svg>`;
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">${iconSvgInner(kind)}</svg>`;
|
||||
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
|
||||
}
|
||||
|
||||
@@ -119,6 +194,16 @@ export function iconKindForExtension(ext: string): FaviconKind {
|
||||
return EXT_KIND[ext] ?? 'file';
|
||||
}
|
||||
|
||||
export function faviconHrefForKind(kind: FaviconKind): string {
|
||||
if (kind === 'hwp') return HWP_ICON_URL;
|
||||
let href = CACHE.get(kind);
|
||||
if (!href) {
|
||||
href = toDataUri(kind);
|
||||
CACHE.set(kind, href);
|
||||
}
|
||||
return href;
|
||||
}
|
||||
|
||||
export function faviconHrefFor(filename?: string | null): string {
|
||||
const name = filename?.trim();
|
||||
let kind: FaviconKind = 'home';
|
||||
@@ -126,12 +211,7 @@ export function faviconHrefFor(filename?: string | null): string {
|
||||
const ext = extensionOf(name);
|
||||
kind = ext ? (EXT_KIND[ext] ?? 'file') : 'home';
|
||||
}
|
||||
let href = CACHE.get(kind);
|
||||
if (!href) {
|
||||
href = toDataUri(kind);
|
||||
CACHE.set(kind, href);
|
||||
}
|
||||
return href;
|
||||
return faviconHrefForKind(kind);
|
||||
}
|
||||
|
||||
export function setFavicon(href: string) {
|
||||
@@ -141,7 +221,10 @@ export function setFavicon(href: string) {
|
||||
link.rel = 'icon';
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
link.type = 'image/svg+xml';
|
||||
link.type =
|
||||
/\.png(?:\?|$)/i.test(href) || href.startsWith('data:image/png')
|
||||
? 'image/png'
|
||||
: 'image/svg+xml';
|
||||
// Bust stubborn tab caches when switching between data URIs
|
||||
if (link.href !== href) {
|
||||
link.href = href;
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
export type BgRemoveMode = 'threshold' | 'flood';
|
||||
|
||||
export interface BgRemoveOptions {
|
||||
/** 0–100: how far from sampled background still counts as background */
|
||||
tolerance: number;
|
||||
/** 0–100: soft edge width beyond hard threshold */
|
||||
feather: number;
|
||||
mode: BgRemoveMode;
|
||||
}
|
||||
|
||||
export interface Rgb {
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_BG_REMOVE_OPTIONS: BgRemoveOptions = {
|
||||
tolerance: 28,
|
||||
feather: 12,
|
||||
mode: 'threshold',
|
||||
};
|
||||
|
||||
function clampByte(value: number) {
|
||||
return Math.min(255, Math.max(0, Math.round(value)));
|
||||
}
|
||||
|
||||
function colorDistance(a: Rgb, r: number, g: number, b: number) {
|
||||
return Math.max(Math.abs(r - a.r), Math.abs(g - a.g), Math.abs(b - a.b));
|
||||
}
|
||||
|
||||
function median(values: number[]) {
|
||||
if (!values.length) return 0;
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const mid = Math.floor(sorted.length / 2);
|
||||
return sorted.length % 2
|
||||
? sorted[mid]!
|
||||
: Math.round((sorted[mid - 1]! + sorted[mid]!) / 2);
|
||||
}
|
||||
|
||||
/** Sample corner neighborhoods and take per-channel median as background. */
|
||||
export function sampleCornerBackground(
|
||||
data: Uint8ClampedArray,
|
||||
width: number,
|
||||
height: number,
|
||||
radius = 2,
|
||||
): Rgb {
|
||||
const rs: number[] = [];
|
||||
const gs: number[] = [];
|
||||
const bs: number[] = [];
|
||||
const corners: Array<[number, number]> = [
|
||||
[0, 0],
|
||||
[width - 1, 0],
|
||||
[0, height - 1],
|
||||
[width - 1, height - 1],
|
||||
];
|
||||
|
||||
for (const [cx, cy] of corners) {
|
||||
for (let dy = -radius; dy <= radius; dy += 1) {
|
||||
for (let dx = -radius; dx <= radius; dx += 1) {
|
||||
const x = Math.min(width - 1, Math.max(0, cx + dx));
|
||||
const y = Math.min(height - 1, Math.max(0, cy + dy));
|
||||
const i = (y * width + x) * 4;
|
||||
rs.push(data[i]!);
|
||||
gs.push(data[i + 1]!);
|
||||
bs.push(data[i + 2]!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { r: median(rs), g: median(gs), b: median(bs) };
|
||||
}
|
||||
|
||||
function hardLimit(tolerance: number) {
|
||||
return Math.round((Math.min(100, Math.max(0, tolerance)) / 100) * 180);
|
||||
}
|
||||
|
||||
function featherWidth(feather: number) {
|
||||
return Math.round((Math.min(100, Math.max(0, feather)) / 100) * 80);
|
||||
}
|
||||
|
||||
function alphaForDistance(
|
||||
distance: number,
|
||||
hard: number,
|
||||
soft: number,
|
||||
originalAlpha: number,
|
||||
) {
|
||||
if (distance <= hard) return 0;
|
||||
if (soft <= 0 || distance >= hard + soft) return originalAlpha;
|
||||
const t = (distance - hard) / soft;
|
||||
return clampByte(originalAlpha * t);
|
||||
}
|
||||
|
||||
function loadImage(source: string | Blob | HTMLImageElement | HTMLCanvasElement) {
|
||||
if (source instanceof HTMLCanvasElement) {
|
||||
return Promise.resolve(source);
|
||||
}
|
||||
if (source instanceof HTMLImageElement) {
|
||||
if (source.complete && source.naturalWidth) return Promise.resolve(source);
|
||||
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
source.onload = () => resolve(source);
|
||||
source.onerror = () =>
|
||||
reject(new Error('이미지를 불러오지 못했습니다.'));
|
||||
});
|
||||
}
|
||||
|
||||
const objectUrl =
|
||||
typeof source === 'string' ? null : URL.createObjectURL(source);
|
||||
const src = typeof source === 'string' ? source : objectUrl!;
|
||||
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.onload = () => {
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
resolve(image);
|
||||
};
|
||||
image.onerror = () => {
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
reject(new Error('이미지를 불러오지 못했습니다.'));
|
||||
};
|
||||
image.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
function drawToCanvas(
|
||||
image: HTMLImageElement | HTMLCanvasElement,
|
||||
): HTMLCanvasElement {
|
||||
if (image instanceof HTMLCanvasElement) {
|
||||
const copy = document.createElement('canvas');
|
||||
copy.width = image.width;
|
||||
copy.height = image.height;
|
||||
const ctx = copy.getContext('2d', { willReadFrequently: true });
|
||||
if (!ctx) throw new Error('Canvas를 초기화하지 못했습니다.');
|
||||
ctx.drawImage(image, 0, 0);
|
||||
return copy;
|
||||
}
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = image.naturalWidth || image.width;
|
||||
canvas.height = image.naturalHeight || image.height;
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
if (!ctx) throw new Error('Canvas를 초기화하지 못했습니다.');
|
||||
ctx.drawImage(image, 0, 0);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
function applyThreshold(
|
||||
data: Uint8ClampedArray,
|
||||
bg: Rgb,
|
||||
hard: number,
|
||||
soft: number,
|
||||
) {
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const d = colorDistance(bg, data[i]!, data[i + 1]!, data[i + 2]!);
|
||||
data[i + 3] = alphaForDistance(d, hard, soft, data[i + 3]!);
|
||||
}
|
||||
}
|
||||
|
||||
function applyFlood(
|
||||
data: Uint8ClampedArray,
|
||||
width: number,
|
||||
height: number,
|
||||
bg: Rgb,
|
||||
hard: number,
|
||||
soft: number,
|
||||
) {
|
||||
const total = width * height;
|
||||
const match = new Uint8Array(total);
|
||||
const limit = hard + soft;
|
||||
for (let p = 0; p < total; p += 1) {
|
||||
const i = p * 4;
|
||||
const d = colorDistance(bg, data[i]!, data[i + 1]!, data[i + 2]!);
|
||||
if (d <= limit) match[p] = 1;
|
||||
}
|
||||
|
||||
const visited = new Uint8Array(total);
|
||||
const queue = new Int32Array(total);
|
||||
let head = 0;
|
||||
let tail = 0;
|
||||
|
||||
const seed = (x: number, y: number) => {
|
||||
const p = y * width + x;
|
||||
if (!match[p] || visited[p]) return;
|
||||
visited[p] = 1;
|
||||
queue[tail++] = p;
|
||||
};
|
||||
|
||||
seed(0, 0);
|
||||
seed(width - 1, 0);
|
||||
seed(0, height - 1);
|
||||
seed(width - 1, height - 1);
|
||||
|
||||
while (head < tail) {
|
||||
const p = queue[head++]!;
|
||||
const x = p % width;
|
||||
const y = (p / width) | 0;
|
||||
if (x > 0) seed(x - 1, y);
|
||||
if (x + 1 < width) seed(x + 1, y);
|
||||
if (y > 0) seed(x, y - 1);
|
||||
if (y + 1 < height) seed(x, y + 1);
|
||||
}
|
||||
|
||||
for (let p = 0; p < total; p += 1) {
|
||||
if (!visited[p]) continue;
|
||||
const i = p * 4;
|
||||
const d = colorDistance(bg, data[i]!, data[i + 1]!, data[i + 2]!);
|
||||
data[i + 3] = alphaForDistance(d, hard, soft, data[i + 3]!);
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeBackgroundBySample(
|
||||
source: string | Blob | HTMLImageElement | HTMLCanvasElement,
|
||||
options: Partial<BgRemoveOptions> = {},
|
||||
): Promise<{
|
||||
blob: Blob;
|
||||
url: string;
|
||||
width: number;
|
||||
height: number;
|
||||
background: Rgb;
|
||||
}> {
|
||||
const opts = { ...DEFAULT_BG_REMOVE_OPTIONS, ...options };
|
||||
const image = await loadImage(source);
|
||||
const canvas = drawToCanvas(image);
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
if (!ctx) throw new Error('Canvas를 초기화하지 못했습니다.');
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const { data, width, height } = imageData;
|
||||
const background = sampleCornerBackground(data, width, height);
|
||||
const hard = hardLimit(opts.tolerance);
|
||||
const soft = featherWidth(opts.feather);
|
||||
|
||||
if (opts.mode === 'flood') {
|
||||
applyFlood(data, width, height, background, hard, soft);
|
||||
} else {
|
||||
applyThreshold(data, background, hard, soft);
|
||||
}
|
||||
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
const blob = await new Promise<Blob>((resolve, reject) => {
|
||||
canvas.toBlob(
|
||||
(value) =>
|
||||
value
|
||||
? resolve(value)
|
||||
: reject(new Error('투명 PNG를 만들지 못했습니다.')),
|
||||
'image/png',
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
blob,
|
||||
url: URL.createObjectURL(blob),
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
background,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import {
|
||||
GlobalWorkerOptions,
|
||||
getDocument,
|
||||
RenderingCancelledException,
|
||||
type PDFDocumentProxy,
|
||||
type RenderTask,
|
||||
} from 'pdfjs-dist';
|
||||
|
||||
const WORKER_SRC = '/viewer-assets/scanner/pdfjs/pdf.worker.min.mjs';
|
||||
|
||||
let workerConfigured = false;
|
||||
let activeRender: RenderTask | null = null;
|
||||
/** Serialize canvas renders so pdf.js never sees overlapping render() calls. */
|
||||
let renderGate: Promise<void> = Promise.resolve();
|
||||
|
||||
function ensureWorker() {
|
||||
if (workerConfigured) return;
|
||||
GlobalWorkerOptions.workerSrc = WORKER_SRC;
|
||||
workerConfigured = true;
|
||||
}
|
||||
|
||||
/** PDF-compatible .ai embeds a PDF stream (often starts with %PDF). */
|
||||
export function looksPdfCompatibleAi(data: ArrayBuffer): boolean {
|
||||
const n = Math.min(data.byteLength, 8192);
|
||||
if (n < 5) return false;
|
||||
const text = new TextDecoder('latin1').decode(data.slice(0, n));
|
||||
return text.includes('%PDF');
|
||||
}
|
||||
|
||||
export const AI_INCOMPATIBLE_MESSAGE =
|
||||
'PDF 호환으로 저장된 .ai만 열 수 있습니다. Illustrator에서 「PDF 호환 파일 만들기」로 저장한 뒤 다시 열어 주세요.';
|
||||
|
||||
export function isAiRenderCancelled(reason: unknown): boolean {
|
||||
return (
|
||||
reason instanceof RenderingCancelledException ||
|
||||
(typeof reason === 'object' &&
|
||||
reason !== null &&
|
||||
'name' in reason &&
|
||||
(reason as { name: string }).name === 'RenderingCancelledException')
|
||||
);
|
||||
}
|
||||
|
||||
export async function cancelAiRender(): Promise<void> {
|
||||
const task = activeRender;
|
||||
if (!task) return;
|
||||
activeRender = null;
|
||||
task.cancel();
|
||||
try {
|
||||
await task.promise;
|
||||
} catch {
|
||||
/* cancelled or failed after cancel */
|
||||
}
|
||||
}
|
||||
|
||||
export async function openAiAsPdf(data: ArrayBuffer): Promise<PDFDocumentProxy> {
|
||||
ensureWorker();
|
||||
if (!looksPdfCompatibleAi(data)) {
|
||||
throw new Error(AI_INCOMPATIBLE_MESSAGE);
|
||||
}
|
||||
try {
|
||||
return await getDocument({ data: data.slice(0) }).promise;
|
||||
} catch {
|
||||
throw new Error(AI_INCOMPATIBLE_MESSAGE);
|
||||
}
|
||||
}
|
||||
|
||||
export async function renderAiPage(
|
||||
pdf: PDFDocumentProxy,
|
||||
pageNumber: number,
|
||||
canvas: HTMLCanvasElement,
|
||||
scale: number,
|
||||
): Promise<{ width: number; height: number }> {
|
||||
// Interrupt in-flight work so the gate holder can exit quickly.
|
||||
void cancelAiRender();
|
||||
|
||||
let release!: () => void;
|
||||
const previous = renderGate;
|
||||
renderGate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
await previous;
|
||||
|
||||
try {
|
||||
await cancelAiRender();
|
||||
|
||||
const page = await pdf.getPage(pageNumber);
|
||||
try {
|
||||
const viewport = page.getViewport({ scale });
|
||||
canvas.width = Math.max(1, Math.floor(viewport.width));
|
||||
canvas.height = Math.max(1, Math.floor(viewport.height));
|
||||
const context = canvas.getContext('2d', { alpha: false });
|
||||
if (!context) {
|
||||
throw new Error('캔버스를 초기화하지 못했습니다.');
|
||||
}
|
||||
context.fillStyle = '#ffffff';
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const task = page.render({ canvasContext: context, viewport, canvas });
|
||||
activeRender = task;
|
||||
try {
|
||||
await task.promise;
|
||||
} finally {
|
||||
if (activeRender === task) activeRender = null;
|
||||
}
|
||||
|
||||
return { width: canvas.width, height: canvas.height };
|
||||
} finally {
|
||||
page.cleanup();
|
||||
}
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
}
|
||||
|
||||
export function isAiFile(file: File): boolean {
|
||||
return /\.ai$/i.test(file.name) || file.type === 'application/postscript';
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
const PNG_SIG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
||||
|
||||
export function isIcoFile(file: File) {
|
||||
return (
|
||||
/image\/(x-icon|vnd\.microsoft\.icon|icon)/i.test(file.type) ||
|
||||
/\.ico$/i.test(file.name)
|
||||
);
|
||||
}
|
||||
|
||||
function isPng(bytes: Uint8Array) {
|
||||
return (
|
||||
bytes.length >= 8 && PNG_SIG.every((value, index) => bytes[index] === value)
|
||||
);
|
||||
}
|
||||
|
||||
function rowStride(width: number, bits: number) {
|
||||
return (((width * bits + 31) >> 5) << 2);
|
||||
}
|
||||
|
||||
function decodeDib(bytes: Uint8Array): ImageData {
|
||||
if (bytes.length < 40) {
|
||||
throw new Error('ICO 비트맵 헤더가 올바르지 않습니다.');
|
||||
}
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
const headerSize = view.getUint32(0, true);
|
||||
if (headerSize < 40) {
|
||||
throw new Error('지원하지 않는 ICO 비트맵입니다.');
|
||||
}
|
||||
const width = Math.abs(view.getInt32(4, true));
|
||||
const rawHeight = view.getInt32(8, true);
|
||||
const bitCount = view.getUint16(14, true);
|
||||
const compression = view.getUint32(16, true);
|
||||
if (!(width > 0) || compression !== 0) {
|
||||
throw new Error('압축된 ICO 비트맵은 열 수 없습니다.');
|
||||
}
|
||||
|
||||
const storedHeight = Math.abs(rawHeight) || 1;
|
||||
const bottomUp = rawHeight >= 0;
|
||||
const paletteColors =
|
||||
bitCount <= 8
|
||||
? view.getUint32(32, true) || 1 << bitCount
|
||||
: 0;
|
||||
let offset = headerSize + paletteColors * 4;
|
||||
const xorStride = rowStride(width, bitCount);
|
||||
const remaining = Math.max(0, bytes.length - offset);
|
||||
let height = storedHeight;
|
||||
if (storedHeight % 2 === 0) {
|
||||
const half = storedHeight / 2;
|
||||
const xorHalf = xorStride * half;
|
||||
const andHalf = rowStride(width, 1) * half;
|
||||
if (xorHalf + andHalf <= remaining || xorHalf <= remaining) {
|
||||
height = half;
|
||||
}
|
||||
}
|
||||
const xorSize = xorStride * height;
|
||||
if (!(height > 0) || offset + xorSize > bytes.length) {
|
||||
throw new Error('ICO 비트맵 데이터가 잘렸습니다.');
|
||||
}
|
||||
|
||||
const palette: Array<[number, number, number, number]> = [];
|
||||
for (let i = 0; i < paletteColors; i += 1) {
|
||||
const p = headerSize + i * 4;
|
||||
palette.push([bytes[p + 2]!, bytes[p + 1]!, bytes[p]!, 255]);
|
||||
}
|
||||
|
||||
const pixels = new Uint8ClampedArray(width * height * 4);
|
||||
const andOffset = offset + xorSize;
|
||||
const andStride = rowStride(width, 1);
|
||||
const hasAnd = andOffset + andStride * height <= bytes.length;
|
||||
|
||||
function xorPixel(x: number, y: number): [number, number, number, number] {
|
||||
const row = bottomUp ? height - 1 - y : y;
|
||||
const rowStart = offset + row * xorStride;
|
||||
if (bitCount === 32) {
|
||||
const i = rowStart + x * 4;
|
||||
return [bytes[i + 2]!, bytes[i + 1]!, bytes[i]!, bytes[i + 3]!];
|
||||
}
|
||||
if (bitCount === 24) {
|
||||
const i = rowStart + x * 3;
|
||||
return [bytes[i + 2]!, bytes[i + 1]!, bytes[i]!, 255];
|
||||
}
|
||||
if (bitCount === 8) {
|
||||
return palette[bytes[rowStart + x]!] ?? [0, 0, 0, 255];
|
||||
}
|
||||
if (bitCount === 4) {
|
||||
const byte = bytes[rowStart + (x >> 1)]!;
|
||||
const index = x & 1 ? byte & 0x0f : byte >> 4;
|
||||
return palette[index] ?? [0, 0, 0, 255];
|
||||
}
|
||||
if (bitCount === 1) {
|
||||
const byte = bytes[rowStart + (x >> 3)]!;
|
||||
const index = (byte >> (7 - (x & 7))) & 1;
|
||||
return palette[index] ?? [0, 0, 0, 255];
|
||||
}
|
||||
throw new Error(`지원하지 않는 ICO 색 깊이입니다 (${bitCount}bit).`);
|
||||
}
|
||||
|
||||
function andTransparent(x: number, y: number) {
|
||||
if (!hasAnd) return false;
|
||||
const row = bottomUp ? height - 1 - y : y;
|
||||
const byte = bytes[andOffset + row * andStride + (x >> 3)]!;
|
||||
return ((byte >> (7 - (x & 7))) & 1) === 1;
|
||||
}
|
||||
|
||||
let anyAlpha = false;
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
const [r, g, b, a] = xorPixel(x, y);
|
||||
if (a > 0 && a < 255) anyAlpha = true;
|
||||
if (a === 255) anyAlpha = anyAlpha || bitCount === 32;
|
||||
const i = (y * width + x) * 4;
|
||||
pixels[i] = r;
|
||||
pixels[i + 1] = g;
|
||||
pixels[i + 2] = b;
|
||||
pixels[i + 3] = a;
|
||||
}
|
||||
}
|
||||
|
||||
const useAnd = bitCount < 32 || !anyAlpha;
|
||||
if (useAnd) {
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
if (andTransparent(x, y)) {
|
||||
pixels[(y * width + x) * 4 + 3] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new ImageData(pixels, width, height);
|
||||
}
|
||||
|
||||
function canvasFromImageData(data: ImageData) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = data.width;
|
||||
canvas.height = data.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) throw new Error('캔버스를 만들 수 없습니다.');
|
||||
ctx.putImageData(data, 0, 0);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
function pngBlobFromCanvas(canvas: HTMLCanvasElement) {
|
||||
return new Promise<Blob>((resolve, reject) => {
|
||||
canvas.toBlob(
|
||||
(blob) =>
|
||||
blob ? resolve(blob) : reject(new Error('ICO를 PNG로 바꾸지 못했습니다.')),
|
||||
'image/png',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function wrapPngAsIco(png: ArrayBuffer, width: number, height: number) {
|
||||
const header = 22;
|
||||
const out = new Uint8Array(header + png.byteLength);
|
||||
const view = new DataView(out.buffer);
|
||||
view.setUint16(0, 0, true);
|
||||
view.setUint16(2, 1, true);
|
||||
view.setUint16(4, 1, true);
|
||||
out[6] = width >= 256 ? 0 : width;
|
||||
out[7] = height >= 256 ? 0 : height;
|
||||
view.setUint16(10, 1, true);
|
||||
view.setUint16(12, 32, true);
|
||||
view.setUint32(14, png.byteLength, true);
|
||||
view.setUint32(18, header, true);
|
||||
out.set(new Uint8Array(png), header);
|
||||
return new Blob([out], { type: 'image/x-icon' });
|
||||
}
|
||||
|
||||
async function decodeEntry(bytes: Uint8Array) {
|
||||
if (isPng(bytes)) {
|
||||
const blob = new Blob([bytes.slice()], { type: 'image/png' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
try {
|
||||
const image = new Image();
|
||||
image.decoding = 'async';
|
||||
image.src = url;
|
||||
await image.decode();
|
||||
return {
|
||||
blob,
|
||||
width: image.naturalWidth || image.width,
|
||||
height: image.naturalHeight || image.height,
|
||||
};
|
||||
} finally {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
const canvas = canvasFromImageData(decodeDib(bytes));
|
||||
const blob = await pngBlobFromCanvas(canvas);
|
||||
return { blob, width: canvas.width, height: canvas.height };
|
||||
}
|
||||
|
||||
/** Decode an ICO and return the largest image as a PNG blob. */
|
||||
export async function decodeIcoToPng(buffer: ArrayBuffer) {
|
||||
const view = new DataView(buffer);
|
||||
if (view.byteLength < 22) {
|
||||
throw new Error('ICO 파일이 너무 짧습니다.');
|
||||
}
|
||||
const reserved = view.getUint16(0, true);
|
||||
const type = view.getUint16(2, true);
|
||||
const count = view.getUint16(4, true);
|
||||
if (reserved !== 0 || (type !== 1 && type !== 2) || count < 1) {
|
||||
throw new Error('올바른 ICO 파일이 아닙니다.');
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let best: { score: number; offset: number; size: number } | null = null;
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const entry = 6 + i * 16;
|
||||
const width = view.getUint8(entry) || 256;
|
||||
const height = view.getUint8(entry + 1) || 256;
|
||||
const size = view.getUint32(entry + 8, true);
|
||||
const offset = view.getUint32(entry + 12, true);
|
||||
if (offset + size > buffer.byteLength) continue;
|
||||
const score = width * height + size;
|
||||
if (!best || score > best.score) {
|
||||
best = { score, offset, size };
|
||||
}
|
||||
}
|
||||
if (!best) {
|
||||
throw new Error('ICO에서 이미지를 찾지 못했습니다.');
|
||||
}
|
||||
|
||||
const slice = bytes.subarray(best.offset, best.offset + best.size);
|
||||
const decoded = await decodeEntry(slice);
|
||||
return { ...decoded, imageCount: count };
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
import { wrapPngAsIco } from './ico';
|
||||
|
||||
export type PixelRect = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export type ExportFormat = 'image/jpeg' | 'image/webp' | 'image/png' | 'image/x-icon';
|
||||
|
||||
export type RatioFit = 'crop' | 'pad';
|
||||
|
||||
const MIN_CROP = 8;
|
||||
|
||||
export function loadImageFromUrl(url: string) {
|
||||
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.decoding = 'async';
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () => reject(new Error('이미지를 불러오지 못했습니다.'));
|
||||
image.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
export function clampCrop(
|
||||
crop: PixelRect,
|
||||
imageWidth: number,
|
||||
imageHeight: number,
|
||||
): PixelRect {
|
||||
const maxW = Math.max(1, Math.floor(imageWidth));
|
||||
const maxH = Math.max(1, Math.floor(imageHeight));
|
||||
const width = Math.min(Math.max(MIN_CROP, Math.round(crop.width)), maxW);
|
||||
const height = Math.min(Math.max(MIN_CROP, Math.round(crop.height)), maxH);
|
||||
const x = Math.min(Math.max(0, Math.round(crop.x)), maxW - width);
|
||||
const y = Math.min(Math.max(0, Math.round(crop.y)), maxH - height);
|
||||
return { x, y, width, height };
|
||||
}
|
||||
|
||||
export function fullImageCrop(imageWidth: number, imageHeight: number): PixelRect {
|
||||
return clampCrop(
|
||||
{ x: 0, y: 0, width: imageWidth, height: imageHeight },
|
||||
imageWidth,
|
||||
imageHeight,
|
||||
);
|
||||
}
|
||||
|
||||
/** Largest rectangle of `aspect` (width/height) that fits in the image. */
|
||||
export function maxCropForAspect(
|
||||
imageWidth: number,
|
||||
imageHeight: number,
|
||||
aspect: number,
|
||||
center?: { x: number; y: number },
|
||||
): PixelRect {
|
||||
if (!(aspect > 0) || !Number.isFinite(aspect)) {
|
||||
return fullImageCrop(imageWidth, imageHeight);
|
||||
}
|
||||
let width = imageWidth;
|
||||
let height = width / aspect;
|
||||
if (height > imageHeight) {
|
||||
height = imageHeight;
|
||||
width = height * aspect;
|
||||
}
|
||||
const cx = center?.x ?? imageWidth / 2;
|
||||
const cy = center?.y ?? imageHeight / 2;
|
||||
return clampCrop(
|
||||
{ x: cx - width / 2, y: cy - height / 2, width, height },
|
||||
imageWidth,
|
||||
imageHeight,
|
||||
);
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number) {
|
||||
if (!Number.isFinite(bytes) || bytes < 0) return '—';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
export function extensionForFormat(format: ExportFormat) {
|
||||
if (format === 'image/jpeg') return 'jpg';
|
||||
if (format === 'image/webp') return 'webp';
|
||||
if (format === 'image/x-icon') return 'ico';
|
||||
return 'png';
|
||||
}
|
||||
|
||||
export function clampPixelSize(value: number) {
|
||||
return Math.min(8192, Math.max(1, Math.round(Number(value)) || 1));
|
||||
}
|
||||
|
||||
function outputSize(
|
||||
srcWidth: number,
|
||||
srcHeight: number,
|
||||
scale: number,
|
||||
maxEdge: number | null,
|
||||
padAspect: number | null,
|
||||
targetWidth?: number | null,
|
||||
targetHeight?: number | null,
|
||||
) {
|
||||
let width = Math.max(1, srcWidth);
|
||||
let height = Math.max(1, srcHeight);
|
||||
|
||||
if (padAspect && padAspect > 0) {
|
||||
const current = width / height;
|
||||
if (current < padAspect) {
|
||||
width = height * padAspect;
|
||||
} else if (current > padAspect) {
|
||||
height = width / padAspect;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
targetWidth != null &&
|
||||
targetHeight != null &&
|
||||
targetWidth > 0 &&
|
||||
targetHeight > 0
|
||||
) {
|
||||
width = targetWidth;
|
||||
height = targetHeight;
|
||||
} else {
|
||||
const factor = Math.min(8, Math.max(0.01, scale));
|
||||
width *= factor;
|
||||
height *= factor;
|
||||
}
|
||||
|
||||
if (maxEdge && maxEdge > 0) {
|
||||
const cap = Math.min(1, maxEdge / width, maxEdge / height);
|
||||
width *= cap;
|
||||
height *= cap;
|
||||
}
|
||||
|
||||
return {
|
||||
width: clampPixelSize(width),
|
||||
height: clampPixelSize(height),
|
||||
};
|
||||
}
|
||||
|
||||
export function previewOutputSize(options: {
|
||||
crop: PixelRect;
|
||||
scale: number;
|
||||
maxEdge: number | null;
|
||||
padAspect: number | null;
|
||||
fit: RatioFit;
|
||||
targetWidth?: number | null;
|
||||
targetHeight?: number | null;
|
||||
}) {
|
||||
const padAspect = options.fit === 'pad' ? options.padAspect : null;
|
||||
return outputSize(
|
||||
options.crop.width,
|
||||
options.crop.height,
|
||||
options.scale,
|
||||
options.maxEdge,
|
||||
padAspect,
|
||||
options.targetWidth,
|
||||
options.targetHeight,
|
||||
);
|
||||
}
|
||||
|
||||
function canvasToBlob(
|
||||
canvas: HTMLCanvasElement,
|
||||
format: ExportFormat,
|
||||
quality: number,
|
||||
) {
|
||||
const mime = format === 'image/x-icon' ? 'image/png' : format;
|
||||
return new Promise<Blob>((resolve, reject) => {
|
||||
const q =
|
||||
mime === 'image/png' ? undefined : Math.min(1, Math.max(0.01, quality));
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (!blob) {
|
||||
reject(new Error('이미지를 인코딩하지 못했습니다.'));
|
||||
return;
|
||||
}
|
||||
resolve(blob);
|
||||
},
|
||||
mime,
|
||||
q,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function parseCssColor(color: string): { r: number; g: number; b: number } {
|
||||
const hex = color.trim();
|
||||
const short = /^#([0-9a-f]{3})$/i.exec(hex);
|
||||
if (short) {
|
||||
const n = short[1];
|
||||
return {
|
||||
r: Number.parseInt(n[0] + n[0], 16),
|
||||
g: Number.parseInt(n[1] + n[1], 16),
|
||||
b: Number.parseInt(n[2] + n[2], 16),
|
||||
};
|
||||
}
|
||||
const full = /^#([0-9a-f]{6})$/i.exec(hex);
|
||||
if (full) {
|
||||
const n = full[1];
|
||||
return {
|
||||
r: Number.parseInt(n.slice(0, 2), 16),
|
||||
g: Number.parseInt(n.slice(2, 4), 16),
|
||||
b: Number.parseInt(n.slice(4, 6), 16),
|
||||
};
|
||||
}
|
||||
return { r: 255, g: 255, b: 255 };
|
||||
}
|
||||
|
||||
export async function exportEditedImage(options: {
|
||||
image: HTMLImageElement;
|
||||
crop: PixelRect;
|
||||
scale: number;
|
||||
maxEdge: number | null;
|
||||
format: ExportFormat;
|
||||
quality: number;
|
||||
fit: RatioFit;
|
||||
padAspect: number | null;
|
||||
padColor: string;
|
||||
targetWidth?: number | null;
|
||||
targetHeight?: number | null;
|
||||
lockAspect?: boolean;
|
||||
}): Promise<{ blob: Blob; width: number; height: number }> {
|
||||
const crop = clampCrop(
|
||||
options.crop,
|
||||
options.image.naturalWidth || options.image.width,
|
||||
options.image.naturalHeight || options.image.height,
|
||||
);
|
||||
const padAspect =
|
||||
options.fit === 'pad' && options.padAspect && options.padAspect > 0
|
||||
? options.padAspect
|
||||
: null;
|
||||
const size = outputSize(
|
||||
crop.width,
|
||||
crop.height,
|
||||
options.scale,
|
||||
options.maxEdge,
|
||||
padAspect,
|
||||
options.targetWidth,
|
||||
options.targetHeight,
|
||||
);
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = size.width;
|
||||
canvas.height = size.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) throw new Error('캔버스를 만들 수 없습니다.');
|
||||
|
||||
if (options.format === 'image/jpeg' || padAspect) {
|
||||
const { r, g, b } = parseCssColor(options.padColor);
|
||||
ctx.fillStyle = `rgb(${r}, ${g}, ${b})`;
|
||||
ctx.fillRect(0, 0, size.width, size.height);
|
||||
} else {
|
||||
ctx.clearRect(0, 0, size.width, size.height);
|
||||
}
|
||||
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
|
||||
const letterbox = Boolean(padAspect) && options.lockAspect !== false;
|
||||
if (letterbox) {
|
||||
const fitted = containSize(crop.width, crop.height, size.width, size.height);
|
||||
const dx = Math.round((size.width - fitted.width) / 2);
|
||||
const dy = Math.round((size.height - fitted.height) / 2);
|
||||
ctx.drawImage(
|
||||
options.image,
|
||||
crop.x,
|
||||
crop.y,
|
||||
crop.width,
|
||||
crop.height,
|
||||
dx,
|
||||
dy,
|
||||
fitted.width,
|
||||
fitted.height,
|
||||
);
|
||||
} else {
|
||||
ctx.drawImage(
|
||||
options.image,
|
||||
crop.x,
|
||||
crop.y,
|
||||
crop.width,
|
||||
crop.height,
|
||||
0,
|
||||
0,
|
||||
size.width,
|
||||
size.height,
|
||||
);
|
||||
}
|
||||
|
||||
const blob = await canvasToBlob(canvas, options.format, options.quality);
|
||||
if (options.format === 'image/x-icon') {
|
||||
return {
|
||||
blob: wrapPngAsIco(await blob.arrayBuffer(), size.width, size.height),
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
};
|
||||
}
|
||||
return { blob, width: size.width, height: size.height };
|
||||
}
|
||||
|
||||
function containSize(
|
||||
srcW: number,
|
||||
srcH: number,
|
||||
boxW: number,
|
||||
boxH: number,
|
||||
) {
|
||||
const scale = Math.min(boxW / srcW, boxH / srcH);
|
||||
return {
|
||||
width: Math.max(1, Math.round(srcW * scale)),
|
||||
height: Math.max(1, Math.round(srcH * scale)),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { marked } from 'marked';
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
marked.setOptions({
|
||||
gfm: true,
|
||||
breaks: true,
|
||||
});
|
||||
|
||||
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
|
||||
if (node instanceof HTMLAnchorElement) {
|
||||
node.setAttribute('target', '_blank');
|
||||
node.setAttribute('rel', 'noopener noreferrer');
|
||||
}
|
||||
});
|
||||
|
||||
export function renderMarkdown(source: string): string {
|
||||
const html = marked.parse(source ?? '', { async: false });
|
||||
return DOMPurify.sanitize(typeof html === 'string' ? html : '', {
|
||||
USE_PROFILES: { html: true },
|
||||
});
|
||||
}
|
||||
|
||||
export function isMarkdownFile(file: File) {
|
||||
return (
|
||||
file.type === 'text/markdown' ||
|
||||
file.type === 'text/x-markdown' ||
|
||||
/\.(?:md|markdown|mdown)$/i.test(file.name)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
BufferGeometry,
|
||||
Color,
|
||||
Group,
|
||||
Points,
|
||||
PointsMaterial,
|
||||
} from 'three';
|
||||
import { PLYLoader } from 'three/examples/jsm/loaders/PLYLoader.js';
|
||||
import type { ModelPropertyGroup, ModelStats, ModelTreeNode } from './types';
|
||||
|
||||
export function isPlyFileName(name: string) {
|
||||
return /\.ply$/i.test(name);
|
||||
}
|
||||
|
||||
export function isNoFacesImportError(message: string) {
|
||||
return /no faces/i.test(message);
|
||||
}
|
||||
|
||||
export async function createPointCloudFromPly(file: File): Promise<{
|
||||
object: Group;
|
||||
pointCount: number;
|
||||
hasColors: boolean;
|
||||
stats: ModelStats;
|
||||
tree: ModelTreeNode;
|
||||
groups: ModelPropertyGroup[];
|
||||
}> {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const geometry = new PLYLoader().parse(buffer) as BufferGeometry;
|
||||
const position = geometry.getAttribute('position');
|
||||
if (!position || position.count < 1) {
|
||||
throw new Error('PLY에 표시할 포인트가 없습니다.');
|
||||
}
|
||||
|
||||
geometry.computeBoundingSphere();
|
||||
const hasColors = Boolean(geometry.getAttribute('color'));
|
||||
const radius = geometry.boundingSphere?.radius || 1;
|
||||
const material = new PointsMaterial({
|
||||
size: Math.max(radius / 250, 0.0005),
|
||||
sizeAttenuation: true,
|
||||
vertexColors: hasColors,
|
||||
color: hasColors ? new Color(0xffffff) : new Color(0x4fc3f7),
|
||||
});
|
||||
|
||||
const points = new Points(geometry, material);
|
||||
points.name = file.name;
|
||||
const object = new Group();
|
||||
object.name = file.name;
|
||||
object.add(points);
|
||||
|
||||
const pointCount = position.count;
|
||||
const stats: ModelStats = {
|
||||
nodes: 1,
|
||||
meshes: 0,
|
||||
meshInstances: 0,
|
||||
vertices: pointCount,
|
||||
triangles: 0,
|
||||
materials: 1,
|
||||
};
|
||||
const tree: ModelTreeNode = {
|
||||
id: 'pointcloud-root',
|
||||
name: file.name,
|
||||
type: '포인트 클라우드',
|
||||
children: [
|
||||
{
|
||||
id: 'pointcloud-points',
|
||||
name: `포인트 ${pointCount.toLocaleString()}개`,
|
||||
type: 'points',
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
const groups: ModelPropertyGroup[] = [
|
||||
{
|
||||
name: '포인트 클라우드',
|
||||
properties: [
|
||||
{ name: '파일', value: file.name },
|
||||
{ name: '포인트', value: pointCount.toLocaleString() },
|
||||
{ name: '정점 색', value: hasColors ? '있음' : '없음' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return { object, pointCount, hasColors, stats, tree, groups };
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { readPsd, type Psd } from 'ag-psd';
|
||||
|
||||
export const PSD_INCOMPATIBLE_MESSAGE =
|
||||
'이 브라우저에서는 RGB·Grayscale·Bitmap / 8bit .psd만 안정적으로 볼 수 있습니다. CMYK·16bit·PSB는 지원하지 않습니다.';
|
||||
|
||||
export type OpenedPsd = {
|
||||
width: number;
|
||||
height: number;
|
||||
/** Composite image produced by ag-psd */
|
||||
canvas: HTMLCanvasElement;
|
||||
};
|
||||
|
||||
export function isPsdFile(file: File): boolean {
|
||||
return (
|
||||
/\.psd$/i.test(file.name) || file.type === 'image/vnd.adobe.photoshop'
|
||||
);
|
||||
}
|
||||
|
||||
export function openPsd(data: ArrayBuffer): OpenedPsd {
|
||||
let psd: Psd;
|
||||
try {
|
||||
psd = readPsd(data);
|
||||
} catch {
|
||||
throw new Error(PSD_INCOMPATIBLE_MESSAGE);
|
||||
}
|
||||
|
||||
const source = psd.canvas;
|
||||
if (!source || source.width < 1 || source.height < 1) {
|
||||
throw new Error(PSD_INCOMPATIBLE_MESSAGE);
|
||||
}
|
||||
|
||||
return {
|
||||
width: psd.width || source.width,
|
||||
height: psd.height || source.height,
|
||||
canvas: source as HTMLCanvasElement,
|
||||
};
|
||||
}
|
||||
|
||||
export function drawPsdToCanvas(
|
||||
source: HTMLCanvasElement,
|
||||
target: HTMLCanvasElement,
|
||||
scale: number,
|
||||
): { width: number; height: number } {
|
||||
const width = Math.max(1, Math.floor(source.width * scale));
|
||||
const height = Math.max(1, Math.floor(source.height * scale));
|
||||
target.width = width;
|
||||
target.height = height;
|
||||
const context = target.getContext('2d', { alpha: true });
|
||||
if (!context) {
|
||||
throw new Error('캔버스를 초기화하지 못했습니다.');
|
||||
}
|
||||
context.clearRect(0, 0, width, height);
|
||||
context.imageSmoothingEnabled = true;
|
||||
context.imageSmoothingQuality = 'high';
|
||||
context.drawImage(source, 0, 0, width, height);
|
||||
return { width, height };
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
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 });
|
||||
}
|
||||
|
||||
/** 스캔 페이지의 긴 변을 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로 내보낼 페이지가 없습니다.');
|
||||
}
|
||||
|
||||
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 = 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: pageWidth,
|
||||
height: pageHeight,
|
||||
});
|
||||
|
||||
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;
|
||||
// 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,
|
||||
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,110 @@
|
||||
import * as ort from 'onnxruntime-web';
|
||||
import ortWasmUrl from 'onnxruntime-web/ort-wasm-simd-threaded.jsep.wasm?url';
|
||||
import ortMjsUrl from 'onnxruntime-web/ort-wasm-simd-threaded.jsep.mjs?url';
|
||||
import {
|
||||
PaddleOcrService,
|
||||
V5_KOREAN_MOBILE_MODEL,
|
||||
} from 'ppu-paddle-ocr/web';
|
||||
import type { OcrResult, OcrWord } from './types';
|
||||
|
||||
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 모델 준비 중');
|
||||
// Vite는 /public .mjs 를 import() 하면 실패하므로 node_modules 에셋 URL 사용
|
||||
ort.env.wasm.numThreads = 1;
|
||||
ort.env.wasm.wasmPaths = {
|
||||
wasm: ortWasmUrl,
|
||||
mjs: ortMjsUrl,
|
||||
};
|
||||
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,45 @@
|
||||
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 type ScanMode = 'original' | 'color' | 'gray' | 'bw';
|
||||
|
||||
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;
|
||||
scanMode: ScanMode;
|
||||
/** 글자 진하기·선명도 0–100 (흑백은 이진화 문턱) */
|
||||
scanIntensity: number;
|
||||
warpedUrl: string | null;
|
||||
warpedWidth: number;
|
||||
warpedHeight: number;
|
||||
ocr: OcrResult | null;
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
import { ensureOpenCv, loadImageFromUrl } from './detect';
|
||||
import type { CornerPoints, PaperAspect, ScanMode } from './types';
|
||||
|
||||
/** 0 = 은은하게 · 100 = 글자를 아주 진하고 선명하게 (조명 평탄화는 강도와 무관하게 항상) */
|
||||
export const DEFAULT_SCAN_INTENSITY = 60;
|
||||
export const DEFAULT_SCAN_MODE: ScanMode = 'color';
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 원근을 고려한 실제 용지 가로/세로 비.
|
||||
* 비스듬히 찍으면 먼 쪽 변이 짧아져 변 길이만으로는 비율이 크게 틀어진다(늘어난 듯한 결과).
|
||||
* Zhang & He, "Whiteboard scanning and image enhancement"(2007) — 주점은 사진 중앙으로 가정.
|
||||
* 정면에 가깝거나 값이 불안정하면 변 길이 평균으로 대신한다.
|
||||
*/
|
||||
export function estimateAspect(
|
||||
c: CornerPoints,
|
||||
imageWidth: number,
|
||||
imageHeight: number,
|
||||
) {
|
||||
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);
|
||||
}
|
||||
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,
|
||||
) {
|
||||
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 };
|
||||
}
|
||||
|
||||
/** 원근 보정 (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,
|
||||
);
|
||||
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 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 {
|
||||
topLeftCorner: f(c.topLeftCorner),
|
||||
topRightCorner: f(c.topRightCorner),
|
||||
bottomRightCorner: f(c.bottomRightCorner),
|
||||
bottomLeftCorner: f(c.bottomLeftCorner),
|
||||
};
|
||||
}
|
||||
|
||||
export async function warpImage(
|
||||
sourceUrl: string,
|
||||
corners: CornerPoints,
|
||||
aspect: PaperAspect,
|
||||
scanIntensity: number = DEFAULT_SCAN_INTENSITY,
|
||||
scanMode: ScanMode = DEFAULT_SCAN_MODE,
|
||||
) {
|
||||
await ensureOpenCv();
|
||||
const cv: Cv = window.cv;
|
||||
const image = await loadImageFromUrl(sourceUrl);
|
||||
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();
|
||||
}
|
||||
|
||||
// 흑백은 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('보정 이미지를 만들지 못했습니다.')),
|
||||
type,
|
||||
0.92,
|
||||
);
|
||||
});
|
||||
return {
|
||||
blob,
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
url: URL.createObjectURL(blob),
|
||||
};
|
||||
}
|
||||
@@ -44,6 +44,36 @@ const router = createRouter({
|
||||
name: 'model3d-viewer',
|
||||
component: () => import('@/views/Model3DViewerView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/viewer/illustrator',
|
||||
name: 'illustrator-viewer',
|
||||
component: () => import('@/views/IllustratorViewerView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/viewer/photoshop',
|
||||
name: 'photoshop-viewer',
|
||||
component: () => import('@/views/PhotoshopViewerView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/viewer/markdown',
|
||||
name: 'markdown-viewer',
|
||||
component: () => import('@/views/MarkdownViewerView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/tools/scanner',
|
||||
name: 'tools-scanner',
|
||||
component: () => import('@/views/ToolsScannerView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/tools/bg-remove',
|
||||
name: 'tools-bg-remove',
|
||||
component: () => import('@/views/ToolsBgRemoveView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/tools/image-edit',
|
||||
name: 'tools-image-edit',
|
||||
component: () => import('@/views/ToolsImageEditView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/licenses',
|
||||
name: 'licenses',
|
||||
|
||||
@@ -10,8 +10,10 @@
|
||||
--card: #ffffff;
|
||||
--card-hover: #f5f5f5;
|
||||
--danger: #c62828;
|
||||
--font-sans: 'IBM Plex Sans KR', 'Apple SD Gothic Neo', sans-serif;
|
||||
--font-display: 'IBM Plex Sans KR', 'Apple SD Gothic Neo', sans-serif;
|
||||
--font-sans: 'Pretendard Variable', Pretendard, -apple-system, BlinkMacSystemFont,
|
||||
system-ui, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
|
||||
--font-display: 'Pretendard Variable', Pretendard, -apple-system, BlinkMacSystemFont,
|
||||
system-ui, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
|
||||
+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;
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from '@mlightcad/cad-simple-viewer';
|
||||
import AppShell from '@/components/AppShell.vue';
|
||||
import EditorToolbar from '@/components/EditorToolbar.vue';
|
||||
import { useFileDropZone } from '@/composables/useFileDropZone';
|
||||
import { consumeViewerFiles } from '@/composables/viewerSession';
|
||||
import { usePageTitle } from '@/composables/usePageTitle';
|
||||
import { useSystemTheme } from '@/composables/useSystemTheme';
|
||||
@@ -38,7 +39,7 @@ const error = ref('');
|
||||
const notice = ref('');
|
||||
const theme = useSystemTheme();
|
||||
|
||||
usePageTitle(filename);
|
||||
usePageTitle(filename, { faviconKind: 'cad' });
|
||||
|
||||
const settings = AcApSettingManager.instance;
|
||||
settings.set('isShowCommandLine', false);
|
||||
@@ -52,7 +53,12 @@ settings.set('isShowToolbar', true);
|
||||
|
||||
function applyFiles(files: File[]) {
|
||||
const file = files.find((item) => /\.(?:dxf|dwg)$/i.test(item.name));
|
||||
if (!file) return;
|
||||
if (!file) {
|
||||
if (files.length) {
|
||||
error.value = '.dxf 또는 .dwg 파일을 선택해 주세요.';
|
||||
}
|
||||
return;
|
||||
}
|
||||
pendingFile.value = file;
|
||||
if (fontReady.value) localFile.value = file;
|
||||
filename.value = file.name;
|
||||
@@ -63,6 +69,14 @@ function applyFiles(files: File[]) {
|
||||
: '';
|
||||
}
|
||||
|
||||
const {
|
||||
dropActive,
|
||||
onDragEnter,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onDrop,
|
||||
} = useFileDropZone(applyFiles);
|
||||
|
||||
async function initialize() {
|
||||
applyFiles(consumeViewerFiles('cad'));
|
||||
try {
|
||||
@@ -98,10 +112,6 @@ function onFileSelected(event: Event) {
|
||||
const files = Array.from(input.files ?? []);
|
||||
input.value = '';
|
||||
if (!files.length) return;
|
||||
if (!files.some((file) => /\.(?:dxf|dwg)$/i.test(file.name))) {
|
||||
error.value = '.dxf 또는 .dwg 파일을 선택해 주세요.';
|
||||
return;
|
||||
}
|
||||
applyFiles(files);
|
||||
void nextTick();
|
||||
}
|
||||
@@ -126,7 +136,14 @@ function onClose() {
|
||||
|
||||
<template>
|
||||
<AppShell :editor="true">
|
||||
<div class="cad-page">
|
||||
<div
|
||||
class="cad-page"
|
||||
:class="{ 'is-drop-active': dropActive }"
|
||||
@dragenter="onDragEnter"
|
||||
@dragover="onDragOver"
|
||||
@dragleave="onDragLeave"
|
||||
@drop="onDrop"
|
||||
>
|
||||
<EditorToolbar
|
||||
:filename="filename"
|
||||
:show-save="false"
|
||||
@@ -160,9 +177,15 @@ function onClose() {
|
||||
/>
|
||||
<div v-if="ready && !localFile && viewerCreated" class="empty-state">
|
||||
<strong>CAD 도면을 선택해 주세요.</strong>
|
||||
<span>DXF와 DWG 파일은 브라우저 안에서만 처리됩니다.</span>
|
||||
<span>
|
||||
DXF와 DWG 파일을 열거나 이 영역으로 끌어다 놓으세요. 처리는
|
||||
브라우저 안에서만 이뤄집니다.
|
||||
</span>
|
||||
<button type="button" @click="onOpen">파일 열기</button>
|
||||
</div>
|
||||
<div v-if="dropActive" class="drop-overlay" aria-hidden="true">
|
||||
DXF · DWG 파일을 놓으세요
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
@@ -231,6 +254,20 @@ function onClose() {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.drop-overlay {
|
||||
position: absolute;
|
||||
inset: 0.75rem;
|
||||
z-index: 30;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 2px dashed var(--accent);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--accent-soft) 88%, transparent);
|
||||
color: var(--ink);
|
||||
font-weight: 650;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
|
||||
@@ -154,7 +154,6 @@ function onClose() {
|
||||
<div class="editor-page">
|
||||
<EditorToolbar
|
||||
:filename="filename"
|
||||
save-label="원본 저장"
|
||||
:show-export-actions="true"
|
||||
:show-filename="false"
|
||||
:busy="busy || !ready"
|
||||
|
||||
@@ -135,7 +135,6 @@ function onClose() {
|
||||
<div class="editor-page">
|
||||
<EditorToolbar
|
||||
:filename="filename"
|
||||
save-label="원본 저장"
|
||||
:show-export-actions="true"
|
||||
:busy="busy"
|
||||
@open="onOpen"
|
||||
|
||||
@@ -60,6 +60,45 @@ const viewerTypes = [
|
||||
title: '3D 모델',
|
||||
subtitle: 'glTF · OBJ · STEP · IFC · STL · 3DM 등',
|
||||
},
|
||||
{
|
||||
route: 'illustrator-viewer',
|
||||
extension: 'ai',
|
||||
title: '일러스트',
|
||||
subtitle: 'Adobe Illustrator (.ai, PDF 호환)',
|
||||
},
|
||||
{
|
||||
route: 'photoshop-viewer',
|
||||
extension: 'psd',
|
||||
title: '포토샵',
|
||||
subtitle: 'Adobe Photoshop (.psd, RGB 8bit)',
|
||||
},
|
||||
{
|
||||
route: 'markdown-viewer',
|
||||
extension: 'md',
|
||||
title: '마크다운',
|
||||
subtitle: '.md · 입력 미리보기',
|
||||
},
|
||||
] as const;
|
||||
|
||||
const toolTypes = [
|
||||
{
|
||||
route: 'tools-scanner',
|
||||
extension: 'png',
|
||||
title: '문서 스캐너',
|
||||
subtitle: '코너 보정 · OCR · PDF',
|
||||
},
|
||||
{
|
||||
route: 'tools-bg-remove',
|
||||
extension: 'bgremove',
|
||||
title: '배경 제거',
|
||||
subtitle: '로고 단색 배경 · 투명 PNG',
|
||||
},
|
||||
{
|
||||
route: 'tools-image-edit',
|
||||
extension: 'imgedit',
|
||||
title: '이미지 편집',
|
||||
subtitle: '크롭 · 축소 · 경량화 · 비율',
|
||||
},
|
||||
] as const;
|
||||
|
||||
function onDrop(e: DragEvent) {
|
||||
@@ -107,7 +146,7 @@ function onOpenTool(route: string) {
|
||||
<input
|
||||
class="drop__input"
|
||||
type="file"
|
||||
accept=".docx,.xlsx,.pptx,.pdf,.hwp,.hwpx,.odt,.ods,.odp,.doc,.xls,.ppt,.rtf,.txt,.csv,.excalidraw,.mmd,.mermaid,.drawio,.xml,.dxf,.dwg,.3dm,.3ds,.3mf,.amf,.bim,.brp,.brep,.dae,.fbx,.fcstd,.gltf,.glb,.ifc,.igs,.iges,.stp,.step,.stl,.obj,.off,.ply,.wrl,.zip,.bin,.mtl,.png,.jpg,.jpeg,.webp"
|
||||
accept=".docx,.xlsx,.pptx,.pdf,.hwp,.hwpx,.odt,.ods,.odp,.doc,.xls,.ppt,.rtf,.txt,.csv,.excalidraw,.mmd,.mermaid,.drawio,.xml,.dxf,.dwg,.ai,.psd,.md,.markdown,.3dm,.3ds,.3mf,.amf,.bim,.brp,.brep,.dae,.fbx,.fcstd,.gltf,.glb,.ifc,.igs,.iges,.stp,.step,.stl,.obj,.off,.ply,.wrl,.zip,.bin,.mtl,.png,.jpg,.jpeg,.webp,.ico"
|
||||
multiple
|
||||
:disabled="busy"
|
||||
@change="onInput"
|
||||
@@ -116,7 +155,7 @@ function onOpenTool(route: string) {
|
||||
{{ busy ? '여는 중…' : '파일을 끌어다 놓거나 클릭하여 선택' }}
|
||||
</span>
|
||||
<span class="drop__hint">
|
||||
오피스·PDF · HWP/HWPX · 다이어그램 · CAD · 3D 모델(ZIP 포함)
|
||||
오피스·PDF · HWP/HWPX · 다이어그램 · CAD · 3D · 일러스트(.ai) · 포토샵(.psd) · 마크다운(.md) · 이미지(스캐너) · 아이콘(.ico)
|
||||
</span>
|
||||
</label>
|
||||
|
||||
@@ -170,10 +209,11 @@ function onOpenTool(route: string) {
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2 class="section__title">CAD·3D 모델</h2>
|
||||
<h2 class="section__title">뷰어</h2>
|
||||
<p class="section__lead">
|
||||
서버 업로드 없이 도면과 모델을 조회합니다. 텍스처·MTL·BIN 등 보조
|
||||
파일이나 ZIP은 원본과 함께 선택하세요.
|
||||
서버 업로드 없이 도면·모델·일러스트·포토샵·마크다운을 조회합니다. 텍스처·MTL·BIN
|
||||
등 보조 파일이나 ZIP은 원본과 함께 선택하세요. .ai는 PDF 호환, .psd는
|
||||
RGB·8bit만 안정적으로 열립니다.
|
||||
</p>
|
||||
|
||||
<div class="grid">
|
||||
@@ -193,6 +233,30 @@ function onOpenTool(route: string) {
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2 class="section__title">도구</h2>
|
||||
<p class="section__lead">
|
||||
브라우저에서 문서를 스캔하고, 배경을 제거하며, 이미지를 크롭·축소·경량화합니다.
|
||||
</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>
|
||||
</AppShell>
|
||||
</template>
|
||||
@@ -325,6 +389,11 @@ function onOpenTool(route: string) {
|
||||
border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.tile :deep(.ft-icon) {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.tile:hover:not(:disabled) {
|
||||
background: var(--card-hover);
|
||||
border-color: color-mix(in srgb, var(--accent) 40%, var(--border));
|
||||
|
||||
@@ -0,0 +1,476 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
onActivated,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
ref,
|
||||
watch,
|
||||
} from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import type { PDFDocumentProxy } from 'pdfjs-dist';
|
||||
import AppShell from '@/components/AppShell.vue';
|
||||
import EditorToolbar from '@/components/EditorToolbar.vue';
|
||||
import { useFileDropZone } from '@/composables/useFileDropZone';
|
||||
import { consumeViewerFiles } from '@/composables/viewerSession';
|
||||
import { usePageTitle } from '@/composables/usePageTitle';
|
||||
import {
|
||||
AI_INCOMPATIBLE_MESSAGE,
|
||||
cancelAiRender,
|
||||
isAiFile,
|
||||
isAiRenderCancelled,
|
||||
openAiAsPdf,
|
||||
renderAiPage,
|
||||
} from '@/integrations/illustrator/loadAi';
|
||||
|
||||
defineOptions({ name: 'IllustratorViewerView' });
|
||||
|
||||
const router = useRouter();
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
||||
const stageRef = ref<HTMLElement | null>(null);
|
||||
const filename = ref('일러스트');
|
||||
const error = ref('');
|
||||
const notice = ref('');
|
||||
const busy = ref(false);
|
||||
const page = ref(1);
|
||||
const pageCount = ref(0);
|
||||
const zoom = ref(1);
|
||||
const fitZoom = ref(1);
|
||||
|
||||
let pdfDoc: PDFDocumentProxy | null = null;
|
||||
let renderSeq = 0;
|
||||
|
||||
usePageTitle(filename, { faviconKind: 'illustrator' });
|
||||
|
||||
const pageLabel = computed(() =>
|
||||
pageCount.value > 0 ? `${page.value} / ${pageCount.value}` : '—',
|
||||
);
|
||||
|
||||
const zoomLabel = computed(() => `${Math.round(zoom.value * 100)}%`);
|
||||
|
||||
async function disposePdf() {
|
||||
renderSeq += 1;
|
||||
await cancelAiRender();
|
||||
if (pdfDoc) {
|
||||
try {
|
||||
await pdfDoc.cleanup();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
pdfDoc = null;
|
||||
}
|
||||
pageCount.value = 0;
|
||||
page.value = 1;
|
||||
}
|
||||
|
||||
async function paint() {
|
||||
const canvas = canvasRef.value;
|
||||
if (!pdfDoc || !canvas || pageCount.value < 1) return;
|
||||
const seq = ++renderSeq;
|
||||
// Coalesce rapid page/zoom updates onto the latest request.
|
||||
await Promise.resolve();
|
||||
if (seq !== renderSeq) return;
|
||||
|
||||
busy.value = true;
|
||||
try {
|
||||
await renderAiPage(pdfDoc, page.value, canvas, zoom.value);
|
||||
if (seq !== renderSeq) return;
|
||||
error.value = '';
|
||||
} catch (reason) {
|
||||
if (seq !== renderSeq || isAiRenderCancelled(reason)) return;
|
||||
error.value =
|
||||
reason instanceof Error ? reason.message : '페이지를 그리지 못했습니다.';
|
||||
} finally {
|
||||
if (seq === renderSeq) busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateFitZoom() {
|
||||
if (!pdfDoc || !stageRef.value) return;
|
||||
try {
|
||||
const p = await pdfDoc.getPage(page.value);
|
||||
const viewport = p.getViewport({ scale: 1 });
|
||||
p.cleanup();
|
||||
const pad = 32;
|
||||
const availW = Math.max(120, stageRef.value.clientWidth - pad);
|
||||
const availH = Math.max(120, stageRef.value.clientHeight - pad);
|
||||
const sx = availW / viewport.width;
|
||||
const sy = availH / viewport.height;
|
||||
fitZoom.value = Math.max(0.1, Math.min(sx, sy, 4));
|
||||
} catch {
|
||||
fitZoom.value = 1;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFile(file: File) {
|
||||
if (!isAiFile(file)) {
|
||||
error.value = '.ai 파일을 선택해 주세요.';
|
||||
return;
|
||||
}
|
||||
busy.value = true;
|
||||
error.value = '';
|
||||
notice.value = '';
|
||||
filename.value = file.name;
|
||||
await disposePdf();
|
||||
try {
|
||||
const data = await file.arrayBuffer();
|
||||
pdfDoc = await openAiAsPdf(data);
|
||||
pageCount.value = pdfDoc.numPages;
|
||||
page.value = 1;
|
||||
await nextTick();
|
||||
await updateFitZoom();
|
||||
zoom.value = fitZoom.value;
|
||||
await paint();
|
||||
if (pdfDoc.numPages > 1) {
|
||||
notice.value = `아트보드(페이지) ${pdfDoc.numPages}개`;
|
||||
}
|
||||
} catch (reason) {
|
||||
error.value =
|
||||
reason instanceof Error ? reason.message : AI_INCOMPATIBLE_MESSAGE;
|
||||
await disposePdf();
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function applyFiles(files: File[]) {
|
||||
const file = files.find((item) => isAiFile(item));
|
||||
if (!file) {
|
||||
if (files.length) error.value = '.ai 파일을 선택해 주세요.';
|
||||
return;
|
||||
}
|
||||
void loadFile(file);
|
||||
}
|
||||
|
||||
const {
|
||||
dropActive,
|
||||
onDragEnter,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onDrop,
|
||||
} = useFileDropZone(applyFiles);
|
||||
|
||||
onMounted(() => {
|
||||
applyFiles(consumeViewerFiles('illustrator'));
|
||||
});
|
||||
|
||||
onActivated(() => {
|
||||
const files = consumeViewerFiles('illustrator');
|
||||
if (files.length) applyFiles(files);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
void disposePdf();
|
||||
});
|
||||
|
||||
watch([page, zoom], () => {
|
||||
if (!pdfDoc || pageCount.value < 1) return;
|
||||
void paint();
|
||||
});
|
||||
|
||||
function onOpen() {
|
||||
fileInput.value?.click();
|
||||
}
|
||||
|
||||
function onFileSelected(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const files = Array.from(input.files ?? []);
|
||||
input.value = '';
|
||||
if (files.length) applyFiles(files);
|
||||
}
|
||||
|
||||
function onClose() {
|
||||
void router.push({ name: 'home' });
|
||||
}
|
||||
|
||||
function zoomBy(factor: number) {
|
||||
zoom.value = Math.min(4, Math.max(0.1, Math.round(zoom.value * factor * 100) / 100));
|
||||
}
|
||||
|
||||
async function zoomFit() {
|
||||
await updateFitZoom();
|
||||
zoom.value = fitZoom.value;
|
||||
}
|
||||
|
||||
function zoomReset() {
|
||||
zoom.value = 1;
|
||||
}
|
||||
|
||||
function prevPage() {
|
||||
if (page.value > 1) page.value -= 1;
|
||||
}
|
||||
|
||||
function nextPage() {
|
||||
if (page.value < pageCount.value) page.value += 1;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell :editor="true">
|
||||
<div
|
||||
class="ai-page"
|
||||
:class="{ 'is-drop-active': dropActive }"
|
||||
@dragenter="onDragEnter"
|
||||
@dragover="onDragOver"
|
||||
@dragleave="onDragLeave"
|
||||
@drop="onDrop"
|
||||
>
|
||||
<EditorToolbar
|
||||
:filename="filename"
|
||||
:show-save="false"
|
||||
:busy="busy"
|
||||
@open="onOpen"
|
||||
@close="onClose"
|
||||
/>
|
||||
<input
|
||||
ref="fileInput"
|
||||
class="sr-only"
|
||||
type="file"
|
||||
accept=".ai,application/postscript"
|
||||
@change="onFileSelected"
|
||||
/>
|
||||
|
||||
<div v-if="pageCount > 0" class="ai-controls">
|
||||
<div class="ai-controls__group">
|
||||
<button
|
||||
type="button"
|
||||
class="ai-btn"
|
||||
:disabled="page <= 1 || busy"
|
||||
@click="prevPage"
|
||||
>
|
||||
이전
|
||||
</button>
|
||||
<span class="ai-controls__label">{{ pageLabel }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="ai-btn"
|
||||
:disabled="page >= pageCount || busy"
|
||||
@click="nextPage"
|
||||
>
|
||||
다음
|
||||
</button>
|
||||
</div>
|
||||
<div class="ai-controls__group">
|
||||
<button type="button" class="ai-btn" :disabled="busy" @click="zoomBy(1 / 1.25)">
|
||||
−
|
||||
</button>
|
||||
<span class="ai-controls__label">{{ zoomLabel }}</span>
|
||||
<button type="button" class="ai-btn" :disabled="busy" @click="zoomBy(1.25)">
|
||||
+
|
||||
</button>
|
||||
<button type="button" class="ai-btn" :disabled="busy" @click="zoomFit">
|
||||
맞춤
|
||||
</button>
|
||||
<button type="button" class="ai-btn" :disabled="busy" @click="zoomReset">
|
||||
100%
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 ref="stageRef" class="ai-stage">
|
||||
<canvas
|
||||
v-show="pageCount > 0 && !error"
|
||||
ref="canvasRef"
|
||||
class="ai-canvas"
|
||||
/>
|
||||
<div v-if="!pageCount && !error && !busy" class="empty-state">
|
||||
<strong>Illustrator (.ai) 파일을 선택해 주세요.</strong>
|
||||
<span>
|
||||
PDF 호환으로 저장된 .ai만 브라우저에서 볼 수 있습니다. 파일을 열거나
|
||||
이 영역으로 끌어다 놓으세요.
|
||||
</span>
|
||||
<button type="button" @click="onOpen">파일 열기</button>
|
||||
</div>
|
||||
<div v-if="dropActive" class="drop-overlay" aria-hidden="true">
|
||||
.ai 파일을 놓으세요
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ai-page {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
background: var(--canvas);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.ai-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.65rem;
|
||||
padding: 0.45rem 0.85rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.ai-controls__group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.ai-controls__label {
|
||||
min-width: 3.5rem;
|
||||
text-align: center;
|
||||
font-size: 0.82rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.ai-btn {
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
padding: 0.28rem 0.65rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--card);
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ai-btn:hover:not(:disabled) {
|
||||
border-color: color-mix(in srgb, var(--accent) 40%, var(--border));
|
||||
background: var(--card-hover);
|
||||
}
|
||||
|
||||
.ai-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.viewer-message {
|
||||
margin: 0;
|
||||
padding: 0.45rem 0.85rem;
|
||||
font-size: 0.82rem;
|
||||
color: var(--ink-soft);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.viewer-message--error {
|
||||
color: var(--danger);
|
||||
background: color-mix(in srgb, var(--danger) 10%, var(--surface));
|
||||
}
|
||||
|
||||
.ai-stage {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1rem;
|
||||
background:
|
||||
linear-gradient(45deg, #ececec 25%, transparent 25%),
|
||||
linear-gradient(-45deg, #ececec 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #ececec 75%),
|
||||
linear-gradient(-45deg, transparent 75%, #ececec 75%);
|
||||
background-size: 16px 16px;
|
||||
background-position:
|
||||
0 0,
|
||||
0 8px,
|
||||
8px -8px,
|
||||
-8px 0;
|
||||
background-color: #f7f7f7;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.ai-stage {
|
||||
background-color: #1a1a1a;
|
||||
background-image:
|
||||
linear-gradient(45deg, #242424 25%, transparent 25%),
|
||||
linear-gradient(-45deg, #242424 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #242424 75%),
|
||||
linear-gradient(-45deg, transparent 75%, #242424 75%);
|
||||
}
|
||||
}
|
||||
|
||||
.ai-canvas {
|
||||
display: block;
|
||||
max-width: none;
|
||||
box-shadow: 0 4px 24px rgb(0 0 0 / 18%);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.55rem;
|
||||
max-width: 28rem;
|
||||
padding: 1.25rem 1.35rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.empty-state strong {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.empty-state span {
|
||||
font-size: 0.88rem;
|
||||
color: var(--muted);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.empty-state button {
|
||||
font: inherit;
|
||||
font-size: 0.86rem;
|
||||
font-weight: 600;
|
||||
margin-top: 0.25rem;
|
||||
padding: 0.4rem 0.85rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.drop-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: color-mix(in srgb, var(--accent) 22%, transparent);
|
||||
color: var(--ink);
|
||||
font-weight: 650;
|
||||
font-size: 1.05rem;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.ai-page.is-drop-active .ai-stage {
|
||||
outline: 2px dashed var(--accent);
|
||||
outline-offset: -8px;
|
||||
}
|
||||
</style>
|
||||
@@ -20,6 +20,16 @@ const thirdParty = [
|
||||
license: 'MIT',
|
||||
url: 'https://github.com/mermaid-js/mermaid',
|
||||
},
|
||||
{
|
||||
name: 'marked',
|
||||
license: 'MIT',
|
||||
url: 'https://github.com/markedjs/marked',
|
||||
},
|
||||
{
|
||||
name: 'DOMPurify',
|
||||
license: 'MPL-2.0 OR Apache-2.0',
|
||||
url: 'https://github.com/cure53/DOMPurify',
|
||||
},
|
||||
{ name: 'Three.js', license: 'MIT', url: 'https://github.com/mrdoob/three.js' },
|
||||
{
|
||||
name: 'Online 3D Viewer',
|
||||
@@ -51,6 +61,46 @@ const thirdParty = [
|
||||
license: 'MIT',
|
||||
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: 'Pretendard',
|
||||
license: 'SIL OFL 1.1',
|
||||
url: 'https://github.com/orioncactus/pretendard',
|
||||
},
|
||||
{
|
||||
name: 'Noto Sans KR',
|
||||
license: 'SIL OFL 1.1',
|
||||
@@ -149,6 +199,42 @@ const thirdParty = [
|
||||
</p>
|
||||
</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">
|
||||
<h2>Euro-Office Document Server</h2>
|
||||
<p>
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onActivated, onMounted, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import AppShell from '@/components/AppShell.vue';
|
||||
import EditorToolbar from '@/components/EditorToolbar.vue';
|
||||
import { useFileDropZone } from '@/composables/useFileDropZone';
|
||||
import { consumeViewerFiles } from '@/composables/viewerSession';
|
||||
import { usePageTitle } from '@/composables/usePageTitle';
|
||||
import {
|
||||
isMarkdownFile,
|
||||
renderMarkdown,
|
||||
} from '@/integrations/markdown/render';
|
||||
import { downloadText, readTextFile } from '@/utils/downloadBlob';
|
||||
|
||||
defineOptions({ name: 'MarkdownViewerView' });
|
||||
|
||||
const DEFAULT_SOURCE = `# 마크다운
|
||||
|
||||
왼쪽 칸에 입력하거나 \`.md\` 파일을 열면 미리보기가 표시됩니다.
|
||||
|
||||
- **굵게** · *기울임* · \`코드\`
|
||||
- [링크](https://git.sejong.dev)
|
||||
|
||||
\`\`\`ts
|
||||
console.log('hello');
|
||||
\`\`\`
|
||||
`;
|
||||
|
||||
const router = useRouter();
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
const filename = ref('미리보기.md');
|
||||
const source = ref(DEFAULT_SOURCE);
|
||||
const error = ref('');
|
||||
const busy = ref(false);
|
||||
|
||||
usePageTitle(filename, { faviconKind: 'markdown' });
|
||||
|
||||
const html = computed(() => renderMarkdown(source.value));
|
||||
|
||||
function onClose() {
|
||||
void router.push({ name: 'home' });
|
||||
}
|
||||
|
||||
function onOpen() {
|
||||
fileInput.value?.click();
|
||||
}
|
||||
|
||||
function onSave() {
|
||||
downloadText(source.value, filename.value, 'text/markdown;charset=utf-8');
|
||||
}
|
||||
|
||||
async function loadFile(file: File) {
|
||||
if (!isMarkdownFile(file)) {
|
||||
error.value = '.md 또는 .markdown 파일을 선택해 주세요.';
|
||||
return;
|
||||
}
|
||||
busy.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
source.value = await readTextFile(file);
|
||||
filename.value = file.name.replace(/\.markdown$/i, '.md');
|
||||
} catch (reason) {
|
||||
error.value =
|
||||
reason instanceof Error ? reason.message : '파일을 열지 못했습니다.';
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onFileSelected(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
input.value = '';
|
||||
if (file) void loadFile(file);
|
||||
}
|
||||
|
||||
function consumePending() {
|
||||
const [file] = consumeViewerFiles('markdown');
|
||||
if (file) void loadFile(file);
|
||||
}
|
||||
|
||||
onMounted(consumePending);
|
||||
onActivated(consumePending);
|
||||
|
||||
const {
|
||||
dropActive,
|
||||
onDragEnter,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onDrop: onZoneDrop,
|
||||
} = useFileDropZone((files) => {
|
||||
const file = files[0];
|
||||
if (file) void loadFile(file);
|
||||
});
|
||||
|
||||
watch(source, () => {
|
||||
if (error.value && source.value) error.value = '';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell :editor="true">
|
||||
<div
|
||||
class="md-page"
|
||||
:class="{ 'is-drop-active': dropActive }"
|
||||
@dragenter="onDragEnter"
|
||||
@dragover="onDragOver"
|
||||
@dragleave="onDragLeave"
|
||||
@drop="onZoneDrop"
|
||||
>
|
||||
<EditorToolbar
|
||||
:filename="filename"
|
||||
:busy="busy"
|
||||
save-label="다운로드"
|
||||
@open="onOpen"
|
||||
@save="onSave"
|
||||
@close="onClose"
|
||||
/>
|
||||
<input
|
||||
ref="fileInput"
|
||||
class="sr-only"
|
||||
type="file"
|
||||
accept=".md,.markdown,.mdown,text/markdown,text/plain"
|
||||
@change="onFileSelected"
|
||||
/>
|
||||
<p v-if="error" class="viewer-message viewer-message--error" role="alert">
|
||||
{{ error }}
|
||||
</p>
|
||||
|
||||
<div class="md-layout">
|
||||
<section class="source-pane" aria-label="마크다운 입력">
|
||||
<textarea
|
||||
v-model="source"
|
||||
class="source"
|
||||
spellcheck="false"
|
||||
:disabled="busy"
|
||||
aria-label="마크다운 원문"
|
||||
placeholder="# 제목 여기에 마크다운을 입력하세요."
|
||||
/>
|
||||
</section>
|
||||
<section class="preview-pane" aria-label="마크다운 미리보기">
|
||||
<article class="preview" v-html="html" />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div v-if="dropActive" class="drop-overlay" aria-hidden="true">
|
||||
마크다운 파일을 놓아 열기
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.md-page {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
background: var(--canvas);
|
||||
}
|
||||
|
||||
.md-layout {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(16rem, 1fr) minmax(0, 1.15fr);
|
||||
gap: 1px;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.source-pane,
|
||||
.preview-pane {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.source {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
resize: none;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
padding: 1rem;
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
font: 0.9rem/1.55 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
tab-size: 2;
|
||||
}
|
||||
|
||||
.preview-pane {
|
||||
overflow: auto;
|
||||
padding: 1.25rem 1.4rem 2rem;
|
||||
}
|
||||
|
||||
.preview {
|
||||
max-width: 46rem;
|
||||
color: var(--ink);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.65;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.preview :deep(:first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.preview :deep(h1),
|
||||
.preview :deep(h2),
|
||||
.preview :deep(h3),
|
||||
.preview :deep(h4) {
|
||||
margin: 1.35em 0 0.5em;
|
||||
line-height: 1.3;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.preview :deep(h1) {
|
||||
font-size: 1.7rem;
|
||||
padding-bottom: 0.3em;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.preview :deep(h2) {
|
||||
font-size: 1.3rem;
|
||||
padding-bottom: 0.25em;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.preview :deep(h3) {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.preview :deep(p),
|
||||
.preview :deep(ul),
|
||||
.preview :deep(ol),
|
||||
.preview :deep(blockquote),
|
||||
.preview :deep(pre),
|
||||
.preview :deep(table) {
|
||||
margin: 0 0 1em;
|
||||
}
|
||||
|
||||
.preview :deep(ul),
|
||||
.preview :deep(ol) {
|
||||
padding-left: 1.4em;
|
||||
}
|
||||
|
||||
.preview :deep(a) {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.preview :deep(code) {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.86em;
|
||||
padding: 0.12em 0.35em;
|
||||
border-radius: 4px;
|
||||
background: var(--canvas);
|
||||
}
|
||||
|
||||
.preview :deep(pre) {
|
||||
overflow: auto;
|
||||
padding: 0.85rem 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--canvas);
|
||||
}
|
||||
|
||||
.preview :deep(pre code) {
|
||||
padding: 0;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.preview :deep(blockquote) {
|
||||
padding: 0 0.85rem;
|
||||
border-left: 3px solid var(--accent);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.preview :deep(hr) {
|
||||
border: 0;
|
||||
border-top: 1px solid var(--border);
|
||||
margin: 1.4em 0;
|
||||
}
|
||||
|
||||
.preview :deep(img) {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.preview :deep(table) {
|
||||
display: block;
|
||||
width: max-content;
|
||||
max-width: 100%;
|
||||
overflow: auto;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.preview :deep(th),
|
||||
.preview :deep(td) {
|
||||
padding: 0.4rem 0.65rem;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.preview :deep(th) {
|
||||
background: var(--canvas);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.drop-overlay {
|
||||
position: absolute;
|
||||
inset: 2.75rem 0.5rem 0.5rem;
|
||||
z-index: 20;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 2px dashed var(--accent);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--accent-soft) 88%, transparent);
|
||||
color: var(--ink);
|
||||
font-weight: 650;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.viewer-message {
|
||||
margin: 0;
|
||||
padding: 0.45rem 0.85rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.viewer-message--error {
|
||||
color: #b42318;
|
||||
background: color-mix(in srgb, #b42318 10%, var(--surface));
|
||||
}
|
||||
|
||||
.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: 760px) {
|
||||
.md-layout {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: minmax(12rem, 0.85fr) minmax(14rem, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -58,7 +58,7 @@ async function renderDiagram() {
|
||||
startOnLoad: false,
|
||||
securityLevel: 'strict',
|
||||
theme: theme.value === 'dark' ? 'dark' : 'default',
|
||||
fontFamily: 'IBM Plex Sans KR, sans-serif',
|
||||
fontFamily: 'Pretendard Variable, Pretendard, sans-serif',
|
||||
htmlLabels: false,
|
||||
});
|
||||
await mermaid.parse(code.value);
|
||||
@@ -176,7 +176,6 @@ function onClose() {
|
||||
<div class="editor-page">
|
||||
<EditorToolbar
|
||||
:filename="filename"
|
||||
save-label="원본 저장"
|
||||
:show-export-actions="true"
|
||||
:busy="busy"
|
||||
@open="onOpen"
|
||||
|
||||
@@ -13,6 +13,7 @@ import Model3DCanvas from '@/components/model3d/Model3DCanvas.vue';
|
||||
import ModelTreeItem from '@/components/model3d/ModelTreeItem.vue';
|
||||
import { MODEL3D_EXTENSIONS } from '@/config/app';
|
||||
import { consumeViewerFiles } from '@/composables/viewerSession';
|
||||
import { useFileDropZone } from '@/composables/useFileDropZone';
|
||||
import { usePageTitle } from '@/composables/usePageTitle';
|
||||
import { useSystemTheme } from '@/composables/useSystemTheme';
|
||||
import {
|
||||
@@ -84,7 +85,7 @@ const propertyGroups = computed(() =>
|
||||
selectedKey.value ? selectionGroups.value : modelGroups.value,
|
||||
);
|
||||
|
||||
usePageTitle(filename);
|
||||
usePageTitle(filename, { faviconKind: 'model3d' });
|
||||
|
||||
function applyFiles(nextFiles: File[]) {
|
||||
const primary = findPrimaryModelFile(nextFiles);
|
||||
@@ -135,10 +136,18 @@ function onFilesSelected(event: Event) {
|
||||
if (selected.length) applyFiles(selected);
|
||||
}
|
||||
|
||||
const {
|
||||
dropActive,
|
||||
onDragEnter,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onDrop,
|
||||
} = useFileDropZone(applyFiles);
|
||||
|
||||
function onProgress(value: number, label: string) {
|
||||
loading.value = true;
|
||||
progress.value = value;
|
||||
progressLabel.value = label;
|
||||
if (value < 1) loading.value = true;
|
||||
}
|
||||
|
||||
function onLoaded(
|
||||
@@ -238,10 +247,16 @@ function onClose() {
|
||||
|
||||
<template>
|
||||
<AppShell :editor="true">
|
||||
<div class="model-page">
|
||||
<div
|
||||
class="model-page"
|
||||
:class="{ 'is-drop-active': dropActive }"
|
||||
@dragenter="onDragEnter"
|
||||
@dragover="onDragOver"
|
||||
@dragleave="onDragLeave"
|
||||
@drop="onDrop"
|
||||
>
|
||||
<EditorToolbar
|
||||
:filename="filename"
|
||||
save-label="내보내기"
|
||||
:show-save="Boolean(stats)"
|
||||
:busy="loading || exportBusy"
|
||||
@open="onOpen"
|
||||
@@ -314,8 +329,9 @@ function onClose() {
|
||||
<div v-else class="empty-state">
|
||||
<strong>3D 모델을 선택해 주세요.</strong>
|
||||
<span>
|
||||
Online3DViewer가 지원하는 형식과 ZIP, 보조 텍스처를 함께 열 수
|
||||
있습니다.
|
||||
Online3DViewer가 지원하는 형식과 ZIP, 보조 텍스처를 함께 열거나
|
||||
이 영역으로 끌어다 놓을 수 있습니다. 면 없는 PLY는 포인트
|
||||
클라우드로 표시됩니다.
|
||||
</span>
|
||||
<button type="button" @click="onOpen">파일 열기</button>
|
||||
</div>
|
||||
@@ -369,6 +385,10 @@ function onClose() {
|
||||
<progress :value="progressPercent" max="100" />
|
||||
<small>{{ progressPercent }}%</small>
|
||||
</div>
|
||||
|
||||
<div v-if="dropActive" class="drop-overlay" aria-hidden="true">
|
||||
3D 모델 파일을 놓으세요
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<aside class="inspector">
|
||||
@@ -632,6 +652,20 @@ function onClose() {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.drop-overlay {
|
||||
position: absolute;
|
||||
inset: 0.75rem;
|
||||
z-index: 25;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 2px dashed var(--accent);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--accent-soft) 88%, transparent);
|
||||
color: var(--ink);
|
||||
font-weight: 650;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.export-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
import { nextTick, onActivated, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import AppShell from '@/components/AppShell.vue';
|
||||
import DownloadGuideModal from '@/components/DownloadGuideModal.vue';
|
||||
import EditorErrorState from '@/components/EditorErrorState.vue';
|
||||
import EditorToolbar from '@/components/EditorToolbar.vue';
|
||||
import { downloadDocumentUrl, fetchEditorConfig } from '@/api/client';
|
||||
import { fetchEditorConfig } from '@/api/client';
|
||||
import { useOpenDocument } from '@/composables/useOpenDocument';
|
||||
import { usePageTitle } from '@/composables/usePageTitle';
|
||||
import { useSessionLifecycle } from '@/composables/useSessionLifecycle';
|
||||
@@ -15,6 +16,7 @@ const route = useRoute();
|
||||
const router = useRouter();
|
||||
const error = ref<unknown>(null);
|
||||
const filename = ref('문서');
|
||||
const guideOpen = ref(false);
|
||||
const frameHost = ref<HTMLElement | null>(null);
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
let editor: { destroyEditor?: () => void } | null = null;
|
||||
@@ -27,6 +29,12 @@ const { openDocument } = useOpenDocument();
|
||||
usePageTitle(filename);
|
||||
useSessionLifecycle({ enableHeartbeat: true });
|
||||
|
||||
const downloadGuideSteps = [
|
||||
'편집기 좌측 상단의 파일을 누릅니다.',
|
||||
'다운로드 방법을 선택합니다.',
|
||||
'원하는 확장자를 고르면 파일이 저장됩니다.',
|
||||
];
|
||||
|
||||
function editorHeightPx(): number {
|
||||
const host = frameHost.value;
|
||||
if (host) {
|
||||
@@ -123,15 +131,8 @@ function onFileSelected(e: Event) {
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
function onSave() {
|
||||
const docId = route.params.docId as string;
|
||||
const a = document.createElement('a');
|
||||
a.href = downloadDocumentUrl(docId);
|
||||
a.download = filename.value;
|
||||
a.rel = 'noopener';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
function onDownload() {
|
||||
guideOpen.value = true;
|
||||
}
|
||||
|
||||
function onClose() {
|
||||
@@ -147,7 +148,7 @@ function onClose() {
|
||||
:filename="filename"
|
||||
:show-filename="false"
|
||||
@open="onOpen"
|
||||
@save="onSave"
|
||||
@save="onDownload"
|
||||
@close="onClose"
|
||||
/>
|
||||
<input
|
||||
@@ -162,6 +163,14 @@ function onClose() {
|
||||
<div v-show="!error" ref="frameHost" class="editor-frame">
|
||||
<div id="onlyoffice-placeholder" class="editor-frame__inner" />
|
||||
</div>
|
||||
|
||||
<DownloadGuideModal
|
||||
:open="guideOpen"
|
||||
lead="수정한 파일은 EuroOffice 편집기 메뉴에서 받을 수 있습니다."
|
||||
:steps="downloadGuideSteps"
|
||||
path-hint="파일 → 다운로드 방법 → 확장자 선택"
|
||||
@close="guideOpen = false"
|
||||
/>
|
||||
</div>
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
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 { useFileDropZone } from '@/composables/useFileDropZone';
|
||||
import { consumeViewerFiles } from '@/composables/viewerSession';
|
||||
import { usePageTitle } from '@/composables/usePageTitle';
|
||||
import {
|
||||
drawPsdToCanvas,
|
||||
isPsdFile,
|
||||
openPsd,
|
||||
PSD_INCOMPATIBLE_MESSAGE,
|
||||
type OpenedPsd,
|
||||
} from '@/integrations/photoshop/loadPsd';
|
||||
|
||||
defineOptions({ name: 'PhotoshopViewerView' });
|
||||
|
||||
const router = useRouter();
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
||||
const stageRef = ref<HTMLElement | null>(null);
|
||||
const filename = ref('포토샵');
|
||||
const error = ref('');
|
||||
const notice = ref('');
|
||||
const busy = ref(false);
|
||||
const hasImage = ref(false);
|
||||
const zoom = ref(1);
|
||||
const fitZoom = ref(1);
|
||||
|
||||
let opened: OpenedPsd | null = null;
|
||||
let renderSeq = 0;
|
||||
|
||||
usePageTitle(filename, { faviconKind: 'photoshop' });
|
||||
|
||||
const zoomLabel = computed(() => `${Math.round(zoom.value * 100)}%`);
|
||||
|
||||
function disposePsd() {
|
||||
renderSeq += 1;
|
||||
opened = null;
|
||||
hasImage.value = false;
|
||||
}
|
||||
|
||||
function paint() {
|
||||
const canvas = canvasRef.value;
|
||||
if (!opened || !canvas) return;
|
||||
const seq = ++renderSeq;
|
||||
try {
|
||||
drawPsdToCanvas(opened.canvas, canvas, zoom.value);
|
||||
if (seq !== renderSeq) return;
|
||||
error.value = '';
|
||||
} catch (reason) {
|
||||
if (seq !== renderSeq) return;
|
||||
error.value =
|
||||
reason instanceof Error ? reason.message : '이미지를 그리지 못했습니다.';
|
||||
}
|
||||
}
|
||||
|
||||
function updateFitZoom() {
|
||||
if (!opened || !stageRef.value) {
|
||||
fitZoom.value = 1;
|
||||
return;
|
||||
}
|
||||
const pad = 32;
|
||||
const availW = Math.max(120, stageRef.value.clientWidth - pad);
|
||||
const availH = Math.max(120, stageRef.value.clientHeight - pad);
|
||||
const sx = availW / opened.width;
|
||||
const sy = availH / opened.height;
|
||||
fitZoom.value = Math.max(0.1, Math.min(sx, sy, 4));
|
||||
}
|
||||
|
||||
async function loadFile(file: File) {
|
||||
if (!isPsdFile(file)) {
|
||||
error.value = '.psd 파일을 선택해 주세요.';
|
||||
return;
|
||||
}
|
||||
busy.value = true;
|
||||
error.value = '';
|
||||
notice.value = '';
|
||||
filename.value = file.name;
|
||||
disposePsd();
|
||||
try {
|
||||
const data = await file.arrayBuffer();
|
||||
opened = openPsd(data);
|
||||
hasImage.value = true;
|
||||
await nextTick();
|
||||
updateFitZoom();
|
||||
zoom.value = fitZoom.value;
|
||||
paint();
|
||||
notice.value = `${opened.width} × ${opened.height}px`;
|
||||
} catch (reason) {
|
||||
error.value =
|
||||
reason instanceof Error ? reason.message : PSD_INCOMPATIBLE_MESSAGE;
|
||||
disposePsd();
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function applyFiles(files: File[]) {
|
||||
const file = files.find((item) => isPsdFile(item));
|
||||
if (!file) {
|
||||
if (files.length) error.value = '.psd 파일을 선택해 주세요.';
|
||||
return;
|
||||
}
|
||||
void loadFile(file);
|
||||
}
|
||||
|
||||
const {
|
||||
dropActive,
|
||||
onDragEnter,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onDrop,
|
||||
} = useFileDropZone(applyFiles);
|
||||
|
||||
onMounted(() => {
|
||||
applyFiles(consumeViewerFiles('photoshop'));
|
||||
});
|
||||
|
||||
onActivated(() => {
|
||||
const files = consumeViewerFiles('photoshop');
|
||||
if (files.length) applyFiles(files);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
disposePsd();
|
||||
});
|
||||
|
||||
watch(zoom, () => {
|
||||
if (!opened) return;
|
||||
paint();
|
||||
});
|
||||
|
||||
function onOpen() {
|
||||
fileInput.value?.click();
|
||||
}
|
||||
|
||||
function onFileSelected(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const files = Array.from(input.files ?? []);
|
||||
input.value = '';
|
||||
if (files.length) applyFiles(files);
|
||||
}
|
||||
|
||||
function onClose() {
|
||||
void router.push({ name: 'home' });
|
||||
}
|
||||
|
||||
function zoomBy(factor: number) {
|
||||
zoom.value = Math.min(
|
||||
4,
|
||||
Math.max(0.1, Math.round(zoom.value * factor * 100) / 100),
|
||||
);
|
||||
}
|
||||
|
||||
function zoomFit() {
|
||||
updateFitZoom();
|
||||
zoom.value = fitZoom.value;
|
||||
}
|
||||
|
||||
function zoomReset() {
|
||||
zoom.value = 1;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell :editor="true">
|
||||
<div
|
||||
class="psd-page"
|
||||
:class="{ 'is-drop-active': dropActive }"
|
||||
@dragenter="onDragEnter"
|
||||
@dragover="onDragOver"
|
||||
@dragleave="onDragLeave"
|
||||
@drop="onDrop"
|
||||
>
|
||||
<EditorToolbar
|
||||
:filename="filename"
|
||||
:show-save="false"
|
||||
:busy="busy"
|
||||
@open="onOpen"
|
||||
@close="onClose"
|
||||
/>
|
||||
<input
|
||||
ref="fileInput"
|
||||
class="sr-only"
|
||||
type="file"
|
||||
accept=".psd,image/vnd.adobe.photoshop"
|
||||
@change="onFileSelected"
|
||||
/>
|
||||
|
||||
<div v-if="hasImage" class="psd-controls">
|
||||
<div class="psd-controls__group">
|
||||
<button type="button" class="psd-btn" :disabled="busy" @click="zoomBy(1 / 1.25)">
|
||||
−
|
||||
</button>
|
||||
<span class="psd-controls__label">{{ zoomLabel }}</span>
|
||||
<button type="button" class="psd-btn" :disabled="busy" @click="zoomBy(1.25)">
|
||||
+
|
||||
</button>
|
||||
<button type="button" class="psd-btn" :disabled="busy" @click="zoomFit">
|
||||
맞춤
|
||||
</button>
|
||||
<button type="button" class="psd-btn" :disabled="busy" @click="zoomReset">
|
||||
100%
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 ref="stageRef" class="psd-stage">
|
||||
<canvas
|
||||
v-show="hasImage && !error"
|
||||
ref="canvasRef"
|
||||
class="psd-canvas"
|
||||
/>
|
||||
<div v-if="!hasImage && !error && !busy" class="empty-state">
|
||||
<strong>Photoshop (.psd) 파일을 선택해 주세요.</strong>
|
||||
<span>
|
||||
RGB·Grayscale·Bitmap / 8bit .psd만 브라우저에서 볼 수 있습니다.
|
||||
파일을 열거나 이 영역으로 끌어다 놓으세요.
|
||||
</span>
|
||||
<button type="button" @click="onOpen">파일 열기</button>
|
||||
</div>
|
||||
<div v-if="dropActive" class="drop-overlay" aria-hidden="true">
|
||||
.psd 파일을 놓으세요
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.psd-page {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
background: var(--canvas);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.psd-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.65rem;
|
||||
padding: 0.45rem 0.85rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.psd-controls__group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.psd-controls__label {
|
||||
min-width: 3.5rem;
|
||||
text-align: center;
|
||||
font-size: 0.82rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.psd-btn {
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
padding: 0.28rem 0.65rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--card);
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.psd-btn:hover:not(:disabled) {
|
||||
border-color: color-mix(in srgb, var(--accent) 40%, var(--border));
|
||||
background: var(--card-hover);
|
||||
}
|
||||
|
||||
.psd-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.viewer-message {
|
||||
margin: 0;
|
||||
padding: 0.45rem 0.85rem;
|
||||
font-size: 0.82rem;
|
||||
color: var(--ink-soft);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.viewer-message--error {
|
||||
color: var(--danger);
|
||||
background: color-mix(in srgb, var(--danger) 10%, var(--surface));
|
||||
}
|
||||
|
||||
.psd-stage {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1rem;
|
||||
background:
|
||||
linear-gradient(45deg, #ececec 25%, transparent 25%),
|
||||
linear-gradient(-45deg, #ececec 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #ececec 75%),
|
||||
linear-gradient(-45deg, transparent 75%, #ececec 75%);
|
||||
background-size: 16px 16px;
|
||||
background-position:
|
||||
0 0,
|
||||
0 8px,
|
||||
8px -8px,
|
||||
-8px 0;
|
||||
background-color: #f7f7f7;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.psd-stage {
|
||||
background-color: #1a1a1a;
|
||||
background-image:
|
||||
linear-gradient(45deg, #242424 25%, transparent 25%),
|
||||
linear-gradient(-45deg, #242424 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #242424 75%),
|
||||
linear-gradient(-45deg, transparent 75%, #242424 75%);
|
||||
}
|
||||
}
|
||||
|
||||
.psd-canvas {
|
||||
display: block;
|
||||
max-width: none;
|
||||
box-shadow: 0 4px 24px rgb(0 0 0 / 18%);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.55rem;
|
||||
max-width: 28rem;
|
||||
padding: 1.25rem 1.35rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.empty-state strong {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.empty-state span {
|
||||
font-size: 0.88rem;
|
||||
color: var(--muted);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.empty-state button {
|
||||
font: inherit;
|
||||
font-size: 0.86rem;
|
||||
font-weight: 600;
|
||||
margin-top: 0.25rem;
|
||||
padding: 0.4rem 0.85rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.drop-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: color-mix(in srgb, var(--accent) 22%, transparent);
|
||||
color: var(--ink);
|
||||
font-weight: 650;
|
||||
font-size: 1.05rem;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.psd-page.is-drop-active .psd-stage {
|
||||
outline: 2px dashed var(--accent);
|
||||
outline-offset: -8px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,37 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from 'vue';
|
||||
import { nextTick, onActivated, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { createEditor, type RhwpEditor } from '@rhwp/editor';
|
||||
import AppShell from '@/components/AppShell.vue';
|
||||
import DownloadGuideModal from '@/components/DownloadGuideModal.vue';
|
||||
import EditorErrorState from '@/components/EditorErrorState.vue';
|
||||
import EditorToolbar from '@/components/EditorToolbar.vue';
|
||||
import { ApiError } from '@/api/client';
|
||||
import { extensionOf, isHwp } from '@/config/app';
|
||||
import { isHwp } from '@/config/app';
|
||||
import { usePageTitle } from '@/composables/usePageTitle';
|
||||
import {
|
||||
clearHwpFile,
|
||||
hwpOpenToken,
|
||||
loadHwpFile,
|
||||
storeHwpFile,
|
||||
useSessionLifecycle,
|
||||
} from '@/composables/useSessionLifecycle';
|
||||
import { downloadBlob } from '@/utils/downloadBlob';
|
||||
|
||||
defineOptions({ name: 'RhwpEditorView' });
|
||||
|
||||
const router = useRouter();
|
||||
const error = ref<unknown>(null);
|
||||
const filename = ref('문서.hwp');
|
||||
const guideOpen = ref(false);
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
let editor: RhwpEditor | null = null;
|
||||
let initialized = false;
|
||||
let loadedToken = '';
|
||||
let loadSeq = 0;
|
||||
|
||||
usePageTitle(filename);
|
||||
useSessionLifecycle({ enableHeartbeat: true });
|
||||
|
||||
onMounted(async () => {
|
||||
if (initialized) return;
|
||||
const downloadGuideSteps = [
|
||||
'편집기 메뉴에서 파일을 누릅니다.',
|
||||
'저장 또는 다른 이름으로 저장을 선택합니다.',
|
||||
'원하는 위치와 이름으로 저장합니다.',
|
||||
];
|
||||
|
||||
async function bootstrapFromStore() {
|
||||
const seq = ++loadSeq;
|
||||
const token = hwpOpenToken();
|
||||
const stored = loadHwpFile();
|
||||
|
||||
if (!stored) {
|
||||
error.value = new ApiError(
|
||||
404,
|
||||
@@ -41,25 +51,51 @@ onMounted(async () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Same staged file already shown (e.g. mounted + activated).
|
||||
if (editor && loadedToken === token && loadedToken !== '0') {
|
||||
filename.value = stored.name;
|
||||
error.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
filename.value = stored.name;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
await nextTick();
|
||||
if (seq !== loadSeq) return;
|
||||
|
||||
if (!editor) {
|
||||
editor = await createEditor('#rhwp-host', {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
});
|
||||
}
|
||||
if (seq !== loadSeq) return;
|
||||
|
||||
await editor.loadFile(stored.buffer, stored.name);
|
||||
initialized = true;
|
||||
if (seq !== loadSeq) return;
|
||||
|
||||
loadedToken = token;
|
||||
} catch (err) {
|
||||
if (seq !== loadSeq) return;
|
||||
error.value = err;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void bootstrapFromStore();
|
||||
});
|
||||
|
||||
onActivated(() => {
|
||||
void bootstrapFromStore();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
loadSeq += 1;
|
||||
editor?.destroy();
|
||||
editor = null;
|
||||
initialized = false;
|
||||
loadedToken = '';
|
||||
});
|
||||
|
||||
function onOpen() {
|
||||
@@ -70,7 +106,7 @@ async function onFileSelected(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
input.value = '';
|
||||
if (!file || !editor) return;
|
||||
if (!file) return;
|
||||
|
||||
if (!isHwp(file.name)) {
|
||||
error.value = new ApiError(
|
||||
@@ -83,30 +119,16 @@ async function onFileSelected(e: Event) {
|
||||
|
||||
try {
|
||||
await storeHwpFile(file);
|
||||
const stored = loadHwpFile();
|
||||
if (!stored) throw new Error('파일을 읽을 수 없습니다.');
|
||||
await editor.loadFile(stored.buffer, stored.name);
|
||||
filename.value = stored.name;
|
||||
error.value = null;
|
||||
// Token bump remounts via App viewKey; also reload in place if not remounted.
|
||||
loadedToken = '';
|
||||
await bootstrapFromStore();
|
||||
} catch (err) {
|
||||
error.value = err;
|
||||
}
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
if (!editor) return;
|
||||
try {
|
||||
const ext = extensionOf(filename.value);
|
||||
const bytes =
|
||||
ext === 'hwpx' ? await editor.exportHwpx() : await editor.exportHwp();
|
||||
const name =
|
||||
ext === 'hwpx' || ext === 'hwp'
|
||||
? filename.value
|
||||
: `${filename.value}.hwp`;
|
||||
downloadBlob(bytes, name);
|
||||
} catch (err) {
|
||||
error.value = err;
|
||||
}
|
||||
async function onDownload() {
|
||||
guideOpen.value = true;
|
||||
}
|
||||
|
||||
function onClose() {
|
||||
@@ -122,7 +144,7 @@ function onClose() {
|
||||
v-if="!error"
|
||||
:filename="filename"
|
||||
@open="onOpen"
|
||||
@save="onSave"
|
||||
@save="onDownload"
|
||||
@close="onClose"
|
||||
/>
|
||||
<input
|
||||
@@ -135,6 +157,14 @@ function onClose() {
|
||||
|
||||
<EditorErrorState v-if="error" :error="error" context="rhwp" />
|
||||
<div v-show="!error" id="rhwp-host" class="editor-frame" />
|
||||
|
||||
<DownloadGuideModal
|
||||
:open="guideOpen"
|
||||
lead="수정한 한글 문서는 rhwp 편집기 메뉴에서 받을 수 있습니다."
|
||||
:steps="downloadGuideSteps"
|
||||
path-hint="파일 → 저장(다른 이름으로 저장)"
|
||||
@close="guideOpen = false"
|
||||
/>
|
||||
</div>
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,654 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import AppShell from '@/components/AppShell.vue';
|
||||
import EditorToolbar from '@/components/EditorToolbar.vue';
|
||||
import { useFileDropZone } from '@/composables/useFileDropZone';
|
||||
import { usePageTitle } from '@/composables/usePageTitle';
|
||||
import {
|
||||
DEFAULT_BG_REMOVE_OPTIONS,
|
||||
removeBackgroundBySample,
|
||||
type BgRemoveMode,
|
||||
type Rgb,
|
||||
} from '@/integrations/bgremove/removeBg';
|
||||
import { decodeIcoToPng, isIcoFile } from '@/integrations/imageedit/ico';
|
||||
import { downloadBlob } from '@/utils/downloadBlob';
|
||||
|
||||
defineOptions({ name: 'ToolsBgRemoveView' });
|
||||
|
||||
const router = useRouter();
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
const filename = ref('배경 제거');
|
||||
const sourceUrl = ref<string | null>(null);
|
||||
const resultUrl = ref<string | null>(null);
|
||||
const background = ref<Rgb | null>(null);
|
||||
const busy = ref(false);
|
||||
const error = ref('');
|
||||
const notice = ref('');
|
||||
const tolerance = ref(DEFAULT_BG_REMOVE_OPTIONS.tolerance);
|
||||
const feather = ref(DEFAULT_BG_REMOVE_OPTIONS.feather);
|
||||
const mode = ref<BgRemoveMode>(DEFAULT_BG_REMOVE_OPTIONS.mode);
|
||||
const imageUrlInput = ref('');
|
||||
|
||||
usePageTitle(filename, { faviconKind: 'bgremove' });
|
||||
|
||||
const hasImage = computed(() => Boolean(sourceUrl.value));
|
||||
const bgCss = computed(() => {
|
||||
if (!background.value) return '';
|
||||
const { r, g, b } = background.value;
|
||||
return `rgb(${r}, ${g}, ${b})`;
|
||||
});
|
||||
|
||||
function revoke(url: string | null) {
|
||||
if (url?.startsWith('blob:')) URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function baseName(name = filename.value) {
|
||||
return name.replace(/\.[^.]+$/, '') || 'logo';
|
||||
}
|
||||
|
||||
function onClose() {
|
||||
void router.push({ name: 'home' });
|
||||
}
|
||||
|
||||
function onOpen() {
|
||||
fileInput.value?.click();
|
||||
}
|
||||
|
||||
function isImageFile(file: File) {
|
||||
if (isIcoFile(file)) return true;
|
||||
if (file.type.startsWith('image/')) return true;
|
||||
return /\.(png|jpe?g|webp|gif|bmp|svg)$/i.test(file.name);
|
||||
}
|
||||
|
||||
async function loadFile(file: File) {
|
||||
if (!isImageFile(file)) {
|
||||
error.value = 'PNG, JPEG, WebP, GIF, BMP, SVG, ICO 이미지를 선택해 주세요.';
|
||||
return;
|
||||
}
|
||||
error.value = '';
|
||||
notice.value = '';
|
||||
revoke(sourceUrl.value);
|
||||
revoke(resultUrl.value);
|
||||
resultUrl.value = null;
|
||||
background.value = null;
|
||||
filename.value = file.name;
|
||||
if (isIcoFile(file)) {
|
||||
try {
|
||||
const decoded = await decodeIcoToPng(await file.arrayBuffer());
|
||||
sourceUrl.value = URL.createObjectURL(decoded.blob);
|
||||
} catch (reason) {
|
||||
error.value =
|
||||
reason instanceof Error ? reason.message : 'ICO를 열지 못했습니다.';
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
sourceUrl.value = URL.createObjectURL(file);
|
||||
}
|
||||
await processImage();
|
||||
}
|
||||
|
||||
async function loadFromUrl(raw = imageUrlInput.value) {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
error.value = '이미지 URL을 입력해 주세요.';
|
||||
return;
|
||||
}
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(trimmed);
|
||||
} catch {
|
||||
error.value = '올바른 이미지 URL을 입력해 주세요.';
|
||||
return;
|
||||
}
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
error.value = 'http(s) URL만 사용할 수 있습니다.';
|
||||
return;
|
||||
}
|
||||
|
||||
busy.value = true;
|
||||
error.value = '';
|
||||
notice.value = '';
|
||||
try {
|
||||
const response = await fetch(parsed.href, { mode: 'cors' });
|
||||
if (!response.ok) {
|
||||
throw new Error(`이미지를 가져오지 못했습니다 (${response.status}).`);
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const looksImage =
|
||||
blob.type.startsWith('image/') ||
|
||||
/\.(png|jpe?g|webp|gif|bmp|svg|ico)(\?|$)/i.test(parsed.pathname);
|
||||
if (!looksImage) {
|
||||
throw new Error('응답이 이미지가 아닙니다.');
|
||||
}
|
||||
const leaf = decodeURIComponent(
|
||||
parsed.pathname.split('/').filter(Boolean).pop() || 'image',
|
||||
);
|
||||
const name = /\.[a-z0-9]+$/i.test(leaf) ? leaf : `${leaf}.png`;
|
||||
const file = new File([blob], name, {
|
||||
type: blob.type || 'image/png',
|
||||
});
|
||||
imageUrlInput.value = parsed.href;
|
||||
await loadFile(file);
|
||||
} catch (reason) {
|
||||
const message =
|
||||
reason instanceof Error ? reason.message : 'URL에서 이미지를 불러오지 못했습니다.';
|
||||
error.value = /Failed to fetch|NetworkError|CORS/i.test(message)
|
||||
? '이미지를 가져오지 못했습니다. CORS를 허용하는 URL인지 확인해 주세요.'
|
||||
: message;
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onFilesSelected(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
input.value = '';
|
||||
if (file) void loadFile(file);
|
||||
}
|
||||
|
||||
const {
|
||||
dropActive,
|
||||
onDragEnter,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onDrop: onZoneDrop,
|
||||
} = useFileDropZone((files) => {
|
||||
const file = files[0];
|
||||
if (file) void loadFile(file);
|
||||
});
|
||||
|
||||
async function processImage() {
|
||||
if (!sourceUrl.value) return;
|
||||
busy.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const result = await removeBackgroundBySample(sourceUrl.value, {
|
||||
tolerance: tolerance.value,
|
||||
feather: feather.value,
|
||||
mode: mode.value,
|
||||
});
|
||||
revoke(resultUrl.value);
|
||||
resultUrl.value = result.url;
|
||||
background.value = result.background;
|
||||
notice.value = '배경을 제거했습니다.';
|
||||
} catch (reason) {
|
||||
error.value =
|
||||
reason instanceof Error ? reason.message : '배경 제거에 실패했습니다.';
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function exportPng() {
|
||||
if (!resultUrl.value) return;
|
||||
void fetch(resultUrl.value)
|
||||
.then((response) => response.blob())
|
||||
.then((blob) => downloadBlob(blob, `${baseName()}-transparent.png`));
|
||||
}
|
||||
|
||||
watch(mode, () => {
|
||||
if (sourceUrl.value) void processImage();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
revoke(sourceUrl.value);
|
||||
revoke(resultUrl.value);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell :editor="true">
|
||||
<div
|
||||
class="bg-page"
|
||||
:class="{ 'is-drop-active': dropActive }"
|
||||
@dragenter="onDragEnter"
|
||||
@dragover="onDragOver"
|
||||
@dragleave="onDragLeave"
|
||||
@drop="onZoneDrop"
|
||||
>
|
||||
<EditorToolbar
|
||||
:filename="filename"
|
||||
:show-save="Boolean(resultUrl)"
|
||||
:busy="busy"
|
||||
@open="onOpen"
|
||||
@save="exportPng"
|
||||
@close="onClose"
|
||||
/>
|
||||
<input
|
||||
ref="fileInput"
|
||||
class="sr-only"
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/gif,image/bmp,image/svg+xml,image/x-icon,image/vnd.microsoft.icon,.png,.jpg,.jpeg,.webp,.gif,.bmp,.svg,.ico"
|
||||
@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>
|
||||
|
||||
<main class="workspace">
|
||||
<div
|
||||
v-if="!hasImage"
|
||||
class="empty"
|
||||
:class="{ 'is-drop-active': dropActive }"
|
||||
>
|
||||
<strong>배경 제거</strong>
|
||||
<p>
|
||||
단색 배경 로고 이미지를 열거나 끌어다 놓으면 모서리 색을 기준으로
|
||||
배경을 투명하게 만듭니다. 흰 배경·검은 배경 모두 처리됩니다.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-primary"
|
||||
:disabled="busy"
|
||||
@click="onOpen"
|
||||
>
|
||||
이미지 열기
|
||||
</button>
|
||||
<form class="url-form" @submit.prevent="void loadFromUrl()">
|
||||
<input
|
||||
v-model="imageUrlInput"
|
||||
type="url"
|
||||
inputmode="url"
|
||||
placeholder="https://example.com/logo.png"
|
||||
:disabled="busy"
|
||||
aria-label="이미지 URL"
|
||||
/>
|
||||
<button type="submit" :disabled="busy">URL 불러오기</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="controls">
|
||||
<form class="url-form url-form--inline" @submit.prevent="void loadFromUrl()">
|
||||
<input
|
||||
v-model="imageUrlInput"
|
||||
type="url"
|
||||
inputmode="url"
|
||||
placeholder="이미지 URL"
|
||||
:disabled="busy"
|
||||
aria-label="이미지 URL"
|
||||
/>
|
||||
<button type="submit" :disabled="busy">URL</button>
|
||||
</form>
|
||||
<label class="field field--mode">
|
||||
<span>모드</span>
|
||||
<select v-model="mode" :disabled="busy">
|
||||
<option value="threshold">전체 유사색 (글자 구멍 포함)</option>
|
||||
<option value="flood">바깥만 (모서리 연결)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field__row">
|
||||
허용치
|
||||
<em>{{ tolerance }}</em>
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
step="1"
|
||||
:value="tolerance"
|
||||
:disabled="busy"
|
||||
@input="
|
||||
tolerance = Number(($event.target as HTMLInputElement).value)
|
||||
"
|
||||
@change="void processImage()"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field__row">
|
||||
가장자리
|
||||
<em>{{ feather }}</em>
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
step="1"
|
||||
:value="feather"
|
||||
:disabled="busy"
|
||||
@input="
|
||||
feather = Number(($event.target as HTMLInputElement).value)
|
||||
"
|
||||
@change="void processImage()"
|
||||
/>
|
||||
</label>
|
||||
<div v-if="background" class="swatch" title="추정 배경색">
|
||||
<span class="swatch__chip" :style="{ background: bgCss }" />
|
||||
<span>배경 {{ bgCss }}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-primary"
|
||||
:disabled="busy || !resultUrl"
|
||||
@click="exportPng"
|
||||
>
|
||||
PNG 저장
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="panes">
|
||||
<section class="pane">
|
||||
<h2>원본</h2>
|
||||
<div class="frame">
|
||||
<img :src="sourceUrl!" alt="원본" />
|
||||
</div>
|
||||
</section>
|
||||
<section class="pane">
|
||||
<h2>결과</h2>
|
||||
<div class="frame frame--checker">
|
||||
<img v-if="resultUrl" :src="resultUrl" alt="배경 제거 결과" />
|
||||
<p v-else class="muted">처리 중…</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-if="dropActive && hasImage"
|
||||
class="drop-overlay"
|
||||
aria-hidden="true"
|
||||
>
|
||||
이미지를 놓아 바꾸기
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bg-page {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
background: var(--canvas);
|
||||
}
|
||||
|
||||
.workspace {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 0.85rem;
|
||||
}
|
||||
|
||||
.empty {
|
||||
height: 100%;
|
||||
min-height: min(60vh, 28rem);
|
||||
display: grid;
|
||||
place-content: center;
|
||||
justify-items: center;
|
||||
gap: 0.75rem;
|
||||
text-align: center;
|
||||
color: var(--ink);
|
||||
border: 1px dashed transparent;
|
||||
border-radius: 6px;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.empty.is-drop-active {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.empty p {
|
||||
margin: 0;
|
||||
max-width: 28rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
min-height: 2.5rem;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 4px;
|
||||
padding: 0.4rem 0.9rem;
|
||||
background: var(--accent-soft);
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.url-form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
width: min(100%, 28rem);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.url-form input {
|
||||
flex: 1 1 12rem;
|
||||
min-height: 2.5rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 0.35rem 0.55rem;
|
||||
background: var(--canvas);
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.url-form button {
|
||||
min-height: 2.5rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 0.35rem 0.75rem;
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.url-form button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.url-form--inline {
|
||||
width: auto;
|
||||
flex: 1 1 16rem;
|
||||
margin-top: 0;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.url-form--inline input {
|
||||
min-height: 2.25rem;
|
||||
}
|
||||
|
||||
.url-form--inline button {
|
||||
min-height: 2.25rem;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: end;
|
||||
gap: 0.75rem 1rem;
|
||||
margin-bottom: 0.85rem;
|
||||
padding: 0.65rem 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
min-width: 10rem;
|
||||
flex: 1 1 12rem;
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.field--mode {
|
||||
flex: 0 1 16rem;
|
||||
}
|
||||
|
||||
.field__row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.field__row em {
|
||||
font-style: normal;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.field select,
|
||||
.field input[type='range'] {
|
||||
width: 100%;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.field select {
|
||||
min-height: 2.25rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 0.3rem 0.45rem;
|
||||
background: var(--canvas);
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.swatch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
min-height: 2.25rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.swatch__chip {
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.panes {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.85rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.pane {
|
||||
min-width: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.pane h2 {
|
||||
margin: 0 0 0.45rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.frame {
|
||||
display: inline-block;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--surface);
|
||||
overflow: auto;
|
||||
vertical-align: top;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.frame--checker {
|
||||
background-color: #c8c8c8;
|
||||
background-image:
|
||||
linear-gradient(45deg, #efefef 25%, transparent 25%),
|
||||
linear-gradient(-45deg, #efefef 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #efefef 75%),
|
||||
linear-gradient(-45deg, transparent 75%, #efefef 75%);
|
||||
background-size: 20px 20px;
|
||||
background-position:
|
||||
0 0,
|
||||
0 10px,
|
||||
10px -10px,
|
||||
-10px 0;
|
||||
}
|
||||
|
||||
.frame img {
|
||||
display: block;
|
||||
width: auto;
|
||||
height: auto;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.muted {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.drop-overlay {
|
||||
position: absolute;
|
||||
inset: 0.75rem;
|
||||
z-index: 20;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 2px dashed var(--accent);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--accent-soft) 88%, transparent);
|
||||
color: var(--ink);
|
||||
font-weight: 650;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.viewer-message {
|
||||
margin: 0;
|
||||
padding: 0.45rem 0.85rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.viewer-message--error {
|
||||
color: #b42318;
|
||||
background: color-mix(in srgb, #b42318 10%, var(--surface));
|
||||
}
|
||||
|
||||
.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) {
|
||||
.panes {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.controls {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.controls .btn-primary {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -9,6 +9,9 @@ export default defineConfig({
|
||||
'@': path.resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: ['ppu-paddle-ocr', 'onnxruntime-web'],
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
@@ -22,4 +25,5 @@ export default defineConfig({
|
||||
outDir: path.resolve(__dirname, '../backend/public'),
|
||||
emptyOutDir: true,
|
||||
},
|
||||
assetsInclude: ['**/*.onnx', '**/*.ort', '**/*.wasm'],
|
||||
});
|
||||
Reference in new issue
Block a user