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 @@
해외 프로젝트 (overseas)
+
+ 스토리지 제한 (GB)
+
+
취소
@@ -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 (.docx)
+
+
+ Excel (.xlsx)
+
+
+ PPT (.pptx)
+
+
+ 한글 (.hwp)
+
+
+ CAD (.dwg)
+
+
+
+
+
+
+
+
+
+
+
+ 구현 방법
+ 설명
+ 장점 (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.js
+ docx 구조를 순수 HTML 스트링 텍스트로 가볍게 치환해 표출
+
+
+ 매우 가볍고 렌더링 속도가 가장 빠름
+ 본문 텍스트 추출 및 본문 검색에 유리
+
+
+
+
+ 레이아웃 및 원본 스타일 대부분 유실
+ 줄글 형태 이외의 디자인 요소 깨짐
+
+
+ 완전 무료 (BSD)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 구현 방법
+ 설명
+ 장점 (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 필수
+ 사내 기밀 엑셀 데이터 반출 위험
+
+
+ 무료 (비상업용 제한)
+
+
+ Handsontable
+ SheetJS 등 데이터 파서 결과와 연동해 엑셀 형태 그리드로 표출
+
+
+ 가장 엑셀에 근접한 편집/뷰포트 UI
+ 정렬, 필터, 다중 복사 기능 지원
+
+
+
+
+ 단순 미리보기 뷰어 대비 오버스펙
+ 라이선스 비용이 매우 높음
+
+
+ 비영리만 무료 / 상업 유료
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 구현 방법
+ 설명
+ 장점 (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로 드로잉
+
+
+ 서버 전처리 없이 프론트 브라우저 드로잉
+
+
+
+
+ 조금만 복잡한 도형, 스마트아트 깨짐 매우 심함
+ 글꼴 폰트 밀림으로 텍스트 겹침 다수 발생
+
+
+ 완전 무료 (오픈소스)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 현재 한글 미리보기 적용 형태: 현재 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 라이선스 / 한컴 비용
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 구현 방법
+ 설명
+ 장점 (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으로 연동합니다. 변환 대기 시간 없이 오리지널 수준의 재현력을 자랑하며, 웹 상에서 직접 편집 및 문서 다중 협업 기능까지 추가할 수 있는 중대형 인트라넷을 위한 최고급 구성입니다.
+
+
+
+
+
+
+
+
+
+
+
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`)로 변환하여 렌더링하도록 확장 대응이 가능합니다.