한글뷰어 기능수정 Ver.01

This commit is contained in:
koj729
2026-06-19 17:58:47 +09:00
parent 9268e4e6bc
commit 83b6e891ab
49 changed files with 8741 additions and 446 deletions

View File

@@ -1,4 +1,4 @@
# 한글 파일(HWP) 클라이언트 사이드 미리보기 구현 및 기술 명세서
# 한글 파일(HWP) 클라이언트 사이드 미리보기 구현 및 기술 명세서 (고도화 버전)
본 문서는 서버 자원 소모 없이 웹 브라우저(클라이언트) 단에서 `.hwp` 파일을 직접 파싱하고 안정적으로 이미지를 비롯한 도형 요소까지 화면에 렌더링하도록 커스텀 반영한 작업 내용, 구현 로직, 아키텍처 및 코드 명세를 다룹니다.
@@ -6,7 +6,7 @@
## 1. 미리보기 아키텍처 및 데이터 흐름
클라이언트 브라우저가 HWP 바이너리를 가져와 렌더링하기까지의 흐름입니다.
클라이언트 브라우저가 HWP 바이너리를 가져와 렌더링하기까지의 전체적인 흐름입니다.
```mermaid
graph TD
@@ -14,49 +14,90 @@ graph TD
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) 실행"]
E --> F{"Magic Number 분석\nRaw 이미지 여부 판별"}
F -->|"PNG/JPEG/GIF/WMF 헤더 일치"| G["Decompress 건너뛰기\nRaw 복사"]
F -->|"헤더 불일치 - 압축 상태"| H["pako.inflate 실행\nwindowBits: -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["최종 웹 뷰어 화면 렌더링"]
J --> K["Blob 객체 및 URL.createObjectURL 생성"]
K --> L{"control.type 판별"}
L -->|"그림 Picture"| M{"확장자 판별"}
M -->|"일반 이미지 PNG/JPG"| M1["shapeGroup div에\nbackgroundImage 매핑"]
M -->|"벡터 이미지 WMF"| M2["wmf.js Canvas 렌더링 후\nPNG 변환 바인딩"]
L -->|"도형 Rectangle/Ellipse/Line 등"| N["shapeGroup 내 SVG 배경 레이어 삽입\nrect, ellipse, line 요소 매핑"]
M1 --> O["최종 웹 뷰어 화면 렌더링"]
M2 --> O
N --> O
```
---
## 2. 해결한 핵심 이슈 및 작업 내용
### 2.1 OLE 이미지 압축 해제(Decompression) 안정화 및 우회 로직
* **현상:** 구형 HWP 파서 라이브러리(`hwp.js`)는 `pako.inflate`를 수행할 때 윈도우 비트 옵션(`{ windowBits: -15 }`)을 무조건적으로 주입해 압축을 해제했습니다. 그러나 한글 문서가 압축 없이 저장되었거나 일부 이미지가 무압축 Raw 바이너리(PNG, JPEG 등)로 OLE 스트림에 기록된 경우, 해제 오류로 인해 뷰어가 크래시되거나 이미지가 유실(0바이트)되는 문제가 있었습니다.
* **조치:**
* **현상**: 기존 HWP 파서 라이브러리(`hwp.js`)는 `pako.inflate`를 수행할 때 윈도우 비트 옵션(`{ windowBits: -15 }`)을 무조건적으로 주입해 압축을 해제했습니다. 그러나 한글 문서가 압축 없이 저장되었거나 일부 이미지가 무압축 Raw 바이너리(PNG, JPEG 등)로 OLE 스트림에 기록된 경우, 해제 오류로 인해 뷰어가 크래시되거나 이미지가 유실(0바이트)되는 문제가 있었습니다.
* **조치**:
1. 압축 해제 전 바이너리의 **첫 4바이트(Magic Number) 시그니처**를 대조하여 웹 표준 파일 유형(PNG, JPEG, GIF, WMF) 검사를 선행합니다.
2. 이미 시그니처를 충족하는 Raw 이미지의 경우 압축 해제를 우회하도록 최적화하여 렌더링 성능 안정성을 향상했습니다.
2. 이미 시그니처를 충족하는 Raw 이미지의 경우 압축 해제를 우회하도록 최적화하여 렌더링 성능 안정성을 향상했습니다.
3. 압축이 필요한 경우 3단계 예외 처리 폴백(`windowBits: -15` -> `표준 inflate` -> `inflateRaw` -> `원본 바이너리`)을 구축하여 어떠한 조건에서도 파싱이 멈추지 않도록 조치했습니다.
### 2.2 MIME 타입 오류 수정 및 바이너리 안전성 보장
* **현상:** 기존 뷰어 소스 코드에 `type: "images/".concat(extension)`이라는 치명적인 오타가 있었습니다. 브라우저는 `images/png`와 같은 비표준 MIME 타입을 이해하지 못해 이미지 Blob URL을 백그라운드로 로드하려 할 때 엑스박스나 투명 빈 공간으로 처리(이미지 사라짐 현상)했습니다. 또한, 브라우저 환경에 따라 OLE 파서가 원본을 일반 Array 형태로 리턴할 때 문자열로 깨지는 위험이 존재했습니다.
* **조치:**
* **현상**: 기존 뷰어 소스 코드에 `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) 등의 벡터 도형**은 파서가 태그 분류를 누락하여 완전히 투명하게 렌더링되었습니다. 이로 인해 테두리와 사각형 배경 없이 글자만 겹쳐서 표시되는 문제가 있었습니다.
* **조치:**
### 2.3 한글 문서 자체 생성 도형(Shape Object) 렌더링의 SVG 배경 레이어 적용 및 위치 정합성 교정
* **현상**: 본문 내에 삽입된 이미지는 로드가 완료되었으나, 문서 편집기 내부에서 자체 제작한 **직사각형(Rectangle), 타원(Ellipse), 선(Line), 다각형(Polygon) 등의 벡터 도형**은 파서가 태그 분류를 누락하여 완전히 투명하게 렌더링되거나, 상대 배치 마진 누적으로 인해 본문 뒤로 엉뚱하게 밀려나서 페이지 바깥의 회색 프레임에 잘못 렌더링되는 문제가 있었습니다.
* **조치**:
1. HWP 파서의 핵심 순회 구조(`visit` 스위치-케이스 문) 내에 누락된 도형 컴포넌트 태그 ID들을 등록하여 파싱 단계에서 도형 종류(`control.type`)를 올바르게 정의하도록 했습니다.
2. 렌더링 엔진(`drawShape`) 단에서 도형 종류별 CSS 대응(직사각형 외곽선 그리기, 원형 둥글기 `borderRadius: 50%` 처리, 선 굵기 및 정렬, 투명 배경색 지정 등)을 적용하여 시각적으로 구현하고 내부 텍스트와 레이어링이 맞물리도록 개선했습니다.
2. 렌더링 엔진(`drawShape`) 단에서 단순 HTML/CSS 스타일로 형태만 구현하던 단독 태그 방식 대신, 글씨 배치가 되는 부모 컨테이너 `div` 내부에 **절대 배치된 `<svg>` 배경 레이어를 생성하여 `<rect>`, `<ellipse>`, `<line>` 요소를 주입**해 벡터 윤곽선을 정밀하게 구현했습니다.
3. 도형의 세로 기준 설정(`vertRelTo`) 속성을 해석하여 분기 처리했습니다:
- `vertRelTo === 2` (문단 기준): 문단 컨테이너 `div``position: relative` 스타일을 부여하고 도형을 `position: absolute; top: [verticalOffset]; left: [horizontalOffset];`로 정밀 정렬하였습니다.
- `vertRelTo === 0 또는 1` (종이/쪽 기준): 도형을 부모 쪽 컨테이너(`container.parentNode`)의 절대 레이어에 직접 덧그려 배치했습니다.
- `drawParagraph` 시작 지점에서 문단 `div`를 부모(쪽)에 선제적으로 덧붙여 도형들의 절대 배치 위치 기준(`parentNode`)이 유효하도록 설계했습니다.
### 2.4 단락 및 텍스트 줄 간격(Line Spacing) 조절을 통한 겹침 결함 조치
* **현상:** 뷰어 화면의 텍스트 줄 간격이 브라우저 기본값(normal)을 사용하여 좁게 나타날 뿐만 아니라, 프로젝트 전반의 글로벌 CSS 규격에 의해 자식 요소인 `div` 태그의 `line-height` 속성이 덮어씌워져 줄 영역이 위아래로 심하게 겹쳐 보이는 시각적 오류가 발생했습니다.
* **조치:**
* **현상**: 뷰어 화면의 텍스트 줄 간격이 브라우저 기본값(normal)을 사용하여 좁게 나타날 뿐만 아니라, 프로젝트 전반의 글로벌 CSS 규격에 의해 자식 요소인 `div` 태그의 `line-height` 속성이 덮어씌워져 줄 영역이 위아래로 심하게 겹쳐 보이는 시각적 오류가 발생했습니다.
* **조치**:
1. `drawParagraph`를 통해 생성되는 단락 컨테이너뿐만 아니라, 실제 텍스트가 바인딩되는 개별 문자열 `div` 스팬 요소(`drawText` 내의 `span` 객체)에도 **`line-height: 1.65` 인라인 스타일을 강제 적용**했습니다.
2. 인라인 스타일을 직접 지정함으로써 글로벌 스타일시트(CSS) 셀렉터에 의한 오버라이드를 완전 차단하고, 텍스트 줄 간 영역이 서로 침범 및 겹침 현상 없이 한글 표준 규격(160%대)으로 선명하게 공간 배치되도록 수정했습니다.
### 2.5 한글 기본 폰트의 웹 폰트(@font-face) 매핑
* **현상**: 모바일이나 macOS 등 한글 기본 폰트(바탕체, 돋움체, 굴림체, 궁서체)가 설치되어 있지 않은 클라이언트에서 볼 때 줄바꿈과 표 넓이가 어긋나는 문제가 발생했습니다.
* **조치**:
1. `views/main/css/font.css`에 구글 Noto Serif KR 및 Noto Sans KR 웹 폰트를 임포트했습니다.
2. 각 한글 기본 폰트 이름에 대응하여 로컬 폰트를 우선 선언하고 없으면 Google Web Fonts에서 가져오도록 `@font-face`를 매핑했습니다.
### 2.6 WMF 벡터 이미지의 클라이언트 캔버스 디코딩 연동
* **현상**: 구형 한글 문서에 많이 쓰이는 WMF 포맷은 브라우저 표준 이미지 디코더로 해독할 수 없어 엑스박스로 출력되었습니다.
* **조치**:
1. SheetJS의 `wmf.js` 라이브러리를 로컬 `libs/wmf.js`에 다운로드하고 `main.html`에 로드하였습니다.
2. `drawShape` 실행 시 이미지 확장자가 `wmf`인 경우 `WMF.draw_canvas` API로 메모리 내에 Canvas를 생성해 렌더링한 후, PNG Data URL(`canvas.toDataURL('image/png')`)로 추출해 `backgroundImage`에 바인딩하도록 수정했습니다.
### 2.7 표 셀(td) padding 지정 및 세로 정렬(vertical-align) 완벽 일치
* **현상**: 표 내부의 텍스트가 셀 경계선에 너무 붙어 가독성이 저하되거나, 원본 문서에 지정된 세로 정렬 속성(위, 중간, 아래)이 반영되지 않는 문제가 있었습니다.
* **조치**:
1. `libs/hwp.js``visitCellListHeader` 파서에서 셀 버퍼 데이터로부터 **세로 정렬 바이트(vertical-align: 0-상단, 1-중앙, 2-하단)**를 추가로 해독하여 전달하도록 수정했습니다.
2. `drawColumn`에서 각 셀의 HWP `padding` 속성을 CSS로 적용하며, 누락되거나 0인 경우에는 웹 표준 기본 패딩(`2px 5px`)이 설정되도록 삼항 조건식을 추가했습니다.
3. 해독된 정렬 바이트를 CSS `vertical-align` 속성과 매핑시켜 인라인 스타일에 강제 반영했습니다.
### 2.8 뷰어 레이아웃 너비 확장 (횡스크롤바 제거)
* **현상**: 우측 미리보기 영역 너비가 좁아 A4 Portrait 규격(가로 약 794px + 여백) 문서를 미리볼 때 불필요한 하단 가로 스크롤바가 매번 발생했습니다.
* **조치**:
1. `views/main/css/style_archive.css`의 우측 미리보기 컨테이너(`.archive-main-right`) 가로 너비를 기존 `41rem`에서 **`55rem` (880px)**으로 확장하여 스크롤바 없는 쾌적한 뷰를 구현했습니다.
2. 이에 맞춰 우측 영역과 인접한 토글 스위치 및 버튼 메뉴들의 우측 절대 좌표 위치(`right: 41.5rem` -> **`right: 55.5rem`**)를 상향 조정했습니다.
### 2.9 HWP 팝업 미리보기 기능 정상화 및 레이아웃 개선
* **현상**: 한글 미리보기 전용 팝업창(`/popup/` 경로)에서 WMF 이미지/도형 리소스가 누락되거나, default CJK 폰트 매핑이 존재하지 않아 줄바꿈 및 벡터 도형이 비정상적으로 크고 잘못된 곳에 그려지며 횡스크롤바가 다발하는 문제가 지속되었습니다. 또한 페이지 너비를 강제로 줄일 시 A4 고유 가로폭과 절대 좌표 계산이 무너져 표나 이미지가 찌그러지고 겹치는 왜곡이 발생했습니다.
* **조치**:
1. `views/main/popup.html` 파일 내부에서 `hwp.js`가 구동되기 전 웹 폰트 매핑 스타일시트(`/main/css/font.css`)와 WMF 벡터 캔버스 렌더러 스크립트(`/libs/wmf.js`)를 반드시 추가 로드하도록 수정했습니다.
2. `popup.js`, `pageRenderer.js`, `docPageRenderer.js` 각 한글 미리보기 영역의 동적 CSS 주입(`styleEl.textContent`) 블록 내에 `.hwp-inner-container`의 최대 너비를 **`950px`**로 확장하였습니다.
3. 페이지 요소(`div[data-page-number]`)에 일시적으로 인젝션했던 `max-width: 100%``box-sizing: border-box` 설정을 완전 소거하여, 한글 고유의 A4 정밀 레이아웃 비율이 파괴되거나 표가 깨지는 문제를 방지하고 원래의 정상적인 형태로 복구시켰습니다.
4. `.hwpjs-viewer > div` 영역에 `overflow-x: hidden !important` 스타일만을 입혀 불필요한 횡스크롤바의 노출을 제어하고, `libs/hwp.js` 내에서 `vertRelTo === 0` (종이) 및 `1` (쪽) 절대 배치 도형 렌더링 시 부모 페이지의 padding(마진) 값만큼을 좌표에서 빼주어 도형들이 밀려나지 않고 표 바로 밑의 지정된 본문 영역 안에 안착하도록 보정했습니다.
---
## 3. 구현 코드 및 로직 명세 (libs/hwp.js)
@@ -91,15 +132,13 @@ value: function visitBinData(record) {
// 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;
else if (payload[0] === 0x7D && 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) {
@@ -109,7 +148,7 @@ value: function visitBinData(record) {
try {
decompressed = pako_1.inflateRaw(payload);
} catch (e3) {
decompressed = payload; // 최종 폴백
decompressed = payload;
}
}
}
@@ -121,82 +160,106 @@ value: function visitBinData(record) {
}
```
### 3.2 도형 태그 식별을 위한 파서 해석 추가 (`visit`)
### 3.2 셀 속성 세로 정렬 감지 추가 (`visitCellListHeader`)
`switch-case` 블록 내부에 한글 파일 자체 도형 컴포넌트 레코드 식별자를 등록하여 각 도형 오브젝트의 타입 분류를 해석합니다.
표 내부 셀(Cell)의 크기 및 패딩에 더하여 세로 정렬(상단/중앙/하단) 옵션의 바이트 코드를 감지하는 구조입니다.
```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;
}
key: "visitCellListHeader",
value: function visitCellListHeader(reader) {
var option = {
column: reader.readUInt16(),
row: reader.readUInt16(),
colSpan: reader.readUInt16(),
rowSpan: reader.readUInt16(),
width: reader.readUInt32(),
height: reader.readUInt32(),
padding: [reader.readUInt16(), reader.readUInt16(), reader.readUInt16(), reader.readUInt16()]
};
if (!reader.isEOF()) {
option.borderFillID = reader.readUInt16() - 1;
}
if (!reader.isEOF()) {
reader.readUInt8(); // text flow 건너뛰기
}
if (!reader.isEOF()) {
option.verticalAlign = reader.readUInt8(); // 0: Top, 1: Middle, 2: Bottom
}
return option;
}
```
### 3.3 이미지 및 도형 요소 스타일 렌더링 (`drawShape`)
### 3.3 표 셀 스타일 및 세로 정렬 일치 (`drawColumn`)
타입 정보가 명시된 컨트롤을 바탕으로 그림 및 각 도형 컴포넌트를 브라우저에 알맞게 그리는 로직입니다.
각 td 셀 요소에 여백 수치를 변환하여 할당하고 세로 정렬 스타일을 적용하는 코드입니다.
```javascript
key: "drawColumn",
value: function drawColumn(container, paragraphList) {
var _this = this;
var column = document.createElement('td');
var attrib = paragraphList.attribute;
column.style.width = "".concat(attrib.width / 100, "pt");
column.style.height = "".concat(attrib.height / 100, "pt");
column.colSpan = attrib.colSpan;
column.rowSpan = attrib.rowSpan;
// 여백(padding) 스타일 매핑 & 강제 주입
var pad = attrib.padding || [0, 0, 0, 0];
var ptLeft = pad[0] > 0 ? (pad[0] / 100) + "pt" : "5px";
var ptRight = pad[1] > 0 ? (pad[1] / 100) + "pt" : "5px";
var ptTop = pad[2] > 0 ? (pad[2] / 100) + "pt" : "2px";
var ptBottom = pad[3] > 0 ? (pad[3] / 100) + "pt" : "2px";
column.style.padding = ptTop + " " + ptRight + " " + ptBottom + " " + ptLeft;
// 세로 정렬(vertical-align) 매핑 (0: top, 1: middle, 2: bottom)
var valigns = ['top', 'middle', 'bottom'];
column.style.verticalAlign = valigns[attrib.verticalAlign] || 'middle';
this.drawBorderFill(column, attrib.borderFillID);
paragraphList.items.forEach(function (paragraph) {
_this.drawParagraph(column, paragraph);
});
container.appendChild(column);
}
```
### 3.4 이미지 WMF 캔버스 디코딩 및 도형 SVG 렌더링 & 절대 배치 (`drawShape`)
타입 정보가 명시된 컨트롤을 바탕으로 그림(WMF 대응 포함) 및 각 도형 컴포넌트를 브라우저에 배경 레이어로 알맞게 그리는 로직입니다.
```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) {
var targetContainer = container;
if (control.attribute.vertRelTo === 2) {
// 문단(Paragraph) 기준 절대 배치
container.style.position = 'relative';
shapeGroup.style.position = 'absolute';
shapeGroup.style.top = "".concat(control.verticalOffset / 100, "pt");
shapeGroup.style.left = "".concat(control.horizontalOffset / 100, "pt");
} else if (control.attribute.vertRelTo === 0 || control.attribute.vertRelTo === 1) {
// 종이(Paper) / 쪽(Page) 기준 절대 배치
shapeGroup.style.position = 'absolute';
shapeGroup.style.top = "".concat(control.verticalOffset / 100, "pt");
shapeGroup.style.left = "".concat(control.horizontalOffset / 100, "pt");
if (container.parentNode) {
targetContainer = container.parentNode;
}
} else {
// 문자 단위 등 기타 여백 배치
shapeGroup.style.marginTop = "".concat(control.verticalOffset / 100, "pt");
shapeGroup.style.marginLeft = "".concat(control.horizontalOffset / 100, "pt");
}
@@ -206,7 +269,6 @@ value: function drawShape(container, control) {
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';
@@ -224,134 +286,110 @@ value: function drawShape(container, control) {
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';
// WMF 디코딩 처리
if (image.extension.toLowerCase() === 'wmf' && typeof WMF !== 'undefined') {
try {
var size = WMF.image_size(uint8Arr);
var canvas = document.createElement('canvas');
canvas.width = size[0] || (control.width / 10);
canvas.height = size[1] || (control.height / 10);
WMF.draw_canvas(uint8Arr, canvas);
var imageURL = canvas.toDataURL('image/png');
shapeGroup.style.backgroundImage = "url(\"".concat(imageURL, "\")");
shapeGroup.style.backgroundRepeat = 'no-repeat';
shapeGroup.style.backgroundPosition = 'center';
shapeGroup.style.backgroundSize = 'contain';
} catch (wmfErr) {
console.error("[HWP.js Debug] WMF rendering error:", wmfErr);
shapeGroup.style.border = '1px solid red';
}
} else {
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';
shapeGroup.style.position = 'relative';
var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('width', '100%');
svg.setAttribute('height', '100%');
svg.style.position = 'absolute';
svg.style.top = '0';
svg.style.left = '0';
svg.style.pointerEvents = 'none';
svg.style.zIndex = '-1';
if (control.type === CommonCtrlID.Rectangle) {
// 직사각형 외곽 테두리 및 옅은 배경
shapeGroup.style.border = '1.5px solid #333333';
shapeGroup.style.backgroundColor = 'rgba(0, 0, 0, 0.02)';
var rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
rect.setAttribute('x', '1.5');
rect.setAttribute('y', '1.5');
rect.setAttribute('width', 'calc(100% - 3px)');
rect.setAttribute('height', 'calc(100% - 3px)');
rect.setAttribute('fill', 'rgba(0, 0, 0, 0.02)');
rect.setAttribute('stroke', '#333333');
rect.setAttribute('stroke-width', '1.5');
svg.appendChild(rect);
} 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)';
var ellipse = document.createElementNS('http://www.w3.org/2000/svg', 'ellipse');
ellipse.setAttribute('cx', '50%');
ellipse.setAttribute('cy', '50%');
ellipse.setAttribute('rx', 'calc(50% - 1.5px)');
ellipse.setAttribute('ry', 'calc(50% - 1.5px)');
ellipse.setAttribute('fill', 'rgba(0, 0, 0, 0.02)');
ellipse.setAttribute('stroke', '#333333');
ellipse.setAttribute('stroke-width', '1.5');
svg.appendChild(ellipse);
} 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';
}
var line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
line.setAttribute('x1', '0');
line.setAttribute('y1', '0');
line.setAttribute('x2', '100%');
line.setAttribute('y2', '100%');
line.setAttribute('stroke', '#333333');
line.setAttribute('stroke-width', '1.5');
svg.appendChild(line);
} 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)';
var placeholder = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
placeholder.setAttribute('x', '1');
placeholder.setAttribute('y', '1');
placeholder.setAttribute('width', 'calc(100% - 2px)');
placeholder.setAttribute('height', 'calc(100% - 2px)');
placeholder.setAttribute('fill', 'rgba(0, 0, 0, 0.01)');
placeholder.setAttribute('stroke', '#555555');
placeholder.setAttribute('stroke-width', '1');
placeholder.setAttribute('stroke-dasharray', '4');
svg.appendChild(placeholder);
} else {
shapeGroup.style.border = '1px solid #cccccc';
shapeGroup.style.backgroundColor = 'rgba(0, 0, 0, 0.01)';
var defaultRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
defaultRect.setAttribute('x', '1');
defaultRect.setAttribute('y', '1');
defaultRect.setAttribute('width', 'calc(100% - 2px)');
defaultRect.setAttribute('height', 'calc(100% - 2px)');
defaultRect.setAttribute('fill', 'rgba(0, 0, 0, 0.01)');
defaultRect.setAttribute('stroke', '#cccccc');
defaultRect.setAttribute('stroke-width', '1');
svg.appendChild(defaultRect);
}
shapeGroup.appendChild(svg);
}
// 도형 내부 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);
targetContainer.appendChild(shapeGroup);
}
```
@@ -359,8 +397,12 @@ value: function drawParagraph(container, paragraph) {
## 4. 디버깅 및 유지보수 가이드
1. **클라이언트 캐시 문제:**
* 소스 파일인 `hwp.js`가 업데이트된 후에도 브라우저가 이전 라이브러리를 캐싱하고 있다면 이미지/도형이 보이지 않거나 줄 간격이 좁게 유지됩니다. 테스트 전에는 반드시 **`Ctrl + F5`** 또는 개발자 도구의 **`Disable cache`** 옵션을 활성화하여 확인을 진행하십시오.
2. **WMF/EMF 벡터 포맷 추가 제언:**
* 현재 웹 표준 규격(`PNG`, `JPEG`, `GIF`) 이미지는 프론트엔드 내에서 완전하게 디코딩됩니다.
* 향후 다수의 WMF/EMF 포맷에 대한 정교한 벡터 렌더링이 필요한 경우, `wmf.js` 라이브러리를 연동하거나, 가이드 3절의 백엔드 경량 이미지 변환 API(`magick convert` 등)를 통해 웹 표준 이미지(`WebP`/`PNG`)로 변환하여 렌더링하도록 확장 대응이 가능합니다.
1. **클라이언트 캐시 문제**:
* 소스 파일인 `hwp.js`나 스타일시트가 업데이트된 후에도 브라우저가 이전 파일을 캐싱하고 있다면 레이아웃 변경이 보이지 않거나 스크롤이 유지됩니다. 테스트 전에는 반드시 **`Ctrl + F5`** 또는 개발자 도구의 **`Disable cache`** 옵션을 활성화하여 확인을 진행하십시오.
2. **폰트 로딩**:
* CDN을 통해 가져오는 구글 Noto 웹 폰트가 차단된 특수 오프라인(망분리) 환경인 경우, 폰트 파일(`woff2`)을 로컬 서버 `views/main/css/font/` 경로 등에 직접 배치하고 `font.css` 파일에서 경로명을 상대경로로 기입하여 로딩해야 합니다.
3. **가로/세로 혼용 문서의 가로 스크롤 조치**:
* 구역 설정으로 인해 세로형 문서 중간에 가로 방향(Landscape) 페이지가 포함될 경우, 해당 가로 페이지는 구역 크기에 비례하여 화면에 훨씬 넓게 그려집니다.
* 기본적으로 뷰어 컨테이너에 `overflow: auto`가 설정되어 가로 스크롤바가 자동 제공되나, 모바일 뷰 등을 고려하여 한 화면에 맞추는 기능이 요구될 경우 JavaScript 단에서 `transform: scale(비율)` 스타일을 동적으로 입혀 축소 렌더링하는 반응형 고도화 적용이 가능합니다.
---