diff --git a/.env b/.env index 25d300a..85c1c0f 100644 --- a/.env +++ b/.env @@ -22,7 +22,7 @@ REDIS_PORT=6379 REDIS_PASSWORD= # 5. MinIO 로컬 S3 스토리지 설정 (Docker 연동) -MINIO_ENDPOINT=http://localhost:9000 +MINIO_ENDPOINT=http://172.16.40.52:9000 MINIO_ACCESSKEYID=minio_access_key MINIO_SECRETACCESSKEY=minio_secret_key diff --git a/README.md b/README.md index 9f9e118..66c3d63 100644 --- a/README.md +++ b/README.md @@ -118,12 +118,24 @@ PM_ver4/ 2. **사용자 삭제 제한**: 프로젝트 권한 매핑 테이블(`tb_permission`)에 해당 유저가 참여 중인 현장 정보가 등록되어 있을 경우 사용자 계정 삭제를 제한합니다. 3. **공통 코드 삭제 제한**: 대분류 마스터 코드(`code_master`) 하위에 소분류 세부 코드(`code_detail`)가 단 1개라도 생성되어 있을 경우 대분류 코드 삭제를 방지합니다. -### 4.2 시스템 글로벌 보존 정책 및 정기 청소 스케줄러 -시스템의 디스크 용량 관리 및 보안 가이드라인에 따라 자동 파일 삭제 스케줄러가 탑재되어 작동합니다. -* **동작 주기**: 매일 자정 배치 구동 (`node-cron` 또는 백그라운드 스케줄러 엔진) -* **보존 규칙**: `tb_system_policy` 테이블에서 `GLOBAL_DELETE_POLICY` 설정 정보를 로드하여 활성화 여부(`is_active=true`), 보존 기한(`limit_days`), 최대 파일 개수(`limit_file_count`) 기준을 확인합니다. -* **삭제 기법**: 삭제 기준을 충족하는 아카이브 임시 파일들을 MinIO/R2 스토리지에서 제거하고 DB 내 파일 메타데이터 상태를 업데이트합니다. -* **기록 적재**: 작업의 수행 일시, 삭제 경로, 적용된 정책 기준 및 성공 여부를 `tb_auto_clean_log` 테이블에 `SYSTEM` 작업자 식별자로 기록하여 감사 이력을 보존합니다. +### 4.2 시스템 글로벌 보존 정책 및 자동 삭제 실행 방식 +시스템의 디스크 용량 관리 및 보안 가이드라인에 따라 자동 파일 삭제 정책이 탑재되어 작동합니다. + +#### 1) 적용 기준 (Criteria) +* `tb_system_policy` 테이블의 `GLOBAL_DELETE_POLICY` 설정(활성화 여부 `is_active`, 보존 기한 `limit_days`, 기준 파일 개수 `limit_file_count`)을 기준으로 작동합니다. +* **대상**: 아카이브의 **3단계 폴더** (`is_folder = true` 및 `data_depth = 3`) 중 아래 두 조건을 동시에 만족하는 폴더입니다. + - 해당 폴더 하위(4단계 이하)의 유효한 파일 개수(삭제되지 않은 파일)가 `limit_file_count` 미만인 경우. + - 폴더의 최종 변경 활동 시각(`last_folder_act_date`)으로부터 `limit_days` 일이 경과한 경우. + +#### 2) 실행 시점 및 작동 방식 (Execution & Deletion Timing) +정책이 활성화(`is_active=true`)된 경우, 다음 두 가지 시점에 조건에 부합하는 폴더와 하위 파일들이 자동으로 감지되어 **휴지통으로 즉시 이동(Soft-Delete: `is_removed = true`)** 처리됩니다. + +1. **실시간 감지 (사용자 접속 시)**: + - 사용자가 아카이브 화면(메인 트리 화면)에 진입하여 폴더 목록이 렌더링될 때, 화면단(`pageRenderer.js`)에서 만료가 경과한 3단계 폴더를 즉시 탐색합니다. + - 탐색 성공 시 백엔드 API(`removeTarget`)를 통해 폴더 및 하위 파일들을 휴지통으로 이동시킵니다. 이 자동 처리 과정은 **참관인(Viewer)이나 작업자(Worker) 등 낮은 권한을 지닌 사용자 세션에서도 정상 수행되도록 예외 처리(Bypass)**되어 있으며, 최신 DB 설정 기준 및 만료 시간을 백엔드에서 2차 검증(Double-Check)하여 안전성을 확보합니다. +2. **정기 배치 스케줄러 (매일 자정 00:00)**: + - 사용자가 접속하지 않더라도, 백엔드 서버에서 실행 중인 백그라운드 배치 엔진(`scheduler.js`)이 매일 자정에 한 번씩 데이터베이스를 직접 조회하여 조건에 부합하는 대상을 자동 감지하고 휴지통으로 이동시킵니다. + - 작업 결과(성공 여부, 대상 경로, 적용 기준 등)는 `tb_auto_clean_log` 테이블에 `SYSTEM` 작업자 식별자로 기록됩니다. ### 4.3 시스템 활동 로그(tb_log) 활동유형 정의 시스템 내 모든 파일 관리, 사용자 권한 및 프로젝트 설정 관련 중요 감사 로그는 `tb_log` 테이블에 기록되며, `activity` 컬럼에 설정되는 주요 활동유형은 다음과 같습니다. diff --git a/controllers/admin/adminController.js b/controllers/admin/adminController.js index f609f55..d1fd6b3 100644 --- a/controllers/admin/adminController.js +++ b/controllers/admin/adminController.js @@ -76,7 +76,7 @@ exports.getProjects = async (req, res) => { }; exports.createProject = async (req, res) => { - const { project_id, project_nm, short_nm, category, limit_storage, is_active } = req.body; + const { project_id, project_nm, short_nm, category, limit_storage, is_active, overview } = req.body; if (!project_id || !project_nm) { return res.status(400).json({ error: "프로젝트 ID와 명칭은 필수입니다." }); } @@ -93,8 +93,8 @@ exports.createProject = async (req, res) => { const storage_byte = limit_storage ? parseInt(limit_storage) * 1024 * 1024 * 1024 : 0; const query = ` - INSERT INTO ver4.${tbProject} (project_id, project_nm, short_nm, category, storage_byte, is_active, user_id, create_date) - VALUES ($1, $2, $3, $4, $5, $6, $7, CURRENT_TIMESTAMP) + INSERT INTO ver4.${tbProject} (project_id, project_nm, short_nm, category, storage_byte, is_active, user_id, overview, create_date) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, CURRENT_TIMESTAMP) RETURNING *; `; const result = await client.query(query, [ @@ -104,13 +104,15 @@ exports.createProject = async (req, res) => { category || null, storage_byte, is_active ?? true, - req.user?.user_id || 'admin' + req.user?.user_id || 'admin', + overview !== false // 기본값 true ]); const userIp = req.headers['cf-connecting-ip'] || req.ip || req.headers['x-forwarded-for'] || req.connection?.remoteAddress; await insertAuditLog(project_id, 'createProject', req.user?.user_id, userIp, [ `Project Name: ${project_nm}`, `Category: ${category}`, - `Storage limit: ${limit_storage} GB` + `Storage limit: ${limit_storage} GB`, + `Overview enabled: ${overview !== false}` ]); res.status(201).json(result.rows[0]); @@ -124,18 +126,18 @@ exports.createProject = async (req, res) => { exports.updateProject = async (req, res) => { const { id } = req.params; - const { project_nm, short_nm, category, limit_storage, is_active } = req.body; + const { project_nm, short_nm, category, limit_storage, is_active, overview } = req.body; const client = await pool.connect(); try { const storage_byte = limit_storage ? parseInt(limit_storage) * 1024 * 1024 * 1024 : 0; const query = ` UPDATE ver4.${tbProject} - SET project_nm = $1, short_nm = $2, category = $3, storage_byte = $4, is_active = $5 - WHERE project_id = $6 + SET project_nm = $1, short_nm = $2, category = $3, storage_byte = $4, is_active = $5, overview = $6 + WHERE project_id = $7 RETURNING *; `; - const result = await client.query(query, [project_nm, short_nm || null, category || null, storage_byte, is_active, id]); + const result = await client.query(query, [project_nm, short_nm || null, category || null, storage_byte, is_active, overview, id]); if (result.rows.length === 0) { return res.status(404).json({ error: "대상을 찾을 수 없습니다." }); } @@ -144,7 +146,8 @@ exports.updateProject = async (req, res) => { `Project Name: ${project_nm}`, `Category: ${category}`, `Storage limit: ${limit_storage} GB`, - `Active status: ${is_active}` + `Active status: ${is_active}`, + `Overview enabled: ${overview}` ]); res.status(200).json(result.rows[0]); } catch (err) { @@ -517,37 +520,62 @@ exports.createUser = async (req, res) => { exports.updateUser = async (req, res) => { const { id } = req.params; - const { user_nm, company, dept, position, group, is_resigned } = req.body; + const { user_nm, user_pw, company, dept, position, group, is_resigned } = req.body; const client = await pool.connect(); try { - const query = ` - UPDATE ver4.tb_user - SET user_nm = $1, company = $2, dept = $3, position = $4, "group" = $5, is_resigned = $6 - WHERE user_id = $7 - RETURNING *; - `; - const result = await client.query(query, [ - user_nm, - company || null, - dept || null, - position || null, - group || null, - is_resigned, - id - ]); + let result; + if (user_pw && user_pw.trim() !== '') { + const passwordHash = crypto.createHash('sha256').update(user_pw).digest('hex'); + const query = ` + UPDATE ver4.tb_user + SET user_nm = $1, user_pw = $2, company = $3, dept = $4, position = $5, "group" = $6, is_resigned = $7 + WHERE user_id = $8 + RETURNING *; + `; + result = await client.query(query, [ + user_nm, + passwordHash, + company || null, + dept || null, + position || null, + group || null, + is_resigned, + id + ]); + } else { + const query = ` + UPDATE ver4.tb_user + SET user_nm = $1, company = $2, dept = $3, position = $4, "group" = $5, is_resigned = $6 + WHERE user_id = $7 + RETURNING *; + `; + result = await client.query(query, [ + user_nm, + company || null, + dept || null, + position || null, + group || null, + is_resigned, + id + ]); + } if (result.rows.length === 0) { return res.status(404).json({ error: "대상을 찾을 수 없습니다." }); } const user = result.rows[0]; const userIp = req.headers['cf-connecting-ip'] || req.ip || req.headers['x-forwarded-for'] || req.connection?.remoteAddress; - await insertAuditLog('SYSTEM', 'updateUser', req.user?.user_id, userIp, [ + const details = [ `Updated user_id: ${id}`, `User name: ${user_nm}`, `Group: ${group}`, `Is resigned: ${is_resigned}` - ]); + ]; + if (user_pw && user_pw.trim() !== '') { + details.push("Password was updated"); + } + await insertAuditLog('SYSTEM', 'updateUser', req.user?.user_id, userIp, details); user.user_pw = undefined; res.status(200).json(user); } catch (err) { @@ -671,6 +699,13 @@ exports.updateSystemPolicy = async (req, res) => { `Limit days: ${limit_days}`, `Is active: ${is_active}` ]); + try { + const { getIo } = require('../../socket.js'); + const io = getIo(); + io.emit('updateSystemPolicy_success', result.rows[0]); + } catch (socketErr) { + console.error("Failed to emit updateSystemPolicy_success:", socketErr.message); + } res.status(200).json(result.rows[0]); } catch (err) { console.error("updateSystemPolicy Error:", err); diff --git a/controllers/archiveController.js b/controllers/archiveController.js index 655a52d..ec45ec9 100644 --- a/controllers/archiveController.js +++ b/controllers/archiveController.js @@ -2581,6 +2581,64 @@ exports.uploadData = async (req, res, next) => { let insertLogResult = await insertLog(params); if (insertLogResult.message == 'insertLog_success') { + // PPT/PPTX 파일 업로드 즉시 PDF 변환 처리 + for (let i = 0; i < insertDataResult.rows.length; i++) { + try { + let row = insertDataResult.rows[i]; + let resourcePath = params.resourcePathArr[i]; + let ext = (resourcePath.split('.').pop()).replace('.', '').toLowerCase(); + + if (['ppt', 'pptx'].includes(ext)) { + let dataId = row.data_id; + let objectKey = params.objectKeyArr[i]; + let storageType = params.storageType; + let userInfoString = params.userInfoString; + let userIp = req.ip; + let bucket = projectId; + + let command = new GetObjectCommand({ + Bucket: bucket, + Key: objectKey, + }); + + let url = await getSignedUrl(s3, command, { expiresIn: 60 * 60 * 6 }); // 유효시간 6시간 + + let initiator = `DEV_LOCAL_${JSON.parse(userInfoString).user_id}`; + let type = 'archive'; + if (env == 'production') { + if (deploymentType == 'ONPREMISE') initiator = 'HYHC_ONPREMISE'; + if (deploymentType == 'CLOUD') initiator = `AWS_CLOUD_${cloudType}`; + } + + const job = await convertPdfQueue.add( + `'${initiator}'에서 문서를 PDF로 변환`, + { resourcePath, url, objectKey, bucket, storageType, dataId, projectId, userInfoString, userIp, initiator, type, serviceName } + ); + + let resourcePathClean = resourcePath.startsWith('/') ? resourcePath.slice(1) : resourcePath; + let pathArray = getPathArray(resourcePathClean); + + convertingDataArr.push({ + dataId: dataId, + resourcePath: resourcePath, + depth1: pathArray[0], + depth2: pathArray[1], + depth3: pathArray[2], + jobId: job.id + }); + + // 변환 시작 소켓 전송 + let startEventData = { projectId: projectId, resourcePath: resourcePath, convertingDataArr: convertingDataArr }; + let io = getIo(); + io.emit('convert_start', startEventData); + + console.log(`[PPT Auto-Convert] queued job ${job.id} for dataId ${dataId} (${resourcePath})`); + } + } catch (autoErr) { + console.error(`[PPT Auto-Convert Failed] index ${i}:`, autoErr); + } + } + let resultData = { message: 'uploadData_success', projectId: projectId, @@ -2881,8 +2939,81 @@ exports.removeTarget = async(req, res) => { let permission = JSON.parse(params.userInfoString).permission; let depth = getDepth(params.resourcePathArr[0]); let isRecycleBinModal = params.isRecycleBinModal; + const isExpiredFolder = params.isExpiredFolder === true; - if (!isRecycleBinModal && (depth == 1 && permission < 191) || (depth >= 2 && permission < 7)) { + if (isExpiredFolder) { + // 자동 기한 만료 삭제의 경우 안전 검증 + if (depth !== 3 || params.dataType !== 'folder') { + return res.status(400).json({ + message: 'removeTarget_failed', + error: '보존 정책 자동 삭제는 3단계 폴더만 대상이 될 수 있습니다.' + }); + } + + const client = await pool.connect(); + try { + // 1. 최신 시스템 정책 조회 + const policyRes = await client.query("SELECT * FROM ver4.tb_system_policy WHERE policy_key = 'GLOBAL_DELETE_POLICY'"); + if (policyRes.rows.length === 0 || !policyRes.rows[0].is_active) { + return res.status(400).json({ + message: 'removeTarget_failed', + error: '자동 삭제 정책이 비활성화 상태입니다.' + }); + } + const { limit_file_count, limit_days } = policyRes.rows[0]; + + // 2. 해당 폴더의 실제 정보 조회 (최종 활동 시각 확인) + const resourcePath = params.resourcePathArr[0]; + const projectId = req.baseUrl.split('/')[1]; + const folderQuery = ` + SELECT last_folder_act_date + FROM ver4.${tbData} + WHERE project_id = $1 AND is_folder = true AND data_depth = 3 AND is_removed = false + AND path1 = $2 AND path2 = $3 AND path3 = $4; + `; + const folderRes = await client.query(folderQuery, [ + projectId, + getPathSegment(resourcePath, 1), + getPathSegment(resourcePath, 2), + getPathSegment(resourcePath, 3) + ]); + + if (folderRes.rows.length === 0) { + return res.status(404).json({ + message: 'removeTarget_failed', + error: '해당 폴더를 찾을 수 없거나 이미 삭제되었습니다.' + }); + } + + const lastFolderActDate = new Date(folderRes.rows[0].last_folder_act_date); + const expiryDate = new Date(lastFolderActDate.getTime() + limit_days * 24 * 60 * 60 * 1000); + if (expiryDate > new Date()) { + return res.status(400).json({ + message: 'removeTarget_failed', + error: `해당 폴더는 아직 만료 기한이 지나지 않았습니다. (남은 기한 검증 실패)` + }); + } + + // 3. 하위 파일 개수 계산 + const filesCount = await getFilesCount(projectId, params.storageType || 'ONPREMISE', resourcePath); + if (Number(filesCount) >= limit_file_count) { + return res.status(400).json({ + message: 'removeTarget_failed', + error: `해당 폴더의 파일 개수가 기준(${limit_file_count}개) 이상입니다. (파일 개수 검증 실패)` + }); + } + } catch (err) { + console.error("isExpiredFolder verification error:", err); + return res.status(500).json({ + message: 'removeTarget_failed', + error: '만료 폴더 검증 처리 중 오류가 발생했습니다.' + }); + } finally { + client.release(); + } + } + + if (!isExpiredFolder && !isRecycleBinModal && ((depth == 1 && permission < 191) || (depth >= 2 && permission < 7))) { return res.status(200).json({ message: 'removeTarget_failed_permission', }); @@ -3221,7 +3352,7 @@ exports.addConvetPdfLog = async(req, res) => { exports.removeConvertingData = async(req, res) => { const projectId = req.baseUrl.split('/')[1]; let { params } = req.body; - let { resourcePath, dataId, userInfoString } = params; + let { resourcePath, dataId, userInfoString, stdout } = params; //// 배열에서 파일 정보 삭제 convertingDataArr = convertingDataArr.filter(data => data.dataId !== dataId); @@ -3238,7 +3369,8 @@ exports.removeConvertingData = async(req, res) => { convertingDataArr: convertingDataArr, resourcePath: resourcePath, dataId: dataId, - userInfoString: userInfoString + userInfoString: userInfoString, + stdout: stdout || '' }; let io = getIo(); @@ -4363,6 +4495,7 @@ async function updateThumbnailInfoAction(projectId, params) { } } + // 삭제예정(2025.10.31): 이호성 // exports.get3dViewerThumbUrl = async(req, res, next) => { // const dataId = req.query.dataId; diff --git a/libs/hwp.js b/libs/hwp.js index 76f981c..4692db5 100644 Binary files a/libs/hwp.js and b/libs/hwp.js differ diff --git a/libs/scheduler.js b/libs/scheduler.js index 2f04e9d..c8af654 100644 --- a/libs/scheduler.js +++ b/libs/scheduler.js @@ -1,12 +1,4 @@ const pool = require("../db/pool.js"); -const { DeleteObjectCommand } = require("@aws-sdk/client-s3"); -const onPremiseClient = require('../config/onPremiseClient.js'); -const cloudClient = require('../config/cloudClient.js'); -const storageClients = { - 'ONPREMISE': onPremiseClient, - 'CLOUD': cloudClient -}; -const s3 = storageClients[process.env.DEPLOYMENT_TYPE || 'ONPREMISE']; const env = process.env.NODE_ENV; const tbData = env === 'production' ? 'tb_data' : '_test_tb_data'; @@ -24,67 +16,67 @@ async function runAutoClean() { const { limit_file_count, limit_days } = policyRes.rows[0]; - // 2. 삭제 대상 파일 검색 (각 현장의 파일 개수가 limit_file_count 미만이고, limit_days 일이 지난 파일) + // 2. 삭제 대상 3단계 폴더 검색 + // - is_folder = true 이고 depth = 3 이며 아직 삭제되지 않은 폴더 + // - 하위의 유효 파일 개수가 limit_file_count 미만 + // - last_folder_act_date 가 limit_days 일 이상 경과 const targetQuery = ` - SELECT data_id, project_id, object_key, preview_key, popup_key, thumbnail_key, - path1, path2, path3, path4, path5, path6, path7, path8, data_depth, is_folder - FROM ver4.${tbData} - WHERE is_folder = false AND is_removed = false - AND project_id IN ( - SELECT project_id - FROM ver4.${tbData} - WHERE is_folder = false AND is_removed = false - GROUP BY project_id - HAVING COUNT(*) < $1 - ) - AND create_date < NOW() - CAST($2 || ' days' AS INTERVAL); + SELECT data_id, project_id, path1, path2, path3, last_folder_act_date + FROM ver4.${tbData} f + WHERE f.is_folder = true + AND f.data_depth = 3 + AND f.is_removed = false + AND f.last_folder_act_date < NOW() - CAST($1 || ' days' AS INTERVAL) + AND ( + SELECT COUNT(*) + FROM ver4.${tbData} files + WHERE files.project_id = f.project_id + AND files.path1 = f.path1 + AND files.path2 = f.path2 + AND files.path3 = f.path3 + AND files.is_folder = false + AND files.is_removed = false + ) < $2; `; - const targets = await client.query(targetQuery, [limit_file_count, limit_days]); + const targets = await client.query(targetQuery, [limit_days, limit_file_count]); - console.log(`⏰ Found ${targets.rows.length} files to clean up.`); + console.log(`⏰ Found ${targets.rows.length} folders to clean up.`); - for (const file of targets.rows) { + for (const folder of targets.rows) { let success = true; - let keysToDelete = []; - if (file.object_key) keysToDelete.push(file.object_key); - if (file.preview_key) keysToDelete.push(file.preview_key); - if (file.popup_key) keysToDelete.push(file.popup_key); - if (file.thumbnail_key) keysToDelete.push(file.thumbnail_key); - - // S3 실물 파일 삭제 - for (const key of keysToDelete) { - try { - const command = new DeleteObjectCommand({ - Bucket: file.project_id, // 현장 ID를 버킷 명으로 사용함 - Key: key - }); - await s3.send(command); - } catch (s3Err) { - console.error(`❌ S3 Delete Error [Key: ${key}]:`, s3Err.message); - success = false; - } - } - - // DB 메타 정보 완전 제거 try { - await client.query(`DELETE FROM ver4.${tbData} WHERE data_id = $1`, [file.data_id]); - } catch (dbErr) { - console.error(`❌ DB Delete Error [DataID: ${file.data_id}]:`, dbErr.message); + await client.query('BEGIN'); + + // 폴더 자체 및 그 하위 파일/폴더들을 전부 Soft-Delete (is_removed = true) + const updateQuery = ` + UPDATE ver4.${tbData} + SET is_removed = true, + mod_date = CURRENT_TIMESTAMP, + mod_user_id = 'SYSTEM' + WHERE project_id = $1 + AND path1 = $2 + AND path2 = $3 + AND path3 = $4 + AND is_removed = false; + `; + await client.query(updateQuery, [folder.project_id, folder.path1, folder.path2, folder.path3]); + + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + console.error(`❌ Auto Clean Folder Error [Folder ID: ${folder.data_id}]:`, err.message); success = false; } - // 삭제 경로 조합 - let cleanPath = ''; - for (let i = 1; i <= file.data_depth; i++) { - if (file[`path${i}`]) cleanPath += '/' + file[`path${i}`]; - } - - // 로그 기록 + // 삭제 경로 조합 (/path1/path2/path3) + const cleanPath = `/${folder.path1}/${folder.path2}/${folder.path3}`; const criteria = `보관수량 ${limit_file_count}개 미만 / 기한 ${limit_days}일 경과`; + + // 로그 기록 await client.query(` INSERT INTO ver4.tb_auto_clean_log (clean_date, project_id, clean_path, criteria_info, result_status) - VALUES (CURRENT_TIMESTAMP, 'SYSTEM', $1, $2, $3); - `, [cleanPath, criteria, success ? 'SUCCESS' : 'FAILED']); + VALUES (CURRENT_TIMESTAMP, $1, $2, $3, $4); + `, [folder.project_id, cleanPath, criteria, success ? 'SUCCESS' : 'FAILED']); } console.log("⏰ Auto clean batch job finished successfully."); } catch (err) { diff --git a/local_pdf_worker.js b/local_pdf_worker.js index f7fd43a..96a68cf 100644 --- a/local_pdf_worker.js +++ b/local_pdf_worker.js @@ -87,83 +87,107 @@ const worker = new Worker('convert-pdf', async (job) => { const tempOutputPath = tempInputPath.replace(`.${ext}`, '.pdf'); console.log(`[Job ${job.id}] ⚙️ CLI 변환 실행: ${path.basename(exePath)}`); - // 5. CLI 실행을 통해 PDF 변환 수행 - await new Promise((resolve, reject) => { - execFile(exePath, [tempInputPath, tempOutputPath], (err, stdout, stderr) => { - if (err) { - console.error(`[Job ${job.id}] ❌ 변환 CLI 실행 에러:`, err.message); - return reject(err); - } - console.log(`[Job ${job.id}] 📝 변환기 출력:`, stdout.trim()); - resolve(); - }); - }); + let previewKey = ''; + let popupKey = ''; - if (!fs.existsSync(tempOutputPath)) { - throw new Error(`변환 성공했으나 결과 PDF 파일이 존재하지 않습니다: ${tempOutputPath}`); - } - - // 6. 생성된 PDF를 MinIO 스토리지에 업로드 - console.log(`[Job ${job.id}] 📤 변환된 PDF MinIO 스토리지 업로드 중...`); - const pdfBuffer = fs.readFileSync(tempOutputPath); - - // preview_key, popup_key 네이밍 규칙 적용 - let previewKey = objectKey.replace('archive/origin/', 'archive/preview/').replace(/\.[^.]+$/, '.pdf'); - let popupKey = objectKey.replace('archive/origin/', 'archive/popup/').replace(/\.[^.]+$/, '.pdf'); - - if (previewKey === objectKey) { - previewKey = `archive/preview/${path.basename(objectKey)}`.replace(/\.[^.]+$/, '.pdf'); - } - if (popupKey === objectKey) { - popupKey = `archive/popup/${path.basename(objectKey)}`.replace(/\.[^.]+$/, '.pdf'); - } - - // preview_key 업로드 - await s3.send(new PutObjectCommand({ - Bucket: bucket, - Key: previewKey, - Body: pdfBuffer, - ContentType: 'application/pdf' - })); - - // popup_key 업로드 - await s3.send(new PutObjectCommand({ - Bucket: bucket, - Key: popupKey, - Body: pdfBuffer, - ContentType: 'application/pdf' - })); - console.log(`[Job ${job.id}] 🚀 업로드 성공: \n - Preview Key: ${previewKey}\n - Popup Key: ${popupKey}`); - - // 7. 데이터베이스 레코드 정보 업데이트 (preview_key, popup_key 반영) - console.log(`[Job ${job.id}] 🗄️ 데이터베이스 정보 갱신 중...`); - const envSetting = process.env.NODE_ENV || 'development'; - const tbData = envSetting === 'production' ? 'tb_data' : '_test_tb_data'; - - if (type === 'archive') { - const updateQuery = ` - UPDATE ver4.${tbData} - SET preview_key = $1, popup_key = $2, mod_date = NOW(), mod_activity = 'convertPdf' - WHERE data_id = $3 - `; - await pool.query(updateQuery, [previewKey, popupKey, dataId]); - } else if (type === 'officialDoc') { - const updateQuery = ` - UPDATE ver4.tb_official_doc_file - SET preview_key = $1, popup_key = $2, mod_date = NOW(), mod_activity = 'convertDocPdf' - WHERE doc_id = $3 - `; - await pool.query(updateQuery, [previewKey, popupKey, dataId]); - } - console.log(`[Job ${job.id}] ✅ 데이터베이스 업데이트 완료.`); - - // 8. 로컬 임시 파일 삭제 try { - fs.unlinkSync(tempInputPath); - fs.unlinkSync(tempOutputPath); - console.log(`[Job ${job.id}] 🧹 임시 파일 정리 완료.`); - } catch (cleanErr) { - console.warn(`[Job ${job.id}] ⚠️ 임시 파일 제거 실패:`, cleanErr.message); + // 5. CLI 실행을 통해 PDF 변환 수행 + const conversionResult = await new Promise((resolve) => { + execFile(exePath, [tempInputPath, tempOutputPath], { encoding: 'buffer' }, (err, stdoutBuffer, stderrBuffer) => { + const decoder = new TextDecoder('euc-kr'); + const stdout = decoder.decode(stdoutBuffer || Buffer.alloc(0)).trim(); + const stderr = decoder.decode(stderrBuffer || Buffer.alloc(0)).trim(); + resolve({ err, stdout, stderr }); + }); + }); + + if (conversionResult.stdout) { + console.log(`[Job ${job.id}] 📝 변환기 출력:\n${conversionResult.stdout}`); + } + if (conversionResult.stderr) { + console.error(`[Job ${job.id}] 📝 변환기 에러 출력:\n${conversionResult.stderr}`); + } + + if (!fs.existsSync(tempOutputPath)) { + let errMsg = `PDF 변환 파일 생성에 실패했습니다. (결과 파일이 존재하지 않습니다.)`; + if (conversionResult.stdout) { + errMsg += `\n[변환기 출력]: ${conversionResult.stdout}`; + } + if (conversionResult.stderr) { + errMsg += `\n[변환기 에러]: ${conversionResult.stderr}`; + } + if (conversionResult.err) { + errMsg += `\n[실행 에러]: ${conversionResult.err.message}`; + } + throw new Error(errMsg); + } + + // 6. 생성된 PDF를 MinIO 스토리지에 업로드 + console.log(`[Job ${job.id}] 📤 변환된 PDF MinIO 스토리지 업로드 중...`); + const pdfBuffer = fs.readFileSync(tempOutputPath); + + // preview_key, popup_key 네이밍 규칙 적용 + previewKey = objectKey.replace('archive/origin/', 'archive/preview/').replace(/\.[^.]+$/, '.pdf'); + popupKey = objectKey.replace('archive/origin/', 'archive/popup/').replace(/\.[^.]+$/, '.pdf'); + + if (previewKey === objectKey) { + previewKey = `archive/preview/${path.basename(objectKey)}`.replace(/\.[^.]+$/, '.pdf'); + } + if (popupKey === objectKey) { + popupKey = `archive/popup/${path.basename(objectKey)}`.replace(/\.[^.]+$/, '.pdf'); + } + + // preview_key 업로드 + await s3.send(new PutObjectCommand({ + Bucket: bucket, + Key: previewKey, + Body: pdfBuffer, + ContentType: 'application/pdf' + })); + + // popup_key 업로드 + await s3.send(new PutObjectCommand({ + Bucket: bucket, + Key: popupKey, + Body: pdfBuffer, + ContentType: 'application/pdf' + })); + console.log(`[Job ${job.id}] 🚀 업로드 성공: \n - Preview Key: ${previewKey}\n - Popup Key: ${popupKey}`); + + // 7. 데이터베이스 레코드 정보 업데이트 (preview_key, popup_key 반영) + console.log(`[Job ${job.id}] 🗄️ 데이터베이스 정보 갱신 중...`); + const envSetting = process.env.NODE_ENV || 'development'; + const tbData = envSetting === 'production' ? 'tb_data' : '_test_tb_data'; + + if (type === 'archive') { + const updateQuery = ` + UPDATE ver4.${tbData} + SET preview_key = $1, popup_key = $2, mod_date = NOW(), mod_activity = 'convertPdf' + WHERE data_id = $3 + `; + await pool.query(updateQuery, [previewKey, popupKey, dataId]); + } else if (type === 'officialDoc') { + const updateQuery = ` + UPDATE ver4.tb_official_doc_file + SET preview_key = $1, popup_key = $2, mod_date = NOW(), mod_activity = 'convertDocPdf' + WHERE doc_id = $3 + `; + await pool.query(updateQuery, [previewKey, popupKey, dataId]); + } + console.log(`[Job ${job.id}] ✅ 데이터베이스 업데이트 완료.`); + } finally { + // 8. 로컬 임시 파일 삭제 + try { + if (fs.existsSync(tempInputPath)) { + fs.unlinkSync(tempInputPath); + } + if (fs.existsSync(tempOutputPath)) { + fs.unlinkSync(tempOutputPath); + } + console.log(`[Job ${job.id}] 🧹 임시 파일 정리 완료.`); + } catch (cleanErr) { + console.warn(`[Job ${job.id}] ⚠️ 임시 파일 제거 실패:`, cleanErr.message); + } } // 완료 후 completed 이벤트 리스너로 데이터 리턴 diff --git a/logs/.b654e5b245003e35c7a21bd1b2e78bed5f148798-audit.json b/logs/.b654e5b245003e35c7a21bd1b2e78bed5f148798-audit.json index 8b81878..dc9290d 100644 --- a/logs/.b654e5b245003e35c7a21bd1b2e78bed5f148798-audit.json +++ b/logs/.b654e5b245003e35c7a21bd1b2e78bed5f148798-audit.json @@ -24,6 +24,21 @@ "date": 1781483235835, "name": "D:\\40. 개발소스\\04. PM\\pm_ver4\\trunk\\PM_ver4\\logs\\2026-06-15.exception.log", "hash": "11b2e04cffe03e4f31cf1e53fa6682fb6c1a12e4fb5cf7799e90729ad81b1d0b" + }, + { + "date": 1781567098727, + "name": "D:\\40. 개발소스\\04. PM\\pm_ver4\\trunk\\PM_ver4\\logs\\2026-06-16.exception.log", + "hash": "2b25cb03566dbfe3a7b1f0c371b247298168bfbe6fd7e2ba7cfdb00d612dc0e5" + }, + { + "date": 1781653576334, + "name": "D:\\40. 개발소스\\04. PM\\pm_ver4\\trunk\\PM_ver4\\logs\\2026-06-17.exception.log", + "hash": "5823ed078b7d2a17c33873eda5850d62d9335a37ba0ee9c34953f7040ab1be23" + }, + { + "date": 1781740205874, + "name": "D:\\40. 개발소스\\04. PM\\pm_ver4\\trunk\\PM_ver4\\logs\\2026-06-18.exception.log", + "hash": "b6c375bb37b791ea2408e99580d68d5582d2e051ea204bc0529f30d0d3452da6" } ], "hashType": "sha256" diff --git a/logs/.ca4432f26b21ac44b7757d5574f2dc85546b9be4-audit.json b/logs/.ca4432f26b21ac44b7757d5574f2dc85546b9be4-audit.json index f6e7051..f87c638 100644 --- a/logs/.ca4432f26b21ac44b7757d5574f2dc85546b9be4-audit.json +++ b/logs/.ca4432f26b21ac44b7757d5574f2dc85546b9be4-audit.json @@ -24,6 +24,21 @@ "date": 1781483235833, "name": "D:\\40. 개발소스\\04. PM\\pm_ver4\\trunk\\PM_ver4\\logs\\2026-06-15.error.log", "hash": "1030626feeefe2d3b4a2d169ecc3774fb59d61b8573c967313dec48f1ab60bc5" + }, + { + "date": 1781567098714, + "name": "D:\\40. 개발소스\\04. PM\\pm_ver4\\trunk\\PM_ver4\\logs\\2026-06-16.error.log", + "hash": "4dec9fbb6e0a02431e5c32274ce79d99023312551743ec4cd89aaca6f1d7fdd6" + }, + { + "date": 1781653576319, + "name": "D:\\40. 개발소스\\04. PM\\pm_ver4\\trunk\\PM_ver4\\logs\\2026-06-17.error.log", + "hash": "aea47f4d2a27282d86165d971ee833124401fca007fcdafb616145f9c334dc3c" + }, + { + "date": 1781740205863, + "name": "D:\\40. 개발소스\\04. PM\\pm_ver4\\trunk\\PM_ver4\\logs\\2026-06-18.error.log", + "hash": "c3ea07a16e0988461aee9271b7f5b0faabeadbf8df4f36e428e1e43c313c7738" } ], "hashType": "sha256" diff --git a/logs/2026-06-16.error.log b/logs/2026-06-16.error.log new file mode 100644 index 0000000..83baaf8 --- /dev/null +++ b/logs/2026-06-16.error.log @@ -0,0 +1,9 @@ +2026-06-16 09:08:40 [error 테스트: ] error: null value in column "user_pw" of relation "tb_user" violates not-null constraint +2026-06-16 09:08:42 [error 테스트: ] error: null value in column "user_pw" of relation "tb_user" violates not-null constraint +2026-06-16 09:08:46 [error 테스트: ] error: null value in column "user_pw" of relation "tb_user" violates not-null constraint +2026-06-16 09:08:58 [error 테스트: ] error: null value in column "user_pw" of relation "tb_user" violates not-null constraint +2026-06-16 09:25:49 [error 테스트: ] error: null value in column "user_pw" of relation "tb_user" violates not-null constraint +2026-06-16 09:25:51 [error 테스트: ] error: null value in column "user_pw" of relation "tb_user" violates not-null constraint +2026-06-16 11:41:44 [error 테스트: ] error: null value in column "user_pw" of relation "tb_user" violates not-null constraint +2026-06-16 11:41:46 [error 테스트: ] error: null value in column "user_pw" of relation "tb_user" violates not-null constraint +2026-06-16 11:42:38 [error 테스트: ] error: null value in column "user_pw" of relation "tb_user" violates not-null constraint diff --git a/logs/2026-06-16.exception.log b/logs/2026-06-16.exception.log new file mode 100644 index 0000000..e69de29 diff --git a/logs/2026-06-17.error.log b/logs/2026-06-17.error.log new file mode 100644 index 0000000..e69de29 diff --git a/logs/2026-06-17.exception.log b/logs/2026-06-17.exception.log new file mode 100644 index 0000000..e69de29 diff --git a/logs/2026-06-18.error.log b/logs/2026-06-18.error.log new file mode 100644 index 0000000..e69de29 diff --git a/logs/2026-06-18.exception.log b/logs/2026-06-18.exception.log new file mode 100644 index 0000000..e69de29 diff --git a/queue.js b/queue.js index e1a8f3a..b4aa2e5 100644 --- a/queue.js +++ b/queue.js @@ -61,7 +61,7 @@ convertPdfQueueEvents.on('failed', async ({ jobId, failedReason }) => { console.log(''); // let resultData = (job.progress == 0) ? job.data : job.progress; - let resultData = { jobData: job.data, jobProgress: job.progress }; + let resultData = { jobData: job.data, jobProgress: job.progress, failedReason: failedReason }; let io = getIo(); io.emit('convertPdf_failed', resultData); diff --git a/scratch_check_policy.js b/scratch_check_policy.js new file mode 100644 index 0000000..938bccf --- /dev/null +++ b/scratch_check_policy.js @@ -0,0 +1,25 @@ +const pool = require("./db/pool.js"); + +async function checkPolicy() { + const client = await pool.connect(); + try { + console.log("=== tb_system_policy content ==="); + const policyRes = await client.query("SELECT * FROM ver4.tb_system_policy;"); + console.log(policyRes.rows); + + console.log("\n=== tb_auto_clean_log count ==="); + const countRes = await client.query("SELECT COUNT(*) FROM ver4.tb_auto_clean_log;"); + console.log(countRes.rows[0]); + + console.log("\n=== tb_auto_clean_log (latest 10 entries) ==="); + const logsRes = await client.query("SELECT * FROM ver4.tb_auto_clean_log ORDER BY log_id DESC LIMIT 10;"); + console.log(logsRes.rows); + } catch (err) { + console.error("Database query failed:", err); + } finally { + client.release(); + await pool.end(); + } +} + +checkPolicy(); diff --git a/server.js b/server.js index b6e7c29..97ed186 100644 --- a/server.js +++ b/server.js @@ -1,6 +1,49 @@ +// restarted for .env change const app = require('./app'); const http = require('http'); const socket = require('./socket'); +const net = require('net'); + +// MinIO TCP Port Proxy Helper (WSL/Docker Desktop loopback bridge) +function startMinioProxy() { + const ports = [9000, 9001]; + ports.forEach(port => { + const server = net.createServer((clientSocket) => { + const targetSocket = net.connect(port, '127.0.0.1', () => { + clientSocket.pipe(targetSocket); + targetSocket.pipe(clientSocket); + }); + + clientSocket.on('error', (err) => { + targetSocket.destroy(); + }); + targetSocket.on('error', (err) => { + clientSocket.destroy(); + }); + clientSocket.on('close', () => { + targetSocket.destroy(); + }); + targetSocket.on('close', () => { + clientSocket.destroy(); + }); + }); + + server.on('error', (err) => { + if (err.code === 'EADDRINUSE') { + console.log(`[MinIO Proxy] Port ${port} is already in use. Skipping proxy initialization.`); + } else { + console.error(`[MinIO Proxy] Port ${port} error:`, err); + } + }); + + server.listen(port, '0.0.0.0', () => { + console.log(`>> [MinIO Proxy] Listening on 0.0.0.0:${port} -> 127.0.0.1:${port}`); + }); + }); +} + +// Start MinIO Proxy +startMinioProxy(); const server = http.createServer(app); socket.init(server); // 웹소켓 초기화 diff --git a/temp_convert/input_15_1781576604096.docx b/temp_convert/input_15_1781576604096.docx new file mode 100644 index 0000000..c949ad1 Binary files /dev/null and b/temp_convert/input_15_1781576604096.docx differ diff --git a/temp_convert/input_19_1781576941674.docx b/temp_convert/input_19_1781576941674.docx new file mode 100644 index 0000000..c949ad1 Binary files /dev/null and b/temp_convert/input_19_1781576941674.docx differ diff --git a/temp_convert/input_1_1781504351932.hwpx b/temp_convert/input_1_1781504351932.hwpx deleted file mode 100644 index b1ffd61..0000000 Binary files a/temp_convert/input_1_1781504351932.hwpx and /dev/null differ diff --git a/temp_convert/input_2_1781504354837.docx b/temp_convert/input_2_1781504354837.docx deleted file mode 100644 index e1ede68..0000000 Binary files a/temp_convert/input_2_1781504354837.docx and /dev/null differ diff --git a/temp_convert/input_3_1781504356321.docx b/temp_convert/input_3_1781504356321.docx deleted file mode 100644 index e1ede68..0000000 Binary files a/temp_convert/input_3_1781504356321.docx and /dev/null differ diff --git a/temp_convert/input_4_1781504357435.hwpx b/temp_convert/input_4_1781504357435.hwpx deleted file mode 100644 index b1ffd61..0000000 Binary files a/temp_convert/input_4_1781504357435.hwpx and /dev/null differ diff --git a/temp_convert/input_5_1781504358912.hwpx b/temp_convert/input_5_1781504358912.hwpx deleted file mode 100644 index b1ffd61..0000000 Binary files a/temp_convert/input_5_1781504358912.hwpx and /dev/null differ diff --git a/temp_convert/input_6_1781504360423.docx b/temp_convert/input_6_1781504360423.docx deleted file mode 100644 index e1ede68..0000000 Binary files a/temp_convert/input_6_1781504360423.docx and /dev/null differ diff --git a/temp_convert/input_7_1781505810092.docx b/temp_convert/input_7_1781505810092.docx deleted file mode 100644 index e1ede68..0000000 Binary files a/temp_convert/input_7_1781505810092.docx and /dev/null differ diff --git a/temp_convert/input_8_1781570010369.xlsx b/temp_convert/input_8_1781570010369.xlsx new file mode 100644 index 0000000..be7b3fe Binary files /dev/null and b/temp_convert/input_8_1781570010369.xlsx differ diff --git a/temp_convert/input_8_1781570010369_test.pdf b/temp_convert/input_8_1781570010369_test.pdf new file mode 100644 index 0000000..9ca8128 Binary files /dev/null and b/temp_convert/input_8_1781570010369_test.pdf differ diff --git a/temp_convert/test_pptx.pdf b/temp_convert/test_pptx.pdf new file mode 100644 index 0000000..142d2f5 Binary files /dev/null and b/temp_convert/test_pptx.pdf differ diff --git a/views/admin/dashboard.html b/views/admin/dashboard.html index 03b7412..6ca522c 100644 --- a/views/admin/dashboard.html +++ b/views/admin/dashboard.html @@ -760,6 +760,7 @@ 카테고리 용량 제한 상태 + 과업개요 관리 @@ -1004,10 +1005,10 @@

