오토한글auto-hwp 앱 내려받기GitHub

임베드 가이드 (React SDK)

내 웹앱에 편집기를 얹는 최단 경로 — 패키지, 에셋, 패널 배치.

원문: docs/EMBED-GUIDE.md · 이 페이지는 빌드할 때 그 파일에서 생성됩니다.

English: EMBED-GUIDE.en.md (임계 경로 전문 번역).

대상: Next 가 아닌 임의 호스트(Vite/CRA/SvelteKit 정적/S3+CloudFront 등)에서 npm i @auto-hwp/react @auto-hwp/engine 으로 hwp 뷰어/에디터를 자기 페이지에 심으려는 개발자. Next.js 통합은 INTEGRATION-HANDOVER.md(참조 앱 apps/hwp-lab)를 보라 — 이 문서는 그 Next 편중을 보완하는 프레임워크 독립 절이다.

실동작 예제(이 레포에서 실검증):

  • examples/vite-embedpublished tarball 을 설치<HwpWorkspace/> 를 렌더하는 최소 Vite 앱 + Playwright 스모크(업로드→8쪽 렌더→셀 마킹→mock 편집→undo).
  • examples/ai-proxy-express — 정적/비-Next 호스트용 얇은 AI 프록시(Express).

1. 설치 — 4개 패키지

npm i @auto-hwp/react @auto-hwp/engine @auto-hwp/editor-core @auto-hwp/ai-protocol
패키지레이어역할
@auto-hwp/engineL1 (wasm)파싱·조판(px)·렌더(SVG 문자열)·Intent 적용·undo·export. 폰트/LLM/키 0.
@auto-hwp/editor-coreL2 (headless)DocSession·SelectionModel·EditController. React·DOM 0.
@auto-hwp/ai-protocolL2' (isomorphic)EditRequest/Response·buildDocContext(R5 펜스)·validate*. fetch·키 0. 서버·클라 공유.
@auto-hwp/reactL3 (UI)<HwpWorkspace/> + 오버레이·채팅. 전부 교체 가능. peerDependencies: react/react-dom ≥18.

@auto-hwp/react@auto-hwp/engine·@auto-hwp/editor-core 를 실버전(^0.0.5)으로 의존한다(모노레포 file: 아님 — 발행본은 레지스트리에서 정상 해석된다). @auto-hwp/ai-protocol 은 서버 프록시에서도 쓰므로 독립 설치한다.


2. wasm / 워커 로딩 — 기본값은 CDN, 자기 호스팅은 오버라이드

현재 공개 stable 0.0.5에는 이 절의 **CDN 기본값·onProgress·prefetch()**가 모두 포함돼 있다. examples/vite-embed는 CDN에 기대지 않는 자기호스팅 경로도 계속 검증하기 위해 명시 URL을 사용한다.

2.1 기본값 — 아무것도 복사하지 않는다

WasmAdapter 에 URL 을 주지 않으면 엔진은 자기 패키지 버전으로 pin 된 jsDelivr 에서 wasm/워커를 받는다:

https://cdn.jsdelivr.net/npm/@auto-hwp/engine@<설치된 버전>/pkg/hwp_wasm_bg.wasm
https://cdn.jsdelivr.net/npm/@auto-hwp/engine@<설치된 버전>/worker.js
const adapter = new WasmAdapter();                 // 메인스레드 엔진 + CDN wasm
const adapter = new WasmAdapter(undefined, { worker: {} });  // 워커 엔진 + CDN worker.js/wasm
  • @latest 는 절대 쓰지 않는다. wasm-bindgen 글루(JS)와 wasm 바이너리는 함께 컴파일된 한 벌이라 버전이 어긋나면 링크에 실패하거나, 더 나쁘게는 링크된 뒤 신규 Intent 를 unknown variant 로 거부한다 (이 레포의 "스테일 wasm 함정"을 제도화하는 셈). 그래서 pin 은 로딩을 수행하는 JS 자신의 버전이다 (ENGINE_VERSION, packages/engine/cdn.js — 발행 훅이 package.json 과 하드 게이트로 대조).
  • 교차 출처 워커 스크립트new Worker(url) 에 그대로 못 넘긴다(동일 출처 정책) → 클라이언트가 같은 출처의 blob 심(import "<cdn>/worker.js")으로 감싼다. 엄격 CSP 라면 worker-src blob: 필요(§4).
  • 오프라인/내부망·CDN 차단 환경은 §2.2 로 간다. CDN 은 기본값일 뿐, 요구사항이 아니다.

