@zip.js/zip.js 2.14.1 → 2.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/BENCHMARKS.md +19 -11
- package/deno.json +1 -1
- package/dist/zip-core-external.js +82 -41
- package/dist/zip-core-external.min.js +1 -1
- package/dist/zip-core.js +82 -41
- package/dist/zip-core.min.js +1 -1
- package/dist/zip-fs-core-external.js +82 -41
- package/dist/zip-fs-core-external.min.js +1 -1
- package/dist/zip-fs-core.js +82 -41
- package/dist/zip-fs-core.min.js +1 -1
- package/dist/zip-fs-external.js +82 -41
- package/dist/zip-fs-external.min.js +1 -1
- package/dist/zip-fs-native.js +84 -43
- package/dist/zip-fs-native.min.js +1 -1
- package/dist/zip-fs.js +84 -43
- package/dist/zip-fs.min.js +1 -1
- package/dist/zip-legacy.js +84 -43
- package/dist/zip-legacy.min.js +1 -1
- package/dist/zip-native.js +84 -43
- package/dist/zip-native.min.js +1 -1
- package/dist/zip-web-worker-native.js +1 -1
- package/dist/zip-web-worker.js +1 -1
- package/dist/zip.js +84 -43
- package/dist/zip.min.js +1 -1
- package/index-native.cjs +84 -43
- package/index-native.min.js +1 -1
- package/index.cjs +84 -43
- package/index.d.cts +20 -13
- package/index.d.ts +20 -13
- package/index.min.js +1 -1
- package/lib/core/codec-pool.js +35 -19
- package/lib/core/codec-worker.js +4 -2
- package/lib/core/streams/zip-entry-stream.js +18 -10
- package/lib/core/util/decode-text.js +15 -1
- package/lib/core/version.js +1 -1
- package/lib/core/web-worker-inline-native.js +1 -1
- package/lib/core/web-worker-inline-wasm.js +1 -1
- package/lib/core/zip-reader.js +12 -10
- package/lib/core/zlib-streams-inline.js +1 -1
- package/package.json +1 -1
package/BENCHMARKS.md
CHANGED
|
@@ -39,8 +39,11 @@ The page answers four questions, each from one or two scripts of the harness:
|
|
|
39
39
|
about 2 KB each, and the 8 × 8 MB and 256 MB workloads are text.
|
|
40
40
|
- **Isolation.** In `bench.js` and `bench-backends.js`, each (library, operation, workload)
|
|
41
41
|
combination runs in its own freshly spawned Node process under macOS's `/usr/bin/time -l`, so
|
|
42
|
-
the libraries never share a heap and peak memory is per library.
|
|
43
|
-
|
|
42
|
+
the libraries never share a heap and peak memory is per library. Every row of those two
|
|
43
|
+
scripts includes what a fresh process pays on its first operation: the JIT warm-up of each
|
|
44
|
+
library and, for the zip.js WebAssembly rows, the module instantiation, about 20 ms. The
|
|
45
|
+
codec, encryption and runtime scripts run in-process, with one warmup run before the timed
|
|
46
|
+
ones.
|
|
44
47
|
- **Timing.** `performance.now()` around the measured operation. Each combination runs 3 times
|
|
45
48
|
(5 in the encryption script) and the tables report the median. Differences of a few percent
|
|
46
49
|
are within run-to-run noise. A bold cell is the best number among the alternatives it is
|
|
@@ -59,6 +62,9 @@ The page answers four questions, each from one or two scripts of the harness:
|
|
|
59
62
|
where they exist; the bundled WebAssembly zlib; and the pure-JavaScript zlib port. The last two
|
|
60
63
|
ship with the library, like the codecs of jszip and fflate, so those rows compare the
|
|
61
64
|
libraries without the host's zlib in the picture.
|
|
65
|
+
- **Checks.** No decompression row verifies the CRC-32 of the entries: zip.js and jszip leave
|
|
66
|
+
the check off by default, and fflate has none on read. Turning it on in zip.js adds one pass
|
|
67
|
+
over the output, about 15 ms per 20 MB.
|
|
62
68
|
- **Units.** MB in the tables is 10^6 bytes. The workload sizes (20 MB, 8 MB, 256 MB) and the
|
|
63
69
|
MB/s throughputs use 2^20 bytes.
|
|
64
70
|
|
|
@@ -126,15 +132,17 @@ Peak memory for the same runs (Δ over baseline):
|
|
|
126
132
|
| fflate | fflate (JavaScript) | **92 MB** | **120 MB** |
|
|
127
133
|
|
|
128
134
|
On the 20 MB stream `DecompressionStream` takes 66 ms and the WebAssembly inflate 78 ms, then
|
|
129
|
-
fflate 116 ms, jszip 137 ms and the pure-JavaScript port 180 ms.
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
135
|
+
fflate 116 ms, jszip 137 ms and the pure-JavaScript port 180 ms. The WebAssembly row includes
|
|
136
|
+
the module instantiation a fresh process pays once, about 20 ms. Warm, the two inflates are
|
|
137
|
+
within 6 % of each other in the [Codecs](#codecs-at-equal-output-size) table. On 5,000 small
|
|
138
|
+
files the order reverses: fflate takes 102 ms, jszip 444 ms and the three zip.js rows 476 to
|
|
139
|
+
628 ms, with `DecompressionStream` the slowest of them, the same per-entry cost as in
|
|
140
|
+
compression. fflate uses the least memory on both workloads, 34 % of the `DecompressionStream`
|
|
141
|
+
row on the small files, and the WebAssembly and pure-JavaScript rows peak higher than
|
|
142
|
+
`DecompressionStream`. The three zip.js rows allocate the same 800 MB of stream objects over the
|
|
143
|
+
5,000 entries, about 160 KB per entry. The WebAssembly and pure-JavaScript codecs run on the
|
|
144
|
+
JavaScript thread, so V8's incremental marking keeps more of that garbage alive between
|
|
145
|
+
collections, and that is the difference between the rows.
|
|
138
146
|
|
|
139
147
|
### Streaming a 256 MB file, disk to disk
|
|
140
148
|
|
package/deno.json
CHANGED
|
@@ -1900,8 +1900,8 @@ class DeflateStream extends TransformStream {
|
|
|
1900
1900
|
let crc32Stream, encryptionStream, gzipCrc32Stream;
|
|
1901
1901
|
let readable = super.readable;
|
|
1902
1902
|
const codecStreams = format && getCodecStreams(format);
|
|
1903
|
-
const
|
|
1904
|
-
|
|
1903
|
+
const GzipCompressionStream = getGzipCompressionStream(useCompressionStream, CompressionStream, CompressionStreamFallback);
|
|
1904
|
+
const useGzipCrc32 = computeCrc32 && compressed && !deflate64 && !codecStreams && (!encrypted || zipCrypto) && Boolean(GzipCompressionStream);
|
|
1905
1905
|
if ((!encrypted || zipCrypto) && computeCrc32 && !useGzipCrc32) {
|
|
1906
1906
|
crc32Stream = new Crc32Stream();
|
|
1907
1907
|
readable = pipeThrough(readable, crc32Stream);
|
|
@@ -1911,7 +1911,7 @@ class DeflateStream extends TransformStream {
|
|
|
1911
1911
|
readable = pipeThroughBackpressured(readable, createCodecStream(codecStreams.CompressionStream, format, { level, chunkSize, compressionMethod, uncompressedSize: inputSize }));
|
|
1912
1912
|
} else if (useGzipCrc32) {
|
|
1913
1913
|
gzipCrc32Stream = new GzipToRawDeflateStream();
|
|
1914
|
-
readable = pipeThroughBackpressured(readable, new
|
|
1914
|
+
readable = pipeThroughBackpressured(readable, new GzipCompressionStream(FORMAT_GZIP$1, { level, chunkSize }));
|
|
1915
1915
|
readable = pipeThrough(readable, gzipCrc32Stream);
|
|
1916
1916
|
} else {
|
|
1917
1917
|
try {
|
|
@@ -1985,8 +1985,7 @@ class GzipToRawDeflateStream extends TransformStream {
|
|
|
1985
1985
|
}
|
|
1986
1986
|
}
|
|
1987
1987
|
|
|
1988
|
-
function pipeThroughGzipDecompressionStream(readable, gzipStream, outputSize) {
|
|
1989
|
-
const crc32 = new Crc32();
|
|
1988
|
+
function pipeThroughGzipDecompressionStream(readable, gzipStream, outputSize, crc32) {
|
|
1990
1989
|
let outputLength = 0;
|
|
1991
1990
|
let inputDone = false;
|
|
1992
1991
|
let watchdogTimeout;
|
|
@@ -2060,7 +2059,7 @@ class InflateStream extends TransformStream {
|
|
|
2060
2059
|
constructor(options, { chunkSize, DecompressionStreamFallback, DecompressionStream }) {
|
|
2061
2060
|
super({});
|
|
2062
2061
|
const { zipCrypto, encrypted, checkCrc32, crc32, compressed, useCompressionStream, deflate64, format, compressionMethod, rawBitFlag, outputSize } = options;
|
|
2063
|
-
let crc32Stream, decryptionStream;
|
|
2062
|
+
let crc32Stream, decryptionStream, gzipCrc32;
|
|
2064
2063
|
let readable = super.readable;
|
|
2065
2064
|
if (encrypted) {
|
|
2066
2065
|
if (zipCrypto) {
|
|
@@ -2087,19 +2086,20 @@ class InflateStream extends TransformStream {
|
|
|
2087
2086
|
} catch {
|
|
2088
2087
|
throw error;
|
|
2089
2088
|
}
|
|
2090
|
-
|
|
2089
|
+
gzipCrc32 = new Crc32();
|
|
2090
|
+
readable = pipeThroughGzipDecompressionStream(readable, gzipStream, outputSize, gzipCrc32);
|
|
2091
2091
|
}
|
|
2092
2092
|
}
|
|
2093
2093
|
readable = mapInflateStreamError(readable);
|
|
2094
2094
|
}
|
|
2095
|
-
if (checkCrc32) {
|
|
2095
|
+
if (checkCrc32 && !gzipCrc32) {
|
|
2096
2096
|
crc32Stream = new Crc32Stream();
|
|
2097
2097
|
readable = pipeThrough(readable, crc32Stream);
|
|
2098
2098
|
}
|
|
2099
2099
|
setReadable(this, readable, () => {
|
|
2100
2100
|
if (checkCrc32) {
|
|
2101
|
-
const
|
|
2102
|
-
if (crc32 !=
|
|
2101
|
+
const computedCrc32 = gzipCrc32 ? gzipCrc32.get() >>> 0 : new DataView(crc32Stream.value.buffer).getUint32(0, false);
|
|
2102
|
+
if (crc32 != computedCrc32) {
|
|
2103
2103
|
throw new Error(ERR_INVALID_CRC32);
|
|
2104
2104
|
}
|
|
2105
2105
|
}
|
|
@@ -2155,6 +2155,14 @@ function createCodecStream(CodecStreamClass, format, options) {
|
|
|
2155
2155
|
return new CodecStreamClass(format, options);
|
|
2156
2156
|
}
|
|
2157
2157
|
|
|
2158
|
+
function getGzipCompressionStream(useCompressionStream, CompressionStreamNative, CompressionStreamFallback) {
|
|
2159
|
+
if (useCompressionStream && CompressionStreamNative) {
|
|
2160
|
+
return CompressionStreamNative;
|
|
2161
|
+
} else if (CompressionStreamFallback && CompressionStreamFallback.requiresModule) {
|
|
2162
|
+
return CompressionStreamFallback;
|
|
2163
|
+
}
|
|
2164
|
+
}
|
|
2165
|
+
|
|
2158
2166
|
function pipeThroughCompressionStream(readable, useCompressionStream, options, CompressionStreamNative, CompressionStreamFallback) {
|
|
2159
2167
|
const Stream = useCompressionStream && CompressionStreamNative ?
|
|
2160
2168
|
CompressionStreamNative :
|
|
@@ -2527,10 +2535,12 @@ class CodecWorker {
|
|
|
2527
2535
|
workerData.terminated = true;
|
|
2528
2536
|
worker.terminate();
|
|
2529
2537
|
}
|
|
2530
|
-
terminateResolvers.forEach(resolve => resolve());
|
|
2531
2538
|
}
|
|
2532
2539
|
workerData.busy = false;
|
|
2533
|
-
onTaskFinished(workerData);
|
|
2540
|
+
const pendingTasks = onTaskFinished(workerData);
|
|
2541
|
+
if (terminateResolvers) {
|
|
2542
|
+
terminateResolvers.forEach(resolve => resolve(pendingTasks));
|
|
2543
|
+
}
|
|
2534
2544
|
}
|
|
2535
2545
|
}
|
|
2536
2546
|
});
|
|
@@ -3215,7 +3225,10 @@ async function runWorker(stream, workerOptions) {
|
|
|
3215
3225
|
|
|
3216
3226
|
function onTaskFinished(workerData) {
|
|
3217
3227
|
clearStarvationTimeout();
|
|
3218
|
-
if (
|
|
3228
|
+
if (workerData.terminated) {
|
|
3229
|
+
workerData.terminated = false;
|
|
3230
|
+
return runPendingRequestsInline();
|
|
3231
|
+
} else if (pendingRequests.length) {
|
|
3219
3232
|
const [{ resolve, stream, workerOptions }] = pendingRequests.splice(0, 1);
|
|
3220
3233
|
resolve(new CodecWorker(workerData, stream, workerOptions, onTaskFinished));
|
|
3221
3234
|
armStarvationTimeout();
|
|
@@ -3253,12 +3266,26 @@ function onWorkerStarvation() {
|
|
|
3253
3266
|
starvationTimeout = null;
|
|
3254
3267
|
if (pendingRequests.length) {
|
|
3255
3268
|
const [{ resolve, stream, workerOptions }] = pendingRequests.splice(0, 1);
|
|
3256
|
-
|
|
3257
|
-
resolve(new CodecWorker({}, stream, inlineWorkerOptions, onInlineTaskFinished));
|
|
3269
|
+
resolve(new CodecWorker({}, stream, getInlineWorkerOptions(workerOptions), onInlineTaskFinished));
|
|
3258
3270
|
armStarvationTimeout();
|
|
3259
3271
|
}
|
|
3260
3272
|
}
|
|
3261
3273
|
|
|
3274
|
+
function runPendingRequestsInline() {
|
|
3275
|
+
const tasks = pendingRequests.splice(0).map(({ resolve, stream, workerOptions }) => new Promise(resolveTask => {
|
|
3276
|
+
resolve(new CodecWorker({}, stream, getInlineWorkerOptions(workerOptions), () => {
|
|
3277
|
+
onInlineTaskFinished();
|
|
3278
|
+
resolveTask();
|
|
3279
|
+
}));
|
|
3280
|
+
}));
|
|
3281
|
+
clearStarvationTimeout();
|
|
3282
|
+
return Promise.all(tasks);
|
|
3283
|
+
}
|
|
3284
|
+
|
|
3285
|
+
function getInlineWorkerOptions(workerOptions) {
|
|
3286
|
+
return Object.assign({}, workerOptions, { useWebWorkers: false, workerURI: UNDEFINED_VALUE, createWorker: UNDEFINED_VALUE });
|
|
3287
|
+
}
|
|
3288
|
+
|
|
3262
3289
|
function onInlineTaskFinished() {
|
|
3263
3290
|
clearStarvationTimeout();
|
|
3264
3291
|
armStarvationTimeout();
|
|
@@ -3268,18 +3295,14 @@ function terminateWorker(workerData, workerOptions) {
|
|
|
3268
3295
|
const { config } = workerOptions;
|
|
3269
3296
|
const { terminateWorkerTimeout } = config;
|
|
3270
3297
|
if (Number.isFinite(terminateWorkerTimeout) && terminateWorkerTimeout >= 0) {
|
|
3271
|
-
|
|
3272
|
-
|
|
3273
|
-
|
|
3274
|
-
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
// ignored
|
|
3280
|
-
}
|
|
3281
|
-
}, terminateWorkerTimeout);
|
|
3282
|
-
}
|
|
3298
|
+
workerData.terminateTimeout = setTimeout(async () => {
|
|
3299
|
+
pool = pool.filter(data => data != workerData);
|
|
3300
|
+
try {
|
|
3301
|
+
await workerData.terminate();
|
|
3302
|
+
} catch {
|
|
3303
|
+
// ignored
|
|
3304
|
+
}
|
|
3305
|
+
}, terminateWorkerTimeout);
|
|
3283
3306
|
}
|
|
3284
3307
|
}
|
|
3285
3308
|
|
|
@@ -3292,10 +3315,13 @@ function clearTerminateTimeout(workerData) {
|
|
|
3292
3315
|
}
|
|
3293
3316
|
|
|
3294
3317
|
async function terminateWorkers() {
|
|
3295
|
-
await Promise.allSettled(
|
|
3296
|
-
|
|
3297
|
-
|
|
3298
|
-
|
|
3318
|
+
await Promise.allSettled([
|
|
3319
|
+
runPendingRequestsInline(),
|
|
3320
|
+
...pool.map(workerData => {
|
|
3321
|
+
clearTerminateTimeout(workerData);
|
|
3322
|
+
return workerData.terminate();
|
|
3323
|
+
})
|
|
3324
|
+
]);
|
|
3299
3325
|
resetWebWorkerSupport();
|
|
3300
3326
|
}
|
|
3301
3327
|
|
|
@@ -3370,6 +3396,19 @@ function decodeText(value, encoding) {
|
|
|
3370
3396
|
return decode(value, encoding, true);
|
|
3371
3397
|
}
|
|
3372
3398
|
|
|
3399
|
+
function isUTF8Text(value) {
|
|
3400
|
+
if (value.some(byte => byte > 0x7f)) {
|
|
3401
|
+
try {
|
|
3402
|
+
new TextDecoder("utf-8", { fatal: true }).decode(value);
|
|
3403
|
+
return true;
|
|
3404
|
+
} catch {
|
|
3405
|
+
return false;
|
|
3406
|
+
}
|
|
3407
|
+
} else {
|
|
3408
|
+
return false;
|
|
3409
|
+
}
|
|
3410
|
+
}
|
|
3411
|
+
|
|
3373
3412
|
function decodeTextRemovingBOM(value, encoding) {
|
|
3374
3413
|
return decode(value, encoding, false);
|
|
3375
3414
|
}
|
|
@@ -4659,6 +4698,7 @@ const WARNING_MISMATCHED_LOCAL_FILE_HEADER_COMPRESSION_METHOD = "mismatched loca
|
|
|
4659
4698
|
const WARNING_MISMATCHED_LOCAL_FILE_HEADER_CRC32_OR_SIZES = "mismatched local file header (crc32 or sizes)";
|
|
4660
4699
|
const MAX_KNOWN_VERSION = 63;
|
|
4661
4700
|
const DRIVE_LETTER_REGEXP = /^[a-zA-Z]:/;
|
|
4701
|
+
const PARENT_DIRECTORY_REGEXP = /(^|[\\/])\.\.([\\/]|$)/;
|
|
4662
4702
|
const CHARSET_UTF8 = "utf-8";
|
|
4663
4703
|
const PROPERTY_NAME_UTF8_SUFFIX = "UTF8";
|
|
4664
4704
|
const CHARSET_CP437 = "cp437";
|
|
@@ -4911,12 +4951,13 @@ class ZipReader {
|
|
|
4911
4951
|
const versionMadeBy = getUint16$1(directoryView, offset + 4);
|
|
4912
4952
|
const msDosCompatible = versionMadeBy >> 8 == 0;
|
|
4913
4953
|
const unixCompatible = versionMadeBy >> 8 == 3;
|
|
4914
|
-
const rawFilename = directoryArray.subarray(filenameOffset, extraFieldOffset);
|
|
4915
4954
|
const commentLength = getUint16$1(directoryView, offset + 32);
|
|
4916
4955
|
const endOffset = commentOffset + commentLength;
|
|
4917
|
-
const
|
|
4918
|
-
const
|
|
4919
|
-
const
|
|
4956
|
+
const rawEntryData = new Uint8Array(directoryArray.subarray(filenameOffset, endOffset));
|
|
4957
|
+
const rawFilename = rawEntryData.subarray(0, fileEntry.filenameLength);
|
|
4958
|
+
const rawComment = rawEntryData.subarray(fileEntry.filenameLength + fileEntry.extraFieldLength);
|
|
4959
|
+
const filenameUTF8 = languageEncodingFlag || (!filenameEncoding && isUTF8Text(rawFilename));
|
|
4960
|
+
const commentUTF8 = languageEncodingFlag || (!commentEncoding && isUTF8Text(rawComment));
|
|
4920
4961
|
const externalFileAttributes = getUint32$1(directoryView, offset + 38);
|
|
4921
4962
|
const msdosAttributesRaw = externalFileAttributes & MAX_8_BITS;
|
|
4922
4963
|
const msdosAttributes = {
|
|
@@ -4967,7 +5008,7 @@ class ZipReader {
|
|
|
4967
5008
|
rawFilename,
|
|
4968
5009
|
filenameUTF8,
|
|
4969
5010
|
commentUTF8,
|
|
4970
|
-
rawExtraField:
|
|
5011
|
+
rawExtraField: rawEntryData.subarray(fileEntry.filenameLength, fileEntry.filenameLength + fileEntry.extraFieldLength),
|
|
4971
5012
|
rawComment,
|
|
4972
5013
|
filename,
|
|
4973
5014
|
comment
|
|
@@ -5475,7 +5516,7 @@ function readDigitalSignature(signatureRecordArray) {
|
|
|
5475
5516
|
if (getUint32$1(signatureRecordView, 0) == DIGITAL_SIGNATURE_RECORD_SIGNATURE) {
|
|
5476
5517
|
const signatureDataLength = getUint16$1(signatureRecordView, 4);
|
|
5477
5518
|
if (6 + signatureDataLength <= signatureRecordArray.length) {
|
|
5478
|
-
return signatureRecordArray.subarray(6, 6 + signatureDataLength);
|
|
5519
|
+
return new Uint8Array(signatureRecordArray.subarray(6, 6 + signatureDataLength));
|
|
5479
5520
|
}
|
|
5480
5521
|
}
|
|
5481
5522
|
}
|
|
@@ -5998,10 +6039,10 @@ function isUnsafeFilename(filename, filenameValidation) {
|
|
|
5998
6039
|
if (pathParts.length > 1 && pathParts[pathParts.length - 1] === "") {
|
|
5999
6040
|
pathParts.pop();
|
|
6000
6041
|
}
|
|
6001
|
-
if (
|
|
6042
|
+
if (PARENT_DIRECTORY_REGEXP.test(filename) || filename.startsWith("/") || filename.startsWith("\\") || DRIVE_LETTER_REGEXP.test(filename)) {
|
|
6002
6043
|
return true;
|
|
6003
6044
|
}
|
|
6004
|
-
return filenameValidation == STRICTNESS_STRICT && (pathParts.includes(".") || pathParts.includes(""));
|
|
6045
|
+
return filenameValidation == STRICTNESS_STRICT && (pathParts.includes(".") || pathParts.includes("") || filename.includes("\0"));
|
|
6005
6046
|
}
|
|
6006
6047
|
|
|
6007
6048
|
function getMaxAppendedDataSize(maxAppendedDataSize, strictness) {
|
|
@@ -6087,7 +6128,7 @@ async function* scanEndOfCentralDirectory(reader, scanLength) {
|
|
|
6087
6128
|
}
|
|
6088
6129
|
|
|
6089
6130
|
function getEndOfCentralDirectoryInfo(scanArray, indexByte, offset) {
|
|
6090
|
-
return { offset, buffer: scanArray.
|
|
6131
|
+
return { offset, buffer: new Uint8Array(scanArray.subarray(indexByte, indexByte + END_OF_CENTRAL_DIR_LENGTH)).buffer };
|
|
6091
6132
|
}
|
|
6092
6133
|
|
|
6093
6134
|
async function getCentralDirectoryReachability(reader, view, anchoredOffset, indexByte, offset, size, remoteProbeBudget) {
|
|
@@ -8734,7 +8775,7 @@ function formatSupported(StreamClass, format) {
|
|
|
8734
8775
|
EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
8735
8776
|
*/
|
|
8736
8777
|
|
|
8737
|
-
const VERSION = "2.
|
|
8778
|
+
const VERSION = "2.15.0";
|
|
8738
8779
|
|
|
8739
8780
|
/*
|
|
8740
8781
|
Copyright (c) 2025 Gildas Lormeau. All rights reserved.
|