omni-viewer-core 0.3.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.
Files changed (36) hide show
  1. package/README.md +28 -0
  2. package/dist/host/index.d.ts +14 -2
  3. package/dist/i18n/catalog.en.js +8 -0
  4. package/dist/i18n/catalog.ja.js +9 -1
  5. package/dist/i18n/catalog.ko.js +8 -0
  6. package/dist/i18n/catalog.zh-cn.js +8 -1
  7. package/dist/parsers/pptx/index.d.ts +1 -0
  8. package/dist/parsers/pptx/index.js +1 -1
  9. package/dist/parsers/yaml/index.js +14 -2
  10. package/dist/parsers/yaml/self-loading.js +26 -14
  11. package/dist/registry/index.js +1 -1
  12. package/dist/styles/archive.css +1 -1
  13. package/dist/styles/pdf.css +26 -0
  14. package/dist/styles/toml.css +1 -1
  15. package/dist/styles/yaml.css +1 -1
  16. package/dist/viewers/archive/index.js +60 -11
  17. package/dist/viewers/archive/styles.d.ts +1 -1
  18. package/dist/viewers/archive/styles.js +1 -1
  19. package/dist/viewers/json/controller.js +13 -24
  20. package/dist/viewers/json/index.js +5 -1
  21. package/dist/viewers/markdown/index.d.ts +3 -2
  22. package/dist/viewers/markdown/index.js +29 -14
  23. package/dist/viewers/pdf/controller.d.ts +14 -2
  24. package/dist/viewers/pdf/controller.js +28 -10
  25. package/dist/viewers/pdf/index.d.ts +79 -8
  26. package/dist/viewers/pdf/index.js +363 -81
  27. package/dist/viewers/pdf/self-loading.d.ts +3 -3
  28. package/dist/viewers/pdf/styles.d.ts +1 -1
  29. package/dist/viewers/pdf/styles.js +26 -0
  30. package/dist/viewers/ppt/index.js +3 -3
  31. package/dist/viewers/structured-styles.d.ts +1 -1
  32. package/dist/viewers/structured-styles.js +1 -1
  33. package/dist/viewers/structured.d.ts +1 -0
  34. package/dist/viewers/structured.js +5 -3
  35. package/dist/viewers/yaml/controller.js +48 -5
  36. package/package.json +2 -1
@@ -10,12 +10,14 @@
10
10
  // alert / confirm — those are blocked inside the VS Code webview, so text
11
11
  // annotations use an inline input overlay instead.
12
12
  import { MountAbortedError, VIEWER_ROOT_CLASS } from '../types.js';
13
- import { createPdfController, mergeHighlightRects, PDF_ZOOM_LEVELS } from './controller.js';
14
- import { buildSavedPdf, mergePdfBytes, parseLayer, savedPdfName, SIDECAR_LAYER_NAME, SIDECAR_BASE_NAME } from './editing.js';
13
+ import { createPdfController, mergeHighlightRects } from './controller.js';
14
+ import { buildEditedPdf, buildSavedPdf, mergePdfBytes, parseLayer, savedPdfName, SIDECAR_LAYER_NAME, SIDECAR_BASE_NAME } from './editing.js';
15
15
  import { pdfViewerCss } from './styles.js';
16
- export { createPdfController, PDF_ZOOM_LEVELS } from './controller.js';
16
+ export { createPdfController, PDF_MAX_ZOOM, PDF_MIN_ZOOM, PDF_ZOOM_LEVELS } from './controller.js';
17
17
  export { pdfViewerCss } from './styles.js';
18
- export { buildEditedPdf, mergePdfBytes, savedPdfName } from './editing.js';
18
+ export { buildEditedPdf, buildSavedPdf, mergePdfBytes, parseLayer, savedPdfName, SIDECAR_BASE_NAME, SIDECAR_LAYER_NAME } from './editing.js';
19
+ /** Asset key passed to `HostContext.assets.resolveAssetUrl` by default. */
20
+ export const PDF_WORKER_ASSET_KEY = 'assets/pdfjs/pdf.worker.min.mjs';
19
21
  /** Viewer metadata — single source for the registry codegen (DESIGN.md §7). */
20
22
  export const PDF_VIEWER_META = {
21
23
  id: 'pdf',
@@ -32,6 +34,13 @@ function isPasswordError(error) {
32
34
  error !== null &&
33
35
  error.name === 'PasswordException');
34
36
  }
