17 KiB
17 KiB
한글 파일(HWP) 클라이언트 사이드 미리보기 구현 및 기술 명세서
본 문서는 서버 자원 소모 없이 웹 브라우저(클라이언트) 단에서 .hwp 파일을 직접 파싱하고 안정적으로 이미지를 비롯한 도형 요소까지 화면에 렌더링하도록 커스텀 반영한 작업 내용, 구현 로직, 아키텍처 및 코드 명세를 다룹니다.
1. 미리보기 아키텍처 및 데이터 흐름
클라이언트 브라우저가 HWP 바이너리를 가져와 렌더링하기까지의 흐름도입니다.
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바이트)되는 문제가 있었습니다. - 조치:
- 압축 해제 전 바이너리의 첫 4바이트(Magic Number) 시그니처를 대조하여 웹 표준 파일 유형(PNG, JPEG, GIF, WMF) 검사를 선행합니다.
- 이미 시그니처를 충족하는 Raw 이미지의 경우 압축 해제를 우회하도록 최적화하여 렌더링 성능과 안정성을 향상했습니다.
- 압축이 필요한 경우 3단계 예외 처리 폴백(
windowBits: -15->표준 inflate->inflateRaw->원본 바이너리)을 구축하여 어떠한 조건에서도 파싱이 멈추지 않도록 조치했습니다.
2.2 MIME 타입 오류 수정 및 바이너리 안전성 보장
- 현상: 기존 뷰어 소스 코드에
type: "images/".concat(extension)이라는 치명적인 오타가 있었습니다. 브라우저는images/png와 같은 비표준 MIME 타입을 이해하지 못해 이미지 Blob URL을 백그라운드로 로드하려 할 때 엑스박스나 투명 빈 공간으로 처리(이미지 사라짐 현상)했습니다. 또한, 브라우저 환경에 따라 OLE 파서가 원본을 일반 Array 형태로 리턴할 때 문자열로 깨지는 위험이 존재했습니다. - 조치:
- MIME 타입을 표준 규격에 맞게
"image/".concat(extension)으로 전면 변경하고,jpg확장자는 브라우저 표준 명칭인image/jpeg로 정확히 매핑했습니다. - Blob 생성 시 바이트의 깨짐을 방지하기 위해 생성자 주입 전 데이터 타입을 검증하고
Uint8Array인스턴스로 안전하게 래핑하도록 보장했습니다.
- MIME 타입을 표준 규격에 맞게
2.3 한글 문서 자체 생성 도형(Shape Object) 렌더링 기능 추가
- 현상: 본문 내에 삽입된 이미지는 로드가 완료되었으나, 문서 편집기 내부에서 자체 제작한 직사각형(Rectangle), 타원(Ellipse), 선(Line), 다각형(Polygon) 등의 벡터 도형은 파서가 태그 분류를 누락하여 완전히 투명하게 렌더링되었습니다. 이로 인해 테두리와 사각형 배경 없이 글자만 겹쳐서 표시되는 문제가 있었습니다.
- 조치:
- HWP 파서의 핵심 순회 구조(
visit스위치-케이스 문) 내에 누락된 도형 컴포넌트 태그 ID들을 등록하여 파싱 단계에서 도형 종류(control.type)를 올바르게 정의하도록 했습니다. - 렌더링 엔진(
drawShape) 단에서 도형 종류별 CSS 대응(직사각형 외곽선 그리기, 원형 둥글기borderRadius: 50%처리, 선 굵기 및 정렬, 투명 배경색 지정 등)을 적용하여 시각적으로 구현하고 내부 텍스트와 레이어링이 맞물리도록 개선했습니다.
- HWP 파서의 핵심 순회 구조(
2.4 단락 및 텍스트 줄 간격(Line Spacing) 조절을 통한 겹침 결함 조치
- 현상: 뷰어 화면의 텍스트 줄 간격이 브라우저 기본값(normal)을 사용하여 좁게 나타날 뿐만 아니라, 프로젝트 전반의 글로벌 CSS 규격에 의해 자식 요소인
div태그의line-height속성이 덮어씌워져 줄 영역이 위아래로 심하게 겹쳐 보이는 시각적 오류가 발생했습니다. - 조치:
drawParagraph를 통해 생성되는 단락 컨테이너뿐만 아니라, 실제 텍스트가 바인딩되는 개별 문자열div스팬 요소(drawText내의span객체)에도line-height: 1.65인라인 스타일을 강제 적용했습니다.- 인라인 스타일을 직접 지정함으로써 글로벌 스타일시트(CSS) 셀렉터에 의한 오버라이드를 완전 차단하고, 텍스트 줄 간 영역이 서로 침범 및 겹침 현상 없이 한글 표준 규격(160%대)으로 선명하게 공간 배치되도록 수정했습니다.
3. 구현 코드 및 로직 명세 (libs/hwp.js)
3.1 이미지 원본 파싱 및 Decompress 처리 (visitBinData)
가상 OLE 디렉토리 내부에서 이미지를 파싱할 때 헤더를 분석하여 압축 우회 및 안정적으로 원본 바이트 배열을 추출하는 로직입니다.
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 블록 내부에 한글 파일 자체 도형 컴포넌트 레코드 식별자를 등록하여 각 도형 오브젝트의 타입 분류를 해석합니다.
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)
타입 정보가 명시된 컨트롤을 바탕으로 그림 및 각 도형 컴포넌트를 브라우저에 알맞게 그리는 로직입니다.
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 오버라이드를 해결한 코드입니다.
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배) 렌더링하는 코드 부분입니다.
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. 디버깅 및 유지보수 가이드
- 클라이언트 캐시 문제:
- 소스 파일인
hwp.js가 업데이트된 후에도 브라우저가 이전 라이브러리를 캐싱하고 있다면 이미지/도형이 보이지 않거나 줄 간격이 좁게 유지됩니다. 테스트 전에는 반드시Ctrl + F5또는 개발자 도구의Disable cache옵션을 활성화하여 확인을 진행하십시오.
- 소스 파일인
- WMF/EMF 벡터 포맷 추가 제언:
- 현재 웹 표준 규격(
PNG,JPEG,GIF) 이미지는 프론트엔드 내에서 완전하게 디코딩됩니다. - 향후 다수의 WMF/EMF 포맷에 대한 정교한 벡터 렌더링이 필요한 경우,
wmf.js라이브러리를 연동하거나, 가이드 3절의 백엔드 경량 이미지 변환 API(magick convert등)를 통해 웹 표준 이미지(WebP/PNG)로 변환하여 렌더링하도록 확장 대응이 가능합니다.
- 현재 웹 표준 규격(