@rhwp/editor 0.7.19 → 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
@@ -81,18 +81,34 @@ const editor = await createEditor(document.getElementById('editor'));
81
81
  | `studioUrl` | `https://edwardkim.github.io/rhwp/` | rhwp-studio HTTP(S) URL. opaque origin은 지원하지 않음 |
82
82
  | `width` | `'100%'` | iframe 너비 |
83
83
  | `height` | `'100%'` | iframe 높이 |
84
+ | `renderer` | `'canvas2d'` | 문서 단위 renderer 요청. `auto`, `canvas2d`, `canvaskit` 중 하나 |
84
85
  | `requestTimeoutMs` | method별 기본값 | 모든 method 제한 시간 override(ms). 일반 10초, load/export 60초 |
85
86
  | `handshakeTimeoutMs` | `1000` | v1 협상 후 legacy 전환까지의 제한 시간(ms) |
86
87
 
87
- ### editor.loadFile(data, fileName?)
88
+ ### editor.loadFile(data, fileName?, options?)
88
89
 
89
90
  HWP 파일을 로드합니다.
90
91
 
91
92
  ```javascript
92
93
  const result = await editor.loadFile(buffer, 'sample.hwp');
93
94
  // result = { pageCount: 5 }
95
+
96
+ // 안내창에서 사용자가 직접 선택하도록 열기
97
+ await editor.loadFile(buffer, 'sample.hwpx', { suppressDialogs: false });
94
98
  ```
95
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
+
96
112
  ### editor.pageCount()
97
113
 
98
114
  현재 문서의 페이지 수를 반환합니다.
@@ -113,6 +129,14 @@ const svg = await editor.getPageSvg(0); // 첫 페이지
113
129
 
114
130
  선택된 renderer와 0부터 시작하는 페이지별 readiness 진단을 반환합니다.
115
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`에서 확인합니다.
116
140
 
117
141
  ```javascript
118
142
  const diagnostics = await editor.getRendererDiagnostics(0);
@@ -169,6 +193,89 @@ if (!verify.recovered || verify.pageCountBefore !== verify.pageCountAfter) {
169
193
  }
170
194
  ```
171
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
+
172
279
  ### editor.destroy()
173
280
 
174
281
  에디터를 제거합니다.
@@ -177,6 +284,43 @@ if (!verify.recovered || verify.pageCountBefore !== verify.pageCountAfter) {
177
284
  editor.destroy();
178
285
  ```
179
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
+
180
324
  ## 디버깅 도구 — rhwpDev
181
325
 
182
326
  iframe 안의 rhwp-studio에는 **개발 모드 전용 디버깅 헬퍼** `rhwpDev`가 내장되어 있습니다. 검색, 커서 이동, 문단 ID 점검 등을 콘솔에서 직접 호출할 수 있습니다.
