unhwp 0.5.1

A high-performance library for extracting HWP/HWPX documents into structured Markdown
Documentation
<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>unhwp — HWP/HWPX WASM 플레이그라운드</title>
  <style>
    *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
    :root {
      --bg: #0f1117; --surface: #1a1d27; --surface2: #242736;
      --border: #2e3347; --accent: #4a9eff; --accent-dim: #2a5a99;
      --text: #e2e4ee; --text-dim: #8890a8; --err: #ff6b6b; --ok: #6bffb3;
      --radius: 10px; --mono: 'Fira Code', Consolas, monospace;
    }
    body { background: var(--bg); color: var(--text);
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
      min-height: 100vh; display: flex; flex-direction: column;
      align-items: center; padding: 2rem 1rem; gap: 1.5rem; }
    header { text-align: center; }
    header h1 { font-size: 1.75rem; }
    header h1 span { color: var(--accent); }
    header p { color: var(--text-dim); margin-top: 0.35rem; font-size: 0.9rem; }
    .drop-zone { width: 100%; max-width: 720px; border: 2px dashed var(--border);
      border-radius: var(--radius); padding: 2.5rem 2rem; text-align: center;
      cursor: pointer; transition: border-color 0.15s, background 0.15s;
      background: var(--surface); user-select: none; }
    .drop-zone:hover, .drop-zone.dragover { border-color: var(--accent); background: #1e2136; }
    .drop-zone p { color: var(--text-dim); font-size: 0.9rem; }
    .drop-zone p strong { color: var(--text); }
    #file-input { display: none; }
    .loading { width: 100%; max-width: 720px; text-align: center; color: var(--text-dim);
      font-size: 0.9rem; }
    .result-card { width: 100%; max-width: 720px; background: var(--surface);
      border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
    .card-header { display: flex; align-items: center; justify-content: space-between;
      padding: 0.75rem 1rem; background: var(--surface2); border-bottom: 1px solid var(--border);
      gap: 0.5rem; flex-wrap: wrap; }
    .file-info { font-size: 0.85rem; color: var(--text-dim); }
    .file-info strong { color: var(--text); }
    .tabs { display: flex; gap: 0.25rem; }
    .tab { padding: 0.3rem 0.75rem; border-radius: 5px; border: 1px solid transparent;
      background: transparent; color: var(--text-dim); font-size: 0.8rem; cursor: pointer;
      transition: all 0.12s; }
    .tab:hover { color: var(--text); background: var(--surface); }
    .tab.active { color: var(--accent); border-color: var(--accent-dim); background: var(--surface); }
    .content-area { padding: 1rem; max-height: 60vh; overflow-y: auto; }
    pre { font-family: var(--mono); font-size: 0.82rem; white-space: pre-wrap;
      word-break: break-word; line-height: 1.6; color: var(--text); }
    .status-bar { padding: 0.5rem 1rem; font-size: 0.8rem; color: var(--text-dim);
      border-top: 1px solid var(--border); background: var(--surface2); }
    .ok { color: var(--ok); }
    .error-card { width: 100%; max-width: 720px; background: #2a1a1a;
      border: 1px solid var(--err); border-radius: var(--radius);
      padding: 1rem; font-size: 0.9rem; color: var(--err); }
    .hidden { display: none !important; }
    .options-row { display: flex; gap: 1rem; flex-wrap: wrap; align-items: center;
      font-size: 0.85rem; color: var(--text-dim); }
    .options-row label { display: flex; align-items: center; gap: 0.4rem; cursor: pointer; }
    .options-row input[type=checkbox] { accent-color: var(--accent); cursor: pointer; }
  </style>
</head>
<body>
  <header>
    <h1><span>unhwp</span> WASM 플레이그라운드</h1>
    <p>HWP / HWPX 파일을 드래그하거나 클릭하여 Markdown으로 변환합니다.</p>
  </header>

  <div class="options-row">
    <label title="파싱 오류 발생 시 해당 섹션을 건너뛰고 계속 파싱합니다">
      <input type="checkbox" id="opt-lenient"> 관대 모드 (오류 허용)
    </label>
    <label title="이미지 등 바이너리 리소스를 추출하지 않아 속도가 빠릅니다">
      <input type="checkbox" id="opt-text-only"> 텍스트 전용
    </label>
  </div>

  <div class="drop-zone" id="dropZone">
    <p><strong>HWP 또는 HWPX 파일 선택</strong></p>
    <p>드래그 앤 드롭 또는 클릭하여 업로드</p>
    <input type="file" id="file-input" accept=".hwp,.hwpx">
  </div>

  <div class="loading hidden" id="loading">파싱 중...</div>
  <div id="errorContainer"></div>

  <div class="result-card hidden" id="resultCard">
    <div class="card-header">
      <div class="file-info" id="fileInfo"></div>
      <div class="tabs">
        <button class="tab active" data-tab="markdown">Markdown</button>
        <button class="tab" data-tab="text">텍스트</button>
      </div>
    </div>
    <div class="content-area">
      <pre id="outputContent"></pre>
    </div>
    <div class="status-bar" id="statusBar"></div>
  </div>

  <script type="module">
    import init, { parse, parseWithOptions, ParseOptions } from './pkg/unhwp_wasm.js';

    let wasmReady = false;
    let tabs = { markdown: '', text: '' };
    let activeTab = 'markdown';

    async function initWasm() {
      await init();
      wasmReady = true;
    }

    initWasm().catch(err => showError(new Error(`WASM  : ${err.message}`)));

    const dropZone = document.getElementById('dropZone');
    const fileInput = document.getElementById('file-input');
    const loadingEl = document.getElementById('loading');
    const resultCard = document.getElementById('resultCard');
    const fileInfoEl = document.getElementById('fileInfo');
    const outputContent = document.getElementById('outputContent');
    const statusBarEl = document.getElementById('statusBar');
    const errorContainer = document.getElementById('errorContainer');

    dropZone.addEventListener('click', () => fileInput.click());
    dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('dragover'); });
    dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover'));
    dropZone.addEventListener('drop', e => {
      e.preventDefault();
      dropZone.classList.remove('dragover');
      if (e.dataTransfer.files[0]) processFile(e.dataTransfer.files[0]);
    });
    fileInput.addEventListener('change', () => {
      if (fileInput.files[0]) processFile(fileInput.files[0]);
    });

    document.querySelectorAll('.tab').forEach(btn => {
      btn.addEventListener('click', () => {
        document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
        btn.classList.add('active');
        activeTab = btn.dataset.tab;
        outputContent.textContent = tabs[activeTab] ?? '';
      });
    });

    async function processFile(file) {
      if (!wasmReady) { alert('WASM을 로드 중입니다. 잠시 후 다시 시도하세요.'); return; }

      errorContainer.replaceChildren();
      resultCard.classList.add('hidden');
      loadingEl.classList.remove('hidden');

      try {
        const buf = await file.arrayBuffer();
        const data = new Uint8Array(buf);
        const isLenient = document.getElementById('opt-lenient').checked;
        const isTextOnly = document.getElementById('opt-text-only').checked;
        let doc;
        if (isLenient || isTextOnly) {
          let opts = new ParseOptions();
          if (isLenient) opts = opts.lenient();
          if (isTextOnly) opts = opts.textOnly();
          doc = parseWithOptions(data, opts);
        } else {
          doc = parse(data);
        }

        tabs.markdown = doc.toMarkdown();
        tabs.text = doc.toText();

        const sections = doc.sectionCount();
        const paragraphs = doc.paragraphCount();

        const nameEl = document.createElement('strong');
        nameEl.textContent = file.name;
        fileInfoEl.replaceChildren(nameEl, document.createTextNode(` (${formatBytes(file.size)})`));
        outputContent.textContent = tabs[activeTab];

        const checkEl = document.createElement('span');
        checkEl.className = 'ok';
        checkEl.textContent = '';
        statusBarEl.replaceChildren(
          checkEl,
          document.createTextNode(`  ${sections} ·  ${paragraphs} · ${tabs.markdown.length.toLocaleString()} chars (MD)`)
        );
        loadingEl.classList.add('hidden');
        resultCard.classList.remove('hidden');
      } catch (err) {
        loadingEl.classList.add('hidden');
        showError(err);
      }
    }

    function showError(err) {
      const msg = err?.message ?? String(err);
      let hint = '';
      if (/unsupported|unknown format/i.test(msg)) hint = 'HWP 또는 HWPX 파일만 지원합니다.';
      else if (/encrypt|password/i.test(msg)) hint = '암호화된 문서는 현재 지원하지 않습니다.';

      const card = document.createElement('div');
      card.className = 'error-card';

      const heading = document.createElement('strong');
      heading.textContent = '파싱 오류 ';
      card.appendChild(heading);
      card.appendChild(document.createTextNode(msg));

      if (hint) {
        const hintEl = document.createElement('span');
        hintEl.style.cssText = 'color:var(--text-dim);display:block;margin-top:0.25rem';
        hintEl.textContent = hint;
        card.appendChild(hintEl);
      }

      errorContainer.replaceChildren(card);
    }

    function formatBytes(n) {
      if (n < 1024) return `${n} B`;
      if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
      return `${(n / 1024 / 1024).toFixed(1)} MB`;
    }
  </script>
</body>
</html>