역사적 전송 기준 실측(2026-07-30, @auto-hwp/engine@0.0.2; 현재 stable의 기능 기준은 0.0.5):

항목
jsDelivr content-type / CORSapplication/wasm / access-control-allow-origin: *, expose-headers: *
전송 크기 (brotli)2,962,776 B (≈2.96 MB)
비압축 크기7,718,539 B (≈7.72 MB)
GitHub Pages 자기 호스팅 (gzip)3,145,131 B / Vite 빌드 리포트 gzip 3,151,674 B

2.2 오버라이드 — 자기 호스팅(정적 파일 복사)

CDN 을 못 쓰거나(폐쇄망·CSP·규정) 버전을 직접 고정하고 싶으면 명시 URL 을 준다. 그때만 발행본 (node_modules/@auto-hwp/engine)에서 아래 파일들을 상대구조 그대로 정적 루트로 복사한다 (examples/vite-embed/scripts/copy-assets.mjs 가 그 스크립트다):

public/hwp/hwp_wasm_bg.wasm      ← node_modules/@auto-hwp/engine/pkg/hwp_wasm_bg.wasm  (런타임에 URL fetch)
public/hwp/worker.js             ← node_modules/@auto-hwp/engine/worker.js             (모듈 워커 엔트리)
public/hwp/index.js              ← node_modules/@auto-hwp/engine/index.js              (worker.js 가 import)
public/hwp/cdn.js                ← node_modules/@auto-hwp/engine/cdn.js                (현행 stable — index.js 가 import)
public/hwp/pkg/hwp_wasm.js       ← node_modules/@auto-hwp/engine/pkg/hwp_wasm.js       (wasm-bindgen 글루)

⚠️ 현행 stable은 cdn.js가 필수다. index.js./cdn.js를 import하므로 빠지면 워커가 모듈 로드 404로 즉사한다. 다섯 파일의 상대 구조를 한 벌로 유지한다.

worker.js → ./index.js → ./pkg/hwp_wasm.js상대 import 체인public/hwp/ 안에서 그대로 성립하도록 디렉토리 구조를 보존해 복사해야 한다. Vite 라면 vite.configoptimizeDeps.exclude: ["@auto-hwp/engine"] 를 두어 워커/글루가 esbuild 사전번들 대상이 되지 않게 한다(런타임 정적 로딩 대상).

