@rhwp/editor 0.7.18 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,6 +8,12 @@
8
8
  웹 페이지에 HWP 에디터를 통째로 임베드합니다.
9
9
  메뉴, 툴바, 서식, 표 편집 — rhwp-studio의 모든 기능을 그대로 사용할 수 있습니다.
10
10
 
11
+ SDK는 지원되는 Studio와 `MessageChannel` v1을 협상해 binary를 transferable로 전송합니다.
12
+ 구버전 Studio에는 기존 `postMessage` protocol로 자동 전환되며 기존 공개 API는 유지됩니다.
13
+ `getRendererDiagnostics()`는 `renderer-diagnostics-v1` capability를 협상한 Studio에서만 사용할 수 있습니다.
14
+ 호스트와 `studioUrl`은 HTTP(S) origin만 지원합니다. `file:`, `data:`, 브라우저 확장처럼
15
+ origin이 `null`이거나 불투명한 환경의 연결은 SDK와 Studio 양쪽에서 거부합니다.
16
+
11
17
  > **[온라인 데모](https://edwardkim.github.io/rhwp/)** 에서 먼저 체험해보세요.
12
18
 
13
19
  ## 설치
@@ -72,19 +78,37 @@ const editor = await createEditor(document.getElementById('editor'));
72
78
 
73
79
  | 옵션 | 기본값 | 설명 |
74
80
  |------|--------|------|
75
- | `studioUrl` | `https://edwardkim.github.io/rhwp/` | rhwp-studio URL |
81
+ | `studioUrl` | `https://edwardkim.github.io/rhwp/` | rhwp-studio HTTP(S) URL. opaque origin은 지원하지 않음 |
76
82
  | `width` | `'100%'` | iframe 너비 |
77
83
  | `height` | `'100%'` | iframe 높이 |
84
+ | `renderer` | `'canvas2d'` | 문서 단위 renderer 요청. `auto`, `canvas2d`, `canvaskit` 중 하나 |
85
+ | `requestTimeoutMs` | method별 기본값 | 모든 method 제한 시간 override(ms). 일반 10초, load/export 60초 |
86
+ | `handshakeTimeoutMs` | `1000` | v1 협상 후 legacy 전환까지의 제한 시간(ms) |
78
87
 
79
- ### editor.loadFile(data, fileName?)
88
+ ### editor.loadFile(data, fileName?, options?)
80
89
 
81
90
  HWP 파일을 로드합니다.
82
91
 
83
92
  ```javascript
84
93
  const result = await editor.loadFile(buffer, 'sample.hwp');
85
94
  // result = { pageCount: 5 }
95
+
96
+ // 안내창에서 사용자가 직접 선택하도록 열기
97
+ await editor.loadFile(buffer, 'sample.hwpx', { suppressDialogs: false });
86
98
  ```
87
99
 
100
+ **options:**
101
+
102
+ | 옵션 | 기본값 | 설명 |
103
+ |------|--------|------|
104
+ | `skipUnsavedGuard` | `false` | 미저장 변경 확인 없이 문서 교체 |
105
+ | `suppressDialogs` | `true` | 로드 후 안내창(HWPX 검증, 로컬 글꼴 감지) 없이 열기 |
106
+
107
+ > `suppressDialogs`를 생략하면 검증 경고는 '그대로 열기'로 처리하고 글꼴은 웹 대체 글꼴로
108
+ > 표시하여 `loadFile` 응답을 즉시 반환합니다. 안내창(HWPX 비표준 lineseg 검증, 로컬 글꼴
109
+ > 감지)에서 사용자가 직접 선택해야 하는 대화형 흐름이 필요하면 `suppressDialogs: false`를
110
+ > 명시하세요.
111
+
88
112
  ### editor.pageCount()
89
113
 
90
114
  현재 문서의 페이지 수를 반환합니다.
@@ -101,6 +125,24 @@ const count = await editor.pageCount();
101
125
  const svg = await editor.getPageSvg(0); // 첫 페이지
102
126
  ```
103
127
 
128
+ ### editor.getRendererDiagnostics(page?)
129
+
130
+ 선택된 renderer와 0부터 시작하는 페이지별 readiness 진단을 반환합니다.
131
+ Studio가 `renderer-diagnostics-v1` capability를 제공하지 않으면 명시적으로 실패합니다.
132
+ `auto`에서는 문서 capability preflight, 실제 선택 이유, fallback 이유, 문서 revision과
133
+ resource generation을 `selection`에서 확인할 수 있습니다. 현재 Studio는 `selection`을
134
+ 반환하지만, 같은 v1 capability를 제공하는 이전 Studio와의 additive 호환을 위해 이 필드는
135
+ 없을 수도 있습니다. `selectionError`는 preflight/리소스/replay 실패를, top-level
136
+ `initializationError`는 Studio 앱 초기화 실패를 나타냅니다. preflight의
137
+ `requiredFontFamilies`는 자동 선택에서 첫 replay 전에 준비해야 하는 문서 폰트 목록입니다.
138
+ 기존 v1 소비자 호환을 위해 top-level `request.backend.backend`는 계속 `canvas2d` 또는
139
+ `canvaskit`만 반환하며, `auto` 요청과 실제 선택 과정은 additive `selection`에서 확인합니다.
140
+
141
+ ```javascript
142
+ const diagnostics = await editor.getRendererDiagnostics(0);
143
+ console.log(diagnostics.schemaVersion, diagnostics.effectiveBackend);
144
+ ```
145
+
104
146
  ### editor.exportHwp()
105
147
 
106
148
  현재 편집 중인 문서를 HWP 바이너리로 내보냅니다.
@@ -151,6 +193,89 @@ if (!verify.recovered || verify.pageCountBefore !== verify.pageCountAfter) {
151
193
  }
152
194
  ```
153
195
 
196
+ ### editor.exportHml()
197
+
198
+ 현재 편집 중인 문서를 HML(XML) 바이너리로 내보냅니다.
199
+
200
+ ```javascript
201
+ const bytes = await editor.exportHml();
202
+ const blob = new Blob([bytes], { type: 'application/xml' });
203
+
204
+ const url = URL.createObjectURL(blob);
205
+ const a = document.createElement('a');
206
+ a.href = url;
207
+ a.download = 'document.hml';
208
+ a.click();
209
+ URL.revokeObjectURL(url);
210
+ ```
211
+
212
+ ### editor.getHmlSaveState()
213
+
214
+ 현재 문서의 HML 저장 가능 여부와 blocker 목록을 반환합니다.
215
+ 저장 가능 여부는 `hmlSavable`로 판정하고, 실패 원인은 `blockers` 배열로 표시하세요.
216
+ `exportHml()`의 오류 문자열을 파싱하지 마세요.
217
+
218
+ ```javascript
219
+ const state = await editor.getHmlSaveState();
220
+ // {
221
+ // sourceFormat: 'hwp',
222
+ // hmlSavable: false,
223
+ // blockers: [
224
+ // {
225
+ // code: 'HML_SOURCE_REQUIRED',
226
+ // xmlPath: '/HWPML',
227
+ // message: 'HML source metadata is required',
228
+ // preserved: false
229
+ // }
230
+ // ]
231
+ // }
232
+
233
+ if (state.hmlSavable) {
234
+ const bytes = await editor.exportHml();
235
+ // ... 저장 진행
236
+ } else {
237
+ for (const item of state.blockers) {
238
+ console.warn(`HML 저장 불가 [${item.code}] ${item.xmlPath} — ${item.message}`);
239
+ }
240
+ }
241
+ ```
242
+
243
+ **반환값:**
244
+
245
+ | 필드 | 타입 | 설명 |
246
+ |------|------|------|
247
+ | `sourceFormat` | `string` | 현재 문서의 원본 형식 — `'hwp'`, `'hwpx'`, `'hml'` |
248
+ | `hmlSavable` | `boolean` | HML로 저장 가능하면 `true` |
249
+ | `blockers` | `HmlSaveBlocker[]` | 저장을 막는 요소 목록. `hmlSavable`이 `true`이면 빈 배열 |
250
+
251
+ **`HmlSaveBlocker`:**
252
+
253
+ | 필드 | 타입 | 설명 |
254
+ |------|------|------|
255
+ | `code` | `string` | blocker 종류 코드 |
256
+ | `xmlPath` | `string` | 해당 요소의 XML 경로 |
257
+ | `message` | `string` | 사람이 읽을 수 있는 사유 |
258
+ | `preserved` | `false` | 항상 `false` — 해당 요소가 HML로 보존되지 않음을 뜻함 |
259
+ ### editor.notifySaved(fileName?)
260
+
261
+ 내보내기 바이트의 영속화(서버 업로드 또는 호스트 핸드오프) 완료를 스튜디오에
262
+ 통지합니다 (#2660). 통지 시 스튜디오는 미저장(dirty) 상태를 해제하고 자동복구
263
+ draft(IndexedDB)를 삭제합니다. **draft 삭제가 완료된 뒤 resolve**되므로, resolve
264
+ 이후 창을 닫아도 안전합니다.
265
+
266
+ ```javascript
267
+ const bytes = await editor.exportHwp();
268
+ await uploadToServer(bytes); // 호스트 저장
269
+ await editor.notifySaved(); // 저장 완료 통지 — dirty 해제 + 복구 draft 삭제
270
+ ```
271
+
272
+ - `fileName` (선택): 호스트가 저장에 사용한 파일 이름. 전달하면 스튜디오의 현재
273
+ 파일 이름이 갱신됩니다.
274
+ - 반환: `{ ok: true, wasDirty: boolean }` — `wasDirty`는 통지 시점에 미저장
275
+ 변경이 있었는지 여부(멱등 호출 관찰용).
276
+ - 스튜디오가 `notify-saved-v1` capability를 광고하지 않으면(구버전 스튜디오 또는
277
+ legacy 폴백 연결) 요청을 보내지 않고 예외를 던집니다.
278
+
154
279
  ### editor.destroy()
155
280
 
156
281
  에디터를 제거합니다.
@@ -159,6 +284,43 @@ if (!verify.recovered || verify.pageCountBefore !== verify.pageCountAfter) {
159
284
  editor.destroy();
160
285
  ```
161
286
 
287
+ ## 저장 계약 — 내보내기와 저장 완료 통지
288
+
289
+ `exportHwp()`/`exportHwpx()`/`exportHml()`은 직렬화 바이트만 반환하며 **문서를
290
+ "저장됨"으로 표시하지 않습니다.** 호스트 저장(업로드)이 실패할 수 있으므로,
291
+ 스튜디오는 호스트가 `notifySaved()`로 확정하기 전까지 자동복구 draft를
292
+ 보존합니다. 통지 없이 종료하면 다음 실행 시 "문서 복구" 안내가 표시됩니다.
293
+
294
+ **iframe 임베드 (업로드 확정 후 통지):**
295
+
296
+ ```javascript
297
+ const bytes = await editor.exportHwp();
298
+ try {
299
+ await uploadToServer(bytes);
300
+ await editor.notifySaved(); // 업로드 "성공 시에만" 호출
301
+ } catch (error) {
302
+ // 통지하지 않음 — 복구 draft가 보존되어 재시도/복구 가능
303
+ }
304
+ ```
305
+
306
+ **팝업 통합 (핸드오프 즉시 통지 후 닫기):**
307
+
308
+ 스튜디오를 `window.open`으로 띄우고 `window.opener.postMessage`로 바이트를
309
+ 넘기는 통합에서는, 핸드오프 시점에 통지하고 닫습니다. 바이트를 수신한 호스트가
310
+ 이후 영속화(재시도 포함)를 책임집니다.
311
+
312
+ ```javascript
313
+ window.opener?.postMessage(
314
+ { action: 'documentExported', content: base64, format: 'hwp' },
315
+ HOST_ORIGIN, // '*' 대신 반드시 명시적 오리진 사용 (문서 유출 방지)
316
+ );
317
+ await editor.notifySaved(); // draft 삭제 완료까지 대기
318
+ window.close();
319
+ ```
320
+
321
+ SDK 없이 스튜디오 페이지 안에서 직접 통합(포크/주입)하는 경우에는 같은 동작의
322
+ `window.rhwpStudio.notifySaved(fileName?)`를 사용할 수 있습니다.
323
+
162
324
  ## 디버깅 도구 — rhwpDev
163
325
 
164
326
  iframe 안의 rhwp-studio에는 **개발 모드 전용 디버깅 헬퍼** `rhwpDev`가 내장되어 있습니다. 검색, 커서 이동, 문단 ID 점검 등을 콘솔에서 직접 호출할 수 있습니다.
@@ -266,7 +428,9 @@ setInterval(() => {
266
428
 
267
429
  ### 기본 동작 — 별도 설정 없이 사용 가능
268
430
 
269
- `@rhwp/editor`는 오픈소스 폰트를 내장하고 있어 **별도 폰트 설정 없이 바로 사용**할 수 있습니다.
431
+ `@rhwp/editor`가 기본으로 연결하는 rhwp-studio 배포본은 오픈소스 폰트를 포함하므로
432
+ **별도 폰트 설정 없이 바로 사용**할 수 있습니다. `@rhwp/editor` 패키지 자체에는 폰트 파일이나
433
+ UI runtime dependency가 포함되지 않습니다.
270
434
 
271
435
  HWP 문서에서 사용된 한컴 전용 폰트(한컴바탕, HY명조 등)는 자동으로 오픈소스 폰트로 폴백됩니다.
272
436
 
@@ -290,7 +454,10 @@ HWP 문서에서 사용된 한컴 전용 폰트(한컴바탕, HY명조 등)는
290
454
 
291
455
  ### 셀프 호스팅 시 폰트
292
456
 
293
- 셀프 호스팅 환경에서는 rhwp-studio 빌드에 포함된 `web/fonts/` 폴더의 오픈소스 폰트가 자동으로 사용됩니다. 추가 폰트를 원하면 `web/fonts/`에 woff2 파일을 추가하고 CSS `@font-face`를 등록하면 됩니다.
457
+ 저장소의 canonical source는 `assets/fonts/`이고, rhwp-studio build는 이를 배포본의 runtime `fonts/`
458
+ 경로로 노출합니다. 셀프 호스팅 서버에서는 이 runtime 경로가 함께 배포되므로 별도 설정 없이
459
+ 오픈소스 폰트를 사용합니다. 추가 폰트는 `assets/fonts/`에 WOFF2를 추가하고
460
+ `rhwp-studio/src/core/font-loader.ts`에 `@font-face`와 로딩 정책을 등록해야 합니다.
294
461
 
295
462
  ## 셀프 호스팅
296
463
 
package/index.d.ts CHANGED
@@ -3,12 +3,18 @@
3
3
  */
4
4
 
5
5
  export interface EditorOptions {
6
- /** rhwp-studio URL (기본: https://edwardkim.github.io/rhwp/) */
6
+ /** rhwp-studio HTTP(S) URL. file:, data:, browser extension 등 opaque origin은 지원하지 않음 */
7
7
  studioUrl?: string;
8
+ /** 문서 단위 renderer 요청. 기본값은 canvas2d이며 auto/CanvasKit 명시 선택도 지원함 */
9
+ renderer?: 'auto' | 'canvas2d' | 'canvaskit';
8
10
  /** iframe 너비 (기본: '100%') */
9
11
  width?: string;
10
12
  /** iframe 높이 (기본: '100%') */
11
13
  height?: string;
14
+ /** 모든 method 요청 제한 시간 override(ms, 기본: 일반 10000, load/export 60000) */
15
+ requestTimeoutMs?: number;
16
+ /** v1 협상 제한 시간(ms, 기본: 1000) */
17
+ handshakeTimeoutMs?: number;
12
18
  }
13
19
 
14
20
  export interface LoadResult {
@@ -26,19 +32,152 @@ export interface HwpVerifyResult {
26
32
  recovered: boolean;
27
33
  }
28
34
 
35
+ export interface HmlSaveBlocker {
36
+ code: string;
37
+ xmlPath: string;
38
+ message: string;
39
+ preserved: false;
40
+ }
41
+
42
+ export interface HmlSaveState {
43
+ sourceFormat: string;
44
+ hmlSavable: boolean;
45
+ blockers: HmlSaveBlocker[];
46
+ }
47
+
48
+ export interface CanvasKitRendererDiagnostics {
49
+ mode: 'default' | 'compat';
50
+ surfacePreference: 'auto' | 'webgpu' | 'webgl' | 'software';
51
+ surfaceBackend: 'default' | 'software' | null;
52
+ surfaceFallbackReason: string | null;
53
+ lastRenderCompleted: boolean;
54
+ lastUnsupportedOps: string[];
55
+ lastExpectedUnsupportedOps: string[];
56
+ lastUnexpectedUnsupportedOps: string[];
57
+ lastRenderError: string | null;
58
+ passesRuntimeReadinessGate: boolean;
59
+ readinessBlockers: Array<'renderNotCompleted' | 'renderError' | 'unexpectedUnsupportedOps' | 'localFontsPending'>;
60
+ hiddenCanvas2dOverlayUsed: false;
61
+ lastRenderDurationMs: number | null;
62
+ renderCount: number;
63
+ imageCacheEntries: number;
64
+ imageCacheLimit: number;
65
+ imageCachePixels: number;
66
+ imageCachePixelLimit: number;
67
+ imageCacheHits: number;
68
+ imageCacheMisses: number;
69
+ imageCacheEvictions: number;
70
+ localTypefaceCount: number;
71
+ localTypefaceLoadFailureCount: number;
72
+ localTypefacePendingCount: number;
73
+ bundledTypefaceCount: number;
74
+ bundledTypefaceLoadFailureCount: number;
75
+ }
76
+
77
+ export interface CanvasKitDocumentPreflightV1 {
78
+ schemaVersion: 1;
79
+ mode: 'default' | 'compat';
80
+ profile: 'fastPreview' | 'screen' | 'print' | 'highQuality';
81
+ status: 'eligible' | 'ineligible' | 'incomplete';
82
+ eligible: boolean;
83
+ complete: boolean;
84
+ pageCount: number;
85
+ scannedPages: number;
86
+ scannedWorkUnits: number;
87
+ limits: {
88
+ maxPages: number;
89
+ maxWorkUnits: number;
90
+ maxBlockers: number;
91
+ maxRequiredFontFamilies: number;
92
+ };
93
+ summary: {
94
+ totalItems: number;
95
+ directItems: number;
96
+ directRequiredItems: number;
97
+ compatOverlayItems: number;
98
+ textFallbackItems: number;
99
+ unsupportedItems: number;
100
+ hiddenOverlayViolations: number;
101
+ };
102
+ blockers: Array<{ pageIndex: number; code: string; opType?: string; detail?: string }>;
103
+ requiredFontFamilies: string[];
104
+ capabilityDigest: string;
105
+ }
106
+
107
+ export interface RendererSelectionV1 {
108
+ schemaVersion: 1;
109
+ request: { backend: 'auto' | 'canvas2d' | 'canvaskit'; source: 'default' | 'url'; requested?: string; unsupportedReason?: string };
110
+ requestedBackend: 'auto' | 'canvas2d' | 'canvaskit';
111
+ effectiveBackend: 'canvas2d' | 'canvaskit';
112
+ selectionReason: string;
113
+ fallbackReason: string | null;
114
+ documentRevision: number;
115
+ resourceGeneration: number;
116
+ renderProfile: 'fastPreview' | 'screen' | 'print' | 'highQuality';
117
+ documentDigest: string | null;
118
+ decisionKey: string;
119
+ preflight: CanvasKitDocumentPreflightV1 | null;
120
+ initializationError: string | null;
121
+ selectionError: string | null;
122
+ }
123
+
124
+ export interface RendererDiagnosticsV1 {
125
+ schemaVersion: 1;
126
+ request: {
127
+ /** Legacy v1 request snapshot. Automatic intent is exposed additively through selection. */
128
+ backend: { backend: 'canvas2d' | 'canvaskit'; source: 'default' | 'url'; requested?: string; unsupportedReason?: string };
129
+ canvaskitMode: { mode: 'default' | 'compat'; source: 'default' | 'storage' | 'url'; requested?: string; unsupportedReason?: string };
130
+ canvaskitSurface: { preference: 'auto' | 'webgpu' | 'webgl' | 'software'; requested: string; unsupportedReason?: string };
131
+ renderProfile: 'fastPreview' | 'screen' | 'print' | 'highQuality';
132
+ } | null;
133
+ initialized: boolean;
134
+ initializationError: string | null;
135
+ effectiveBackend: 'canvas2d' | 'canvaskit' | null;
136
+ backendFallbackReason: string | null;
137
+ /** Added additively to renderer-diagnostics-v1; older Studio builds may omit it. */
138
+ selection?: RendererSelectionV1 | null;
139
+ page: { index: number; canvaskit: CanvasKitRendererDiagnostics | null };
140
+ }
141
+
142
+ export interface LoadFileOptions {
143
+ /** 미저장 변경 확인 없이 문서 교체 */
144
+ skipUnsavedGuard?: boolean;
145
+ /**
146
+ * 로드 후 안내창(HWPX 검증, 로컬 글꼴 감지) 없이 열기.
147
+ * 임베드 환경에서 안내창의 사용자 선택을 기다리느라 loadFile 응답이
148
+ * 지연/교착되는 것을 방지한다. 기본값은 true이며, 안내창을 표시하려면
149
+ * false를 명시한다.
150
+ */
151
+ suppressDialogs?: boolean;
152
+ }
153
+
29
154
  export declare class RhwpEditor {
155
+ private constructor();
30
156
  /** HWP 파일을 로드합니다 */
31
- loadFile(data: ArrayBuffer | Uint8Array, fileName?: string): Promise<LoadResult>;
157
+ loadFile(data: ArrayBuffer | Uint8Array, fileName?: string, options?: LoadFileOptions): Promise<LoadResult>;
32
158
  /** 현재 문서의 페이지 수를 반환합니다 */
33
159
  pageCount(): Promise<number>;
34
160
  /** 특정 페이지를 SVG 문자열로 렌더링합니다 */
35
161
  getPageSvg(page?: number): Promise<string>;
162
+ /** 선택된 renderer와 페이지별 readiness 진단을 반환합니다 */
163
+ getRendererDiagnostics(page?: number): Promise<RendererDiagnosticsV1>;
36
164
  /** 현재 문서를 HWP 바이너리로 내보냅니다 */
37
165
  exportHwp(): Promise<Uint8Array>;
38
166
  /** 현재 문서를 HWPX(ZIP+XML) 바이너리로 내보냅니다 */
39
167
  exportHwpx(): Promise<Uint8Array>;
168
+ /** 현재 문서를 HML(XML) 바이너리로 내보냅니다 */
169
+ exportHml(): Promise<Uint8Array>;
170
+ /** 현재 문서의 HML 저장 가능 여부와 blocker를 반환합니다 */
171
+ getHmlSaveState(): Promise<HmlSaveState>;
40
172
  /** HWP 직렬화 + 자기 재로드 검증 메타데이터 (#178) */
41
173
  exportHwpVerify(): Promise<HwpVerifyResult>;
174
+ /**
175
+ * 내보내기 바이트의 영속화(업로드/핸드오프) 완료를 스튜디오에 통지합니다 (#2660).
176
+ * dirty 해제 + 자동복구 draft 삭제 완료 후 resolve — resolve 이후 창을 닫아도 안전.
177
+ * 업로드 실패 시에는 호출하지 마세요(백업 draft 보존).
178
+ * 스튜디오가 notify-saved-v1 capability를 광고하지 않으면 요청 없이 실패합니다.
179
+ */
180
+ notifySaved(fileName?: string): Promise<{ ok: true; wasDirty: boolean }>;
42
181
  /** iframe 엘리먼트를 반환합니다 */
43
182
  readonly element: HTMLIFrameElement;
44
183
  /** 에디터를 제거합니다 */
package/index.js CHANGED
@@ -9,9 +9,9 @@
9
9
  * 본 제품은 한글과컴퓨터의 한글 문서 파일(.hwp) 공개 문서를 참고하여 개발하였습니다.
10
10
  */
11
11
 
12
- const DEFAULT_STUDIO_URL = 'https://edwardkim.github.io/rhwp/';
12
+ import { EditorTransport } from './transport.js';
13
13
 
14
- let requestId = 0;
14
+ const DEFAULT_STUDIO_URL = 'https://edwardkim.github.io/rhwp/';
15
15
 
16
16
  /**
17
17
  * HWP 에디터를 생성하여 지정된 컨테이너에 마운트합니다.
@@ -36,7 +36,15 @@ export async function createEditor(container, options = {}) {
36
36
  throw new Error(`Container not found: ${container}`);
37
37
  }
38
38
 
39
- const studioUrl = options.studioUrl || DEFAULT_STUDIO_URL;
39
+ let studioUrl = options.studioUrl || DEFAULT_STUDIO_URL;
40
+ if (options.renderer !== undefined) {
41
+ if (!['auto', 'canvas2d', 'canvaskit'].includes(options.renderer)) {
42
+ throw new TypeError(`Unsupported renderer: ${options.renderer}`);
43
+ }
44
+ const resolvedStudioUrl = new URL(studioUrl, document.baseURI);
45
+ resolvedStudioUrl.searchParams.set('renderer', options.renderer);
46
+ studioUrl = resolvedStudioUrl.href;
47
+ }
40
48
 
41
49
  // iframe 생성
42
50
  const iframe = document.createElement('iframe');
@@ -53,9 +61,21 @@ export async function createEditor(container, options = {}) {
53
61
  });
54
62
 
55
63
  // WASM 초기화 대기 (ready 메서드로 확인)
56
- const editor = new RhwpEditor(iframe);
57
- await editor._waitReady();
58
- return editor;
64
+ let transport;
65
+ try {
66
+ transport = new EditorTransport(iframe, studioUrl, {
67
+ requestTimeoutMs: options.requestTimeoutMs,
68
+ handshakeTimeoutMs: options.handshakeTimeoutMs,
69
+ });
70
+ await transport.connect();
71
+ const editor = new RhwpEditor(iframe, transport);
72
+ await editor._waitReady();
73
+ return editor;
74
+ } catch (error) {
75
+ transport?.destroy();
76
+ iframe.remove();
77
+ throw error;
78
+ }
59
79
  }
60
80
 
61
81
  /**
@@ -63,25 +83,10 @@ export async function createEditor(container, options = {}) {
63
83
  *
64
84
  * iframe 내부의 rhwp-studio와 postMessage로 통신합니다.
65
85
  */
66
- class RhwpEditor {
67
- constructor(iframe) {
86
+ export class RhwpEditor {
87
+ constructor(iframe, transport) {
68
88
  this._iframe = iframe;
69
- this._pending = new Map();
70
-
71
- // 응답 수신 리스너
72
- window.addEventListener('message', (e) => {
73
- if (e.data?.type === 'rhwp-response' && e.data.id != null) {
74
- const resolver = this._pending.get(e.data.id);
75
- if (resolver) {
76
- this._pending.delete(e.data.id);
77
- if (e.data.error) {
78
- resolver.reject(new Error(e.data.error));
79
- } else {
80
- resolver.resolve(e.data.result);
81
- }
82
- }
83
- }
84
- });
89
+ this._transport = transport;
85
90
  }
86
91
 
87
92
  /**
@@ -89,21 +94,7 @@ class RhwpEditor {
89
94
  * @internal
90
95
  */
91
96
  _request(method, params = {}) {
92
- return new Promise((resolve, reject) => {
93
- const id = ++requestId;
94
- this._pending.set(id, { resolve, reject });
95
- this._iframe.contentWindow.postMessage(
96
- { type: 'rhwp-request', id, method, params },
97
- '*'
98
- );
99
- // 10초 타임아웃
100
- setTimeout(() => {
101
- if (this._pending.has(id)) {
102
- this._pending.delete(id);
103
- reject(new Error(`Request timeout: ${method}`));
104
- }
105
- }, 10000);
106
- });
97
+ return this._transport.request(method, params);
107
98
  }
108
99
 
109
100
  /** WASM 초기화 완료 대기 @internal */
@@ -125,6 +116,11 @@ class RhwpEditor {
125
116
  *
126
117
  * @param data - HWP 파일의 ArrayBuffer 또는 Uint8Array
127
118
  * @param fileName - 파일 이름 (선택)
119
+ * @param options - 로드 옵션 (선택)
120
+ * @param options.skipUnsavedGuard - 미저장 변경 확인 없이 문서 교체
121
+ * @param options.suppressDialogs - 로드 후 안내창(HWPX 검증, 로컬 글꼴 감지) 없이 열기.
122
+ * 임베드 환경에서 안내창의 사용자 선택을 기다리느라 loadFile 응답이 지연/교착되는
123
+ * 것을 방지한다. 검증 경고는 '그대로 열기'로 처리되고, 글꼴은 웹 대체 글꼴로 표시된다.
128
124
  * @returns { pageCount: number }
129
125
  *
130
126
  * @example
@@ -135,9 +131,13 @@ class RhwpEditor {
135
131
  * console.log(`${result.pageCount}페이지`);
136
132
  * ```
137
133
  */
138
- async loadFile(data, fileName = 'document.hwp') {
139
- const bytes = data instanceof ArrayBuffer ? Array.from(new Uint8Array(data)) : Array.from(data);
140
- return this._request('loadFile', { data: bytes, fileName });
134
+ async loadFile(data, fileName = 'document.hwp', options = {}) {
135
+ return this._request('loadFile', {
136
+ data,
137
+ fileName,
138
+ skipUnsavedGuard: options.skipUnsavedGuard === true,
139
+ suppressDialogs: options.suppressDialogs === undefined || options.suppressDialogs === true,
140
+ });
141
141
  }
142
142
 
143
143
  /**
@@ -157,6 +157,24 @@ class RhwpEditor {
157
157
  return this._request('getPageSvg', { page });
158
158
  }
159
159
 
160
+ /**
161
+ * 선택된 renderer와 페이지별 CanvasKit readiness 진단을 반환합니다.
162
+ * @param page - 0부터 시작하는 페이지 번호
163
+ */
164
+ async getRendererDiagnostics(page = 0) {
165
+ if (!Number.isSafeInteger(page) || page < 0) {
166
+ throw new TypeError('page must be a non-negative safe integer');
167
+ }
168
+ if (!this._transport.supports('renderer-diagnostics-v1')) {
169
+ throw new Error('Renderer diagnostics v1 is not supported by this Studio');
170
+ }
171
+ const result = await this._request('getRendererDiagnostics', { page });
172
+ if (result?.schemaVersion !== 1 || result?.page?.index !== page) {
173
+ throw new Error('Studio returned invalid renderer diagnostics v1');
174
+ }
175
+ return result;
176
+ }
177
+
160
178
  /**
161
179
  * 현재 문서를 HWP 바이너리로 내보냅니다.
162
180
  * @returns {Promise<Uint8Array>} HWP 파일 bytes
@@ -175,6 +193,17 @@ class RhwpEditor {
175
193
  return result instanceof Uint8Array ? result : new Uint8Array(result || []);
176
194
  }
177
195
 
196
+ /** 현재 문서를 HML(XML) 바이너리로 내보냅니다. */
197
+ async exportHml() {
198
+ const result = await this._request('exportHml');
199
+ return result instanceof Uint8Array ? result : new Uint8Array(result || []);
200
+ }
201
+
202
+ /** 현재 문서의 HML 저장 가능 여부와 blocker를 반환합니다. */
203
+ async getHmlSaveState() {
204
+ return this._request('getHmlSaveState');
205
+ }
206
+
178
207
  /**
179
208
  * HWP 직렬화 + 자기 재로드 검증 메타데이터를 반환합니다 (#178).
180
209
  *
@@ -186,6 +215,27 @@ class RhwpEditor {
186
215
  return this._request('exportHwpVerify');
187
216
  }
188
217
 
218
+ /**
219
+ * 내보내기 바이트의 영속화(업로드/핸드오프) 완료를 스튜디오에 통지합니다.
220
+ *
221
+ * dirty 상태를 해제하고 자동복구 draft의 IndexedDB 삭제 "완료"까지 기다린 뒤
222
+ * resolve합니다 — resolve 이후 창을 닫아도 안전합니다. 업로드 실패 시에는
223
+ * 호출하지 마세요(백업 draft가 보존되어야 합니다).
224
+ *
225
+ * 스튜디오가 `notify-saved-v1` capability를 광고하지 않으면(구버전 또는
226
+ * legacy 폴백 연결) 요청을 보내지 않고 명시적으로 실패합니다.
227
+ *
228
+ * @param fileName - 호스트가 저장에 사용한 파일 이름 (선택)
229
+ * @returns {Promise<{ ok: true, wasDirty: boolean }>}
230
+ */
231
+ async notifySaved(fileName) {
232
+ if (!this._transport.supports('notify-saved-v1')) {
233
+ throw new Error('notifySaved is not supported by this Studio');
234
+ }
235
+ const params = typeof fileName === 'string' && fileName.length > 0 ? { fileName } : {};
236
+ return this._request('notifySaved', params);
237
+ }
238
+
189
239
  /**
190
240
  * iframe 엘리먼트를 반환합니다.
191
241
  */
@@ -197,7 +247,7 @@ class RhwpEditor {
197
247
  * 에디터를 제거합니다.
198
248
  */
199
249
  destroy() {
250
+ this._transport.destroy();
200
251
  this._iframe.remove();
201
- this._pending.clear();
202
252
  }
203
253
  }
package/package.json CHANGED
@@ -1,13 +1,18 @@
1
1
  {
2
2
  "name": "@rhwp/editor",
3
- "version": "0.7.18",
3
+ "version": "0.8.0",
4
4
  "description": "HWP 에디터 웹 컴포넌트 — iframe 기반 임베드",
5
5
  "type": "module",
6
6
  "main": "index.js",
7
7
  "types": "index.d.ts",
8
+ "scripts": {
9
+ "test": "node --test tests/*.test.mjs"
10
+ },
8
11
  "files": [
9
12
  "index.js",
10
- "index.d.ts"
13
+ "index.d.ts",
14
+ "transport.js",
15
+ "README.md"
11
16
  ],
12
17
  "keywords": [
13
18
  "hwp",
@@ -28,6 +33,10 @@
28
33
  "bugs": {
29
34
  "url": "https://github.com/edwardkim/rhwp/issues"
30
35
  },
36
+ "funding": "https://github.com/sponsors/edwardkim",
31
37
  "license": "MIT",
32
- "author": "Edward Kim"
38
+ "author": "Edward Kim",
39
+ "engines": {
40
+ "node": ">=18.0.0"
41
+ }
33
42
  }
package/transport.js ADDED
@@ -0,0 +1,239 @@
1
+ const PROTOCOL_VERSION = 1;
2
+ const CAPABILITIES = [
3
+ 'transferable-array-buffer',
4
+ 'hml-export',
5
+ 'renderer-diagnostics-v1',
6
+ 'notify-saved-v1',
7
+ ];
8
+ const LONG_RUNNING_METHODS = new Set([
9
+ 'loadFile', 'exportHwp', 'exportHwpVerify', 'exportHwpx', 'exportHml',
10
+ ]);
11
+
12
+ export function requestTimeoutFor(method, configuredTimeout) {
13
+ if (configuredTimeout != null) return configuredTimeout;
14
+ return LONG_RUNNING_METHODS.has(method) ? 60000 : 10000;
15
+ }
16
+
17
+ function sessionId() {
18
+ const secureRandom = globalThis.crypto;
19
+ if (typeof secureRandom?.randomUUID === 'function') return secureRandom.randomUUID();
20
+ if (typeof secureRandom?.getRandomValues !== 'function') {
21
+ throw new Error('Secure random generation is unavailable');
22
+ }
23
+ const bytes = secureRandom.getRandomValues(new Uint8Array(16));
24
+ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
25
+ }
26
+
27
+ function copiedBinary(value) {
28
+ if (value instanceof ArrayBuffer) return new Uint8Array(value).slice();
29
+ if (ArrayBuffer.isView(value)) {
30
+ return new Uint8Array(value.buffer, value.byteOffset, value.byteLength).slice();
31
+ }
32
+ return null;
33
+ }
34
+
35
+ function prepareParams(params) {
36
+ const data = copiedBinary(params?.data);
37
+ if (!data) return { params, transfer: [] };
38
+ return { params: { ...params, data }, transfer: [data.buffer] };
39
+ }
40
+
41
+ function isResponseEnvelope(message, legacy, sessionId) {
42
+ if (message?.type !== 'rhwp-response' || !Number.isSafeInteger(message.id)) return false;
43
+ if (legacy) return true;
44
+ if (message.version !== PROTOCOL_VERSION || message.sessionId !== sessionId) return false;
45
+ const hasResult = Object.prototype.hasOwnProperty.call(message, 'result');
46
+ const hasError = Object.prototype.hasOwnProperty.call(message, 'error');
47
+ if (hasResult === hasError) return false;
48
+ return !hasError || (typeof message.error?.code === 'string'
49
+ && typeof message.error?.message === 'string');
50
+ }
51
+
52
+ export class EditorTransport {
53
+ constructor(iframe, studioUrl, options = {}) {
54
+ this._iframe = iframe;
55
+ const targetUrl = new URL(studioUrl, globalThis.location?.href);
56
+ if (targetUrl.protocol !== 'http:' && targetUrl.protocol !== 'https:') {
57
+ throw new Error('studioUrl must use HTTP(S)');
58
+ }
59
+ this._targetOrigin = targetUrl.origin;
60
+ this._window = options.window || window;
61
+ this._requestTimeoutMs = options.requestTimeoutMs;
62
+ this._handshakeTimeoutMs = options.handshakeTimeoutMs ?? 1000;
63
+ this._sessionId = sessionId();
64
+ this._nextId = 0;
65
+ this._pending = new Map();
66
+ this._port = null;
67
+ this._peerCapabilities = new Set();
68
+ this._legacy = false;
69
+ this._destroyed = false;
70
+ this._onLegacyMessage = (event) => this._handleLegacyMessage(event);
71
+ }
72
+
73
+ connect() {
74
+ if (this._destroyed) return Promise.reject(new Error('Editor destroyed'));
75
+ const channel = new MessageChannel();
76
+ this._port = channel.port1;
77
+ this._port.onmessage = (event) => this._handlePortMessage(event.data);
78
+ this._port.start();
79
+ return new Promise((resolve, reject) => {
80
+ this._connectResolve = resolve;
81
+ this._connectReject = reject;
82
+ this._connectTimer = setTimeout(() => this._useLegacy(), this._handshakeTimeoutMs);
83
+ this._iframe.contentWindow.postMessage({
84
+ type: 'rhwp-connect', version: PROTOCOL_VERSION, sessionId: this._sessionId,
85
+ capabilities: CAPABILITIES,
86
+ }, this._targetOrigin, [channel.port2]);
87
+ });
88
+ }
89
+
90
+ request(method, params = {}) {
91
+ if (this._destroyed) return Promise.reject(new Error('Editor destroyed'));
92
+ const id = ++this._nextId;
93
+ const prepared = prepareParams(params);
94
+ return new Promise((resolve, reject) => {
95
+ const timeout = setTimeout(() => {
96
+ this._pending.delete(id);
97
+ reject(new Error(`Request timeout: ${method}`));
98
+ }, requestTimeoutFor(method, this._requestTimeoutMs));
99
+ this._pending.set(id, { resolve, reject, timeout });
100
+ try {
101
+ this._send({
102
+ type: 'rhwp-request', version: PROTOCOL_VERSION,
103
+ sessionId: this._sessionId, id, method, params: prepared.params,
104
+ }, prepared.transfer);
105
+ } catch (error) {
106
+ clearTimeout(timeout);
107
+ this._pending.delete(id);
108
+ reject(error);
109
+ }
110
+ });
111
+ }
112
+
113
+ supports(capability) {
114
+ return this._peerCapabilities.has(capability);
115
+ }
116
+
117
+ destroy() {
118
+ if (this._destroyed) return;
119
+ this._destroyed = true;
120
+ clearTimeout(this._connectTimer);
121
+ this._port && (this._port.onmessage = null);
122
+ this._port?.close();
123
+ this._rejectConnect(new Error('Editor destroyed'));
124
+ this._window.removeEventListener('message', this._onLegacyMessage);
125
+ for (const pending of this._pending.values()) {
126
+ clearTimeout(pending.timeout);
127
+ pending.reject(new Error('Editor destroyed'));
128
+ }
129
+ this._pending.clear();
130
+ }
131
+
132
+ _send(message, transfer) {
133
+ if (this._legacy) {
134
+ const { version, sessionId: ignored, ...legacyMessage } = message;
135
+ this._iframe.contentWindow.postMessage(legacyMessage, this._targetOrigin, transfer);
136
+ return;
137
+ }
138
+ this._port.postMessage(message, transfer);
139
+ }
140
+
141
+ _handlePortMessage(message) {
142
+ if (message?.type === 'rhwp-connected'
143
+ && message.version === PROTOCOL_VERSION
144
+ && message.sessionId === this._sessionId
145
+ && message.capabilities?.includes('transferable-array-buffer')) {
146
+ clearTimeout(this._connectTimer);
147
+ this._peerCapabilities = new Set(message.capabilities);
148
+ const resolve = this._connectResolve;
149
+ this._connectResolve = null;
150
+ this._connectReject = null;
151
+ resolve?.();
152
+ return;
153
+ }
154
+ if (message?.type === 'rhwp-connect-error'
155
+ && message.version === PROTOCOL_VERSION
156
+ && message.sessionId === this._sessionId
157
+ && typeof message.error?.message === 'string') {
158
+ const error = new Error(message.error.message);
159
+ error.code = message.error.code;
160
+ error.supportedVersions = message.error.supportedVersions;
161
+ this._rejectConnect(error);
162
+ return;
163
+ }
164
+ this._handleResponse(message);
165
+ }
166
+
167
+ _handleLegacyMessage(event) {
168
+ if (event.source !== this._iframe.contentWindow || event.origin !== this._targetOrigin) return;
169
+ this._handleResponse(event.data, true);
170
+ }
171
+
172
+ _handleResponse(message, legacy = false) {
173
+ if (legacy) {
174
+ if (!isResponseEnvelope(message, true, this._sessionId)) return;
175
+ } else if (message?.type !== 'rhwp-response'
176
+ || !Number.isSafeInteger(message.id)
177
+ || message.sessionId !== this._sessionId) {
178
+ return;
179
+ }
180
+ const pending = this._pending.get(message.id);
181
+ if (!pending) return;
182
+ if (!legacy && Number.isSafeInteger(message.version)
183
+ && message.version !== PROTOCOL_VERSION) {
184
+ this._rejectPendingResponse(message.id, pending, 'UNSUPPORTED_VERSION',
185
+ `Unsupported embed protocol version: ${message.version}`, [PROTOCOL_VERSION]);
186
+ return;
187
+ }
188
+ if (!isResponseEnvelope(message, legacy, this._sessionId)) {
189
+ setTimeout(() => this._rejectPendingResponse(
190
+ message.id, pending, 'INVALID_RESPONSE', 'Invalid response envelope',
191
+ ), 0);
192
+ return;
193
+ }
194
+ this._pending.delete(message.id);
195
+ clearTimeout(pending.timeout);
196
+ if (message.error) {
197
+ const error = new Error(message.error.message || message.error);
198
+ if (message.error.code) error.code = message.error.code;
199
+ pending.reject(error);
200
+ }
201
+ else pending.resolve(message.result);
202
+ }
203
+
204
+ _rejectPendingResponse(id, pending, code, message, supportedVersions) {
205
+ if (this._pending.get(id) !== pending) return;
206
+ this._pending.delete(id);
207
+ clearTimeout(pending.timeout);
208
+ const error = new Error(message);
209
+ error.code = code;
210
+ if (supportedVersions) error.supportedVersions = supportedVersions;
211
+ pending.reject(error);
212
+ }
213
+
214
+ _rejectConnect(error) {
215
+ if (!this._connectReject) return;
216
+ clearTimeout(this._connectTimer);
217
+ this._port && (this._port.onmessage = null);
218
+ this._port?.close();
219
+ this._port = null;
220
+ const reject = this._connectReject;
221
+ this._connectResolve = null;
222
+ this._connectReject = null;
223
+ reject(error);
224
+ }
225
+
226
+ _useLegacy() {
227
+ if (this._destroyed || !this._connectResolve) return;
228
+ this._port && (this._port.onmessage = null);
229
+ this._port?.close();
230
+ this._port = null;
231
+ this._legacy = true;
232
+ this._peerCapabilities.clear();
233
+ this._window.addEventListener('message', this._onLegacyMessage);
234
+ const resolve = this._connectResolve;
235
+ this._connectResolve = null;
236
+ this._connectReject = null;
237
+ resolve();
238
+ }
239
+ }