한글뷰어 기능수정

This commit is contained in:
koj729
2026-06-18 08:52:23 +09:00
parent cb0c42fbeb
commit 9268e4e6bc
38 changed files with 2544 additions and 211 deletions

2
.env
View File

@@ -22,7 +22,7 @@ REDIS_PORT=6379
REDIS_PASSWORD= REDIS_PASSWORD=
# 5. MinIO 로컬 S3 스토리지 설정 (Docker 연동) # 5. MinIO 로컬 S3 스토리지 설정 (Docker 연동)
MINIO_ENDPOINT=http://localhost:9000 MINIO_ENDPOINT=http://172.16.40.52:9000
MINIO_ACCESSKEYID=minio_access_key MINIO_ACCESSKEYID=minio_access_key
MINIO_SECRETACCESSKEY=minio_secret_key MINIO_SECRETACCESSKEY=minio_secret_key

View File

@@ -118,12 +118,24 @@ PM_ver4/
2. **사용자 삭제 제한**: 프로젝트 권한 매핑 테이블(`tb_permission`)에 해당 유저가 참여 중인 현장 정보가 등록되어 있을 경우 사용자 계정 삭제를 제한합니다. 2. **사용자 삭제 제한**: 프로젝트 권한 매핑 테이블(`tb_permission`)에 해당 유저가 참여 중인 현장 정보가 등록되어 있을 경우 사용자 계정 삭제를 제한합니다.
3. **공통 코드 삭제 제한**: 대분류 마스터 코드(`code_master`) 하위에 소분류 세부 코드(`code_detail`)가 단 1개라도 생성되어 있을 경우 대분류 코드 삭제를 방지합니다. 3. **공통 코드 삭제 제한**: 대분류 마스터 코드(`code_master`) 하위에 소분류 세부 코드(`code_detail`)가 단 1개라도 생성되어 있을 경우 대분류 코드 삭제를 방지합니다.
### 4.2 시스템 글로벌 보존 정책 및 정기 청소 스케줄러 ### 4.2 시스템 글로벌 보존 정책 및 자동 삭제 실행 방식
시스템의 디스크 용량 관리 및 보안 가이드라인에 따라 자동 파일 삭제 스케줄러가 탑재되어 작동합니다. 시스템의 디스크 용량 관리 및 보안 가이드라인에 따라 자동 파일 삭제 정책이 탑재되어 작동합니다.
* **동작 주기**: 매일 자정 배치 구동 (`node-cron` 또는 백그라운드 스케줄러 엔진)
* **보존 규칙**: `tb_system_policy` 테이블에서 `GLOBAL_DELETE_POLICY` 설정 정보를 로드하여 활성화 여부(`is_active=true`), 보존 기한(`limit_days`), 최대 파일 개수(`limit_file_count`) 기준을 확인합니다. #### 1) 적용 기준 (Criteria)
* **삭제 기법**: 삭제 기준을 충족하는 아카이브 임시 파일들을 MinIO/R2 스토리지에서 제거하고 DB 내 파일 메타데이터 상태를 업데이트합니다. * `tb_system_policy` 테이블의 `GLOBAL_DELETE_POLICY` 설정(활성화 여부 `is_active`, 보존 기한 `limit_days`, 기준 파일 개수 `limit_file_count`)을 기준으로 작동합니다.
* **기록 적재**: 작업의 수행 일시, 삭제 경로, 적용된 정책 기준 및 성공 여부를 `tb_auto_clean_log` 테이블에 `SYSTEM` 작업자 식별자로 기록하여 감사 이력을 보존합니다. * **대상**: 아카이브의 **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) 활동유형 정의 ### 4.3 시스템 활동 로그(tb_log) 활동유형 정의
시스템 내 모든 파일 관리, 사용자 권한 및 프로젝트 설정 관련 중요 감사 로그는 `tb_log` 테이블에 기록되며, `activity` 컬럼에 설정되는 주요 활동유형은 다음과 같습니다. 시스템 내 모든 파일 관리, 사용자 권한 및 프로젝트 설정 관련 중요 감사 로그는 `tb_log` 테이블에 기록되며, `activity` 컬럼에 설정되는 주요 활동유형은 다음과 같습니다.

View File

