Files
PM_test/한글파일미리보기구현.md
2026-06-19 17:58:47 +09:00

24 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 분석\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 내부에 절대 배치된 <svg> 배경 레이어를 생성하여 <rect>, <ellipse>, <line> 요소를 주입해 벡터 윤곽선을 정밀하게 구현했습니다.
    3. 도형의 세로 기준 설정(vertRelTo) 속성을 해석하여 분기 처리했습니다:
      • vertRelTo === 2 (문단 기준): 문단 컨테이너 divposition: 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.jsvisitCellListHeader 파서에서 셀 버퍼 데이터로부터 **세로 정렬 바이트(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 디렉토리 내부에서 이미지를 파싱할 때 헤더를 분석하여 압축 우회 및 안정적으로 원본 바이트 배열을 추출하는 로직입니다.

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)의 크기 및 패딩에 더하여 세로 정렬(상단/중앙/하단) 옵션의 바이트 코드를 감지하는 구조입니다.

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 셀 요소에 여백 수치를 변환하여 할당하고 세로 정렬 스타일을 적용하는 코드입니다.

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 대응 포함) 및 각 도형 컴포넌트를 브라우저에 배경 레이어로 알맞게 그리는 로직입니다.

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(비율) 스타일을 동적으로 입혀 축소 렌더링하는 반응형 고도화 적용이 가능합니다.