37
+ export function containPdfSignatureSize(width, height, maxWidth = 120, maxHeight = 60) {
38
+ if (!(width > 0) || !(height > 0) || !(maxWidth > 0) || !(maxHeight > 0)) {
39
+ return { width: 0, height: 0 };
40
+ }
41
+ const scale = Math.min(maxWidth / width, maxHeight / height);
42
+ return { width: width * scale, height: height * scale };
43
+ }
35
44
  export async function mountPdfViewer(input, container, ctx, deps, options = {}) {
36
45
  if (options.signal?.aborted)
37
46
  throw new MountAbortedError();
@@ -81,13 +90,16 @@ export async function mountPdfViewer(input, container, ctx, deps, options = {})
81
90
  status.setAttribute('aria-live', 'polite');
82
91
  frame.append(header, zoomBar, body, status);
83
92
  root.appendChild(frame);
93
+ const customActionCleanups = [];
84
94
  // ------------------------------------------------------------- lifecycle
85
95
  let disposed = false;
86
96
  let doc;
87
97
  let loadingTask;
88
98
  let pageObserver;
89
99
  let thumbObserver;
100
+ let thumbTasks = [];
90
101
  let records = [];
102
+ let thumbRecords = [];
91
103
  let renderEpoch = 0;
92
104
  let currentPage = 1;
93
105
  let tool = 'view';
@@ -98,15 +110,27 @@ export async function mountPdfViewer(input, container, ctx, deps, options = {})
98
110
  let pageLayout = 'single';
99
111
  let matchThemeColors = false;
100
112
  let sourceBytes = input.data;
113
+ const saveMode = options.saveMode ?? 'hybrid';
114
+ const processingMode = options.processingMode ?? 'auto';
115
+ const controllerOptions = {
116
+ ...(options.zoomLevels === undefined ? {} : { zoomLevels: options.zoomLevels }),
117
+ ...(options.minZoom === undefined ? {} : { minZoom: options.minZoom }),
118
+ ...(options.maxZoom === undefined ? {} : { maxZoom: options.maxZoom })
119
+ };
101
120
  /** A merge changed the document without going through the edit stack. */
102
121
  let mergedSinceSave = false;
103
122
  let pdfLib;
104
123
  let unsubscribe;
105
- let controller = createPdfController(0);
124
+ let controller = createPdfController(0, undefined, controllerOptions);
125
+ let operationState = { status: 'idle' };
126
+ let operationController;
127
+ let cancelPasswordPrompt;
106
128
  /** Layout inputs of the last rebuild — annotation-only changes skip it. */
107
129
  let layoutKey = '';
108
130
  const abortListener = () => {
109
- loadingTask?.destroy();
131
+ void loadingTask?.destroy();
132
+ operationController?.abort();
133
+ cancelPasswordPrompt?.();
110
134
  };
111
135
  options.signal?.addEventListener('abort', abortListener, { once: true });
112
136
  function throwIfAborted() {
@@ -120,14 +144,23 @@ export async function mountPdfViewer(input, container, ctx, deps, options = {})
120
144
  document.removeEventListener('keydown', keyHandler);
121
145
  document.removeEventListener('selectionchange', syncMarkupAvailability);
122
146
  options.signal?.removeEventListener('abort', abortListener);
147
+ operationController?.abort();
148
+ operationController = undefined;
149
+ cancelPasswordPrompt?.();
150
+ for (const cleanup of customActionCleanups.splice(0))
151
+ cleanup();
123
152
  unsubscribe?.();
124
153
  pageObserver?.disconnect();
125
154
  thumbObserver?.disconnect();
155
+ for (const task of thumbTasks)
156
+ task.cancel();
157
+ thumbTasks = [];
126
158
  for (const record of records) {
127
159
  record.task?.cancel();
128
160
  record.textLayer?.cancel();
129
161
  }
130
162
  records = [];
163
+ thumbRecords = [];
131
164
  void doc?.destroy();
132
165
  doc = undefined;
133
166
  root.replaceChildren();
@@ -156,23 +189,49 @@ export async function mountPdfViewer(input, container, ctx, deps, options = {})
156
189
  };
157
190
  document.addEventListener('keydown', keyHandler);
158
191
  // -------------------------------------------------------------- pdf.js
159
- const pdfjs = await deps.loadPdfjs();
160
- throwIfAborted();
161
- pdfjs.GlobalWorkerOptions.workerSrc = await ctx.assets.resolveAssetUrl('assets/pdfjs/pdf.worker.min.mjs');
162
- throwIfAborted();
192
+ let pdfjs;
193
+ try {
194
+ pdfjs = await deps.loadPdfjs();
195
+ throwIfAborted();
196
+ pdfjs.GlobalWorkerOptions.workerSrc = options.workerSrc
197
+ ?? await ctx.assets.resolveAssetUrl(PDF_WORKER_ASSET_KEY);
198
+ throwIfAborted();
199
+ }
200
+ catch (error) {
201
+ if (error instanceof MountAbortedError)
202
+ throw error;
203
+ ctx.logger.log('error', `pdf worker asset failed: ${String(error)}`);
204
+ status.textContent = t('pdf.workerLoadFailed');
205
+ status.classList.add('omni-pdf__status--error');
206
+ return {
207
+ get controller() { return controller; },
208
+ get operation() { return operationState; },
209
+ isDirty: () => false,
210
+ cancelOperation: () => undefined,
211
+ refreshToolbarActions: () => undefined,
212
+ dispose: teardown
213
+ };
214
+ }
163
215
  async function loadDocument(bytes, password) {
164
216
  const request = {
165
217
  // pdf.js transfers the buffer to its worker; keep ours intact.
166
- data: bytes.slice()
218
+ data: bytes.slice(),
219
+ isEvalSupported: options.isEvalSupported ?? false
167
220
  };
168
221
  if (password !== undefined)
169
222
  request.password = password;
170
- loadingTask = pdfjs.getDocument(request);
223
+ const task = pdfjs.getDocument(request);
224
+ loadingTask = task;
171
225
  try {
172
- return await loadingTask.promise;
226
+ return await task.promise;
227
+ }
228
+ catch (error) {
229
+ await task.destroy();
230
+ throw error;
173
231
  }
174
232
  finally {
175
- loadingTask = undefined;
233
+ if (loadingTask === task)
234
+ loadingTask = undefined;
176
235
  }
177
236
  }
178
237
  /**
@@ -224,10 +283,16 @@ export async function mountPdfViewer(input, container, ctx, deps, options = {})
224
283
  panel.append(inputEl, actions);
225
284
  overlay.appendChild(panel);
226
285
  frame.appendChild(overlay);
286
+ let finished = false;
227
287
  const finish = (value) => {
288
+ if (finished)
289
+ return;
290
+ finished = true;
291
+ cancelPasswordPrompt = undefined;
228
292
  overlay.remove();
229
293
  resolve(value);
230
294
  };
295
+ cancelPasswordPrompt = () => finish(null);
231
296
  submit.addEventListener('click', () => finish(inputEl.value));
232
297
  cancel.addEventListener('click', () => finish(null));
233
298
  inputEl.addEventListener('keydown', (e) => {
@@ -240,7 +305,11 @@ export async function mountPdfViewer(input, container, ctx, deps, options = {})
240
305
  });
241
306
  }
242
307
  // --------------------------------------------------------------- toolbar
243
- const editingAvailable = typeof deps.loadPdfLib === 'function';
308
+ const canUseBrowserProcessing = processingMode !== 'host' && typeof deps.loadPdfLib === 'function';
309
+ const canDelegateBuild = processingMode !== 'browser' && typeof deps.processing?.buildPdf === 'function';
310
+ const canDelegateMerge = processingMode !== 'browser' && typeof deps.processing?.mergePdfs === 'function';
311
+ const editingAvailable = canUseBrowserProcessing || canDelegateBuild;
312
+ const mergeAvailable = canUseBrowserProcessing || canDelegateMerge;
244
313
  function toolButton(labelKey, onClick, parent = toolbar) {
245
314
  const b = el('button', 'omni-pdf__tool', t(labelKey));
246
315
  b.type = 'button';
@@ -254,6 +323,9 @@ export async function mountPdfViewer(input, container, ctx, deps, options = {})
254
323
  button.title = t(reasonKey);
255
324
  }
256
325
  toolbar.appendChild(pageInfo);
326
+ const saveModeBadge = el('span', 'omni-pdf__save-mode', t(saveMode === 'hybrid' ? 'pdf.saveModeHybrid' : 'pdf.saveModeFlattened'));
327
+ saveModeBadge.title = t(saveMode === 'hybrid' ? 'pdf.saveModeHybridTitle' : 'pdf.saveModeFlattenedTitle');
328
+ toolbar.appendChild(saveModeBadge);
257
329
  const textBtn = toolButton('pdf.annotationText', () => {
258
330
  tool = tool === 'text' ? 'view' : 'text';
259
331
  syncToolButtons();
@@ -341,6 +413,28 @@ export async function mountPdfViewer(input, container, ctx, deps, options = {})
341
413
  const saveBtn = toolButton('pdf.save', () => void save());
342
414
  const saveAsBtn = toolButton('pdf.saveAs', () => void saveAs());
343
415
  const mergeBtn = toolButton('pdf.merge', () => void merge());
416
+ const customActionButtons = (options.toolbarActions ?? []).map((action) => {
417
+ const button = el('button', 'omni-pdf__tool omni-pdf__host-action', action.label);
418
+ button.type = 'button';
419
+ button.dataset.actionId = action.id;
420
+ if (action.title !== undefined)
421
+ button.title = action.title;
422
+ button.setAttribute('aria-label', action.ariaLabel ?? action.label);
423
+ const listener = () => {
424
+ try {
425
+ void Promise.resolve(action.onClick()).catch((error) => {
426
+ ctx.logger.log('error', `pdf toolbar action ${action.id} failed: ${String(error)}`);
427
+ });
428
+ }
429
+ catch (error) {
430
+ ctx.logger.log('error', `pdf toolbar action ${action.id} failed: ${String(error)}`);
431
+ }
432
+ };
433
+ button.addEventListener('click', listener);
434
+ customActionCleanups.push(() => button.removeEventListener('click', listener));
435
+ toolbar.appendChild(button);
436
+ return { action, button };
437
+ });
344
438
  function syncToolButtons() {
345
439
  textBtn.setAttribute('aria-pressed', String(tool === 'text'));
346
440
  signatureBtn.setAttribute('aria-pressed', String(tool === 'signature'));
@@ -349,7 +443,7 @@ export async function mountPdfViewer(input, container, ctx, deps, options = {})
349
443
  }
350
444
  syncToolButtons();
351
445
  if (!editingAvailable) {
352
- for (const b of [textBtn, signatureBtn, highlightBtn, markupMenuBtn, highlightChoiceBtn, underlineBtn, strikeoutBtn, resetBtn, saveBtn, saveAsBtn, mergeBtn]) {
446
+ for (const b of [textBtn, signatureBtn, highlightBtn, markupMenuBtn, highlightChoiceBtn, underlineBtn, strikeoutBtn, resetBtn, saveBtn, saveAsBtn]) {
353
447
  degrade(b, 'pdf.editingUnavailable');
354
448
  }
355
449
  highlightColorInput.disabled = true;
@@ -361,9 +455,11 @@ export async function mountPdfViewer(input, container, ctx, deps, options = {})
361
455
  degrade(saveBtn, 'common.noWriteback');
362
456
  if (!ctx.save)
363
457
  degrade(saveAsBtn, 'common.noFileSave');
364
- if (!ctx.filePick)
365
- degrade(mergeBtn, 'pdf.noFilePick');
366
458
  }
459
+ if (!ctx.filePick)
460
+ degrade(mergeBtn, 'pdf.noFilePick');
461
+ else if (!mergeAvailable)
462
+ degrade(mergeBtn, 'pdf.editingUnavailable');
367
463
  function syncMarkupAvailability() {
368
464
  if (!editingAvailable)
369
465
  return;
@@ -544,9 +640,9 @@ export async function mountPdfViewer(input, container, ctx, deps, options = {})
544
640
  page: position.page,
545
641
  x: position.x,
546
642
  y: position.y,
547
- width: 120,
548
- height: 60,
549
- dataUrl: image
643
+ width: image.width,
644
+ height: image.height,
645
+ dataUrl: image.dataUrl
550
646
  }
551
647
  });
552
648
  });
@@ -575,7 +671,8 @@ export async function mountPdfViewer(input, container, ctx, deps, options = {})
575
671
  crop.width = maxX - minX + 1 + pad * 2;
576
672
  crop.height = maxY - minY + 1 + pad * 2;
577
673
  crop.getContext('2d')?.drawImage(signatureCanvas, minX, minY, maxX - minX + 1, maxY - minY + 1, pad, pad, maxX - minX + 1, maxY - minY + 1);
578
- return crop.toDataURL('image/png');
674
+ const size = containPdfSignatureSize(crop.width, crop.height);
675
+ return { dataUrl: crop.toDataURL('image/png'), ...size };
579
676
  }
580
677
  /** Rasterize user-added text for portable PDF flattening. The sidecar
581
678
  * keeps the original string, size and color, so Omni can still edit it. */
@@ -800,6 +897,7 @@ export async function mountPdfViewer(input, container, ctx, deps, options = {})
800
897
  continue;
801
898
  }
