omni-viewer-core 0.4.0 → 0.5.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
@@ -63,6 +63,34 @@ Viewers follow the same pattern — a `mount*Viewer(input, container, ctx,
63
63
  deps)` entry per format, plus `self-loading` variants that dynamically import
64
64
  their own dependencies for hosts that don't want to wire them manually.
65
65
 
66
+ ### PDF host integration
67
+
68
+ The PDF viewer asks `ctx.assets.resolveAssetUrl` for the exact key
69
+ `assets/pdfjs/pdf.worker.min.mjs`. The published package also exposes that
70
+ worker at `omni-viewer-core/assets/pdfjs/pdf.worker.min.mjs`; a host may return
71
+ its own compatible `pdfjs-dist` worker URL instead, or pass `workerSrc` in the
72
+ PDF mount options. `isEvalSupported` defaults to `false` for CSP-safe hosts.
73
+
74
+ PDF mount options are additive and optional. `saveMode` is `hybrid` by default
75
+ (editable text/markup sidecar; signatures and deleted pages are permanent) and
76
+ may be set to `flattened` for smaller output. `toolbarActions` adds host-owned
77
+ buttons without exposing platform APIs to core. `zoomLevels`, `maxMergeBytes`,
78
+ and `onSaveAsComplete` configure navigation and host save behavior.
79
+
80
+ Large save and merge work can be delegated through the optional
81
+ `PdfViewerDeps.processing` service. VS Code extension-host and Web Worker
82
+ adapters can implement `buildPdf` and/or `mergePdfs`; each receives an
83
+ `AbortSignal` and progress callback. When the service is absent, the default
84
+ `auto` mode preserves the browser `pdf-lib` fallback. `host` requires the
85
+ service and `browser` forces the fallback. `PdfViewerHandle.operation` reports
86
+ running, succeeded, failed, and cancelled states, and `cancelOperation()`
87
+ requests cancellation.
88
+
89
+ `FileSaveService.saveFile` may continue resolving `void` for compatibility.
90
+ New adapters should return `{ status: 'cancelled' }` when a Save As picker is
91
+ dismissed, or `{ status: 'saved', fileName?, uri? }` after any host post-save
92
+ work (for example opening the new file or showing a notification) completes.
93
+
66
94
  Parsers never throw on malformed input; they return a
67
95
  `ParseOutcome` with a typed failure and diagnostics, and they enforce
