omni-viewer-core 0.1.0 → 0.3.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 (45) hide show
  1. package/dist/i18n/catalog.de.d.ts +1 -0
  2. package/dist/i18n/catalog.de.js +68 -0
  3. package/dist/i18n/catalog.en.js +3 -0
  4. package/dist/i18n/catalog.fr.d.ts +1 -0
  5. package/dist/i18n/catalog.fr.js +68 -0
  6. package/dist/i18n/catalog.it.d.ts +1 -0
  7. package/dist/i18n/catalog.it.js +68 -0
  8. package/dist/i18n/catalog.ja.d.ts +1 -0
  9. package/dist/i18n/catalog.ja.js +288 -0
  10. package/dist/i18n/catalog.ko.js +372 -26
  11. package/dist/i18n/catalog.th.d.ts +1 -0
  12. package/dist/i18n/catalog.th.js +68 -0
  13. package/dist/i18n/catalog.zh-cn.d.ts +1 -0
  14. package/dist/i18n/catalog.zh-cn.js +265 -0
  15. package/dist/i18n/catalog.zh-tw.d.ts +1 -0
  16. package/dist/i18n/catalog.zh-tw.js +52 -0
  17. package/dist/i18n/index.d.ts +7 -0
  18. package/dist/i18n/index.js +35 -2
  19. package/dist/parsers/archive/index.d.ts +16 -2
  20. package/dist/parsers/archive/index.js +39 -11
  21. package/dist/parsers/archive/model.d.ts +20 -0
  22. package/dist/parsers/audio/index.d.ts +3 -0
  23. package/dist/parsers/audio/index.js +5 -0
  24. package/dist/parsers/pptx/index.d.ts +2 -1
  25. package/dist/parsers/pptx/index.js +5 -2
  26. package/dist/parsers/video/index.d.ts +3 -0
  27. package/dist/parsers/video/index.js +3 -0
  28. package/dist/styles/archive.css +1 -0
  29. package/dist/styles/audio.css +8 -0
  30. package/dist/styles/ppt.css +1 -1
  31. package/dist/viewers/archive/index.d.ts +17 -1
  32. package/dist/viewers/archive/index.js +124 -21
  33. package/dist/viewers/archive/styles.d.ts +1 -1
  34. package/dist/viewers/archive/styles.js +1 -0
  35. package/dist/viewers/audio/styles.js +8 -0
  36. package/dist/viewers/pdf/editing.d.ts +6 -4
  37. package/dist/viewers/pdf/editing.js +47 -13
  38. package/dist/viewers/pdf/index.js +11 -10
  39. package/dist/viewers/ppt/chart.js +33 -7
  40. package/dist/viewers/ppt/render.js +53 -8
  41. package/dist/viewers/ppt/styles.d.ts +1 -1
  42. package/dist/viewers/ppt/styles.js +1 -1
  43. package/package.json +2 -2
  44. package/dist/viewers/hwp/self-loading.d.ts +0 -2
  45. package/dist/viewers/hwp/self-loading.js +0 -6
@@ -1,2 +1,5 @@
1
1
  const MIME = { mp4: 'video/mp4', mts: 'video/mp2t', m2ts: 'video/mp2t', avi: 'video/x-msvideo', mov: 'video/quicktime', wmv: 'video/x-ms-wmv', flv: 'video/x-flv', webm: 'video/webm', mkv: 'video/x-matroska' };
2
2
  export function parseVideoInfo(fileName, data) { const ext = fileName.toLowerCase().split('.').pop() ?? ''; return { format: ext.toUpperCase() || 'VIDEO', mimeType: MIME[ext] ?? 'application/octet-stream', warnings: data.byteLength ? [] : ['The video file is empty.'] }; }