802
899
  const overlay = el('div', 'omni-pdf__annotation');
900
+ overlay.dataset.annotationId = annotation.id;
803
901
  overlay.tabIndex = 0;
804
902
  overlay.setAttribute('role', 'button');
805
903
  overlay.setAttribute('aria-label', annotation.kind === 'text' ? annotation.text : t('pdf.signature'));
@@ -870,6 +968,47 @@ export async function mountPdfViewer(input, container, ctx, deps, options = {})
870
968
  record.wrapper.appendChild(overlay);
871
969
  }
872
970
  }
971
+ /** Mirror the page annotations onto a thumbnail as non-interactive,
972
+ * scaled-down overlays so thumbs stay in sync with markup edits. */
973
+ function renderThumbOverlays(thumbRecord) {
974
+ for (const node of thumbRecord.holder.querySelectorAll('.omni-pdf__thumb-annotation')) {
975
+ node.remove();
976
+ }
977
+ const scale = thumbRecord.scale;
978
+ for (const annotation of controller.state.annotations) {
979
+ if (annotation.page !== thumbRecord.pageNumber)
980
+ continue;
981
+ if (annotation.kind === 'highlight' || annotation.kind === 'underline' || annotation.kind === 'strikeout') {
982
+ for (const rect of mergeHighlightRects(annotation.rects)) {
983
+ const box = el('div', `omni-pdf__thumb-annotation omni-pdf__${annotation.kind}`);
984
+ box.style.left = `${rect.x * scale}px`;
985
+ box.style.top = `${rect.y * scale}px`;
986
+ box.style.width = `${rect.width * scale}px`;
987
+ box.style.height = `${rect.height * scale}px`;
988
+ box.style.setProperty('--omni-hl-color', annotation.color);
989
+ thumbRecord.holder.appendChild(box);
990
+ }
991
+ continue;
992
+ }
993
+ const overlay = el('div', 'omni-pdf__thumb-annotation');
994
+ overlay.style.left = `${annotation.x * scale}px`;
995
+ overlay.style.top = `${annotation.y * scale}px`;
996
+ if (annotation.kind === 'text') {
997
+ overlay.textContent = annotation.text;
998
+ overlay.style.fontSize = `${annotation.size * scale}px`;
999
+ overlay.style.color = annotation.color;
1000
+ }
1001
+ else {
1002
+ const image = el('img');
1003
+ image.src = annotation.dataUrl;
1004
+ image.alt = '';
1005
+ image.style.width = `${annotation.width * scale}px`;
1006
+ image.style.height = `${annotation.height * scale}px`;
1007
+ overlay.appendChild(image);
1008
+ }
1009
+ thumbRecord.holder.appendChild(overlay);
1010
+ }
1011
+ }
873
1012
  // -------------------------------------------------------------- render
