한글뷰어 기능수정 Ver.01
This commit is contained in:
440
한글파일미리보기구현.md
440
한글파일미리보기구현.md
@@ -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(비율)` 스타일을 동적으로 입혀 축소 렌더링하는 반응형 고도화 적용이 가능합니다.
|
||||
|
||||
---
|
||||
|
||||
Reference in New Issue
Block a user