3
+ /** Returns the video MIME type for a file name's extension, or undefined when
4
+ * the extension is not a recognized video format. */
5
+ export function videoMimeType(fileName) { return MIME[fileName.toLowerCase().split('.').pop() ?? '']; }
@@ -7,4 +7,5 @@
7
7
  .omni-archive__workspace{display:grid;grid-template-columns:minmax(0,1.7fr) minmax(320px,.95fr);gap:16px;align-items:start}.omni-archive__table-wrap{overflow:auto;min-width:0;max-height:70vh}.omni-archive__table{width:100%;table-layout:fixed;border-collapse:collapse}.omni-archive__table th,.omni-archive__table td{padding:12px 14px;border-bottom:1px solid var(--omni-border,#3c3c3c);text-align:left;vertical-align:top;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.omni-archive__table th:first-child{width:42%}.omni-archive__table th{position:sticky;top:0;font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:var(--omni-muted-fg,#9d9d9d);background:var(--omni-bg-secondary,#252526);z-index:1}.omni-archive__path{font-family:var(--omni-mono-font,"SFMono-Regular",Consolas,monospace)}.omni-archive__entry{height:43px;cursor:pointer;transition:background-color 120ms ease}.omni-archive__entry:hover{background:color-mix(in srgb,var(--omni-accent,#d97706) 8%,transparent)}.omni-archive__entry:focus-visible{outline:2px solid var(--omni-accent,#d97706);outline-offset:-2px}.omni-archive__entry.is-selected{background:color-mix(in srgb,var(--omni-accent,#d97706) 16%,transparent)}.omni-archive__empty{padding:26px 18px;text-align:center;color:var(--omni-muted-fg,#9d9d9d)}
8
8
  .omni-archive__preview-panel{padding:18px;min-height:420px;display:flex;flex-direction:column;gap:14px}.omni-archive__preview-header{display:flex;justify-content:space-between;gap:12px;align-items:flex-start}.omni-archive__preview-kicker{font-size:12px;letter-spacing:.08em;text-transform:uppercase;color:var(--omni-muted-fg,#9d9d9d);margin-bottom:6px}.omni-archive__preview-header h2{margin:0;font-size:18px;line-height:1.35;word-break:break-word}.omni-archive__preview-badge{border-radius:999px;padding:8px 12px;font-size:12px;font-weight:700;background:var(--omni-badge-bg,#3a3d41);white-space:nowrap}.omni-archive__preview-meta{margin:0}.omni-archive__preview{margin:0;flex:1;overflow:auto;white-space:pre-wrap;word-break:break-word;border-radius:14px;border:1px solid var(--omni-border,#3c3c3c);background:var(--omni-code-bg,#181818);padding:14px;color:var(--omni-fg,#d4d4d4);font:12px/1.55 var(--omni-mono-font,"SFMono-Regular",Consolas,monospace)}
9
9
  .omni-archive__save{align-self:flex-start;padding:8px 12px;border:1px solid var(--omni-border,#3c3c3c);border-radius:6px;background:var(--omni-button-bg,#3a3d41);color:var(--omni-fg,#d4d4d4);font:inherit;cursor:pointer}.omni-archive__save:hover:not(:disabled){background:var(--omni-button-hover-bg,#45494e)}.omni-archive__save:disabled{opacity:.55;cursor:not-allowed}.omni-archive__spacer td{padding:0!important;border:0!important}
10
+ .omni-archive__preview-media{flex:1;display:flex;align-items:center;justify-content:center;border-radius:14px;border:1px solid var(--omni-border,#3c3c3c);background:var(--omni-code-bg,#181818);padding:14px;overflow:auto}.omni-archive__audio{width:100%}.omni-archive__video{max-width:100%;max-height:100%}.omni-archive__image{max-width:100%;max-height:100%;object-fit:contain}
10
11
  @media(max-width:720px){.omni-viewer--archive{padding:16px 10px 24px}.omni-archive__hero{flex-direction:column}.omni-archive__pills{justify-content:flex-start}.omni-archive__table th:nth-child(n+3),.omni-archive__table td:nth-child(n+3){display:none}.omni-archive__workspace{grid-template-columns:1fr}.omni-archive__preview-panel{min-height:280px}}
@@ -55,6 +55,14 @@
55
55
  .omni-audio__spectrogram { min-height: 0; }
56
56
  .omni-audio__spectrogram--active { min-height: 200px; }
57
57
 
58
+ /* Keep the WaveSurfer playhead (cursor + played-region overlay) pinned to the
59
+ waveform strip. Some wavesurfer spectrogram builds render the spectrogram
60
+ inside the main wrapper instead of our dedicated container; without this the
61
+ full-height playhead sweeps down across the spectrogram ("progress bar"
62
+ running along the bottom) rather than staying with the waveform at the top. */
63
+ .omni-audio__waveform ::part(cursor),
64
+ .omni-audio__waveform ::part(progress) { height: 128px !important; }
65
+
58
66
  .omni-audio__status { color: var(--omni-muted, #9d9d9d); font-size: 12px; min-height: 16px; }
59
67
 
60
68
  .omni-audio__loading { display: flex; justify-content: center; align-items: center; min-height: 120px; color: var(--omni-muted, #9d9d9d); }
@@ -1 +1 @@
1
- :host,.omni-viewer--ppt{color:var(--omni-fg,#ddd);font:13px system-ui}.omni-ppt{height:100%;display:flex;flex-direction:column;background:var(--omni-bg,#181818)}.omni-ppt__toolbar{display:flex;gap:6px;align-items:center;padding:8px;border-bottom:1px solid var(--omni-border,#444)}.omni-ppt__toolbar button,.omni-ppt__toolbar select{background:var(--omni-control-bg,#292929);color:inherit;border:1px solid var(--omni-border,#555);border-radius:4px;padding:4px 8px}.omni-ppt__viewport{overflow:auto;flex:1;padding:20px}.omni-ppt__slides{display:flex;flex-direction:column;align-items:center;gap:20px}.omni-ppt__slide{position:relative;overflow:hidden;box-shadow:0 2px 12px #0008;transform-origin:top center;flex:none}.omni-ppt__element{position:absolute;box-sizing:border-box;overflow:hidden}.omni-ppt__text{white-space:pre-wrap}.omni-ppt__text p{margin:0}.omni-ppt__image{width:100%;height:100%;object-fit:contain}.omni-ppt__table{border-collapse:collapse;width:100%;height:100%;color:#111}.omni-ppt__table td{border:1px solid #777;padding:3px}.omni-ppt__shape{width:100%;height:100%}.omni-ppt__empty{padding:32px;text-align:center}
1
+ :host,.omni-viewer--ppt{color:var(--omni-fg,#ddd);font:13px system-ui}.omni-ppt{height:100%;display:flex;flex-direction:column;background:var(--omni-bg,#181818)}.omni-ppt__toolbar{display:flex;gap:6px;align-items:center;padding:8px;border-bottom:1px solid var(--omni-border,#444)}.omni-ppt__toolbar button,.omni-ppt__toolbar select{background:var(--omni-control-bg,#292929);color:inherit;border:1px solid var(--omni-border,#555);border-radius:4px;padding:4px 8px}.omni-ppt__viewport{overflow:auto;flex:1;padding:20px}.omni-ppt__slides{display:flex;flex-direction:column;align-items:center;gap:20px}.omni-ppt__slide{position:relative;overflow:hidden;border-radius:6px;box-shadow:0 2px 12px #0008;transform-origin:top center;flex:none}.omni-ppt__element{position:absolute;box-sizing:border-box;overflow:hidden}.omni-ppt__text{padding:2px;white-space:pre-wrap;word-break:break-word}.omni-ppt__text p{margin:0 0 4px}.omni-ppt__text[data-text-preset="cover-title"] .omni-ppt__text-line,.omni-ppt__text[data-text-preset="cover-subtitle"] .omni-ppt__text-line{margin-bottom:0}.omni-ppt__image{width:100%;height:100%;object-fit:cover;object-position:center}.omni-ppt__table-frame{background:#fff;border:1px solid #bdbdbd;overflow:auto}.omni-ppt__table{border-collapse:collapse;width:100%;font-size:12px;color:#111}.omni-ppt__table th,.omni-ppt__table td{border:1px solid #d3d3d3;padding:4px 6px;vertical-align:top}.omni-ppt__chart{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;background:#f7f7f7;border:1px dashed #9a9a9a;color:#333}.omni-ppt__chart--rendered{background:transparent;border:0}.omni-ppt__chart-title{font-size:14px;font-weight:700;margin-bottom:4px}.omni-ppt__chart-subtitle{font-size:12px;opacity:.8}.omni-ppt__shape{width:100%;height:100%}.omni-ppt__empty{padding:32px;text-align:center}
@@ -1,5 +1,5 @@
1
1
  import type { FileSaveService, HostContext } from '../../host/index.js';
2
- import { type ArchiveDecoder } from '../../parsers/archive/index.js';
2
+ import { type ArchiveDecoder, type ArchiveEntrySaver, type ArchiveStreamDecoder } from '../../parsers/archive/index.js';
3
3
  import type { ResourceLimits } from '../../parsers/types.js';
4
4
  import { type MountOptions, type ViewerHandle, type ViewerInput } from '../types.js';
5
5
  export * from '../../parsers/archive/index.js';
@@ -17,9 +17,25 @@ export declare const ARCHIVE_VIEWER_META: {
17
17
  export interface ArchiveViewerDeps extends ArchiveDecoder {
18
18
  }
19
19
  export type ArchiveViewerContext = HostContext & {
20
+ /** Buffered save: core extracts the entry and hands over its bytes. */
20
21
  save?: FileSaveService;
22
+ /** Streaming save: the adapter pipes the entry to disk itself, so a
23
+ * multi-GB entry is never materialized in memory. Preferred over `save`
24
+ * when present. */
25
+ saveEntry?: ArchiveEntrySaver;
21
26
  };
22
27
  export interface ArchiveMountOptions extends MountOptions {
23
28
  limits?: ResourceLimits;
29
+ createObjectUrl?(blob: Blob): string;
30
+ revokeObjectUrl?(url: string): void;
31
+ }
32
+ /** Lazy streaming input: the adapter's path-based decoder opens the archive
33
+ * without core ever holding the full file bytes (opt-in, opposite the buffered
34
+ * {@link ViewerInput} path used by vscode/chrome/web). */
35
+ export interface ArchiveStreamingSource extends ArchiveStreamDecoder {
36
+ fileName: string;
37
+ totalSize?: number;
38
+ lastModified?: number;
24
39
  }
25
40
  export declare function mountArchiveViewer(input: ViewerInput, container: HTMLElement, ctx: ArchiveViewerContext, deps: ArchiveViewerDeps, options?: ArchiveMountOptions): Promise<ViewerHandle>;
41
+ export declare function mountArchiveViewer(source: ArchiveStreamingSource, container: HTMLElement, ctx: ArchiveViewerContext, options?: ArchiveMountOptions): Promise<ViewerHandle>;
@@ -1,4 +1,7 @@
1
- import { ARCHIVE_DEFAULT_LIMITS, ArchiveError, parseArchive } from '../../parsers/archive/index.js';
1
+ import { ARCHIVE_DEFAULT_LIMITS, ArchiveError, openArchiveStream, parseArchive } from '../../parsers/archive/index.js';
2
+ import { audioMimeType } from '../../parsers/audio/index.js';
3
+ import { videoMimeType } from '../../parsers/video/index.js';
4
+ import { detectImageMime } from '../image/decode.js';
2
5
  import { MountAbortedError, VIEWER_ROOT_CLASS } from '../types.js';
3
6
  import { createArchiveController } from './controller.js';
4
7
  import { tryDecodeArchiveEntryPreview } from './archive-preview-decoder.js';
@@ -8,6 +11,17 @@ export * from '../../parsers/archive/index.js';
8
11
  export { createArchiveController } from './controller.js';
9
12
  export { archiveViewerCss } from './styles.js';
10
13
  export const ARCHIVE_VIEWER_META = { id: 'archive', displayNameKey: 'archive.title', extensions: ['zip', 'jar', 'apk', 'tar', 'tgz', 'tar.gz', 'tbz', 'tbz2', 'tar.bz2', 'txz', 'tar.xz', 'gz', 'bz2', 'xz', '7z', 'rar', 'dmg'], priority: 5, requiredServices: [], optionalServices: ['save'], inputOwnership: 'consumes' };
14
+ /** Ceiling for extracting an audio entry into memory for inline playback.
15
+ * Larger than the text/hex preview budget, but bounded to avoid huge allocations. */
16
+ const ARCHIVE_AUDIO_PREVIEW_BYTES = 64 * 1024 * 1024;
17
+ /** Ceiling for extracting an image entry into memory for inline preview. */
18
+ const ARCHIVE_IMAGE_PREVIEW_BYTES = 32 * 1024 * 1024;
19
+ /** Ceiling for extracting a video entry into memory for inline playback. The
20
+ * whole entry is buffered into a blob URL, so this is the largest of the caps. */
21
+ const ARCHIVE_VIDEO_PREVIEW_BYTES = 128 * 1024 * 1024;
22
+ const IMAGE_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg']);
23
+ const hasImageExtension = (path) => IMAGE_EXTENSIONS.has(path.toLowerCase().split('.').pop() ?? '');
24
+ const blobPart = (data) => (data.buffer instanceof ArrayBuffer ? data : new Uint8Array(data));
11
25
  const element = (tag, className, text) => {
12
26
  const node = document.createElement(tag);
13
27
  if (className)
@@ -39,10 +53,26 @@ const archiveFormat = (name) => {
39
53
  const lower = name.toLocaleLowerCase();
40
54
  return ['tar.gz', 'tar.bz2', 'tar.xz'].find(ext => lower.endsWith(`.${ext}`))?.toUpperCase() ?? (lower.split('.').pop()?.toUpperCase() || 'ARCHIVE');
41
55
  };
42
- export async function mountArchiveViewer(input, container, ctx, deps, options = {}) {
56
+ export async function mountArchiveViewer(inputOrSource, container, ctx, depsOrOptions, maybeOptions) {
57
+ const streaming = !('data' in inputOrSource);
58
+ const options = (streaming ? depsOrOptions : maybeOptions) ?? {};
43
59
  if (options.signal?.aborted)
44
60
  throw new MountAbortedError();
45
- const parsed = await parseArchive(input.data, deps, { fileName: input.fileName, ...(options.signal ? { signal: options.signal } : {}), ...(options.limits ? { limits: options.limits } : {}) });
61
+ let fileName;
62
+ let totalBytes;
63
+ let parsed;
64
+ if (streaming) {
65
+ const source = inputOrSource;
66
+ fileName = source.fileName;
67
+ totalBytes = source.totalSize;
68
+ parsed = await openArchiveStream(source, { fileName, ...(source.totalSize !== undefined ? { totalSize: source.totalSize } : {}), ...(options.signal ? { signal: options.signal } : {}), ...(options.limits ? { limits: options.limits } : {}) });
69
+ }
70
+ else {
71
+ const input = inputOrSource;
72
+ fileName = input.fileName;
73
+ totalBytes = input.data.byteLength;
74
+ parsed = await parseArchive(input.data, depsOrOptions, { fileName, ...(options.signal ? { signal: options.signal } : {}), ...(options.limits ? { limits: options.limits } : {}) });
75
+ }
46
76
  if (parsed.outcome.result.status === 'failed')
47
77
  throw new Error(parsed.outcome.result.failure.messageKey);
48
78
  if (options.signal?.aborted) {
@@ -64,9 +94,9 @@ export async function mountArchiveViewer(input, container, ctx, deps, options =
64
94
  const wrap = element('section', `${VIEWER_ROOT_CLASS} omni-viewer--archive`);
65
95
  const hero = element('header', 'omni-archive__hero');
66
96
  const heading = element('div');
67
- heading.append(element('div', 'omni-archive__eyebrow', t('archive.preview')), element('h1', '', input.fileName), element('p', 'omni-archive__subtitle', t('archive.summary')));
97
+ heading.append(element('div', 'omni-archive__eyebrow', t('archive.preview')), element('h1', '', fileName), element('p', 'omni-archive__subtitle', t('archive.summary')));
68
98
  const pills = element('div', 'omni-archive__pills');
69
- pills.append(element('span', 'omni-archive__pill', archiveFormat(input.fileName)), element('span', 'omni-archive__pill', t('archive.entries', { count: entries.length })), element('span', 'omni-archive__pill', formatSize(input.data.byteLength)));
99
+ pills.append(element('span', 'omni-archive__pill', archiveFormat(fileName)), element('span', 'omni-archive__pill', t('archive.entries', { count: entries.length })), element('span', 'omni-archive__pill', formatSize(totalBytes)));
70
100
  hero.append(heading, pills);
71
101
  const controls = element('section', 'omni-archive__controls');
72
102
  const label = element('label', 'omni-archive__search-label', t('archive.filter'));
@@ -107,11 +137,13 @@ export async function mountArchiveViewer(input, container, ctx, deps, options =
107
137
  const previewMeta = element('p', 'omni-archive__preview-meta', t('archive.selectHint'));
108
138
  const preview = element('pre', 'omni-archive__preview', t('archive.noSelection'));
109
139
  preview.setAttribute('aria-live', 'polite');
140
+ const previewMedia = element('div', 'omni-archive__preview-media');
141
+ previewMedia.style.display = 'none';
110
142
  const saveButton = element('button', 'omni-archive__save', t('archive.saveEntry'));
111
143
  saveButton.type = 'button';
112
- saveButton.hidden = !ctx.save;
144
+ saveButton.hidden = !ctx.save && !ctx.saveEntry;
113
145
  saveButton.disabled = true;
114
- previewPanel.append(previewHeader, previewMeta, preview, saveButton);
146
+ previewPanel.append(previewHeader, previewMeta, preview, previewMedia, saveButton);
115
147
  workspace.append(tableWrap, previewPanel);
116
148
  wrap.append(hero, controls, stats, workspace);
117
149
  root.append(wrap);
@@ -119,7 +151,52 @@ export async function mountArchiveViewer(input, container, ctx, deps, options =
119
151
  let extraction;
120
152
  let saveExtraction;
121
153
  let selectedEntry;
122
- const setPreview = (entry, status, meta, content) => { previewTitle.textContent = entry?.path ?? t('archive.chooseFile'); previewStatus.textContent = status; previewMeta.textContent = meta; preview.textContent = content; };
154
+ let previewUrl;
155
+ const createUrl = options.createObjectUrl ?? URL.createObjectURL.bind(URL);
156
+ const revokeUrl = options.revokeObjectUrl ?? URL.revokeObjectURL.bind(URL);
157
+ // Inline display beats author CSS (.omni-archive__preview-media sets display:flex,
158
+ // which would otherwise override the [hidden] attribute), so the text preview and
159
+ // the media preview stay reliably mutually exclusive.
160
+ const clearPreviewMedia = () => { if (previewUrl) {
161
+ revokeUrl(previewUrl);
162
+ previewUrl = undefined;
163
+ } previewMedia.replaceChildren(); previewMedia.style.display = 'none'; preview.style.display = ''; };
164
+ const setPreview = (entry, status, meta, content) => { clearPreviewMedia(); previewTitle.textContent = entry?.path ?? t('archive.chooseFile'); previewStatus.textContent = status; previewMeta.textContent = meta; preview.textContent = content; };
165
+ const MEDIA_META = { audio: 'archive.audioReady', image: 'archive.imageReady', video: 'archive.videoReady' };
166
+ const showMediaPreview = (entry, data, kind, mimeType) => {
167
+ clearPreviewMedia();
168
+ let objectUrl;
169
+ try {
170
+ objectUrl = createUrl(new Blob([blobPart(data)], { type: mimeType }));
171
+ }
172
+ catch {
173
+ setPreview(entry, t('archive.previewUnavailable'), t('archive.mediaUnsupported'), t('archive.previewUnavailable'));
174
+ return;
175
+ }
176
+ previewUrl = objectUrl;
177
+ let node;
178
+ if (kind === 'image') {
179
+ const img = document.createElement('img');
180
+ img.className = 'omni-archive__image';
181
+ img.alt = entry.path;
182
+ img.src = objectUrl;
183
+ node = img;
184
+ }
185
+ else {
186
+ const media = document.createElement(kind);
187
+ media.controls = true;
188
+ media.preload = 'metadata';
189
+ media.className = `omni-archive__${kind}`;
190
+ media.src = objectUrl;
191
+ node = media;
192
+ }
193
+ previewMedia.replaceChildren(node);
194
+ previewMedia.style.display = 'flex';
195
+ preview.style.display = 'none';
196
+ previewTitle.textContent = entry.path;
197
+ previewStatus.textContent = t('archive.ready');
198
+ previewMeta.textContent = t(MEDIA_META[kind]);
199
+ };
123
200
  const select = async (entry) => {
124
201
  extraction?.abort();
125
202
  extraction = undefined;
@@ -138,7 +215,10 @@ export async function mountArchiveViewer(input, container, ctx, deps, options =
138
215
  extraction = new AbortController();
139
216
  const request = extraction;
140
217
  setPreview(entry, t('archive.loading'), t('archive.loadingHint'), t('archive.loadingContent'));
141
- const maxPreview = ARCHIVE_DEFAULT_LIMITS.maxPreviewBytes;
218
+ const audioMime = audioMimeType(entry.path);
219
+ const videoMime = !audioMime ? videoMimeType(entry.path) : undefined;
220
+ const imageCandidate = !audioMime && !videoMime && hasImageExtension(entry.path);
221
+ const maxPreview = audioMime ? ARCHIVE_AUDIO_PREVIEW_BYTES : videoMime ? ARCHIVE_VIDEO_PREVIEW_BYTES : imageCandidate ? ARCHIVE_IMAGE_PREVIEW_BYTES : ARCHIVE_DEFAULT_LIMITS.maxPreviewBytes;
142
222
  if (entry.uncompressedSize !== undefined && entry.uncompressedSize > maxPreview) {
143
223
  setPreview(entry, t('archive.previewUnavailable'), t('diag.archive.limit-exceeded'), t('archive.previewUnavailable'));
144
224
  return;
@@ -147,6 +227,21 @@ export async function mountArchiveViewer(input, container, ctx, deps, options =
147
227
  const data = await handle.extract(entry.entryId, { signal: request.signal, maxBytes: maxPreview });
148
228
  if (local !== ticket)
149
229
  return;
230
+ if (audioMime) {
231
+ showMediaPreview(entry, data, 'audio', audioMime);
232
+ return;
233
+ }
234
+ if (videoMime) {
235
+ showMediaPreview(entry, data, 'video', videoMime);
236
+ return;
237
+ }
238
+ if (imageCandidate) {
239
+ const imageMime = detectImageMime(data, entry.path);
240
+ if (imageMime) {
241
+ showMediaPreview(entry, data, 'image', imageMime);
242
+ return;
243
+ }
244
+ }
150
245
  const decoded = tryDecodeArchiveEntryPreview(entry.path, data);
151
246
  const text = isText(data);
152
247
  setPreview(entry, t('archive.ready'), t(decoded ? 'archive.androidBinaryXmlReady' : text ? 'archive.textReady' : 'archive.binaryReady'), decoded?.content ?? (text ? new TextDecoder().decode(data) : hexPreview(data)));
@@ -158,24 +253,32 @@ export async function mountArchiveViewer(input, container, ctx, deps, options =
158
253
  };
159
254
  saveButton.addEventListener('click', async () => {
160
255
  const entry = selectedEntry;
161
- const save = ctx.save;
162
- if (!entry || entry.isDirectory || !save || !handle)
256
+ if (!entry || entry.isDirectory || !handle || (!ctx.save && !ctx.saveEntry))
163
257
  return;
164
258
  saveExtraction?.abort();
165
259
  saveExtraction = new AbortController();
166
260
  const request = saveExtraction;
167
261
  saveButton.disabled = true;
168
262
  saveButton.textContent = t('archive.savingEntry');
263
+ const suggestedName = entry.path.split('/').filter(Boolean).pop() ?? 'archive-entry';
169
264
  try {
170
- const configuredLimit = options.limits?.maxDecompressedBytes ?? ARCHIVE_DEFAULT_LIMITS.maxDecompressedBytes;
171
- const maxBytes = Math.max(1, Math.min(entry.uncompressedSize ?? configuredLimit, configuredLimit));
172
- const data = await handle.extract(entry.entryId, { signal: request.signal, maxBytes });
173
- if (request.signal.aborted)
174
- return;
175
- const suggestedName = entry.path.split('/').filter(Boolean).pop() ?? 'archive-entry';
176
- await save.saveFile(suggestedName, data, entry.mimeType ?? 'application/octet-stream');
177
- if (!request.signal.aborted)
178
- previewMeta.textContent = t('common.saved', { name: suggestedName });
265
+ // Streaming save (adapter pipes the entry to disk) is preferred: a
266
+ // multi-GB entry never lands in memory. Buffered save is the fallback.
267
+ if (ctx.saveEntry) {
268
+ const savedName = await ctx.saveEntry.saveEntry(entry, { signal: request.signal });
269
+ if (!request.signal.aborted && savedName)
270
+ previewMeta.textContent = t('common.saved', { name: savedName });
271
+ }
272
+ else {
273
+ const configuredLimit = options.limits?.maxDecompressedBytes ?? ARCHIVE_DEFAULT_LIMITS.maxDecompressedBytes;
274
+ const maxBytes = Math.max(1, Math.min(entry.uncompressedSize ?? configuredLimit, configuredLimit));
275
+ const data = await handle.extract(entry.entryId, { signal: request.signal, maxBytes });
276
+ if (request.signal.aborted)
277
+ return;
278
+ await ctx.save.saveFile(suggestedName, data, entry.mimeType ?? 'application/octet-stream');
279
+ if (!request.signal.aborted)
280
+ previewMeta.textContent = t('common.saved', { name: suggestedName });
281
+ }
179
282
  }
180
283
  catch {
181
284
  if (!request.signal.aborted)
@@ -226,5 +329,5 @@ export async function mountArchiveViewer(input, container, ctx, deps, options =
226
329
  search.addEventListener('input', () => { tableWrap.scrollTop = 0; controller.dispatch({ type: 'set-search', query: search.value }); });
227
330
  const unsubscribe = controller.subscribe(render);
228
331
  render();
229
- return { dispose() { ticket++; extraction?.abort(); saveExtraction?.abort(); unsubscribe(); wrap.remove(); void handle?.close(); handle = undefined; } };
332
+ return { dispose() { ticket++; extraction?.abort(); saveExtraction?.abort(); clearPreviewMedia(); unsubscribe(); wrap.remove(); void handle?.close(); handle = undefined; } };
230
333
  }
@@ -1 +1 @@
1
- export declare const archiveViewerCss = "\n.omni-viewer--archive{all:initial}.omni-viewer--archive :where(header,section,aside,div,h1,h2,p,span,label,input,table,thead,tbody,tr,th,td,pre){all:revert}\n:host,.omni-viewer--archive{display:block;min-height:100%;box-sizing:border-box;color:var(--omni-fg,#d4d4d4);background:radial-gradient(circle at top left,color-mix(in srgb,var(--omni-accent,#d97706) 14%,transparent),transparent 28%),var(--omni-bg,#1e1e1e);font:13px var(--omni-font,-apple-system,BlinkMacSystemFont,\"Segoe UI\",sans-serif)}\n.omni-viewer--archive *{box-sizing:border-box}.omni-viewer--archive{padding:28px 20px 40px}.omni-archive__hero,.omni-archive__controls,.omni-archive__stats,.omni-archive__table-wrap,.omni-archive__preview-panel{background:var(--omni-bg-secondary,#252526);border:1px solid var(--omni-border,#3c3c3c);border-radius:18px;box-shadow:0 20px 40px rgba(0,0,0,.12)}\n.omni-archive__hero{display:flex;justify-content:space-between;gap:16px;padding:24px;align-items:flex-start}.omni-archive__eyebrow{font-size:12px;letter-spacing:.18em;text-transform:uppercase;color:var(--omni-accent,#d97706);font-weight:700}.omni-archive__hero h1{margin:8px 0 6px;font-size:clamp(26px,4vw,40px);line-height:1.1}.omni-archive__subtitle,.omni-archive__preview-meta{color:var(--omni-muted-fg,#9d9d9d);line-height:1.5}.omni-archive__pills{display:flex;gap:10px;flex-wrap:wrap;justify-content:flex-end}.omni-archive__pill{padding:10px 14px;border-radius:999px;background:color-mix(in srgb,var(--omni-accent,#d97706) 16%,transparent);border:1px solid color-mix(in srgb,var(--omni-accent,#d97706) 35%,transparent);font-weight:600;white-space:nowrap}\n.omni-archive__controls,.omni-archive__stats,.omni-archive__workspace{margin-top:16px}.omni-archive__controls{padding:16px}.omni-archive__search-label{display:block;font-weight:600}.omni-archive__search{display:block;width:100%;margin-top:8px;padding:12px 14px;border-radius:12px;border:1px solid var(--omni-border,#3c3c3c);background:var(--omni-input-bg,#3c3c3c);color:var(--omni-fg,#d4d4d4);font:inherit}.omni-archive__stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px;padding:16px}.omni-archive__stat{padding:14px;border-radius:14px;background:color-mix(in srgb,var(--omni-bg-secondary,#252526) 76%,#000);border:1px solid var(--omni-border,#3c3c3c)}.omni-archive__stat-label{font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:var(--omni-muted-fg,#9d9d9d);margin-bottom:6px}.omni-archive__stat-value{font-size:24px;font-weight:700}\n.omni-archive__workspace{display:grid;grid-template-columns:minmax(0,1.7fr) minmax(320px,.95fr);gap:16px;align-items:start}.omni-archive__table-wrap{overflow:auto;min-width:0;max-height:70vh}.omni-archive__table{width:100%;table-layout:fixed;border-collapse:collapse}.omni-archive__table th,.omni-archive__table td{padding:12px 14px;border-bottom:1px solid var(--omni-border,#3c3c3c);text-align:left;vertical-align:top;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.omni-archive__table th:first-child{width:42%}.omni-archive__table th{position:sticky;top:0;font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:var(--omni-muted-fg,#9d9d9d);background:var(--omni-bg-secondary,#252526);z-index:1}.omni-archive__path{font-family:var(--omni-mono-font,\"SFMono-Regular\",Consolas,monospace)}.omni-archive__entry{height:43px;cursor:pointer;transition:background-color 120ms ease}.omni-archive__entry:hover{background:color-mix(in srgb,var(--omni-accent,#d97706) 8%,transparent)}.omni-archive__entry:focus-visible{outline:2px solid var(--omni-accent,#d97706);outline-offset:-2px}.omni-archive__entry.is-selected{background:color-mix(in srgb,var(--omni-accent,#d97706) 16%,transparent)}.omni-archive__empty{padding:26px 18px;text-align:center;color:var(--omni-muted-fg,#9d9d9d)}\n.omni-archive__preview-panel{padding:18px;min-height:420px;display:flex;flex-direction:column;gap:14px}.omni-archive__preview-header{display:flex;justify-content:space-between;gap:12px;align-items:flex-start}.omni-archive__preview-kicker{font-size:12px;letter-spacing:.08em;text-transform:uppercase;color:var(--omni-muted-fg,#9d9d9d);margin-bottom:6px}.omni-archive__preview-header h2{margin:0;font-size:18px;line-height:1.35;word-break:break-word}.omni-archive__preview-badge{border-radius:999px;padding:8px 12px;font-size:12px;font-weight:700;background:var(--omni-badge-bg,#3a3d41);white-space:nowrap}.omni-archive__preview-meta{margin:0}.omni-archive__preview{margin:0;flex:1;overflow:auto;white-space:pre-wrap;word-break:break-word;border-radius:14px;border:1px solid var(--omni-border,#3c3c3c);background:var(--omni-code-bg,#181818);padding:14px;color:var(--omni-fg,#d4d4d4);font:12px/1.55 var(--omni-mono-font,\"SFMono-Regular\",Consolas,monospace)}\n.omni-archive__save{align-self:flex-start;padding:8px 12px;border:1px solid var(--omni-border,#3c3c3c);border-radius:6px;background:var(--omni-button-bg,#3a3d41);color:var(--omni-fg,#d4d4d4);font:inherit;cursor:pointer}.omni-archive__save:hover:not(:disabled){background:var(--omni-button-hover-bg,#45494e)}.omni-archive__save:disabled{opacity:.55;cursor:not-allowed}.omni-archive__spacer td{padding:0!important;border:0!important}\n@media(max-width:720px){.omni-viewer--archive{padding:16px 10px 24px}.omni-archive__hero{flex-direction:column}.omni-archive__pills{justify-content:flex-start}.omni-archive__table th:nth-child(n+3),.omni-archive__table td:nth-child(n+3){display:none}.omni-archive__workspace{grid-template-columns:1fr}.omni-archive__preview-panel{min-height:280px}}\n";
1
+ export declare const archiveViewerCss = "\n.omni-viewer--archive{all:initial}.omni-viewer--archive :where(header,section,aside,div,h1,h2,p,span,label,input,table,thead,tbody,tr,th,td,pre){all:revert}\n:host,.omni-viewer--archive{display:block;min-height:100%;box-sizing:border-box;color:var(--omni-fg,#d4d4d4);background:radial-gradient(circle at top left,color-mix(in srgb,var(--omni-accent,#d97706) 14%,transparent),transparent 28%),var(--omni-bg,#1e1e1e);font:13px var(--omni-font,-apple-system,BlinkMacSystemFont,\"Segoe UI\",sans-serif)}\n.omni-viewer--archive *{box-sizing:border-box}.omni-viewer--archive{padding:28px 20px 40px}.omni-archive__hero,.omni-archive__controls,.omni-archive__stats,.omni-archive__table-wrap,.omni-archive__preview-panel{background:var(--omni-bg-secondary,#252526);border:1px solid var(--omni-border,#3c3c3c);border-radius:18px;box-shadow:0 20px 40px rgba(0,0,0,.12)}\n.omni-archive__hero{display:flex;justify-content:space-between;gap:16px;padding:24px;align-items:flex-start}.omni-archive__eyebrow{font-size:12px;letter-spacing:.18em;text-transform:uppercase;color:var(--omni-accent,#d97706);font-weight:700}.omni-archive__hero h1{margin:8px 0 6px;font-size:clamp(26px,4vw,40px);line-height:1.1}.omni-archive__subtitle,.omni-archive__preview-meta{color:var(--omni-muted-fg,#9d9d9d);line-height:1.5}.omni-archive__pills{display:flex;gap:10px;flex-wrap:wrap;justify-content:flex-end}.omni-archive__pill{padding:10px 14px;border-radius:999px;background:color-mix(in srgb,var(--omni-accent,#d97706) 16%,transparent);border:1px solid color-mix(in srgb,var(--omni-accent,#d97706) 35%,transparent);font-weight:600;white-space:nowrap}\n.omni-archive__controls,.omni-archive__stats,.omni-archive__workspace{margin-top:16px}.omni-archive__controls{padding:16px}.omni-archive__search-label{display:block;font-weight:600}.omni-archive__search{display:block;width:100%;margin-top:8px;padding:12px 14px;border-radius:12px;border:1px solid var(--omni-border,#3c3c3c);background:var(--omni-input-bg,#3c3c3c);color:var(--omni-fg,#d4d4d4);font:inherit}.omni-archive__stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px;padding:16px}.omni-archive__stat{padding:14px;border-radius:14px;background:color-mix(in srgb,var(--omni-bg-secondary,#252526) 76%,#000);border:1px solid var(--omni-border,#3c3c3c)}.omni-archive__stat-label{font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:var(--omni-muted-fg,#9d9d9d);margin-bottom:6px}.omni-archive__stat-value{font-size:24px;font-weight:700}\n.omni-archive__workspace{display:grid;grid-template-columns:minmax(0,1.7fr) minmax(320px,.95fr);gap:16px;align-items:start}.omni-archive__table-wrap{overflow:auto;min-width:0;max-height:70vh}.omni-archive__table{width:100%;table-layout:fixed;border-collapse:collapse}.omni-archive__table th,.omni-archive__table td{padding:12px 14px;border-bottom:1px solid var(--omni-border,#3c3c3c);text-align:left;vertical-align:top;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.omni-archive__table th:first-child{width:42%}.omni-archive__table th{position:sticky;top:0;font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:var(--omni-muted-fg,#9d9d9d);background:var(--omni-bg-secondary,#252526);z-index:1}.omni-archive__path{font-family:var(--omni-mono-font,\"SFMono-Regular\",Consolas,monospace)}.omni-archive__entry{height:43px;cursor:pointer;transition:background-color 120ms ease}.omni-archive__entry:hover{background:color-mix(in srgb,var(--omni-accent,#d97706) 8%,transparent)}.omni-archive__entry:focus-visible{outline:2px solid var(--omni-accent,#d97706);outline-offset:-2px}.omni-archive__entry.is-selected{background:color-mix(in srgb,var(--omni-accent,#d97706) 16%,transparent)}.omni-archive__empty{padding:26px 18px;text-align:center;color:var(--omni-muted-fg,#9d9d9d)}\n.omni-archive__preview-panel{padding:18px;min-height:420px;display:flex;flex-direction:column;gap:14px}.omni-archive__preview-header{display:flex;justify-content:space-between;gap:12px;align-items:flex-start}.omni-archive__preview-kicker{font-size:12px;letter-spacing:.08em;text-transform:uppercase;color:var(--omni-muted-fg,#9d9d9d);margin-bottom:6px}.omni-archive__preview-header h2{margin:0;font-size:18px;line-height:1.35;word-break:break-word}.omni-archive__preview-badge{border-radius:999px;padding:8px 12px;font-size:12px;font-weight:700;background:var(--omni-badge-bg,#3a3d41);white-space:nowrap}.omni-archive__preview-meta{margin:0}.omni-archive__preview{margin:0;flex:1;overflow:auto;white-space:pre-wrap;word-break:break-word;border-radius:14px;border:1px solid var(--omni-border,#3c3c3c);background:var(--omni-code-bg,#181818);padding:14px;color:var(--omni-fg,#d4d4d4);font:12px/1.55 var(--omni-mono-font,\"SFMono-Regular\",Consolas,monospace)}\n.omni-archive__save{align-self:flex-start;padding:8px 12px;border:1px solid var(--omni-border,#3c3c3c);border-radius:6px;background:var(--omni-button-bg,#3a3d41);color:var(--omni-fg,#d4d4d4);font:inherit;cursor:pointer}.omni-archive__save:hover:not(:disabled){background:var(--omni-button-hover-bg,#45494e)}.omni-archive__save:disabled{opacity:.55;cursor:not-allowed}.omni-archive__spacer td{padding:0!important;border:0!important}\n.omni-archive__preview-media{flex:1;display:flex;align-items:center;justify-content:center;border-radius:14px;border:1px solid var(--omni-border,#3c3c3c);background:var(--omni-code-bg,#181818);padding:14px;overflow:auto}.omni-archive__audio{width:100%}.omni-archive__video{max-width:100%;max-height:100%}.omni-archive__image{max-width:100%;max-height:100%;object-fit:contain}\n@media(max-width:720px){.omni-viewer--archive{padding:16px 10px 24px}.omni-archive__hero{flex-direction:column}.omni-archive__pills{justify-content:flex-start}.omni-archive__table th:nth-child(n+3),.omni-archive__table td:nth-child(n+3){display:none}.omni-archive__workspace{grid-template-columns:1fr}.omni-archive__preview-panel{min-height:280px}}\n";
@@ -7,5 +7,6 @@ export const archiveViewerCss = `
7
7
  .omni-archive__workspace{display:grid;grid-template-columns:minmax(0,1.7fr) minmax(320px,.95fr);gap:16px;align-items:start}.omni-archive__table-wrap{overflow:auto;min-width:0;max-height:70vh}.omni-archive__table{width:100%;table-layout:fixed;border-collapse:collapse}.omni-archive__table th,.omni-archive__table td{padding:12px 14px;border-bottom:1px solid var(--omni-border,#3c3c3c);text-align:left;vertical-align:top;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.omni-archive__table th:first-child{width:42%}.omni-archive__table th{position:sticky;top:0;font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:var(--omni-muted-fg,#9d9d9d);background:var(--omni-bg-secondary,#252526);z-index:1}.omni-archive__path{font-family:var(--omni-mono-font,"SFMono-Regular",Consolas,monospace)}.omni-archive__entry{height:43px;cursor:pointer;transition:background-color 120ms ease}.omni-archive__entry:hover{background:color-mix(in srgb,var(--omni-accent,#d97706) 8%,transparent)}.omni-archive__entry:focus-visible{outline:2px solid var(--omni-accent,#d97706);outline-offset:-2px}.omni-archive__entry.is-selected{background:color-mix(in srgb,var(--omni-accent,#d97706) 16%,transparent)}.omni-archive__empty{padding:26px 18px;text-align:center;color:var(--omni-muted-fg,#9d9d9d)}
8
8
  .omni-archive__preview-panel{padding:18px;min-height:420px;display:flex;flex-direction:column;gap:14px}.omni-archive__preview-header{display:flex;justify-content:space-between;gap:12px;align-items:flex-start}.omni-archive__preview-kicker{font-size:12px;letter-spacing:.08em;text-transform:uppercase;color:var(--omni-muted-fg,#9d9d9d);margin-bottom:6px}.omni-archive__preview-header h2{margin:0;font-size:18px;line-height:1.35;word-break:break-word}.omni-archive__preview-badge{border-radius:999px;padding:8px 12px;font-size:12px;font-weight:700;background:var(--omni-badge-bg,#3a3d41);white-space:nowrap}.omni-archive__preview-meta{margin:0}.omni-archive__preview{margin:0;flex:1;overflow:auto;white-space:pre-wrap;word-break:break-word;border-radius:14px;border:1px solid var(--omni-border,#3c3c3c);background:var(--omni-code-bg,#181818);padding:14px;color:var(--omni-fg,#d4d4d4);font:12px/1.55 var(--omni-mono-font,"SFMono-Regular",Consolas,monospace)}
9
9
  .omni-archive__save{align-self:flex-start;padding:8px 12px;border:1px solid var(--omni-border,#3c3c3c);border-radius:6px;background:var(--omni-button-bg,#3a3d41);color:var(--omni-fg,#d4d4d4);font:inherit;cursor:pointer}.omni-archive__save:hover:not(:disabled){background:var(--omni-button-hover-bg,#45494e)}.omni-archive__save:disabled{opacity:.55;cursor:not-allowed}.omni-archive__spacer td{padding:0!important;border:0!important}
10
+ .omni-archive__preview-media{flex:1;display:flex;align-items:center;justify-content:center;border-radius:14px;border:1px solid var(--omni-border,#3c3c3c);background:var(--omni-code-bg,#181818);padding:14px;overflow:auto}.omni-archive__audio{width:100%}.omni-archive__video{max-width:100%;max-height:100%}.omni-archive__image{max-width:100%;max-height:100%;object-fit:contain}
10
11
  @media(max-width:720px){.omni-viewer--archive{padding:16px 10px 24px}.omni-archive__hero{flex-direction:column}.omni-archive__pills{justify-content:flex-start}.omni-archive__table th:nth-child(n+3),.omni-archive__table td:nth-child(n+3){display:none}.omni-archive__workspace{grid-template-columns:1fr}.omni-archive__preview-panel{min-height:280px}}
11
12
  `;
@@ -61,6 +61,14 @@ export const audioViewerCss = mediaViewerCss + `
61
61
  .omni-audio__spectrogram { min-height: 0; }
62
62
  .omni-audio__spectrogram--active { min-height: 200px; }
63
63
 
64
+ /* Keep the WaveSurfer playhead (cursor + played-region overlay) pinned to the
65
+ waveform strip. Some wavesurfer spectrogram builds render the spectrogram
66
+ inside the main wrapper instead of our dedicated container; without this the
67
+ full-height playhead sweeps down across the spectrogram ("progress bar"
68
+ running along the bottom) rather than staying with the waveform at the top. */
69
+ .omni-audio__waveform ::part(cursor),
70
+ .omni-audio__waveform ::part(progress) { height: 128px !important; }
71
+
64
72
  .omni-audio__status { color: var(--omni-muted, #9d9d9d); font-size: 12px; min-height: 16px; }
65
73
 
66
74
  .omni-audio__loading { display: flex; justify-content: center; align-items: center; min-height: 120px; color: var(--omni-muted, #9d9d9d); }
@@ -2,7 +2,7 @@ import { type PdfAnnotation, type PdfViewState } from './controller.js';
2
2
  /** Type-only reference — erased at compile time. */
3
3
  export type PdfLibModule = typeof import('pdf-lib');
4
4
  export declare const SIDECAR_LAYER_NAME = "omni-viewer-layer.json";
5
- export declare const SIDECAR_ORIGINAL_NAME = "omni-viewer-original.pdf";
5
+ export declare const SIDECAR_BASE_NAME = "omni-viewer-base.pdf";
6
6
  /** Result of parsing a sidecar's layer JSON. */
7
7
  export interface ParsedLayer {
8
8
  pageOrder: number[];
@@ -14,9 +14,11 @@ export declare function buildEditedPdf(pdfLib: PdfLibModule, source: Uint8Array,
14
14
  /**
15
15
  * Like {@link buildEditedPdf}, but also embeds the hybrid sidecar so the
16
16
  * omni viewer can rehydrate a removable overlay layer on reopen:
17
- * - the pristine (unflattened) source, and
18
- * - the layer JSON `{ version, pageOrder, annotations }`.
19
- * External readers only ever see the flattened pages.
17
+ * - a re-editable base (kept pages only, signatures flattened in), and
18
+ * - the layer JSON `{ version, pageOrder, annotations }` for text/markup,
19
+ * with page references remapped onto the kept-pages base.
20
+ * Deleted pages and signatures are unrecoverable; external readers only ever
21
+ * see the flattened pages.
20
22
  */
21
23
  export declare function buildSavedPdf(pdfLib: PdfLibModule, pristine: Uint8Array, state: PdfViewState): Promise<Uint8Array>;
22
24
  /** Parse and validate a sidecar layer JSON string; null if unusable. */
@@ -5,13 +5,16 @@
5
5
  // imports). Callers inject the loaded module; `viewers/pdf/self-loading`
6
6
  // provides the dynamic-import loader for platforms whose bundler resolves it.
7
7
  import { mergeHighlightRects } from './controller.js';
8
- // Hybrid sidecar: saved PDFs keep flattened (portable) pages while embedding
9
- // the pristine source + a layer JSON, so the omni viewer can restore a
10
- // removable overlay layer on reopen. External readers only see the flat pages.
8
+ // Hybrid sidecar: saved PDFs keep flattened (portable) pages for external
9
+ // readers while embedding a re-editable base + a layer JSON, so the omni
10
+ // viewer can restore a removable overlay layer on reopen. The embedded base
11
+ // holds only the kept pages (deleted pages are gone for good) with signatures
12
+ // permanently flattened in; text/markup stay out so they remain editable.
13
+ // External readers only ever see the flat pages.
11
14
  export const SIDECAR_LAYER_NAME = 'omni-viewer-layer.json';
12
- export const SIDECAR_ORIGINAL_NAME = 'omni-viewer-original.pdf';
15
+ export const SIDECAR_BASE_NAME = 'omni-viewer-base.pdf';
13
16
  /** Bumped when the sidecar shape changes; older/newer versions are ignored. */
14
- const SIDECAR_VERSION = 1;
17
+ const SIDECAR_VERSION = 2;
15
18
  /** Defensive ceilings — the sidecar is untrusted input on reopen. */
16
19
  const MAX_ANNOTATIONS = 2000;
17
20
  const MAX_TEXT_LENGTH = 10_000;
@@ -104,16 +107,39 @@ async function stamp(pdfLib, page, annotation) {
104
107
  height: annotation.height
105
108
  });
106
109
  }
107
- /** Reorder/delete pages and stamp the overlays into a fresh (unsaved) doc. */
108
- async function flattenInto(pdfLib, source, state) {
110
+ /** Copy the kept pages (in display order) from `source` into a fresh doc. */
111
+ async function copyKeptPages(pdfLib, source, pageOrder) {
109
112
  const original = await pdfLib.PDFDocument.load(source);
110
113
  const output = await pdfLib.PDFDocument.create();
111
- const indices = state.pageOrder
114
+ const indices = pageOrder
112
115
  .map((page) => page - 1)
113
116
  .filter((page) => page >= 0 && page < original.getPageCount());
114
117
  const copied = await output.copyPages(original, indices);
115
118
  copied.forEach((page) => output.addPage(page));
119
+ return output;
120
+ }
121
+ /** Reorder/delete pages and stamp the overlays into a fresh (unsaved) doc. */
122
+ async function flattenInto(pdfLib, source, state) {
123
+ const output = await copyKeptPages(pdfLib, source, state.pageOrder);
124
+ for (const annotation of state.annotations) {
125
+ const outputIndex = state.pageOrder.indexOf(annotation.page);
126
+ const page = output.getPages()[outputIndex];
127
+ if (page)
128
+ await stamp(pdfLib, page, annotation);
129
+ }
130
+ return output;
131
+ }
132
+ /**
133
+ * The re-editable base embedded in a saved PDF: the kept pages (unflattened)
134
+ * with ONLY signatures permanently stamped in. Text/markup are deliberately
135
+ * left out so the omni viewer can restore them as a removable layer, while
136
+ * signatures — and any pages the user deleted — cannot be recovered.
137
+ */
138
+ async function buildEditableBase(pdfLib, pristine, state) {
139
+ const output = await copyKeptPages(pdfLib, pristine, state.pageOrder);
116
140
  for (const annotation of state.annotations) {
141
+ if (annotation.kind !== 'signature')
142
+ continue;
117
143
  const outputIndex = state.pageOrder.indexOf(annotation.page);
118
144
  const page = output.getPages()[outputIndex];
119
145
  if (page)
@@ -130,21 +156,29 @@ export async function buildEditedPdf(pdfLib, source, state) {
130
156
  /**
131
157
  * Like {@link buildEditedPdf}, but also embeds the hybrid sidecar so the
132
158
  * omni viewer can rehydrate a removable overlay layer on reopen:
133
- * - the pristine (unflattened) source, and
134
- * - the layer JSON `{ version, pageOrder, annotations }`.
135
- * External readers only ever see the flattened pages.
159
+ * - a re-editable base (kept pages only, signatures flattened in), and
160
+ * - the layer JSON `{ version, pageOrder, annotations }` for text/markup,
161
+ * with page references remapped onto the kept-pages base.
162
+ * Deleted pages and signatures are unrecoverable; external readers only ever
163
+ * see the flattened pages.
136
164
  */
137
165
  export async function buildSavedPdf(pdfLib, pristine, state) {
138
166
  const output = await flattenInto(pdfLib, pristine, state);
167
+ const base = await buildEditableBase(pdfLib, pristine, state);
168
+ // The base is already in display order, so its pages number 1..keptCount.
169
+ // Remap each overlay's page reference (an original page number) onto that.
170
+ const remap = new Map(state.pageOrder.map((page, index) => [page, index + 1]));
139
171
  const layer = JSON.stringify({
140
172
  version: SIDECAR_VERSION,
141
- pageOrder: state.pageOrder,
173
+ pageOrder: base.getPages().map((_, index) => index + 1),
142
174
  annotations: state.annotations
175
+ .filter((annotation) => annotation.kind !== 'signature')
176
+ .map((annotation) => ({ ...annotation, page: remap.get(annotation.page) ?? annotation.page }))
143
177
  });
144
178
  await output.attach(new TextEncoder().encode(layer), SIDECAR_LAYER_NAME, {
145
179
  mimeType: 'application/json'
146
180
  });
147
- await output.attach(pristine, SIDECAR_ORIGINAL_NAME, {
181
+ await output.attach(await base.save(), SIDECAR_BASE_NAME, {
148
182
  mimeType: 'application/pdf'
149
183
  });
150
184
  return output.save();
@@ -11,7 +11,7 @@
11
11
  // annotations use an inline input overlay instead.
12
12
  import { MountAbortedError, VIEWER_ROOT_CLASS } from '../types.js';
13
13
  import { createPdfController, mergeHighlightRects, PDF_ZOOM_LEVELS } from './controller.js';
14
- import { buildSavedPdf, mergePdfBytes, parseLayer, savedPdfName, SIDECAR_LAYER_NAME, SIDECAR_ORIGINAL_NAME } from './editing.js';
14
+ import { buildSavedPdf, mergePdfBytes, parseLayer, savedPdfName, SIDECAR_LAYER_NAME, SIDECAR_BASE_NAME } from './editing.js';
15
15
  import { pdfViewerCss } from './styles.js';
16
16
  export { createPdfController, PDF_ZOOM_LEVELS } from './controller.js';
17
17
  export { pdfViewerCss } from './styles.js';
@@ -176,7 +176,7 @@ export async function mountPdfViewer(input, container, ctx, deps, options = {})
176
176
  }
177
177
  }
178
178
  /**
179
- * Read the hybrid sidecar (pristine source + layer JSON) from a document's
179
+ * Read the hybrid sidecar (re-editable base + layer JSON) from a document's
180
180
  * attachments. Returns null unless BOTH parts are present and the layer
181
181
  * parses — a lone layer over already-flattened pages would double up.
182
182
  */
@@ -185,19 +185,19 @@ export async function mountPdfViewer(input, container, ctx, deps, options = {})
185
185
  const attachments = await d.getAttachments?.();
186
186
  if (!attachments)
187
187
  return null;
188
- let pristine;
188
+ let base;
189
189
  let layerText;
190
190
  for (const entry of Object.values(attachments)) {
191
- if (entry.filename === SIDECAR_ORIGINAL_NAME)
192
- pristine = entry.content;
191
+ if (entry.filename === SIDECAR_BASE_NAME)
192
+ base = entry.content;
193
193
  else if (entry.filename === SIDECAR_LAYER_NAME) {
194
194
  layerText = new TextDecoder().decode(entry.content);
195
195
  }
196
196
  }
197
- if (!pristine || layerText === undefined)
197
+ if (!base || layerText === undefined)
198
198
  return null;
199
199
  const layer = parseLayer(layerText);
200
- return layer ? { pristine, layer } : null;
200
+ return layer ? { base, layer } : null;
201
201
  }
202
202
  catch {
203
203
  return null;
@@ -1374,12 +1374,13 @@ export async function mountPdfViewer(input, container, ctx, deps, options = {})
1374
1374
  }
1375
1375
  throwIfAborted();
1376
1376
  // Hybrid rehydration: if this file was saved by us, its visible pages are
1377
- // flattened but it carries the pristine source + layer JSON. Render/edit
1378
- // from the pristine base and restore the overlays as a removable layer.
1377
+ // flattened but it carries a re-editable base (kept pages, signatures baked
1378
+ // in) + layer JSON. Render/edit from that base and restore the text/markup
1379
+ // overlays as a removable layer.
1379
1380
  const sidecar = await readSidecar(doc);
1380
1381
  if (sidecar) {
1381
1382
  await doc.destroy();
1382
- sourceBytes = sidecar.pristine;
1383
+ sourceBytes = sidecar.base;
1383
1384
  doc = await loadDocument(sourceBytes, password);
1384
1385
  throwIfAborted();
1385
1386
  adoptController(createPdfController(doc.numPages, sidecar.layer));