68
96
  resource limits (input size, cell/row/entry counts, declared decompressed
@@ -27,11 +27,23 @@ export type LogLevel = 'info' | 'warn' | 'error';
27
27
  export interface LoggerService {
28
28
  log(level: LogLevel, message: string): void;
29
29
  }
30
+ export type FileSaveResult = {
31
+ status: 'saved';
32
+ fileName?: string;
33
+ uri?: string;
34
+ } | {
35
+ status: 'cancelled';
36
+ };
30
37
  export interface FileSaveService {
31
38
  /** Creates a new file. Implementations present the platform's save
32
39
  * destination picker (or browser-equivalent download prompt) so the
33
- * user chooses the target path and may change the suggested name. */
34
- saveFile(name: string, data: Uint8Array, mimeType: string): Promise<void>;
40
+ * user chooses the target path and may change the suggested name.
41
+ *
42
+ * Existing implementations may continue returning `void` (treated as a
43
+ * successful save). New implementations should return `cancelled` when the
44
+ * picker is dismissed, and may open the saved file or show host UI before
45
+ * resolving `saved`. */
46
+ saveFile(name: string, data: Uint8Array, mimeType: string): Promise<void | FileSaveResult>;
35
47
  }
36
48
  export interface ClipboardService {
37
49
  writeText(text: string): Promise<void>;
@@ -142,6 +142,7 @@ export const CATALOG_EN = {
142
142
  'diag.yaml.node-limit': 'The YAML node limit was reached.',
143
143
  'diag.yaml.depth-limit': 'The YAML nesting depth limit was reached.',
144
144
  'diag.yaml.alias-limit': 'The YAML alias limit was reached.',
145
+ 'diag.yaml.duplicate-key': 'Duplicate key "{key}"; the last value is used.',
145
146
  'structured.tree': 'Tree', 'structured.flat': 'Flat', 'structured.json': 'JSON', 'structured.raw': 'Raw', 'structured.expandAll': 'Expand all',
146
147
  'structured.collapseAll': 'Collapse all', 'structured.expand': 'Expand', 'structured.collapse': 'Collapse',
147
148
  'structured.search': 'Search', 'structured.copy': 'Copy', 'structured.copyPath': 'Path',
@@ -340,6 +341,13 @@ export const CATALOG_EN = {
340
341
  'pdf.annotationColor': 'Text color',
341
342
  'pdf.save': 'Save',
342
343
  'pdf.saveAs': 'Save as',
344
+ 'pdf.saving': 'Saving PDF…',
345
+ 'pdf.operationCancelled': 'PDF operation cancelled.',
346
+ 'pdf.workerLoadFailed': 'Unable to load the PDF worker asset.',
347
+ 'pdf.saveModeHybrid': 'Editable',
348
+ 'pdf.saveModeHybridTitle': 'Hybrid save: text markup remains editable; signatures and deleted pages cannot be recovered.',
349
+ 'pdf.saveModeFlattened': 'Compact',
350
+ 'pdf.saveModeFlattenedTitle': 'Flattened save: smaller output without an editable sidecar.',
343
351
  'pdf.merge': 'Merge PDF',
344
352
  'pdf.merging': 'Merging PDF…',
345
353
  'pdf.mergeComplete': 'PDF merged. Save or Save as to keep the changes.',
@@ -146,6 +146,7 @@ export const CATALOG_JA = {
146
146
  'diag.yaml.node-limit': 'YAMLノードの上限に達しました。',
147
147
  'diag.yaml.depth-limit': 'YAMLのネスト深度上限に達しました。',
148
148
  'diag.yaml.alias-limit': 'YAMLエイリアスの上限に達しました。',
149
+ 'diag.yaml.duplicate-key': 'キー「{key}」が重複しています。最後の値が使用されます。',
149
150
  'structured.tree': 'ツリー', 'structured.flat': 'フラット', 'structured.json': 'JSON', 'structured.raw': 'RAW', 'structured.expandAll': 'すべて展開',
150
151
  'structured.collapseAll': 'すべて折りたたむ', 'structured.expand': '展開', 'structured.collapse': '折りたたむ',
151
152
  'structured.search': '検索', 'structured.copy': 'コピー', 'structured.copyPath': 'パス',
@@ -247,7 +248,14 @@ export const CATALOG_JA = {
247
248
  'pdf.twoPagesEven': '見開き(偶数ページを左)', 'pdf.matchTheme': 'テーマの色に合わせる',
248
249
  'pdf.resetPages': 'ページをリセット', 'pdf.annotationText': 'テキストを追加',
249
250
  'pdf.annotationSize': 'テキストサイズ', 'pdf.annotationColor': 'テキストの色',
250
- 'pdf.save': '保存', 'pdf.saveAs': '名前を付けて保存', 'pdf.merge': 'PDFを結合',
251
+ 'pdf.save': '保存', 'pdf.saveAs': '名前を付けて保存', 'pdf.saving': 'PDFを保存中…',
252
+ 'pdf.operationCancelled': 'PDFの処理をキャンセルしました。',
253
+ 'pdf.workerLoadFailed': 'PDF Workerアセットを読み込めません。',
254
+ 'pdf.saveModeHybrid': '編集可能',
255
+ 'pdf.saveModeHybridTitle': 'ハイブリッド保存:テキストマークアップは再編集できます。署名と削除したページは復元できません。',
256
+ 'pdf.saveModeFlattened': 'コンパクト',
257
+ 'pdf.saveModeFlattenedTitle': 'フラット化保存:編集用サイドカーを含まない小さいファイルです。',
258
+ 'pdf.merge': 'PDFを結合',
251
259
  'pdf.merging': 'PDFを結合中…', 'pdf.mergeComplete': 'PDFを結合しました。変更を保持するには保存してください。',
252
260
  'pdf.mergeFailed': 'このPDFを結合できません。', 'pdf.signature': '署名', 'pdf.signatureColor': '署名の色',
253
261
  'pdf.signatureClear': 'クリア', 'pdf.signatureUse': '署名を使用',
@@ -142,6 +142,7 @@ export const CATALOG_KO = {
142
142
  'diag.yaml.node-limit': 'YAML 노드 한도에 도달했습니다.',
143
143
  'diag.yaml.depth-limit': 'YAML 중첩 깊이 한도에 도달했습니다.',
144
144
  'diag.yaml.alias-limit': 'YAML 별칭 한도에 도달했습니다.',
145
+ 'diag.yaml.duplicate-key': '중복된 키 "{key}"; 마지막 값이 사용됩니다.',
145
146
  'structured.tree': '트리', 'structured.flat': '평면', 'structured.json': 'JSON', 'structured.raw': '원시', 'structured.expandAll': '모두 펼치기',
146
147
  'structured.collapseAll': '모두 접기', 'structured.expand': '펼치기', 'structured.collapse': '접기',
147
148
  'structured.search': '검색', 'structured.copy': '복사', 'structured.copyPath': '경로',
@@ -340,6 +341,13 @@ export const CATALOG_KO = {
340
341
  'pdf.annotationColor': '텍스트 색상',
341
342
  'pdf.save': '저장',
342
343
  'pdf.saveAs': '다른 이름으로 저장',
344
+ 'pdf.saving': 'PDF 저장 중…',
345
+ 'pdf.operationCancelled': 'PDF 작업이 취소되었습니다.',
346
+ 'pdf.workerLoadFailed': 'PDF Worker 자산을 불러올 수 없습니다.',
347
+ 'pdf.saveModeHybrid': '재편집 가능',
348
+ 'pdf.saveModeHybridTitle': '하이브리드 저장: 텍스트 마크업은 재편집할 수 있으며 서명과 삭제한 페이지는 복구할 수 없습니다.',
349
+ 'pdf.saveModeFlattened': '용량 우선',
350
+ 'pdf.saveModeFlattenedTitle': '평면화 저장: 편집용 sidecar 없이 더 작은 파일로 저장합니다.',
343
351
  'pdf.merge': 'PDF 병합',
344
352
  'pdf.merging': 'PDF 병합 중…',
345
353
  'pdf.mergeComplete': 'PDF가 병합되었습니다. 저장 또는 다른 이름으로 저장하여 변경 사항을 유지하세요.',
@@ -134,6 +134,7 @@ export const CATALOG_ZH_CN = {
134
134
  'diag.yaml.document-limit': '仅解析了前 {count} 个 YAML 文档。',
135
135
  'diag.yaml.node-limit': '已达到 YAML 节点限制。', 'diag.yaml.depth-limit': '已达到 YAML 嵌套深度限制。',
136
136
  'diag.yaml.alias-limit': '已达到 YAML 别名限制。',
137
+ 'diag.yaml.duplicate-key': '键 "{key}" 重复;将使用最后一个值。',
137
138
  'structured.tree': '树', 'structured.flat': '平铺', 'structured.json': 'JSON', 'structured.raw': '原始',
138
139
  'structured.expandAll': '全部展开', 'structured.collapseAll': '全部折叠',
139
140
  'structured.expand': '展开', 'structured.collapse': '折叠', 'structured.search': '搜索',
@@ -230,7 +231,13 @@ export const CATALOG_ZH_CN = {
230
231
  'pdf.singlePage': '单页', 'pdf.twoPagesOdd': '双页(奇数页在左)', 'pdf.twoPagesEven': '双页(偶数页在左)',
231
232
  'pdf.matchTheme': '匹配主题颜色', 'pdf.resetPages': '重置页面',
232
233
  'pdf.annotationText': '添加文本', 'pdf.annotationSize': '文本大小', 'pdf.annotationColor': '文本颜色',
233
- 'pdf.save': '保存', 'pdf.saveAs': '另存为', 'pdf.merge': '合并 PDF', 'pdf.merging': '正在合并 PDF…',
234
+ 'pdf.save': '保存', 'pdf.saveAs': '另存为', 'pdf.saving': '正在保存 PDF…',
235
+ 'pdf.operationCancelled': 'PDF 操作已取消。', 'pdf.workerLoadFailed': '无法加载 PDF Worker 资源。',
236
+ 'pdf.saveModeHybrid': '可重新编辑',
237
+ 'pdf.saveModeHybridTitle': '混合保存:文本标记可重新编辑;签名和已删除页面无法恢复。',
238
+ 'pdf.saveModeFlattened': '精简',
239
+ 'pdf.saveModeFlattenedTitle': '扁平化保存:不含编辑 sidecar,文件更小。',
240
+ 'pdf.merge': '合并 PDF', 'pdf.merging': '正在合并 PDF…',
234
241
  'pdf.mergeComplete': 'PDF 已合并。请保存或另存为以保留更改。', 'pdf.mergeFailed': '无法合并此 PDF。',
235
242
  'pdf.signature': '签名', 'pdf.signatureColor': '签名颜色', 'pdf.signatureClear': '清除',
236
243
  'pdf.signatureUse': '使用签名', 'pdf.signaturePlace': '点击页面以放置签名。',
@@ -18,6 +18,7 @@ export function parseYaml(input, options = {}) {
18
18
  const ast = options.deps.parse(text);
19
19
  const maxDocs = options.limits?.maxDocuments ?? 100;
20
20
  const docs = [];
21
+ const duplicates = [];
21
22
  for (let i = 0; i < Math.min(ast.length, maxDocs); i++) {
22
23
  if (options.signal?.aborted)
23
24
  return finish(docs.length ? { status: 'partial', document: { text, documents: docs }, diagnostics: [...diagnostics, { severity: 'warning', code: 'yaml.aborted', messageKey: 'diag.aborted' }] } : { status: 'failed', failure: { code: 'aborted', retryable: true, messageKey: 'diag.aborted' }, diagnostics });
@@ -28,11 +29,13 @@ export function parseYaml(input, options = {}) {
28
29
  diagnostics.push({ severity: 'error', code: 'yaml.invalid', messageKey: 'diag.yaml.invalid', location: pos ? `line:${pos.line}:${pos.col}` : `document:${i + 1}` });
29
30
  }
30
31
  const node = options.deps.normalize ? options.deps.normalize(sourceDoc, i) : normalizeUnknown(sourceDoc, `$doc[${i}]`);
31
- const violation = validateTree(node, options.limits?.maxEntries ?? 100_000, options.limits?.maxDepth ?? 100, options.limits?.maxAliases ?? 10_000);
32
+ const violation = validateTree(node, options.limits?.maxEntries ?? 100_000, options.limits?.maxDepth ?? 100, options.limits?.maxAliases ?? 10_000, duplicates);
32
33
  if (violation)
33
34
  diagnostics.push({ severity: 'warning', code: violation, messageKey: `diag.${violation}`, location: `document:${i + 1}` });
34
35
  docs.push(node);
35
36
  }
37
+ for (const duplicate of duplicates)
38
+ diagnostics.push({ severity: 'warning', code: 'yaml.duplicate-key', messageKey: 'diag.yaml.duplicate-key', args: { key: duplicate.key }, location: duplicate.path });
36
39
  if (ast.length > maxDocs)
37
40
  diagnostics.push({ severity: 'warning', code: 'yaml.document-limit', messageKey: 'diag.yaml.document-limit', args: { count: maxDocs } });
38
41
  return finish(diagnostics.length ? { status: 'partial', document: { text, documents: docs }, diagnostics } : { status: 'ok', document: { text, documents: docs }, diagnostics });
@@ -44,7 +47,8 @@ export function parseYaml(input, options = {}) {
44
47
  function normalizeUnknown(value, path, key = '$') { if (Array.isArray(value))
45
48
  return { kind: 'seq', key, path, children: value.map((v, i) => normalizeUnknown(v, `${path}[${i}]`, String(i))) }; if (value !== null && typeof value === 'object')
46
49
  return { kind: 'map', key, path, children: Object.entries(value).map(([k, v]) => normalizeUnknown(v, `${path}.${k}`, k)) }; return { kind: 'scalar', key, path, value: value, raw: String(value) }; }
47
- function validateTree(root, maxNodes, maxDepth, maxAliases) { let nodes = 0, aliases = 0; const stack = [{ node: root, depth: 0 }]; while (stack.length) {
50
+ const MAX_DUPLICATE_KEY_DIAGNOSTICS = 20;
51
+ function validateTree(root, maxNodes, maxDepth, maxAliases, duplicates) { let nodes = 0, aliases = 0; const stack = [{ node: root, depth: 0 }]; while (stack.length) {
48
52
  const item = stack.pop();
49
53
  nodes++;
50
54
  if (nodes > maxNodes)
@@ -53,6 +57,14 @@ function validateTree(root, maxNodes, maxDepth, maxAliases) { let nodes = 0, ali
53
57
  return 'yaml.depth-limit';
54
58
  if (item.node.kind === 'alias' && ++aliases > maxAliases)
55
59
  return 'yaml.alias-limit';
60
+ if (item.node.kind === 'map' && item.node.children) {
61
+ const seen = new Set();
62
+ for (const child of item.node.children) {
63
+ if (seen.has(child.key) && duplicates.length < MAX_DUPLICATE_KEY_DIAGNOSTICS && !duplicates.some(d => d.path === item.node.path && d.key === child.key))
64
+ duplicates.push({ path: item.node.path, key: child.key });
65
+ seen.add(child.key);
66
+ }
67
+ }
56
68
  for (const child of item.node.children ?? [])
57
69
  stack.push({ node: child, depth: item.depth + 1 });
58
70
  } return null; }
@@ -1,19 +1,31 @@
1
1
  /** Dynamic optional-peer loader; base parser/viewer modules never import yaml. */
2
2
  export async function loadYamlParserDeps() {
3
3
  const yaml = await import('yaml');
4
- const normalize = (node, path, key = '$') => {
5
- const n = node;
6
- if (n.contents !== undefined)
7
- return normalize(n.contents, path, key);
8
- const common = { key, path, range: n.range, anchorId: n.anchor };
9
- if (n.type === 'ALIAS' || n.constructor?.name === 'Alias')
10
- return { ...common, kind: 'alias', aliasOf: String(n.source ?? '') };
11
- if (Array.isArray(n.items)) {
12
- const pairs = n.items.every(item => item && typeof item === 'object' && 'key' in item);
13
- return { ...common, kind: pairs ? 'map' : 'seq', children: n.items.map((item, i) => { const pair = item; const childKey = pairs ? String(pair.key?.value ?? pair.key?.source ?? i) : String(i); return normalize(pairs ? pair.value : item, pairs ? `${path}.${childKey}` : `${path}[${i}]`, childKey); }) };
14
- }
15
- const value = n.value ?? node;
16
- return { ...common, kind: 'scalar', value: value, raw: n.source === undefined ? String(value) : String(n.source) };
4
+ const normalizeDocument = (document, index) => {
5
+ const anchors = new Map();
6
+ const record = (node) => { if (node.anchorId)
7
+ anchors.set(node.anchorId, node); return node; };
8
+ const normalize = (node, path, key = '$') => {
9
+ const n = node;
10
+ if (n.contents !== undefined)
11
+ return normalize(n.contents, path, key);
12
+ const common = { key, path, range: n.range, anchorId: n.anchor };
13
+ if (n.type === 'ALIAS' || n.constructor?.name === 'Alias') {
14
+ const name = String(n.source ?? '');
15
+ const target = anchors.get(name);
16
+ if (target?.kind !== 'scalar')
17
+ return { ...common, kind: 'alias', aliasOf: name, raw: `*${name}` };
18
+ return { ...common, kind: 'alias', aliasOf: name, value: target.value ?? null, raw: target.raw ?? `*${name}` };
19
+ }
20
+ if (Array.isArray(n.items)) {
21
+ const pairs = n.items.every(item => item && typeof item === 'object' && 'key' in item);
22
+ const seen = new Map();
23
+ return record({ ...common, kind: pairs ? 'map' : 'seq', children: n.items.map((item, i) => { const pair = item; const keyValue = typeof pair.key?.value === 'symbol' ? pair.key?.source ?? '<<' : pair.key?.value; const childKey = pairs ? String(keyValue ?? pair.key?.source ?? i) : String(i); const occurrence = (seen.get(childKey) ?? 0) + 1; seen.set(childKey, occurrence); return normalize(pairs ? pair.value : item, pairs ? `${path}.${childKey}${occurrence > 1 ? `#${occurrence}` : ''}` : `${path}[${i}]`, childKey); }) });
24
+ }
25
+ const value = n.value ?? node;
26
+ return record({ ...common, kind: 'scalar', value: value, raw: n.source === undefined ? String(value) : String(n.source) });
27
+ };
28
+ return normalize(document, `$doc[${index}]`);
17
29
  };
18
- return { parse: (text) => yaml.parseAllDocuments(text, { schema: 'core', customTags: [] }), normalize: (document, index) => normalize(document, `$doc[${index}]`) };
30
+ return { parse: (text) => yaml.parseAllDocuments(text, { schema: 'core', customTags: [], uniqueKeys: false, merge: true }), normalize: normalizeDocument };
19
31
  }
@@ -162,7 +162,7 @@ export const IMAGE_VIEWER_DESCRIPTOR = {
162
162
  export const MARKDOWN_VIEWER_DESCRIPTOR = {
163
163
  id: 'markdown', displayNameKey: 'markdown.title',
164
164
  extensions: ['md', 'markdown', 'mdown', 'mkdn', 'mkd'], priority: 10,
165
- requiredServices: [], optionalServices: ['clipboard', 'navigation', 'documentAssets', 'writeback']
165
+ requiredServices: [], optionalServices: ['clipboard', 'navigation', 'documentAssets', 'writeback', 'save']
166
166
  };
167
167
  export const ARCHIVE_MAGIC_SIGNATURES = [
168
168
  [{ offset: 0, bytes: [0x50, 0x4b, 0x03, 0x04] }], [{ offset: 0, bytes: [0x50, 0x4b, 0x05, 0x06] }],
@@ -51,6 +51,14 @@
51
51
  color: var(--omni-fg-muted, #9d9d9d);
52
52
  margin-right: 8px;
53
53
  }
54
+ .omni-pdf__save-mode {
55
+ padding: 2px 6px;
56
+ color: var(--omni-fg-muted, #9d9d9d);
57
+ border: 1px solid var(--omni-border, #3c3c3c);
58
+ border-radius: 999px;
59
+ font-size: 11px;
60
+ white-space: nowrap;
61
+ }
54
62
  .omni-pdf__page-input {
55
63
  width: 52px;
56
64
  padding: 3px 5px;
@@ -265,10 +273,28 @@
265
273
  .omni-pdf__thumb.drop-before::before { top: -6px; }
266
274
  .omni-pdf__thumb.drop-after::after { bottom: -6px; }
267
275
  .omni-pdf__thumb canvas {
276
+ display: block;
268
277
  max-width: 100%;
269
278
  background: #ffffff;
270
279
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
271
280
  }
281
+ .omni-pdf__thumb-page {
282
+ position: relative;
283
+ max-width: 100%;
284
+ }
285
+ .omni-pdf__thumb-annotation {
286
+ position: absolute;
287
+ z-index: 1;
288
+ overflow: hidden;
289
+ pointer-events: none;
290
+ color: #111111; /* ink on paper */
291
+ white-space: nowrap;
292
+ }
293
+ .omni-pdf__thumb-annotation img { display: block; }
294
+ .omni-pdf__thumb-annotation.omni-pdf__underline::after,
295
+ .omni-pdf__thumb-annotation.omni-pdf__strikeout::after {
296
+ height: 1px;
297
+ }
272
298
  .omni-pdf__thumb-label {
273
299
  font-family: var(--omni-font-mono, Monaco, Menlo, monospace);
274
300
  font-size: 10px;
@@ -1 +1 @@
1
- :host,.omni-viewer--toml,.omni-viewer--yaml{display:block;height:100%;color:var(--omni-fg,#ddd);background:var(--omni-bg,#1e1e1e);font:13px var(--omni-font,system-ui)}.omni-structured{height:100%;display:flex;flex-direction:column;box-sizing:border-box}.omni-structured__bar{display:flex;gap:5px;align-items:center;padding:6px;border-bottom:1px solid var(--omni-border,#444)}.omni-structured button,.omni-structured input,.omni-structured select{font:inherit;background:var(--omni-button-bg,#333);color:inherit;border:1px solid var(--omni-border,#555);border-radius:3px;padding:3px 7px}.omni-structured button:disabled{opacity:.5}.omni-structured__search{min-width:180px}.omni-structured__diagnostics{white-space:pre-wrap;color:var(--omni-error,#f48771);padding:6px;border-bottom:1px solid var(--omni-border,#444)}.omni-structured__workspace{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);grid-template-rows:minmax(0,1fr);gap:12px;flex:1;min-height:0;padding:12px}.omni-structured__pane{display:flex;flex-direction:column;min-width:0;min-height:0;border:1px solid var(--omni-border,#444);border-radius:8px;overflow:hidden;background:var(--omni-input-bg,#171717)}.omni-structured__pane-header{display:flex;justify-content:space-between;gap:12px;padding:10px 12px;border-bottom:1px solid var(--omni-border,#444)}.omni-structured__pane-header span{color:var(--omni-fg-muted,#999)}.omni-structured__editor,.omni-structured__tree{box-sizing:border-box;flex:1;min-height:0;padding:10px;background:transparent;color:inherit;font:inherit;font-family:var(--omni-font-mono,ui-monospace,monospace)}.omni-structured__editor{resize:none;border:0;outline:none;line-height:1.5;white-space:pre;overflow:auto}.omni-structured__source-surface{position:relative;flex:1;min-height:0;overflow:hidden}.omni-structured__source-highlight{position:absolute;inset:0;box-sizing:border-box;margin:0;padding:10px;overflow:auto;pointer-events:none;font:inherit;font-family:var(--omni-font-mono,ui-monospace,monospace);line-height:1.5;white-space:pre}.omni-structured__pane--highlighted-source .omni-structured__editor{position:relative;z-index:1;width:100%;height:100%;padding:10px;color:transparent;caret-color:var(--omni-fg,#ddd);-webkit-text-fill-color:transparent}.omni-structured__source-key{color:#9cdcfe}.omni-structured__source-value{color:#ce9178}.omni-structured__source-table{color:#c586c0}.omni-structured__source-comment{color:var(--omni-fg-muted,#999)}.omni-structured__tree{overflow:auto}.omni-structured__node{display:flex;gap:6px;align-items:baseline;min-height:25px}.omni-structured__toggle{width:24px;padding:0!important;border:0!important;background:transparent!important}.omni-structured__key{color:var(--omni-accent,#5aa9e6)}.omni-structured__kind{color:var(--omni-fg-muted,#999)}.omni-structured__value{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.omni-structured__actions{margin-left:auto;display:flex;gap:4px;opacity:0}.omni-structured__node:hover .omni-structured__actions,.omni-structured__actions:focus-within{opacity:1}.omni-structured__raw-preview,.omni-structured__json-preview{flex:1;min-height:0;margin:0;padding:10px;overflow:auto;font-family:var(--omni-font-mono,ui-monospace,monospace);line-height:1.5}.omni-structured__flat-row .omni-structured__key{flex-shrink:0}@media(max-width:720px){.omni-structured__workspace{grid-template-columns:1fr;overflow:auto}.omni-structured__pane{min-height:320px}}
1
+ :host,.omni-viewer--toml,.omni-viewer--yaml{display:block;height:100%;color:var(--omni-fg,#ddd);background:var(--omni-bg,#1e1e1e);font:13px var(--omni-font,system-ui)}.omni-structured{height:100%;display:flex;flex-direction:column;box-sizing:border-box}.omni-structured__bar{display:flex;gap:5px;align-items:center;padding:6px;border-bottom:1px solid var(--omni-border,#444)}.omni-structured button,.omni-structured input,.omni-structured select{font:inherit;background:var(--omni-button-bg,#333);color:inherit;border:1px solid var(--omni-border,#555);border-radius:3px;padding:3px 7px}.omni-structured button:disabled{opacity:.5}.omni-structured__search{min-width:180px}.omni-structured__diagnostics{white-space:pre-wrap;color:var(--omni-error,#f48771);padding:6px;border-bottom:1px solid var(--omni-border,#444)}.omni-structured__workspace{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);grid-template-rows:minmax(0,1fr);gap:12px;flex:1;min-height:0;padding:12px}.omni-structured__pane{display:flex;flex-direction:column;min-width:0;min-height:0;border:1px solid var(--omni-border,#444);border-radius:8px;overflow:hidden;background:var(--omni-input-bg,#171717)}.omni-structured__pane-header{display:flex;justify-content:space-between;gap:12px;padding:10px 12px;border-bottom:1px solid var(--omni-border,#444)}.omni-structured__pane-header span{color:var(--omni-fg-muted,#999)}.omni-structured__editor,.omni-structured__tree{box-sizing:border-box;flex:1;min-height:0;padding:10px;background:transparent;color:inherit;font:inherit;font-family:var(--omni-font-mono,ui-monospace,monospace)}.omni-structured__editor{resize:none;border:0;outline:none;line-height:1.5;white-space:pre;overflow:auto}.omni-structured__source-surface{position:relative;flex:1;min-height:0;overflow:hidden}.omni-structured__source-highlight{position:absolute;inset:0;box-sizing:border-box;margin:0;padding:10px;overflow:auto;pointer-events:none;font:inherit;font-family:var(--omni-font-mono,ui-monospace,monospace);line-height:1.5;white-space:pre}.omni-structured__pane--highlighted-source .omni-structured__editor{position:relative;z-index:1;width:100%;height:100%;padding:10px;color:transparent;caret-color:var(--omni-fg,#ddd);-webkit-text-fill-color:transparent}.omni-structured__source-key{color:#9cdcfe}.omni-structured__source-value{color:#ce9178}.omni-structured__source-table{color:#c586c0}.omni-structured__source-comment{color:var(--omni-fg-muted,#999)}.omni-structured__tree{overflow:auto}.omni-structured__preview-body{position:relative;flex:1;min-height:200px}.omni-structured__preview-body>.omni-structured__tree,.omni-structured__preview-body>.omni-structured__json-preview,.omni-structured__preview-body>.omni-structured__raw-preview{position:absolute;inset:0}.omni-structured__node{display:flex;gap:6px;align-items:baseline;min-height:25px}.omni-structured__toggle{width:24px;padding:0!important;border:0!important;background:transparent!important}.omni-structured__key{color:var(--omni-accent,#5aa9e6)}.omni-structured__kind{color:var(--omni-fg-muted,#999)}.omni-structured__value{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.omni-structured__actions{margin-left:auto;display:flex;gap:4px;opacity:0}.omni-structured__node:hover .omni-structured__actions,.omni-structured__actions:focus-within{opacity:1}.omni-structured__raw-preview,.omni-structured__json-preview{flex:1;min-height:0;margin:0;padding:10px;overflow:auto;font-family:var(--omni-font-mono,ui-monospace,monospace);line-height:1.5}.omni-structured__flat-row .omni-structured__key{flex-shrink:0}@media(max-width:720px){.omni-structured__workspace{grid-template-columns:1fr;overflow:auto}.omni-structured__pane{min-height:320px}}
@@ -1 +1 @@
1
- :host,.omni-viewer--toml,.omni-viewer--yaml{display:block;height:100%;color:var(--omni-fg,#ddd);background:var(--omni-bg,#1e1e1e);font:13px var(--omni-font,system-ui)}.omni-structured{height:100%;display:flex;flex-direction:column;box-sizing:border-box}.omni-structured__bar{display:flex;gap:5px;align-items:center;padding:6px;border-bottom:1px solid var(--omni-border,#444)}.omni-structured button,.omni-structured input,.omni-structured select{font:inherit;background:var(--omni-button-bg,#333);color:inherit;border:1px solid var(--omni-border,#555);border-radius:3px;padding:3px 7px}.omni-structured button:disabled{opacity:.5}.omni-structured__search{min-width:180px}.omni-structured__diagnostics{white-space:pre-wrap;color:var(--omni-error,#f48771);padding:6px;border-bottom:1px solid var(--omni-border,#444)}.omni-structured__workspace{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);grid-template-rows:minmax(0,1fr);gap:12px;flex:1;min-height:0;padding:12px}.omni-structured__pane{display:flex;flex-direction:column;min-width:0;min-height:0;border:1px solid var(--omni-border,#444);border-radius:8px;overflow:hidden;background:var(--omni-input-bg,#171717)}.omni-structured__pane-header{display:flex;justify-content:space-between;gap:12px;padding:10px 12px;border-bottom:1px solid var(--omni-border,#444)}.omni-structured__pane-header span{color:var(--omni-fg-muted,#999)}.omni-structured__editor,.omni-structured__tree{box-sizing:border-box;flex:1;min-height:0;padding:10px;background:transparent;color:inherit;font:inherit;font-family:var(--omni-font-mono,ui-monospace,monospace)}.omni-structured__editor{resize:none;border:0;outline:none;line-height:1.5;white-space:pre;overflow:auto}.omni-structured__source-surface{position:relative;flex:1;min-height:0;overflow:hidden}.omni-structured__source-highlight{position:absolute;inset:0;box-sizing:border-box;margin:0;padding:10px;overflow:auto;pointer-events:none;font:inherit;font-family:var(--omni-font-mono,ui-monospace,monospace);line-height:1.5;white-space:pre}.omni-structured__pane--highlighted-source .omni-structured__editor{position:relative;z-index:1;width:100%;height:100%;padding:10px;color:transparent;caret-color:var(--omni-fg,#ddd);-webkit-text-fill-color:transparent}.omni-structured__source-key{color:#9cdcfe}.omni-structured__source-value{color:#ce9178}.omni-structured__source-table{color:#c586c0}.omni-structured__source-comment{color:var(--omni-fg-muted,#999)}.omni-structured__tree{overflow:auto}.omni-structured__node{display:flex;gap:6px;align-items:baseline;min-height:25px}.omni-structured__toggle{width:24px;padding:0!important;border:0!important;background:transparent!important}.omni-structured__key{color:var(--omni-accent,#5aa9e6)}.omni-structured__kind{color:var(--omni-fg-muted,#999)}.omni-structured__value{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.omni-structured__actions{margin-left:auto;display:flex;gap:4px;opacity:0}.omni-structured__node:hover .omni-structured__actions,.omni-structured__actions:focus-within{opacity:1}.omni-structured__raw-preview,.omni-structured__json-preview{flex:1;min-height:0;margin:0;padding:10px;overflow:auto;font-family:var(--omni-font-mono,ui-monospace,monospace);line-height:1.5}.omni-structured__flat-row .omni-structured__key{flex-shrink:0}@media(max-width:720px){.omni-structured__workspace{grid-template-columns:1fr;overflow:auto}.omni-structured__pane{min-height:320px}}
1
+ :host,.omni-viewer--toml,.omni-viewer--yaml{display:block;height:100%;color:var(--omni-fg,#ddd);background:var(--omni-bg,#1e1e1e);font:13px var(--omni-font,system-ui)}.omni-structured{height:100%;display:flex;flex-direction:column;box-sizing:border-box}.omni-structured__bar{display:flex;gap:5px;align-items:center;padding:6px;border-bottom:1px solid var(--omni-border,#444)}.omni-structured button,.omni-structured input,.omni-structured select{font:inherit;background:var(--omni-button-bg,#333);color:inherit;border:1px solid var(--omni-border,#555);border-radius:3px;padding:3px 7px}.omni-structured button:disabled{opacity:.5}.omni-structured__search{min-width:180px}.omni-structured__diagnostics{white-space:pre-wrap;color:var(--omni-error,#f48771);padding:6px;border-bottom:1px solid var(--omni-border,#444)}.omni-structured__workspace{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);grid-template-rows:minmax(0,1fr);gap:12px;flex:1;min-height:0;padding:12px}.omni-structured__pane{display:flex;flex-direction:column;min-width:0;min-height:0;border:1px solid var(--omni-border,#444);border-radius:8px;overflow:hidden;background:var(--omni-input-bg,#171717)}.omni-structured__pane-header{display:flex;justify-content:space-between;gap:12px;padding:10px 12px;border-bottom:1px solid var(--omni-border,#444)}.omni-structured__pane-header span{color:var(--omni-fg-muted,#999)}.omni-structured__editor,.omni-structured__tree{box-sizing:border-box;flex:1;min-height:0;padding:10px;background:transparent;color:inherit;font:inherit;font-family:var(--omni-font-mono,ui-monospace,monospace)}.omni-structured__editor{resize:none;border:0;outline:none;line-height:1.5;white-space:pre;overflow:auto}.omni-structured__source-surface{position:relative;flex:1;min-height:0;overflow:hidden}.omni-structured__source-highlight{position:absolute;inset:0;box-sizing:border-box;margin:0;padding:10px;overflow:auto;pointer-events:none;font:inherit;font-family:var(--omni-font-mono,ui-monospace,monospace);line-height:1.5;white-space:pre}.omni-structured__pane--highlighted-source .omni-structured__editor{position:relative;z-index:1;width:100%;height:100%;padding:10px;color:transparent;caret-color:var(--omni-fg,#ddd);-webkit-text-fill-color:transparent}.omni-structured__source-key{color:#9cdcfe}.omni-structured__source-value{color:#ce9178}.omni-structured__source-table{color:#c586c0}.omni-structured__source-comment{color:var(--omni-fg-muted,#999)}.omni-structured__tree{overflow:auto}.omni-structured__preview-body{position:relative;flex:1;min-height:200px}.omni-structured__preview-body>.omni-structured__tree,.omni-structured__preview-body>.omni-structured__json-preview,.omni-structured__preview-body>.omni-structured__raw-preview{position:absolute;inset:0}.omni-structured__node{display:flex;gap:6px;align-items:baseline;min-height:25px}.omni-structured__toggle{width:24px;padding:0!important;border:0!important;background:transparent!important}.omni-structured__key{color:var(--omni-accent,#5aa9e6)}.omni-structured__kind{color:var(--omni-fg-muted,#999)}.omni-structured__value{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.omni-structured__actions{margin-left:auto;display:flex;gap:4px;opacity:0}.omni-structured__node:hover .omni-structured__actions,.omni-structured__actions:focus-within{opacity:1}.omni-structured__raw-preview,.omni-structured__json-preview{flex:1;min-height:0;margin:0;padding:10px;overflow:auto;font-family:var(--omni-font-mono,ui-monospace,monospace);line-height:1.5}.omni-structured__flat-row .omni-structured__key{flex-shrink:0}@media(max-width:720px){.omni-structured__workspace{grid-template-columns:1fr;overflow:auto}.omni-structured__pane{min-height:320px}}
@@ -187,27 +187,16 @@ export function createJsonController(input, options = {}) {
187
187
  state.statusMessage = { key: messageKey };
188
188
  };
189
189
  const requireValidRoot = () => state.status === 'ok' && state.root ? state.root : null;
190
- /** Show raw-text conversions for review without overwriting the editor. */
191
- const runRawResult = (action, result) => {
190
+ /** Raw-text transforms replace the scratchpad immediately (J11, vscode
191
+ * routing) so they chain naturally; failures surface as status messages. */
192
+ const runRawTransform = (action, result) => {
192
193
  const transformed = typeof result === 'string' ? { ok: true, output: result } : result;
193
- state.toolResult = {
194
- action,
195
- titleKey: TOOL_MESSAGE[action],
196
- output: transformed.ok ? transformed.output : '',
197
- ...(transformed.ok
198
- ? {}
199
- : { error: { key: TRANSFORM_ERROR[transformed.error] ?? 'json.tool.failed' } })
200
- };
201
- };
202
- /** Chain Base64 operations through their latest successful result while
203
- * leaving the editor's original JSON untouched. */
204
- const base64Input = () => {
205
- const previous = state.toolResult;
206
- return previous
207
- && !previous.error
208
- && (previous.action === 'base64-encode' || previous.action === 'base64-decode')
209
- ? previous.output
210
- : state.scratchpad;
194
+ if (transformed.ok) {
195
+ replaceScratchpad(transformed.output, TOOL_MESSAGE[action]);
196
+ }
197
+ else {
198
+ state.statusMessage = { key: TRANSFORM_ERROR[transformed.error] ?? 'json.tool.failed' };
199
+ }
211
200
  };
212
201
  const runConverter = (action, convert) => {
213
202
  const root = requireValidRoot();
@@ -269,16 +258,16 @@ export function createJsonController(input, options = {}) {
269
258
  break;
270
259
  }
271
260
  case 'escape':
272
- runRawResult('escape', escapeText(state.scratchpad));
261
+ runRawTransform('escape', escapeText(state.scratchpad));
273
262
  break;
274
263
  case 'unescape':
275
- runRawResult('unescape', unescapeText(state.scratchpad));
264
+ runRawTransform('unescape', unescapeText(state.scratchpad));
276
265
  break;
277
266
  case 'base64-encode':
278
- runRawResult('base64-encode', base64Encode(base64Input()));
267
+ runRawTransform('base64-encode', base64Encode(state.scratchpad));
279
268
  break;
280
269
  case 'base64-decode':
281
- runRawResult('base64-decode', base64Decode(base64Input()));
270
+ runRawTransform('base64-decode', base64Decode(state.scratchpad));
282
271
  break;
283
272
  case 'to-csv':
284
273
  runConverter('to-csv', jsonToCsv);
@@ -246,10 +246,13 @@ export async function mountJsonViewer(input, container, ctx, options = {}) {
246
246
  copyResultBtn.disabled = true;
247
247
  copyResultBtn.title = t('common.noClipboard');
248
248
  }
249
+ const replaceResultBtn = el('button', undefined, t('json.result.replace'));
250
+ replaceResultBtn.type = 'button';
251
+ on(replaceResultBtn, 'click', () => controller.dispatch({ type: 'apply-result-to-editor' }));
249
252
  const closeResultBtn = el('button', undefined, t('json.result.close'));
250
253
  closeResultBtn.type = 'button';
251
254
  on(closeResultBtn, 'click', () => controller.dispatch({ type: 'dismiss-result' }));
252
- resultActions.append(copyResultBtn, closeResultBtn);
255
+ resultActions.append(copyResultBtn, replaceResultBtn, closeResultBtn);
253
256
  resultPanel.append(resultTitle, resultOutput, resultMarkup, resultTableWrap, resultActions);
254
257
  frame.append(toolbar, statusBar, diagnosticsBar, resultPanel, body);
255
258
  root.appendChild(frame);
@@ -475,6 +478,7 @@ export async function mountJsonViewer(input, container, ctx, options = {}) {
475
478
  if (isMarkup)
476
479
  renderMarkupResult(state.toolResult.action, state.toolResult.output);
477
480
  copyResultBtn.disabled = !clipboard || !!state.toolResult.error;
481
+ replaceResultBtn.disabled = !!state.toolResult.error;
478
482
  }
479
483
  else {
480
484
  resultPanel.style.display = 'none';
@@ -1,4 +1,4 @@
1
- import type { ClipboardService, DocumentAssetsService, FileWritebackService, HostContext, NavigationService } from '../../host/index.js';
1
+ import type { ClipboardService, DocumentAssetsService, FileSaveService, FileWritebackService, HostContext, NavigationService } from '../../host/index.js';
2
2
  import { type MarkdownParseOptions } from '../../parsers/markdown/index.js';
3
3
  import type { ResourceLimits } from '../../parsers/types.js';
4
4
  import { type MountOptions, type ViewerHandle, type ViewerInput } from '../types.js';
@@ -12,7 +12,7 @@ export declare const MARKDOWN_VIEWER_META: {
12
12
  extensions: string[];
13
13
  priority: number;
14
14
  requiredServices: readonly [];
15
- optionalServices: readonly ["clipboard", "navigation", "documentAssets", "writeback"];
15
+ optionalServices: readonly ["clipboard", "navigation", "documentAssets", "writeback", "save"];
16
16
  inputOwnership: "borrows";
17
17
  };
18
18
  export interface MarkdownRenderer {
@@ -56,6 +56,7 @@ export type MarkdownViewerContext = HostContext & {
56
56
  navigation?: NavigationService;
57
57
  documentAssets?: DocumentAssetsService;
58
58
  writeback?: FileWritebackService;
59
+ save?: FileSaveService;
59
60
  };
60
61
  export interface MarkdownMountOptions extends MountOptions {
61
62
  limits?: ResourceLimits;
@@ -12,7 +12,7 @@ export const MARKDOWN_VIEWER_META = {
12
12
  id: 'markdown', displayNameKey: 'markdown.title',
13
13
  extensions: ['md', 'markdown', 'mdown', 'mkdn', 'mkd'], priority: 10,
14
14
  requiredServices: [],
15
- optionalServices: ['clipboard', 'navigation', 'documentAssets', 'writeback'],
15
+ optionalServices: ['clipboard', 'navigation', 'documentAssets', 'writeback', 'save'],
16
16
  inputOwnership: 'borrows'
17
17
  };
18
18
  const SANITIZE = {
@@ -323,22 +323,37 @@ export async function mountMarkdownViewer(input, container, ctx, deps, options =
323
323
  }
324
324
  };
325
325
  const saveSource = async () => {
326
- if (!ctx.writeback) {
327
- showMessage(t('common.noWriteback'));
328
- setStatus('common.saveFailed', 'invalid');
326
+ const bytes = new TextEncoder().encode(controller.state.source);
327
+ if (ctx.writeback) {
328
+ try {
329
+ await ctx.writeback.write(bytes);
330
+ controller.dispatch({ type: 'mark-saved' });
331
+ setStatus('common.savedToOriginal', 'valid');
332
+ sourceCaption.textContent = t('common.savedToOriginal');
333
+ }
334
+ catch (error) {
335
+ ctx.logger.log('error', `markdown save failed: ${String(error)}`);
336
+ setStatus('common.saveFailed', 'invalid');
337
+ showMessage(errorText(error, t('common.saveFailed')));
338
+ }
329
339
  return;
330
340
  }
331
- try {
332
- await ctx.writeback.write(new TextEncoder().encode(controller.state.source));
333
- controller.dispatch({ type: 'mark-saved' });
334
- setStatus('common.savedToOriginal', 'valid');
335
- sourceCaption.textContent = t('common.savedToOriginal');
336
- }
337
- catch (error) {
338
- ctx.logger.log('error', `markdown save failed: ${String(error)}`);
339
- setStatus('common.saveFailed', 'invalid');
340
- showMessage(errorText(error, t('common.saveFailed')));
341
+ if (ctx.save) {
342
+ // Download fallback saves a copy, not the original — the dirty
343
+ // state is intentionally kept (no mark-saved).
344
+ try {
345
+ await ctx.save.saveFile(input.fileName, bytes, 'text/markdown');
346
+ setStatus('common.savedToOriginal', 'valid');
347
+ }
348
+ catch (error) {
349
+ ctx.logger.log('error', `markdown save failed: ${String(error)}`);
350
+ setStatus('common.saveFailed', 'invalid');
351
+ showMessage(errorText(error, t('common.saveFailed')));
352
+ }
353
+ return;
341
354
  }
355
+ showMessage(t('common.noWriteback'));
356
+ setStatus('common.saveFailed', 'invalid');
342
357
  };
343
358
  const copy = async (value, successKey) => {
344
359
  if (!ctx.clipboard)
@@ -1,4 +1,6 @@
1
- export declare const PDF_ZOOM_LEVELS: readonly [50, 75, 100, 125, 150, 200, 300];
1
+ export declare const PDF_ZOOM_LEVELS: readonly [50, 75, 100, 125, 150, 175, 200, 225, 250, 275, 300];
2
+ export declare const PDF_MIN_ZOOM = 25;
3
+ export declare const PDF_MAX_ZOOM = 400;
2
4
  export type PdfZoom = number;
3
5
  export interface PdfTextAnnotation {
4
6
  id: string;
@@ -109,12 +111,22 @@ export type PdfAction = {
109
111
  };
110
112
  export interface PdfController {
111
113
  readonly state: PdfViewState;
114
+ /** Ordered button steps used by zoom-in/zoom-out. */
115
+ readonly zoomLevels: readonly number[];
116
+ readonly minZoom: number;
117
+ readonly maxZoom: number;
112
118
  dispatch(action: PdfAction): void;
113
119
  subscribe(listener: (state: PdfViewState) => void): () => void;
114
120
  }
121
+ export interface PdfControllerOptions {
122
+ /** Custom button steps. Fit-to-width/height may still set an intermediate value. */
123
+ zoomLevels?: readonly number[];
124
+ minZoom?: number;
125
+ maxZoom?: number;
126
+ }
115
127
  /** Persisted layer used to restore an editing session (sidecar rehydration). */
116
128
  export interface PdfControllerSeed {
117
129
  pageOrder?: readonly number[];
118
130
  annotations?: readonly PdfAnnotation[];
119
131
  }
120
- export declare function createPdfController(pageCount: number, seed?: PdfControllerSeed): PdfController;
132
+ export declare function createPdfController(pageCount: number, seed?: PdfControllerSeed, options?: PdfControllerOptions): PdfController;