# 한글 파일(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 분석\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{"확장자 판별"} 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바이트)되는 문제가 있었습니다. * **조치**: 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) 렌더링의 SVG 배경 레이어 적용 및 위치 정합성 교정 * **현상**: 본문 내에 삽입된 이미지는 로드가 완료되었으나, 문서 편집기 내부에서 자체 제작한 **직사각형(Rectangle), 타원(Ellipse), 선(Line), 다각형(Polygon) 등의 벡터 도형**은 파서가 태그 분류를 누락하여 완전히 투명하게 렌더링되거나, 상대 배치 마진 누적으로 인해 본문 뒤로 엉뚱하게 밀려나서 페이지 바깥의 회색 프레임에 잘못 렌더링되는 문제가 있었습니다. * **조치**: 1. HWP 파서의 핵심 순회 구조(`visit` 스위치-케이스 문) 내에 누락된 도형 컴포넌트 태그 ID들을 등록하여 파싱 단계에서 도형 종류(`control.type`)를 올바르게 정의하도록 했습니다. 2. 렌더링 엔진(`drawShape`) 단에서 단순 HTML/CSS 스타일로 형태만 구현하던 단독 태그 방식 대신, 글씨 배치가 되는 부모 컨테이너 `div` 내부에 **절대 배치된 `` 배경 레이어를 생성하여 ``, ``, `` 요소를 주입**해 벡터 윤곽선을 정밀하게 구현했습니다. 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` 속성이 덮어씌워져 줄 영역이 위아래로 심하게 겹쳐 보이는 시각적 오류가 발생했습니다. * **조치**: 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) ### 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] === 0x7D && payload[1] === 0xCD && payload[2] === 0xC6 && payload[3] === 0x9A) isRawImage = true; } var decompressed; if (isRawImage) { decompressed = payload; } else { 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 셀 속성 세로 정렬 감지 추가 (`visitCellListHeader`) 표 내부 셀(Cell)의 크기 및 패딩에 더하여 세로 정렬(상단/중앙/하단) 옵션의 바이트 코드를 감지하는 구조입니다. ```javascript 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 표 셀 스타일 및 세로 정렬 일치 (`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"); 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"); } shapeGroup.style.zIndex = "".concat(control.zIndex); shapeGroup.style.verticalAlign = 'middle'; shapeGroup.style.display = 'inline-block'; if (isPicture(control)) { 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); // 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 { 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) { 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) { 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 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) { 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 { 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); } control.content.forEach(function (paragraphList) { paragraphList.items.forEach(function (paragraph) { _this3.drawParagraph(shapeGroup, paragraph); }); }); targetContainer.appendChild(shapeGroup); } ``` --- ## 4. 디버깅 및 유지보수 가이드 1. **클라이언트 캐시 문제**: * 소스 파일인 `hwp.js`나 스타일시트가 업데이트된 후에도 브라우저가 이전 파일을 캐싱하고 있다면 레이아웃 변경이 보이지 않거나 스크롤이 유지됩니다. 테스트 전에는 반드시 **`Ctrl + F5`** 또는 개발자 도구의 **`Disable cache`** 옵션을 활성화하여 확인을 진행하십시오. 2. **폰트 로딩**: * CDN을 통해 가져오는 구글 Noto 웹 폰트가 차단된 특수 오프라인(망분리) 환경인 경우, 폰트 파일(`woff2`)을 로컬 서버 `views/main/css/font/` 경로 등에 직접 배치하고 `font.css` 파일에서 경로명을 상대경로로 기입하여 로딩해야 합니다. 3. **가로/세로 혼용 문서의 가로 스크롤 조치**: * 구역 설정으로 인해 세로형 문서 중간에 가로 방향(Landscape) 페이지가 포함될 경우, 해당 가로 페이지는 구역 크기에 비례하여 화면에 훨씬 넓게 그려집니다. * 기본적으로 뷰어 컨테이너에 `overflow: auto`가 설정되어 가로 스크롤바가 자동 제공되나, 모바일 뷰 등을 고려하여 한 화면에 맞추는 기능이 요구될 경우 JavaScript 단에서 `transform: scale(비율)` 스타일을 동적으로 입혀 축소 렌더링하는 반응형 고도화 적용이 가능합니다. ---