한글뷰어 기능수정
This commit is contained in:
2
.env
2
.env
@@ -22,7 +22,7 @@ REDIS_PORT=6379
|
||||
REDIS_PASSWORD=
|
||||
|
||||
# 5. MinIO 로컬 S3 스토리지 설정 (Docker 연동)
|
||||
MINIO_ENDPOINT=http://localhost:9000
|
||||
MINIO_ENDPOINT=http://172.16.40.52:9000
|
||||
MINIO_ACCESSKEYID=minio_access_key
|
||||
MINIO_SECRETACCESSKEY=minio_secret_key
|
||||
|
||||
|
||||
24
README.md
24
README.md
@@ -118,12 +118,24 @@ PM_ver4/
|
||||
2. **사용자 삭제 제한**: 프로젝트 권한 매핑 테이블(`tb_permission`)에 해당 유저가 참여 중인 현장 정보가 등록되어 있을 경우 사용자 계정 삭제를 제한합니다.
|
||||
3. **공통 코드 삭제 제한**: 대분류 마스터 코드(`code_master`) 하위에 소분류 세부 코드(`code_detail`)가 단 1개라도 생성되어 있을 경우 대분류 코드 삭제를 방지합니다.
|
||||
|
||||
### 4.2 시스템 글로벌 보존 정책 및 정기 청소 스케줄러
|
||||
시스템의 디스크 용량 관리 및 보안 가이드라인에 따라 자동 파일 삭제 스케줄러가 탑재되어 작동합니다.
|
||||
* **동작 주기**: 매일 자정 배치 구동 (`node-cron` 또는 백그라운드 스케줄러 엔진)
|
||||
* **보존 규칙**: `tb_system_policy` 테이블에서 `GLOBAL_DELETE_POLICY` 설정 정보를 로드하여 활성화 여부(`is_active=true`), 보존 기한(`limit_days`), 최대 파일 개수(`limit_file_count`) 기준을 확인합니다.
|
||||
* **삭제 기법**: 삭제 기준을 충족하는 아카이브 임시 파일들을 MinIO/R2 스토리지에서 제거하고 DB 내 파일 메타데이터 상태를 업데이트합니다.
|
||||
* **기록 적재**: 작업의 수행 일시, 삭제 경로, 적용된 정책 기준 및 성공 여부를 `tb_auto_clean_log` 테이블에 `SYSTEM` 작업자 식별자로 기록하여 감사 이력을 보존합니다.
|
||||
### 4.2 시스템 글로벌 보존 정책 및 자동 삭제 실행 방식
|
||||
시스템의 디스크 용량 관리 및 보안 가이드라인에 따라 자동 파일 삭제 정책이 탑재되어 작동합니다.
|
||||
|
||||
#### 1) 적용 기준 (Criteria)
|
||||
* `tb_system_policy` 테이블의 `GLOBAL_DELETE_POLICY` 설정(활성화 여부 `is_active`, 보존 기한 `limit_days`, 기준 파일 개수 `limit_file_count`)을 기준으로 작동합니다.
|
||||
* **대상**: 아카이브의 **3단계 폴더** (`is_folder = true` 및 `data_depth = 3`) 중 아래 두 조건을 동시에 만족하는 폴더입니다.
|
||||
- 해당 폴더 하위(4단계 이하)의 유효한 파일 개수(삭제되지 않은 파일)가 `limit_file_count` 미만인 경우.
|
||||
- 폴더의 최종 변경 활동 시각(`last_folder_act_date`)으로부터 `limit_days` 일이 경과한 경우.
|
||||
|
||||
#### 2) 실행 시점 및 작동 방식 (Execution & Deletion Timing)
|
||||
정책이 활성화(`is_active=true`)된 경우, 다음 두 가지 시점에 조건에 부합하는 폴더와 하위 파일들이 자동으로 감지되어 **휴지통으로 즉시 이동(Soft-Delete: `is_removed = true`)** 처리됩니다.
|
||||
|
||||
1. **실시간 감지 (사용자 접속 시)**:
|
||||
- 사용자가 아카이브 화면(메인 트리 화면)에 진입하여 폴더 목록이 렌더링될 때, 화면단(`pageRenderer.js`)에서 만료가 경과한 3단계 폴더를 즉시 탐색합니다.
|
||||
- 탐색 성공 시 백엔드 API(`removeTarget`)를 통해 폴더 및 하위 파일들을 휴지통으로 이동시킵니다. 이 자동 처리 과정은 **참관인(Viewer)이나 작업자(Worker) 등 낮은 권한을 지닌 사용자 세션에서도 정상 수행되도록 예외 처리(Bypass)**되어 있으며, 최신 DB 설정 기준 및 만료 시간을 백엔드에서 2차 검증(Double-Check)하여 안전성을 확보합니다.
|
||||
2. **정기 배치 스케줄러 (매일 자정 00:00)**:
|
||||
- 사용자가 접속하지 않더라도, 백엔드 서버에서 실행 중인 백그라운드 배치 엔진(`scheduler.js`)이 매일 자정에 한 번씩 데이터베이스를 직접 조회하여 조건에 부합하는 대상을 자동 감지하고 휴지통으로 이동시킵니다.
|
||||
- 작업 결과(성공 여부, 대상 경로, 적용 기준 등)는 `tb_auto_clean_log` 테이블에 `SYSTEM` 작업자 식별자로 기록됩니다.
|
||||
|
||||
### 4.3 시스템 활동 로그(tb_log) 활동유형 정의
|
||||
시스템 내 모든 파일 관리, 사용자 권한 및 프로젝트 설정 관련 중요 감사 로그는 `tb_log` 테이블에 기록되며, `activity` 컬럼에 설정되는 주요 활동유형은 다음과 같습니다.
|
||||
|
||||
@@ -76,7 +76,7 @@ exports.getProjects = async (req, res) => {
|
||||
};
|
||||
|
||||
exports.createProject = async (req, res) => {
|
||||
const { project_id, project_nm, short_nm, category, limit_storage, is_active } = req.body;
|
||||
const { project_id, project_nm, short_nm, category, limit_storage, is_active, overview } = req.body;
|
||||
if (!project_id || !project_nm) {
|
||||
return res.status(400).json({ error: "프로젝트 ID와 명칭은 필수입니다." });
|
||||
}
|
||||
@@ -93,8 +93,8 @@ exports.createProject = async (req, res) => {
|
||||
const storage_byte = limit_storage ? parseInt(limit_storage) * 1024 * 1024 * 1024 : 0;
|
||||
|
||||
const query = `
|
||||
INSERT INTO ver4.${tbProject} (project_id, project_nm, short_nm, category, storage_byte, is_active, user_id, create_date)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, CURRENT_TIMESTAMP)
|
||||
INSERT INTO ver4.${tbProject} (project_id, project_nm, short_nm, category, storage_byte, is_active, user_id, overview, create_date)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, CURRENT_TIMESTAMP)
|
||||
RETURNING *;
|
||||
`;
|
||||
const result = await client.query(query, [
|
||||
@@ -104,13 +104,15 @@ exports.createProject = async (req, res) => {
|
||||
category || null,
|
||||
storage_byte,
|
||||
is_active ?? true,
|
||||
req.user?.user_id || 'admin'
|
||||
req.user?.user_id || 'admin',
|
||||
overview !== false // 기본값 true
|
||||
]);
|
||||
const userIp = req.headers['cf-connecting-ip'] || req.ip || req.headers['x-forwarded-for'] || req.connection?.remoteAddress;
|
||||
await insertAuditLog(project_id, 'createProject', req.user?.user_id, userIp, [
|
||||
`Project Name: ${project_nm}`,
|
||||
`Category: ${category}`,
|
||||
`Storage limit: ${limit_storage} GB`
|
||||
`Storage limit: ${limit_storage} GB`,
|
||||
`Overview enabled: ${overview !== false}`
|
||||
]);
|
||||
res.status(201).json(result.rows[0]);
|
||||
|
||||
@@ -124,18 +126,18 @@ exports.createProject = async (req, res) => {
|
||||
|
||||
exports.updateProject = async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { project_nm, short_nm, category, limit_storage, is_active } = req.body;
|
||||
const { project_nm, short_nm, category, limit_storage, is_active, overview } = req.body;
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
const storage_byte = limit_storage ? parseInt(limit_storage) * 1024 * 1024 * 1024 : 0;
|
||||
const query = `
|
||||
UPDATE ver4.${tbProject}
|
||||
SET project_nm = $1, short_nm = $2, category = $3, storage_byte = $4, is_active = $5
|
||||
WHERE project_id = $6
|
||||
SET project_nm = $1, short_nm = $2, category = $3, storage_byte = $4, is_active = $5, overview = $6
|
||||
WHERE project_id = $7
|
||||
RETURNING *;
|
||||
`;
|
||||
const result = await client.query(query, [project_nm, short_nm || null, category || null, storage_byte, is_active, id]);
|
||||
const result = await client.query(query, [project_nm, short_nm || null, category || null, storage_byte, is_active, overview, id]);
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: "대상을 찾을 수 없습니다." });
|
||||
}
|
||||
@@ -144,7 +146,8 @@ exports.updateProject = async (req, res) => {
|
||||
`Project Name: ${project_nm}`,
|
||||
`Category: ${category}`,
|
||||
`Storage limit: ${limit_storage} GB`,
|
||||
`Active status: ${is_active}`
|
||||
`Active status: ${is_active}`,
|
||||
`Overview enabled: ${overview}`
|
||||
]);
|
||||
res.status(200).json(result.rows[0]);
|
||||
} catch (err) {
|
||||
@@ -517,37 +520,62 @@ exports.createUser = async (req, res) => {
|
||||
|
||||
exports.updateUser = async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { user_nm, company, dept, position, group, is_resigned } = req.body;
|
||||
const { user_nm, user_pw, company, dept, position, group, is_resigned } = req.body;
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
const query = `
|
||||
UPDATE ver4.tb_user
|
||||
SET user_nm = $1, company = $2, dept = $3, position = $4, "group" = $5, is_resigned = $6
|
||||
WHERE user_id = $7
|
||||
RETURNING *;
|
||||
`;
|
||||
const result = await client.query(query, [
|
||||
user_nm,
|
||||
company || null,
|
||||
dept || null,
|
||||
position || null,
|
||||
group || null,
|
||||
is_resigned,
|
||||
id
|
||||
]);
|
||||
let result;
|
||||
if (user_pw && user_pw.trim() !== '') {
|
||||
const passwordHash = crypto.createHash('sha256').update(user_pw).digest('hex');
|
||||
const query = `
|
||||
UPDATE ver4.tb_user
|
||||
SET user_nm = $1, user_pw = $2, company = $3, dept = $4, position = $5, "group" = $6, is_resigned = $7
|
||||
WHERE user_id = $8
|
||||
RETURNING *;
|
||||
`;
|
||||
result = await client.query(query, [
|
||||
user_nm,
|
||||
passwordHash,
|
||||
company || null,
|
||||
dept || null,
|
||||
position || null,
|
||||
group || null,
|
||||
is_resigned,
|
||||
id
|
||||
]);
|
||||
} else {
|
||||
const query = `
|
||||
UPDATE ver4.tb_user
|
||||
SET user_nm = $1, company = $2, dept = $3, position = $4, "group" = $5, is_resigned = $6
|
||||
WHERE user_id = $7
|
||||
RETURNING *;
|
||||
`;
|
||||
result = await client.query(query, [
|
||||
user_nm,
|
||||
company || null,
|
||||
dept || null,
|
||||
position || null,
|
||||
group || null,
|
||||
is_resigned,
|
||||
id
|
||||
]);
|
||||
}
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: "대상을 찾을 수 없습니다." });
|
||||
}
|
||||
|
||||
const user = result.rows[0];
|
||||
const userIp = req.headers['cf-connecting-ip'] || req.ip || req.headers['x-forwarded-for'] || req.connection?.remoteAddress;
|
||||
await insertAuditLog('SYSTEM', 'updateUser', req.user?.user_id, userIp, [
|
||||
const details = [
|
||||
`Updated user_id: ${id}`,
|
||||
`User name: ${user_nm}`,
|
||||
`Group: ${group}`,
|
||||
`Is resigned: ${is_resigned}`
|
||||
]);
|
||||
];
|
||||
if (user_pw && user_pw.trim() !== '') {
|
||||
details.push("Password was updated");
|
||||
}
|
||||
await insertAuditLog('SYSTEM', 'updateUser', req.user?.user_id, userIp, details);
|
||||
user.user_pw = undefined;
|
||||
res.status(200).json(user);
|
||||
} catch (err) {
|
||||
@@ -671,6 +699,13 @@ exports.updateSystemPolicy = async (req, res) => {
|
||||
`Limit days: ${limit_days}`,
|
||||
`Is active: ${is_active}`
|
||||
]);
|
||||
try {
|
||||
const { getIo } = require('../../socket.js');
|
||||
const io = getIo();
|
||||
io.emit('updateSystemPolicy_success', result.rows[0]);
|
||||
} catch (socketErr) {
|
||||
console.error("Failed to emit updateSystemPolicy_success:", socketErr.message);
|
||||
}
|
||||
res.status(200).json(result.rows[0]);
|
||||
} catch (err) {
|
||||
console.error("updateSystemPolicy Error:", err);
|
||||
|
||||
@@ -2581,6 +2581,64 @@ exports.uploadData = async (req, res, next) => {
|
||||
let insertLogResult = await insertLog(params);
|
||||
if (insertLogResult.message == 'insertLog_success') {
|
||||
|
||||
// PPT/PPTX 파일 업로드 즉시 PDF 변환 처리
|
||||
for (let i = 0; i < insertDataResult.rows.length; i++) {
|
||||
try {
|
||||
let row = insertDataResult.rows[i];
|
||||
let resourcePath = params.resourcePathArr[i];
|
||||
let ext = (resourcePath.split('.').pop()).replace('.', '').toLowerCase();
|
||||
|
||||
if (['ppt', 'pptx'].includes(ext)) {
|
||||
let dataId = row.data_id;
|
||||
let objectKey = params.objectKeyArr[i];
|
||||
let storageType = params.storageType;
|
||||
let userInfoString = params.userInfoString;
|
||||
let userIp = req.ip;
|
||||
let bucket = projectId;
|
||||
|
||||
let command = new GetObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: objectKey,
|
||||
});
|
||||
|
||||
let url = await getSignedUrl(s3, command, { expiresIn: 60 * 60 * 6 }); // 유효시간 6시간
|
||||
|
||||
let initiator = `DEV_LOCAL_${JSON.parse(userInfoString).user_id}`;
|
||||
let type = 'archive';
|
||||
if (env == 'production') {
|
||||
if (deploymentType == 'ONPREMISE') initiator = 'HYHC_ONPREMISE';
|
||||
if (deploymentType == 'CLOUD') initiator = `AWS_CLOUD_${cloudType}`;
|
||||
}
|
||||
|
||||
const job = await convertPdfQueue.add(
|
||||
`'${initiator}'에서 문서를 PDF로 변환`,
|
||||
{ resourcePath, url, objectKey, bucket, storageType, dataId, projectId, userInfoString, userIp, initiator, type, serviceName }
|
||||
);
|
||||
|
||||
let resourcePathClean = resourcePath.startsWith('/') ? resourcePath.slice(1) : resourcePath;
|
||||
let pathArray = getPathArray(resourcePathClean);
|
||||
|
||||
convertingDataArr.push({
|
||||
dataId: dataId,
|
||||
resourcePath: resourcePath,
|
||||
depth1: pathArray[0],
|
||||
depth2: pathArray[1],
|
||||
depth3: pathArray[2],
|
||||
jobId: job.id
|
||||
});
|
||||
|
||||
// 변환 시작 소켓 전송
|
||||
let startEventData = { projectId: projectId, resourcePath: resourcePath, convertingDataArr: convertingDataArr };
|
||||
let io = getIo();
|
||||
io.emit('convert_start', startEventData);
|
||||
|
||||
console.log(`[PPT Auto-Convert] queued job ${job.id} for dataId ${dataId} (${resourcePath})`);
|
||||
}
|
||||
} catch (autoErr) {
|
||||
console.error(`[PPT Auto-Convert Failed] index ${i}:`, autoErr);
|
||||
}
|
||||
}
|
||||
|
||||
let resultData = {
|
||||
message: 'uploadData_success',
|
||||
projectId: projectId,
|
||||
@@ -2881,8 +2939,81 @@ exports.removeTarget = async(req, res) => {
|
||||
let permission = JSON.parse(params.userInfoString).permission;
|
||||
let depth = getDepth(params.resourcePathArr[0]);
|
||||
let isRecycleBinModal = params.isRecycleBinModal;
|
||||
const isExpiredFolder = params.isExpiredFolder === true;
|
||||
|
||||
if (!isRecycleBinModal && (depth == 1 && permission < 191) || (depth >= 2 && permission < 7)) {
|
||||
if (isExpiredFolder) {
|
||||
// 자동 기한 만료 삭제의 경우 안전 검증
|
||||
if (depth !== 3 || params.dataType !== 'folder') {
|
||||
return res.status(400).json({
|
||||
message: 'removeTarget_failed',
|
||||
error: '보존 정책 자동 삭제는 3단계 폴더만 대상이 될 수 있습니다.'
|
||||
});
|
||||
}
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
// 1. 최신 시스템 정책 조회
|
||||
const policyRes = await client.query("SELECT * FROM ver4.tb_system_policy WHERE policy_key = 'GLOBAL_DELETE_POLICY'");
|
||||
if (policyRes.rows.length === 0 || !policyRes.rows[0].is_active) {
|
||||
return res.status(400).json({
|
||||
message: 'removeTarget_failed',
|
||||
error: '자동 삭제 정책이 비활성화 상태입니다.'
|
||||
});
|
||||
}
|
||||
const { limit_file_count, limit_days } = policyRes.rows[0];
|
||||
|
||||
// 2. 해당 폴더의 실제 정보 조회 (최종 활동 시각 확인)
|
||||
const resourcePath = params.resourcePathArr[0];
|
||||
const projectId = req.baseUrl.split('/')[1];
|
||||
const folderQuery = `
|
||||
SELECT last_folder_act_date
|
||||
FROM ver4.${tbData}
|
||||
WHERE project_id = $1 AND is_folder = true AND data_depth = 3 AND is_removed = false
|
||||
AND path1 = $2 AND path2 = $3 AND path3 = $4;
|
||||
`;
|
||||
const folderRes = await client.query(folderQuery, [
|
||||
projectId,
|
||||
getPathSegment(resourcePath, 1),
|
||||
getPathSegment(resourcePath, 2),
|
||||
getPathSegment(resourcePath, 3)
|
||||
]);
|
||||
|
||||
if (folderRes.rows.length === 0) {
|
||||
return res.status(404).json({
|
||||
message: 'removeTarget_failed',
|
||||
error: '해당 폴더를 찾을 수 없거나 이미 삭제되었습니다.'
|
||||
});
|
||||
}
|
||||
|
||||
const lastFolderActDate = new Date(folderRes.rows[0].last_folder_act_date);
|
||||
const expiryDate = new Date(lastFolderActDate.getTime() + limit_days * 24 * 60 * 60 * 1000);
|
||||
if (expiryDate > new Date()) {
|
||||
return res.status(400).json({
|
||||
message: 'removeTarget_failed',
|
||||
error: `해당 폴더는 아직 만료 기한이 지나지 않았습니다. (남은 기한 검증 실패)`
|
||||
});
|
||||
}
|
||||
|
||||
// 3. 하위 파일 개수 계산
|
||||
const filesCount = await getFilesCount(projectId, params.storageType || 'ONPREMISE', resourcePath);
|
||||
if (Number(filesCount) >= limit_file_count) {
|
||||
return res.status(400).json({
|
||||
message: 'removeTarget_failed',
|
||||
error: `해당 폴더의 파일 개수가 기준(${limit_file_count}개) 이상입니다. (파일 개수 검증 실패)`
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("isExpiredFolder verification error:", err);
|
||||
return res.status(500).json({
|
||||
message: 'removeTarget_failed',
|
||||
error: '만료 폴더 검증 처리 중 오류가 발생했습니다.'
|
||||
});
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
if (!isExpiredFolder && !isRecycleBinModal && ((depth == 1 && permission < 191) || (depth >= 2 && permission < 7))) {
|
||||
return res.status(200).json({
|
||||
message: 'removeTarget_failed_permission',
|
||||
});
|
||||
@@ -3221,7 +3352,7 @@ exports.addConvetPdfLog = async(req, res) => {
|
||||
exports.removeConvertingData = async(req, res) => {
|
||||
const projectId = req.baseUrl.split('/')[1];
|
||||
let { params } = req.body;
|
||||
let { resourcePath, dataId, userInfoString } = params;
|
||||
let { resourcePath, dataId, userInfoString, stdout } = params;
|
||||
|
||||
//// 배열에서 파일 정보 삭제
|
||||
convertingDataArr = convertingDataArr.filter(data => data.dataId !== dataId);
|
||||
@@ -3238,7 +3369,8 @@ exports.removeConvertingData = async(req, res) => {
|
||||
convertingDataArr: convertingDataArr,
|
||||
resourcePath: resourcePath,
|
||||
dataId: dataId,
|
||||
userInfoString: userInfoString
|
||||
userInfoString: userInfoString,
|
||||
stdout: stdout || ''
|
||||
};
|
||||
|
||||
let io = getIo();
|
||||
@@ -4363,6 +4495,7 @@ async function updateThumbnailInfoAction(projectId, params) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 삭제예정(2025.10.31): 이호성
|
||||
// exports.get3dViewerThumbUrl = async(req, res, next) => {
|
||||
// const dataId = req.query.dataId;
|
||||
|
||||
BIN
libs/hwp.js
BIN
libs/hwp.js
Binary file not shown.
@@ -1,12 +1,4 @@
|
||||
const pool = require("../db/pool.js");
|
||||
const { DeleteObjectCommand } = require("@aws-sdk/client-s3");
|
||||
const onPremiseClient = require('../config/onPremiseClient.js');
|
||||
const cloudClient = require('../config/cloudClient.js');
|
||||
const storageClients = {
|
||||
'ONPREMISE': onPremiseClient,
|
||||
'CLOUD': cloudClient
|
||||
};
|
||||
const s3 = storageClients[process.env.DEPLOYMENT_TYPE || 'ONPREMISE'];
|
||||
const env = process.env.NODE_ENV;
|
||||
const tbData = env === 'production' ? 'tb_data' : '_test_tb_data';
|
||||
|
||||
@@ -24,67 +16,67 @@ async function runAutoClean() {
|
||||
|
||||
const { limit_file_count, limit_days } = policyRes.rows[0];
|
||||
|
||||
// 2. 삭제 대상 파일 검색 (각 현장의 파일 개수가 limit_file_count 미만이고, limit_days 일이 지난 파일)
|
||||
// 2. 삭제 대상 3단계 폴더 검색
|
||||
// - is_folder = true 이고 depth = 3 이며 아직 삭제되지 않은 폴더
|
||||
// - 하위의 유효 파일 개수가 limit_file_count 미만
|
||||
// - last_folder_act_date 가 limit_days 일 이상 경과
|
||||
const targetQuery = `
|
||||
SELECT data_id, project_id, object_key, preview_key, popup_key, thumbnail_key,
|
||||
path1, path2, path3, path4, path5, path6, path7, path8, data_depth, is_folder
|
||||
FROM ver4.${tbData}
|
||||
WHERE is_folder = false AND is_removed = false
|
||||
AND project_id IN (
|
||||
SELECT project_id
|
||||
FROM ver4.${tbData}
|
||||
WHERE is_folder = false AND is_removed = false
|
||||
GROUP BY project_id
|
||||
HAVING COUNT(*) < $1
|
||||
)
|
||||
AND create_date < NOW() - CAST($2 || ' days' AS INTERVAL);
|
||||
SELECT data_id, project_id, path1, path2, path3, last_folder_act_date
|
||||
FROM ver4.${tbData} f
|
||||
WHERE f.is_folder = true
|
||||
AND f.data_depth = 3
|
||||
AND f.is_removed = false
|
||||
AND f.last_folder_act_date < NOW() - CAST($1 || ' days' AS INTERVAL)
|
||||
AND (
|
||||
SELECT COUNT(*)
|
||||
FROM ver4.${tbData} files
|
||||
WHERE files.project_id = f.project_id
|
||||
AND files.path1 = f.path1
|
||||
AND files.path2 = f.path2
|
||||
AND files.path3 = f.path3
|
||||
AND files.is_folder = false
|
||||
AND files.is_removed = false
|
||||
) < $2;
|
||||
`;
|
||||
const targets = await client.query(targetQuery, [limit_file_count, limit_days]);
|
||||
const targets = await client.query(targetQuery, [limit_days, limit_file_count]);
|
||||
|
||||
console.log(`⏰ Found ${targets.rows.length} files to clean up.`);
|
||||
console.log(`⏰ Found ${targets.rows.length} folders to clean up.`);
|
||||
|
||||
for (const file of targets.rows) {
|
||||
for (const folder of targets.rows) {
|
||||
let success = true;
|
||||
let keysToDelete = [];
|
||||
if (file.object_key) keysToDelete.push(file.object_key);
|
||||
if (file.preview_key) keysToDelete.push(file.preview_key);
|
||||
if (file.popup_key) keysToDelete.push(file.popup_key);
|
||||
if (file.thumbnail_key) keysToDelete.push(file.thumbnail_key);
|
||||
|
||||
// S3 실물 파일 삭제
|
||||
for (const key of keysToDelete) {
|
||||
try {
|
||||
const command = new DeleteObjectCommand({
|
||||
Bucket: file.project_id, // 현장 ID를 버킷 명으로 사용함
|
||||
Key: key
|
||||
});
|
||||
await s3.send(command);
|
||||
} catch (s3Err) {
|
||||
console.error(`❌ S3 Delete Error [Key: ${key}]:`, s3Err.message);
|
||||
success = false;
|
||||
}
|
||||
}
|
||||
|
||||
// DB 메타 정보 완전 제거
|
||||
try {
|
||||
await client.query(`DELETE FROM ver4.${tbData} WHERE data_id = $1`, [file.data_id]);
|
||||
} catch (dbErr) {
|
||||
console.error(`❌ DB Delete Error [DataID: ${file.data_id}]:`, dbErr.message);
|
||||
await client.query('BEGIN');
|
||||
|
||||
// 폴더 자체 및 그 하위 파일/폴더들을 전부 Soft-Delete (is_removed = true)
|
||||
const updateQuery = `
|
||||
UPDATE ver4.${tbData}
|
||||
SET is_removed = true,
|
||||
mod_date = CURRENT_TIMESTAMP,
|
||||
mod_user_id = 'SYSTEM'
|
||||
WHERE project_id = $1
|
||||
AND path1 = $2
|
||||
AND path2 = $3
|
||||
AND path3 = $4
|
||||
AND is_removed = false;
|
||||
`;
|
||||
await client.query(updateQuery, [folder.project_id, folder.path1, folder.path2, folder.path3]);
|
||||
|
||||
await client.query('COMMIT');
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error(`❌ Auto Clean Folder Error [Folder ID: ${folder.data_id}]:`, err.message);
|
||||
success = false;
|
||||
}
|
||||
|
||||
// 삭제 경로 조합
|
||||
let cleanPath = '';
|
||||
for (let i = 1; i <= file.data_depth; i++) {
|
||||
if (file[`path${i}`]) cleanPath += '/' + file[`path${i}`];
|
||||
}
|
||||
|
||||
// 로그 기록
|
||||
// 삭제 경로 조합 (/path1/path2/path3)
|
||||
const cleanPath = `/${folder.path1}/${folder.path2}/${folder.path3}`;
|
||||
const criteria = `보관수량 ${limit_file_count}개 미만 / 기한 ${limit_days}일 경과`;
|
||||
|
||||
// 로그 기록
|
||||
await client.query(`
|
||||
INSERT INTO ver4.tb_auto_clean_log (clean_date, project_id, clean_path, criteria_info, result_status)
|
||||
VALUES (CURRENT_TIMESTAMP, 'SYSTEM', $1, $2, $3);
|
||||
`, [cleanPath, criteria, success ? 'SUCCESS' : 'FAILED']);
|
||||
VALUES (CURRENT_TIMESTAMP, $1, $2, $3, $4);
|
||||
`, [folder.project_id, cleanPath, criteria, success ? 'SUCCESS' : 'FAILED']);
|
||||
}
|
||||
console.log("⏰ Auto clean batch job finished successfully.");
|
||||
} catch (err) {
|
||||
|
||||
@@ -87,83 +87,107 @@ const worker = new Worker('convert-pdf', async (job) => {
|
||||
const tempOutputPath = tempInputPath.replace(`.${ext}`, '.pdf');
|
||||
console.log(`[Job ${job.id}] ⚙️ CLI 변환 실행: ${path.basename(exePath)}`);
|
||||
|
||||
// 5. CLI 실행을 통해 PDF 변환 수행
|
||||
await new Promise((resolve, reject) => {
|
||||
execFile(exePath, [tempInputPath, tempOutputPath], (err, stdout, stderr) => {
|
||||
if (err) {
|
||||
console.error(`[Job ${job.id}] ❌ 변환 CLI 실행 에러:`, err.message);
|
||||
return reject(err);
|
||||
}
|
||||
console.log(`[Job ${job.id}] 📝 변환기 출력:`, stdout.trim());
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
let previewKey = '';
|
||||
let popupKey = '';
|
||||
|
||||
if (!fs.existsSync(tempOutputPath)) {
|
||||
throw new Error(`변환 성공했으나 결과 PDF 파일이 존재하지 않습니다: ${tempOutputPath}`);
|
||||
}
|
||||
|
||||
// 6. 생성된 PDF를 MinIO 스토리지에 업로드
|
||||
console.log(`[Job ${job.id}] 📤 변환된 PDF MinIO 스토리지 업로드 중...`);
|
||||
const pdfBuffer = fs.readFileSync(tempOutputPath);
|
||||
|
||||
// preview_key, popup_key 네이밍 규칙 적용
|
||||
let previewKey = objectKey.replace('archive/origin/', 'archive/preview/').replace(/\.[^.]+$/, '.pdf');
|
||||
let popupKey = objectKey.replace('archive/origin/', 'archive/popup/').replace(/\.[^.]+$/, '.pdf');
|
||||
|
||||
if (previewKey === objectKey) {
|
||||
previewKey = `archive/preview/${path.basename(objectKey)}`.replace(/\.[^.]+$/, '.pdf');
|
||||
}
|
||||
if (popupKey === objectKey) {
|
||||
popupKey = `archive/popup/${path.basename(objectKey)}`.replace(/\.[^.]+$/, '.pdf');
|
||||
}
|
||||
|
||||
// preview_key 업로드
|
||||
await s3.send(new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: previewKey,
|
||||
Body: pdfBuffer,
|
||||
ContentType: 'application/pdf'
|
||||
}));
|
||||
|
||||
// popup_key 업로드
|
||||
await s3.send(new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: popupKey,
|
||||
Body: pdfBuffer,
|
||||
ContentType: 'application/pdf'
|
||||
}));
|
||||
console.log(`[Job ${job.id}] 🚀 업로드 성공: \n - Preview Key: ${previewKey}\n - Popup Key: ${popupKey}`);
|
||||
|
||||
// 7. 데이터베이스 레코드 정보 업데이트 (preview_key, popup_key 반영)
|
||||
console.log(`[Job ${job.id}] 🗄️ 데이터베이스 정보 갱신 중...`);
|
||||
const envSetting = process.env.NODE_ENV || 'development';
|
||||
const tbData = envSetting === 'production' ? 'tb_data' : '_test_tb_data';
|
||||
|
||||
if (type === 'archive') {
|
||||
const updateQuery = `
|
||||
UPDATE ver4.${tbData}
|
||||
SET preview_key = $1, popup_key = $2, mod_date = NOW(), mod_activity = 'convertPdf'
|
||||
WHERE data_id = $3
|
||||
`;
|
||||
await pool.query(updateQuery, [previewKey, popupKey, dataId]);
|
||||
} else if (type === 'officialDoc') {
|
||||
const updateQuery = `
|
||||
UPDATE ver4.tb_official_doc_file
|
||||
SET preview_key = $1, popup_key = $2, mod_date = NOW(), mod_activity = 'convertDocPdf'
|
||||
WHERE doc_id = $3
|
||||
`;
|
||||
await pool.query(updateQuery, [previewKey, popupKey, dataId]);
|
||||
}
|
||||
console.log(`[Job ${job.id}] ✅ 데이터베이스 업데이트 완료.`);
|
||||
|
||||
// 8. 로컬 임시 파일 삭제
|
||||
try {
|
||||
fs.unlinkSync(tempInputPath);
|
||||
fs.unlinkSync(tempOutputPath);
|
||||
console.log(`[Job ${job.id}] 🧹 임시 파일 정리 완료.`);
|
||||
} catch (cleanErr) {
|
||||
console.warn(`[Job ${job.id}] ⚠️ 임시 파일 제거 실패:`, cleanErr.message);
|
||||
// 5. CLI 실행을 통해 PDF 변환 수행
|
||||
const conversionResult = await new Promise((resolve) => {
|
||||
execFile(exePath, [tempInputPath, tempOutputPath], { encoding: 'buffer' }, (err, stdoutBuffer, stderrBuffer) => {
|
||||
const decoder = new TextDecoder('euc-kr');
|
||||
const stdout = decoder.decode(stdoutBuffer || Buffer.alloc(0)).trim();
|
||||
const stderr = decoder.decode(stderrBuffer || Buffer.alloc(0)).trim();
|
||||
resolve({ err, stdout, stderr });
|
||||
});
|
||||
});
|
||||
|
||||
if (conversionResult.stdout) {
|
||||
console.log(`[Job ${job.id}] 📝 변환기 출력:\n${conversionResult.stdout}`);
|
||||
}
|
||||
if (conversionResult.stderr) {
|
||||
console.error(`[Job ${job.id}] 📝 변환기 에러 출력:\n${conversionResult.stderr}`);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(tempOutputPath)) {
|
||||
let errMsg = `PDF 변환 파일 생성에 실패했습니다. (결과 파일이 존재하지 않습니다.)`;
|
||||
if (conversionResult.stdout) {
|
||||
errMsg += `\n[변환기 출력]: ${conversionResult.stdout}`;
|
||||
}
|
||||
if (conversionResult.stderr) {
|
||||
errMsg += `\n[변환기 에러]: ${conversionResult.stderr}`;
|
||||
}
|
||||
if (conversionResult.err) {
|
||||
errMsg += `\n[실행 에러]: ${conversionResult.err.message}`;
|
||||
}
|
||||
throw new Error(errMsg);
|
||||
}
|
||||
|
||||
// 6. 생성된 PDF를 MinIO 스토리지에 업로드
|
||||
console.log(`[Job ${job.id}] 📤 변환된 PDF MinIO 스토리지 업로드 중...`);
|
||||
const pdfBuffer = fs.readFileSync(tempOutputPath);
|
||||
|
||||
// preview_key, popup_key 네이밍 규칙 적용
|
||||
previewKey = objectKey.replace('archive/origin/', 'archive/preview/').replace(/\.[^.]+$/, '.pdf');
|
||||
popupKey = objectKey.replace('archive/origin/', 'archive/popup/').replace(/\.[^.]+$/, '.pdf');
|
||||
|
||||
if (previewKey === objectKey) {
|
||||
previewKey = `archive/preview/${path.basename(objectKey)}`.replace(/\.[^.]+$/, '.pdf');
|
||||
}
|
||||
if (popupKey === objectKey) {
|
||||
popupKey = `archive/popup/${path.basename(objectKey)}`.replace(/\.[^.]+$/, '.pdf');
|
||||
}
|
||||
|
||||
// preview_key 업로드
|
||||
await s3.send(new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: previewKey,
|
||||
Body: pdfBuffer,
|
||||
ContentType: 'application/pdf'
|
||||
}));
|
||||
|
||||
// popup_key 업로드
|
||||
await s3.send(new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: popupKey,
|
||||
Body: pdfBuffer,
|
||||
ContentType: 'application/pdf'
|
||||
}));
|
||||
console.log(`[Job ${job.id}] 🚀 업로드 성공: \n - Preview Key: ${previewKey}\n - Popup Key: ${popupKey}`);
|
||||
|
||||
// 7. 데이터베이스 레코드 정보 업데이트 (preview_key, popup_key 반영)
|
||||
console.log(`[Job ${job.id}] 🗄️ 데이터베이스 정보 갱신 중...`);
|
||||
const envSetting = process.env.NODE_ENV || 'development';
|
||||
const tbData = envSetting === 'production' ? 'tb_data' : '_test_tb_data';
|
||||
|
||||
if (type === 'archive') {
|
||||
const updateQuery = `
|
||||
UPDATE ver4.${tbData}
|
||||
SET preview_key = $1, popup_key = $2, mod_date = NOW(), mod_activity = 'convertPdf'
|
||||
WHERE data_id = $3
|
||||
`;
|
||||
await pool.query(updateQuery, [previewKey, popupKey, dataId]);
|
||||
} else if (type === 'officialDoc') {
|
||||
const updateQuery = `
|
||||
UPDATE ver4.tb_official_doc_file
|
||||
SET preview_key = $1, popup_key = $2, mod_date = NOW(), mod_activity = 'convertDocPdf'
|
||||
WHERE doc_id = $3
|
||||
`;
|
||||
await pool.query(updateQuery, [previewKey, popupKey, dataId]);
|
||||
}
|
||||
console.log(`[Job ${job.id}] ✅ 데이터베이스 업데이트 완료.`);
|
||||
} finally {
|
||||
// 8. 로컬 임시 파일 삭제
|
||||
try {
|
||||
if (fs.existsSync(tempInputPath)) {
|
||||
fs.unlinkSync(tempInputPath);
|
||||
}
|
||||
if (fs.existsSync(tempOutputPath)) {
|
||||
fs.unlinkSync(tempOutputPath);
|
||||
}
|
||||
console.log(`[Job ${job.id}] 🧹 임시 파일 정리 완료.`);
|
||||
} catch (cleanErr) {
|
||||
console.warn(`[Job ${job.id}] ⚠️ 임시 파일 제거 실패:`, cleanErr.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 완료 후 completed 이벤트 리스너로 데이터 리턴
|
||||
|
||||
@@ -24,6 +24,21 @@
|
||||
"date": 1781483235835,
|
||||
"name": "D:\\40. 개발소스\\04. PM\\pm_ver4\\trunk\\PM_ver4\\logs\\2026-06-15.exception.log",
|
||||
"hash": "11b2e04cffe03e4f31cf1e53fa6682fb6c1a12e4fb5cf7799e90729ad81b1d0b"
|
||||
},
|
||||
{
|
||||
"date": 1781567098727,
|
||||
"name": "D:\\40. 개발소스\\04. PM\\pm_ver4\\trunk\\PM_ver4\\logs\\2026-06-16.exception.log",
|
||||
"hash": "2b25cb03566dbfe3a7b1f0c371b247298168bfbe6fd7e2ba7cfdb00d612dc0e5"
|
||||
},
|
||||
{
|
||||
"date": 1781653576334,
|
||||
"name": "D:\\40. 개발소스\\04. PM\\pm_ver4\\trunk\\PM_ver4\\logs\\2026-06-17.exception.log",
|
||||
"hash": "5823ed078b7d2a17c33873eda5850d62d9335a37ba0ee9c34953f7040ab1be23"
|
||||
},
|
||||
{
|
||||
"date": 1781740205874,
|
||||
"name": "D:\\40. 개발소스\\04. PM\\pm_ver4\\trunk\\PM_ver4\\logs\\2026-06-18.exception.log",
|
||||
"hash": "b6c375bb37b791ea2408e99580d68d5582d2e051ea204bc0529f30d0d3452da6"
|
||||
}
|
||||
],
|
||||
"hashType": "sha256"
|
||||
|
||||
@@ -24,6 +24,21 @@
|
||||
"date": 1781483235833,
|
||||
"name": "D:\\40. 개발소스\\04. PM\\pm_ver4\\trunk\\PM_ver4\\logs\\2026-06-15.error.log",
|
||||
"hash": "1030626feeefe2d3b4a2d169ecc3774fb59d61b8573c967313dec48f1ab60bc5"
|
||||
},
|
||||
{
|
||||
"date": 1781567098714,
|
||||
"name": "D:\\40. 개발소스\\04. PM\\pm_ver4\\trunk\\PM_ver4\\logs\\2026-06-16.error.log",
|
||||
"hash": "4dec9fbb6e0a02431e5c32274ce79d99023312551743ec4cd89aaca6f1d7fdd6"
|
||||
},
|
||||
{
|
||||
"date": 1781653576319,
|
||||
"name": "D:\\40. 개발소스\\04. PM\\pm_ver4\\trunk\\PM_ver4\\logs\\2026-06-17.error.log",
|
||||
"hash": "aea47f4d2a27282d86165d971ee833124401fca007fcdafb616145f9c334dc3c"
|
||||
},
|
||||
{
|
||||
"date": 1781740205863,
|
||||
"name": "D:\\40. 개발소스\\04. PM\\pm_ver4\\trunk\\PM_ver4\\logs\\2026-06-18.error.log",
|
||||
"hash": "c3ea07a16e0988461aee9271b7f5b0faabeadbf8df4f36e428e1e43c313c7738"
|
||||
}
|
||||
],
|
||||
"hashType": "sha256"
|
||||
|
||||
9
logs/2026-06-16.error.log
Normal file
9
logs/2026-06-16.error.log
Normal 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
|
||||
0
logs/2026-06-16.exception.log
Normal file
0
logs/2026-06-16.exception.log
Normal file
0
logs/2026-06-17.error.log
Normal file
0
logs/2026-06-17.error.log
Normal file
0
logs/2026-06-17.exception.log
Normal file
0
logs/2026-06-17.exception.log
Normal file
0
logs/2026-06-18.error.log
Normal file
0
logs/2026-06-18.error.log
Normal file
0
logs/2026-06-18.exception.log
Normal file
0
logs/2026-06-18.exception.log
Normal file
2
queue.js
2
queue.js
@@ -61,7 +61,7 @@ convertPdfQueueEvents.on('failed', async ({ jobId, failedReason }) => {
|
||||
console.log('');
|
||||
|
||||
// let resultData = (job.progress == 0) ? job.data : job.progress;
|
||||
let resultData = { jobData: job.data, jobProgress: job.progress };
|
||||
let resultData = { jobData: job.data, jobProgress: job.progress, failedReason: failedReason };
|
||||
|
||||
let io = getIo();
|
||||
io.emit('convertPdf_failed', resultData);
|
||||
|
||||
25
scratch_check_policy.js
Normal file
25
scratch_check_policy.js
Normal 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();
|
||||
43
server.js
43
server.js
@@ -1,6 +1,49 @@
|
||||
// restarted for .env change
|
||||
const app = require('./app');
|
||||
const http = require('http');
|
||||
const socket = require('./socket');
|
||||
const net = require('net');
|
||||
|
||||
// MinIO TCP Port Proxy Helper (WSL/Docker Desktop loopback bridge)
|
||||
function startMinioProxy() {
|
||||
const ports = [9000, 9001];
|
||||
ports.forEach(port => {
|
||||
const server = net.createServer((clientSocket) => {
|
||||
const targetSocket = net.connect(port, '127.0.0.1', () => {
|
||||
clientSocket.pipe(targetSocket);
|
||||
targetSocket.pipe(clientSocket);
|
||||
});
|
||||
|
||||
clientSocket.on('error', (err) => {
|
||||
targetSocket.destroy();
|
||||
});
|
||||
targetSocket.on('error', (err) => {
|
||||
clientSocket.destroy();
|
||||
});
|
||||
clientSocket.on('close', () => {
|
||||
targetSocket.destroy();
|
||||
});
|
||||
targetSocket.on('close', () => {
|
||||
clientSocket.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
server.on('error', (err) => {
|
||||
if (err.code === 'EADDRINUSE') {
|
||||
console.log(`[MinIO Proxy] Port ${port} is already in use. Skipping proxy initialization.`);
|
||||
} else {
|
||||
console.error(`[MinIO Proxy] Port ${port} error:`, err);
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(port, '0.0.0.0', () => {
|
||||
console.log(`>> [MinIO Proxy] Listening on 0.0.0.0:${port} -> 127.0.0.1:${port}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Start MinIO Proxy
|
||||
startMinioProxy();
|
||||
|
||||
const server = http.createServer(app);
|
||||
socket.init(server); // 웹소켓 초기화
|
||||
|
||||
BIN
temp_convert/input_15_1781576604096.docx
Normal file
BIN
temp_convert/input_15_1781576604096.docx
Normal file
Binary file not shown.
BIN
temp_convert/input_19_1781576941674.docx
Normal file
BIN
temp_convert/input_19_1781576941674.docx
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
temp_convert/input_8_1781570010369.xlsx
Normal file
BIN
temp_convert/input_8_1781570010369.xlsx
Normal file
Binary file not shown.
BIN
temp_convert/input_8_1781570010369_test.pdf
Normal file
BIN
temp_convert/input_8_1781570010369_test.pdf
Normal file
Binary file not shown.
BIN
temp_convert/test_pptx.pdf
Normal file
BIN
temp_convert/test_pptx.pdf
Normal file
Binary file not shown.
@@ -760,6 +760,7 @@
|
||||
<th>카테고리</th>
|
||||
<th>용량 제한</th>
|
||||
<th>상태</th>
|
||||
<th style="width: 70px;">과업개요</th>
|
||||
<th>관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -1004,10 +1005,10 @@
|
||||
<h3 class="card-title">🔎 시스템 활동 로그 조회 (tb_log)</h3>
|
||||
</div>
|
||||
<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-project" placeholder="프로젝트명 검색...">
|
||||
<input type="text" class="text-input" id="filter-log-action" placeholder="조작 액션 검색...">
|
||||
<button class="btn btn-secondary" onclick="renderAuditLogs()">활동 로그 필터링</button>
|
||||
<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="프로젝트명 검색..." onkeyup="if(event.key === 'Enter') renderAuditLogs()">
|
||||
<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>
|
||||
</div>
|
||||
<div class="table-wrapper">
|
||||
<table class="admin-table">
|
||||
@@ -1194,11 +1195,11 @@
|
||||
<option value="overseas">해외 프로젝트 (overseas)</option>
|
||||
</select>
|
||||
</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-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">
|
||||
<label for="form-project-active">운영 상태</label>
|
||||
<select class="select-input" id="form-project-active">
|
||||
@@ -1206,6 +1207,13 @@
|
||||
<option value="false">일시잠금 (Inactive)</option>
|
||||
</select>
|
||||
</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 style="display: flex; gap: 10px; justify-content: flex-end; margin-top: 10px;">
|
||||
<button class="btn btn-secondary" type="button" onclick="closeProjectModal()">취소</button>
|
||||
@@ -1616,6 +1624,7 @@
|
||||
<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><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>
|
||||
<div class="action-btns" onclick="event.stopPropagation();">
|
||||
<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').disabled = false;
|
||||
document.getElementById('project-submit-btn').innerText = '등록 하기';
|
||||
document.getElementById('form-project-overview').value = 'true';
|
||||
form.onsubmit = submitCreateProject;
|
||||
} else {
|
||||
document.getElementById('project-modal-title').innerText = '📝 프로젝트 상세 정보 수정';
|
||||
@@ -1848,6 +1858,7 @@
|
||||
document.getElementById('form-project-category').value = p.category || '';
|
||||
document.getElementById('form-project-storage').value = p.storage_byte ? (Number(p.storage_byte) / (1024*1024*1024)).toFixed(0) : 10;
|
||||
document.getElementById('form-project-active').value = p.is_active ? 'true' : 'false';
|
||||
document.getElementById('form-project-overview').value = p.overview !== false ? 'true' : 'false';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
@@ -1869,7 +1880,8 @@
|
||||
short_nm: document.getElementById('form-project-short').value.trim(),
|
||||
category: document.getElementById('form-project-category').value,
|
||||
limit_storage: Number(document.getElementById('form-project-storage').value),
|
||||
is_active: document.getElementById('form-project-active').value === 'true'
|
||||
is_active: document.getElementById('form-project-active').value === 'true',
|
||||
overview: document.getElementById('form-project-overview').value === 'true'
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -1893,7 +1905,8 @@
|
||||
short_nm: document.getElementById('form-project-short').value.trim(),
|
||||
category: document.getElementById('form-project-category').value,
|
||||
limit_storage: Number(document.getElementById('form-project-storage').value),
|
||||
is_active: document.getElementById('form-project-active').value === 'true'
|
||||
is_active: document.getElementById('form-project-active').value === 'true',
|
||||
overview: document.getElementById('form-project-overview').value === 'true'
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -2147,6 +2160,7 @@
|
||||
document.getElementById('form-user-id').removeAttribute('readonly');
|
||||
document.getElementById('form-user-id').disabled = false;
|
||||
document.getElementById('form-user-pw').required = true;
|
||||
document.getElementById('form-user-pw').placeholder = '••••••••';
|
||||
pwRow.style.display = 'flex'; // PW 보이기
|
||||
document.getElementById('user-submit-btn').innerText = '등록 하기';
|
||||
form.onsubmit = submitCreateUser;
|
||||
@@ -2155,9 +2169,10 @@
|
||||
document.getElementById('form-user-id').setAttribute('readonly', 'true');
|
||||
document.getElementById('form-user-id').disabled = true;
|
||||
|
||||
// 패스워드 입력칸 숨기기 및 필수조건 해제
|
||||
// 패스워드 필수조건 해제 및 노출 유지 (공백 시 미수정)
|
||||
document.getElementById('form-user-pw').required = false;
|
||||
pwRow.style.display = 'none';
|
||||
document.getElementById('form-user-pw').placeholder = '변경할 비밀번호 입력 (공백 시 유지)';
|
||||
pwRow.style.display = 'flex';
|
||||
|
||||
document.getElementById('user-submit-btn').innerText = '수정 하기';
|
||||
|
||||
@@ -2216,6 +2231,7 @@
|
||||
async function submitEditUser(event, userId) {
|
||||
event.preventDefault();
|
||||
const payload = {
|
||||
user_pw: document.getElementById('form-user-pw').value,
|
||||
user_nm: document.getElementById('form-user-nm').value.trim(),
|
||||
company: document.getElementById('form-user-company').value.trim(),
|
||||
dept: document.getElementById('form-user-dept').value.trim(),
|
||||
|
||||
@@ -370,7 +370,7 @@ export async function openNewWindowViewer() {
|
||||
case 'pdf' :
|
||||
case 'doc' :
|
||||
case 'ppt' :
|
||||
case 'pptx' :
|
||||
case 'pptx':
|
||||
case 'dwg' :
|
||||
case 'dxf' :
|
||||
case 'grm' :
|
||||
|
||||
@@ -94,6 +94,22 @@ async function loadSystemPolicy() {
|
||||
}
|
||||
}
|
||||
|
||||
export function updateSystemPolicyCache(policy) {
|
||||
if (policy) {
|
||||
FOLDER_KEEP_POLICY_ACTIVE = policy.is_active ?? false;
|
||||
if (FOLDER_KEEP_POLICY_ACTIVE) {
|
||||
FOLDER_KEEP_FILE_THRESHOLD = Number(policy.limit_file_count) || 3;
|
||||
FOLDER_KEEP_DAYS_THRESHOLD = Number(policy.limit_days) || 15;
|
||||
} else {
|
||||
FOLDER_KEEP_FILE_THRESHOLD = 3;
|
||||
FOLDER_KEEP_DAYS_THRESHOLD = 15;
|
||||
}
|
||||
} else {
|
||||
isPolicyLoaded = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 브라우저 뒤로가기, 앞으로가기 이벤트
|
||||
window.addEventListener('popstate', async (e)=>{
|
||||
// console.log(e);
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
changeHeaderBtnStyle,
|
||||
changeTreeItemStyle,
|
||||
changeListItemStyle,
|
||||
updateSystemPolicyCache,
|
||||
} from './pageRenderer.js';
|
||||
import { toggleModal } from './modalManager.js'
|
||||
import { mgmtFunc_addClickLog } from './managementFunctions.js';
|
||||
@@ -326,6 +327,27 @@ socket.on('popupNotice', (data)=>{
|
||||
alert(text);
|
||||
})
|
||||
|
||||
//// 보관 및 삭제 정책 변경 실시간 반영
|
||||
socket.on('updateSystemPolicy_success', async (policy) => {
|
||||
// 정책 캐시 갱신
|
||||
updateSystemPolicyCache(policy);
|
||||
|
||||
// 트리 화면 갱신 (D-Day 타이머 갱신을 위해)
|
||||
let userCurPath = getMyCurPath();
|
||||
if (userCurPath) {
|
||||
let pathSplit = userCurPath.split('/');
|
||||
let extractedPath = extractPathByLength(pathSplit, 1);
|
||||
let pageRanderingOption = {
|
||||
scope: 'tree',
|
||||
resourcePath: extractedPath,
|
||||
userCurPath: userCurPath,
|
||||
pushState: false,
|
||||
debug: '정책 변경 실시간 갱신 - tree'
|
||||
};
|
||||
await preparePageRendering(pageRanderingOption);
|
||||
}
|
||||
})
|
||||
|
||||
//// 강제 로그아웃
|
||||
socket.on('forcedLogout', () => {
|
||||
alert('프로젝트 재시작으로 인해 자동으로 로그아웃됩니다.\n다시 로그인 후 사용해주세요.');
|
||||
@@ -441,15 +463,16 @@ socket.on('addConvetPdfLog_success', async (resultData) => {
|
||||
socket.on('convertPdf_failed', async (resultData) => {
|
||||
console.log('-------- convertPdf_failed');
|
||||
console.log(resultData);
|
||||
let resourcePath = (resultData.jobData.resourcePath) ? resultData.jobData.resourcePath : resultData.jobProgress.resourcePath;
|
||||
let dataId = (resultData.jobData.dataId) ? resultData.jobData.dataId : resultData.jobProgress.dataId;
|
||||
let userInfoString = (resultData.jobData.userInfoString) ? resultData.jobData.userInfoString : resultData.jobProgress.userInfoString;
|
||||
// 서버의 convertingDataArr에서 변환 실패한 파일 정보 삭제
|
||||
let resourcePath = (resultData.jobData && resultData.jobData.resourcePath) ? resultData.jobData.resourcePath : (resultData.jobProgress ? resultData.jobProgress.resourcePath : '');
|
||||
let dataId = (resultData.jobData && resultData.jobData.dataId) ? resultData.jobData.dataId : (resultData.jobProgress ? resultData.jobProgress.dataId : '');
|
||||
let userInfoString = (resultData.jobData && resultData.jobData.userInfoString) ? resultData.jobData.userInfoString : (resultData.jobProgress ? resultData.jobProgress.userInfoString : '');
|
||||
let failedReason = resultData.failedReason || '';
|
||||
|
||||
let removeConvertingDataParams = {
|
||||
resourcePath: resourcePath,
|
||||
dataId: dataId,
|
||||
userInfoString: userInfoString
|
||||
userInfoString: userInfoString,
|
||||
stdout: failedReason
|
||||
}
|
||||
let removeConvertingDataResult = await axios.post(`${vars.path_name}/removeConvertingData`, { params: removeConvertingDataParams });
|
||||
console.log(removeConvertingDataResult);
|
||||
|
||||
@@ -573,7 +573,8 @@ export async function renderDocViewer(resourcePath, docId) {
|
||||
let excelDirectArr = ['xls', 'xlsx', 'xlsm'];
|
||||
let hwpDirectArr = ['hwp', 'hwpx'];
|
||||
let wordDirectArr = ['docx'];
|
||||
let isDirectView = excelDirectArr.includes(ext) || hwpDirectArr.includes(ext) || wordDirectArr.includes(ext);
|
||||
let pptxDirectArr = [];
|
||||
let isDirectView = excelDirectArr.includes(ext) || hwpDirectArr.includes(ext) || wordDirectArr.includes(ext) || pptxDirectArr.includes(ext);
|
||||
|
||||
let selectedDoc = docVars.allDocData?.find((doc) => doc.doc_id === docId);
|
||||
let previewKey = selectedDoc?.preview_key;
|
||||
@@ -617,12 +618,13 @@ export async function renderDocViewer(resourcePath, docId) {
|
||||
let threeArr = ['glb', 'gltf', 'obj', 'stl', 'fbx', '3dm'];
|
||||
let allArr = [...pdfArr, ...gsimArr, ...ifcArr, ...imageArr, ...videoArr, ...textArr, ...urlArr, ...zipArr, ...threeArr];
|
||||
if (allArr.includes(ext)) {
|
||||
let pdfArrFiltered = pdfArr.filter(e => !excelDirectArr.includes(e) && !hwpDirectArr.includes(e) && !wordDirectArr.includes(e));
|
||||
let pdfArrFiltered = pdfArr.filter(e => !excelDirectArr.includes(e) && !hwpDirectArr.includes(e) && !wordDirectArr.includes(e) && !pptxDirectArr.includes(e));
|
||||
|
||||
if (pdfArrFiltered.includes(ext)) viewerPdf(PresignedUrl);
|
||||
if (excelDirectArr.includes(ext)) viewerExcel(PresignedUrl);
|
||||
if (hwpDirectArr.includes(ext)) viewerHwp(PresignedUrl);
|
||||
if (wordDirectArr.includes(ext)) viewerWord(PresignedUrl);
|
||||
if (pptxDirectArr.includes(ext)) viewerPptx(PresignedUrl);
|
||||
if (gsimArr.includes(ext)) viewerGsim(PresignedUrl);
|
||||
if (ifcArr.includes(ext)) viewerIfc(PresignedUrl);
|
||||
if (threeArr.includes(ext)) viewer3d(PresignedUrl);
|
||||
@@ -860,18 +862,54 @@ export async function renderDocViewer(resourcePath, docId) {
|
||||
const container = document.createElement('div');
|
||||
container.style.width = '100%';
|
||||
container.style.height = '100%';
|
||||
container.style.overflow = 'auto';
|
||||
container.style.overflowX = 'hidden';
|
||||
container.style.overflowY = 'auto';
|
||||
container.style.padding = '20px';
|
||||
container.style.boxSizing = 'border-box';
|
||||
container.style.background = '#f5f5f5';
|
||||
|
||||
const styleEl = document.createElement('style');
|
||||
styleEl.textContent = `
|
||||
.hwp-inner-container {
|
||||
background: #ffffff;
|
||||
margin: 0 auto;
|
||||
max-width: 800px;
|
||||
box-shadow: 0 4px 10px rgba(0,0,0,0.1);
|
||||
padding: 30px !important;
|
||||
box-sizing: border-box !important;
|
||||
min-height: 100%;
|
||||
}
|
||||
.hwp-inner-container > div > div {
|
||||
max-width: 100% !important;
|
||||
height: auto !important;
|
||||
box-sizing: border-box !important;
|
||||
padding-left: 20px !important;
|
||||
padding-right: 20px !important;
|
||||
margin-bottom: 20px !important;
|
||||
}
|
||||
.hwp-inner-container table {
|
||||
max-width: 100% !important;
|
||||
width: 100% !important;
|
||||
table-layout: fixed !important;
|
||||
}
|
||||
.hwp-inner-container img {
|
||||
max-width: 100% !important;
|
||||
height: auto !important;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.hwp-inner-container {
|
||||
padding: 10px !important;
|
||||
}
|
||||
.hwp-inner-container > div > div {
|
||||
padding-left: 10px !important;
|
||||
padding-right: 10px !important;
|
||||
}
|
||||
}
|
||||
`;
|
||||
container.appendChild(styleEl);
|
||||
|
||||
const hwpInner = document.createElement('div');
|
||||
hwpInner.style.background = '#ffffff';
|
||||
hwpInner.style.margin = '0 auto';
|
||||
hwpInner.style.maxWidth = '800px';
|
||||
hwpInner.style.boxShadow = '0 4px 10px rgba(0,0,0,0.1)';
|
||||
hwpInner.style.padding = '40px';
|
||||
hwpInner.style.minHeight = '100%';
|
||||
hwpInner.classList.add('hwp-inner-container');
|
||||
|
||||
container.appendChild(hwpInner);
|
||||
docVars.viewer.appendChild(container);
|
||||
@@ -896,6 +934,281 @@ export async function renderDocViewer(resourcePath, docId) {
|
||||
docVars.viewer.dataset.viewerType = 'hwp';
|
||||
}
|
||||
|
||||
function viewerPptx(presignedUrl) {
|
||||
docVars.viewer.innerHTML = '<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) {
|
||||
resetViewer();
|
||||
|
||||
@@ -1222,7 +1535,11 @@ document.querySelector('.official-doc-main .official-doc-preview .official-doc-p
|
||||
//Presigned URL
|
||||
let PresignedUrl;
|
||||
await syncDocInfo(['official', 'attach', null]);
|
||||
let objectKey = docVars.allDocData?.find((doc) => doc.doc_id === docId)?.preview_key;
|
||||
|
||||
let directViewExtArr = ['xls', 'xlsx', 'xlsm', 'docx', 'hwp', 'hwpx'];
|
||||
let selectedDoc = docVars.allDocData?.find((doc) => doc.doc_id === docId);
|
||||
let objectKey = directViewExtArr.includes(ext) ? selectedDoc?.object_key : selectedDoc?.preview_key;
|
||||
|
||||
if (objectKey == undefined || objectKey == `` || objectKey == null) {
|
||||
return;
|
||||
}
|
||||
@@ -1250,19 +1567,26 @@ document.querySelector('.official-doc-main .official-doc-preview .official-doc-p
|
||||
let open_ext = `pdf`;
|
||||
switch (ext) {
|
||||
case 'pdf':
|
||||
case 'hwp':
|
||||
case 'hwpx':
|
||||
case 'xls':
|
||||
case 'xlsm':
|
||||
case 'ppt':
|
||||
case 'pptx':
|
||||
case 'doc':
|
||||
case 'docx':
|
||||
case 'dwg':
|
||||
case 'dxf':
|
||||
case 'grm':
|
||||
open_ext = 'pdf';
|
||||
break;
|
||||
case 'hwp':
|
||||
case 'hwpx':
|
||||
open_ext = ext;
|
||||
break;
|
||||
case 'xls':
|
||||
case 'xlsx':
|
||||
case 'xlsm':
|
||||
open_ext = ext;
|
||||
break;
|
||||
case 'docx':
|
||||
open_ext = ext;
|
||||
break;
|
||||
case 'gsim':
|
||||
open_ext = 'gsim';
|
||||
break;
|
||||
|
||||
@@ -44,6 +44,9 @@ if(data && Object.keys(data).length>0 && (data.$type == 'text'|| data.type == 't
|
||||
case 'docx':
|
||||
_openDocx(fullPath, data);
|
||||
break;
|
||||
case 'pptx':
|
||||
_openPptx(fullPath, data);
|
||||
break;
|
||||
case 'hwp':
|
||||
case 'hwpx':
|
||||
_openHwp(fullPath, data);
|
||||
@@ -812,18 +815,54 @@ function _openHwp(path, data) {
|
||||
const container = document.createElement('div');
|
||||
container.style.width = '100%';
|
||||
container.style.height = '100%';
|
||||
container.style.overflow = 'auto';
|
||||
container.style.overflowX = 'hidden';
|
||||
container.style.overflowY = 'auto';
|
||||
container.style.padding = '20px';
|
||||
container.style.boxSizing = 'border-box';
|
||||
container.style.background = '#f5f5f5';
|
||||
|
||||
const styleEl = document.createElement('style');
|
||||
styleEl.textContent = `
|
||||
.hwp-inner-container {
|
||||
background: #ffffff;
|
||||
margin: 0 auto;
|
||||
max-width: 800px;
|
||||
box-shadow: 0 4px 10px rgba(0,0,0,0.1);
|
||||
padding: 30px !important;
|
||||
box-sizing: border-box !important;
|
||||
min-height: 100%;
|
||||
}
|
||||
.hwp-inner-container > div > div {
|
||||
max-width: 100% !important;
|
||||
height: auto !important;
|
||||
box-sizing: border-box !important;
|
||||
padding-left: 20px !important;
|
||||
padding-right: 20px !important;
|
||||
margin-bottom: 20px !important;
|
||||
}
|
||||
.hwp-inner-container table {
|
||||
max-width: 100% !important;
|
||||
width: 100% !important;
|
||||
table-layout: fixed !important;
|
||||
}
|
||||
.hwp-inner-container img {
|
||||
max-width: 100% !important;
|
||||
height: auto !important;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.hwp-inner-container {
|
||||
padding: 10px !important;
|
||||
}
|
||||
.hwp-inner-container > div > div {
|
||||
padding-left: 10px !important;
|
||||
padding-right: 10px !important;
|
||||
}
|
||||
}
|
||||
`;
|
||||
container.appendChild(styleEl);
|
||||
|
||||
const hwpInner = document.createElement('div');
|
||||
hwpInner.style.background = '#ffffff';
|
||||
hwpInner.style.margin = '0 auto';
|
||||
hwpInner.style.maxWidth = '800px';
|
||||
hwpInner.style.boxShadow = '0 4px 10px rgba(0,0,0,0.1)';
|
||||
hwpInner.style.padding = '40px';
|
||||
hwpInner.style.minHeight = '100%';
|
||||
hwpInner.classList.add('hwp-inner-container');
|
||||
|
||||
container.appendChild(hwpInner);
|
||||
viewer.appendChild(container);
|
||||
@@ -844,4 +883,281 @@ function _openHwp(path, data) {
|
||||
console.error(err);
|
||||
viewer.innerHTML = '<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>';
|
||||
});
|
||||
}
|
||||
969
문서뷰어_구현방법_가이드.html
Normal file
969
문서뷰어_구현방법_가이드.html
Normal 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>© 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>
|
||||
366
한글파일미리보기구현.md
Normal file
366
한글파일미리보기구현.md
Normal 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`)로 변환하여 렌더링하도록 확장 대응이 가능합니다.
|
||||
Reference in New Issue
Block a user