From 2967548ff3392c8c671cad38214d426727a1927d Mon Sep 17 00:00:00 2001 From: Sejong Yoo Date: Mon, 27 Jul 2026 11:30:58 +0900 Subject: [PATCH] .ai, .psd support --- package-lock.json | 17 + packages/frontend/package.json | 1 + packages/frontend/src/App.vue | 4 + packages/frontend/src/config/app.ts | 4 +- packages/frontend/src/config/favicons.ts | 12 + .../src/integrations/illustrator/loadAi.ts | 117 +++++ .../src/integrations/photoshop/loadPsd.ts | 57 +++ packages/frontend/src/router/index.ts | 10 + packages/frontend/src/views/HomeView.vue | 23 +- .../src/views/IllustratorViewerView.vue | 476 ++++++++++++++++++ .../src/views/PhotoshopViewerView.vue | 421 ++++++++++++++++ 11 files changed, 1136 insertions(+), 6 deletions(-) create mode 100644 packages/frontend/src/integrations/illustrator/loadAi.ts create mode 100644 packages/frontend/src/integrations/photoshop/loadPsd.ts create mode 100644 packages/frontend/src/views/IllustratorViewerView.vue create mode 100644 packages/frontend/src/views/PhotoshopViewerView.vue diff --git a/package-lock.json b/package-lock.json index 252614a..543c8b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4309,6 +4309,22 @@ "node": ">= 0.6" } }, + "node_modules/ag-psd": { + "version": "31.0.2", + "resolved": "https://registry.npmjs.org/ag-psd/-/ag-psd-31.0.2.tgz", + "integrity": "sha512-5s5PvqbomPIIJ9YjL6robz55rT4RYPiQkww4brisyZEb5jzlHB1EKh47IJg7gIFj0RUrD0cMgdntM907qaZjXA==", + "license": "MIT", + "dependencies": { + "base64-js": "1.5.1", + "pako": "2.1.0" + } + }, + "node_modules/ag-psd/node_modules/pako": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", + "license": "(MIT AND Zlib)" + }, "node_modules/ai": { "version": "5.0.217", "resolved": "https://registry.npmjs.org/ai/-/ai-5.0.217.tgz", @@ -9349,6 +9365,7 @@ "@pdf-lib/fontkit": "^1.1.1", "@rhwp/editor": "^0.7.18", "@vueuse/core": "^11.3.0", + "ag-psd": "^31.0.2", "draco3d": "^1.5.7", "element-plus": "^2.14.3", "fflate": "^0.8.2", diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 83326b8..d35b829 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -26,6 +26,7 @@ "@pdf-lib/fontkit": "^1.1.1", "@rhwp/editor": "^0.7.18", "@vueuse/core": "^11.3.0", + "ag-psd": "^31.0.2", "draco3d": "^1.5.7", "element-plus": "^2.14.3", "fflate": "^0.8.2", diff --git a/packages/frontend/src/App.vue b/packages/frontend/src/App.vue index 3fb73a2..359d18b 100644 --- a/packages/frontend/src/App.vue +++ b/packages/frontend/src/App.vue @@ -25,6 +25,8 @@ const viewKey = computed(() => { route.name === 'drawio-editor' || route.name === 'cad-viewer' || route.name === 'model3d-viewer' || + route.name === 'illustrator-viewer' || + route.name === 'photoshop-viewer' || route.name === 'tools-scanner' || route.name === 'tools-bg-remove' ) { @@ -44,6 +46,8 @@ const viewKey = computed(() => { 'DrawioEditorView', 'CadViewerView', 'Model3DViewerView', + 'IllustratorViewerView', + 'PhotoshopViewerView', 'ToolsScannerView', 'ToolsBgRemoveView', ]" diff --git a/packages/frontend/src/config/app.ts b/packages/frontend/src/config/app.ts index 91175f4..25a58ad 100644 --- a/packages/frontend/src/config/app.ts +++ b/packages/frontend/src/config/app.ts @@ -6,7 +6,7 @@ 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"; export type ToolKind = "scanner"; const DIAGRAM_EXTENSION_KIND: Record = { @@ -66,6 +66,8 @@ export const MODEL3D_EXTENSIONS = [ const VIEWER_EXTENSION_KIND: Record = { dxf: "cad", dwg: "cad", + ai: "illustrator", + psd: "photoshop", ...Object.fromEntries( MODEL3D_EXTENSIONS.map((ext) => [ext, "model3d" as const]), ), diff --git a/packages/frontend/src/config/favicons.ts b/packages/frontend/src/config/favicons.ts index bfd8444..329df25 100644 --- a/packages/frontend/src/config/favicons.ts +++ b/packages/frontend/src/config/favicons.ts @@ -18,6 +18,8 @@ export type FaviconKind = | 'diagram' | 'cad' | 'model3d' + | 'illustrator' + | 'photoshop' | 'scanner' | 'bgremove' | 'text' @@ -40,6 +42,12 @@ export const ICON_PATHS: Record = { 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', @@ -79,6 +87,8 @@ export const ICON_COLORS: Record = { diagram: '#2e7d8a', cad: '#5c6bc0', model3d: '#6d4c41', + illustrator: '#f57c00', + photoshop: '#31a8ff', scanner: '#00897b', bgremove: '#5c6bc0', text: '#607d8b', @@ -111,6 +121,8 @@ const EXT_KIND: Record = { xml: 'diagram', dxf: 'cad', dwg: 'cad', + ai: 'illustrator', + psd: 'photoshop', '3dm': 'model3d', '3ds': 'model3d', '3mf': 'model3d', diff --git a/packages/frontend/src/integrations/illustrator/loadAi.ts b/packages/frontend/src/integrations/illustrator/loadAi.ts new file mode 100644 index 0000000..f2ed508 --- /dev/null +++ b/packages/frontend/src/integrations/illustrator/loadAi.ts @@ -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 = 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 { + 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 { + 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((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'; +} diff --git a/packages/frontend/src/integrations/photoshop/loadPsd.ts b/packages/frontend/src/integrations/photoshop/loadPsd.ts new file mode 100644 index 0000000..905123f --- /dev/null +++ b/packages/frontend/src/integrations/photoshop/loadPsd.ts @@ -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 }; +} diff --git a/packages/frontend/src/router/index.ts b/packages/frontend/src/router/index.ts index 82ada3d..e675127 100644 --- a/packages/frontend/src/router/index.ts +++ b/packages/frontend/src/router/index.ts @@ -44,6 +44,16 @@ 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: '/tools/scanner', name: 'tools-scanner', diff --git a/packages/frontend/src/views/HomeView.vue b/packages/frontend/src/views/HomeView.vue index 027db89..a384ee5 100644 --- a/packages/frontend/src/views/HomeView.vue +++ b/packages/frontend/src/views/HomeView.vue @@ -60,6 +60,18 @@ 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)', + }, ] as const; const toolTypes = [ @@ -122,7 +134,7 @@ function onOpenTool(route: string) { - 오피스·PDF · HWP/HWPX · 다이어그램 · CAD · 3D · 이미지(스캐너) + 오피스·PDF · HWP/HWPX · 다이어그램 · CAD · 3D · 일러스트(.ai) · 포토샵(.psd) · 이미지(스캐너) @@ -185,10 +197,11 @@ function onOpenTool(route: string) {
-

CAD·3D 모델

+

뷰어

- 서버 업로드 없이 도면과 모델을 조회합니다. 텍스처·MTL·BIN 등 보조 - 파일이나 ZIP은 원본과 함께 선택하세요. + 서버 업로드 없이 도면·모델·일러스트·포토샵을 조회합니다. 텍스처·MTL·BIN + 등 보조 파일이나 ZIP은 원본과 함께 선택하세요. .ai는 PDF 호환, .psd는 + RGB·8bit만 안정적으로 열립니다.

diff --git a/packages/frontend/src/views/IllustratorViewerView.vue b/packages/frontend/src/views/IllustratorViewerView.vue new file mode 100644 index 0000000..bb59a69 --- /dev/null +++ b/packages/frontend/src/views/IllustratorViewerView.vue @@ -0,0 +1,476 @@ + + + + + diff --git a/packages/frontend/src/views/PhotoshopViewerView.vue b/packages/frontend/src/views/PhotoshopViewerView.vue new file mode 100644 index 0000000..8618df9 --- /dev/null +++ b/packages/frontend/src/views/PhotoshopViewerView.vue @@ -0,0 +1,421 @@ + + + + +