🔎 시스템 활동 로그 조회 (tb_log)

- - - - + + + +
@@ -1194,11 +1195,11 @@ +
+ + +
-
- - -
+
+ + +
@@ -1616,6 +1624,7 @@
+
${p.category_nm || p.category || '-'} ${p.storage_byte ? (Number(p.storage_byte) / (1024*1024*1024)).toFixed(0) + ' GB' : '0 GB'} ${p.is_active ? '활성' : '비활성'}${p.overview !== false ? '사용' : '미사용'}
@@ -1831,6 +1840,7 @@ document.getElementById('form-project-id').removeAttribute('readonly'); document.getElementById('form-project-id').disabled = false; document.getElementById('project-submit-btn').innerText = '등록 하기'; + document.getElementById('form-project-overview').value = 'true'; form.onsubmit = submitCreateProject; } else { document.getElementById('project-modal-title').innerText = '📝 프로젝트 상세 정보 수정'; @@ -1848,6 +1858,7 @@ document.getElementById('form-project-category').value = p.category || ''; document.getElementById('form-project-storage').value = p.storage_byte ? (Number(p.storage_byte) / (1024*1024*1024)).toFixed(0) : 10; document.getElementById('form-project-active').value = p.is_active ? 'true' : 'false'; + document.getElementById('form-project-overview').value = p.overview !== false ? 'true' : 'false'; } } catch (err) { console.error(err); @@ -1869,7 +1880,8 @@ short_nm: document.getElementById('form-project-short').value.trim(), category: document.getElementById('form-project-category').value, limit_storage: Number(document.getElementById('form-project-storage').value), - is_active: document.getElementById('form-project-active').value === 'true' + is_active: document.getElementById('form-project-active').value === 'true', + overview: document.getElementById('form-project-overview').value === 'true' }; try { @@ -1893,7 +1905,8 @@ short_nm: document.getElementById('form-project-short').value.trim(), category: document.getElementById('form-project-category').value, limit_storage: Number(document.getElementById('form-project-storage').value), - is_active: document.getElementById('form-project-active').value === 'true' + is_active: document.getElementById('form-project-active').value === 'true', + overview: document.getElementById('form-project-overview').value === 'true' }; try { @@ -2147,6 +2160,7 @@ document.getElementById('form-user-id').removeAttribute('readonly'); document.getElementById('form-user-id').disabled = false; document.getElementById('form-user-pw').required = true; + document.getElementById('form-user-pw').placeholder = '••••••••'; pwRow.style.display = 'flex'; // PW 보이기 document.getElementById('user-submit-btn').innerText = '등록 하기'; form.onsubmit = submitCreateUser; @@ -2155,9 +2169,10 @@ document.getElementById('form-user-id').setAttribute('readonly', 'true'); document.getElementById('form-user-id').disabled = true; - // 패스워드 입력칸 숨기기 및 필수조건 해제 + // 패스워드 필수조건 해제 및 노출 유지 (공백 시 미수정) document.getElementById('form-user-pw').required = false; - pwRow.style.display = 'none'; + document.getElementById('form-user-pw').placeholder = '변경할 비밀번호 입력 (공백 시 유지)'; + pwRow.style.display = 'flex'; document.getElementById('user-submit-btn').innerText = '수정 하기'; @@ -2216,6 +2231,7 @@ async function submitEditUser(event, userId) { event.preventDefault(); const payload = { + user_pw: document.getElementById('form-user-pw').value, user_nm: document.getElementById('form-user-nm').value.trim(), company: document.getElementById('form-user-company').value.trim(), dept: document.getElementById('form-user-dept').value.trim(), diff --git a/views/main/jsm/archive/common.js b/views/main/jsm/archive/common.js index 526fc4b..88a58b9 100644 --- a/views/main/jsm/archive/common.js +++ b/views/main/jsm/archive/common.js @@ -370,7 +370,7 @@ export async function openNewWindowViewer() { case 'pdf' : case 'doc' : case 'ppt' : - case 'pptx' : + case 'pptx': case 'dwg' : case 'dxf' : case 'grm' : diff --git a/views/main/jsm/archive/pageRenderer.js b/views/main/jsm/archive/pageRenderer.js index f871b4c..3871885 100644 --- a/views/main/jsm/archive/pageRenderer.js +++ b/views/main/jsm/archive/pageRenderer.js @@ -94,6 +94,22 @@ async function loadSystemPolicy() { } } +export function updateSystemPolicyCache(policy) { + if (policy) { + FOLDER_KEEP_POLICY_ACTIVE = policy.is_active ?? false; + if (FOLDER_KEEP_POLICY_ACTIVE) { + FOLDER_KEEP_FILE_THRESHOLD = Number(policy.limit_file_count) || 3; + FOLDER_KEEP_DAYS_THRESHOLD = Number(policy.limit_days) || 15; + } else { + FOLDER_KEEP_FILE_THRESHOLD = 3; + FOLDER_KEEP_DAYS_THRESHOLD = 15; + } + } else { + isPolicyLoaded = false; + } +} + + // 브라우저 뒤로가기, 앞으로가기 이벤트 window.addEventListener('popstate', async (e)=>{ // console.log(e); diff --git a/views/main/jsm/archive/socketManager.js b/views/main/jsm/archive/socketManager.js index 4ba4af0..c82720c 100644 --- a/views/main/jsm/archive/socketManager.js +++ b/views/main/jsm/archive/socketManager.js @@ -22,6 +22,7 @@ import { changeHeaderBtnStyle, changeTreeItemStyle, changeListItemStyle, + updateSystemPolicyCache, } from './pageRenderer.js'; import { toggleModal } from './modalManager.js' import { mgmtFunc_addClickLog } from './managementFunctions.js'; @@ -326,6 +327,27 @@ socket.on('popupNotice', (data)=>{ alert(text); }) +//// 보관 및 삭제 정책 변경 실시간 반영 +socket.on('updateSystemPolicy_success', async (policy) => { + // 정책 캐시 갱신 + updateSystemPolicyCache(policy); + + // 트리 화면 갱신 (D-Day 타이머 갱신을 위해) + let userCurPath = getMyCurPath(); + if (userCurPath) { + let pathSplit = userCurPath.split('/'); + let extractedPath = extractPathByLength(pathSplit, 1); + let pageRanderingOption = { + scope: 'tree', + resourcePath: extractedPath, + userCurPath: userCurPath, + pushState: false, + debug: '정책 변경 실시간 갱신 - tree' + }; + await preparePageRendering(pageRanderingOption); + } +}) + //// 강제 로그아웃 socket.on('forcedLogout', () => { alert('프로젝트 재시작으로 인해 자동으로 로그아웃됩니다.\n다시 로그인 후 사용해주세요.'); @@ -441,15 +463,16 @@ socket.on('addConvetPdfLog_success', async (resultData) => { socket.on('convertPdf_failed', async (resultData) => { console.log('-------- convertPdf_failed'); console.log(resultData); - let resourcePath = (resultData.jobData.resourcePath) ? resultData.jobData.resourcePath : resultData.jobProgress.resourcePath; - let dataId = (resultData.jobData.dataId) ? resultData.jobData.dataId : resultData.jobProgress.dataId; - let userInfoString = (resultData.jobData.userInfoString) ? resultData.jobData.userInfoString : resultData.jobProgress.userInfoString; - // 서버의 convertingDataArr에서 변환 실패한 파일 정보 삭제 + let resourcePath = (resultData.jobData && resultData.jobData.resourcePath) ? resultData.jobData.resourcePath : (resultData.jobProgress ? resultData.jobProgress.resourcePath : ''); + let dataId = (resultData.jobData && resultData.jobData.dataId) ? resultData.jobData.dataId : (resultData.jobProgress ? resultData.jobProgress.dataId : ''); + let userInfoString = (resultData.jobData && resultData.jobData.userInfoString) ? resultData.jobData.userInfoString : (resultData.jobProgress ? resultData.jobProgress.userInfoString : ''); + let failedReason = resultData.failedReason || ''; let removeConvertingDataParams = { resourcePath: resourcePath, dataId: dataId, - userInfoString: userInfoString + userInfoString: userInfoString, + stdout: failedReason } let removeConvertingDataResult = await axios.post(`${vars.path_name}/removeConvertingData`, { params: removeConvertingDataParams }); console.log(removeConvertingDataResult); diff --git a/views/main/jsm/officialDoc/docPageRenderer.js b/views/main/jsm/officialDoc/docPageRenderer.js index ebbd182..7b144b8 100644 --- a/views/main/jsm/officialDoc/docPageRenderer.js +++ b/views/main/jsm/officialDoc/docPageRenderer.js @@ -573,7 +573,8 @@ export async function renderDocViewer(resourcePath, docId) { let excelDirectArr = ['xls', 'xlsx', 'xlsm']; let hwpDirectArr = ['hwp', 'hwpx']; let wordDirectArr = ['docx']; - let isDirectView = excelDirectArr.includes(ext) || hwpDirectArr.includes(ext) || wordDirectArr.includes(ext); + let pptxDirectArr = []; + let isDirectView = excelDirectArr.includes(ext) || hwpDirectArr.includes(ext) || wordDirectArr.includes(ext) || pptxDirectArr.includes(ext); let selectedDoc = docVars.allDocData?.find((doc) => doc.doc_id === docId); let previewKey = selectedDoc?.preview_key; @@ -617,12 +618,13 @@ export async function renderDocViewer(resourcePath, docId) { let threeArr = ['glb', 'gltf', 'obj', 'stl', 'fbx', '3dm']; let allArr = [...pdfArr, ...gsimArr, ...ifcArr, ...imageArr, ...videoArr, ...textArr, ...urlArr, ...zipArr, ...threeArr]; if (allArr.includes(ext)) { - let pdfArrFiltered = pdfArr.filter(e => !excelDirectArr.includes(e) && !hwpDirectArr.includes(e) && !wordDirectArr.includes(e)); + let pdfArrFiltered = pdfArr.filter(e => !excelDirectArr.includes(e) && !hwpDirectArr.includes(e) && !wordDirectArr.includes(e) && !pptxDirectArr.includes(e)); if (pdfArrFiltered.includes(ext)) viewerPdf(PresignedUrl); if (excelDirectArr.includes(ext)) viewerExcel(PresignedUrl); if (hwpDirectArr.includes(ext)) viewerHwp(PresignedUrl); if (wordDirectArr.includes(ext)) viewerWord(PresignedUrl); + if (pptxDirectArr.includes(ext)) viewerPptx(PresignedUrl); if (gsimArr.includes(ext)) viewerGsim(PresignedUrl); if (ifcArr.includes(ext)) viewerIfc(PresignedUrl); if (threeArr.includes(ext)) viewer3d(PresignedUrl); @@ -860,18 +862,54 @@ export async function renderDocViewer(resourcePath, docId) { const container = document.createElement('div'); container.style.width = '100%'; container.style.height = '100%'; - container.style.overflow = 'auto'; + container.style.overflowX = 'hidden'; + container.style.overflowY = 'auto'; container.style.padding = '20px'; container.style.boxSizing = 'border-box'; container.style.background = '#f5f5f5'; + const styleEl = document.createElement('style'); + styleEl.textContent = ` + .hwp-inner-container { + background: #ffffff; + margin: 0 auto; + max-width: 800px; + box-shadow: 0 4px 10px rgba(0,0,0,0.1); + padding: 30px !important; + box-sizing: border-box !important; + min-height: 100%; + } + .hwp-inner-container > div > div { + max-width: 100% !important; + height: auto !important; + box-sizing: border-box !important; + padding-left: 20px !important; + padding-right: 20px !important; + margin-bottom: 20px !important; + } + .hwp-inner-container table { + max-width: 100% !important; + width: 100% !important; + table-layout: fixed !important; + } + .hwp-inner-container img { + max-width: 100% !important; + height: auto !important; + } + @media (max-width: 600px) { + .hwp-inner-container { + padding: 10px !important; + } + .hwp-inner-container > div > div { + padding-left: 10px !important; + padding-right: 10px !important; + } + } + `; + container.appendChild(styleEl); + const hwpInner = document.createElement('div'); - hwpInner.style.background = '#ffffff'; - hwpInner.style.margin = '0 auto'; - hwpInner.style.maxWidth = '800px'; - hwpInner.style.boxShadow = '0 4px 10px rgba(0,0,0,0.1)'; - hwpInner.style.padding = '40px'; - hwpInner.style.minHeight = '100%'; + hwpInner.classList.add('hwp-inner-container'); container.appendChild(hwpInner); docVars.viewer.appendChild(container); @@ -896,6 +934,281 @@ export async function renderDocViewer(resourcePath, docId) { docVars.viewer.dataset.viewerType = 'hwp'; } + function viewerPptx(presignedUrl) { + docVars.viewer.innerHTML = '
PPTX 문서를 불러오는 중...
'; + initDocFallbackPdfButton(docId, resourcePath, objectKey, previewKey); + + fetch(presignedUrl) + .then(res => { + if (!res.ok) throw new Error('PPTX fetch failed'); + return res.arrayBuffer(); + }) + .then(async (arrayBuffer) => { + try { + const zip = await JSZip.loadAsync(arrayBuffer); + + // Read presentation.xml to get slide size + const presentationXmlText = await zip.file("ppt/presentation.xml").async("text"); + const parser = new DOMParser(); + const presDoc = parser.parseFromString(presentationXmlText, "text/xml"); + const sldSz = presDoc.getElementsByTagName("p:sldSz")[0]; + const cx = sldSz ? (parseInt(sldSz.getAttribute("cx"), 10) || 12192000) : 12192000; + const cy = sldSz ? (parseInt(sldSz.getAttribute("cy"), 10) || 6858000) : 6858000; + const ratio = (cy / cx) * 100; + + // Get slide files + const slideFiles = Object.keys(zip.files).filter(name => name.startsWith("ppt/slides/slide") && name.endsWith(".xml")); + slideFiles.sort((a, b) => { + const numA = parseInt(a.replace("ppt/slides/slide", "").replace(".xml", ""), 10); + const numB = parseInt(b.replace("ppt/slides/slide", "").replace(".xml", ""), 10); + return numA - numB; + }); + + docVars.viewer.innerHTML = ''; + + const slidesContainer = document.createElement('div'); + slidesContainer.style.display = 'flex'; + slidesContainer.style.flexDirection = 'column'; + slidesContainer.style.gap = '20px'; + slidesContainer.style.alignItems = 'center'; + slidesContainer.style.background = '#f0f0f0'; + slidesContainer.style.padding = '20px'; + slidesContainer.style.width = '100%'; + slidesContainer.style.height = '100%'; + slidesContainer.style.overflow = 'auto'; + slidesContainer.style.boxSizing = 'border-box'; + docVars.viewer.appendChild(slidesContainer); + + for (let i = 0; i < slideFiles.length; i++) { + const slideXmlText = await zip.file(slideFiles[i]).async("text"); + const slideDoc = parser.parseFromString(slideXmlText, "text/xml"); + + const slideCard = document.createElement('div'); + slideCard.className = 'pptx-slide-card'; + slideCard.style.position = 'relative'; + slideCard.style.width = '100%'; + slideCard.style.maxWidth = '800px'; + slideCard.style.backgroundColor = '#ffffff'; + slideCard.style.boxShadow = '0 4px 10px rgba(0,0,0,0.1)'; + slideCard.style.height = '0'; + slideCard.style.paddingTop = ratio + '%'; + slideCard.style.overflow = 'hidden'; + slideCard.style.flexShrink = '0'; + + const slideContent = document.createElement('div'); + slideContent.style.position = 'absolute'; + slideContent.style.top = '0'; + slideContent.style.left = '0'; + slideContent.style.width = '100%'; + slideContent.style.height = '100%'; + slideCard.appendChild(slideContent); + slidesContainer.appendChild(slideCard); + + // Parse relationships for this slide + const relMap = {}; + try { + const slideName = slideFiles[i].split('/').pop(); + const relsFileName = `ppt/slides/_rels/${slideName}.rels`; + const relsFile = zip.file(relsFileName); + if (relsFile) { + const relsXmlText = await relsFile.async("text"); + const relsDoc = parser.parseFromString(relsXmlText, "text/xml"); + const relationships = relsDoc.getElementsByTagName("Relationship"); + for (let r = 0; r < relationships.length; r++) { + const id = relationships[r].getAttribute("Id"); + const target = relationships[r].getAttribute("Target"); + relMap[id] = target; + } + } + } catch (relErr) { + console.warn("Failed to parse relationships for slide:", slideFiles[i], relErr); + } + + const elements = slideDoc.querySelectorAll('p\\:sp, sp, p\\:pic, pic, p\\:graphicFrame, graphicFrame'); + + for (const elem of elements) { + const xfrm = elem.querySelector('a\\:xfrm, xfrm'); + if (!xfrm) continue; + + const off = xfrm.querySelector('a\\:off, off'); + const ext = xfrm.querySelector('a\\:ext, ext'); + if (!off || !ext) continue; + + const x = parseInt(off.getAttribute('x'), 10); + const y = parseInt(off.getAttribute('y'), 10); + const w = parseInt(ext.getAttribute('cx'), 10); + const h = parseInt(ext.getAttribute('cy'), 10); + + const leftPct = (x / cx) * 100; + const topPct = (y / cy) * 100; + const widthPct = (w / cx) * 100; + const heightPct = (h / cy) * 100; + + const itemDiv = document.createElement('div'); + itemDiv.style.position = 'absolute'; + itemDiv.style.left = leftPct + '%'; + itemDiv.style.top = topPct + '%'; + itemDiv.style.width = widthPct + '%'; + itemDiv.style.height = heightPct + '%'; + itemDiv.style.boxSizing = 'border-box'; + + const nodeName = elem.nodeName.toLowerCase(); + if (nodeName.includes('pic')) { + let imgUrl = null; + try { + const blip = elem.querySelector('a\\:blip, blip'); + const rId = blip ? (blip.getAttribute('r:embed') || blip.getAttribute('embed')) : null; + if (rId && relMap[rId]) { + const targetPath = relMap[rId].replace('../', 'ppt/'); + const imgFile = zip.file(targetPath); + if (imgFile) { + const imgBlob = await imgFile.async("blob"); + imgUrl = URL.createObjectURL(imgBlob); + } + } + } catch (imgErr) { + console.warn("Failed to extract slide image:", imgErr); + } + + if (imgUrl) { + itemDiv.style.backgroundImage = `url("${imgUrl}")`; + itemDiv.style.backgroundRepeat = 'no-repeat'; + itemDiv.style.backgroundPosition = 'center'; + itemDiv.style.backgroundSize = 'contain'; + } else { + itemDiv.style.border = '1px dashed #cccccc'; + itemDiv.style.backgroundColor = '#f9f9f9'; + itemDiv.style.display = 'flex'; + itemDiv.style.alignItems = 'center'; + itemDiv.style.justifyContent = 'center'; + + const label = document.createElement('span'); + label.style.color = '#999999'; + label.style.fontSize = '10px'; + label.style.fontWeight = 'bold'; + label.textContent = '[그림 영역]'; + itemDiv.appendChild(label); + } + } else if (nodeName.includes('graphicframe')) { + const tbl = elem.querySelector('a\\:tbl, tbl'); + if (tbl) { + const htmlTable = document.createElement('table'); + htmlTable.style.width = '100%'; + htmlTable.style.height = '100%'; + htmlTable.style.borderCollapse = 'collapse'; + htmlTable.style.fontSize = 'calc(0.4vw + 5px)'; + htmlTable.style.fontFamily = 'sans-serif'; + htmlTable.style.backgroundColor = '#ffffff'; + htmlTable.style.boxShadow = '0 1px 3px rgba(0,0,0,0.05)'; + + const rows = tbl.querySelectorAll('a\\:tr, tr'); + rows.forEach((row, rIdx) => { + const trEl = document.createElement('tr'); + if (rIdx === 0) { + trEl.style.backgroundColor = '#f8f9fa'; + trEl.style.fontWeight = '600'; + } else if (rIdx % 2 === 0) { + trEl.style.backgroundColor = '#fafafa'; + } + + const cells = row.querySelectorAll('a\\:tc, tc'); + cells.forEach(cell => { + const tdEl = document.createElement('td'); + tdEl.style.border = '1px solid #e0e0e0'; + tdEl.style.padding = '4px 6px'; + tdEl.style.wordBreak = 'break-all'; + tdEl.style.verticalAlign = 'middle'; + + const gridSpan = cell.getAttribute('gridSpan'); + if (gridSpan) tdEl.setAttribute('colspan', gridSpan); + const rowSpan = cell.getAttribute('rowSpan'); + if (rowSpan) tdEl.setAttribute('rowspan', rowSpan); + + const txBody = cell.querySelector('a\\:txBody, txBody'); + if (txBody) { + const paragraphs = txBody.querySelectorAll('a\\:p, p'); + paragraphs.forEach(p => { + const runs = p.querySelectorAll('a\\:r, r'); + let cellText = ''; + runs.forEach(r => { + const t = r.querySelector('a\\:t, t'); + if (t) cellText += t.textContent; + }); + if (cellText.trim()) { + const pEl = document.createElement('p'); + pEl.style.margin = '0'; + pEl.style.lineHeight = '1.2'; + pEl.textContent = cellText; + tdEl.appendChild(pEl); + } + }); + } + trEl.appendChild(tdEl); + }); + htmlTable.appendChild(trEl); + }); + itemDiv.appendChild(htmlTable); + } else { + itemDiv.style.border = '1px dashed #dddddd'; + itemDiv.style.backgroundColor = '#fdfdfd'; + itemDiv.style.display = 'flex'; + itemDiv.style.alignItems = 'center'; + itemDiv.style.justifyContent = 'center'; + + const label = document.createElement('span'); + label.style.color = '#aaaaaa'; + label.style.fontSize = '10px'; + label.style.fontWeight = 'bold'; + label.textContent = '[차트 영역]'; + itemDiv.appendChild(label); + } + } else { + const txBody = elem.querySelector('p\\:txBody, txBody'); + if (txBody) { + itemDiv.style.overflow = 'hidden'; + itemDiv.style.wordBreak = 'break-all'; + itemDiv.style.fontSize = 'calc(0.5vw + 5px)'; + itemDiv.style.fontFamily = 'sans-serif'; + itemDiv.style.color = '#333333'; + + const paragraphs = txBody.querySelectorAll('a\\:p, p'); + paragraphs.forEach(p => { + const runs = p.querySelectorAll('a\\:r, r'); + let paraText = ''; + runs.forEach(r => { + const t = r.querySelector('a\\:t, t'); + if (t) paraText += t.textContent; + }); + + if (paraText.trim()) { + const pEl = document.createElement('p'); + pEl.style.margin = '0 0 2px 0'; + pEl.style.lineHeight = '1.2'; + pEl.textContent = paraText; + itemDiv.appendChild(pEl); + } + }); + } else { + itemDiv.style.border = '1px solid #eeeeee'; + itemDiv.style.backgroundColor = 'rgba(0,0,0,0.01)'; + } + } + slideContent.appendChild(itemDiv); + } + } + } catch (parseErr) { + console.error("PPTX parse error:", parseErr); + docVars.viewer.innerHTML = '
PPTX 파싱 중 에러가 발생했습니다. 상단의 "PDF로 보기" 버튼을 이용해 주세요.
'; + } + }) + .catch(err => { + console.error(err); + docVars.viewer.innerHTML = '
PPTX 문서를 불러오는데 실패했습니다.
'; + }); + + docVars.viewer.dataset.viewerType = 'pptx'; + } + async function viewerPdf(PresignedUrl) { resetViewer(); @@ -1222,7 +1535,11 @@ document.querySelector('.official-doc-main .official-doc-preview .official-doc-p //Presigned URL let PresignedUrl; await syncDocInfo(['official', 'attach', null]); - let objectKey = docVars.allDocData?.find((doc) => doc.doc_id === docId)?.preview_key; + + let directViewExtArr = ['xls', 'xlsx', 'xlsm', 'docx', 'hwp', 'hwpx']; + let selectedDoc = docVars.allDocData?.find((doc) => doc.doc_id === docId); + let objectKey = directViewExtArr.includes(ext) ? selectedDoc?.object_key : selectedDoc?.preview_key; + if (objectKey == undefined || objectKey == `` || objectKey == null) { return; } @@ -1250,19 +1567,26 @@ document.querySelector('.official-doc-main .official-doc-preview .official-doc-p let open_ext = `pdf`; switch (ext) { case 'pdf': - case 'hwp': - case 'hwpx': - case 'xls': - case 'xlsm': case 'ppt': case 'pptx': case 'doc': - case 'docx': case 'dwg': case 'dxf': case 'grm': open_ext = 'pdf'; break; + case 'hwp': + case 'hwpx': + open_ext = ext; + break; + case 'xls': + case 'xlsx': + case 'xlsm': + open_ext = ext; + break; + case 'docx': + open_ext = ext; + break; case 'gsim': open_ext = 'gsim'; break; diff --git a/views/main/jsm/popup.js b/views/main/jsm/popup.js index a946d98..cf5a35d 100644 --- a/views/main/jsm/popup.js +++ b/views/main/jsm/popup.js @@ -44,6 +44,9 @@ if(data && Object.keys(data).length>0 && (data.$type == 'text'|| data.type == 't case 'docx': _openDocx(fullPath, data); break; + case 'pptx': + _openPptx(fullPath, data); + break; case 'hwp': case 'hwpx': _openHwp(fullPath, data); @@ -812,18 +815,54 @@ function _openHwp(path, data) { const container = document.createElement('div'); container.style.width = '100%'; container.style.height = '100%'; - container.style.overflow = 'auto'; + container.style.overflowX = 'hidden'; + container.style.overflowY = 'auto'; container.style.padding = '20px'; container.style.boxSizing = 'border-box'; container.style.background = '#f5f5f5'; + const styleEl = document.createElement('style'); + styleEl.textContent = ` + .hwp-inner-container { + background: #ffffff; + margin: 0 auto; + max-width: 800px; + box-shadow: 0 4px 10px rgba(0,0,0,0.1); + padding: 30px !important; + box-sizing: border-box !important; + min-height: 100%; + } + .hwp-inner-container > div > div { + max-width: 100% !important; + height: auto !important; + box-sizing: border-box !important; + padding-left: 20px !important; + padding-right: 20px !important; + margin-bottom: 20px !important; + } + .hwp-inner-container table { + max-width: 100% !important; + width: 100% !important; + table-layout: fixed !important; + } + .hwp-inner-container img { + max-width: 100% !important; + height: auto !important; + } + @media (max-width: 600px) { + .hwp-inner-container { + padding: 10px !important; + } + .hwp-inner-container > div > div { + padding-left: 10px !important; + padding-right: 10px !important; + } + } + `; + container.appendChild(styleEl); + const hwpInner = document.createElement('div'); - hwpInner.style.background = '#ffffff'; - hwpInner.style.margin = '0 auto'; - hwpInner.style.maxWidth = '800px'; - hwpInner.style.boxShadow = '0 4px 10px rgba(0,0,0,0.1)'; - hwpInner.style.padding = '40px'; - hwpInner.style.minHeight = '100%'; + hwpInner.classList.add('hwp-inner-container'); container.appendChild(hwpInner); viewer.appendChild(container); @@ -844,4 +883,281 @@ function _openHwp(path, data) { console.error(err); viewer.innerHTML = '
한글 문서를 불러오는데 실패했습니다.
'; }); +} + +function _openPptx(path, data) { + const viewer = document.getElementById('popup_viewer'); + viewer.innerHTML = '
PPTX 문서를 불러오는 중...
'; + + if (dataId && path_name) { + initFallbackPdfButton(dataId, path_name, resourcePath); + } + + fetch(path) + .then(res => { + if (!res.ok) throw new Error('PPTX fetch failed'); + return res.arrayBuffer(); + }) + .then(async (arrayBuffer) => { + try { + const zip = await JSZip.loadAsync(arrayBuffer); + + // Read presentation.xml to get slide size + const presentationXmlText = await zip.file("ppt/presentation.xml").async("text"); + const parser = new DOMParser(); + const presDoc = parser.parseFromString(presentationXmlText, "text/xml"); + const sldSz = presDoc.getElementsByTagName("p:sldSz")[0]; + const cx = sldSz ? (parseInt(sldSz.getAttribute("cx"), 10) || 12192000) : 12192000; + const cy = sldSz ? (parseInt(sldSz.getAttribute("cy"), 10) || 6858000) : 6858000; + const ratio = (cy / cx) * 100; + + // Get slide files + const slideFiles = Object.keys(zip.files).filter(name => name.startsWith("ppt/slides/slide") && name.endsWith(".xml")); + slideFiles.sort((a, b) => { + const numA = parseInt(a.replace("ppt/slides/slide", "").replace(".xml", ""), 10); + const numB = parseInt(b.replace("ppt/slides/slide", "").replace(".xml", ""), 10); + return numA - numB; + }); + + viewer.innerHTML = ''; + + const slidesContainer = document.createElement('div'); + slidesContainer.style.display = 'flex'; + slidesContainer.style.flexDirection = 'column'; + slidesContainer.style.gap = '20px'; + slidesContainer.style.alignItems = 'center'; + slidesContainer.style.background = '#f0f0f0'; + slidesContainer.style.padding = '20px'; + slidesContainer.style.width = '100%'; + slidesContainer.style.height = '100%'; + slidesContainer.style.overflow = 'auto'; + slidesContainer.style.boxSizing = 'border-box'; + viewer.appendChild(slidesContainer); + + for (let i = 0; i < slideFiles.length; i++) { + const slideXmlText = await zip.file(slideFiles[i]).async("text"); + const slideDoc = parser.parseFromString(slideXmlText, "text/xml"); + + const slideCard = document.createElement('div'); + slideCard.className = 'pptx-slide-card'; + slideCard.style.position = 'relative'; + slideCard.style.width = '100%'; + slideCard.style.maxWidth = '800px'; + slideCard.style.backgroundColor = '#ffffff'; + slideCard.style.boxShadow = '0 4px 10px rgba(0,0,0,0.1)'; + slideCard.style.height = '0'; + slideCard.style.paddingTop = ratio + '%'; + slideCard.style.overflow = 'hidden'; + slideCard.style.flexShrink = '0'; + + const slideContent = document.createElement('div'); + slideContent.style.position = 'absolute'; + slideContent.style.top = '0'; + slideContent.style.left = '0'; + slideContent.style.width = '100%'; + slideContent.style.height = '100%'; + slideCard.appendChild(slideContent); + slidesContainer.appendChild(slideCard); + + // Parse relationships for this slide + const relMap = {}; + try { + const slideName = slideFiles[i].split('/').pop(); + const relsFileName = `ppt/slides/_rels/${slideName}.rels`; + const relsFile = zip.file(relsFileName); + if (relsFile) { + const relsXmlText = await relsFile.async("text"); + const relsDoc = parser.parseFromString(relsXmlText, "text/xml"); + const relationships = relsDoc.getElementsByTagName("Relationship"); + for (let r = 0; r < relationships.length; r++) { + const id = relationships[r].getAttribute("Id"); + const target = relationships[r].getAttribute("Target"); + relMap[id] = target; + } + } + } catch (relErr) { + console.warn("Failed to parse relationships for slide:", slideFiles[i], relErr); + } + + const elements = slideDoc.querySelectorAll('p\\:sp, sp, p\\:pic, pic, p\\:graphicFrame, graphicFrame'); + + for (const elem of elements) { + const xfrm = elem.querySelector('a\\:xfrm, xfrm'); + if (!xfrm) continue; + + const off = xfrm.querySelector('a\\:off, off'); + const ext = xfrm.querySelector('a\\:ext, ext'); + if (!off || !ext) continue; + + const x = parseInt(off.getAttribute('x'), 10); + const y = parseInt(off.getAttribute('y'), 10); + const w = parseInt(ext.getAttribute('cx'), 10); + const h = parseInt(ext.getAttribute('cy'), 10); + + const leftPct = (x / cx) * 100; + const topPct = (y / cy) * 100; + const widthPct = (w / cx) * 100; + const heightPct = (h / cy) * 100; + + const itemDiv = document.createElement('div'); + itemDiv.style.position = 'absolute'; + itemDiv.style.left = leftPct + '%'; + itemDiv.style.top = topPct + '%'; + itemDiv.style.width = widthPct + '%'; + itemDiv.style.height = heightPct + '%'; + itemDiv.style.boxSizing = 'border-box'; + + const nodeName = elem.nodeName.toLowerCase(); + if (nodeName.includes('pic')) { + let imgUrl = null; + try { + const blip = elem.querySelector('a\\:blip, blip'); + const rId = blip ? (blip.getAttribute('r:embed') || blip.getAttribute('embed')) : null; + if (rId && relMap[rId]) { + const targetPath = relMap[rId].replace('../', 'ppt/'); + const imgFile = zip.file(targetPath); + if (imgFile) { + const imgBlob = await imgFile.async("blob"); + imgUrl = URL.createObjectURL(imgBlob); + } + } + } catch (imgErr) { + console.warn("Failed to extract slide image:", imgErr); + } + + if (imgUrl) { + itemDiv.style.backgroundImage = `url("${imgUrl}")`; + itemDiv.style.backgroundRepeat = 'no-repeat'; + itemDiv.style.backgroundPosition = 'center'; + itemDiv.style.backgroundSize = 'contain'; + } else { + itemDiv.style.border = '1px dashed #cccccc'; + itemDiv.style.backgroundColor = '#f9f9f9'; + itemDiv.style.display = 'flex'; + itemDiv.style.alignItems = 'center'; + itemDiv.style.justifyContent = 'center'; + + const label = document.createElement('span'); + label.style.color = '#999999'; + label.style.fontSize = '10px'; + label.style.fontWeight = 'bold'; + label.textContent = '[그림 영역]'; + itemDiv.appendChild(label); + } + } else if (nodeName.includes('graphicframe')) { + const tbl = elem.querySelector('a\\:tbl, tbl'); + if (tbl) { + const htmlTable = document.createElement('table'); + htmlTable.style.width = '100%'; + htmlTable.style.height = '100%'; + htmlTable.style.borderCollapse = 'collapse'; + htmlTable.style.fontSize = 'calc(0.4vw + 5px)'; + htmlTable.style.fontFamily = 'sans-serif'; + htmlTable.style.backgroundColor = '#ffffff'; + htmlTable.style.boxShadow = '0 1px 3px rgba(0,0,0,0.05)'; + + const rows = tbl.querySelectorAll('a\\:tr, tr'); + rows.forEach((row, rIdx) => { + const trEl = document.createElement('tr'); + if (rIdx === 0) { + trEl.style.backgroundColor = '#f8f9fa'; + trEl.style.fontWeight = '600'; + } else if (rIdx % 2 === 0) { + trEl.style.backgroundColor = '#fafafa'; + } + + const cells = row.querySelectorAll('a\\:tc, tc'); + cells.forEach(cell => { + const tdEl = document.createElement('td'); + tdEl.style.border = '1px solid #e0e0e0'; + tdEl.style.padding = '4px 6px'; + tdEl.style.wordBreak = 'break-all'; + tdEl.style.verticalAlign = 'middle'; + + const gridSpan = cell.getAttribute('gridSpan'); + if (gridSpan) tdEl.setAttribute('colspan', gridSpan); + const rowSpan = cell.getAttribute('rowSpan'); + if (rowSpan) tdEl.setAttribute('rowspan', rowSpan); + + const txBody = cell.querySelector('a\\:txBody, txBody'); + if (txBody) { + const paragraphs = txBody.querySelectorAll('a\\:p, p'); + paragraphs.forEach(p => { + const runs = p.querySelectorAll('a\\:r, r'); + let cellText = ''; + runs.forEach(r => { + const t = r.querySelector('a\\:t, t'); + if (t) cellText += t.textContent; + }); + if (cellText.trim()) { + const pEl = document.createElement('p'); + pEl.style.margin = '0'; + pEl.style.lineHeight = '1.2'; + pEl.textContent = cellText; + tdEl.appendChild(pEl); + } + }); + } + trEl.appendChild(tdEl); + }); + htmlTable.appendChild(trEl); + }); + itemDiv.appendChild(htmlTable); + } else { + itemDiv.style.border = '1px dashed #dddddd'; + itemDiv.style.backgroundColor = '#fdfdfd'; + itemDiv.style.display = 'flex'; + itemDiv.style.alignItems = 'center'; + itemDiv.style.justifyContent = 'center'; + + const label = document.createElement('span'); + label.style.color = '#aaaaaa'; + label.style.fontSize = '10px'; + label.style.fontWeight = 'bold'; + label.textContent = '[차트 영역]'; + itemDiv.appendChild(label); + } + } else { + const txBody = elem.querySelector('p\\:txBody, txBody'); + if (txBody) { + itemDiv.style.overflow = 'hidden'; + itemDiv.style.wordBreak = 'break-all'; + itemDiv.style.fontSize = 'calc(0.5vw + 5px)'; + itemDiv.style.fontFamily = 'sans-serif'; + itemDiv.style.color = '#333333'; + + const paragraphs = txBody.querySelectorAll('a\\:p, p'); + paragraphs.forEach(p => { + const runs = p.querySelectorAll('a\\:r, r'); + let paraText = ''; + runs.forEach(r => { + const t = r.querySelector('a\\:t, t'); + if (t) paraText += t.textContent; + }); + + if (paraText.trim()) { + const pEl = document.createElement('p'); + pEl.style.margin = '0 0 2px 0'; + pEl.style.lineHeight = '1.2'; + pEl.textContent = paraText; + itemDiv.appendChild(pEl); + } + }); + } else { + itemDiv.style.border = '1px solid #eeeeee'; + itemDiv.style.backgroundColor = 'rgba(0,0,0,0.01)'; + } + } + slideContent.appendChild(itemDiv); + } + } + } catch (parseErr) { + console.error("PPTX parse error:", parseErr); + viewer.innerHTML = '
PPTX 파싱 중 에러가 발생했습니다. 상단의 "PDF로 보기" 버튼을 이용해 주세요.
'; + } + }) + .catch(err => { + console.error(err); + viewer.innerHTML = '
PPTX 문서를 불러오는데 실패했습니다.
'; + }); } \ No newline at end of file diff --git a/문서뷰어_구현방법_가이드.html b/문서뷰어_구현방법_가이드.html new file mode 100644 index 0000000..8b7a3c7 --- /dev/null +++ b/문서뷰어_구현방법_가이드.html @@ -0,0 +1,969 @@ + + + + + + 웹 문서/도면 미리보기 구현 가이드 + + + + + + + + +
+

웹 문서 및 도면 미리보기 기술 가이드

+

무료 오픈소스 라이브러리 및 서버 렌더러 방식을 중심으로 한 아키텍처 비교표

+
+ +
+ + + + +
+
+
+ +

Word (.doc, .docx) 미리보기 방식 비교

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
구현 방법설명장점 (Pros)단점 (Cons)라이선스 / 비용
+ docx-preview
(npm 패키지) +
현재 기본 적용됨
+
docx 이진 데이터를 읽어 브라우저 JS로 파싱하여 HTML/CSS로 그리기 +
    +
  • 순수 프론트엔드 작동 (서버 부하 없음)
  • +
  • 워드 파일 서식 보존 수준 우수
  • +
+
+
    +
  • 구형 .doc 파일 감지 및 파싱 불가
  • +
  • 일부 복잡한 다단, 도형 객체 깨짐
  • +
+
완전 무료 (MIT)
+ 서버 PDF 변환
(LibreOffice) +
PDF로 보기 선택적용
+
서버 단에서 LibreOffice CLI로 PDF 변환 후 브라우저 PDF.js로 화면 표출 +
    +
  • 100% 보안 및 오프라인망 지원
  • +
  • 다양한 오피스 파일 공통 규격 처리 가능
  • +
+
+
    +
  • 최초 변환 대기 시간 발생 (1~2초)
  • +
  • 서버 자원 소모 및 변환 엔진 세팅 필요
  • +
+
완전 무료 (LGPLv3)
Microsoft Office
Online Viewer
MS 뷰어 URL 주소에 파일 링크를 태워 iframe으로 임베드하는 방식 +
    +
  • 개발 공수 제로에 가까움
  • +
  • 오리지널 서식 레이아웃 완벽 보존
  • +
+
+
    +
  • 외부 인터넷 연동 필수 (로컬 사용 불가)
  • +
  • 사내 기밀 문서 외부 반출 보안 이슈
  • +
+
무료 (비상업용 제한)
Mammoth.jsdocx 구조를 순수 HTML 스트링 텍스트로 가볍게 치환해 표출 +
    +
  • 매우 가볍고 렌더링 속도가 가장 빠름
  • +
  • 본문 텍스트 추출 및 본문 검색에 유리
  • +
+
+
    +
  • 레이아웃 및 원본 스타일 대부분 유실
  • +
  • 줄글 형태 이외의 디자인 요소 깨짐
  • +
+
완전 무료 (BSD)
+
+
+
+ + +
+
+
+ +

Excel (.xls, .xlsx) 미리보기 방식 비교

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
구현 방법설명장점 (Pros)단점 (Cons)라이선스 / 비용
+ SheetJS (xlsx.js)
+ Luckysheet +
현재 기본 적용됨
+
브라우저 JS로 엑셀 파일을 읽어 순수 HTML Table 및 Luckysheet 웹 엑셀 셀로 렌더링 +
    +
  • 완전한 클라이언트 독립 처리 (서버 연산 없음)
  • +
  • 스프레드시트 형태에 유사하게 파싱하여 표출
  • +
+
+
    +
  • 복잡한 글꼴, 서식 일부 및 테두리 소실 우려
  • +
  • 엑셀 내장형 차트 및 피벗 드로잉 불가
  • +
+
무료 (Community 에디션)
+ 서버 PDF 변환
(LibreOffice) +
PDF로 보기 선택적용
+
서버 단에서 엑셀을 PDF/HTML로 변환하여 브라우저에 표시 +
    +
  • 보안 유출 없는 자체 서버 환경 구축
  • +
  • 열 너비, 정밀 선 스타일 보존 우수
  • +
+
+
    +
  • 시트가 여러 개일 때 출력 용지 맞춤 조절 필요
  • +
  • 인터랙션(필터링, 탭 편집) 불가
  • +
+
완전 무료 (LGPLv3)
Microsoft Office
Online Viewer
MS 뷰어 URL 주소에 파일 링크를 태워 iframe으로 임베드하는 방식 +
    +
  • 시트 탭, 대용량 표, 차트 완벽 렌더링
  • +
  • 수식 연산 결과 그대로 노출
  • +
+
+
    +
  • 인터넷 및 공인 URL 필수
  • +
  • 사내 기밀 엑셀 데이터 반출 위험
  • +
+
무료 (비상업용 제한)
HandsontableSheetJS 등 데이터 파서 결과와 연동해 엑셀 형태 그리드로 표출 +
    +
  • 가장 엑셀에 근접한 편집/뷰포트 UI
  • +
  • 정렬, 필터, 다중 복사 기능 지원
  • +
+
+
    +
  • 단순 미리보기 뷰어 대비 오버스펙
  • +
  • 라이선스 비용이 매우 높음
  • +
+
비영리만 무료 / 상업 유료
+
+
+
+ + +
+
+
+ +

PPT (.ppt, .pptx) 미리보기 방식 비교

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
구현 방법설명장점 (Pros)단점 (Cons)라이선스 / 비용
+ 서버 PDF 변환
+ PDF.js +
현재 기본 적용됨
+
서버 단에서 PPT를 PDF로 일체 변환 후 브라우저에 임베딩 렌더링 +
    +
  • 자체 사내망 보안 완벽 보존 (100% 로컬)
  • +
  • 슬라이드 레이아웃 훼손 없는 완벽한 품질 열람
  • +
  • 페이지 점프 및 반응형 뷰어 연동
  • +
+
+
    +
  • 최초 요청 시 PDF 변환 연산 시간 필요
  • +
  • 전환 애니메이션 및 동영상 등 미디어 소실
  • +
+
완전 무료 (LGPLv3)
Microsoft Office
Online Viewer
MS 뷰어 URL 주소에 파일 링크를 태워 iframe으로 임베드하는 방식 +
    +
  • 슬라이드 애니메이션 효과 지원
  • +
  • 도형, 그림, 차트 레이아웃 100% 보존
  • +
+
+
    +
  • 인터넷 및 공인 URL 필수
  • +
  • 대용량 도표가 포함된 발표 기밀 반출 위험
  • +
+
무료 (비상업용 제한)
PptxGenJS 역파싱
/ PPTXjs
pptx 압축을 풀어 XML 벡터 데이터를 해석해 Canvas/SVG로 드로잉 +
    +
  • 서버 전처리 없이 프론트 브라우저 드로잉
  • +
+
+
    +
  • 조금만 복잡한 도형, 스마트아트 깨짐 매우 심함
  • +
  • 글꼴 폰트 밀림으로 텍스트 겹침 다수 발생
  • +
+
완전 무료 (오픈소스)
+
+
+
+ + +
+
+
+ +

한글 (.hwp, .hwpx) 미리보기 방식 비교

+
+ +
+ +
+ 현재 한글 미리보기 적용 형태: 현재 PM 시스템에는 hwp.js 기반의 직접 렌더링 방식이 프론트엔드에 기본 탑재되어 있으며, 오피스 파일 공통으로 "PDF로 보기" 버튼을 제공하여 백엔드의 LibreOffice 엔진으로 변환하여 정밀하게 볼 수도 있도록 이중 구성(Hybrid)되어 있습니다. +
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
구현 방법설명장점 (Pros)단점 (Cons)라이선스 / 비용
+ hwp.js
(오픈소스 파서) +
현재 기본 적용됨
+
오픈소스 HWP 바이너리 파서를 활용하여 브라우저에서 HTML5 객체화 +
    +
  • 서버 거치지 않아 즉시 로딩
  • +
  • 완전한 오프라인/폐쇄망 무료 사용
  • +
+
+
    +
  • 정밀한 표 테두리, 수식 개체 스타일 일부 깨짐
  • +
  • 신형 규격인 .hwpx 파싱력 아직 불안정
  • +
+
완전 무료 (MIT)
+ 서버 LibreOffice
(Linux / Windows) +
PDF로 보기 선택적용
+
리눅스 등 무료 백엔드 서버에서 LibreOffice 내장 변환 필터로 PDF 변환 +
    +
  • 추가 하드웨어 및 OS 제약 없음
  • +
  • 최근 규격인 .hwpx는 꽤 준수하게 변환
  • +
+
+
    +
  • 구형 .hwp의 경우 폰트/표 틀어짐 가능성 있음
  • +
  • 서버에 나눔 폰트 등 전용 한글 폰트 사전 설치 필수
  • +
+
완전 무료 (LGPLv3)
한컴 공식
클라우드 뷰어 API
한글과컴퓨터 공식 API 서버를 거쳐 문서 미리보기를 HTML로 획득 +
    +
  • 한글 문서 원본과 100% 일치하는 퀄리티
  • +
+
+
    +
  • 고가의 연간 이용요금 발생
  • +
  • 외부 클라우드 통신 및 계약 절차 번거로움
  • +
+
유료 (계약 및 과금)
서버 한글 프로그램
(Windows 서버)
Windows 서버 환경에 한글 패키지 설치 후 백그라운드 CLI로 PDF 인쇄 +
    +
  • 안정적인 고품질 한글 PDF 변환 가능
  • +
+
+
    +
  • 서버가 Windows 환경으로 강제 제한됨
  • +
  • 상업용 한글 오피스 구매 라이선스 비용
  • +
+
Windows 라이선스 / 한컴 비용
+
+
+
+ + +
+
+
+ +

CAD (.dwg, .dxf) 미리보기 방식 비교

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
구현 방법설명장점 (Pros)단점 (Cons)라이선스 / 비용
+ 서버 이미지/PDF 변환
(QCAD 등) +
현재 기본 적용됨
+
백엔드 서버 내의 변환 필터를 거쳐 DWG/DXF 캐드 파일을 PDF로 강제 변환 후 브라우저에 PDF.js로 표출 +
    +
  • 보안 및 오프라인 지원: 도면의 외부 반출 원천 차단
  • +
  • SHX 한글 캐드 폰트 세팅 시 치수 및 글자 깨짐 없음
  • +
+
+
    +
  • 도면 렌더링에 따른 백엔드 서버 부하
  • +
  • 도면 레이어 제어 불가능 (고정된 평면 PDF 형태)
  • +
+
무료 (단, 기업 라이선스 체크)
dxf-parser +
Three.js / Canvas
DXF 아스키 텍스트 데이터를 분석해 브라우저 3D/2D Canvas로 직접 드로잉 +
    +
  • 인터랙티브 휠 줌/인/아웃, 팬(이동) 가능
  • +
  • 도면 레이어 On/Off 제어 스위치 구현 가능
  • +
+
+
    +
  • 이진 파일인 .dwg 직접 파싱 불가
  • +
  • 대형 설계 도면 로드 시 브라우저 연산 렉 유발
  • +
  • 한글/설계 폰트 유실 시 텍스트 위치 틀어짐
  • +
+
완전 무료 (MIT)
Autodesk Platform
Services (APS)
오토캐드 공식 클라우드 뷰어 API를 iframe으로 웹 포털에 삽입 +
    +
  • 설계 도면을 왜곡 없이 100% 완벽히 렌더링
  • +
  • 치수 측정, 단면 추출, 3D 단면 분해 제공
  • +
+
+
    +
  • 종량제 기반의 API 요금 발생
  • +
  • 기밀 도면 설계 자산 유출 우려
  • +
+
유료 (사용량 종량제 과금)
+
+
+
+ + +

💡 사내 시스템 개발을 위한 최적의 조합 (Best Practice)

+
+
+

1순위: 자체 서버 PDF 선변환 방식

+

+ 대상: Word, Excel, PPT, HWP, CAD 공통
+ 설명: LibreOffice + QCAD 변환 엔진을 사내 서버에 탑재하고, 파일이 업로드되는 즉시 백그라운드에서 PDF로 변환을 완료해 저장해 둡니다. 사용자가 열람 시 이미 변환된 PDF를 PDF.js로 보여주므로 대기 시간 0초에 완벽한 사내 보안을 달성하는 실무상 가장 검증된 안전한 구성입니다. +

+
+ +
+

2순위: 설치형 OnlyOffice 서버 도입

+

+ 대상: MS Office 파일군 (Word, Excel, PPT)
+ 설명: 사내 인프라(Docker 등)에 무료 오픈소스인 OnlyOffice Document Server를 1대 개설하여 iframe으로 연동합니다. 변환 대기 시간 없이 오리지널 수준의 재현력을 자랑하며, 웹 상에서 직접 편집 및 문서 다중 협업 기능까지 추가할 수 있는 중대형 인트라넷을 위한 최고급 구성입니다. +

+
+
+
+ +
+

© 2026 Antigravity. Web Document Viewer Implementation Guide for Enterprise. All Rights Reserved.

+
+ + + + + diff --git a/한글파일미리보기구현.md b/한글파일미리보기구현.md new file mode 100644 index 0000000..1043daa --- /dev/null +++ b/한글파일미리보기구현.md @@ -0,0 +1,366 @@ +# 한글 파일(HWP) 클라이언트 사이드 미리보기 구현 및 기술 명세서 + +본 문서는 서버 자원 소모 없이 웹 브라우저(클라이언트) 단에서 `.hwp` 파일을 직접 파싱하고 안정적으로 이미지를 비롯한 도형 요소까지 화면에 렌더링하도록 커스텀 반영한 작업 내용, 구현 로직, 아키텍처 및 코드 명세를 다룹니다. + +--- + +## 1. 미리보기 아키텍처 및 데이터 흐름 + +클라이언트 브라우저가 HWP 바이너리를 가져와 렌더링하기까지의 흐름도입니다. + +```mermaid +graph TD + A["HWP 파일 다운로드 (Blob)"] --> B["FileReader로 Binary String 로드"] + B --> C["hwp.js Viewer 초기화"] + C --> D["OLE Compound 파일 구조 해석"] + D --> E["BinData 가상 폴더 내 이미지 파싱"] + E --> F{"Magic Number 분석 (Raw 이미지 여부)"} + F -->|"PNG/JPEG/GIF/WMF 헤더 일치"| G["Decompress 건너뛰기 (Raw 복사)"] + F -->|"헤더 불일치 (압축 상태)"| H["pako.inflate (윈도우 비트 -15) 실행"] + H -->|"실패 시"| I["표준 inflate / raw / 원본 복사 폴백"] + G --> J["바이너리 데이터 Uint8Array 래핑"] + I --> J + J --> K["Blob 객체 및 URL.createObjectURL 주소 생성"] + K --> L{"컨트롤 타입 (control.type) 판별"} + L -->|"그림 (Picture)"| M["shapeGroup div에 backgroundImage 매핑"] + L -->|"도형 (Rectangle/Ellipse/Line/Polygon/Arc/Curve)"| N["도형 테두리 선, 둥글기, 연한 배경색 스타일 및 텍스트 렌더링"] + M --> O["최종 웹 뷰어 화면 렌더링"] + N --> O +``` + +--- + +## 2. 해결한 핵심 이슈 및 작업 내용 + +### 2.1 OLE 이미지 압축 해제(Decompression) 안정화 및 우회 로직 +* **현상:** 구형 HWP 파서 라이브러리(`hwp.js`)는 `pako.inflate`를 수행할 때 윈도우 비트 옵션(`{ windowBits: -15 }`)을 무조건적으로 주입해 압축을 해제했습니다. 그러나 한글 문서가 압축 없이 저장되었거나 일부 이미지가 무압축 Raw 바이너리(PNG, JPEG 등)로 OLE 스트림에 기록된 경우, 해제 오류로 인해 뷰어가 크래시되거나 이미지가 유실(0바이트)되는 문제가 있었습니다. +* **조치:** + 1. 압축 해제 전 바이너리의 **첫 4바이트(Magic Number) 시그니처**를 대조하여 웹 표준 파일 유형(PNG, JPEG, GIF, WMF) 검사를 선행합니다. + 2. 이미 시그니처를 충족하는 Raw 이미지의 경우 압축 해제를 우회하도록 최적화하여 렌더링 성능과 안정성을 향상했습니다. + 3. 압축이 필요한 경우 3단계 예외 처리 폴백(`windowBits: -15` -> `표준 inflate` -> `inflateRaw` -> `원본 바이너리`)을 구축하여 어떠한 조건에서도 파싱이 멈추지 않도록 조치했습니다. + +### 2.2 MIME 타입 오류 수정 및 바이너리 안전성 보장 +* **현상:** 기존 뷰어 소스 코드에 `type: "images/".concat(extension)`이라는 치명적인 오타가 있었습니다. 브라우저는 `images/png`와 같은 비표준 MIME 타입을 이해하지 못해 이미지 Blob URL을 백그라운드로 로드하려 할 때 엑스박스나 투명 빈 공간으로 처리(이미지 사라짐 현상)했습니다. 또한, 브라우저 환경에 따라 OLE 파서가 원본을 일반 Array 형태로 리턴할 때 문자열로 깨지는 위험이 존재했습니다. +* **조치:** + 1. MIME 타입을 표준 규격에 맞게 `"image/".concat(extension)`으로 전면 변경하고, `jpg` 확장자는 브라우저 표준 명칭인 `image/jpeg`로 정확히 매핑했습니다. + 2. Blob 생성 시 바이트의 깨짐을 방지하기 위해 생성자 주입 전 데이터 타입을 검증하고 `Uint8Array` 인스턴스로 안전하게 래핑하도록 보장했습니다. + +### 2.3 한글 문서 자체 생성 도형(Shape Object) 렌더링 기능 추가 +* **현상:** 본문 내에 삽입된 이미지는 로드가 완료되었으나, 문서 편집기 내부에서 자체 제작한 **직사각형(Rectangle), 타원(Ellipse), 선(Line), 다각형(Polygon) 등의 벡터 도형**은 파서가 태그 분류를 누락하여 완전히 투명하게 렌더링되었습니다. 이로 인해 테두리와 사각형 배경 없이 글자만 겹쳐서 표시되는 문제가 있었습니다. +* **조치:** + 1. HWP 파서의 핵심 순회 구조(`visit` 스위치-케이스 문) 내에 누락된 도형 컴포넌트 태그 ID들을 등록하여 파싱 단계에서 도형 종류(`control.type`)를 올바르게 정의하도록 했습니다. + 2. 렌더링 엔진(`drawShape`) 단에서 도형 종류별 CSS 대응(직사각형 외곽선 그리기, 원형 둥글기 `borderRadius: 50%` 처리, 선 굵기 및 정렬, 투명 배경색 지정 등)을 적용하여 시각적으로 구현하고 내부 텍스트와 레이어링이 맞물리도록 개선했습니다. + +### 2.4 단락 및 텍스트 줄 간격(Line Spacing) 조절을 통한 겹침 결함 조치 +* **현상:** 뷰어 화면의 텍스트 줄 간격이 브라우저 기본값(normal)을 사용하여 좁게 나타날 뿐만 아니라, 프로젝트 전반의 글로벌 CSS 규격에 의해 자식 요소인 `div` 태그의 `line-height` 속성이 덮어씌워져 줄 영역이 위아래로 심하게 겹쳐 보이는 시각적 오류가 발생했습니다. +* **조치:** + 1. `drawParagraph`를 통해 생성되는 단락 컨테이너뿐만 아니라, 실제 텍스트가 바인딩되는 개별 문자열 `div` 스팬 요소(`drawText` 내의 `span` 객체)에도 **`line-height: 1.65` 인라인 스타일을 강제 적용**했습니다. + 2. 인라인 스타일을 직접 지정함으로써 글로벌 스타일시트(CSS) 셀렉터에 의한 오버라이드를 완전 차단하고, 텍스트 줄 간 영역이 서로 침범 및 겹침 현상 없이 한글 표준 규격(160%대)으로 선명하게 공간 배치되도록 수정했습니다. + +--- + +## 3. 구현 코드 및 로직 명세 (libs/hwp.js) + +### 3.1 이미지 원본 파싱 및 Decompress 처리 (`visitBinData`) + +가상 OLE 디렉토리 내부에서 이미지를 파싱할 때 헤더를 분석하여 압축 우회 및 안정적으로 원본 바이트 배열을 추출하는 로직입니다. + +```javascript +key: "visitBinData", +value: function visitBinData(record) { + var reader = new ByteReader(record.payload); + reader.readUInt16(); + var id = reader.readUInt16(); + var extension = reader.readString(); + try { + var path = "Root Entry/BinData/BIN".concat("".concat(id.toString(16).toUpperCase()).padStart(4, '0'), ".").concat(extension); + var entry = cfb.find(this.container, path); + if (!entry || !entry.content) { + this.result.binData.push(new BinData(extension, new Uint8Array(0))); + return; + } + var payload = entry.content; + + // 1. 첫 4바이트 매직 넘버 검사로 Raw 이미지 선별 + var isRawImage = false; + if (payload.length >= 4) { + // PNG: 89 50 4E 47 + if (payload[0] === 0x89 && payload[1] === 0x50 && payload[2] === 0x4E && payload[3] === 0x47) isRawImage = true; + // JPEG: FF D8 FF + else if (payload[0] === 0xFF && payload[1] === 0xD8 && payload[2] === 0xFF) isRawImage = true; + // GIF: 47 49 46 38 + else if (payload[0] === 0x47 && payload[1] === 0x49 && payload[2] === 0x46 && payload[3] === 0x38) isRawImage = true; + // WMF: D7 CD C6 9A + else if (payload[0] === 0xD7 && payload[1] === 0xCD && payload[2] === 0xC6 && payload[3] === 0x9A) isRawImage = true; + } + + var decompressed; + if (isRawImage) { + // 압축 해제 없이 raw 바이너리 복사 + decompressed = payload; + } else { + // 2단계 다중 압축 해제 시도 (zlib 윈도우 비트 -15 -> 표준 -> raw 순서) + try { + decompressed = pako_1.inflate(payload, { windowBits: -15 }); + } catch (e1) { + try { + decompressed = pako_1.inflate(payload); + } catch (e2) { + try { + decompressed = pako_1.inflateRaw(payload); + } catch (e3) { + decompressed = payload; // 최종 폴백 + } + } + } + } + this.result.binData.push(new BinData(extension, decompressed)); + } catch (err) { + this.result.binData.push(new BinData(extension, new Uint8Array(0))); + } +} +``` + +### 3.2 도형 태그 식별을 위한 파서 해석 추가 (`visit`) + +`switch-case` 블록 내부에 한글 파일 자체 도형 컴포넌트 레코드 식별자를 등록하여 각 도형 오브젝트의 타입 분류를 해석합니다. + +```javascript +switch (record.tagID) { + // ... 기존 공통 케이스 ... + case SectionTagID.HWPTAG_SHAPE_COMPONENT_PICTURE: + { + this.visitPicture(record, control); + break; + } + // 추가 반영된 개별 도형 파싱 케이스 + case SectionTagID.HWPTAG_SHAPE_COMPONENT_RECTANGLE: + { + if (isShape(control)) { + control.type = CommonCtrlID.Rectangle; + } + break; + } + case SectionTagID.HWPTAG_SHAPE_COMPONENT_ELLIPSE: + { + if (isShape(control)) { + control.type = CommonCtrlID.Ellipse; + } + break; + } + case SectionTagID.HWPTAG_SHAPE_COMPONENT_LINE: + { + if (isShape(control)) { + control.type = CommonCtrlID.Line; + } + break; + } + case SectionTagID.HWPTAG_SHAPE_COMPONENT_ARC: + { + if (isShape(control)) { + control.type = CommonCtrlID.Arc; + } + break; + } + case SectionTagID.HWPTAG_SHAPE_COMPONENT_POLYGON: + { + if (isShape(control)) { + control.type = CommonCtrlID.Polygon; + } + break; + } + case SectionTagID.HWPTAG_SHAPE_COMPONENT_CURVE: + { + if (isShape(control)) { + control.type = CommonCtrlID.Curve; + } + break; + } +} +``` + +### 3.3 이미지 및 도형 요소 스타일 렌더링 (`drawShape`) + +타입 정보가 명시된 컨트롤을 바탕으로 그림 및 각 도형 컴포넌트를 브라우저에 알맞게 그리는 로직입니다. + +```javascript +key: "drawShape", +value: function drawShape(container, control) { + var _this3 = this; + var shapeGroup = document.createElement('div'); + shapeGroup.style.width = "".concat(control.width / 100, "pt"); + shapeGroup.style.height = "".concat(control.height / 100, "pt"); + + // 위치 스타일 세팅 (절대 좌표 및 여백) + if (control.attribute.vertRelTo === 0) { + shapeGroup.style.position = 'absolute'; + shapeGroup.style.top = "".concat(control.verticalOffset / 100, "pt"); + shapeGroup.style.left = "".concat(control.horizontalOffset / 100, "pt"); + } else { + shapeGroup.style.marginTop = "".concat(control.verticalOffset / 100, "pt"); + shapeGroup.style.marginLeft = "".concat(control.horizontalOffset / 100, "pt"); + } + + shapeGroup.style.zIndex = "".concat(control.zIndex); + shapeGroup.style.verticalAlign = 'middle'; + shapeGroup.style.display = 'inline-block'; + + if (isPicture(control)) { + // [1] 이미지(그림) 렌더링 분기 + var image = this.hwpDocument.info.binData[control.info.binID]; + if (!image || !image.payload || image.payload.length === 0) { + shapeGroup.style.border = '1px dashed #aaaaaa'; + shapeGroup.style.backgroundColor = '#f8f8f8'; + var placeholder = document.createElement('div'); + placeholder.style.display = 'flex'; + placeholder.style.alignItems = 'center'; + placeholder.style.justifyContent = 'center'; + placeholder.style.width = '100%'; + placeholder.style.height = '100%'; + placeholder.style.color = '#888888'; + placeholder.style.fontSize = '12px'; + placeholder.style.fontWeight = 'bold'; + placeholder.textContent = '[그림 영역]'; + shapeGroup.appendChild(placeholder); + } else { + var uint8Arr = image.payload instanceof Uint8Array ? image.payload : new Uint8Array(image.payload); + var blob = new Blob([uint8Arr], { + type: image.extension === 'jpg' ? 'image/jpeg' : "image/".concat(image.extension) + }); + var imageURL = window.URL.createObjectURL(blob); + + shapeGroup.style.backgroundImage = "url(\"".concat(imageURL, "\")"); + shapeGroup.style.backgroundRepeat = 'no-repeat'; + shapeGroup.style.backgroundPosition = 'center'; + shapeGroup.style.backgroundSize = 'contain'; + } + } else { + // [2] 자체 제작 도형 렌더링 분기 + shapeGroup.style.boxSizing = 'border-box'; + if (control.type === CommonCtrlID.Rectangle) { + // 직사각형 외곽 테두리 및 옅은 배경 + shapeGroup.style.border = '1.5px solid #333333'; + shapeGroup.style.backgroundColor = 'rgba(0, 0, 0, 0.02)'; + } else if (control.type === CommonCtrlID.Ellipse) { + // 타원형 및 둥근 모서리 처리 + shapeGroup.style.border = '1.5px solid #333333'; + shapeGroup.style.borderRadius = '50%'; + shapeGroup.style.backgroundColor = 'rgba(0, 0, 0, 0.02)'; + } else if (control.type === CommonCtrlID.Line) { + // 선 객체의 세로/가로 두께 정렬 + var w = control.width / 100; + var h = control.height / 100; + if (h < 5) { + shapeGroup.style.borderTop = '1.5px solid #333333'; + } else if (w < 5) { + shapeGroup.style.borderLeft = '1.5px solid #333333'; + } else { + shapeGroup.style.border = '1px solid #333333'; + } + } else if (control.type === CommonCtrlID.Arc || control.type === CommonCtrlID.Polygon || control.type === CommonCtrlID.Curve) { + // 다각형, 호, 자유곡선의 흐린 테두리 가이드 + shapeGroup.style.border = '1px dashed #555555'; + shapeGroup.style.backgroundColor = 'rgba(0, 0, 0, 0.01)'; + } else { + shapeGroup.style.border = '1px solid #cccccc'; + shapeGroup.style.backgroundColor = 'rgba(0, 0, 0, 0.01)'; + } + } + + // 도형 내부 Paragraph 텍스트가 정상 레이어링되도록 오버레이 처리 + control.content.forEach(function (paragraphList) { + paragraphList.items.forEach(function (paragraph) { + _this3.drawParagraph(shapeGroup, paragraph); + }); + }); + container.appendChild(shapeGroup); +} +``` + +### 3.4 개별 텍스트 스팬 스타일 지정 (`drawText`) + +개별 문자열 `div` 스팬 단위로 `line-height`를 인라인 스타일로 직접 주입하여 글로벌 CSS 오버라이드를 해결한 코드입니다. + +```javascript +key: "drawText", +value: function drawText(container, paragraph, shapePointer, endPos) { + var _this4 = this; + + var range = paragraph.content.slice(shapePointer.pos, endPos + 1); + var texts = []; + var ctrlIndex = 0; + range.forEach(function (hwpChar) { + if (typeof hwpChar.value === 'string') { + texts.push(hwpChar.value); + return; + } + + if (hwpChar.type === CharType.Extened) { + var control = paragraph.controls[ctrlIndex]; + ctrlIndex += 1; + + _this4.drawControl(container, control); + } + + if (hwpChar.value === 13) { + texts.push('\n'); + } + }); + var text = texts.join(''); + var span = document.createElement('div'); + span.textContent = text; + span.style.lineHeight = '1.65'; // 인라인 줄 간격 직접 부여하여 영역 겹침 현상 원천 차단 + + var charShape = this.hwpDocument.info.getCharShpe(shapePointer.shapeIndex); + + if (charShape) { + var fontBaseSize = charShape.fontBaseSize, + fontRatio = charShape.fontRatio, + color = charShape.color, + fontId = charShape.fontId; + var fontSize = fontBaseSize * (fontRatio[0] / 100); + span.style.fontSize = "".concat(fontSize, "pt"); + span.style.lineBreak = 'anywhere'; + span.style.whiteSpace = 'pre-wrap'; + span.style.color = this.getRGBStyle(color); + var fontFace = this.hwpDocument.info.fontFaces[fontId[0]]; + span.style.fontFamily = fontFace.getFontFamily(); + } + + container.appendChild(span); +} +``` + +### 3.5 텍스트 단락 줄 간격 지정 (`drawParagraph`) + +단락 컨테이너 생성 시 줄 간격을 쾌적하게(1.65배) 렌더링하는 코드 부분입니다. + +```javascript +key: "drawParagraph", +value: function drawParagraph(container, paragraph) { + var _this5 = this; + + var paragraphContainer = document.createElement('div'); + paragraphContainer.style.margin = '0'; + paragraphContainer.style.lineHeight = '1.65'; // 부모 단락에도 줄 간격 지정 + + var shape = this.hwpDocument.info.paragraphShapes[paragraph.shapeIndex]; + paragraphContainer.style.textAlign = TEXT_ALIGN[shape.align]; + paragraph.shapeBuffer.forEach(function (shapePointer, index) { + var endPos = paragraph.getShapeEndPos(index); + + _this5.drawText(paragraphContainer, paragraph, shapePointer, endPos); + }); + container.append(paragraphContainer); +} +``` + +--- + +## 4. 디버깅 및 유지보수 가이드 + +1. **클라이언트 캐시 문제:** + * 소스 파일인 `hwp.js`가 업데이트된 후에도 브라우저가 이전 라이브러리를 캐싱하고 있다면 이미지/도형이 보이지 않거나 줄 간격이 좁게 유지됩니다. 테스트 전에는 반드시 **`Ctrl + F5`** 또는 개발자 도구의 **`Disable cache`** 옵션을 활성화하여 확인을 진행하십시오. +2. **WMF/EMF 벡터 포맷 추가 제언:** + * 현재 웹 표준 규격(`PNG`, `JPEG`, `GIF`) 이미지는 프론트엔드 내에서 완전하게 디코딩됩니다. + * 향후 다수의 WMF/EMF 포맷에 대한 정교한 벡터 렌더링이 필요한 경우, `wmf.js` 라이브러리를 연동하거나, 가이드 3절의 백엔드 경량 이미지 변환 API(`magick convert` 등)를 통해 웹 표준 이미지(`WebP`/`PNG`)로 변환하여 렌더링하도록 확장 대응이 가능합니다.