package/index.d.ts CHANGED
@@ -5,6 +5,8 @@
5
5
  export interface EditorOptions {
6
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%') */
@@ -68,11 +70,61 @@ export interface CanvasKitRendererDiagnostics {
68
70
  localTypefaceCount: number;
69
71
  localTypefaceLoadFailureCount: number;
70
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;
71
122
  }
72
123
 
73
124
  export interface RendererDiagnosticsV1 {
74
125
  schemaVersion: 1;
75
126
  request: {
127
+ /** Legacy v1 request snapshot. Automatic intent is exposed additively through selection. */
76
128
  backend: { backend: 'canvas2d' | 'canvaskit'; source: 'default' | 'url'; requested?: string; unsupportedReason?: string };
77
129
  canvaskitMode: { mode: 'default' | 'compat'; source: 'default' | 'storage' | 'url'; requested?: string; unsupportedReason?: string };
78
130
  canvaskitSurface: { preference: 'auto' | 'webgpu' | 'webgl' | 'software'; requested: string; unsupportedReason?: string };
@@ -82,13 +134,27 @@ export interface RendererDiagnosticsV1 {
82
134
  initializationError: string | null;
83
135
  effectiveBackend: 'canvas2d' | 'canvaskit' | null;
84
136
  backendFallbackReason: string | null;
137
+ /** Added additively to renderer-diagnostics-v1; older Studio builds may omit it. */
138
+ selection?: RendererSelectionV1 | null;
85
139
  page: { index: number; canvaskit: CanvasKitRendererDiagnostics | null };
86
140
  }
87
141
 
142
+ export interface LoadFileOptions {
143
+ /** 미저장 변경 확인 없이 문서 교체 */
144
+ skipUnsavedGuard?: boolean;
145
+ /**
146
+ * 로드 후 안내창(HWPX 검증, 로컬 글꼴 감지) 없이 열기.
147
+ * 임베드 환경에서 안내창의 사용자 선택을 기다리느라 loadFile 응답이
148
+ * 지연/교착되는 것을 방지한다. 기본값은 true이며, 안내창을 표시하려면
149
+ * false를 명시한다.
150
+ */
151
+ suppressDialogs?: boolean;
152
+ }
153
+
88
154
  export declare class RhwpEditor {
89
155
  private constructor();
90
156
  /** HWP 파일을 로드합니다 */
91
- loadFile(data: ArrayBuffer | Uint8Array, fileName?: string): Promise<LoadResult>;
157
+ loadFile(data: ArrayBuffer | Uint8Array, fileName?: string, options?: LoadFileOptions): Promise<LoadResult>;
92
158
  /** 현재 문서의 페이지 수를 반환합니다 */
93
159
  pageCount(): Promise<number>;
94
160
  /** 특정 페이지를 SVG 문자열로 렌더링합니다 */
@@ -105,6 +171,13 @@ export declare class RhwpEditor {
105
171
  getHmlSaveState(): Promise<HmlSaveState>;
106
172
  /** HWP 직렬화 + 자기 재로드 검증 메타데이터 (#178) */
107
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 }>;
108
181
  /** iframe 엘리먼트를 반환합니다 */
109
182
  readonly element: HTMLIFrameElement;
110
183
  /** 에디터를 제거합니다 */
package/index.js CHANGED
@@ -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');
@@ -108,6 +116,11 @@ export class RhwpEditor {
108
116
  *
109
117
  * @param data - HWP 파일의 ArrayBuffer 또는 Uint8Array
110
118
  * @param fileName - 파일 이름 (선택)
119
+ * @param options - 로드 옵션 (선택)
120
+ * @param options.skipUnsavedGuard - 미저장 변경 확인 없이 문서 교체
121
+ * @param options.suppressDialogs - 로드 후 안내창(HWPX 검증, 로컬 글꼴 감지) 없이 열기.
122
+ * 임베드 환경에서 안내창의 사용자 선택을 기다리느라 loadFile 응답이 지연/교착되는
123
+ * 것을 방지한다. 검증 경고는 '그대로 열기'로 처리되고, 글꼴은 웹 대체 글꼴로 표시된다.
111
124
  * @returns { pageCount: number }
112
125
  *
113
126
  * @example
@@ -118,8 +131,13 @@ export class RhwpEditor {
118
131
  * console.log(`${result.pageCount}페이지`);
119
132
  * ```
120
133
  */
121
- async loadFile(data, fileName = 'document.hwp') {
122
- return this._request('loadFile', { data, 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
+ });
123
141
  }
124
142
 
125
143
  /**
@@ -197,6 +215,27 @@ export class RhwpEditor {
197
215
  return this._request('exportHwpVerify');
198
216
  }
199
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
+
200
239
  /**
201
240
  * iframe 엘리먼트를 반환합니다.
202
241
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rhwp/editor",
3
- "version": "0.7.19",
3
+ "version": "0.8.0",
4
4
  "description": "HWP 에디터 웹 컴포넌트 — iframe 기반 임베드",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -11,7 +11,8 @@
11
11
  "files": [
12
12
  "index.js",
13
13
  "index.d.ts",
14
- "transport.js"
14
+ "transport.js",
15
+ "README.md"
15
16
  ],
16
17
  "keywords": [
17
18
  "hwp",
@@ -32,6 +33,10 @@
32
33
  "bugs": {
33
34
  "url": "https://github.com/edwardkim/rhwp/issues"
34
35
  },
36
+ "funding": "https://github.com/sponsors/edwardkim",
35
37
  "license": "MIT",
36
- "author": "Edward Kim"
38
+ "author": "Edward Kim",
39
+ "engines": {
40
+ "node": ">=18.0.0"
41
+ }
37
42
  }
package/transport.js CHANGED
@@ -3,6 +3,7 @@ const CAPABILITIES = [
3
3
  'transferable-array-buffer',
4
4
  'hml-export',
5
5
  'renderer-diagnostics-v1',
6
+ 'notify-saved-v1',
6
7
  ];
7
8
  const LONG_RUNNING_METHODS = new Set([
8
9
  'loadFile', 'exportHwp', 'exportHwpVerify', 'exportHwpx', 'exportHml',