@file-viewer/renderer-archive 2.0.11

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/LICENSE ADDED
@@ -0,0 +1,160 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction, and
10
+ distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by the copyright
13
+ owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all other entities
16
+ that control, are controlled by, or are under common control with that entity.
17
+ For the purposes of this definition, "control" means (i) the power, direct or
18
+ indirect, to cause the direction or management of such entity, whether by
19
+ contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the
20
+ outstanding shares, or (iii) beneficial ownership of such entity.
21
+
22
+ "You" (or "Your") shall mean an individual or Legal Entity exercising
23
+ permissions granted by this License.
24
+
25
+ "Source" form shall mean the preferred form for making modifications, including
26
+ but not limited to software source code, documentation source, and configuration
27
+ files.
28
+
29
+ "Object" form shall mean any form resulting from mechanical transformation or
30
+ translation of a Source form, including but not limited to compiled object code,
31
+ generated documentation, and conversions to other media types.
32
+
33
+ "Work" shall mean the work of authorship, whether in Source or Object form,
34
+ made available under the License, as indicated by a copyright notice that is
35
+ included in or attached to the work.
36
+
37
+ "Derivative Works" shall mean any work, whether in Source or Object form, that
38
+ is based on (or derived from) the Work and for which the editorial revisions,
39
+ annotations, elaborations, or other modifications represent, as a whole, an
40
+ original work of authorship. For the purposes of this License, Derivative Works
41
+ shall not include works that remain separable from, or merely link (or bind by
42
+ name) to the interfaces of, the Work and Derivative Works thereof.
43
+
44
+ "Contribution" shall mean any work of authorship, including the original version
45
+ of the Work and any modifications or additions to that Work or Derivative Works
46
+ thereof, that is intentionally submitted to Licensor for inclusion in the Work by
47
+ the copyright owner or by an individual or Legal Entity authorized to submit on
48
+ behalf of the copyright owner. For the purposes of this definition, "submitted"
49
+ means any form of electronic, verbal, or written communication sent to the
50
+ Licensor or its representatives, including but not limited to communication on
51
+ electronic mailing lists, source code control systems, and issue tracking systems
52
+ that are managed by, or on behalf of, the Licensor for the purpose of discussing
53
+ and improving the Work, but excluding communication that is conspicuously marked
54
+ or otherwise designated in writing by the copyright owner as "Not a
55
+ Contribution."
56
+
57
+ "Contributor" shall mean Licensor and any individual or Legal Entity on behalf
58
+ of whom a Contribution has been received by Licensor and subsequently
59
+ incorporated within the Work.
60
+
61
+ 2. Grant of Copyright License. Subject to the terms and conditions of this
62
+ License, each Contributor hereby grants to You a perpetual, worldwide,
63
+ non-exclusive, no-charge, royalty-free, irrevocable copyright license to
64
+ reproduce, prepare Derivative Works of, publicly display, publicly perform,
65
+ sublicense, and distribute the Work and such Derivative Works in Source or
66
+ Object form.
67
+
68
+ 3. Grant of Patent License. Subject to the terms and conditions of this License,
69
+ each Contributor hereby grants to You a perpetual, worldwide, non-exclusive,
70
+ no-charge, royalty-free, irrevocable (except as stated in this section) patent
71
+ license to make, have made, use, offer to sell, sell, import, and otherwise
72
+ transfer the Work, where such license applies only to those patent claims
73
+ licensable by such Contributor that are necessarily infringed by their
74
+ Contribution(s) alone or by combination of their Contribution(s) with the Work to
75
+ which such Contribution(s) was submitted. If You institute patent litigation
76
+ against any entity (including a cross-claim or counterclaim in a lawsuit)
77
+ alleging that the Work or a Contribution incorporated within the Work
78
+ constitutes direct or contributory patent infringement, then any patent licenses
79
+ granted to You under this License for that Work shall terminate as of the date
80
+ such litigation is filed.
81
+
82
+ 4. Redistribution. You may reproduce and distribute copies of the Work or
83
+ Derivative Works thereof in any medium, with or without modifications, and in
84
+ Source or Object form, provided that You meet the following conditions:
85
+
86
+ (a) You must give any other recipients of the Work or Derivative Works a copy of
87
+ this License; and
88
+
89
+ (b) You must cause any modified files to carry prominent notices stating that
90
+ You changed the files; and
91
+
92
+ (c) You must retain, in the Source form of any Derivative Works that You
93
+ distribute, all copyright, patent, trademark, and attribution notices from the
94
+ Source form of the Work, excluding those notices that do not pertain to any part
95
+ of the Derivative Works; and
96
+
97
+ (d) If the Work includes a "NOTICE" text file as part of its distribution, then
98
+ any Derivative Works that You distribute must include a readable copy of the
99
+ attribution notices contained within such NOTICE file, excluding those notices
100
+ that do not pertain to any part of the Derivative Works, in at least one of the
101
+ following places: within a NOTICE text file distributed as part of the Derivative
102
+ Works; within the Source form or documentation, if provided along with the
103
+ Derivative Works; or, within a display generated by the Derivative Works, if and
104
+ wherever such third-party notices normally appear. The contents of the NOTICE
105
+ file are for informational purposes only and do not modify the License. You may
106
+ add Your own attribution notices within Derivative Works that You distribute,
107
+ alongside or as an addendum to the NOTICE text from the Work, provided that such
108
+ additional attribution notices cannot be construed as modifying the License.
109
+
110
+ You may add Your own copyright statement to Your modifications and may provide
111
+ additional or different license terms and conditions for use, reproduction, or
112
+ distribution of Your modifications, or for any such Derivative Works as a whole,
113
+ provided Your use, reproduction, and distribution of the Work otherwise complies
114
+ with the conditions stated in this License.
115
+
116
+ 5. Submission of Contributions. Unless You explicitly state otherwise, any
117
+ Contribution intentionally submitted for inclusion in the Work by You to the
118
+ Licensor shall be under the terms and conditions of this License, without any
119
+ additional terms or conditions. Notwithstanding the above, nothing herein shall
120
+ supersede or modify the terms of any separate license agreement you may have
121
+ executed with Licensor regarding such Contributions.
122
+
123
+ 6. Trademarks. This License does not grant permission to use the trade names,
124
+ trademarks, service marks, or product names of the Licensor, except as required
125
+ for reasonable and customary use in describing the origin of the Work and
126
+ reproducing the content of the NOTICE file.
127
+
128
+ 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in
129
+ writing, Licensor provides the Work (and each Contributor provides its
130
+ Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
131
+ KIND, either express or implied, including, without limitation, any warranties or
132
+ conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
133
+ PARTICULAR PURPOSE. You are solely responsible for determining the
134
+ appropriateness of using or redistributing the Work and assume any risks
135
+ associated with Your exercise of permissions under this License.
136
+
137
+ 8. Limitation of Liability. In no event and under no legal theory, whether in
138
+ tort (including negligence), contract, or otherwise, unless required by
139
+ applicable law (such as deliberate and grossly negligent acts) or agreed to in
140
+ writing, shall any Contributor be liable to You for damages, including any
141
+ direct, indirect, special, incidental, or consequential damages of any character
142
+ arising as a result of this License or out of the use or inability to use the
143
+ Work (including but not limited to damages for loss of goodwill, work stoppage,
144
+ computer failure or malfunction, or any and all other commercial damages or
145
+ losses), even if such Contributor has been advised of the possibility of such
146
+ damages.
147
+
148
+ 9. Accepting Warranty or Additional Liability. While redistributing the Work or
149
+ Derivative Works thereof, You may choose to offer, and charge a fee for,
150
+ acceptance of support, warranty, indemnity, or other liability obligations and/or
151
+ rights consistent with this License. However, in accepting such obligations, You
152
+ may act only on Your own behalf and on Your sole responsibility, not on behalf of
153
+ any other Contributor, and only if You agree to indemnify, defend, and hold each
154
+ Contributor harmless for any liability incurred by, or claims asserted against,
155
+ such Contributor by reason of your accepting any such warranty or additional
156
+ liability.
157
+
158
+ END OF TERMS AND CONDITIONS
159
+
160
+ Copyright 2026 Flyfish Viewer
package/README.en.md ADDED
@@ -0,0 +1,60 @@
1
+ # @file-viewer/renderer-archive
2
+
3
+ Standalone archive renderer package for Flyfish File Viewer. It reads ZIP, TAR, GZIP, RAR, 7z, and other archive directories with `libarchive.js` Worker + WASM, then extracts internal files only when the user opens them.
4
+
5
+ ## Usage
6
+
7
+ ```ts
8
+ import FileViewer from '@file-viewer/vue3'
9
+ import { archiveRenderer } from '@file-viewer/renderer-archive'
10
+
11
+ const options = {
12
+ rendererMode: 'replace',
13
+ renderers: archiveRenderer,
14
+ }
15
+ ```
16
+
17
+ You can also compose it with other renderer packages:
18
+
19
+ ```ts
20
+ import { archiveRenderer } from '@file-viewer/renderer-archive'
21
+ import { pdfRenderer } from '@file-viewer/renderer-pdf'
22
+ import { cadRenderer } from '@file-viewer/renderer-cad'
23
+
24
+ const options = {
25
+ rendererMode: 'replace',
26
+ renderers: [pdfRenderer, cadRenderer, archiveRenderer],
27
+ }
28
+ ```
29
+
30
+ ## Capabilities
31
+
32
+ - Previews ZIP, TAR, GZIP, RAR, 7z, and common archive directories.
33
+ - Uses `libarchive.js` Worker + WASM first to keep large archive parsing off the main thread.
34
+ - Falls back to ZIP / TAR / GZIP parsing when the Worker cannot be started, which helps mobile WebViews, local static servers, and private intranet deployments.
35
+ - Extracts internal files on demand, then delegates nested previews through `renderNestedBuffer` or the core dispatcher.
36
+ - Includes archive size limits, entry preview limits, worker timeout, IndexedDB cache, and single-entry download.
37
+
38
+ ## Offline Assets
39
+
40
+ Default asset paths are:
41
+
42
+ - `vendor/libarchive/worker-bundle.js`
43
+ - `vendor/libarchive/libarchive.wasm`
44
+
45
+ For private deployments, override them with `options.archive.workerUrl` and `options.archive.wasmUrl`.
46
+
47
+ ```ts
48
+ const options = {
49
+ archive: {
50
+ workerUrl: '/file-viewer/vendor/libarchive/worker-bundle.js',
51
+ wasmUrl: '/file-viewer/vendor/libarchive/libarchive.wasm',
52
+ cache: true,
53
+ workerTimeoutMs: 30000,
54
+ },
55
+ }
56
+ ```
57
+
58
+ ## Migration Note
59
+
60
+ The core package no longer bundles the archive renderer and no longer installs `libarchive.js` for the archive pipeline. ZIP/TAR/GZIP fallback, `jszip`, cache, and Worker logic are owned by this package; core may still temporarily retain `jszip` for the OFD vendor path until OFD is fully extracted. Install this renderer explicitly, or use `@file-viewer/preset-all`, when archive preview is required.
package/README.md ADDED
@@ -0,0 +1,60 @@
1
+ # @file-viewer/renderer-archive
2
+
3
+ Flyfish File Viewer 的独立压缩包 renderer 包。它用 `libarchive.js` Worker + WASM 读取 RAR、7z、ZIP、TAR 等压缩包目录,并在点击内部文件时才按需解压、缓存和调用 File Viewer 的嵌套渲染能力。
4
+
5
+ ## 用法
6
+
7
+ ```ts
8
+ import FileViewer from '@file-viewer/vue3'
9
+ import { archiveRenderer } from '@file-viewer/renderer-archive'
10
+
11
+ const options = {
12
+ rendererMode: 'replace',
13
+ renderers: archiveRenderer,
14
+ }
15
+ ```
16
+
17
+ 也可以和其他 renderer 一起组合:
18
+
19
+ ```ts
20
+ import { archiveRenderer } from '@file-viewer/renderer-archive'
21
+ import { pdfRenderer } from '@file-viewer/renderer-pdf'
22
+ import { cadRenderer } from '@file-viewer/renderer-cad'
23
+
24
+ const options = {
25
+ rendererMode: 'replace',
26
+ renderers: [pdfRenderer, cadRenderer, archiveRenderer],
27
+ }
28
+ ```
29
+
30
+ ## 能力边界
31
+
32
+ - 支持 ZIP、TAR、GZIP、RAR、7z 等常见压缩包目录预览。
33
+ - 优先使用 `libarchive.js` Worker + WASM,避免大压缩包阻塞主线程。
34
+ - Worker 不可用时自动回退到 ZIP / TAR / GZIP 兼容模式,适合手机 WebView、本地临时服务器和内网静态部署排障。
35
+ - 点击内部文件后才按需解压,并通过 `renderNestedBuffer` 或 core dispatcher 复用 PDF、Office、CAD、XMind、图片、代码等现有 renderer。
36
+ - 内置体积上限、单文件预览上限、Worker 超时、IndexedDB 缓存和下载入口,避免一次性把压缩包全部展开到内存。
37
+
38
+ ## 离线资产
39
+
40
+ 默认会从 viewer assets 下读取:
41
+
42
+ - `vendor/libarchive/worker-bundle.js`
43
+ - `vendor/libarchive/libarchive.wasm`
44
+
45
+ 私有化部署时可以通过 `options.archive.workerUrl` 和 `options.archive.wasmUrl` 覆盖。
46
+
47
+ ```ts
48
+ const options = {
49
+ archive: {
50
+ workerUrl: '/file-viewer/vendor/libarchive/worker-bundle.js',
51
+ wasmUrl: '/file-viewer/vendor/libarchive/libarchive.wasm',
52
+ cache: true,
53
+ workerTimeoutMs: 30000,
54
+ },
55
+ }
56
+ ```
57
+
58
+ ## 迁移说明
59
+
60
+ `@file-viewer/core` 已不再内置 archive renderer,也不再为压缩包链路直接安装 `libarchive.js`。ZIP/TAR/GZIP fallback、`jszip`、缓存和 Worker 逻辑由本包维护;core 在 OFD 完全拆出前可能仍会因 OFD vendor 临时保留 `jszip`。需要压缩包预览时,请显式安装本包,或直接使用 `@file-viewer/preset-all` 聚合能力。
@@ -0,0 +1,2 @@
1
+ import { type FileRenderContext, type FileViewerRenderedInstance } from '@file-viewer/core';
2
+ export default function renderArchive(buffer: ArrayBuffer, target: HTMLDivElement, _type?: string, context?: FileRenderContext): Promise<FileViewerRenderedInstance>;
@@ -0,0 +1,547 @@
1
+ import { resolveFileViewerArchiveWasmUrl, resolveFileViewerArchiveWorkerUrl, } from '@file-viewer/core/assets';
2
+ import { disposeFileViewerRendered, } from '@file-viewer/core';
3
+ import { createArchiveCacheKey, flattenArchiveObject, formatArchiveBytes, getArchiveEntryExtension, } from './archiveShared.js';
4
+ import { readArchiveCache, writeArchiveCache } from './archiveCache.js';
5
+ import { loadArchiveEntriesWithoutWorker } from './archiveFallback.js';
6
+ const DEFAULT_MAX_ARCHIVE_SIZE = 320 * 1024 * 1024;
7
+ const DEFAULT_MAX_ENTRY_PREVIEW_SIZE = 64 * 1024 * 1024;
8
+ const DEFAULT_WORKER_TIMEOUT_MS = 30000;
9
+ const MAX_LISTED_ENTRIES = 5000;
10
+ const archiveStyle = `
11
+ .archive-shell,.archive-viewer{position:relative;box-sizing:border-box;height:100%;min-height:0;display:grid;grid-template-columns:minmax(280px,34%) minmax(0,1fr);background:#edf2f7;color:#172033;font-family:Aptos,'Segoe UI','PingFang SC','Microsoft YaHei',sans-serif}
12
+ .archive-shell *,.archive-viewer *{box-sizing:border-box}
13
+ .archive-sidebar{min-width:0;min-height:0;display:flex;flex-direction:column;gap:12px;padding:16px;border-right:1px solid rgba(23,32,51,.08);background:rgba(255,255,255,.72)}
14
+ .archive-head span,.archive-preview-toolbar span{color:#6c7c90;font-size:12px;font-weight:800;letter-spacing:0}
15
+ .archive-head strong,.archive-preview-toolbar strong{display:block;margin-top:4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:18px;line-height:1.25}
16
+ .archive-head p{margin:8px 0 0;color:#69798b;font-size:13px}
17
+ .archive-warning,.archive-info,.archive-error{border-radius:12px;padding:10px 12px;background:#fff7e8;color:#8a4b00;font-size:13px;line-height:1.5}
18
+ .archive-info{background:#ecfdf5;color:#166534}
19
+ .archive-search{width:100%;height:42px;padding:0 12px;border-radius:12px;border:1px solid rgba(23,32,51,.1);outline:none;background:#fff;color:#172033;font:inherit}
20
+ .archive-list{flex:1;min-height:0;overflow:auto;display:flex;flex-direction:column;gap:7px;padding-right:4px}
21
+ .archive-entry{width:100%;min-height:58px;display:grid;grid-template-columns:42px minmax(0,1fr) auto;gap:10px;align-items:center;padding:8px 10px 8px calc(10px + var(--entry-depth,0) * 10px);border:1px solid rgba(23,32,51,.07);border-radius:12px;background:rgba(255,255,255,.86);color:inherit;text-align:left;cursor:pointer;font:inherit}
22
+ .archive-entry:hover,.archive-entry.active{border-color:rgba(33,129,95,.28);box-shadow:0 10px 22px rgba(23,32,51,.08)}
23
+ .entry-ext{height:34px;display:inline-flex;align-items:center;justify-content:center;border-radius:10px;background:rgba(33,129,95,.12);color:#1d7a56;font-size:11px;font-weight:900;text-transform:uppercase}
24
+ .entry-copy{min-width:0}
25
+ .entry-copy strong,.entry-copy em{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
26
+ .entry-copy em,.archive-entry small{color:#718096;font-size:12px;font-style:normal}
27
+ .archive-preview{min-width:0;min-height:0;display:flex;flex-direction:column}
28
+ .archive-preview-toolbar{min-height:64px;display:flex;align-items:center;justify-content:space-between;gap:14px;padding:12px 16px;border-bottom:1px solid rgba(23,32,51,.08);background:rgba(255,255,255,.76)}
29
+ .archive-preview-toolbar button{height:34px;border:0;border-radius:10px;padding:0 12px;background:#1f7a58;color:#fff;font:inherit;font-size:13px;font-weight:800;cursor:pointer}
30
+ .archive-nested-target{position:relative;flex:1;min-height:0;overflow:auto}
31
+ .archive-nested-content{width:100%;height:100%;min-height:420px}
32
+ .archive-empty{height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:28px;text-align:center;color:#64748b}
33
+ .archive-empty strong{color:#172033;font-size:18px}
34
+ .archive-state{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:rgba(241,245,249,.82);backdrop-filter:blur(8px);z-index:4}
35
+ .archive-state>div{display:flex;align-items:center;gap:14px;width:min(92%,430px);padding:18px;border-radius:16px;background:#fff;box-shadow:0 18px 42px rgba(15,23,42,.14)}
36
+ .archive-state p{margin:4px 0 0;color:#64748b}
37
+ .archive-spinner{width:34px;height:34px;flex-shrink:0;border-radius:999px;border:3px solid rgba(31,122,88,.16);border-top-color:#1f7a58;animation:archive-spin .9s linear infinite}
38
+ .archive-error{position:absolute;right:18px;bottom:18px;width:min(460px,calc(100% - 36px));box-shadow:0 16px 36px rgba(23,32,51,.14);z-index:5}
39
+ .archive-hidden{display:none!important}
40
+ .file-viewer[data-viewer-theme='dark'] .archive-shell,.file-viewer[data-viewer-theme='dark'] .archive-viewer{background:#101820;color:#e6edf3}
41
+ .file-viewer[data-viewer-theme='dark'] .archive-sidebar,.file-viewer[data-viewer-theme='dark'] .archive-preview-toolbar{border-color:rgba(139,148,158,.2);background:rgba(21,27,35,.82)}
42
+ .file-viewer[data-viewer-theme='dark'] .archive-entry,.file-viewer[data-viewer-theme='dark'] .archive-search,.file-viewer[data-viewer-theme='dark'] .archive-state>div{background:#151b23;color:#e6edf3;border-color:rgba(139,148,158,.2)}
43
+ .file-viewer[data-viewer-theme='dark'] .archive-empty strong{color:#f8fafc}
44
+ @media (prefers-color-scheme:dark){.file-viewer[data-viewer-theme='system'] .archive-shell,.file-viewer[data-viewer-theme='system'] .archive-viewer{background:#101820;color:#e6edf3}.file-viewer[data-viewer-theme='system'] .archive-sidebar,.file-viewer[data-viewer-theme='system'] .archive-preview-toolbar{border-color:rgba(139,148,158,.2);background:rgba(21,27,35,.82)}.file-viewer[data-viewer-theme='system'] .archive-entry,.file-viewer[data-viewer-theme='system'] .archive-search,.file-viewer[data-viewer-theme='system'] .archive-state>div{background:#151b23;color:#e6edf3;border-color:rgba(139,148,158,.2)}.file-viewer[data-viewer-theme='system'] .archive-empty strong{color:#f8fafc}}
45
+ @keyframes archive-spin{to{transform:rotate(360deg)}}
46
+ @media (max-width:860px){.archive-shell,.archive-viewer{grid-template-columns:1fr;grid-template-rows:minmax(220px,38%) minmax(0,1fr)}.archive-sidebar{border-right:0;border-bottom:1px solid rgba(23,32,51,.08)}}
47
+ `;
48
+ const createStyle = (documentRef) => {
49
+ const style = documentRef.createElement('style');
50
+ style.textContent = archiveStyle;
51
+ return style;
52
+ };
53
+ const createElement = (documentRef, tagName, className, text) => {
54
+ const element = documentRef.createElement(tagName);
55
+ if (className) {
56
+ element.className = className;
57
+ }
58
+ if (text !== undefined) {
59
+ element.textContent = text;
60
+ }
61
+ return element;
62
+ };
63
+ const normalizeWorkerError = (reason) => {
64
+ if (reason instanceof Error) {
65
+ return reason.message;
66
+ }
67
+ return typeof reason === 'string' ? reason : JSON.stringify(reason);
68
+ };
69
+ const withTimeout = async (promise, timeout, message, targetWindow) => {
70
+ let timer = 0;
71
+ const timerWindow = targetWindow || (typeof window !== 'undefined' ? window : undefined);
72
+ try {
73
+ return await Promise.race([
74
+ promise,
75
+ new Promise((_, reject) => {
76
+ timer = (timerWindow === null || timerWindow === void 0 ? void 0 : timerWindow.setTimeout)
77
+ ? timerWindow.setTimeout(() => reject(new Error(message)), timeout)
78
+ : setTimeout(() => reject(new Error(message)), timeout);
79
+ }),
80
+ ]);
81
+ }
82
+ finally {
83
+ if (timerWindow === null || timerWindow === void 0 ? void 0 : timerWindow.clearTimeout) {
84
+ timerWindow.clearTimeout(timer);
85
+ }
86
+ else {
87
+ clearTimeout(timer);
88
+ }
89
+ }
90
+ };
91
+ const getDocumentBaseUrl = (documentRef) => {
92
+ return documentRef.baseURI ||
93
+ documentRef.URL ||
94
+ 'http://localhost/';
95
+ };
96
+ const getWorkerConstructor = (documentRef) => {
97
+ var _a;
98
+ const WorkerCtor = ((_a = documentRef.defaultView) === null || _a === void 0 ? void 0 : _a.Worker) ||
99
+ (typeof Worker !== 'undefined' ? Worker : undefined);
100
+ if (!WorkerCtor) {
101
+ throw new Error('当前浏览器不支持 Web Worker');
102
+ }
103
+ return WorkerCtor;
104
+ };
105
+ const getFileConstructor = (documentRef) => {
106
+ var _a;
107
+ return (((_a = documentRef.defaultView) === null || _a === void 0 ? void 0 : _a.File) ||
108
+ (typeof File !== 'undefined' ? File : undefined));
109
+ };
110
+ const createArchiveFile = (documentRef, buffer, filename) => {
111
+ const FileCtor = getFileConstructor(documentRef);
112
+ if (FileCtor) {
113
+ return new FileCtor([buffer], filename || 'archive.bin', {
114
+ type: 'application/octet-stream',
115
+ });
116
+ }
117
+ return Object.assign(new Blob([buffer], { type: 'application/octet-stream' }), {
118
+ name: filename || 'archive.bin',
119
+ });
120
+ };
121
+ const probeWorkerUrl = async (url) => {
122
+ try {
123
+ const response = await fetch(url, { method: 'HEAD', cache: 'no-cache' });
124
+ const contentType = response.headers.get('content-type') || '';
125
+ if (response.ok && /javascript|ecmascript|octet-stream/i.test(contentType)) {
126
+ return true;
127
+ }
128
+ if (response.status && response.status !== 405) {
129
+ return false;
130
+ }
131
+ }
132
+ catch {
133
+ // Some local servers do not support HEAD; continue with a tiny GET probe.
134
+ }
135
+ try {
136
+ const response = await fetch(url, {
137
+ method: 'GET',
138
+ cache: 'no-cache',
139
+ headers: {
140
+ Range: 'bytes=0-0',
141
+ },
142
+ });
143
+ const contentType = response.headers.get('content-type') || '';
144
+ return response.ok && /javascript|ecmascript|octet-stream/i.test(contentType);
145
+ }
146
+ catch {
147
+ return false;
148
+ }
149
+ };
150
+ const patchLibarchiveWorkerSource = (source, wasmUrl) => {
151
+ const wasmLiteral = JSON.stringify(wasmUrl);
152
+ return source.replace(/new URL\((['"])libarchive\.wasm\1\s*,\s*import\.meta\.url\)\.href/g, wasmLiteral);
153
+ };
154
+ const prepareWorkerUrl = async (candidate, objectUrls) => {
155
+ if (!candidate.wasmUrl) {
156
+ return candidate.workerUrl;
157
+ }
158
+ const response = await fetch(candidate.workerUrl, { cache: 'no-cache' });
159
+ if (!response.ok) {
160
+ throw new Error(`无法读取 libarchive Worker: ${response.status}`);
161
+ }
162
+ const source = await response.text();
163
+ const workerUrl = URL.createObjectURL(new Blob([
164
+ patchLibarchiveWorkerSource(source, candidate.wasmUrl),
165
+ ], { type: 'application/javascript' }));
166
+ objectUrls.push(workerUrl);
167
+ return workerUrl;
168
+ };
169
+ const resolveWorkerCandidates = async (documentRef, options) => {
170
+ const candidates = [];
171
+ const baseUrl = getDocumentBaseUrl(documentRef);
172
+ const wasmUrl = (options === null || options === void 0 ? void 0 : options.wasmUrl)
173
+ ? resolveFileViewerArchiveWasmUrl(options, '')
174
+ : undefined;
175
+ if (options === null || options === void 0 ? void 0 : options.workerUrl) {
176
+ candidates.push({
177
+ label: '自定义 libarchive Worker',
178
+ workerUrl: resolveFileViewerArchiveWorkerUrl(options, baseUrl),
179
+ wasmUrl,
180
+ });
181
+ return candidates;
182
+ }
183
+ const publicWorkerUrl = resolveFileViewerArchiveWorkerUrl(undefined, baseUrl);
184
+ if (await probeWorkerUrl(publicWorkerUrl)) {
185
+ candidates.push({
186
+ label: '静态 libarchive Worker',
187
+ workerUrl: publicWorkerUrl,
188
+ wasmUrl,
189
+ });
190
+ }
191
+ return candidates;
192
+ };
193
+ const buildNestedOptions = (context, archiveOptions) => ({
194
+ ...((context === null || context === void 0 ? void 0 : context.options) || {}),
195
+ archive: archiveOptions,
196
+ });
197
+ const renderNestedWithCoreFallback = async (buffer, type, target, context) => {
198
+ const { fileViewerCoreRendererDispatcher } = await import('@file-viewer/core');
199
+ const handler = fileViewerCoreRendererDispatcher.resolve(type);
200
+ if (!handler) {
201
+ target.textContent = `不支持.${type}格式的在线预览,请下载后预览或转换为支持的格式`;
202
+ return undefined;
203
+ }
204
+ return handler(buffer, target, type, {
205
+ ...context,
206
+ renderNestedBuffer: (context === null || context === void 0 ? void 0 : context.renderNestedBuffer) || renderNestedWithCoreFallback,
207
+ });
208
+ };
209
+ export default async function renderArchive(buffer, target, _type, context) {
210
+ var _a;
211
+ const documentRef = target.ownerDocument;
212
+ const targetWindow = documentRef.defaultView || null;
213
+ const archiveOptions = (_a = context === null || context === void 0 ? void 0 : context.options) === null || _a === void 0 ? void 0 : _a.archive;
214
+ const filename = (context === null || context === void 0 ? void 0 : context.filename) || 'archive.bin';
215
+ const maxArchiveSize = (archiveOptions === null || archiveOptions === void 0 ? void 0 : archiveOptions.maxArchiveSize) || DEFAULT_MAX_ARCHIVE_SIZE;
216
+ const maxEntryPreviewSize = (archiveOptions === null || archiveOptions === void 0 ? void 0 : archiveOptions.maxEntryPreviewSize) || DEFAULT_MAX_ENTRY_PREVIEW_SIZE;
217
+ const cacheEnabled = (archiveOptions === null || archiveOptions === void 0 ? void 0 : archiveOptions.cache) !== false;
218
+ const workerTimeoutMs = (archiveOptions === null || archiveOptions === void 0 ? void 0 : archiveOptions.workerTimeoutMs) || DEFAULT_WORKER_TIMEOUT_MS;
219
+ const objectUrls = [];
220
+ const cleanups = [];
221
+ let archiveReader = null;
222
+ let entries = [];
223
+ let selectedEntry = null;
224
+ let nestedRendered;
225
+ let loading = false;
226
+ let loadingText = '正在读取压缩包目录...';
227
+ let loadingHint = '大文件会在 Worker 中解析,避免阻塞主线程。';
228
+ let errorText = '';
229
+ let archiveNotice = '';
230
+ let encrypted = null;
231
+ let filterText = '';
232
+ const style = createStyle(documentRef);
233
+ const root = createElement(documentRef, 'section', 'archive-shell archive-viewer');
234
+ const sidebar = createElement(documentRef, 'aside', 'archive-sidebar');
235
+ const head = createElement(documentRef, 'div', 'archive-head');
236
+ const badge = createElement(documentRef, 'span', undefined, 'ARCHIVE');
237
+ const title = createElement(documentRef, 'strong', undefined, filename);
238
+ const stats = createElement(documentRef, 'p');
239
+ head.append(badge, title, stats);
240
+ const warning = createElement(documentRef, 'div', 'archive-warning');
241
+ const info = createElement(documentRef, 'div', 'archive-info');
242
+ const search = createElement(documentRef, 'input', 'archive-search');
243
+ search.type = 'search';
244
+ search.placeholder = '筛选压缩包内文件';
245
+ const list = createElement(documentRef, 'div', 'archive-list');
246
+ list.setAttribute('role', 'list');
247
+ sidebar.append(head, warning, info, search, list);
248
+ const preview = createElement(documentRef, 'main', 'archive-preview');
249
+ const toolbar = createElement(documentRef, 'div', 'archive-preview-toolbar');
250
+ const toolbarTitle = createElement(documentRef, 'div');
251
+ toolbarTitle.append(createElement(documentRef, 'span', undefined, '压缩包内预览'), createElement(documentRef, 'strong', undefined, '请选择一个文件'));
252
+ const downloadButton = createElement(documentRef, 'button', undefined, '下载文件');
253
+ downloadButton.type = 'button';
254
+ toolbar.append(toolbarTitle, downloadButton);
255
+ const nestedTarget = createElement(documentRef, 'div', 'archive-nested-target');
256
+ preview.append(toolbar, nestedTarget);
257
+ root.append(sidebar, preview);
258
+ const state = createElement(documentRef, 'div', 'archive-state');
259
+ const stateContent = createElement(documentRef, 'div');
260
+ const spinner = createElement(documentRef, 'span', 'archive-spinner');
261
+ const stateCopy = createElement(documentRef, 'div');
262
+ const stateTitle = createElement(documentRef, 'strong', undefined, loadingText);
263
+ const stateHint = createElement(documentRef, 'p', undefined, loadingHint);
264
+ stateCopy.append(stateTitle, stateHint);
265
+ stateContent.append(spinner, stateCopy);
266
+ state.append(stateContent);
267
+ root.append(state);
268
+ const error = createElement(documentRef, 'div', 'archive-error');
269
+ const errorTitle = createElement(documentRef, 'strong', undefined, '压缩包预览提示');
270
+ const errorMessage = createElement(documentRef, 'p');
271
+ error.append(errorTitle, errorMessage);
272
+ root.append(error);
273
+ target.replaceChildren(style, root);
274
+ const listen = (element, event, listener) => {
275
+ element.addEventListener(event, listener);
276
+ cleanups.push(() => element.removeEventListener(event, listener));
277
+ };
278
+ const getArchiveStats = () => {
279
+ const totalSize = entries.reduce((sum, entry) => sum + entry.size, 0);
280
+ const previewableCount = entries.filter(entry => entry.previewable).length;
281
+ return {
282
+ count: entries.length,
283
+ totalSize,
284
+ previewableCount,
285
+ };
286
+ };
287
+ const getFilteredEntries = () => {
288
+ const keyword = filterText.trim().toLowerCase();
289
+ const source = keyword
290
+ ? entries.filter(entry => entry.path.toLowerCase().includes(keyword))
291
+ : entries;
292
+ return source.slice(0, MAX_LISTED_ENTRIES);
293
+ };
294
+ const clearNestedPreview = async () => {
295
+ await disposeFileViewerRendered(nestedRendered);
296
+ nestedRendered = undefined;
297
+ nestedTarget.replaceChildren();
298
+ };
299
+ const closeArchive = async () => {
300
+ var _a;
301
+ await ((_a = archiveReader === null || archiveReader === void 0 ? void 0 : archiveReader.close) === null || _a === void 0 ? void 0 : _a.call(archiveReader));
302
+ archiveReader = null;
303
+ };
304
+ const syncState = () => {
305
+ const archiveStats = getArchiveStats();
306
+ stats.textContent = `${archiveStats.count} 个文件 · ${formatArchiveBytes(archiveStats.totalSize)} · ${archiveStats.previewableCount} 个可直接预览`;
307
+ warning.textContent = '检测到加密内容,当前在线预览不接收密码,建议下载后本地解压。';
308
+ warning.classList.toggle('archive-hidden', !encrypted);
309
+ info.textContent = archiveNotice;
310
+ info.classList.toggle('archive-hidden', !archiveNotice);
311
+ state.classList.toggle('archive-hidden', !loading);
312
+ stateTitle.textContent = loadingText;
313
+ stateHint.textContent = loadingHint;
314
+ error.classList.toggle('archive-hidden', !errorText);
315
+ errorMessage.textContent = errorText;
316
+ downloadButton.hidden = !selectedEntry;
317
+ const activeTitle = toolbarTitle.querySelector('strong');
318
+ if (activeTitle) {
319
+ activeTitle.textContent = (selectedEntry === null || selectedEntry === void 0 ? void 0 : selectedEntry.name) || '请选择一个文件';
320
+ }
321
+ };
322
+ const renderEmptyState = () => {
323
+ if (selectedEntry || loading || nestedTarget.childElementCount) {
324
+ return;
325
+ }
326
+ const empty = createElement(documentRef, 'div', 'archive-empty');
327
+ empty.append(createElement(documentRef, 'strong', undefined, '选择左侧文件即可预览'), createElement(documentRef, 'p', undefined, '压缩包只读取目录;文件内容会在点击后按需解压,并在体积允许时缓存到 IndexedDB。'));
328
+ nestedTarget.replaceChildren(empty);
329
+ };
330
+ const renderEntryList = () => {
331
+ list.replaceChildren();
332
+ getFilteredEntries().forEach(entry => {
333
+ const button = createElement(documentRef, 'button', 'archive-entry');
334
+ button.type = 'button';
335
+ button.style.setProperty('--entry-depth', String(entry.depth));
336
+ button.classList.toggle('active', (selectedEntry === null || selectedEntry === void 0 ? void 0 : selectedEntry.id) === entry.id);
337
+ const icon = createElement(documentRef, 'span', 'entry-ext', entry.extension || 'file');
338
+ const copy = createElement(documentRef, 'span', 'entry-copy');
339
+ copy.append(createElement(documentRef, 'strong', undefined, entry.name), createElement(documentRef, 'em', undefined, entry.path));
340
+ button.append(icon, copy, createElement(documentRef, 'small', undefined, formatArchiveBytes(entry.size)));
341
+ button.addEventListener('click', () => {
342
+ void previewEntry(entry);
343
+ });
344
+ list.append(button);
345
+ });
346
+ };
347
+ const setLoading = (next, text, hint) => {
348
+ loading = next;
349
+ if (text) {
350
+ loadingText = text;
351
+ }
352
+ if (hint) {
353
+ loadingHint = hint;
354
+ }
355
+ syncState();
356
+ renderEmptyState();
357
+ };
358
+ const setError = (message) => {
359
+ errorText = message;
360
+ syncState();
361
+ };
362
+ const terminateWorkers = (workers) => {
363
+ workers.forEach(worker => worker.terminate());
364
+ workers.length = 0;
365
+ };
366
+ const tryOpenArchiveWithWorker = async (Archive, candidate) => {
367
+ const createdWorkers = [];
368
+ const workerUrl = await prepareWorkerUrl(candidate, objectUrls);
369
+ const WorkerCtor = getWorkerConstructor(documentRef);
370
+ try {
371
+ Archive.init({
372
+ getWorker: () => {
373
+ const worker = new WorkerCtor(workerUrl, { type: 'module' });
374
+ createdWorkers.push(worker);
375
+ return worker;
376
+ },
377
+ });
378
+ setLoading(true, `正在初始化${candidate.label}...`, '如果当前服务器没有正确发布 Worker/WASM,会自动切换兼容模式。');
379
+ const archiveFile = createArchiveFile(documentRef, buffer, filename);
380
+ const archive = await withTimeout(Archive.open(archiveFile), workerTimeoutMs, `${candidate.label} 初始化超时`, targetWindow);
381
+ archiveReader = archive;
382
+ encrypted = await withTimeout(archive.hasEncryptedData(), workerTimeoutMs, `${candidate.label} 加密检测超时`, targetWindow).catch(() => null);
383
+ setLoading(true, '正在读取压缩包目录...', '目录读取完成后,点击内部文件才会按需解压。');
384
+ const fileTree = await withTimeout(archive.getFilesObject(), workerTimeoutMs, `${candidate.label} 读取目录超时`, targetWindow);
385
+ entries = flattenArchiveObject(fileTree)
386
+ .sort((left, right) => left.path.localeCompare(right.path));
387
+ syncState();
388
+ renderEntryList();
389
+ renderEmptyState();
390
+ return true;
391
+ }
392
+ catch (reason) {
393
+ if (!archiveReader) {
394
+ terminateWorkers(createdWorkers);
395
+ }
396
+ throw reason;
397
+ }
398
+ };
399
+ const tryOpenArchiveWithFallback = async () => {
400
+ setLoading(true, 'Worker 不可用,正在切换 ZIP/TAR 兼容模式...', '兼容模式无需额外静态 Worker,适合手机 WebView 或本地临时服务器。');
401
+ const fallbackEntries = await loadArchiveEntriesWithoutWorker(buffer, filename);
402
+ if (!fallbackEntries) {
403
+ return false;
404
+ }
405
+ entries = fallbackEntries.sort((left, right) => left.path.localeCompare(right.path));
406
+ encrypted = null;
407
+ archiveNotice = '当前环境的 libarchive Worker 未能启动,已自动切换到 ZIP/TAR/GZIP 兼容模式。RAR、7z 等格式仍建议发布 vendor/libarchive/worker-bundle.js 与 libarchive.wasm。';
408
+ syncState();
409
+ renderEntryList();
410
+ renderEmptyState();
411
+ return true;
412
+ };
413
+ const openArchive = async () => {
414
+ if (buffer.byteLength > maxArchiveSize) {
415
+ setError(`压缩包体积 ${formatArchiveBytes(buffer.byteLength)} 超过安全上限 ${formatArchiveBytes(maxArchiveSize)},请下载后在本地解压。`);
416
+ return;
417
+ }
418
+ setLoading(true, '正在初始化压缩包解析 Worker...', '大文件会在 Worker 中解析,避免阻塞主线程。');
419
+ setError('');
420
+ archiveNotice = '';
421
+ try {
422
+ const [{ Archive }, candidates] = await Promise.all([
423
+ import('libarchive.js'),
424
+ resolveWorkerCandidates(documentRef, archiveOptions),
425
+ ]);
426
+ const errors = [];
427
+ for (const candidate of candidates) {
428
+ try {
429
+ await closeArchive();
430
+ await tryOpenArchiveWithWorker(Archive, candidate);
431
+ return;
432
+ }
433
+ catch (reason) {
434
+ errors.push(`${candidate.label}: ${normalizeWorkerError(reason)}`);
435
+ }
436
+ }
437
+ await closeArchive();
438
+ if (await tryOpenArchiveWithFallback()) {
439
+ return;
440
+ }
441
+ throw new Error(errors.join(';') || '压缩包 Worker 初始化失败');
442
+ }
443
+ catch (nextError) {
444
+ console.error(nextError);
445
+ setError(nextError instanceof Error ? nextError.message : String(nextError));
446
+ }
447
+ finally {
448
+ setLoading(false);
449
+ }
450
+ };
451
+ const renderEntryBuffer = async (entry, entryBuffer) => {
452
+ await clearNestedPreview();
453
+ const child = createElement(documentRef, 'div', 'archive-nested-content');
454
+ nestedTarget.append(child);
455
+ const nestedContext = {
456
+ ...context,
457
+ filename: entry.name,
458
+ options: buildNestedOptions(context, archiveOptions),
459
+ };
460
+ nestedRendered = (context === null || context === void 0 ? void 0 : context.renderNestedBuffer)
461
+ ? await context.renderNestedBuffer(entryBuffer, entry.extension, child, nestedContext)
462
+ : await renderNestedWithCoreFallback(entryBuffer, entry.extension, child, nestedContext);
463
+ };
464
+ const extractEntryBuffer = async (entry) => {
465
+ const cacheKey = createArchiveCacheKey(filename, buffer.byteLength, entry);
466
+ if (cacheEnabled) {
467
+ const cached = await readArchiveCache(cacheKey);
468
+ if (cached) {
469
+ return cached.buffer;
470
+ }
471
+ }
472
+ const file = await entry.compressedFile.extract();
473
+ const entryBuffer = await file.arrayBuffer();
474
+ if (cacheEnabled) {
475
+ await writeArchiveCache({
476
+ key: cacheKey,
477
+ filename: entry.name,
478
+ size: entryBuffer.byteLength,
479
+ updatedAt: Date.now(),
480
+ buffer: entryBuffer,
481
+ });
482
+ }
483
+ return entryBuffer;
484
+ };
485
+ async function previewEntry(entry) {
486
+ selectedEntry = entry;
487
+ renderEntryList();
488
+ syncState();
489
+ if (entry.size > maxEntryPreviewSize) {
490
+ setError(`压缩包内文件 ${entry.name} 体积 ${formatArchiveBytes(entry.size)} 超过预览上限 ${formatArchiveBytes(maxEntryPreviewSize)}。`);
491
+ return;
492
+ }
493
+ setLoading(true, `正在按需解压 ${entry.name}...`);
494
+ setError('');
495
+ try {
496
+ const entryBuffer = await extractEntryBuffer(entry);
497
+ setLoading(true, `正在渲染 ${entry.name}...`);
498
+ await renderEntryBuffer(entry, entryBuffer);
499
+ }
500
+ catch (nextError) {
501
+ console.error(nextError);
502
+ setError(nextError instanceof Error ? nextError.message : String(nextError));
503
+ }
504
+ finally {
505
+ setLoading(false);
506
+ }
507
+ }
508
+ const downloadEntry = async (entry) => {
509
+ setLoading(true, `正在导出 ${entry.name}...`);
510
+ try {
511
+ const entryBuffer = await extractEntryBuffer(entry);
512
+ const url = URL.createObjectURL(new Blob([entryBuffer]));
513
+ objectUrls.push(url);
514
+ const link = documentRef.createElement('a');
515
+ link.href = url;
516
+ link.download = entry.name;
517
+ documentRef.body.append(link);
518
+ link.click();
519
+ link.remove();
520
+ }
521
+ finally {
522
+ setLoading(false);
523
+ }
524
+ };
525
+ listen(search, 'input', () => {
526
+ filterText = search.value;
527
+ renderEntryList();
528
+ });
529
+ listen(downloadButton, 'click', () => {
530
+ if (selectedEntry) {
531
+ void downloadEntry(selectedEntry);
532
+ }
533
+ });
534
+ syncState();
535
+ renderEmptyState();
536
+ void openArchive();
537
+ return {
538
+ $el: root,
539
+ async unmount() {
540
+ cleanups.splice(0).forEach(cleanup => cleanup());
541
+ await clearNestedPreview();
542
+ await closeArchive();
543
+ objectUrls.forEach(url => URL.revokeObjectURL(url));
544
+ target.replaceChildren();
545
+ },
546
+ };
547
+ }
@@ -0,0 +1,10 @@
1
+ interface CachedArchiveEntry {
2
+ key: string;
3
+ filename: string;
4
+ size: number;
5
+ updatedAt: number;
6
+ buffer: ArrayBuffer;
7
+ }
8
+ export declare const readArchiveCache: (key: string) => Promise<CachedArchiveEntry | null>;
9
+ export declare const writeArchiveCache: (entry: CachedArchiveEntry) => Promise<void>;
10
+ export {};
@@ -0,0 +1,96 @@
1
+ const DB_NAME = 'flyfish-file-viewer-cache';
2
+ const STORE_NAME = 'archiveEntries';
3
+ const DB_VERSION = 1;
4
+ const MAX_CACHE_ENTRY_BYTES = 24 * 1024 * 1024;
5
+ const MAX_CACHE_TOTAL_BYTES = 96 * 1024 * 1024;
6
+ let dbPromise = null;
7
+ const canUseIndexedDB = () => typeof indexedDB !== 'undefined';
8
+ const openCacheDb = () => {
9
+ if (!canUseIndexedDB()) {
10
+ return Promise.reject(new Error('IndexedDB 不可用'));
11
+ }
12
+ if (dbPromise) {
13
+ return dbPromise;
14
+ }
15
+ dbPromise = new Promise((resolve, reject) => {
16
+ const request = indexedDB.open(DB_NAME, DB_VERSION);
17
+ request.onupgradeneeded = () => {
18
+ const db = request.result;
19
+ if (!db.objectStoreNames.contains(STORE_NAME)) {
20
+ const store = db.createObjectStore(STORE_NAME, { keyPath: 'key' });
21
+ store.createIndex('updatedAt', 'updatedAt');
22
+ }
23
+ };
24
+ request.onsuccess = () => resolve(request.result);
25
+ request.onerror = () => reject(request.error);
26
+ });
27
+ return dbPromise;
28
+ };
29
+ const runStore = async (mode, runner) => {
30
+ const db = await openCacheDb();
31
+ return new Promise((resolve, reject) => {
32
+ const transaction = db.transaction(STORE_NAME, mode);
33
+ const request = runner(transaction.objectStore(STORE_NAME));
34
+ request.onsuccess = () => resolve(request.result);
35
+ request.onerror = () => reject(request.error);
36
+ transaction.onerror = () => reject(transaction.error);
37
+ });
38
+ };
39
+ export const readArchiveCache = async (key) => {
40
+ try {
41
+ const cached = await runStore('readonly', store => store.get(key));
42
+ return cached || null;
43
+ }
44
+ catch {
45
+ return null;
46
+ }
47
+ };
48
+ const pruneArchiveCache = async () => {
49
+ try {
50
+ const db = await openCacheDb();
51
+ await new Promise((resolve, reject) => {
52
+ const transaction = db.transaction(STORE_NAME, 'readwrite');
53
+ const store = transaction.objectStore(STORE_NAME);
54
+ const index = store.index('updatedAt');
55
+ const entries = [];
56
+ let total = 0;
57
+ index.openCursor().onsuccess = event => {
58
+ const cursor = event.target.result;
59
+ if (!cursor) {
60
+ while (total > MAX_CACHE_TOTAL_BYTES && entries.length) {
61
+ const entry = entries.shift();
62
+ if (entry) {
63
+ total -= entry.size;
64
+ store.delete(entry.key);
65
+ }
66
+ }
67
+ return;
68
+ }
69
+ const value = cursor.value;
70
+ total += value.size || 0;
71
+ entries.push({ key: value.key, size: value.size || 0 });
72
+ cursor.continue();
73
+ };
74
+ transaction.oncomplete = () => resolve();
75
+ transaction.onerror = () => reject(transaction.error);
76
+ });
77
+ }
78
+ catch {
79
+ // Cache pruning is a best-effort optimization and must not block preview.
80
+ }
81
+ };
82
+ export const writeArchiveCache = async (entry) => {
83
+ if (entry.size > MAX_CACHE_ENTRY_BYTES) {
84
+ return;
85
+ }
86
+ try {
87
+ await runStore('readwrite', store => store.put({
88
+ ...entry,
89
+ updatedAt: Date.now(),
90
+ }));
91
+ await pruneArchiveCache();
92
+ }
93
+ catch {
94
+ // Quota, private-mode, or browser policy errors simply disable cache writes.
95
+ }
96
+ };
@@ -0,0 +1,7 @@
1
+ import { type ArchiveEntryView } from './archiveShared.js';
2
+ /**
3
+ * Worker fallback for constrained browsers, temporary local servers, and
4
+ * mobile WebViews. The main libarchive path still covers broader formats;
5
+ * this covers common ZIP/TAR/GZIP archives without an extra static Worker.
6
+ */
7
+ export declare const loadArchiveEntriesWithoutWorker: (data: ArrayBuffer, filename: string) => Promise<ArchiveEntryView[] | null>;
@@ -0,0 +1,166 @@
1
+ import { getArchiveEntryExtension, isPreviewableArchiveEntry, } from './archiveShared.js';
2
+ const ZIP_LIKE_EXTENSIONS = new Set(['zip', 'zipx', 'jar', 'war', 'ear', 'apk', 'cbz']);
3
+ const TAR_LIKE_EXTENSIONS = new Set(['tar', 'tgz', 'gz', 'gzip']);
4
+ const TAR_BLOCK_SIZE = 512;
5
+ const toArrayBuffer = (bytes) => {
6
+ const output = new Uint8Array(bytes.byteLength);
7
+ output.set(bytes);
8
+ return output.buffer;
9
+ };
10
+ const decompressBytes = async (bytes, format) => {
11
+ if (typeof DecompressionStream === 'undefined') {
12
+ return null;
13
+ }
14
+ const stream = new Blob([toArrayBuffer(bytes)])
15
+ .stream()
16
+ .pipeThrough(new DecompressionStream(format));
17
+ return new Uint8Array(await new Response(stream).arrayBuffer());
18
+ };
19
+ const normalizeArchivePath = (path) => {
20
+ return path.replace(/^\/+/, '').replace(/\\/g, '/');
21
+ };
22
+ const getPathName = (path) => {
23
+ const parts = normalizeArchivePath(path).split('/');
24
+ return parts[parts.length - 1] || path;
25
+ };
26
+ const getPathDepth = (path) => {
27
+ return Math.max(0, normalizeArchivePath(path).split('/').length - 1);
28
+ };
29
+ const createEntryView = (source) => {
30
+ const path = normalizeArchivePath(source.path);
31
+ const name = getPathName(path);
32
+ return {
33
+ id: path,
34
+ path,
35
+ name,
36
+ extension: getArchiveEntryExtension(name),
37
+ size: source.size,
38
+ lastModified: source.lastModified,
39
+ depth: getPathDepth(path),
40
+ previewable: isPreviewableArchiveEntry(name),
41
+ compressedFile: {
42
+ name,
43
+ size: source.size,
44
+ lastModified: source.lastModified,
45
+ async extract() {
46
+ const buffer = await source.load();
47
+ return new File([buffer], name, {
48
+ type: 'application/octet-stream',
49
+ lastModified: source.lastModified || Date.now(),
50
+ });
51
+ },
52
+ },
53
+ };
54
+ };
55
+ const parseOctal = (bytes, start, length) => {
56
+ const text = new TextDecoder('ascii')
57
+ .decode(bytes.slice(start, start + length))
58
+ .replace(/\0.*$/, '')
59
+ .trim();
60
+ return text ? Number.parseInt(text, 8) || 0 : 0;
61
+ };
62
+ const readTarName = (bytes, offset) => {
63
+ const decoder = new TextDecoder('utf-8');
64
+ const name = decoder.decode(bytes.slice(offset, offset + 100)).replace(/\0.*$/, '');
65
+ const prefix = decoder.decode(bytes.slice(offset + 345, offset + 500)).replace(/\0.*$/, '');
66
+ return normalizeArchivePath(prefix ? `${prefix}/${name}` : name);
67
+ };
68
+ const parseTarEntries = (bytes) => {
69
+ const entries = [];
70
+ let offset = 0;
71
+ while (offset + TAR_BLOCK_SIZE <= bytes.length) {
72
+ const header = bytes.slice(offset, offset + TAR_BLOCK_SIZE);
73
+ if (header.every(value => value === 0)) {
74
+ break;
75
+ }
76
+ const path = readTarName(bytes, offset);
77
+ const size = parseOctal(bytes, offset + 124, 12);
78
+ const typeFlag = String.fromCharCode(bytes[offset + 156] || 0);
79
+ const dataOffset = offset + TAR_BLOCK_SIZE;
80
+ const nextOffset = dataOffset + Math.ceil(size / TAR_BLOCK_SIZE) * TAR_BLOCK_SIZE;
81
+ if (path && typeFlag !== '5') {
82
+ const fileBytes = bytes.slice(dataOffset, dataOffset + size);
83
+ entries.push(createEntryView({
84
+ path,
85
+ size,
86
+ load: async () => toArrayBuffer(fileBytes),
87
+ }));
88
+ }
89
+ offset = nextOffset;
90
+ }
91
+ return entries;
92
+ };
93
+ const getArchiveExtension = (filename) => {
94
+ const lower = filename.toLowerCase();
95
+ if (lower.endsWith('.tar.gz') || lower.endsWith('.tgz')) {
96
+ return 'tgz';
97
+ }
98
+ return getArchiveEntryExtension(filename);
99
+ };
100
+ const getGzipEntryName = (filename) => {
101
+ const lower = filename.toLowerCase();
102
+ if (lower.endsWith('.gzip')) {
103
+ return filename.slice(0, -5) || 'archive';
104
+ }
105
+ if (lower.endsWith('.gz')) {
106
+ return filename.slice(0, -3) || 'archive';
107
+ }
108
+ return `${filename || 'archive'}.bin`;
109
+ };
110
+ const loadZipEntries = async (data) => {
111
+ const { default: JSZip } = await import('jszip');
112
+ const zip = await JSZip.loadAsync(data);
113
+ const entries = [];
114
+ zip.forEach((relativePath, file) => {
115
+ var _a, _b;
116
+ if (file.dir) {
117
+ return;
118
+ }
119
+ const metadata = file;
120
+ const normalizedPath = normalizeArchivePath(relativePath);
121
+ entries.push(createEntryView({
122
+ path: normalizedPath,
123
+ size: ((_a = metadata._data) === null || _a === void 0 ? void 0 : _a.uncompressedSize) || 0,
124
+ lastModified: (_b = file.date) === null || _b === void 0 ? void 0 : _b.getTime(),
125
+ load: async () => file.async('arraybuffer'),
126
+ }));
127
+ });
128
+ return entries;
129
+ };
130
+ const loadTarEntries = async (data, filename, extension) => {
131
+ const source = new Uint8Array(data);
132
+ const bytes = extension === 'tar'
133
+ ? source
134
+ : await decompressBytes(source, 'gzip');
135
+ if (!bytes) {
136
+ return null;
137
+ }
138
+ if (extension === 'gz' || extension === 'gzip') {
139
+ const lower = filename.toLowerCase();
140
+ const isTarGz = lower.endsWith('.tar.gz') || lower.endsWith('.tgz');
141
+ if (!isTarGz) {
142
+ const name = getGzipEntryName(filename);
143
+ return [createEntryView({
144
+ path: name,
145
+ size: bytes.byteLength,
146
+ load: async () => toArrayBuffer(bytes),
147
+ })];
148
+ }
149
+ }
150
+ return parseTarEntries(bytes);
151
+ };
152
+ /**
153
+ * Worker fallback for constrained browsers, temporary local servers, and
154
+ * mobile WebViews. The main libarchive path still covers broader formats;
155
+ * this covers common ZIP/TAR/GZIP archives without an extra static Worker.
156
+ */
157
+ export const loadArchiveEntriesWithoutWorker = async (data, filename) => {
158
+ const extension = getArchiveExtension(filename);
159
+ if (ZIP_LIKE_EXTENSIONS.has(extension)) {
160
+ return loadZipEntries(data);
161
+ }
162
+ if (TAR_LIKE_EXTENSIONS.has(extension)) {
163
+ return loadTarEntries(data, filename, extension);
164
+ }
165
+ return null;
166
+ };
@@ -0,0 +1,23 @@
1
+ export declare const ARCHIVE_PREVIEWABLE_EXTENSIONS: readonly string[];
2
+ export interface ArchiveEntryView {
3
+ id: string;
4
+ path: string;
5
+ name: string;
6
+ extension: string;
7
+ size: number;
8
+ lastModified?: number;
9
+ depth: number;
10
+ previewable: boolean;
11
+ compressedFile: {
12
+ name: string;
13
+ size: number;
14
+ lastModified?: number;
15
+ extract(): Promise<File>;
16
+ };
17
+ }
18
+ export declare const getArchiveEntryExtension: (name: string) => string;
19
+ export declare const isArchiveExtension: (extension: string) => boolean;
20
+ export declare const isPreviewableArchiveEntry: (name: string) => boolean;
21
+ export declare const formatArchiveBytes: (value: number) => string;
22
+ export declare const flattenArchiveObject: (input: Record<string, unknown>, prefix?: string) => ArchiveEntryView[];
23
+ export declare const createArchiveCacheKey: (archiveName: string, archiveSize: number, entry: ArchiveEntryView) => string;
@@ -0,0 +1,71 @@
1
+ import { ARCHIVE_EXTENSIONS, DEFAULT_SUPPORTED_EXTENSIONS, } from '@file-viewer/core';
2
+ export const ARCHIVE_PREVIEWABLE_EXTENSIONS = DEFAULT_SUPPORTED_EXTENSIONS;
3
+ export const getArchiveEntryExtension = (name) => {
4
+ const clean = name.split(/[?#]/)[0] || name;
5
+ const dot = clean.lastIndexOf('.');
6
+ return dot === -1 ? '' : clean.slice(dot + 1).toLowerCase();
7
+ };
8
+ export const isArchiveExtension = (extension) => (ARCHIVE_EXTENSIONS.includes(extension.toLowerCase()));
9
+ export const isPreviewableArchiveEntry = (name) => {
10
+ const extension = getArchiveEntryExtension(name);
11
+ return ARCHIVE_PREVIEWABLE_EXTENSIONS.includes(extension);
12
+ };
13
+ export const formatArchiveBytes = (value) => {
14
+ if (!Number.isFinite(value) || value < 0) {
15
+ return '-';
16
+ }
17
+ if (value < 1024) {
18
+ return `${value} B`;
19
+ }
20
+ const units = ['KB', 'MB', 'GB'];
21
+ let next = value / 1024;
22
+ for (const unit of units) {
23
+ if (next < 1024 || unit === units[units.length - 1]) {
24
+ return `${next.toFixed(next < 10 ? 1 : 0)} ${unit}`;
25
+ }
26
+ next /= 1024;
27
+ }
28
+ return `${value} B`;
29
+ };
30
+ const isCompressedFile = (value) => {
31
+ return typeof value === 'object' &&
32
+ value !== null &&
33
+ 'extract' in value &&
34
+ typeof value.extract === 'function';
35
+ };
36
+ export const flattenArchiveObject = (input, prefix = '') => {
37
+ const entries = [];
38
+ Object.entries(input).forEach(([key, value]) => {
39
+ const path = prefix ? `${prefix}/${key}` : key;
40
+ if (isCompressedFile(value)) {
41
+ const name = value.name || key;
42
+ const extension = getArchiveEntryExtension(name);
43
+ entries.push({
44
+ id: path,
45
+ path,
46
+ name,
47
+ extension,
48
+ size: value.size || 0,
49
+ lastModified: value.lastModified,
50
+ depth: path.split('/').length - 1,
51
+ previewable: isPreviewableArchiveEntry(name),
52
+ compressedFile: value,
53
+ });
54
+ return;
55
+ }
56
+ if (value && typeof value === 'object') {
57
+ entries.push(...flattenArchiveObject(value, path));
58
+ }
59
+ });
60
+ return entries;
61
+ };
62
+ export const createArchiveCacheKey = (archiveName, archiveSize, entry) => {
63
+ return [
64
+ 'archive-entry',
65
+ archiveName || 'archive',
66
+ archiveSize,
67
+ entry.path,
68
+ entry.size,
69
+ entry.lastModified || 0,
70
+ ].join(':');
71
+ };
@@ -0,0 +1,5 @@
1
+ import { type FileRenderHandler, type FileViewerRenderedInstance, type FileViewerRendererPlugin, type RendererDefinition } from '@file-viewer/core';
2
+ export declare const archiveRendererDefinition: RendererDefinition;
3
+ export declare const renderFileViewerArchive: FileRenderHandler<FileViewerRenderedInstance, HTMLDivElement>;
4
+ export declare const archiveRenderer: FileViewerRendererPlugin<FileRenderHandler<FileViewerRenderedInstance, HTMLDivElement>>;
5
+ export default archiveRenderer;
package/dist/index.js ADDED
@@ -0,0 +1,17 @@
1
+ import { DEFAULT_RENDERER_DEFINITIONS, } from '@file-viewer/core';
2
+ const archiveDefinition = DEFAULT_RENDERER_DEFINITIONS.find(definition => definition.id === 'archive');
3
+ if (!archiveDefinition) {
4
+ throw new Error('@file-viewer/renderer-archive could not locate the shared archive format definition.');
5
+ }
6
+ export const archiveRendererDefinition = archiveDefinition;
7
+ export const renderFileViewerArchive = (buffer, target, type, context) => import('./archive.js').then(({ default: renderArchive }) => renderArchive(buffer, target, type, context));
8
+ export const archiveRenderer = {
9
+ id: 'file-viewer-renderer-archive',
10
+ label: 'Flyfish File Viewer archive renderer',
11
+ definitions: [archiveRendererDefinition],
12
+ handlers: [{
13
+ rendererId: archiveRendererDefinition.id,
14
+ handler: renderFileViewerArchive,
15
+ }],
16
+ };
17
+ export default archiveRenderer;
package/package.json ADDED
@@ -0,0 +1,71 @@
1
+ {
2
+ "name": "@file-viewer/renderer-archive",
3
+ "version": "2.0.11",
4
+ "private": false,
5
+ "type": "module",
6
+ "description": "Standalone archive renderer plugin for Flyfish File Viewer with libarchive worker, ZIP/TAR fallback, IndexedDB cache, and nested previews.",
7
+ "keywords": [
8
+ "file-viewer",
9
+ "renderer",
10
+ "archive",
11
+ "zip",
12
+ "rar",
13
+ "7z",
14
+ "tar",
15
+ "document-preview",
16
+ "document-viewer",
17
+ "file-preview",
18
+ "self-hosted"
19
+ ],
20
+ "publishConfig": {
21
+ "access": "public",
22
+ "registry": "https://registry.npmjs.org/"
23
+ },
24
+ "author": {
25
+ "name": "Wangyu",
26
+ "email": "wybaby168@gmail.com"
27
+ },
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/flyfish-dev/file-viewer-renderer-archive.git",
31
+ "directory": "packages/renderers/archive"
32
+ },
33
+ "homepage": "https://doc.file-viewer.app/guide/on-demand-renderers",
34
+ "bugs": {
35
+ "url": "https://github.com/flyfish-dev/file-viewer-renderer-archive/issues"
36
+ },
37
+ "funding": {
38
+ "type": "individual",
39
+ "url": "https://dev.flyfish.group/shop"
40
+ },
41
+ "main": "./dist/index.js",
42
+ "module": "./dist/index.js",
43
+ "types": "./dist/index.d.ts",
44
+ "exports": {
45
+ ".": {
46
+ "types": "./dist/index.d.ts",
47
+ "import": "./dist/index.js",
48
+ "default": "./dist/index.js"
49
+ },
50
+ "./package.json": "./package.json"
51
+ },
52
+ "files": [
53
+ "dist",
54
+ "README.md",
55
+ "README.en.md",
56
+ "LICENSE"
57
+ ],
58
+ "dependencies": {
59
+ "@file-viewer/core": "^2.0.11",
60
+ "jszip": "^3.10.1",
61
+ "libarchive.js": "^2.0.2"
62
+ },
63
+ "devDependencies": {
64
+ "typescript": "^6.0.3"
65
+ },
66
+ "license": "Apache-2.0",
67
+ "scripts": {
68
+ "build": "tsc -b tsconfig.json",
69
+ "type-check": "tsc -b tsconfig.json"
70
+ }
71
+ }