874
1013
  async function renderPage(record, epoch) {
875
1014
  if (disposed || epoch !== renderEpoch || record.rendered || record.rendering)
@@ -880,7 +1019,7 @@ export async function mountPdfViewer(input, container, ctx, deps, options = {})
880
1019
  canvas.width = Math.floor(viewport.width);
881
1020
  canvas.height = Math.floor(viewport.height);
882
1021
  canvas.setAttribute('role', 'img');
883
- canvas.setAttribute('aria-label', t('common.page', { page: record.pageNumber, pages: controller.state.pageOrder.length }));
1022
+ canvas.setAttribute('aria-label', t('common.page', { page: record.displayNumber, pages: controller.state.pageOrder.length }));
884
1023
  const canvasContext = canvas.getContext('2d');
885
1024
  if (!canvasContext) {
886
1025
  record.rendering = false;
@@ -941,21 +1080,24 @@ export async function mountPdfViewer(input, container, ctx, deps, options = {})
941
1080
  }
942
1081
  function setCurrentPage(pageNumber) {
943
1082
  currentPage = pageNumber;
944
- pageInput.value = String(currentPage);
1083
+ const displayNumber = records.findIndex((record) => record.pageNumber === currentPage) + 1;
1084
+ pageInput.value = String(Math.max(1, displayNumber));
1085
+ pageInput.max = String(records.length);
945
1086
  pageTotal.textContent = `/ ${records.length}`;
946
1087
  records.forEach((record, index) => {
947
1088
  thumbs.children[index]?.toggleAttribute('aria-current', record.pageNumber === currentPage);
948
1089
  });
949
1090
  }
950
1091
  function goToPageInput() {
951
- const pageNumber = Number(pageInput.value);
952
- const record = records.find((item) => item.pageNumber === pageNumber);
1092
+ const displayNumber = Number(pageInput.value);
1093
+ const record = Number.isInteger(displayNumber) ? records[displayNumber - 1] : undefined;
953
1094
  if (!record) {
954
- pageInput.value = String(currentPage);
1095
+ const currentDisplay = records.findIndex((item) => item.pageNumber === currentPage) + 1;
1096
+ pageInput.value = String(Math.max(1, currentDisplay));
955
1097
  return;
956
1098
  }
957
1099
  scrollToPage(record);
958
- setCurrentPage(pageNumber);
1100
+ setCurrentPage(record.pageNumber);
959
1101
  }
960
1102
  pageInput.addEventListener('keydown', (event) => {
961
1103
  if (event.key !== 'Enter')
@@ -1026,26 +1168,32 @@ export async function mountPdfViewer(input, container, ctx, deps, options = {})
1026
1168
  const epoch = ++renderEpoch;
1027
1169
  pageObserver?.disconnect();
1028
1170
  thumbObserver?.disconnect();
1171
+ for (const task of thumbTasks)
1172
+ task.cancel();
1173
+ thumbTasks = [];
1029
1174
  for (const record of records) {
1030
1175
  record.task?.cancel();
1031
1176
  record.textLayer?.cancel();
1032
1177
  }
1033
1178
  records = [];
1179
+ thumbRecords = [];
1034
1180
  pages.replaceChildren();
1035
1181
  thumbs.replaceChildren();
1036
1182
  const scale = controller.state.zoom / 100;
1037
1183
  const pageTotal = controller.state.pageOrder.length;
1038
1184
  const thumbRenderers = [];
1039
- for (const pageNumber of controller.state.pageOrder) {
1185
+ for (const [displayIndex, pageNumber] of controller.state.pageOrder.entries()) {
1186
+ const displayNumber = displayIndex + 1;
1040
1187
  const page = await doc.getPage(pageNumber);
1041
1188
  if (disposed || epoch !== renderEpoch)
1042
1189
  return;
1043
1190
  const viewport = page.getViewport({ scale });
1044
1191
  const wrapper = el('div', 'omni-pdf__page');
1045
1192
  wrapper.dataset.pageNumber = String(pageNumber);
1193
+ wrapper.dataset.displayNumber = String(displayNumber);
1046
1194
  wrapper.style.width = `${viewport.width}px`;
1047
1195
  wrapper.style.height = `${viewport.height}px`;
1048
- wrapper.appendChild(el('div', 'omni-pdf__placeholder', t('common.page', { page: pageNumber, pages: pageTotal })));
1196
+ wrapper.appendChild(el('div', 'omni-pdf__placeholder', t('common.page', { page: displayNumber, pages: pageTotal })));
1049
1197
  wrapper.addEventListener('click', (event) => {
1050
1198
  const target = event.target;
1051
1199
  if (target instanceof Element && target.closest('.omni-pdf__annotation'))
@@ -1080,6 +1228,7 @@ export async function mountPdfViewer(input, container, ctx, deps, options = {})
1080
1228
  pages.appendChild(wrapper);
1081
1229
  const record = {
1082
1230
  pageNumber,
1231
+ displayNumber,
1083
1232
  page,
1084
1233
  wrapper,
1085
1234
  rendered: false,
@@ -1091,11 +1240,10 @@ export async function mountPdfViewer(input, container, ctx, deps, options = {})
1091
1240
  // --- thumbnail (lazy-rendered — docs §5: never all at once) ---
1092
1241
  const thumb = el('button', 'omni-pdf__thumb');
1093
1242
  thumb.type = 'button';
1094
- thumb.setAttribute('aria-label', t('common.page', { page: pageNumber, pages: pageTotal }));
1243
+ thumb.setAttribute('aria-label', t('common.page', { page: displayNumber, pages: pageTotal }));
1095
1244
  const thumbCanvas = el('canvas');
1096
- const thumbViewport = page.getViewport({
1097
- scale: Math.min(0.18, 90 / (viewport.width / scale || 1))
1098
- });
1245
+ const thumbScale = Math.min(0.18, 90 / (viewport.width / scale || 1));
1246
+ const thumbViewport = page.getViewport({ scale: thumbScale });
1099
1247
  thumbCanvas.width = Math.floor(thumbViewport.width);
1100
1248
  thumbCanvas.height = Math.floor(thumbViewport.height);
1101
1249
  let thumbRendered = false;
@@ -1106,13 +1254,22 @@ export async function mountPdfViewer(input, container, ctx, deps, options = {})
1106
1254
  const thumbContext = thumbCanvas.getContext('2d');
1107
1255
  if (!thumbContext)
1108
1256
  return;
1109
- void page
1110
- .render({ canvasContext: thumbContext, viewport: thumbViewport })
1111
- .promise.catch(() => undefined);
1257
+ const task = page.render({ canvasContext: thumbContext, viewport: thumbViewport });
1258
+ thumbTasks.push(task);
1259
+ void task.promise
1260
+ .catch(() => undefined)
1261
+ .finally(() => {
1262
+ thumbTasks = thumbTasks.filter((candidate) => candidate !== task);
1263
+ });
1112
1264
  };
1113
1265
  thumbRenderers.push({ element: thumb, render: renderThumb });
1114
- const label = el('span', 'omni-pdf__thumb-label', String(pageNumber));
1115
- thumb.append(thumbCanvas, label);
1266
+ const label = el('span', 'omni-pdf__thumb-label', String(displayNumber));
1267
+ const thumbHolder = el('div', 'omni-pdf__thumb-page');
1268
+ thumbHolder.appendChild(thumbCanvas);
1269
+ thumb.append(thumbHolder, label);
1270
+ const thumbRecord = { pageNumber, holder: thumbHolder, scale: thumbScale };
1271
+ thumbRecords.push(thumbRecord);
1272
+ renderThumbOverlays(thumbRecord);
1116
1273
  thumb.addEventListener('click', () => {
1117
1274
  // Keep thumbnail navigation inside the PDF scroll container.
1118
1275
  scrollToPage(record);
@@ -1211,10 +1368,20 @@ export async function mountPdfViewer(input, container, ctx, deps, options = {})
1211
1368
  function refreshChrome() {
1212
1369
  const state = controller.state;
1213
1370
  zoomLevel.textContent = `${state.zoom}%`;
1214
- zoomOutBtn.disabled = state.zoom === PDF_ZOOM_LEVELS[0];
1215
- zoomInBtn.disabled = state.zoom === PDF_ZOOM_LEVELS[PDF_ZOOM_LEVELS.length - 1];
1371
+ zoomOutBtn.disabled = state.zoom <= controller.minZoom;
1372
+ zoomInBtn.disabled = state.zoom >= controller.maxZoom;
1373
+ const busy = operationState.status === 'running';
1216
1374
  if (editingAvailable && ctx.writeback) {
1217
- saveBtn.disabled = !(state.dirty || mergedSinceSave);
1375
+ saveBtn.disabled = busy || !(state.dirty || mergedSinceSave);
1376
+ }
1377
+ if (editingAvailable && ctx.save)
1378
+ saveAsBtn.disabled = busy;
1379
+ if (mergeAvailable && ctx.filePick)
1380
+ mergeBtn.disabled = busy;
1381
+ for (const { action, button } of customActionButtons) {
1382
+ button.disabled = typeof action.disabled === 'function'
1383
+ ? action.disabled()
1384
+ : action.disabled ?? false;
1218
1385
  }
1219
1386
  }
1220
1387
  /** Full rebuild only when zoom / page structure changed; annotation-only
@@ -1232,6 +1399,8 @@ export async function mountPdfViewer(input, container, ctx, deps, options = {})
1232
1399
  if (record.rendered)
1233
1400
  renderOverlays(record);
1234
1401
  }
1402
+ for (const thumbRecord of thumbRecords)
1403
+ renderThumbOverlays(thumbRecord);
1235
1404
  }
1236
1405
  function adoptController(next) {
1237
1406
  unsubscribe?.();
@@ -1261,80 +1430,164 @@ export async function mountPdfViewer(input, container, ctx, deps, options = {})
1261
1430
  })
1262
1431
  };
1263
1432
  }
1433
+ function isOperationCancelled(error, signal) {
1434
+ return signal.aborted || (typeof error === 'object' && error !== null
1435
+ && error.name === 'AbortError');
1436
+ }
1437
+ async function runOperation(kind, work) {
1438
+ const abortController = new AbortController();
1439
+ operationController = abortController;
1440
+ operationState = { status: 'running', kind };
1441
+ refreshChrome();
1442
+ const control = {
1443
+ signal: abortController.signal,
1444
+ onProgress(progress) {
1445
+ if (operationController !== abortController || abortController.signal.aborted)
1446
+ return;
1447
+ const normalized = progress === undefined
1448
+ ? undefined
1449
+ : Math.max(0, Math.min(1, progress));
1450
+ operationState = normalized === undefined
1451
+ ? { status: 'running', kind }
1452
+ : { status: 'running', kind, progress: normalized };
1453
+ const base = t(kind === 'merge' ? 'pdf.merging' : 'pdf.saving');
1454
+ status.textContent = normalized === undefined
1455
+ ? base
1456
+ : `${base} ${Math.round(normalized * 100)}%`;
1457
+ }
1458
+ };
1459
+ control.onProgress(undefined);
1460
+ try {
1461
+ const value = await work(control);
1462
+ if (abortController.signal.aborted || disposed) {
1463
+ throw new DOMException('PDF operation cancelled', 'AbortError');
1464
+ }
1465
+ operationState = { status: 'succeeded', kind };
1466
+ return { status: 'succeeded', value };
1467
+ }
1468
+ catch (error) {
1469
+ if (isOperationCancelled(error, abortController.signal)) {
1470
+ operationState = { status: 'cancelled', kind };
1471
+ status.textContent = t('pdf.operationCancelled');
1472
+ return { status: 'cancelled' };
1473
+ }
1474
+ operationState = { status: 'failed', kind, error: String(error) };
1475
+ return { status: 'failed' };
1476
+ }
1477
+ finally {
1478
+ if (operationController === abortController)
1479
+ operationController = undefined;
1480
+ refreshChrome();
1481
+ }
1482
+ }
1483
+ async function buildOutput(state, control) {
1484
+ if (canDelegateBuild) {
1485
+ return deps.processing.buildPdf({ source: sourceBytes, state, mode: saveMode }, control);
1486
+ }
1487
+ if (!canUseBrowserProcessing)
1488
+ throw new Error('PDF processing service unavailable');
1489
+ if (control.signal.aborted)
1490
+ throw new DOMException('PDF operation cancelled', 'AbortError');
1491
+ const lib = await ensurePdfLib();
1492
+ const output = saveMode === 'hybrid'
1493
+ ? await buildSavedPdf(lib, sourceBytes, state)
1494
+ : await buildEditedPdf(lib, sourceBytes, state);
1495
+ if (control.signal.aborted)
1496
+ throw new DOMException('PDF operation cancelled', 'AbortError');
1497
+ return output;
1498
+ }
1499
+ async function mergeOutput(second, control) {
1500
+ if (canDelegateMerge) {
1501
+ return deps.processing.mergePdfs({ first: sourceBytes, second }, control);
1502
+ }
1503
+ if (!canUseBrowserProcessing)
1504
+ throw new Error('PDF merge service unavailable');
1505
+ if (control.signal.aborted)
1506
+ throw new DOMException('PDF operation cancelled', 'AbortError');
1507
+ const lib = await ensurePdfLib();
1508
+ const output = await mergePdfBytes(lib, sourceBytes, second);
1509
+ if (control.signal.aborted)
1510
+ throw new DOMException('PDF operation cancelled', 'AbortError');
1511
+ return output;
1512
+ }
1264
1513
  async function save() {
1265
1514
  if (!ctx.writeback || saveBtn.disabled)
1266
1515
  return;
1267
- try {
1268
- const lib = await ensurePdfLib();
1269
- // Flatten from the pristine base (not the on-disk flattened copy),
1270
- // embedding the sidecar so overlays stay removable on reopen. The
1271
- // in-memory session keeps `sourceBytes` pristine + the live layer;
1272
- // we only rebaseline the dirty marker.
1273
- const data = await buildSavedPdf(lib, sourceBytes, stateForSave());
1516
+ const result = await runOperation('save', async (control) => {
1517
+ const data = await buildOutput(stateForSave(), control);
1274
1518
  await ctx.writeback.write(data);
1519
+ });
1520
+ if (result.status === 'succeeded') {
1275
1521
  mergedSinceSave = false;
1276
1522
  controller.dispatch({ type: 'mark-saved' });
1277
1523
  refreshChrome();
1278
1524
  status.textContent = t('common.savedToOriginal');
1279
1525
  }
1280
- catch (error) {
1281
- ctx.logger.log('error', `pdf save failed: ${String(error)}`);
1526
+ else if (result.status === 'failed') {
1527
+ ctx.logger.log('error', `pdf save failed: ${operationState.status === 'failed' ? operationState.error : ''}`);
1282
1528
  status.textContent = t('common.saveFailed');
1283
1529
  }
1284
1530
  }
1285
1531
  async function saveAs() {
1286
1532
  if (!ctx.save || saveAsBtn.disabled)
1287
1533
  return;
1288
- try {
1289
- const lib = await ensurePdfLib();
1290
- // Keep the same hybrid sidecar as Save so a Save As document can
1291
- // be reopened in Omni Viewer with all overlays still editable.
1292
- // Other PDF readers continue to see the flattened result.
1293
- const data = await buildSavedPdf(lib, sourceBytes, stateForSave());
1294
- await ctx.save.saveFile(savedPdfName(input.fileName), data, 'application/pdf');
1534
+ const result = await runOperation('save-as', async (control) => {
1535
+ const data = await buildOutput(stateForSave(), control);
1536
+ const saveResult = await ctx.save.saveFile(savedPdfName(input.fileName), data, 'application/pdf');
1537
+ await options.onSaveAsComplete?.(saveResult);
1538
+ if (saveResult?.status === 'cancelled') {
1539
+ throw new DOMException('Save As cancelled', 'AbortError');
1540
+ }
1541
+ });
1542
+ if (result.status === 'succeeded') {
1295
1543
  status.textContent = t('common.saved', { name: savedPdfName(input.fileName) });
1296
1544
  }
1297
- catch (error) {
1298
- ctx.logger.log('error', `pdf save-as failed: ${String(error)}`);
1545
+ else if (result.status === 'failed') {
1546
+ ctx.logger.log('error', `pdf save-as failed: ${operationState.status === 'failed' ? operationState.error : ''}`);
1299
1547
  status.textContent = t('common.saveFailed');
1300
1548
  }
1301
1549
  }
1302
1550
  async function merge() {
1303
1551
  if (!ctx.filePick || mergeBtn.disabled)
1304
1552
  return;
1305
- const picked = await ctx.filePick.pickFile({ accept: ['application/pdf', '.pdf'] });
1553
+ const picked = await ctx.filePick.pickFile({
1554
+ accept: ['application/pdf', '.pdf'],
1555
+ ...(options.maxMergeBytes === undefined ? {} : { maxBytes: options.maxMergeBytes })
1556
+ });
1306
1557
  if (!picked || disposed)
1307
1558
  return;
1308
- try {
1309
- status.textContent = t('pdf.merging');
1310
- const lib = await ensurePdfLib();
1559
+ const result = await runOperation('merge', async (control) => {
1311
1560
  // `sourceBytes` is the pristine base while the controller holds
1312
1561
  // the live reorder/delete/annotation layer. Keep that layer and
1313
1562
  // append the new source pages to its current visible order.
1314
1563
  const previousState = controller.state;
1315
- const previousSourcePageCount = doc?.numPages ?? sourceBytes.length;
1316
- const merged = await mergePdfBytes(lib, sourceBytes, picked.data);
1317
- await doc?.destroy();
1318
- doc = await loadDocument(merged);
1564
+ const previousSourcePageCount = doc?.numPages ?? 0;
1565
+ const merged = await mergeOutput(picked.data, control);
1566
+ const candidate = await loadDocument(merged);
1319
1567
  if (disposed) {
1320
- void doc.destroy();
1321
- return;
1568
+ void candidate.destroy();
1569
+ throw new DOMException('PDF operation cancelled', 'AbortError');
1322
1570
  }
1571
+ const previousDocument = doc;
1572
+ doc = candidate;
1323
1573
  sourceBytes = merged;
1324
1574
  currentPage = 1;
1325
1575
  // The merged pages exist only in memory until the user saves.
1326
1576
  mergedSinceSave = true;
1327
1577
  const appendedPages = Array.from({ length: Math.max(0, doc.numPages - previousSourcePageCount) }, (_, index) => previousSourcePageCount + index + 1);
1328
- adoptController(createPdfController(doc.numPages, {
1578
+ adoptController(createPdfController(candidate.numPages, {
1329
1579
  pageOrder: [...previousState.pageOrder, ...appendedPages],
1330
1580
  annotations: previousState.annotations
1331
- }));
1581
+ }, controllerOptions));
1582
+ await previousDocument?.destroy();
1332
1583
  refreshChrome();
1333
1584
  await rebuildLayout();
1585
+ });
1586
+ if (result.status === 'succeeded') {
1334
1587
  status.textContent = t('pdf.mergeComplete');
1335
1588
  }
1336
- catch (error) {
1337
- ctx.logger.log('error', `pdf merge failed: ${String(error)}`);
1589
+ else if (result.status === 'failed') {
1590
+ ctx.logger.log('error', `pdf merge failed: ${operationState.status === 'failed' ? operationState.error : ''}`);
1338
1591
  status.textContent = t('pdf.mergeFailed');
1339
1592
  }
1340
1593
  }
@@ -1362,12 +1615,17 @@ export async function mountPdfViewer(input, container, ctx, deps, options = {})
1362
1615
  }
1363
1616
  status.classList.add('omni-pdf__status--error');
1364
1617
  // Stable failed handle: one empty controller, normal dispose.
1365
- const failedController = createPdfController(0);
1618
+ const failedController = createPdfController(0, undefined, controllerOptions);
1366
1619
  return {
1367
1620
  get controller() {
1368
1621
  return failedController;
1369
1622
  },
1623
+ get operation() {
1624
+ return operationState;
1625
+ },
1370
1626
  isDirty: () => false,
1627
+ cancelOperation: () => operationController?.abort(),
1628
+ refreshToolbarActions: refreshChrome,
1371
1629
  dispose: teardown
1372
1630
  };
1373
1631
  }
@@ -1379,14 +1637,33 @@ export async function mountPdfViewer(input, container, ctx, deps, options = {})
1379
1637
  // overlays as a removable layer.
1380
1638
  const sidecar = await readSidecar(doc);
1381
1639
  if (sidecar) {
1382
- await doc.destroy();
1383
- sourceBytes = sidecar.base;
1384
- doc = await loadDocument(sourceBytes, password);
1385
- throwIfAborted();
1386
- adoptController(createPdfController(doc.numPages, sidecar.layer));
1640
+ let baseDocument;
1641
+ try {
1642
+ baseDocument = await loadDocument(sidecar.base, password);
1643
+ throwIfAborted();
1644
+ }
1645
+ catch (error) {
1646
+ throwIfAborted();
1647
+ ctx.logger.log('warn', `pdf sidecar base ignored: ${String(error)}`);
1648
+ }
1649
+ if (baseDocument) {
1650
+ const flattenedDocument = doc;
1651
+ doc = baseDocument;
1652
+ sourceBytes = sidecar.base;
1653
+ adoptController(createPdfController(doc.numPages, sidecar.layer, controllerOptions));
1654
+ try {
1655
+ await flattenedDocument.destroy();
1656
+ }
1657
+ catch (error) {
1658
+ ctx.logger.log('warn', `pdf flattened document cleanup failed: ${String(error)}`);
1659
+ }
1660
+ }
1661
+ else {
1662
+ adoptController(createPdfController(doc.numPages, undefined, controllerOptions));
1663
+ }
1387
1664
  }
1388
1665
  else {
1389
- adoptController(createPdfController(doc.numPages));
1666
+ adoptController(createPdfController(doc.numPages, undefined, controllerOptions));
1390
1667
  }
1391
1668
  refreshChrome();
1392
1669
  await rebuildLayout();
@@ -1395,7 +1672,12 @@ export async function mountPdfViewer(input, container, ctx, deps, options = {})
1395
1672
  get controller() {
1396
1673
  return controller;
1397
1674
  },
1675
+ get operation() {
1676
+ return operationState;
1677
+ },
1398
1678
  isDirty: () => controller.state.dirty || mergedSinceSave,
1679
+ cancelOperation: () => operationController?.abort(),
1680
+ refreshToolbarActions: refreshChrome,
1399
1681
  dispose: teardown
1400
1682
  };
1401
1683
  }