Vite 프로덕션 빌드 사본 주의(무해): vite build 는 엔진 글루의 기본 wasm 참조 (new URL('..._bg.wasm', import.meta.url))를 정적 에셋으로 한 번 더 방출한다 (dist/assets/hwp_wasm_bg-*.wasm, ~8.1MB). 런타임엔 WasmAdapter 가 넘긴 public/hwp 의 명시적 URL 만 fetch 되므로 이 사본은 로드되지 않는다(정상 동작). 배포 크기를 줄이려면 빌드 후 dist/assets/*.wasm 를 삭제하거나, 글루의 import.meta.url 자산화를 끄는 rollup 플러그인을 붙이면 된다(선택 — 게이트 아님).

빌드/개발 시 자동 복사(예제의 훅):

"predev":   "node scripts/copy-assets.mjs",
"prebuild": "node scripts/copy-assets.mjs",

번들러에게 wasm 을 자산으로 방출시키고 싶다면(복사 스크립트 없이) Vite 기준 한 줄이면 된다:

import wasmUrl from "@auto-hwp/engine/pkg/hwp_wasm_bg.wasm?url";
const adapter = new WasmAdapter(wasmUrl);   // 워커를 쓰려면 worker.js 는 여전히 정적 서빙 필요

2.3 진행률 + 프리페치 (stable 0.0.5)

wasm 은 비압축 ~7.7MB(압축 전송 ~3.0MB)라 첫 로드가 눈에 띈다. 어댑터가 두 가지를 준다:

const adapter = new WasmAdapter(undefined, {
  onProgress: (p) => setPct(p.ratio == null ? null : Math.round(p.ratio * 100)),
});

// 랜딩에서 유휴에 미리 받아 둔다 → 사용자가 파일을 고르는 순간 대기 0
useEffect(() => { requestIdleCallback(() => void adapter.prefetch()); }, [adapter]);
  • onProgress다운로드만 측정한다(그 뒤의 wasm 컴파일은 측정 지점이 없다). 틱은 {loaded, total, ratio, done, estimated, url}.
  • ⚠️ estimated 의 의미: 응답이 brotli/gzip 이면 Content-Length압축 바이트인데 response.body비압축 바이트를 흘린다 — 그대로 나누면 200% 가 된다. 그래서 압축 응답에서는 발행 크기(WASM_BYTES)를 분모로 쓰고 estimated:true 를 세우며, 스트림이 실제로 끝나기 전에는 절대 100% 를 표시하지 않는다. 자기 빌드를 자기 호스팅한다면 expectedBytes 로 분모를 직접 준다.
  • prefetch() 는 실패를 삼키고 false 를 돌려준다(예열이 앱을 깨면 안 된다 — 진짜 오류는 실제 열기에서 난다).
  • 워커 모드에서도 같은 콜백이 온다(워커 안에서 측정해 id 없는 {progress} 메시지로 올려보낸다).

2.4 아직 안 되는 것 (백로그)

  • Cloudflare Workers / 엣지 런타임: 그쪽은 fetch+instantiateStreaming 대신 번들에 박힌 WebAssembly.Module 을 주입하는 초기화가 필요하다. 지금은 initEngineSync(module) 로 수동 배선해야 하고, 전용 진입점(@auto-hwp/engine/edge 류)은 후속 작업이다(W6.1 스코프 밖).
  • CDN 기본값은 브라우저 전용이다(Node 에서는 URL 대신 바이트/모듈을 넘긴다).

3. 마운트 — <HwpWorkspace/>

import { HwpWorkspace, WasmAdapter } from "@auto-hwp/react";
import "@auto-hwp/react/styles.css";               // ← 스타일은 수동 import (사이드이펙트 CSS)

// stable 0.0.5: 인자 없이 = CDN 기본값(§2.1). 자기 호스팅이면 아래처럼 명시 URL 을 준다(§2.2).
const adapter = new WasmAdapter(
  new URL("/hwp/hwp_wasm_bg.wasm", window.location.origin),
  { worker: { url: new URL("/hwp/worker.js", window.location.origin) } },
);

<HwpWorkspace
  adapter={adapter}
  document={{ bytes, name: "plan.hwpx" }}   // 업로드/드롭으로 얻은 Uint8Array
  onAiRequest={serverSideAi}                // R6당신의 서버가 Intents  돌려준다5)
  defaultFont={{ family: "Nanum Gothic", bytes: fontBytes }}  // R8폰트는 주입6)
  fontUrlBase="/fonts"
  enableEditing                             // 옵트인: 수동 편집 크롬(룰러/열너비/서식 툴바)
  sidePanel={(api) => <MyPanel {...api} />} // 오른쪽 패널은 호스트 것(아래 §3.1) — 생략하면 패널 없음
/>

전체 배선(파일 열기·프로브·폰트 fetch·mock AI)은 examples/vite-embed/src/App.tsx 참조.

3.1 sidePanel — 패널의 내용과 배치는 호스트가 조립한다

HwpWorkspace문서 표면만 소유한다: 페이지·선택·오버레이·수동 편집. 채팅 뷰는 들어 있지 않다. 패널은 슬롯이고, 호스트가 채팅·입력 폼·인스펙터를 넣거나 아무것도 안 넣는다. 슬롯 함수는 WorkspaceSidePanel 을 받는다 — 편집 표면 전체가 여기로 넘어오므로 워크스페이스 내부를 건드릴 일이 없다:

sidePanel={(api) => (
  <MyPanel
    canEdit={api.canEdit}            // 문서가 열려 있고 편집 가능한가
    anchors={api.anchors}            // 사용자가 지정한 위치들([s/b] 주소docContext  동일 좌표)
    modLabel={api.modLabel}          // "⌘" / "Ctrl"
    onRemoveAnchor={api.removeAnchor}
    onClearAnchors={api.clearAnchors}
    docContext={api.docContext}      // AI 브릿지에 넘길 문서 컨텍스트(프로필+지정위치+ 그리드)
    onApply={api.apply}              // 검증된 Intent[] 적용반영 개수
    onJumpToPage={api.jumpToPage}
    onRevealTarget={api.revealTarget}// 블록으로 스크롤 + 깜빡임("위치 보기")
    focusToken={api.focusToken}      // 증가하면 입력창에 포커스
    previewCards={api.previewCards}  // 적용  미리보기 보강
    onRevert={api.revert}            // 마지막 적용 묶음을  단위로 되돌리기
    undoDepth={api.undoDepth}
    selection={api.designSelection} // 종류·텍스트·페이지·X/Y/W/H·현재 서식
    onDesign={api.applyDesign}      // 선택 영역에 서식 delta 적용
    textEditing={api.textEditing}   // 엔진 캐럿 입력 중인지
  />
)}

정의: packages/react/src/components/HwpWorkspace.tsxWorkspaceSidePanel.

기본 UI는 쓰되 배치만 바꾸기

참조 패널은 바이브 편집과 선택 디자인 인스펙터를 제공한다. 제품 셸을 처음부터 만들 필요 없이 presentation만 고를 수 있다:

import { workspacePanel } from "@auto-hwp/react";

<HwpWorkspace
  {...props}
  sidePanel={workspacePanel({
    onAiRequest,
    presentation: "bottom", // "rail" | "bottom" | "modal" | "unstyled"
  })}
/>

WorkspacePanel을 직접 렌더하면 tab/onTabChange, open/onOpenChange를 앱 상태가 제어할 수 있다. 내용은 자기 것으로 유지하고 기본 배치만 빌리려면 WorkspacePanelFrame 안에 임의의 children을 넣는다.

워크스페이스 밖에 마운트하기

sidePanel은 React 노드를 반환하는 일반 render prop이다. 따라서 portal을 쓰면 하단 앱 셸이나 전역 모달 루트처럼 워크스페이스 밖에도 같은 API를 연결할 수 있다:

import { createPortal } from "react-dom";

sidePanel={(api) =>
  createPortal(<MyPanel api={api} />, document.querySelector("#product-tools")!)
}

우리 참조 패널을 그대로 쓰려면 workspacePanel({ onAiRequest, notice }) 한 줄이면 된다 (packages/react/src/chatSlot.tsx). 단 그건 데모 어포던스지 제품 계약이 아니다 — 한국어 문구·카드 레이아웃·상호작용 모델이 전부 우리 것이다. 실제 제품은 자기 패널을 WorkspaceSidePanel 에 대고 그리고, 이 패키지에서는 편집 표면만 가져가는 쪽을 권한다.

styles.css 는 수동 import

@auto-hwp/react 는 CSS-in-JS 가 아니다. import "@auto-hwp/react/styles.css"한 번 넣어야 페이지/ 오버레이/툴바가 스타일된다. 클래스는 네임스페이스드(hw-*)라 호스트 스타일과 충돌하지 않고 오버라이드도 자유다. (sidePanel 에 넣는 패널은 당신 스타일이다 — 참조 채팅 chatSidePanel 을 쓸 때만 이 CSS 가 그 카드까지 그린다.)

"use client" — 호스트가 클라이언트 경계를 친다

@auto-hwp/react 컴포넌트에는 "use client" 지시어가 들어 있지 않다(벤더 중립 — RSC 가 아닌 번들러도 많다). React Server Components 프레임워크(Next App Router 등)에서 쓸 땐 호스트가 워크스페이스를 감싸는 파일 맨 위에 "use client" 를 두거나 dynamic(() => import(...), { ssr: false }) 로 클라이언트 전용 로드한다. 브라우저 전용(wasm/Web Worker/DOM)이므로 SSR 은 반드시 끈다.

SSR 프레임워크: ssr: false

엔진은 window/Worker/WebAssembly 를 쓴다. 서버에서 렌더하면 죽는다. Next 라면 dynamic(() => import("./Workspace"), { ssr: false }), 다른 SSR 프레임워크도 클라이언트 전용 마운트로 감싼다.


4. CSP 헤더 (교차 출처/보안 호스트)

wasm 인스턴스화와 모듈 워커 때문에 CSP 를 쓰는 호스트는 아래를 허용해야 한다:

script-src 'self' 'wasm-unsafe-eval';   # WebAssembly.instantiate (구형 브라우저 대응 시 'unsafe-eval')
worker-src 'self' blob:;                # 모듈 워커 (CDN 기본값은 blob 심을 쓰므로 blob: 필수)
font-src   'self' data:;                # 주입 폰트 @font-face
img-src    'self' data: blob:;          # 이미지 삽입/미리보기
connect-src 'self' <AI 프록시 오리진>;   # onAiRequest 가 POST 하는 서버

CDN 기본값(§2.1)을 쓴다면 두 줄을 더 연다 — 자기 호스팅(§2.2)이면 필요 없다:

script-src  ... https://cdn.jsdelivr.net;   # 워커가 import 하는 index.js / pkg/hwp_wasm.js
connect-src ... https://cdn.jsdelivr.net;   # wasm fetch

SVG 는 문서 파생 신뢰불가 문자열이다 — L3 HwpPageView 가 삽입 전 항상 sanitizeSvg(R7) 를 통과시킨다(<script>/on*/javascript: 제거). 직접 렌더하더라도 sanitizeSvg export 를 반드시 거쳐라.