@@ -76,7 +76,7 @@ exports.getProjects = async (req, res) => {
}; };
exports.createProject = 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) { if (!project_id || !project_nm) {
return res.status(400).json({ error: "프로젝트 ID와 명칭은 필수입니다." }); 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 storage_byte = limit_storage ? parseInt(limit_storage) * 1024 * 1024 * 1024 : 0;
const query = ` const query = `
INSERT INTO ver4.${tbProject} (project_id, project_nm, short_nm, category, storage_byte, is_active, user_id, create_date) 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, CURRENT_TIMESTAMP) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, CURRENT_TIMESTAMP)
RETURNING *; RETURNING *;
`; `;
const result = await client.query(query, [ const result = await client.query(query, [
@@ -104,13 +104,15 @@ exports.createProject = async (req, res) => {
category || null, category || null,
storage_byte, storage_byte,
is_active ?? true, 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; 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, [ await insertAuditLog(project_id, 'createProject', req.user?.user_id, userIp, [
`Project Name: ${project_nm}`, `Project Name: ${project_nm}`,
`Category: ${category}`, `Category: ${category}`,
`Storage limit: ${limit_storage} GB` `Storage limit: ${limit_storage} GB`,
`Overview enabled: ${overview !== false}`
]); ]);
res.status(201).json(result.rows[0]); res.status(201).json(result.rows[0]);
@@ -124,18 +126,18 @@ exports.createProject = async (req, res) => {
exports.updateProject = async (req, res) => { exports.updateProject = async (req, res) => {
const { id } = req.params; 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(); const client = await pool.connect();
try { try {
const storage_byte = limit_storage ? parseInt(limit_storage) * 1024 * 1024 * 1024 : 0; const storage_byte = limit_storage ? parseInt(limit_storage) * 1024 * 1024 * 1024 : 0;
const query = ` const query = `
UPDATE ver4.${tbProject} UPDATE ver4.${tbProject}
SET project_nm = $1, short_nm = $2, category = $3, storage_byte = $4, is_active = $5 SET project_nm = $1, short_nm = $2, category = $3, storage_byte = $4, is_active = $5, overview = $6
WHERE project_id = $6 WHERE project_id = $7
RETURNING *; 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) { if (result.rows.length === 0) {
return res.status(404).json({ error: "대상을 찾을 수 없습니다." }); return res.status(404).json({ error: "대상을 찾을 수 없습니다." });
} }
@@ -144,7 +146,8 @@ exports.updateProject = async (req, res) => {
`Project Name: ${project_nm}`, `Project Name: ${project_nm}`,
`Category: ${category}`, `Category: ${category}`,
`Storage limit: ${limit_storage} GB`, `Storage limit: ${limit_storage} GB`,
`Active status: ${is_active}` `Active status: ${is_active}`,
`Overview enabled: ${overview}`
]); ]);
res.status(200).json(result.rows[0]); res.status(200).json(result.rows[0]);
} catch (err) { } catch (err) {
@@ -517,37 +520,62 @@ exports.createUser = async (req, res) => {
exports.updateUser = async (req, res) => { exports.updateUser = async (req, res) => {
const { id } = req.params; 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(); const client = await pool.connect();
try { try {
const query = ` let result;
UPDATE ver4.tb_user if (user_pw && user_pw.trim() !== '') {
SET user_nm = $1, company = $2, dept = $3, position = $4, "group" = $5, is_resigned = $6 const passwordHash = crypto.createHash('sha256').update(user_pw).digest('hex');
WHERE user_id = $7 const query = `
RETURNING *; UPDATE ver4.tb_user
`; SET user_nm = $1, user_pw = $2, company = $3, dept = $4, position = $5, "group" = $6, is_resigned = $7
const result = await client.query(query, [ WHERE user_id = $8
user_nm, RETURNING *;
company || null, `;
dept || null, result = await client.query(query, [
position || null, user_nm,
group || null, passwordHash,
is_resigned, company || null,
id 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) { if (result.rows.length === 0) {
return res.status(404).json({ error: "대상을 찾을 수 없습니다." }); return res.status(404).json({ error: "대상을 찾을 수 없습니다." });
} }
const user = result.rows[0]; const user = result.rows[0];
const userIp = req.headers['cf-connecting-ip'] || req.ip || req.headers['x-forwarded-for'] || req.connection?.remoteAddress; 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}`, `Updated user_id: ${id}`,
`User name: ${user_nm}`, `User name: ${user_nm}`,
`Group: ${group}`, `Group: ${group}`,
`Is resigned: ${is_resigned}` `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; user.user_pw = undefined;
res.status(200).json(user); res.status(200).json(user);
} catch (err) { } catch (err) {
@@ -671,6 +699,13 @@ exports.updateSystemPolicy = async (req, res) => {
`Limit days: ${limit_days}`, `Limit days: ${limit_days}`,
`Is active: ${is_active}` `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]); res.status(200).json(result.rows[0]);
} catch (err) { } catch (err) {
console.error("updateSystemPolicy Error:", err); console.error("updateSystemPolicy Error:", err);

View File

@@ -2581,6 +2581,64 @@ exports.uploadData = async (req, res, next) => {
let insertLogResult = await insertLog(params); let insertLogResult = await insertLog(params);
if (insertLogResult.message == 'insertLog_success') { 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 = { let resultData = {
message: 'uploadData_success', message: 'uploadData_success',
projectId: projectId, projectId: projectId,
@@ -2881,8 +2939,81 @@ exports.removeTarget = async(req, res) => {
let permission = JSON.parse(params.userInfoString).permission; let permission = JSON.parse(params.userInfoString).permission;
let depth = getDepth(params.resourcePathArr[0]); let depth = getDepth(params.resourcePathArr[0]);
let isRecycleBinModal = params.isRecycleBinModal; 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({ return res.status(200).json({
message: 'removeTarget_failed_permission', message: 'removeTarget_failed_permission',
}); });
@@ -3221,7 +3352,7 @@ exports.addConvetPdfLog = async(req, res) => {
exports.removeConvertingData = async(req, res) => { exports.removeConvertingData = async(req, res) => {
const projectId = req.baseUrl.split('/')[1]; const projectId = req.baseUrl.split('/')[1];
let { params } = req.body; let { params } = req.body;
let { resourcePath, dataId, userInfoString } = params; let { resourcePath, dataId, userInfoString, stdout } = params;
//// 배열에서 파일 정보 삭제 //// 배열에서 파일 정보 삭제
convertingDataArr = convertingDataArr.filter(data => data.dataId !== dataId); convertingDataArr = convertingDataArr.filter(data => data.dataId !== dataId);
@@ -3238,7 +3369,8 @@ exports.removeConvertingData = async(req, res) => {
convertingDataArr: convertingDataArr, convertingDataArr: convertingDataArr,
resourcePath: resourcePath, resourcePath: resourcePath,
dataId: dataId, dataId: dataId,
userInfoString: userInfoString userInfoString: userInfoString,
stdout: stdout || ''
}; };
let io = getIo(); let io = getIo();
@@ -4363,6 +4495,7 @@ async function updateThumbnailInfoAction(projectId, params) {
} }
} }
// 삭제예정(2025.10.31): 이호성 // 삭제예정(2025.10.31): 이호성
// exports.get3dViewerThumbUrl = async(req, res, next) => { // exports.get3dViewerThumbUrl = async(req, res, next) => {
// const dataId = req.query.dataId; // const dataId = req.query.dataId;

Binary file not shown.

View File

@@ -1,12 +1,4 @@
const pool = require("../db/pool.js"); 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 env = process.env.NODE_ENV;
const tbData = env === 'production' ? 'tb_data' : '_test_tb_data'; 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]; 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 = ` const targetQuery = `
SELECT data_id, project_id, object_key, preview_key, popup_key, thumbnail_key, SELECT data_id, project_id, path1, path2, path3, last_folder_act_date
path1, path2, path3, path4, path5, path6, path7, path8, data_depth, is_folder FROM ver4.${tbData} f
FROM ver4.${tbData} WHERE f.is_folder = true
WHERE is_folder = false AND is_removed = false AND f.data_depth = 3
AND project_id IN ( AND f.is_removed = false
SELECT project_id AND f.last_folder_act_date < NOW() - CAST($1 || ' days' AS INTERVAL)
FROM ver4.${tbData} AND (
WHERE is_folder = false AND is_removed = false SELECT COUNT(*)
GROUP BY project_id FROM ver4.${tbData} files
HAVING COUNT(*) < $1 WHERE files.project_id = f.project_id
) AND files.path1 = f.path1
AND create_date < NOW() - CAST($2 || ' days' AS INTERVAL); 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 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 { try {
await client.query(`DELETE FROM ver4.${tbData} WHERE data_id = $1`, [file.data_id]); await client.query('BEGIN');
} catch (dbErr) {
console.error(`❌ DB Delete Error [DataID: ${file.data_id}]:`, dbErr.message); // 폴더 자체 및 그 하위 파일/폴더들을 전부 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; success = false;
} }
// 삭제 경로 조합 // 삭제 경로 조합 (/path1/path2/path3)
let cleanPath = ''; const cleanPath = `/${folder.path1}/${folder.path2}/${folder.path3}`;
for (let i = 1; i <= file.data_depth; i++) { const criteria = `보관수량 ${limit_file_count}개 미만 / 기한 ${limit_days}일 경과`;
if (file[`path${i}`]) cleanPath += '/' + file[`path${i}`];
}
// 로그 기록 // 로그 기록
const criteria = `보관수량 ${limit_file_count}개 미만 / 기한 ${limit_days}일 경과`;
await client.query(` await client.query(`
INSERT INTO ver4.tb_auto_clean_log (clean_date, project_id, clean_path, criteria_info, result_status) INSERT INTO ver4.tb_auto_clean_log (clean_date, project_id, clean_path, criteria_info, result_status)
VALUES (CURRENT_TIMESTAMP, 'SYSTEM', $1, $2, $3); VALUES (CURRENT_TIMESTAMP, $1, $2, $3, $4);
`, [cleanPath, criteria, success ? 'SUCCESS' : 'FAILED']); `, [folder.project_id, cleanPath, criteria, success ? 'SUCCESS' : 'FAILED']);
} }
console.log("⏰ Auto clean batch job finished successfully."); console.log("⏰ Auto clean batch job finished successfully.");
} catch (err) { } catch (err) {

View File

@@ -87,83 +87,107 @@ const worker = new Worker('convert-pdf', async (job) => {
const tempOutputPath = tempInputPath.replace(`.${ext}`, '.pdf'); const tempOutputPath = tempInputPath.replace(`.${ext}`, '.pdf');
console.log(`[Job ${job.id}] ⚙️ CLI 변환 실행: ${path.basename(exePath)}`); console.log(`[Job ${job.id}] ⚙️ CLI 변환 실행: ${path.basename(exePath)}`);
// 5. CLI 실행을 통해 PDF 변환 수행 let previewKey = '';
await new Promise((resolve, reject) => { let popupKey = '';
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();
});
});
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 { try {
fs.unlinkSync(tempInputPath); // 5. CLI 실행을 통해 PDF 변환 수행
fs.unlinkSync(tempOutputPath); const conversionResult = await new Promise((resolve) => {
console.log(`[Job ${job.id}] 🧹 임시 파일 정리 완료.`); execFile(exePath, [tempInputPath, tempOutputPath], { encoding: 'buffer' }, (err, stdoutBuffer, stderrBuffer) => {
} catch (cleanErr) { const decoder = new TextDecoder('euc-kr');
console.warn(`[Job ${job.id}] ⚠️ 임시 파일 제거 실패:`, cleanErr.message); 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 이벤트 리스너로 데이터 리턴 // 완료 후 completed 이벤트 리스너로 데이터 리턴

View File

@@ -24,6 +24,21 @@
"date": 1781483235835, "date": 1781483235835,
"name": "D:\\40. 개발소스\\04. PM\\pm_ver4\\trunk\\PM_ver4\\logs\\2026-06-15.exception.log", "name": "D:\\40. 개발소스\\04. PM\\pm_ver4\\trunk\\PM_ver4\\logs\\2026-06-15.exception.log",
"hash": "11b2e04cffe03e4f31cf1e53fa6682fb6c1a12e4fb5cf7799e90729ad81b1d0b" "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" "hashType": "sha256"

View File

@@ -24,6 +24,21 @@
"date": 1781483235833, "date": 1781483235833,
"name": "D:\\40. 개발소스\\04. PM\\pm_ver4\\trunk\\PM_ver4\\logs\\2026-06-15.error.log", "name": "D:\\40. 개발소스\\04. PM\\pm_ver4\\trunk\\PM_ver4\\logs\\2026-06-15.error.log",
"hash": "1030626feeefe2d3b4a2d169ecc3774fb59d61b8573c967313dec48f1ab60bc5" "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" "hashType": "sha256"

View File

@@ -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

View File

View File

View File

View File

View File

View File

@@ -61,7 +61,7 @@ convertPdfQueueEvents.on('failed', async ({ jobId, failedReason }) => {
console.log(''); console.log('');
// let resultData = (job.progress == 0) ? job.data : job.progress; // 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(); let io = getIo();
io.emit('convertPdf_failed', resultData); io.emit('convertPdf_failed', resultData);

25
scratch_check_policy.js Normal file
View File

@@ -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();

View File

@@ -1,6 +1,49 @@
// restarted for .env change
const app = require('./app'); const app = require('./app');
const http = require('http'); const http = require('http');
const socket = require('./socket'); 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); const server = http.createServer(app);
socket.init(server); // 웹소켓 초기화 socket.init(server); // 웹소켓 초기화

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
temp_convert/test_pptx.pdf Normal file

Binary file not shown.

View File

@@ -760,6 +760,7 @@
<th>카테고리</th> <th>카테고리</th>
<th>용량 제한</th> <th>용량 제한</th>
<th>상태</th> <th>상태</th>
<th style="width: 70px;">과업개요</th>
<th>관리</th> <th>관리</th>
</tr> </tr>
</thead> </thead>
@@ -1004,10 +1005,10 @@
<h3 class="card-title">🔎 시스템 활동 로그 조회 (tb_log)</h3> <h3 class="card-title">🔎 시스템 활동 로그 조회 (tb_log)</h3>
</div> </div>
<div class="form-row"> <div class="form-row">
<input type="text" class="text-input" id="search-log-user" placeholder="사용자 ID 검색..."> <input type="text" class="text-input" id="search-log-user" placeholder="사용자 ID 검색..." onkeyup="if(event.key === 'Enter') renderAuditLogs()">
<input type="text" class="text-input" id="search-log-project" placeholder="프로젝트명 검색..."> <input type="text" class="text-input" id="search-log-project" placeholder="프로젝트명 검색..." onkeyup="if(event.key === 'Enter') renderAuditLogs()">
<input type="text" class="text-input" id="filter-log-action" placeholder="조작 액션 검색..."> <input type="text" class="text-input" id="filter-log-action" placeholder="조작 액션 검색..." onkeyup="if(event.key === 'Enter') renderAuditLogs()">
<button class="btn btn-secondary" onclick="renderAuditLogs()">활동 로그 필터링</button> <button class="btn btn-secondary" onclick="renderAuditLogs()">검색</button>
</div> </div>
<div class="table-wrapper"> <div class="table-wrapper">
<table class="admin-table"> <table class="admin-table">
@@ -1194,11 +1195,11 @@
<option value="overseas">해외 프로젝트 (overseas)</option> <option value="overseas">해외 프로젝트 (overseas)</option>
</select> </select>
</div> </div>
<div class="form-group">
<label for="form-project-storage">스토리지 제한 (GB)</label>
<input type="number" class="text-input" id="form-project-storage" value="10" min="1" max="1000">
</div>
<div class="form-row"> <div class="form-row">
<div class="form-group">
<label for="form-project-storage">스토리지 제한 (GB)</label>
<input type="number" class="text-input" id="form-project-storage" value="10" min="1" max="1000">
</div>
<div class="form-group"> <div class="form-group">
<label for="form-project-active">운영 상태</label> <label for="form-project-active">운영 상태</label>
<select class="select-input" id="form-project-active"> <select class="select-input" id="form-project-active">
@@ -1206,6 +1207,13 @@
<option value="false">일시잠금 (Inactive)</option> <option value="false">일시잠금 (Inactive)</option>
</select> </select>
</div> </div>
<div class="form-group">
<label for="form-project-overview">과업개요 여부</label>
<select class="select-input" id="form-project-overview">
<option value="true" selected>사용 (True)</option>
<option value="false">미사용 (False)</option>
</select>
</div>
</div> </div>
<div style="display: flex; gap: 10px; justify-content: flex-end; margin-top: 10px;"> <div style="display: flex; gap: 10px; justify-content: flex-end; margin-top: 10px;">
<button class="btn btn-secondary" type="button" onclick="closeProjectModal()">취소</button> <button class="btn btn-secondary" type="button" onclick="closeProjectModal()">취소</button>
@@ -1616,6 +1624,7 @@
<td>${p.category_nm || p.category || '-'}</td> <td>${p.category_nm || p.category || '-'}</td>
<td>${p.storage_byte ? (Number(p.storage_byte) / (1024*1024*1024)).toFixed(0) + ' GB' : '0 GB'}</td> <td>${p.storage_byte ? (Number(p.storage_byte) / (1024*1024*1024)).toFixed(0) + ' GB' : '0 GB'}</td>
<td><span class="badge ${p.is_active ? 'active' : 'inactive'}">${p.is_active ? '활성' : '비활성'}</span></td> <td><span class="badge ${p.is_active ? 'active' : 'inactive'}">${p.is_active ? '활성' : '비활성'}</span></td>
<td><span class="badge ${p.overview !== false ? 'active' : 'inactive'}">${p.overview !== false ? '사용' : '미사용'}</span></td>
<td> <td>
<div class="action-btns" onclick="event.stopPropagation();"> <div class="action-btns" onclick="event.stopPropagation();">
<button class="btn btn-secondary btn-sm" onclick="openProjectModal('edit', '${p.project_id}')">수정</button> <button class="btn btn-secondary btn-sm" onclick="openProjectModal('edit', '${p.project_id}')">수정</button>
@@ -1831,6 +1840,7 @@
document.getElementById('form-project-id').removeAttribute('readonly'); document.getElementById('form-project-id').removeAttribute('readonly');
document.getElementById('form-project-id').disabled = false; document.getElementById('form-project-id').disabled = false;
document.getElementById('project-submit-btn').innerText = '등록 하기'; document.getElementById('project-submit-btn').innerText = '등록 하기';
document.getElementById('form-project-overview').value = 'true';
form.onsubmit = submitCreateProject; form.onsubmit = submitCreateProject;
} else { } else {
document.getElementById('project-modal-title').innerText = '📝 프로젝트 상세 정보 수정'; document.getElementById('project-modal-title').innerText = '📝 프로젝트 상세 정보 수정';
@@ -1848,6 +1858,7 @@
document.getElementById('form-project-category').value = p.category || ''; 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-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-active').value = p.is_active ? 'true' : 'false';
document.getElementById('form-project-overview').value = p.overview !== false ? 'true' : 'false';
} }
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@@ -1869,7 +1880,8 @@
short_nm: document.getElementById('form-project-short').value.trim(), short_nm: document.getElementById('form-project-short').value.trim(),
category: document.getElementById('form-project-category').value, category: document.getElementById('form-project-category').value,
limit_storage: Number(document.getElementById('form-project-storage').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 { try {
@@ -1893,7 +1905,8 @@
short_nm: document.getElementById('form-project-short').value.trim(), short_nm: document.getElementById('form-project-short').value.trim(),
category: document.getElementById('form-project-category').value, category: document.getElementById('form-project-category').value,
limit_storage: Number(document.getElementById('form-project-storage').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 { try {
@@ -2147,6 +2160,7 @@
document.getElementById('form-user-id').removeAttribute('readonly'); document.getElementById('form-user-id').removeAttribute('readonly');
document.getElementById('form-user-id').disabled = false; document.getElementById('form-user-id').disabled = false;
document.getElementById('form-user-pw').required = true; document.getElementById('form-user-pw').required = true;
document.getElementById('form-user-pw').placeholder = '••••••••';
pwRow.style.display = 'flex'; // PW 보이기 pwRow.style.display = 'flex'; // PW 보이기
document.getElementById('user-submit-btn').innerText = '등록 하기'; document.getElementById('user-submit-btn').innerText = '등록 하기';
form.onsubmit = submitCreateUser; form.onsubmit = submitCreateUser;
@@ -2155,9 +2169,10 @@
document.getElementById('form-user-id').setAttribute('readonly', 'true'); document.getElementById('form-user-id').setAttribute('readonly', 'true');
document.getElementById('form-user-id').disabled = true; document.getElementById('form-user-id').disabled = true;
// 패스워드 입력칸 숨기기 및 필수조건 해제 // 패스워드 필수조건 해제 및 노출 유지 (공백 시 미수정)
document.getElementById('form-user-pw').required = false; 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 = '수정 하기'; document.getElementById('user-submit-btn').innerText = '수정 하기';
@@ -2216,6 +2231,7 @@
async function submitEditUser(event, userId) { async function submitEditUser(event, userId) {
event.preventDefault(); event.preventDefault();
const payload = { const payload = {
user_pw: document.getElementById('form-user-pw').value,
user_nm: document.getElementById('form-user-nm').value.trim(), user_nm: document.getElementById('form-user-nm').value.trim(),
company: document.getElementById('form-user-company').value.trim(), company: document.getElementById('form-user-company').value.trim(),
dept: document.getElementById('form-user-dept').value.trim(), dept: document.getElementById('form-user-dept').value.trim(),

View File

@@ -370,7 +370,7 @@ export async function openNewWindowViewer() {
case 'pdf' : case 'pdf' :
case 'doc' : case 'doc' :
case 'ppt' : case 'ppt' :
case 'pptx' : case 'pptx':
case 'dwg' : case 'dwg' :
case 'dxf' : case 'dxf' :
case 'grm' : case 'grm' :

View File

@@ -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)=>{ window.addEventListener('popstate', async (e)=>{
// console.log(e); // console.log(e);

View File

@@ -22,6 +22,7 @@ import {
changeHeaderBtnStyle, changeHeaderBtnStyle,
changeTreeItemStyle, changeTreeItemStyle,
changeListItemStyle, changeListItemStyle,
updateSystemPolicyCache,
} from './pageRenderer.js'; } from './pageRenderer.js';
import { toggleModal } from './modalManager.js' import { toggleModal } from './modalManager.js'
import { mgmtFunc_addClickLog } from './managementFunctions.js'; import { mgmtFunc_addClickLog } from './managementFunctions.js';
@@ -326,6 +327,27 @@ socket.on('popupNotice', (data)=>{
alert(text); 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', () => { socket.on('forcedLogout', () => {
alert('프로젝트 재시작으로 인해 자동으로 로그아웃됩니다.\n다시 로그인 후 사용해주세요.'); alert('프로젝트 재시작으로 인해 자동으로 로그아웃됩니다.\n다시 로그인 후 사용해주세요.');
@@ -441,15 +463,16 @@ socket.on('addConvetPdfLog_success', async (resultData) => {
socket.on('convertPdf_failed', async (resultData) => { socket.on('convertPdf_failed', async (resultData) => {
console.log('-------- convertPdf_failed'); console.log('-------- convertPdf_failed');
console.log(resultData); console.log(resultData);
let resourcePath = (resultData.jobData.resourcePath) ? resultData.jobData.resourcePath : resultData.jobProgress.resourcePath; let resourcePath = (resultData.jobData && resultData.jobData.resourcePath) ? resultData.jobData.resourcePath : (resultData.jobProgress ? resultData.jobProgress.resourcePath : '');
let dataId = (resultData.jobData.dataId) ? resultData.jobData.dataId : resultData.jobProgress.dataId; let dataId = (resultData.jobData && resultData.jobData.dataId) ? resultData.jobData.dataId : (resultData.jobProgress ? resultData.jobProgress.dataId : '');
let userInfoString = (resultData.jobData.userInfoString) ? resultData.jobData.userInfoString : resultData.jobProgress.userInfoString; let userInfoString = (resultData.jobData && resultData.jobData.userInfoString) ? resultData.jobData.userInfoString : (resultData.jobProgress ? resultData.jobProgress.userInfoString : '');
// 서버의 convertingDataArr에서 변환 실패한 파일 정보 삭제 let failedReason = resultData.failedReason || '';
let removeConvertingDataParams = { let removeConvertingDataParams = {
resourcePath: resourcePath, resourcePath: resourcePath,
dataId: dataId, dataId: dataId,
userInfoString: userInfoString userInfoString: userInfoString,
stdout: failedReason
} }
let removeConvertingDataResult = await axios.post(`${vars.path_name}/removeConvertingData`, { params: removeConvertingDataParams }); let removeConvertingDataResult = await axios.post(`${vars.path_name}/removeConvertingData`, { params: removeConvertingDataParams });
console.log(removeConvertingDataResult); console.log(removeConvertingDataResult);

View File

@@ -573,7 +573,8 @@ export async function renderDocViewer(resourcePath, docId) {
let excelDirectArr = ['xls', 'xlsx', 'xlsm']; let excelDirectArr = ['xls', 'xlsx', 'xlsm'];
let hwpDirectArr = ['hwp', 'hwpx']; let hwpDirectArr = ['hwp', 'hwpx'];
let wordDirectArr = ['docx']; 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 selectedDoc = docVars.allDocData?.find((doc) => doc.doc_id === docId);
let previewKey = selectedDoc?.preview_key; let previewKey = selectedDoc?.preview_key;
@@ -617,12 +618,13 @@ export async function renderDocViewer(resourcePath, docId) {
let threeArr = ['glb', 'gltf', 'obj', 'stl', 'fbx', '3dm']; let threeArr = ['glb', 'gltf', 'obj', 'stl', 'fbx', '3dm'];
let allArr = [...pdfArr, ...gsimArr, ...ifcArr, ...imageArr, ...videoArr, ...textArr, ...urlArr, ...zipArr, ...threeArr]; let allArr = [...pdfArr, ...gsimArr, ...ifcArr, ...imageArr, ...videoArr, ...textArr, ...urlArr, ...zipArr, ...threeArr];
if (allArr.includes(ext)) { 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 (pdfArrFiltered.includes(ext)) viewerPdf(PresignedUrl);
if (excelDirectArr.includes(ext)) viewerExcel(PresignedUrl); if (excelDirectArr.includes(ext)) viewerExcel(PresignedUrl);
if (hwpDirectArr.includes(ext)) viewerHwp(PresignedUrl); if (hwpDirectArr.includes(ext)) viewerHwp(PresignedUrl);
if (wordDirectArr.includes(ext)) viewerWord(PresignedUrl); if (wordDirectArr.includes(ext)) viewerWord(PresignedUrl);
if (pptxDirectArr.includes(ext)) viewerPptx(PresignedUrl);
if (gsimArr.includes(ext)) viewerGsim(PresignedUrl); if (gsimArr.includes(ext)) viewerGsim(PresignedUrl);
if (ifcArr.includes(ext)) viewerIfc(PresignedUrl); if (ifcArr.includes(ext)) viewerIfc(PresignedUrl);
if (threeArr.includes(ext)) viewer3d(PresignedUrl); if (threeArr.includes(ext)) viewer3d(PresignedUrl);
@@ -860,18 +862,54 @@ export async function renderDocViewer(resourcePath, docId) {
const container = document.createElement('div'); const container = document.createElement('div');
container.style.width = '100%'; container.style.width = '100%';
container.style.height = '100%'; container.style.height = '100%';
container.style.overflow = 'auto'; container.style.overflowX = 'hidden';
container.style.overflowY = 'auto';
container.style.padding = '20px'; container.style.padding = '20px';
container.style.boxSizing = 'border-box'; container.style.boxSizing = 'border-box';
container.style.background = '#f5f5f5'; 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'); const hwpInner = document.createElement('div');
hwpInner.style.background = '#ffffff'; hwpInner.classList.add('hwp-inner-container');
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%';
container.appendChild(hwpInner); container.appendChild(hwpInner);
docVars.viewer.appendChild(container); docVars.viewer.appendChild(container);
@@ -896,6 +934,281 @@ export async function renderDocViewer(resourcePath, docId) {
docVars.viewer.dataset.viewerType = 'hwp'; docVars.viewer.dataset.viewerType = 'hwp';
} }
function viewerPptx(presignedUrl) {
docVars.viewer.innerHTML = '<div style="display:flex;justify-content:center;align-items:center;height:100%;font-size:1.2rem;color:#666;background:#fff;">PPTX 문서를 불러오는 중...</div>';
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 = '<div style="display:flex;justify-content:center;align-items:center;height:100%;color:#d9534f;background:#fff;padding:20px;text-align:center;">PPTX 파싱 중 에러가 발생했습니다. 상단의 "PDF로 보기" 버튼을 이용해 주세요.</div>';
}
})
.catch(err => {
console.error(err);
docVars.viewer.innerHTML = '<div style="display:flex;justify-content:center;align-items:center;height:100%;color:#d9534f;background:#fff;">PPTX 문서를 불러오는데 실패했습니다.</div>';
});
docVars.viewer.dataset.viewerType = 'pptx';
}
async function viewerPdf(PresignedUrl) { async function viewerPdf(PresignedUrl) {
resetViewer(); resetViewer();
@@ -1222,7 +1535,11 @@ document.querySelector('.official-doc-main .official-doc-preview .official-doc-p
//Presigned URL //Presigned URL
let PresignedUrl; let PresignedUrl;
await syncDocInfo(['official', 'attach', null]); 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) { if (objectKey == undefined || objectKey == `` || objectKey == null) {
return; return;
} }
@@ -1250,19 +1567,26 @@ document.querySelector('.official-doc-main .official-doc-preview .official-doc-p
let open_ext = `pdf`; let open_ext = `pdf`;
switch (ext) { switch (ext) {
case 'pdf': case 'pdf':
case 'hwp':
case 'hwpx':
case 'xls':
case 'xlsm':
case 'ppt': case 'ppt':
case 'pptx': case 'pptx':
case 'doc': case 'doc':
case 'docx':
case 'dwg': case 'dwg':
case 'dxf': case 'dxf':
case 'grm': case 'grm':
open_ext = 'pdf'; open_ext = 'pdf';
break; 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': case 'gsim':
open_ext = 'gsim'; open_ext = 'gsim';
break; break;

View File

@@ -44,6 +44,9 @@ if(data && Object.keys(data).length>0 && (data.$type == 'text'|| data.type == 't
case 'docx': case 'docx':
_openDocx(fullPath, data); _openDocx(fullPath, data);
break; break;
case 'pptx':
_openPptx(fullPath, data);
break;
case 'hwp': case 'hwp':
case 'hwpx': case 'hwpx':
_openHwp(fullPath, data); _openHwp(fullPath, data);
@@ -812,18 +815,54 @@ function _openHwp(path, data) {
const container = document.createElement('div'); const container = document.createElement('div');
container.style.width = '100%'; container.style.width = '100%';
container.style.height = '100%'; container.style.height = '100%';
container.style.overflow = 'auto'; container.style.overflowX = 'hidden';
container.style.overflowY = 'auto';
container.style.padding = '20px'; container.style.padding = '20px';
container.style.boxSizing = 'border-box'; container.style.boxSizing = 'border-box';
container.style.background = '#f5f5f5'; 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'); const hwpInner = document.createElement('div');
hwpInner.style.background = '#ffffff'; hwpInner.classList.add('hwp-inner-container');
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%';
container.appendChild(hwpInner); container.appendChild(hwpInner);
viewer.appendChild(container); viewer.appendChild(container);
@@ -845,3 +884,280 @@ function _openHwp(path, data) {
viewer.innerHTML = '<div style="display:flex;justify-content:center;align-items:center;height:100%;color:#d9534f;background:#fff;">한글 문서를 불러오는데 실패했습니다.</div>'; viewer.innerHTML = '<div style="display:flex;justify-content:center;align-items:center;height:100%;color:#d9534f;background:#fff;">한글 문서를 불러오는데 실패했습니다.</div>';
}); });
} }
function _openPptx(path, data) {
const viewer = document.getElementById('popup_viewer');
viewer.innerHTML = '<div style="display:flex;justify-content:center;align-items:center;height:100%;font-size:1.2rem;color:#666;background:#fff;">PPTX 문서를 불러오는 중...</div>';
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 = '<div style="display:flex;justify-content:center;align-items:center;height:100%;color:#d9534f;background:#fff;padding:20px;text-align:center;">PPTX 파싱 중 에러가 발생했습니다. 상단의 "PDF로 보기" 버튼을 이용해 주세요.</div>';
}
})
.catch(err => {
console.error(err);
viewer.innerHTML = '<div style="display:flex;justify-content:center;align-items:center;height:100%;color:#d9534f;background:#fff;">PPTX 문서를 불러오는데 실패했습니다.</div>';
});
}

View File

@@ -0,0 +1,969 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>웹 문서/도면 미리보기 구현 가이드</title>
<!-- 프리미엄 한글 웹폰트 및 아이콘용 폰트 연동 -->
<link rel="stylesheet" as="style" crossorigin href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
:root {
--primary-color: #2563eb;
--primary-hover: #1d4ed8;
--primary-light: #eff6ff;
--bg-color: #f8fafc;
--card-bg: #ffffff;
--text-color: #1e293b;
--text-muted: #64748b;
--border-color: #e2e8f0;
--badge-free: #10b981;
--badge-free-bg: #ecfdf5;
--badge-paid: #f59e0b;
--badge-paid-bg: #fffbeb;
--badge-warning: #ef4444;
--badge-warning-bg: #fef2f2;
--badge-current: #3b82f6;
--badge-current-bg: #dbeafe;
--badge-option: #475569;
--badge-option-bg: #f1f5f9;
--shadow-sm: 0 1px 3px 0 rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.05), 0 4px 6px -2px rgba(0, 0, 0, 0.03);
--transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
body {
font-family: 'Pretendard', -apple-system, BlinkMacSystemFont, system-ui, Roboto, sans-serif;
background-color: var(--bg-color);
color: var(--text-color);
margin: 0;
padding: 0;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
/* 헤더 스타일 */
header {
background: linear-gradient(135deg, #1e3a8a 0%, #2563eb 100%);
color: white;
padding: 3.5rem 2rem;
text-align: center;
box-shadow: var(--shadow-md);
position: relative;
overflow: hidden;
}
header::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: radial-gradient(circle at 80% 20%, rgba(255,255,255,0.08) 0%, transparent 50%);
pointer-events: none;
}
header h1 {
font-size: 2.2rem;
font-weight: 800;
margin: 0 0 0.8rem 0;
letter-spacing: -0.03em;
}
header p {
font-size: 1.1rem;
opacity: 0.9;
margin: 0;
font-weight: 400;
}
/* 메인 컨테이너 */
main {
max-width: 1200px;
margin: -2rem auto 4rem auto;
padding: 0 1.5rem;
position: relative;
z-index: 10;
}
/* 탭 내비게이션 */
.tab-nav {
display: flex;
background: var(--card-bg);
padding: 0.5rem;
border-radius: 1rem;
box-shadow: var(--shadow-lg);
margin-bottom: 2rem;
overflow-x: auto;
scrollbar-width: none; /* Firefox */
border: 1px solid var(--border-color);
}
.tab-nav::-webkit-scrollbar {
display: none; /* Chrome/Safari */
}
.tab-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 0.6rem;
padding: 1rem 1.5rem;
border: none;
background: transparent;
font-size: 1rem;
font-weight: 700;
color: var(--text-muted);
cursor: pointer;
border-radius: 0.75rem;
transition: var(--transition);
white-space: nowrap;
}
.tab-btn i {
font-size: 1.2rem;
transition: var(--transition);
}
.tab-btn:hover {
color: var(--primary-color);
background-color: var(--primary-light);
}
.tab-btn.active {
color: white;
background: var(--primary-color);
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.2);
}
/* 탭 콘텐츠 패널 */
.tab-panel {
display: none;
animation: fadeIn 0.4s ease-out forwards;
}
.tab-panel.active {
display: block;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 카드 및 테이블 공통 스타일 */
.card {
background: var(--card-bg);
border-radius: 1.25rem;
padding: 2rem;
box-shadow: var(--shadow-md);
border: 1px solid var(--border-color);
margin-bottom: 2rem;
}
.card-header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1.5rem;
border-bottom: 2px solid var(--primary-light);
padding-bottom: 1rem;
}
.card-header h2 {
font-size: 1.4rem;
font-weight: 800;
margin: 0;
color: var(--text-color);
letter-spacing: -0.02em;
}
.card-header i {
font-size: 1.5rem;
color: var(--primary-color);
}
/* 반응형 테이블 */
.table-responsive {
overflow-x: auto;
border-radius: 0.75rem;
border: 1px solid var(--border-color);
}
table {
width: 100%;
border-collapse: collapse;
text-align: left;
font-size: 0.95rem;
}
th {
background-color: #f8fafc;
color: var(--text-color);
font-weight: 700;
padding: 1rem 1.25rem;
border-bottom: 2px solid var(--border-color);
white-space: nowrap;
}
td {
padding: 1.25rem;
border-bottom: 1px solid var(--border-color);
vertical-align: top;
}
tr:last-child td {
border-bottom: none;
}
tr:hover td {
background-color: #fafafa;
}
.col-method {
font-weight: 700;
color: var(--primary-color);
width: 22%;
}
.col-desc {
width: 28%;
}
.col-pros {
color: #0f766e;
width: 25%;
}
.col-cons {
color: #b91c1c;
width: 25%;
}
/* 장단점 리스트 아이콘 */
.bullet-list {
margin: 0;
padding-left: 1.2rem;
}
.bullet-list li {
margin-bottom: 0.4rem;
}
.bullet-list li:last-child {
margin-bottom: 0;
}
/* 라이선스 및 적용 배지 */
.badge {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.8rem;
font-weight: 700;
line-height: 1.2;
margin-bottom: 0.25rem;
}
.badge-free {
color: var(--badge-free);
background-color: var(--badge-free-bg);
border: 1px solid rgba(16, 185, 129, 0.2);
}
.badge-paid {
color: var(--badge-paid);
background-color: var(--badge-paid-bg);
border: 1px solid rgba(245, 158, 11, 0.2);
}
.badge-warning {
color: var(--badge-warning);
background-color: var(--badge-warning-bg);
border: 1px solid rgba(239, 68, 68, 0.2);
}
/* 현재 시스템 적용 표시 전용 배지 */
.badge-current {
color: #1e40af;
background-color: #dbeafe;
border: 1px solid rgba(59, 130, 246, 0.4);
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.75rem;
margin-top: 0.5rem;
padding: 0.2rem 0.6rem;
border-radius: 0.375rem;
}
.badge-option {
color: #475569;
background-color: #f1f5f9;
border: 1px solid rgba(71, 85, 105, 0.3);
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.75rem;
margin-top: 0.5rem;
padding: 0.2rem 0.6rem;
border-radius: 0.375rem;
}
/* 가이드 팁 알림창 */
.alert-box {
background-color: var(--primary-light);
border-left: 4px solid var(--primary-color);
padding: 1.25rem 1.5rem;
border-radius: 0.5rem;
margin-top: 1.5rem;
display: flex;
gap: 0.8rem;
align-items: flex-start;
}
.alert-box i {
color: var(--primary-color);
font-size: 1.2rem;
margin-top: 0.2rem;
}
.alert-box div {
margin: 0;
font-size: 0.95rem;
color: #1e3a8a;
}
/* 베스트 프랙티스 카드 */
.bp-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.5rem;
margin-top: 2.5rem;
}
.bp-card {
background: var(--card-bg);
border-radius: 1.25rem;
padding: 1.75rem;
box-shadow: var(--shadow-md);
border: 1px solid var(--border-color);
border-top: 5px solid var(--primary-color);
transition: var(--transition);
}
.bp-card:hover {
transform: translateY(-5px);
box-shadow: var(--shadow-lg);
}
.bp-card.premium {
border-top-color: #8b5cf6;
}
.bp-card h3 {
margin: 0 0 0.75rem 0;
font-size: 1.2rem;
font-weight: 800;
display: flex;
align-items: center;
gap: 0.5rem;
}
.bp-card h3 i {
color: var(--primary-color);
}
.bp-card.premium h3 i {
color: #8b5cf6;
}
.bp-card p {
margin: 0;
font-size: 0.9rem;
color: var(--text-muted);
line-height: 1.5;
}
/* 하단 저작권 표시 */
footer {
text-align: center;
padding: 2rem;
color: var(--text-muted);
font-size: 0.85rem;
border-top: 1px solid var(--border-color);
background: var(--card-bg);
}
/* 반응형 모바일 브레이크포인트 */
@media (max-width: 768px) {
header {
padding: 2.5rem 1rem;
}
header h1 {
font-size: 1.7rem;
}
.tab-btn {
padding: 0.8rem 1rem;
font-size: 0.9rem;
}
td, th {
padding: 0.9rem;
}
.col-method {
width: 25%;
}
.col-desc {
width: 25%;
}
}
</style>
</head>
<body>
<header>
<h1>웹 문서 및 도면 미리보기 기술 가이드</h1>
<p>무료 오픈소스 라이브러리 및 서버 렌더러 방식을 중심으로 한 아키텍처 비교표</p>
</header>
<main>
<!-- 탭 내비게이션 메뉴 -->
<nav class="tab-nav" aria-label="파일 형식별 보기">
<button class="tab-btn active" onclick="switchTab('word')">
<i class="fa-regular fa-file-word"></i> Word (.docx)
</button>
<button class="tab-btn" onclick="switchTab('excel')">
<i class="fa-regular fa-file-excel"></i> Excel (.xlsx)
</button>
<button class="tab-btn" onclick="switchTab('ppt')">
<i class="fa-regular fa-file-powerpoint"></i> PPT (.pptx)
</button>
<button class="tab-btn" onclick="switchTab('hwp')">
<i class="fa-solid fa-file-lines"></i> 한글 (.hwp)
</button>
<button class="tab-btn" onclick="switchTab('cad')">
<i class="fa-solid fa-drafting-compass"></i> CAD (.dwg)
</button>
</nav>
<!-- 1. Word 탭 -->
<div id="word" class="tab-panel active">
<div class="card">
<div class="card-header">
<i class="fa-regular fa-file-word"></i>
<h2>Word (.doc, .docx) 미리보기 방식 비교</h2>
</div>
<div class="table-responsive">
<table>
<thead>
<tr>
<th>구현 방법</th>
<th>설명</th>
<th>장점 (Pros)</th>
<th>단점 (Cons)</th>
<th>라이선스 / 비용</th>
</tr>
</thead>
<tbody>
<tr>
<td class="col-method">
docx-preview<br>(npm 패키지)
<div class="badge-current"><i class="fa-solid fa-check"></i> 현재 기본 적용됨</div>
</td>
<td class="col-desc">docx 이진 데이터를 읽어 브라우저 JS로 파싱하여 HTML/CSS로 그리기</td>
<td class="col-pros">
<ul class="bullet-list">
<li>순수 프론트엔드 작동 (서버 부하 없음)</li>
<li>워드 파일 서식 보존 수준 우수</li>
</ul>
</td>
<td class="col-cons">
<ul class="bullet-list">
<li>구형 <code>.doc</code> 파일 감지 및 파싱 불가</li>
<li>일부 복잡한 다단, 도형 객체 깨짐</li>
</ul>
</td>
<td><span class="badge badge-free">완전 무료 (MIT)</span></td>
</tr>
<tr>
<td class="col-method">
서버 PDF 변환<br>(LibreOffice)
<div class="badge-option"><i class="fa-solid fa-gear"></i> PDF로 보기 선택적용</div>
</td>
<td class="col-desc">서버 단에서 LibreOffice CLI로 PDF 변환 후 브라우저 PDF.js로 화면 표출</td>
<td class="col-pros">
<ul class="bullet-list">
<li><strong>100% 보안 및 오프라인망 지원</strong></li>
<li>다양한 오피스 파일 공통 규격 처리 가능</li>
</ul>
</td>
<td class="col-cons">
<ul class="bullet-list">
<li>최초 변환 대기 시간 발생 (1~2초)</li>
<li>서버 자원 소모 및 변환 엔진 세팅 필요</li>
</ul>
</td>
<td><span class="badge badge-free">완전 무료 (LGPLv3)</span></td>
</tr>
<tr>
<td class="col-method">Microsoft Office<br>Online Viewer</td>
<td class="col-desc">MS 뷰어 URL 주소에 파일 링크를 태워 iframe으로 임베드하는 방식</td>
<td class="col-pros">
<ul class="bullet-list">
<li>개발 공수 제로에 가까움</li>
<li>오리지널 서식 레이아웃 완벽 보존</li>
</ul>
</td>
<td class="col-cons">
<ul class="bullet-list">
<li>외부 인터넷 연동 필수 (로컬 사용 불가)</li>
<li>사내 기밀 문서 외부 반출 보안 이슈</li>
</ul>
</td>
<td><span class="badge badge-free">무료 (비상업용 제한)</span></td>
</tr>
<tr>
<td class="col-method">Mammoth.js</td>
<td class="col-desc">docx 구조를 순수 HTML 스트링 텍스트로 가볍게 치환해 표출</td>
<td class="col-pros">
<ul class="bullet-list">
<li>매우 가볍고 렌더링 속도가 가장 빠름</li>
<li>본문 텍스트 추출 및 본문 검색에 유리</li>
</ul>
</td>
<td class="col-cons">
<ul class="bullet-list">
<li>레이아웃 및 원본 스타일 대부분 유실</li>
<li>줄글 형태 이외의 디자인 요소 깨짐</li>
</ul>
</td>
<td><span class="badge badge-free">완전 무료 (BSD)</span></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- 2. Excel 탭 -->
<div id="excel" class="tab-panel">
<div class="card">
<div class="card-header">
<i class="fa-regular fa-file-excel"></i>
<h2>Excel (.xls, .xlsx) 미리보기 방식 비교</h2>
</div>
<div class="table-responsive">
<table>
<thead>
<tr>
<th>구현 방법</th>
<th>설명</th>
<th>장점 (Pros)</th>
<th>단점 (Cons)</th>
<th>라이선스 / 비용</th>
</tr>
</thead>
<tbody>
<tr>
<td class="col-method">
SheetJS (xlsx.js)<br>+ Luckysheet
<div class="badge-current"><i class="fa-solid fa-check"></i> 현재 기본 적용됨</div>
</td>
<td class="col-desc">브라우저 JS로 엑셀 파일을 읽어 순수 HTML Table 및 Luckysheet 웹 엑셀 셀로 렌더링</td>
<td class="col-pros">
<ul class="bullet-list">
<li>완전한 클라이언트 독립 처리 (서버 연산 없음)</li>
<li>스프레드시트 형태에 유사하게 파싱하여 표출</li>
</ul>
</td>
<td class="col-cons">
<ul class="bullet-list">
<li>복잡한 글꼴, 서식 일부 및 테두리 소실 우려</li>
<li>엑셀 내장형 차트 및 피벗 드로잉 불가</li>
</ul>
</td>
<td><span class="badge badge-free">무료 (Community 에디션)</span></td>
</tr>
<tr>
<td class="col-method">
서버 PDF 변환<br>(LibreOffice)
<div class="badge-option"><i class="fa-solid fa-gear"></i> PDF로 보기 선택적용</div>
</td>
<td class="col-desc">서버 단에서 엑셀을 PDF/HTML로 변환하여 브라우저에 표시</td>
<td class="col-pros">
<ul class="bullet-list">
<li>보안 유출 없는 자체 서버 환경 구축</li>
<li>열 너비, 정밀 선 스타일 보존 우수</li>
</ul>
</td>
<td class="col-cons">
<ul class="bullet-list">
<li>시트가 여러 개일 때 출력 용지 맞춤 조절 필요</li>
<li>인터랙션(필터링, 탭 편집) 불가</li>
</ul>
</td>
<td><span class="badge badge-free">완전 무료 (LGPLv3)</span></td>
</tr>
<tr>
<td class="col-method">Microsoft Office<br>Online Viewer</td>
<td class="col-desc">MS 뷰어 URL 주소에 파일 링크를 태워 iframe으로 임베드하는 방식</td>
<td class="col-pros">
<ul class="bullet-list">
<li>시트 탭, 대용량 표, 차트 완벽 렌더링</li>
<li>수식 연산 결과 그대로 노출</li>
</ul>
</td>
<td class="col-cons">
<ul class="bullet-list">
<li>인터넷 및 공인 URL 필수</li>
<li>사내 기밀 엑셀 데이터 반출 위험</li>
</ul>
</td>
<td><span class="badge badge-free">무료 (비상업용 제한)</span></td>
</tr>
<tr>
<td class="col-method">Handsontable</td>
<td class="col-desc">SheetJS 등 데이터 파서 결과와 연동해 엑셀 형태 그리드로 표출</td>
<td class="col-pros">
<ul class="bullet-list">
<li>가장 엑셀에 근접한 편집/뷰포트 UI</li>
<li>정렬, 필터, 다중 복사 기능 지원</li>
</ul>
</td>
<td class="col-cons">
<ul class="bullet-list">
<li>단순 미리보기 뷰어 대비 오버스펙</li>
<li>라이선스 비용이 매우 높음</li>
</ul>
</td>
<td><span class="badge badge-paid">비영리만 무료 / 상업 유료</span></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- 3. PPT 탭 -->
<div id="ppt" class="tab-panel">
<div class="card">
<div class="card-header">
<i class="fa-regular fa-file-powerpoint"></i>
<h2>PPT (.ppt, .pptx) 미리보기 방식 비교</h2>
</div>
<div class="table-responsive">
<table>
<thead>
<tr>
<th>구현 방법</th>
<th>설명</th>
<th>장점 (Pros)</th>
<th>단점 (Cons)</th>
<th>라이선스 / 비용</th>
</tr>
</thead>
<tbody>
<tr>
<td class="col-method">
서버 PDF 변환<br>+ PDF.js
<div class="badge-current"><i class="fa-solid fa-check"></i> 현재 기본 적용됨</div>
</td>
<td class="col-desc">서버 단에서 PPT를 PDF로 일체 변환 후 브라우저에 임베딩 렌더링</td>
<td class="col-pros">
<ul class="bullet-list">
<li><strong>자체 사내망 보안 완벽 보존 (100% 로컬)</strong></li>
<li>슬라이드 레이아웃 훼손 없는 완벽한 품질 열람</li>
<li>페이지 점프 및 반응형 뷰어 연동</li>
</ul>
</td>
<td class="col-cons">
<ul class="bullet-list">
<li>최초 요청 시 PDF 변환 연산 시간 필요</li>
<li>전환 애니메이션 및 동영상 등 미디어 소실</li>
</ul>
</td>
<td><span class="badge badge-free">완전 무료 (LGPLv3)</span></td>
</tr>
<tr>
<td class="col-method">Microsoft Office<br>Online Viewer</td>
<td class="col-desc">MS 뷰어 URL 주소에 파일 링크를 태워 iframe으로 임베드하는 방식</td>
<td class="col-pros">
<ul class="bullet-list">
<li>슬라이드 애니메이션 효과 지원</li>
<li>도형, 그림, 차트 레이아웃 100% 보존</li>
</ul>
</td>
<td class="col-cons">
<ul class="bullet-list">
<li>인터넷 및 공인 URL 필수</li>
<li>대용량 도표가 포함된 발표 기밀 반출 위험</li>
</ul>
</td>
<td><span class="badge badge-free">무료 (비상업용 제한)</span></td>
</tr>
<tr>
<td class="col-method">PptxGenJS 역파싱<br>/ PPTXjs</td>
<td class="col-desc">pptx 압축을 풀어 XML 벡터 데이터를 해석해 Canvas/SVG로 드로잉</td>
<td class="col-pros">
<ul class="bullet-list">
<li>서버 전처리 없이 프론트 브라우저 드로잉</li>
</ul>
</td>
<td class="col-cons">
<ul class="bullet-list">
<li>조금만 복잡한 도형, 스마트아트 깨짐 매우 심함</li>
<li>글꼴 폰트 밀림으로 텍스트 겹침 다수 발생</li>
</ul>
</td>
<td><span class="badge badge-free">완전 무료 (오픈소스)</span></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- 4. 한글 탭 -->
<div id="hwp" class="tab-panel">
<div class="card">
<div class="card-header">
<i class="fa-solid fa-file-lines"></i>
<h2>한글 (.hwp, .hwpx) 미리보기 방식 비교</h2>
</div>
<div class="alert-box">
<i class="fa-solid fa-triangle-exclamation"></i>
<div>
<strong>현재 한글 미리보기 적용 형태:</strong> 현재 PM 시스템에는 <strong>hwp.js 기반의 직접 렌더링 방식</strong>이 프론트엔드에 기본 탑재되어 있으며, 오피스 파일 공통으로 <strong>"PDF로 보기"</strong> 버튼을 제공하여 백엔드의 LibreOffice 엔진으로 변환하여 정밀하게 볼 수도 있도록 이중 구성(Hybrid)되어 있습니다.
</div>
</div>
<br>
<div class="table-responsive">
<table>
<thead>
<tr>
<th>구현 방법</th>
<th>설명</th>
<th>장점 (Pros)</th>
<th>단점 (Cons)</th>
<th>라이선스 / 비용</th>
</tr>
</thead>
<tbody>
<tr>
<td class="col-method">
hwp.js<br>(오픈소스 파서)
<div class="badge-current"><i class="fa-solid fa-check"></i> 현재 기본 적용됨</div>
</td>
<td class="col-desc">오픈소스 HWP 바이너리 파서를 활용하여 브라우저에서 HTML5 객체화</td>
<td class="col-pros">
<ul class="bullet-list">
<li>서버 거치지 않아 즉시 로딩</li>
<li>완전한 오프라인/폐쇄망 무료 사용</li>
</ul>
</td>
<td class="col-cons">
<ul class="bullet-list">
<li>정밀한 표 테두리, 수식 개체 스타일 일부 깨짐</li>
<li>신형 규격인 <code>.hwpx</code> 파싱력 아직 불안정</li>
</ul>
</td>
<td><span class="badge badge-free">완전 무료 (MIT)</span></td>
</tr>
<tr>
<td class="col-method">
서버 LibreOffice<br>(Linux / Windows)
<div class="badge-option"><i class="fa-solid fa-gear"></i> PDF로 보기 선택적용</div>
</td>
<td class="col-desc">리눅스 등 무료 백엔드 서버에서 LibreOffice 내장 변환 필터로 PDF 변환</td>
<td class="col-pros">
<ul class="bullet-list">
<li>추가 하드웨어 및 OS 제약 없음</li>
<li>최근 규격인 <code>.hwpx</code>는 꽤 준수하게 변환</li>
</ul>
</td>
<td class="col-cons">
<ul class="bullet-list">
<li>구형 <code>.hwp</code>의 경우 폰트/표 틀어짐 가능성 있음</li>
<li>서버에 나눔 폰트 등 전용 한글 폰트 사전 설치 필수</li>
</ul>
</td>
<td><span class="badge badge-free">완전 무료 (LGPLv3)</span></td>
</tr>
<tr>
<td class="col-method">한컴 공식<br>클라우드 뷰어 API</td>
<td class="col-desc">한글과컴퓨터 공식 API 서버를 거쳐 문서 미리보기를 HTML로 획득</td>
<td class="col-pros">
<ul class="bullet-list">
<li>한글 문서 원본과 100% 일치하는 퀄리티</li>
</ul>
</td>
<td class="col-cons">
<ul class="bullet-list">
<li>고가의 연간 이용요금 발생</li>
<li>외부 클라우드 통신 및 계약 절차 번거로움</li>
</ul>
</td>
<td><span class="badge badge-warning">유료 (계약 및 과금)</span></td>
</tr>
<tr>
<td class="col-method">서버 한글 프로그램<br>(Windows 서버)</td>
<td class="col-desc">Windows 서버 환경에 한글 패키지 설치 후 백그라운드 CLI로 PDF 인쇄</td>
<td class="col-pros">
<ul class="bullet-list">
<li>안정적인 고품질 한글 PDF 변환 가능</li>
</ul>
</td>
<td class="col-cons">
<ul class="bullet-list">
<li>서버가 Windows 환경으로 강제 제한됨</li>
<li>상업용 한글 오피스 구매 라이선스 비용</li>
</ul>
</td>
<td><span class="badge badge-paid">Windows 라이선스 / 한컴 비용</span></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- 5. CAD 탭 -->
<div id="cad" class="tab-panel">
<div class="card">
<div class="card-header">
<i class="fa-solid fa-drafting-compass"></i>
<h2>CAD (.dwg, .dxf) 미리보기 방식 비교</h2>
</div>
<div class="table-responsive">
<table>
<thead>
<tr>
<th>구현 방법</th>
<th>설명</th>
<th>장점 (Pros)</th>
<th>단점 (Cons)</th>
<th>라이선스 / 비용</th>
</tr>
</thead>
<tbody>
<tr>
<td class="col-method">
서버 이미지/PDF 변환<br>(QCAD 등)
<div class="badge-current"><i class="fa-solid fa-check"></i> 현재 기본 적용됨</div>
</td>
<td class="col-desc">백엔드 서버 내의 변환 필터를 거쳐 DWG/DXF 캐드 파일을 PDF로 강제 변환 후 브라우저에 PDF.js로 표출</td>
<td class="col-pros">
<ul class="bullet-list">
<li><strong>보안 및 오프라인 지원:</strong> 도면의 외부 반출 원천 차단</li>
<li>SHX 한글 캐드 폰트 세팅 시 치수 및 글자 깨짐 없음</li>
</ul>
</td>
<td class="col-cons">
<ul class="bullet-list">
<li>도면 렌더링에 따른 백엔드 서버 부하</li>
<li>도면 레이어 제어 불가능 (고정된 평면 PDF 형태)</li>
</ul>
</td>
<td><span class="badge badge-free">무료 (단, 기업 라이선스 체크)</span></td>
</tr>
<tr>
<td class="col-method">dxf-parser +<br>Three.js / Canvas</td>
<td class="col-desc">DXF 아스키 텍스트 데이터를 분석해 브라우저 3D/2D Canvas로 직접 드로잉</td>
<td class="col-pros">
<ul class="bullet-list">
<li>인터랙티브 휠 줌/인/아웃, 팬(이동) 가능</li>
<li>도면 레이어 On/Off 제어 스위치 구현 가능</li>
</ul>
</td>
<td class="col-cons">
<ul class="bullet-list">
<li>이진 파일인 <code>.dwg</code> 직접 파싱 불가</li>
<li>대형 설계 도면 로드 시 브라우저 연산 렉 유발</li>
<li>한글/설계 폰트 유실 시 텍스트 위치 틀어짐</li>
</ul>
</td>
<td><span class="badge badge-free">완전 무료 (MIT)</span></td>
</tr>
<tr>
<td class="col-method">Autodesk Platform<br>Services (APS)</td>
<td class="col-desc">오토캐드 공식 클라우드 뷰어 API를 iframe으로 웹 포털에 삽입</td>
<td class="col-pros">
<ul class="bullet-list">
<li>설계 도면을 왜곡 없이 100% 완벽히 렌더링</li>
<li>치수 측정, 단면 추출, 3D 단면 분해 제공</li>
</ul>
</td>
<td class="col-cons">
<ul class="bullet-list">
<li>종량제 기반의 API 요금 발생</li>
<li>기밀 도면 설계 자산 유출 우려</li>
</ul>
</td>
<td><span class="badge badge-paid">유료 (사용량 종량제 과금)</span></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- 실무진 추천 전략 (Best Practice) -->
<h2 style="margin-top: 3rem; font-weight: 800; font-size: 1.6rem; letter-spacing: -0.03em;">💡 사내 시스템 개발을 위한 최적의 조합 (Best Practice)</h2>
<div class="bp-container">
<div class="bp-card">
<h3><i class="fa-solid fa-shield-halved"></i> 1순위: 자체 서버 PDF 선변환 방식</h3>
<p>
<strong>대상:</strong> Word, Excel, PPT, HWP, CAD 공통<br>
<strong>설명:</strong> LibreOffice + QCAD 변환 엔진을 사내 서버에 탑재하고, <strong>파일이 업로드되는 즉시 백그라운드에서 PDF로 변환을 완료</strong>해 저장해 둡니다. 사용자가 열람 시 이미 변환된 PDF를 PDF.js로 보여주므로 <strong>대기 시간 0초에 완벽한 사내 보안</strong>을 달성하는 실무상 가장 검증된 안전한 구성입니다.
</p>
</div>
<div class="bp-card premium">
<h3><i class="fa-solid fa-crown"></i> 2순위: 설치형 OnlyOffice 서버 도입</h3>
<p>
<strong>대상:</strong> MS Office 파일군 (Word, Excel, PPT)<br>
<strong>설명:</strong> 사내 인프라(Docker 등)에 무료 오픈소스인 <strong>OnlyOffice Document Server</strong>를 1대 개설하여 iframe으로 연동합니다. 변환 대기 시간 없이 오리지널 수준의 재현력을 자랑하며, 웹 상에서 직접 편집 및 문서 다중 협업 기능까지 추가할 수 있는 중대형 인트라넷을 위한 최고급 구성입니다.
</p>
</div>
</div>
</main>
<footer>
<p>&copy; 2026 Antigravity. Web Document Viewer Implementation Guide for Enterprise. All Rights Reserved.</p>
</footer>
<!-- 탭 전환 인터랙티브 자바스크립트 -->
<script>
function switchTab(tabId) {
// 모든 탭 버튼 비활성화
const buttons = document.querySelectorAll('.tab-btn');
buttons.forEach(btn => btn.classList.remove('active'));
// 모든 탭 패널 숨기기
const panels = document.querySelectorAll('.tab-panel');
panels.forEach(panel => panel.classList.remove('active'));
// 클릭한 탭 활성화
const clickedBtn = Array.from(buttons).find(btn => btn.getAttribute('onclick').includes(tabId));
if (clickedBtn) clickedBtn.classList.add('active');
// 클릭한 탭 패널 노출
const targetPanel = document.getElementById(tabId);
if (targetPanel) targetPanel.classList.add('active');
}
</script>
</body>
</html>

View File

@@ -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`)로 변환하여 렌더링하도록 확장 대응이 가능합니다.