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