5. AI 프록시 (R6 — 키는 서버 전용)

@auto-hwp/react 는 LLM/키를 갖지 않는다. onAiRequest(instruction, anchors, ctx)당신의 서버로 위임한다. 서버는 @auto-hwp/ai-protocol(서버·클라 동일 모듈)로 프롬프트/펜스/검증을 조립한다. 정적/비-Next 호스트용 얇은 서버 템플릿: examples/ai-proxy-express(Express). 벤더 교체는 liveIntentsimport("@anthropic-ai/sdk") 한 줄. 키 없으면 결정적 mock 으로 전체 플로우가 완주된다.

const onAiRequest = async (instruction, anchors, ctx) => {
  const res = await fetch("/api/hwp-edit", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ instruction, anchors, docContext: buildDocContext(ctx, anchors) }),
  });
  return (await res.json()).intents ?? [];
};

6. 폰트 (R8 — 번들이 아니라 주입)

@auto-hwp/engine 은 폰트를 하나도 번들하지 않는다. 호스트가 defaultFont={{ family, bytes }}한 벌의 바이트를 넣으면 ① 조판 메트릭 ② PDF 임베드 ③ 화면 @font-face 가 동시에 그 폰트로 맞춰진다. 미주입 상태의 PDF 는 {code:"font_missing"} 를 던진다(silent 빈 글리프 금지). 재배포 가능 폰트(OFL)만 서빙하라 — 함초롬/한컴 계열은 재배포 라이선스가 없다(docs/LICENSE-POLICY.md).

카탈로그 온디맨드 (2026-07-22): fontCatalog={FONT_CATALOG} + fontUrlBase 를 주고 카탈로그 파일들(전부 OFL — Pretendard·Noto Sans/Serif KR 등 8종, fetch-fonts.mjs)을 정적 서빙하면, 리본 서체 피커/AI 가 카탈로그 family 를 지정할 때 워크스페이스가 자동으로 fetch→registerFont→화면 @font-face 까지 수행한다 — 그 서체가 화면과 PDF 에 실서체로 반영된다(엔진의 explicit-family bypass: 등록된 이름과 일치하는 명시 지정은 명조/고딕 대체를 우회, docs/FONT-CATALOG.md). 문서 고유 서체명(함초롬 등)은 종전대로 OFL 대체 렌더.


7. Next.js 특이사항 (참고)

  • Next 16 Turbopack 경고: 참조 앱은 next15.5.x 로 고정한다. 엔진 wasm 중복 방출을 막는 우회가 next.config.mjswebpack 훅(parser.url=false on hwp_wasm.js)에 있는데, Next 16 Turbopack 은 이 webpack() 설정을 무시해 wasm 이 클라이언트 번들에 중복 방출될 수 있다. Next App Router 통합은 INTEGRATION-HANDOVER.md §3 참조.
  • 비-Next(Vite 등)에서는 이 이슈가 없다 — wasm/워커를 애초에 public 정적 에셋으로 서빙하기 때문이다(§2).

8. 이식 검증 (이 레포에서 실검증)

예제의 의존은 레지스트리 발행본이 기본이다(외부 사용자와 같은 경로). 레포 로컬 빌드본으로 바꾸려면 REPO_DEV=1 만 붙인다 — packages/* 를 pack 해 npm install --no-save 로 얹으므로 package.json 선언은 레지스트리 그대로 남고 원복은 npm install 한 번이다.

cd examples/vite-embed
npm install              # 레지스트리 @auto-hwp/* ^0.0.5 (fresh clone 경로)
npm run dev              # predev 훅이 wasm/워커/폰트를 public/ 로 복사(자기호스팅 경로)
npm run test:e2e         # Playwright: 업로드 → 8쪽 SVG → 셀 마킹 → mock 편집 → undo

REPO_DEV=1 npm run dev   # 미발행 변경을 이 예제에서 미리 확인
npm run use-local        # REPO_DEV 없이 강제로 로컬 tarball 얹기