@clearmist-labs/comic-archive-handler 1.0.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/dist/index.mjs ADDED
@@ -0,0 +1,2157 @@
1
+ import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.mjs";
2
+ import { fileTypeFromBuffer, fileTypeFromFile } from "file-type";
3
+ import * as fs from "node:fs";
4
+ import { existsSync, readFileSync } from "node:fs";
5
+ import * as fsp from "node:fs/promises";
6
+ import { PassThrough, Readable } from "node:stream";
7
+ import { Unzip, UnzipInflate, Zip, ZipDeflate } from "fflate";
8
+ import * as tarStream from "tar-stream";
9
+ import { createExtractorFromData } from "node-unrar-js";
10
+ import * as path from "node:path";
11
+ import { dirname, join } from "node:path";
12
+ import { createPackage } from "@electron/asar";
13
+ import * as os from "node:os";
14
+ import { spawn } from "node:child_process";
15
+ import sevenBin from "7zip-bin-full";
16
+ import sharp from "sharp";
17
+ import { pipeline } from "node:stream/promises";
18
+ import { XMLBuilder, XMLParser } from "fast-xml-parser";
19
+ import { validateXML } from "xmllint-wasm";
20
+ import { fileURLToPath } from "node:url";
21
+ import { createHash } from "node:crypto";
22
+ //#region src/errors.ts
23
+ var UnsupportedOperationError = class extends Error {
24
+ constructor(message) {
25
+ super(message);
26
+ this.name = "UnsupportedOperationError";
27
+ }
28
+ };
29
+ var ArchiveFormatError = class extends Error {
30
+ constructor(message) {
31
+ super(message);
32
+ this.name = "ArchiveFormatError";
33
+ }
34
+ };
35
+ var MetadataNotFoundError = class extends Error {
36
+ constructor(message) {
37
+ super(message);
38
+ this.name = "MetadataNotFoundError";
39
+ }
40
+ };
41
+ var FilesystemAccessError = class extends Error {
42
+ constructor(message) {
43
+ super(message);
44
+ this.name = "FilesystemAccessError";
45
+ }
46
+ };
47
+ var SevenZipUnavailableError = class extends Error {
48
+ constructor(message) {
49
+ super(message);
50
+ this.name = "SevenZipUnavailableError";
51
+ }
52
+ };
53
+ var NoImagesFoundError = class extends Error {
54
+ constructor(message) {
55
+ super(message);
56
+ this.name = "NoImagesFoundError";
57
+ }
58
+ };
59
+ //#endregion
60
+ //#region src/internal/inputSource.ts
61
+ function isPathInput(input) {
62
+ return typeof input === "string";
63
+ }
64
+ async function inputToBuffer(input) {
65
+ return isPathInput(input) ? fsp.readFile(input) : input;
66
+ }
67
+ async function inputSize(input) {
68
+ if (isPathInput(input)) return (await fsp.stat(input)).size;
69
+ return input.length;
70
+ }
71
+ /** Reads the half-open byte range [start, end) from a Buffer or file path. */
72
+ async function readInputRange(input, start, end) {
73
+ if (!isPathInput(input)) return Buffer.from(input.subarray(start, end));
74
+ const fd = await fsp.open(input, "r");
75
+ try {
76
+ const length = Math.max(0, end - start);
77
+ const buf = Buffer.alloc(length);
78
+ const { bytesRead } = await fd.read(buf, 0, length, start);
79
+ return buf.subarray(0, bytesRead);
80
+ } finally {
81
+ await fd.close();
82
+ }
83
+ }
84
+ function openInputReadStream(input, range) {
85
+ if (isPathInput(input)) return fs.createReadStream(input, range ? {
86
+ start: range.start,
87
+ end: range.end - 1
88
+ } : void 0);
89
+ const slice = range ? input.subarray(range.start, range.end) : input;
90
+ return Readable.from(slice);
91
+ }
92
+ //#endregion
93
+ //#region src/internal/asarHeader.ts
94
+ /**
95
+ * Parses an asar archive's header from a byte reader.
96
+ *
97
+ * On-disk layout (Chromium Pickle format, two levels of nesting):
98
+ * [8 bytes: outer pickle wrapping a uint32 `size`]
99
+ * [`size` bytes: header pickle == [4-byte LE payload length][payload]]
100
+ * payload == string pickle == [4-byte LE string byte-length][UTF-8 bytes (+padding, ignored here)]
101
+ * [content region: raw concatenated file bytes, starting at 8 + size]
102
+ */
103
+ async function parseAsarHeader(readRange) {
104
+ const prefix = await readRange(0, 8);
105
+ if (prefix.length < 8) throw new ArchiveFormatError("File is too small to be a valid asar archive.");
106
+ if (prefix.readUInt32LE(0) !== 4) throw new ArchiveFormatError("Not a valid asar archive (unexpected pickle header).");
107
+ const size = prefix.readUInt32LE(4);
108
+ const headerBuf = await readRange(8, 8 + size);
109
+ if (headerBuf.length < 8) throw new ArchiveFormatError("Not a valid asar archive (truncated header).");
110
+ const stringLength = headerBuf.readUInt32LE(4);
111
+ if (8 + stringLength > headerBuf.length) throw new ArchiveFormatError("Not a valid asar archive (truncated header string).");
112
+ const headerString = headerBuf.subarray(8, 8 + stringLength).toString("utf8");
113
+ let header;
114
+ try {
115
+ header = JSON.parse(headerString);
116
+ } catch {
117
+ throw new ArchiveFormatError("Not a valid asar archive (header is not valid JSON).");
118
+ }
119
+ if (!header || typeof header !== "object" || !header.files) throw new ArchiveFormatError("Not a valid asar archive (header has no files index).");
120
+ const contentOffset = 8 + size;
121
+ const files = [];
122
+ collectFiles(header, "", files);
123
+ return {
124
+ header,
125
+ files,
126
+ contentOffset
127
+ };
128
+ }
129
+ function collectFiles(node, prefix, out) {
130
+ if (!node.files) return;
131
+ for (const [name, child] of Object.entries(node.files)) {
132
+ const entryPath = prefix ? `${prefix}/${name}` : name;
133
+ if (child.files) collectFiles(child, entryPath, out);
134
+ else if (typeof child.offset === "string" && typeof child.size === "number") out.push({
135
+ path: entryPath,
136
+ offset: Number(child.offset),
137
+ size: child.size
138
+ });
139
+ }
140
+ }
141
+ /** True if the header's file tree contains an entry at the archive root named `name`. */
142
+ function hasRootFile(header, name) {
143
+ const root = header;
144
+ return Boolean(root?.files?.[name] && !root.files[name]?.files);
145
+ }
146
+ //#endregion
147
+ //#region src/detect.ts
148
+ var detect_exports = /* @__PURE__ */ __exportAll({
149
+ detectArchiveType: () => detectArchiveType,
150
+ is7z: () => is7z,
151
+ isAce: () => isAce,
152
+ isAsar: () => isAsar,
153
+ isRar: () => isRar,
154
+ isTar: () => isTar,
155
+ isZip: () => isZip
156
+ });
157
+ const EXT_TO_TYPE = {
158
+ zip: "zip",
159
+ rar: "rar",
160
+ tar: "tar",
161
+ "7z": "7z",
162
+ ace: "ace"
163
+ };
164
+ async function detectViaFileType(input) {
165
+ const result = isPathInput(input) ? await fileTypeFromFile(input) : await fileTypeFromBuffer(input);
166
+ if (!result) return;
167
+ return EXT_TO_TYPE[result.ext];
168
+ }
169
+ async function looksLikeAsar(input) {
170
+ try {
171
+ const header = await parseAsarHeader((start, end) => readInputRange(input, start, end));
172
+ return Boolean(header.files);
173
+ } catch {
174
+ return false;
175
+ }
176
+ }
177
+ async function detectArchiveType(input) {
178
+ const viaMagicBytes = await detectViaFileType(input);
179
+ if (viaMagicBytes) return viaMagicBytes;
180
+ if (await looksLikeAsar(input)) return "asar";
181
+ return "unknown";
182
+ }
183
+ async function isZip(input) {
184
+ return await detectArchiveType(input) === "zip";
185
+ }
186
+ async function isRar(input) {
187
+ return await detectArchiveType(input) === "rar";
188
+ }
189
+ async function isTar(input) {
190
+ return await detectArchiveType(input) === "tar";
191
+ }
192
+ async function isAsar(input) {
193
+ return await detectArchiveType(input) === "asar";
194
+ }
195
+ async function is7z(input) {
196
+ return await detectArchiveType(input) === "7z";
197
+ }
198
+ async function isAce(input) {
199
+ return await detectArchiveType(input) === "ace";
200
+ }
201
+ //#endregion
202
+ //#region src/internal/streamUtils.ts
203
+ async function streamToBuffer(stream) {
204
+ const chunks = [];
205
+ for await (const chunk of stream) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
206
+ return Buffer.concat(chunks);
207
+ }
208
+ /** Bridges a callback-driven push source into an async iterable, in arrival order. */
209
+ var AsyncQueue = class {
210
+ items = [];
211
+ waiter = null;
212
+ done = false;
213
+ error = null;
214
+ push(item) {
215
+ this.items.push(item);
216
+ this.waiter?.();
217
+ }
218
+ finish() {
219
+ this.done = true;
220
+ this.waiter?.();
221
+ }
222
+ fail(err) {
223
+ this.error = err;
224
+ this.done = true;
225
+ this.waiter?.();
226
+ }
227
+ async *[Symbol.asyncIterator]() {
228
+ for (;;) {
229
+ if (this.items.length) {
230
+ yield this.items.shift();
231
+ continue;
232
+ }
233
+ if (this.error) throw this.error;
234
+ if (this.done) return;
235
+ await new Promise((resolve) => {
236
+ this.waiter = resolve;
237
+ });
238
+ this.waiter = null;
239
+ }
240
+ }
241
+ };
242
+ //#endregion
243
+ //#region src/archive/zip.ts
244
+ function toEntry(file) {
245
+ return {
246
+ path: file.name,
247
+ size: file.originalSize,
248
+ openReadStream() {
249
+ const pass = new PassThrough();
250
+ file.ondata = (err, data, final) => {
251
+ if (err) {
252
+ pass.destroy(err);
253
+ return;
254
+ }
255
+ if (data.length) pass.write(Buffer.from(data));
256
+ if (final) pass.end();
257
+ };
258
+ file.start();
259
+ return pass;
260
+ }
261
+ };
262
+ }
263
+ const zipAdapter = {
264
+ type: "zip",
265
+ canWrite: true,
266
+ async *listEntries(input) {
267
+ const source = openInputReadStream(input);
268
+ const queue = new AsyncQueue();
269
+ const unzip = new Unzip();
270
+ unzip.register(UnzipInflate);
271
+ unzip.onfile = (file) => {
272
+ if (file.name.endsWith("/")) return;
273
+ queue.push(toEntry(file));
274
+ };
275
+ source.on("data", (chunk) => unzip.push(new Uint8Array(chunk), false));
276
+ source.on("end", () => {
277
+ unzip.push(/* @__PURE__ */ new Uint8Array(0), true);
278
+ queue.finish();
279
+ });
280
+ source.on("error", (err) => queue.fail(err));
281
+ yield* queue;
282
+ },
283
+ async write(entries, destination) {
284
+ await new Promise((resolve, reject) => {
285
+ const zip = new Zip((err, data, final) => {
286
+ if (err) {
287
+ destination.destroy(err);
288
+ reject(err);
289
+ return;
290
+ }
291
+ destination.write(Buffer.from(data));
292
+ if (final) {
293
+ destination.end();
294
+ resolve();
295
+ }
296
+ });
297
+ (async () => {
298
+ for await (const entry of entries) {
299
+ const zipFile = new ZipDeflate(entry.path);
300
+ zip.add(zipFile);
301
+ for await (const chunk of entry.content) zipFile.push(new Uint8Array(chunk), false);
302
+ zipFile.push(/* @__PURE__ */ new Uint8Array(0), true);
303
+ }
304
+ zip.end();
305
+ })().catch(reject);
306
+ });
307
+ }
308
+ };
309
+ //#endregion
310
+ //#region src/archive/tar.ts
311
+ /**
312
+ * tar-stream v3 is built on `streamx`, not Node's native streams; its
313
+ * `extract()` result is directly async-iterable over per-entry streams, and
314
+ * both directions interop with Node streams via `.pipe()`. Each entry's
315
+ * content is buffered fully before being yielded — bounded by a single
316
+ * entry's size (one comic page), not the whole archive — since the
317
+ * underlying iterator only advances once the current entry is drained,
318
+ * which doesn't reconcile with this package's lazily-pulled
319
+ * `ArchiveEntry.openReadStream()` contract.
320
+ */
321
+ const tarAdapter = {
322
+ type: "tar",
323
+ canWrite: true,
324
+ async *listEntries(input) {
325
+ const source = openInputReadStream(input);
326
+ const extract = tarStream.extract();
327
+ source.pipe(extract);
328
+ for await (const entryStream of extract) {
329
+ const header = entryStream.header;
330
+ const buffer = await streamToBuffer(entryStream);
331
+ if (header.type === "directory") continue;
332
+ yield {
333
+ path: header.name,
334
+ size: buffer.length,
335
+ openReadStream: () => Readable.from(buffer)
336
+ };
337
+ }
338
+ },
339
+ async write(entries, destination) {
340
+ const pack = tarStream.pack();
341
+ pack.pipe(destination);
342
+ for await (const entry of entries) {
343
+ const buffer = await streamToBuffer(entry.content);
344
+ await new Promise((resolve, reject) => {
345
+ pack.entry({
346
+ name: entry.path,
347
+ size: buffer.length
348
+ }, buffer, (err) => err ? reject(err) : resolve());
349
+ });
350
+ }
351
+ pack.finalize();
352
+ await new Promise((resolve, reject) => {
353
+ destination.on("finish", resolve);
354
+ destination.on("error", reject);
355
+ });
356
+ }
357
+ };
358
+ //#endregion
359
+ //#region src/archive/rar.ts
360
+ function toArrayBuffer(buf) {
361
+ return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
362
+ }
363
+ /**
364
+ * node-unrar-js cannot extract entries one at a time (a confirmed library
365
+ * limitation) — it decodes the whole archive into memory up front. This
366
+ * adapter still exposes the lazily-pulled ArchiveAdapter shape, but does not
367
+ * get the memory-efficiency win the interface provides for zip/tar/asar.
368
+ */
369
+ const rarAdapter = {
370
+ type: "rar",
371
+ canWrite: false,
372
+ async *listEntries(input) {
373
+ const buffer = await inputToBuffer(input);
374
+ const extractor = await createExtractorFromData({ data: toArrayBuffer(buffer) });
375
+ const names = [...extractor.getFileList().fileHeaders].filter((header) => !header.flags.directory).map((header) => header.name);
376
+ const extracted = extractor.extract({ files: names });
377
+ for (const file of extracted.files) {
378
+ if (!file.extraction) continue;
379
+ const data = Buffer.from(file.extraction);
380
+ yield {
381
+ path: file.fileHeader.name,
382
+ size: data.length,
383
+ openReadStream: () => Readable.from(data)
384
+ };
385
+ }
386
+ },
387
+ async write() {
388
+ throw new UnsupportedOperationError("Creating RAR archives is not supported: the UnRAR source license permits decompression only, not building a compatible compressor. Convert to zip, tar, or asar instead.");
389
+ }
390
+ };
391
+ //#endregion
392
+ //#region src/internal/tempDir.ts
393
+ async function isWritableDir(dir) {
394
+ const probe = path.join(dir, `.cah-write-probe-${process.pid}-${Date.now()}`);
395
+ try {
396
+ await fsp.writeFile(probe, "");
397
+ await fsp.rm(probe, { force: true });
398
+ return true;
399
+ } catch {
400
+ return false;
401
+ }
402
+ }
403
+ /**
404
+ * Resolves a writable staging directory for operations that require real
405
+ * filesystem access (asar writes, any 7z operation).
406
+ *
407
+ * - If `preferredDir` is given, it must already exist and be writable, or a
408
+ * FilesystemAccessError is thrown naming the path (no silent fallback).
409
+ * - Otherwise, attempts `fs.mkdtemp()` under `os.tmpdir()`.
410
+ * - If that also fails (no writable tmp available, e.g. a locked-down
411
+ * sandbox), throws FilesystemAccessError telling the caller to pass
412
+ * `{ tempDir }` explicitly.
413
+ *
414
+ * Returns a freshly created subdirectory the caller owns and is responsible
415
+ * for cleaning up.
416
+ */
417
+ async function resolveWritableTempDir(preferredDir) {
418
+ if (preferredDir) {
419
+ if (!await isWritableDir(preferredDir)) throw new FilesystemAccessError(`The provided tempDir "${preferredDir}" is not writable. Pass a writable directory via { tempDir }.`);
420
+ return fsp.mkdtemp(path.join(preferredDir, "cah-"));
421
+ }
422
+ try {
423
+ return await fsp.mkdtemp(path.join(os.tmpdir(), "cah-"));
424
+ } catch (err) {
425
+ throw new FilesystemAccessError(`No writable temporary directory is available (${err.message}). Supply one explicitly via { tempDir: "<writable path>" }.`);
426
+ }
427
+ }
428
+ async function cleanupTempDir(dir) {
429
+ await fsp.rm(dir, {
430
+ recursive: true,
431
+ force: true
432
+ });
433
+ }
434
+ //#endregion
435
+ //#region src/internal/safePath.ts
436
+ /**
437
+ * Resolves an archive entry path against a staging root and verifies the
438
+ * result stays within that root, guarding against zip-slip style path
439
+ * traversal (`../../etc/passwd`, absolute paths, etc.) before anything is
440
+ * written to a real filesystem location.
441
+ */
442
+ function resolveSafeEntryPath(root, entryPath) {
443
+ const normalizedEntry = entryPath.replace(/\\/g, "/");
444
+ const resolved = path.resolve(root, `.${path.sep}${normalizedEntry}`);
445
+ const rootWithSep = root.endsWith(path.sep) ? root : root + path.sep;
446
+ if (resolved !== root && !resolved.startsWith(rootWithSep)) throw new ArchiveFormatError(`Refusing to write entry outside staging directory: "${entryPath}"`);
447
+ return resolved;
448
+ }
449
+ //#endregion
450
+ //#region src/archive/asar.ts
451
+ /**
452
+ * Reads bypass @electron/asar's extract API entirely: the header is parsed
453
+ * directly (src/internal/asarHeader.ts) to get each file's byte offset/size,
454
+ * then entries are read via direct byte-range reads against the archive —
455
+ * no extraction step, no temp directory, for reads.
456
+ *
457
+ * Writes still require a staging directory, since @electron/asar's
458
+ * `createPackage(srcDir, destFile)` only accepts a source directory on
459
+ * disk, not streams or buffers.
460
+ */
461
+ const asarAdapter = {
462
+ type: "asar",
463
+ canWrite: true,
464
+ async *listEntries(input) {
465
+ const { files, contentOffset } = await parseAsarHeader((start, end) => readInputRange(input, start, end));
466
+ for (const file of files) {
467
+ const start = contentOffset + file.offset;
468
+ const end = start + file.size;
469
+ yield {
470
+ path: file.path,
471
+ size: file.size,
472
+ openReadStream: () => openInputReadStream(input, {
473
+ start,
474
+ end
475
+ })
476
+ };
477
+ }
478
+ },
479
+ async write(entries, destination, options) {
480
+ const tempDir = await resolveWritableTempDir(options?.tempDir);
481
+ const stagingDir = path.join(tempDir, "staging");
482
+ await fsp.mkdir(stagingDir, { recursive: true });
483
+ try {
484
+ for await (const entry of entries) {
485
+ const target = resolveSafeEntryPath(stagingDir, entry.path);
486
+ await fsp.mkdir(path.dirname(target), { recursive: true });
487
+ await new Promise((resolve, reject) => {
488
+ const out = fs.createWriteStream(target);
489
+ entry.content.on("error", reject);
490
+ out.on("error", reject);
491
+ out.on("finish", resolve);
492
+ entry.content.pipe(out);
493
+ });
494
+ }
495
+ const outputFile = path.join(tempDir, "output.asar");
496
+ await createPackage(stagingDir, outputFile);
497
+ await new Promise((resolve, reject) => {
498
+ const readStream = fs.createReadStream(outputFile);
499
+ readStream.on("error", reject);
500
+ destination.on("error", reject);
501
+ destination.on("finish", resolve);
502
+ readStream.pipe(destination);
503
+ });
504
+ } finally {
505
+ await cleanupTempDir(tempDir);
506
+ }
507
+ }
508
+ };
509
+ //#endregion
510
+ //#region src/archive/sevenZip.ts
511
+ async function ensureBinaryAvailable() {
512
+ const binPath = sevenBin.path7zzs;
513
+ if (!fs.existsSync(binPath)) throw new SevenZipUnavailableError(`No 7-Zip binary is available for this platform/architecture (expected at "${binPath}"). 7z read/write is unavailable here; 7z detection via magic bytes still works.`);
514
+ try {
515
+ await fsp.access(binPath, fs.constants.X_OK);
516
+ } catch {
517
+ try {
518
+ await fsp.chmod(binPath, 493);
519
+ await fsp.access(binPath, fs.constants.X_OK);
520
+ } catch {
521
+ throw new SevenZipUnavailableError(`The bundled 7-Zip binary at "${binPath}" is not executable and could not be made executable.`);
522
+ }
523
+ }
524
+ return binPath;
525
+ }
526
+ /**
527
+ * Spawns the bundled 7zzs (standalone 7-Zip) binary directly rather than
528
+ * going through the `node-7z` wrapper: that package has no way to set the
529
+ * child process's working directory, which is required here to get archive
530
+ * entries stored with paths relative to the staging directory (rather than
531
+ * either leaking absolute host paths, or — as discovered during
532
+ * implementation testing — silently operating against this process's actual
533
+ * cwd instead of the intended staging directory).
534
+ */
535
+ function run7z(binPath, args, options) {
536
+ return new Promise((resolve, reject) => {
537
+ const child = spawn(binPath, args, {
538
+ cwd: options.cwd,
539
+ windowsHide: true
540
+ });
541
+ let stderr = "";
542
+ child.stderr.on("data", (chunk) => {
543
+ stderr += chunk.toString();
544
+ });
545
+ child.on("error", reject);
546
+ child.on("close", (code) => {
547
+ if (code === 0) resolve();
548
+ else reject(new ArchiveFormatError(`7zzs exited with code ${code}: ${stderr.trim()}`));
549
+ });
550
+ });
551
+ }
552
+ async function* walkFiles(root, prefix = "") {
553
+ const dirents = await fsp.readdir(path.join(root, prefix), { withFileTypes: true });
554
+ for (const dirent of dirents) {
555
+ const relPath = prefix ? `${prefix}/${dirent.name}` : dirent.name;
556
+ if (dirent.isDirectory()) yield* walkFiles(root, relPath);
557
+ else if (dirent.isFile()) yield {
558
+ absPath: path.join(root, relPath),
559
+ relPath
560
+ };
561
+ }
562
+ }
563
+ /**
564
+ * Both directions round-trip through a staging directory: the 7z CLI
565
+ * operates on real files, not stdin/stdout, for multi-file archives. Entries
566
+ * are still streamed to/from disk one at a time rather than collected into
567
+ * memory first.
568
+ */
569
+ const sevenZipAdapter = {
570
+ type: "7z",
571
+ canWrite: true,
572
+ async *listEntries(input, options) {
573
+ const binPath = await ensureBinaryAvailable();
574
+ const tempDir = await resolveWritableTempDir(options?.tempDir);
575
+ try {
576
+ const archivePath = path.join(tempDir, "input.7z");
577
+ await fsp.writeFile(archivePath, await inputToBuffer(input));
578
+ const extractDir = path.join(tempDir, "extracted");
579
+ await fsp.mkdir(extractDir, { recursive: true });
580
+ await run7z(binPath, [
581
+ "x",
582
+ archivePath,
583
+ `-o${extractDir}`,
584
+ "-y"
585
+ ], { cwd: tempDir });
586
+ for await (const { absPath, relPath } of walkFiles(extractDir)) yield {
587
+ path: relPath,
588
+ size: (await fsp.stat(absPath)).size,
589
+ openReadStream: () => fs.createReadStream(absPath)
590
+ };
591
+ } finally {
592
+ await cleanupTempDir(tempDir);
593
+ }
594
+ },
595
+ async write(entries, destination, options) {
596
+ const binPath = await ensureBinaryAvailable();
597
+ const tempDir = await resolveWritableTempDir(options?.tempDir);
598
+ const stagingDir = path.join(tempDir, "staging");
599
+ await fsp.mkdir(stagingDir, { recursive: true });
600
+ try {
601
+ for await (const entry of entries) {
602
+ const target = resolveSafeEntryPath(stagingDir, entry.path);
603
+ await fsp.mkdir(path.dirname(target), { recursive: true });
604
+ await new Promise((resolve, reject) => {
605
+ const out = fs.createWriteStream(target);
606
+ entry.content.on("error", reject);
607
+ out.on("error", reject);
608
+ out.on("finish", resolve);
609
+ entry.content.pipe(out);
610
+ });
611
+ }
612
+ const outputFile = path.join(tempDir, "output.7z");
613
+ await run7z(binPath, [
614
+ "a",
615
+ "-y",
616
+ outputFile,
617
+ "*",
618
+ "-r"
619
+ ], { cwd: stagingDir });
620
+ await new Promise((resolve, reject) => {
621
+ const readStream = fs.createReadStream(outputFile);
622
+ readStream.on("error", reject);
623
+ destination.on("error", reject);
624
+ destination.on("finish", resolve);
625
+ readStream.pipe(destination);
626
+ });
627
+ } finally {
628
+ await cleanupTempDir(tempDir);
629
+ }
630
+ }
631
+ };
632
+ //#endregion
633
+ //#region src/archive/ace.ts
634
+ const ACE_UNSUPPORTED_MESSAGE = "ACE (CBA) archives are not supported: ACE is a dead format that hasn't been updated since 2011 and has multiple known security vulnerabilities.";
635
+ //#endregion
636
+ //#region src/archive/index.ts
637
+ const adapters = {
638
+ zip: zipAdapter,
639
+ tar: tarAdapter,
640
+ rar: rarAdapter,
641
+ asar: asarAdapter,
642
+ "7z": sevenZipAdapter,
643
+ ace: {
644
+ type: "ace",
645
+ canWrite: false,
646
+ listEntries() {
647
+ throw new UnsupportedOperationError(ACE_UNSUPPORTED_MESSAGE);
648
+ },
649
+ async write() {
650
+ throw new UnsupportedOperationError(ACE_UNSUPPORTED_MESSAGE);
651
+ }
652
+ }
653
+ };
654
+ function getAdapter(type) {
655
+ const adapter = adapters[type];
656
+ if (!adapter) throw new ArchiveFormatError(`No archive adapter is available for type "${type}".`);
657
+ return adapter;
658
+ }
659
+ //#endregion
660
+ //#region src/internal/collectOutput.ts
661
+ /**
662
+ * Runs `run` against a destination stream. When `output` is given (a path or
663
+ * a Writable), the result streams directly there and this resolves to
664
+ * `undefined`. Otherwise the result is collected into a Buffer for
665
+ * convenience.
666
+ */
667
+ async function withOutput(output, run) {
668
+ if (typeof output === "string") {
669
+ await run(fs.createWriteStream(output));
670
+ return;
671
+ }
672
+ if (output) {
673
+ await run(output);
674
+ return;
675
+ }
676
+ const pass = new PassThrough();
677
+ const bufferPromise = streamToBuffer(pass);
678
+ await run(pass);
679
+ return bufferPromise;
680
+ }
681
+ //#endregion
682
+ //#region src/images/isImage.ts
683
+ const IMAGE_EXTENSIONS = [
684
+ "jpg",
685
+ "jpeg",
686
+ "png",
687
+ "gif",
688
+ "webp",
689
+ "bmp",
690
+ "tiff",
691
+ "tif"
692
+ ];
693
+ function getExtension(entryPath) {
694
+ const match = /\.([^./\\]+)$/.exec(entryPath);
695
+ return match ? match[1].toLowerCase() : "";
696
+ }
697
+ function isImagePath(entryPath) {
698
+ return IMAGE_EXTENSIONS.includes(getExtension(entryPath));
699
+ }
700
+ //#endregion
701
+ //#region src/images/convert.ts
702
+ async function convertImageBuffer(image, format, options = {}) {
703
+ let pipeline = sharp(image);
704
+ if (format === "webp") {
705
+ const webp = options.webp ?? {};
706
+ pipeline = pipeline.webp({
707
+ quality: webp.quality ?? 92,
708
+ effort: webp.effort ?? 6,
709
+ smartSubsample: webp.smartSubsample ?? true
710
+ });
711
+ } else if (format === "jpg") {
712
+ const jpeg = options.jpeg ?? {};
713
+ pipeline = pipeline.jpeg({ quality: jpeg.quality ?? 90 });
714
+ } else {
715
+ const png = options.png ?? {};
716
+ pipeline = pipeline.png({
717
+ quality: png.quality,
718
+ compressionLevel: png.compressionLevel,
719
+ palette: png.palette
720
+ });
721
+ }
722
+ return pipeline.toBuffer();
723
+ }
724
+ function replaceExtension$1(entryPath, format) {
725
+ const newExt = format === "jpg" ? "jpg" : format;
726
+ return `${entryPath.replace(/\.[^./\\]+$/, "")}.${newExt}`;
727
+ }
728
+ async function convertArchiveImages(input, format, options = {}) {
729
+ const adapter = getAdapter(await detectArchiveType(input));
730
+ const { tempDir, output, ...imageOptions } = options;
731
+ async function* entries() {
732
+ for await (const entry of adapter.listEntries(input, { tempDir })) if (isImagePath(entry.path) && getExtension(entry.path) !== (format === "jpg" ? "jpg" : format)) {
733
+ const converted = await convertImageBuffer(await streamToBuffer(entry.openReadStream()), format, imageOptions);
734
+ yield {
735
+ path: replaceExtension$1(entry.path, format),
736
+ size: converted.length,
737
+ content: Readable.from(converted)
738
+ };
739
+ } else yield {
740
+ path: entry.path,
741
+ size: entry.size,
742
+ content: entry.openReadStream()
743
+ };
744
+ }
745
+ return withOutput(output, (destination) => adapter.write(entries(), destination, { tempDir }));
746
+ }
747
+ //#endregion
748
+ //#region src/convertArchive.ts
749
+ function replaceExtension(entryPath, format) {
750
+ return `${entryPath.replace(/\.[^./\\]+$/, "")}.${format}`;
751
+ }
752
+ async function convertArchive(input, targetType, options = {}) {
753
+ const sourceType = await detectArchiveType(input);
754
+ if (sourceType === "unknown") throw new ArchiveFormatError("Could not determine the source archive type.");
755
+ const sourceAdapter = getAdapter(sourceType);
756
+ const targetAdapter = getAdapter(targetType);
757
+ const image = options.image;
758
+ async function* entries() {
759
+ for await (const entry of sourceAdapter.listEntries(input, { tempDir: options.tempDir })) if (image && isImagePath(entry.path) && getExtension(entry.path) !== (image.format === "jpg" ? "jpg" : image.format)) {
760
+ const converted = await convertImageBuffer(await streamToBuffer(entry.openReadStream()), image.format, image.options);
761
+ yield {
762
+ path: replaceExtension(entry.path, image.format === "jpg" ? "jpg" : image.format),
763
+ size: converted.length,
764
+ content: Readable.from(converted)
765
+ };
766
+ } else yield {
767
+ path: entry.path,
768
+ size: entry.size,
769
+ content: entry.openReadStream()
770
+ };
771
+ }
772
+ return withOutput(options.output, (destination) => targetAdapter.write(entries(), destination, { tempDir: options.tempDir }));
773
+ }
774
+ //#endregion
775
+ //#region src/extractArchive.ts
776
+ /**
777
+ * Extracts every entry in an archive to real files under `destDir`,
778
+ * preserving relative paths (`destDir` is created if missing). Throws
779
+ * `ArchiveFormatError` for an undetectable/unknown format, or
780
+ * `UnsupportedOperationError` for ACE. Returns the archive-relative entry
781
+ * paths that were written, in archive iteration order.
782
+ */
783
+ async function extractArchive(input, destDir, options = {}) {
784
+ const adapter = getAdapter(await detectArchiveType(input));
785
+ await fs.promises.mkdir(destDir, { recursive: true });
786
+ const written = [];
787
+ for await (const entry of adapter.listEntries(input, { tempDir: options.tempDir })) {
788
+ const target = resolveSafeEntryPath(destDir, entry.path);
789
+ await fs.promises.mkdir(path.dirname(target), { recursive: true });
790
+ await pipeline(entry.openReadStream(), fs.createWriteStream(target));
791
+ written.push(entry.path);
792
+ }
793
+ return written;
794
+ }
795
+ //#endregion
796
+ //#region src/metadata/schema.ts
797
+ /**
798
+ * Authoritative field-mapping table between the canonical ComicMetadata
799
+ * shape and each external XML schema (ComicInfo.xml v2.1, MetronInfo.xml
800
+ * v1.1 — see `schemas/`). Conversion is intentionally lossy in both
801
+ * directions; a field with no equivalent in the target schema is dropped
802
+ * unless the source and target schema happen to be the same one (in which
803
+ * case `comicInfoExtra`/`metronInfoExtra` round-trips it).
804
+ *
805
+ * | Canonical field | ComicInfo.xml | MetronInfo.xml |
806
+ * |-------------------------|-----------------------------------------------------|------------------------------------------------|
807
+ * | title | Title | (no equivalent — dropped) |
808
+ * | series | Series | Series > Name |
809
+ * | seriesSort | (no equivalent) | Series > SortName |
810
+ * | seriesId / seriesLang | (no equivalent) | Series `id`/`lang` attributes |
811
+ * | seriesStartYear | (no equivalent) | Series > StartYear |
812
+ * | seriesIssueCount | (no equivalent) | Series > IssueCount |
813
+ * | seriesVolumeCount | (no equivalent) | Series > VolumeCount |
814
+ * | seriesAlternativeNames | (no equivalent) | Series > AlternativeNames > AlternativeName[] |
815
+ * | seriesGroup | SeriesGroup | (no equivalent — dropped) |
816
+ * | volume | Volume | Series > Volume |
817
+ * | number | Number | Number |
818
+ * | alternateNumber | AlternateNumber | AlternativeNumber |
819
+ * | alternateSeries | AlternateSeries | (no equivalent — dropped) |
820
+ * | alternateCount | AlternateCount | (no equivalent — dropped) |
821
+ * | count | Count | (no equivalent) |
822
+ * | pageCount | PageCount | PageCount |
823
+ * | summary | Summary | Summary |
824
+ * | notes | Notes | Notes |
825
+ * | review | Review | (no equivalent — dropped) |
826
+ * | scanInformation | ScanInformation | (no equivalent — dropped) |
827
+ * | publisher / publisherId | Publisher | Publisher > Name / `id` attribute |
828
+ * | imprint / imprintId | Imprint | Publisher > Imprint / `id` attribute |
829
+ * | collectionTitle | (no equivalent) | CollectionTitle |
830
+ * | mangaVolume | (no equivalent) | MangaVolume |
831
+ * | format | Format | Series > Format |
832
+ * | language | LanguageISO | (no equivalent) |
833
+ * | ageRating | AgeRating | AgeRating |
834
+ * | communityRating(Count) | CommunityRating | CommunityRating > AverageRating / RatingCount |
835
+ * | coverDate | Year / Month / Day | CoverDate (YYYY-MM-DD) |
836
+ * | storeDate | (no equivalent) | StoreDate (YYYY-MM-DD) |
837
+ * | lastModified | (no equivalent) | LastModified |
838
+ * | genres | Genre (comma-separated string) | Genres > Genre[] (`id` attribute preserved) |
839
+ * | tags | Tags (comma-separated string) | Tags > Tag[] (`id` attribute preserved) |
840
+ * | characters | Characters (comma-separated string) | Characters > Character[] (`id` attribute) |
841
+ * | teams | Teams (comma-separated string) | Teams > Team[] (`id` attribute) |
842
+ * | locations | Locations (comma-separated string) | Locations > Location[] (`id` attribute) |
843
+ * | storyArcs | StoryArc / StoryArcNumber (parallel comma lists) | Arcs > Arc[] (Name + Number + `id`) |
844
+ * | mainCharacterOrTeam | MainCharacterOrTeam | (no equivalent — dropped) |
845
+ * | stories | (no equivalent) | Stories > Story[] |
846
+ * | reprints | (no equivalent) | Reprints > Reprint[] |
847
+ * | universes | (no equivalent) | Universes > Universe[] |
848
+ * | identifiers | (no equivalent) | IDS > ID[] |
849
+ * | prices | (no equivalent) | Prices > Price[] |
850
+ * | credits | Writer/Penciller/Inker/Colorist/Letterer/CoverArtist/Editor/Translator (comma-separated per role) | Credits > Credit[] (Creator + Roles > Role[], `id` attributes) |
851
+ * | web | Web (single URL) | URLs > URL[] (`primary` attribute) |
852
+ * | gtin | GTIN | GTIN > ISBN |
853
+ * | gtinUpc | (no equivalent) | GTIN > UPC |
854
+ * | blackAndWhite | BlackAndWhite (Yes/No) | (no equivalent — ComicInfo-only) |
855
+ * | manga | Manga | (no equivalent — ComicInfo-only) |
856
+ * | pages | Pages > Page[] (Image/Type/DoublePage/... attrs) | (no equivalent — ComicInfo-only) |
857
+ */
858
+ const COMIC_INFO_CREDIT_ROLES = [
859
+ "Writer",
860
+ "Penciller",
861
+ "Inker",
862
+ "Colorist",
863
+ "Letterer",
864
+ "CoverArtist",
865
+ "Editor",
866
+ "Translator"
867
+ ];
868
+ /** ComicInfo.xml `YesNo` simple type. */
869
+ const COMIC_INFO_YES_NO_VALUES = [
870
+ "Unknown",
871
+ "No",
872
+ "Yes"
873
+ ];
874
+ /** ComicInfo.xml `Manga` simple type. */
875
+ const COMIC_INFO_MANGA_VALUES = [
876
+ "Unknown",
877
+ "No",
878
+ "Yes",
879
+ "YesAndRightToLeft"
880
+ ];
881
+ /** ComicInfo.xml `AgeRating` simple type. */
882
+ const COMIC_INFO_AGE_RATING_VALUES = [
883
+ "Unknown",
884
+ "Adults Only 18+",
885
+ "Early Childhood",
886
+ "Everyone",
887
+ "Everyone 10+",
888
+ "G",
889
+ "Kids to Adults",
890
+ "M",
891
+ "MA15+",
892
+ "Mature 17+",
893
+ "PG",
894
+ "R18+",
895
+ "Rating Pending",
896
+ "Teen",
897
+ "X18+"
898
+ ];
899
+ /** ComicInfo.xml `ComicPageType` simple type. */
900
+ const COMIC_INFO_PAGE_TYPE_VALUES = [
901
+ "FrontCover",
902
+ "InnerCover",
903
+ "Roundup",
904
+ "Story",
905
+ "Advertisement",
906
+ "Editorial",
907
+ "Letters",
908
+ "Preview",
909
+ "BackCover",
910
+ "Other",
911
+ "Deleted"
912
+ ];
913
+ /** MetronInfo.xml `formatType` simple type (Series > Format). */
914
+ const METRON_FORMAT_VALUES = [
915
+ "Annual",
916
+ "Digital Chapter",
917
+ "Graphic Novel",
918
+ "Hardcover",
919
+ "Limited Series",
920
+ "Omnibus",
921
+ "One-Shot",
922
+ "Single Issue",
923
+ "Trade Paperback"
924
+ ];
925
+ /** MetronInfo.xml `informationSource` simple type (IDS > ID `source` attribute). */
926
+ const METRON_INFORMATION_SOURCE_VALUES = [
927
+ "AniList",
928
+ "Comic Vine",
929
+ "Grand Comics Database",
930
+ "Kitsu",
931
+ "MangaDex",
932
+ "MangaUpdates",
933
+ "Marvel",
934
+ "Metron",
935
+ "MyAnimeList",
936
+ "League of Comic Geeks"
937
+ ];
938
+ /** MetronInfo.xml `roleValues` simple type (Credits > Credit > Roles > Role). */
939
+ const METRON_ROLE_VALUES = [
940
+ "Writer",
941
+ "Script",
942
+ "Story",
943
+ "Plot",
944
+ "Interviewer",
945
+ "Artist",
946
+ "Penciller",
947
+ "Breakdowns",
948
+ "Illustrator",
949
+ "Layouts",
950
+ "Inker",
951
+ "Embellisher",
952
+ "Finishes",
953
+ "Ink Assists",
954
+ "Colorist",
955
+ "Color Separations",
956
+ "Color Assists",
957
+ "Color Flats",
958
+ "Digital Art Technician",
959
+ "Gray Tone",
960
+ "Letterer",
961
+ "Cover",
962
+ "Editor",
963
+ "Consulting Editor",
964
+ "Assistant Editor",
965
+ "Associate Editor",
966
+ "Group Editor",
967
+ "Senior Editor",
968
+ "Managing Editor",
969
+ "Collection Editor",
970
+ "Production",
971
+ "Designer",
972
+ "Logo Design",
973
+ "Translator",
974
+ "Supervising Editor",
975
+ "Executive Editor",
976
+ "Editor In Chief",
977
+ "President",
978
+ "Publisher",
979
+ "Chief Creative Officer",
980
+ "Executive Producer",
981
+ "Other"
982
+ ];
983
+ /** MetronInfo.xml `ageRatingType` simple type. */
984
+ const METRON_AGE_RATING_VALUES = [
985
+ "Unknown",
986
+ "Everyone",
987
+ "Teen",
988
+ "Teen Plus",
989
+ "Mature",
990
+ "Explicit",
991
+ "Adult"
992
+ ];
993
+ function splitCommaList(value) {
994
+ if (!value) return;
995
+ const parts = value.split(",").map((part) => part.trim()).filter(Boolean);
996
+ return parts.length ? parts : void 0;
997
+ }
998
+ function joinCommaList(values) {
999
+ return values && values.length ? values.join(", ") : void 0;
1000
+ }
1001
+ /** Extracts the plain text value from a resource that may carry a MetronInfo `id` attribute. */
1002
+ function resourceName(item) {
1003
+ return typeof item === "string" ? item : item.name;
1004
+ }
1005
+ /** Extracts the MetronInfo `id` attribute from a resource, if any. */
1006
+ function resourceId(item) {
1007
+ return typeof item === "string" ? void 0 : item.id;
1008
+ }
1009
+ function joinResourceNames(values) {
1010
+ return joinCommaList(values?.map(resourceName));
1011
+ }
1012
+ //#endregion
1013
+ //#region src/metadata/comicInfo.ts
1014
+ const PARSE_OPTIONS$1 = {
1015
+ ignoreAttributes: false,
1016
+ attributeNamePrefix: "@_",
1017
+ isArray: (name) => name === "Page"
1018
+ };
1019
+ const BUILD_OPTIONS$1 = {
1020
+ ignoreAttributes: false,
1021
+ attributeNamePrefix: "@_",
1022
+ format: true,
1023
+ suppressEmptyNode: true,
1024
+ suppressBooleanAttributes: false
1025
+ };
1026
+ const KNOWN_KEYS = /* @__PURE__ */ new Set([
1027
+ "Title",
1028
+ "Series",
1029
+ "Number",
1030
+ "Count",
1031
+ "Volume",
1032
+ "AlternateSeries",
1033
+ "AlternateNumber",
1034
+ "AlternateCount",
1035
+ "Summary",
1036
+ "Notes",
1037
+ "Year",
1038
+ "Month",
1039
+ "Day",
1040
+ "Publisher",
1041
+ "Imprint",
1042
+ "Genre",
1043
+ "Tags",
1044
+ "Web",
1045
+ "PageCount",
1046
+ "LanguageISO",
1047
+ "Format",
1048
+ "BlackAndWhite",
1049
+ "Manga",
1050
+ "Characters",
1051
+ "Teams",
1052
+ "Locations",
1053
+ "ScanInformation",
1054
+ "StoryArc",
1055
+ "StoryArcNumber",
1056
+ "SeriesGroup",
1057
+ "AgeRating",
1058
+ "Pages",
1059
+ "CommunityRating",
1060
+ "MainCharacterOrTeam",
1061
+ "Review",
1062
+ "GTIN",
1063
+ ...COMIC_INFO_CREDIT_ROLES
1064
+ ]);
1065
+ function metadataToComicInfoXml(metadata) {
1066
+ const root = {};
1067
+ if (metadata.title !== void 0) root.Title = metadata.title;
1068
+ if (metadata.series !== void 0) root.Series = metadata.series;
1069
+ if (metadata.number !== void 0) root.Number = metadata.number;
1070
+ if (metadata.count !== void 0) root.Count = metadata.count;
1071
+ if (metadata.volume !== void 0) root.Volume = metadata.volume;
1072
+ if (metadata.alternateSeries !== void 0) root.AlternateSeries = metadata.alternateSeries;
1073
+ if (metadata.alternateNumber !== void 0) root.AlternateNumber = metadata.alternateNumber;
1074
+ if (metadata.alternateCount !== void 0) root.AlternateCount = metadata.alternateCount;
1075
+ if (metadata.summary !== void 0) root.Summary = metadata.summary;
1076
+ if (metadata.notes !== void 0) root.Notes = metadata.notes;
1077
+ if (metadata.coverDate?.year !== void 0) root.Year = metadata.coverDate.year;
1078
+ if (metadata.coverDate?.month !== void 0) root.Month = metadata.coverDate.month;
1079
+ if (metadata.coverDate?.day !== void 0) root.Day = metadata.coverDate.day;
1080
+ for (const role of COMIC_INFO_CREDIT_ROLES) {
1081
+ const joined = joinCommaList((metadata.credits ?? []).filter((credit) => credit.role === role).map((credit) => credit.name));
1082
+ if (joined) root[role] = joined;
1083
+ }
1084
+ if (metadata.publisher !== void 0) root.Publisher = metadata.publisher;
1085
+ if (metadata.imprint !== void 0) root.Imprint = metadata.imprint;
1086
+ const genres = joinResourceNames(metadata.genres);
1087
+ if (genres) root.Genre = genres;
1088
+ const tags = joinResourceNames(metadata.tags);
1089
+ if (tags) root.Tags = tags;
1090
+ const web = metadata.web?.[0];
1091
+ if (web !== void 0) root.Web = typeof web === "string" ? web : web.url;
1092
+ if (metadata.pageCount !== void 0) root.PageCount = metadata.pageCount;
1093
+ if (metadata.language !== void 0) root.LanguageISO = metadata.language;
1094
+ if (metadata.format !== void 0) root.Format = metadata.format;
1095
+ if (metadata.blackAndWhite !== void 0) root.BlackAndWhite = metadata.blackAndWhite ? "Yes" : "No";
1096
+ if (metadata.manga !== void 0) root.Manga = metadata.manga;
1097
+ const characters = joinResourceNames(metadata.characters);
1098
+ if (characters) root.Characters = characters;
1099
+ const teams = joinResourceNames(metadata.teams);
1100
+ if (teams) root.Teams = teams;
1101
+ const locations = joinResourceNames(metadata.locations);
1102
+ if (locations) root.Locations = locations;
1103
+ if (metadata.scanInformation !== void 0) root.ScanInformation = metadata.scanInformation;
1104
+ const storyArcs = joinCommaList(metadata.storyArcs?.map((arc) => arc.name));
1105
+ if (storyArcs) root.StoryArc = storyArcs;
1106
+ if (metadata.storyArcs?.some((arc) => arc.number !== void 0)) root.StoryArcNumber = metadata.storyArcs.map((arc) => arc.number ?? "").join(", ");
1107
+ if (metadata.seriesGroup !== void 0) root.SeriesGroup = metadata.seriesGroup;
1108
+ if (metadata.ageRating !== void 0) root.AgeRating = metadata.ageRating;
1109
+ if (metadata.pages?.length) root.Pages = { Page: metadata.pages.map((page) => {
1110
+ const attrs = { "@_Image": page.index };
1111
+ if (page.type !== void 0) attrs["@_Type"] = page.type;
1112
+ if (page.doublePage !== void 0) attrs["@_DoublePage"] = page.doublePage;
1113
+ if (page.imageSize !== void 0) attrs["@_ImageSize"] = page.imageSize;
1114
+ if (page.imageWidth !== void 0) attrs["@_ImageWidth"] = page.imageWidth;
1115
+ if (page.imageHeight !== void 0) attrs["@_ImageHeight"] = page.imageHeight;
1116
+ if (page.key !== void 0) attrs["@_Key"] = page.key;
1117
+ if (page.bookmark !== void 0) attrs["@_Bookmark"] = page.bookmark;
1118
+ return attrs;
1119
+ }) };
1120
+ if (metadata.communityRating !== void 0) root.CommunityRating = metadata.communityRating;
1121
+ if (metadata.mainCharacterOrTeam !== void 0) root.MainCharacterOrTeam = metadata.mainCharacterOrTeam;
1122
+ if (metadata.review !== void 0) root.Review = metadata.review;
1123
+ if (metadata.gtin !== void 0) root.GTIN = metadata.gtin;
1124
+ if (metadata.comicInfoExtra) Object.assign(root, metadata.comicInfoExtra);
1125
+ return `<?xml version="1.0" encoding="utf-8"?>\n${new XMLBuilder(BUILD_OPTIONS$1).build({ ComicInfo: root })}`;
1126
+ }
1127
+ function comicInfoXmlToMetadata(xml) {
1128
+ const root = new XMLParser(PARSE_OPTIONS$1).parse(xml.toString("utf8")).ComicInfo ?? {};
1129
+ const metadata = {};
1130
+ if (root.Title !== void 0) metadata.title = String(root.Title);
1131
+ if (root.Series !== void 0) metadata.series = String(root.Series);
1132
+ if (root.Number !== void 0) metadata.number = String(root.Number);
1133
+ if (root.Count !== void 0) metadata.count = Number(root.Count);
1134
+ if (root.Volume !== void 0) metadata.volume = String(root.Volume);
1135
+ if (root.AlternateSeries !== void 0) metadata.alternateSeries = String(root.AlternateSeries);
1136
+ if (root.AlternateNumber !== void 0) metadata.alternateNumber = String(root.AlternateNumber);
1137
+ if (root.AlternateCount !== void 0) metadata.alternateCount = Number(root.AlternateCount);
1138
+ if (root.Summary !== void 0) metadata.summary = String(root.Summary);
1139
+ if (root.Notes !== void 0) metadata.notes = String(root.Notes);
1140
+ if (root.Year !== void 0 || root.Month !== void 0 || root.Day !== void 0) metadata.coverDate = {
1141
+ year: root.Year !== void 0 ? Number(root.Year) : void 0,
1142
+ month: root.Month !== void 0 ? Number(root.Month) : void 0,
1143
+ day: root.Day !== void 0 ? Number(root.Day) : void 0
1144
+ };
1145
+ if (root.Publisher !== void 0) metadata.publisher = String(root.Publisher);
1146
+ if (root.Imprint !== void 0) metadata.imprint = String(root.Imprint);
1147
+ if (root.Genre !== void 0) metadata.genres = splitCommaList(String(root.Genre));
1148
+ if (root.Tags !== void 0) metadata.tags = splitCommaList(String(root.Tags));
1149
+ if (root.Web !== void 0) metadata.web = [String(root.Web)];
1150
+ if (root.PageCount !== void 0) metadata.pageCount = Number(root.PageCount);
1151
+ if (root.LanguageISO !== void 0) metadata.language = String(root.LanguageISO);
1152
+ if (root.Format !== void 0) metadata.format = String(root.Format);
1153
+ if (root.BlackAndWhite !== void 0) metadata.blackAndWhite = String(root.BlackAndWhite).toLowerCase() === "yes";
1154
+ if (root.Manga !== void 0) metadata.manga = String(root.Manga);
1155
+ if (root.Characters !== void 0) metadata.characters = splitCommaList(String(root.Characters));
1156
+ if (root.Teams !== void 0) metadata.teams = splitCommaList(String(root.Teams));
1157
+ if (root.Locations !== void 0) metadata.locations = splitCommaList(String(root.Locations));
1158
+ if (root.ScanInformation !== void 0) metadata.scanInformation = String(root.ScanInformation);
1159
+ if (root.StoryArc !== void 0) {
1160
+ const names = splitCommaList(String(root.StoryArc)) ?? [];
1161
+ const numbers = root.StoryArcNumber !== void 0 ? String(root.StoryArcNumber).split(",").map((part) => part.trim()) : [];
1162
+ metadata.storyArcs = names.map((name, i) => ({
1163
+ name,
1164
+ number: numbers[i] ? Number(numbers[i]) : void 0
1165
+ }));
1166
+ }
1167
+ if (root.SeriesGroup !== void 0) metadata.seriesGroup = String(root.SeriesGroup);
1168
+ if (root.AgeRating !== void 0) metadata.ageRating = String(root.AgeRating);
1169
+ if (root.CommunityRating !== void 0) metadata.communityRating = Number(root.CommunityRating);
1170
+ if (root.MainCharacterOrTeam !== void 0) metadata.mainCharacterOrTeam = String(root.MainCharacterOrTeam);
1171
+ if (root.Review !== void 0) metadata.review = String(root.Review);
1172
+ if (root.GTIN !== void 0) metadata.gtin = String(root.GTIN);
1173
+ const credits = [];
1174
+ for (const role of COMIC_INFO_CREDIT_ROLES) {
1175
+ const value = root[role];
1176
+ if (value !== void 0) for (const name of splitCommaList(String(value)) ?? []) credits.push({
1177
+ name,
1178
+ role
1179
+ });
1180
+ }
1181
+ if (credits.length) metadata.credits = credits;
1182
+ const pagesNode = root.Pages;
1183
+ if (pagesNode?.Page?.length) metadata.pages = pagesNode.Page.map((page) => ({
1184
+ index: Number(page["@_Image"] ?? 0),
1185
+ type: page["@_Type"] !== void 0 ? String(page["@_Type"]) : void 0,
1186
+ doublePage: page["@_DoublePage"] !== void 0 ? String(page["@_DoublePage"]) === "true" : void 0,
1187
+ imageSize: page["@_ImageSize"] !== void 0 ? Number(page["@_ImageSize"]) : void 0,
1188
+ imageWidth: page["@_ImageWidth"] !== void 0 ? Number(page["@_ImageWidth"]) : void 0,
1189
+ imageHeight: page["@_ImageHeight"] !== void 0 ? Number(page["@_ImageHeight"]) : void 0,
1190
+ key: page["@_Key"] !== void 0 ? String(page["@_Key"]) : void 0,
1191
+ bookmark: page["@_Bookmark"] !== void 0 ? String(page["@_Bookmark"]) : void 0
1192
+ }));
1193
+ const extra = {};
1194
+ for (const [key, value] of Object.entries(root)) if (!KNOWN_KEYS.has(key)) extra[key] = value;
1195
+ if (Object.keys(extra).length) metadata.comicInfoExtra = extra;
1196
+ return metadata;
1197
+ }
1198
+ //#endregion
1199
+ //#region src/metadata/metronInfo.ts
1200
+ const REPEATING_ELEMENTS = /* @__PURE__ */ new Set([
1201
+ "ID",
1202
+ "Genre",
1203
+ "Tag",
1204
+ "Character",
1205
+ "Team",
1206
+ "Universe",
1207
+ "Location",
1208
+ "Reprint",
1209
+ "Arc",
1210
+ "Credit",
1211
+ "Role",
1212
+ "URL",
1213
+ "Story",
1214
+ "Price",
1215
+ "AlternativeName"
1216
+ ]);
1217
+ const PARSE_OPTIONS = {
1218
+ ignoreAttributes: false,
1219
+ attributeNamePrefix: "@_",
1220
+ isArray: (name) => REPEATING_ELEMENTS.has(name)
1221
+ };
1222
+ const BUILD_OPTIONS = {
1223
+ ignoreAttributes: false,
1224
+ attributeNamePrefix: "@_",
1225
+ format: true,
1226
+ suppressEmptyNode: true,
1227
+ suppressBooleanAttributes: false
1228
+ };
1229
+ const KNOWN_ROOT_KEYS = /* @__PURE__ */ new Set([
1230
+ "IDS",
1231
+ "Publisher",
1232
+ "Series",
1233
+ "MangaVolume",
1234
+ "CollectionTitle",
1235
+ "Number",
1236
+ "AlternativeNumber",
1237
+ "Stories",
1238
+ "Summary",
1239
+ "Prices",
1240
+ "CoverDate",
1241
+ "StoreDate",
1242
+ "PageCount",
1243
+ "Notes",
1244
+ "Genres",
1245
+ "Tags",
1246
+ "Arcs",
1247
+ "Characters",
1248
+ "Teams",
1249
+ "Universes",
1250
+ "Locations",
1251
+ "Reprints",
1252
+ "GTIN",
1253
+ "AgeRating",
1254
+ "CommunityRating",
1255
+ "URLs",
1256
+ "Credits",
1257
+ "LastModified"
1258
+ ]);
1259
+ function formatDate(date) {
1260
+ if (!date || date.year === void 0) return;
1261
+ const month = String(date.month ?? 1).padStart(2, "0");
1262
+ const day = String(date.day ?? 1).padStart(2, "0");
1263
+ return `${date.year}-${month}-${day}`;
1264
+ }
1265
+ function parseDate(value) {
1266
+ if (typeof value !== "string") return;
1267
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
1268
+ if (!match) return;
1269
+ return {
1270
+ year: Number(match[1]),
1271
+ month: Number(match[2]),
1272
+ day: Number(match[3])
1273
+ };
1274
+ }
1275
+ /** Builds a MetronInfo `resourceType` node: plain text, or `{ #text, @_id }` when an id is present. */
1276
+ function buildResourceItem(item) {
1277
+ const id = resourceId(item);
1278
+ return id !== void 0 ? {
1279
+ "#text": resourceName(item),
1280
+ "@_id": id
1281
+ } : resourceName(item);
1282
+ }
1283
+ /** Parses a MetronInfo `resourceType` node back into a plain string or `MetronResource`. */
1284
+ function parseResourceItem(item) {
1285
+ if (item !== null && typeof item === "object") {
1286
+ const obj = item;
1287
+ const id = obj["@_id"];
1288
+ const text = String(obj["#text"] ?? "");
1289
+ return id !== void 0 ? {
1290
+ name: text,
1291
+ id: String(id)
1292
+ } : text;
1293
+ }
1294
+ return String(item);
1295
+ }
1296
+ function buildUrlItem(item) {
1297
+ if (typeof item === "string") return item;
1298
+ return item.primary !== void 0 ? {
1299
+ "#text": item.url,
1300
+ "@_primary": item.primary
1301
+ } : item.url;
1302
+ }
1303
+ function parseUrlItem(item) {
1304
+ if (item !== null && typeof item === "object") {
1305
+ const obj = item;
1306
+ const primary = obj["@_primary"];
1307
+ const text = String(obj["#text"] ?? "");
1308
+ return primary !== void 0 ? {
1309
+ url: text,
1310
+ primary: String(primary) === "true"
1311
+ } : text;
1312
+ }
1313
+ return String(item);
1314
+ }
1315
+ function buildIdentifierItem(id) {
1316
+ return {
1317
+ "#text": id.value,
1318
+ "@_source": id.source,
1319
+ ...id.primary !== void 0 ? { "@_primary": id.primary } : {}
1320
+ };
1321
+ }
1322
+ function parseIdentifierItem(item) {
1323
+ const obj = item ?? {};
1324
+ return {
1325
+ source: String(obj["@_source"] ?? ""),
1326
+ value: String(obj["#text"] ?? ""),
1327
+ primary: obj["@_primary"] !== void 0 ? String(obj["@_primary"]) === "true" : void 0
1328
+ };
1329
+ }
1330
+ function buildAlternativeNameItem(item) {
1331
+ const attrs = {};
1332
+ if (item.id !== void 0) attrs["@_id"] = item.id;
1333
+ if (item.lang !== void 0) attrs["@_lang"] = item.lang;
1334
+ return Object.keys(attrs).length ? {
1335
+ "#text": item.name,
1336
+ ...attrs
1337
+ } : item.name;
1338
+ }
1339
+ function parseAlternativeNameItem(item) {
1340
+ if (item === null || typeof item !== "object") return { name: String(item) };
1341
+ const obj = item;
1342
+ return {
1343
+ name: String(obj["#text"] ?? ""),
1344
+ id: obj["@_id"] !== void 0 ? String(obj["@_id"]) : void 0,
1345
+ lang: obj["@_lang"] !== void 0 ? String(obj["@_lang"]) : void 0
1346
+ };
1347
+ }
1348
+ function buildUniverseItem(universe) {
1349
+ return {
1350
+ Name: universe.name,
1351
+ ...universe.designation !== void 0 ? { Designation: universe.designation } : {},
1352
+ ...universe.id !== void 0 ? { "@_id": universe.id } : {}
1353
+ };
1354
+ }
1355
+ function parseUniverseItem(item) {
1356
+ return {
1357
+ name: String(item.Name ?? ""),
1358
+ designation: item.Designation !== void 0 ? String(item.Designation) : void 0,
1359
+ id: item["@_id"] !== void 0 ? String(item["@_id"]) : void 0
1360
+ };
1361
+ }
1362
+ function buildArcItem(arc) {
1363
+ return {
1364
+ Name: arc.name,
1365
+ ...arc.number !== void 0 ? { Number: arc.number } : {},
1366
+ ...arc.id !== void 0 ? { "@_id": arc.id } : {}
1367
+ };
1368
+ }
1369
+ function parseArcItem(item) {
1370
+ return {
1371
+ name: String(item.Name ?? ""),
1372
+ number: item.Number !== void 0 ? Number(item.Number) : void 0,
1373
+ id: item["@_id"] !== void 0 ? String(item["@_id"]) : void 0
1374
+ };
1375
+ }
1376
+ function buildPriceItem(price) {
1377
+ return {
1378
+ "#text": price.amount,
1379
+ "@_country": price.country
1380
+ };
1381
+ }
1382
+ function parsePriceItem(item) {
1383
+ const obj = item ?? {};
1384
+ return {
1385
+ amount: Number(obj["#text"]),
1386
+ country: String(obj["@_country"] ?? "")
1387
+ };
1388
+ }
1389
+ function metadataToMetronInfoXml(metadata) {
1390
+ const root = {};
1391
+ if (metadata.identifiers?.length) root.IDS = { ID: metadata.identifiers.map(buildIdentifierItem) };
1392
+ if (metadata.publisher !== void 0 || metadata.imprint !== void 0 || metadata.publisherId !== void 0) root.Publisher = {
1393
+ ...metadata.publisher !== void 0 ? { Name: metadata.publisher } : {},
1394
+ ...metadata.imprint !== void 0 ? { Imprint: buildResourceItem(metadata.imprintId !== void 0 ? {
1395
+ name: metadata.imprint,
1396
+ id: metadata.imprintId
1397
+ } : metadata.imprint) } : {},
1398
+ ...metadata.publisherId !== void 0 ? { "@_id": metadata.publisherId } : {}
1399
+ };
1400
+ if (metadata.series !== void 0 || metadata.seriesSort !== void 0 || metadata.volume !== void 0 || metadata.format !== void 0 || metadata.seriesStartYear !== void 0 || metadata.seriesIssueCount !== void 0 || metadata.seriesVolumeCount !== void 0 || metadata.seriesAlternativeNames?.length || metadata.seriesId !== void 0 || metadata.seriesLang !== void 0) root.Series = {
1401
+ ...metadata.series !== void 0 ? { Name: metadata.series } : {},
1402
+ ...metadata.seriesSort !== void 0 ? { SortName: metadata.seriesSort } : {},
1403
+ ...metadata.volume !== void 0 ? { Volume: metadata.volume } : {},
1404
+ ...metadata.format !== void 0 ? { Format: metadata.format } : {},
1405
+ ...metadata.seriesStartYear !== void 0 ? { StartYear: metadata.seriesStartYear } : {},
1406
+ ...metadata.seriesIssueCount !== void 0 ? { IssueCount: metadata.seriesIssueCount } : {},
1407
+ ...metadata.seriesVolumeCount !== void 0 ? { VolumeCount: metadata.seriesVolumeCount } : {},
1408
+ ...metadata.seriesAlternativeNames?.length ? { AlternativeNames: { AlternativeName: metadata.seriesAlternativeNames.map(buildAlternativeNameItem) } } : {},
1409
+ ...metadata.seriesId !== void 0 ? { "@_id": metadata.seriesId } : {},
1410
+ ...metadata.seriesLang !== void 0 ? { "@_lang": metadata.seriesLang } : {}
1411
+ };
1412
+ if (metadata.mangaVolume !== void 0) root.MangaVolume = metadata.mangaVolume;
1413
+ if (metadata.collectionTitle !== void 0) root.CollectionTitle = metadata.collectionTitle;
1414
+ if (metadata.number !== void 0) root.Number = metadata.number;
1415
+ if (metadata.alternateNumber !== void 0) root.AlternativeNumber = metadata.alternateNumber;
1416
+ if (metadata.stories?.length) root.Stories = { Story: metadata.stories.map(buildResourceItem) };
1417
+ if (metadata.summary !== void 0) root.Summary = metadata.summary;
1418
+ if (metadata.prices?.length) root.Prices = { Price: metadata.prices.map(buildPriceItem) };
1419
+ const coverDate = formatDate(metadata.coverDate);
1420
+ if (coverDate) root.CoverDate = coverDate;
1421
+ const storeDate = formatDate(metadata.storeDate);
1422
+ if (storeDate) root.StoreDate = storeDate;
1423
+ if (metadata.pageCount !== void 0) root.PageCount = metadata.pageCount;
1424
+ if (metadata.notes !== void 0) root.Notes = metadata.notes;
1425
+ if (metadata.genres?.length) root.Genres = { Genre: metadata.genres.map(buildResourceItem) };
1426
+ if (metadata.tags?.length) root.Tags = { Tag: metadata.tags.map(buildResourceItem) };
1427
+ if (metadata.storyArcs?.length) root.Arcs = { Arc: metadata.storyArcs.map(buildArcItem) };
1428
+ if (metadata.characters?.length) root.Characters = { Character: metadata.characters.map(buildResourceItem) };
1429
+ if (metadata.teams?.length) root.Teams = { Team: metadata.teams.map(buildResourceItem) };
1430
+ if (metadata.universes?.length) root.Universes = { Universe: metadata.universes.map(buildUniverseItem) };
1431
+ if (metadata.locations?.length) root.Locations = { Location: metadata.locations.map(buildResourceItem) };
1432
+ if (metadata.reprints?.length) root.Reprints = { Reprint: metadata.reprints.map(buildResourceItem) };
1433
+ if (metadata.gtin !== void 0 || metadata.gtinUpc !== void 0) root.GTIN = {
1434
+ ...metadata.gtin !== void 0 ? { ISBN: metadata.gtin } : {},
1435
+ ...metadata.gtinUpc !== void 0 ? { UPC: metadata.gtinUpc } : {}
1436
+ };
1437
+ if (metadata.ageRating !== void 0) root.AgeRating = metadata.ageRating;
1438
+ if (metadata.communityRating !== void 0) root.CommunityRating = {
1439
+ AverageRating: metadata.communityRating,
1440
+ ...metadata.communityRatingCount !== void 0 ? { RatingCount: metadata.communityRatingCount } : {}
1441
+ };
1442
+ if (metadata.web?.length) root.URLs = { URL: metadata.web.map(buildUrlItem) };
1443
+ if (metadata.credits?.length) root.Credits = { Credit: metadata.credits.map((credit) => ({
1444
+ Creator: buildResourceItem(credit.creatorId !== void 0 ? {
1445
+ name: credit.name,
1446
+ id: credit.creatorId
1447
+ } : credit.name),
1448
+ Roles: { Role: [buildResourceItem(credit.roleId !== void 0 ? {
1449
+ name: credit.role,
1450
+ id: credit.roleId
1451
+ } : credit.role)] }
1452
+ })) };
1453
+ if (metadata.lastModified !== void 0) root.LastModified = metadata.lastModified;
1454
+ if (metadata.metronInfoExtra) Object.assign(root, metadata.metronInfoExtra);
1455
+ return `<?xml version="1.0" encoding="utf-8"?>\n${new XMLBuilder(BUILD_OPTIONS).build({ MetronInfo: root })}`;
1456
+ }
1457
+ function metronInfoXmlToMetadata(xml) {
1458
+ const root = new XMLParser(PARSE_OPTIONS).parse(xml.toString("utf8")).MetronInfo ?? {};
1459
+ const metadata = {};
1460
+ const ids = root.IDS;
1461
+ if (ids?.ID?.length) metadata.identifiers = ids.ID.map(parseIdentifierItem);
1462
+ const publisher = root.Publisher;
1463
+ if (publisher?.Name !== void 0) metadata.publisher = String(publisher.Name);
1464
+ if (publisher?.Imprint !== void 0) {
1465
+ const imprint = parseResourceItem(publisher.Imprint);
1466
+ metadata.imprint = resourceName(imprint);
1467
+ metadata.imprintId = resourceId(imprint);
1468
+ }
1469
+ if (publisher?.["@_id"] !== void 0) metadata.publisherId = String(publisher["@_id"]);
1470
+ const series = root.Series;
1471
+ if (series?.Name !== void 0) metadata.series = String(series.Name);
1472
+ if (series?.SortName !== void 0) metadata.seriesSort = String(series.SortName);
1473
+ if (series?.Volume !== void 0) metadata.volume = String(series.Volume);
1474
+ if (series?.Format !== void 0) metadata.format = String(series.Format);
1475
+ if (series?.StartYear !== void 0) metadata.seriesStartYear = Number(series.StartYear);
1476
+ if (series?.IssueCount !== void 0) metadata.seriesIssueCount = Number(series.IssueCount);
1477
+ if (series?.VolumeCount !== void 0) metadata.seriesVolumeCount = Number(series.VolumeCount);
1478
+ if (series?.AlternativeNames?.AlternativeName?.length) metadata.seriesAlternativeNames = series.AlternativeNames.AlternativeName.map(parseAlternativeNameItem);
1479
+ if (series?.["@_id"] !== void 0) metadata.seriesId = String(series["@_id"]);
1480
+ if (series?.["@_lang"] !== void 0) metadata.seriesLang = String(series["@_lang"]);
1481
+ if (root.MangaVolume !== void 0) metadata.mangaVolume = String(root.MangaVolume);
1482
+ if (root.CollectionTitle !== void 0) metadata.collectionTitle = String(root.CollectionTitle);
1483
+ if (root.Number !== void 0) metadata.number = String(root.Number);
1484
+ if (root.AlternativeNumber !== void 0) metadata.alternateNumber = String(root.AlternativeNumber);
1485
+ const stories = root.Stories;
1486
+ if (stories?.Story?.length) metadata.stories = stories.Story.map(parseResourceItem);
1487
+ if (root.Summary !== void 0) metadata.summary = String(root.Summary);
1488
+ const prices = root.Prices;
1489
+ if (prices?.Price?.length) metadata.prices = prices.Price.map(parsePriceItem);
1490
+ metadata.coverDate = parseDate(root.CoverDate);
1491
+ metadata.storeDate = parseDate(root.StoreDate);
1492
+ if (root.PageCount !== void 0) metadata.pageCount = Number(root.PageCount);
1493
+ if (root.Notes !== void 0) metadata.notes = String(root.Notes);
1494
+ const genres = root.Genres;
1495
+ if (genres?.Genre?.length) metadata.genres = genres.Genre.map(parseResourceItem);
1496
+ const tags = root.Tags;
1497
+ if (tags?.Tag?.length) metadata.tags = tags.Tag.map(parseResourceItem);
1498
+ const arcs = root.Arcs;
1499
+ if (arcs?.Arc?.length) metadata.storyArcs = arcs.Arc.map(parseArcItem);
1500
+ const characters = root.Characters;
1501
+ if (characters?.Character?.length) metadata.characters = characters.Character.map(parseResourceItem);
1502
+ const teams = root.Teams;
1503
+ if (teams?.Team?.length) metadata.teams = teams.Team.map(parseResourceItem);
1504
+ const universes = root.Universes;
1505
+ if (universes?.Universe?.length) metadata.universes = universes.Universe.map(parseUniverseItem);
1506
+ const locations = root.Locations;
1507
+ if (locations?.Location?.length) metadata.locations = locations.Location.map(parseResourceItem);
1508
+ const reprints = root.Reprints;
1509
+ if (reprints?.Reprint?.length) metadata.reprints = reprints.Reprint.map(parseResourceItem);
1510
+ const gtin = root.GTIN;
1511
+ if (gtin?.ISBN !== void 0) metadata.gtin = String(gtin.ISBN);
1512
+ if (gtin?.UPC !== void 0) metadata.gtinUpc = String(gtin.UPC);
1513
+ if (root.AgeRating !== void 0) metadata.ageRating = String(root.AgeRating);
1514
+ const communityRating = root.CommunityRating;
1515
+ if (communityRating?.AverageRating !== void 0) metadata.communityRating = Number(communityRating.AverageRating);
1516
+ if (communityRating?.RatingCount !== void 0) metadata.communityRatingCount = Number(communityRating.RatingCount);
1517
+ const urls = root.URLs;
1518
+ if (urls?.URL?.length) metadata.web = urls.URL.map(parseUrlItem);
1519
+ const credits = root.Credits;
1520
+ if (credits?.Credit?.length) {
1521
+ const list = [];
1522
+ for (const credit of credits.Credit) {
1523
+ const creator = parseResourceItem(credit.Creator ?? "");
1524
+ const name = resourceName(creator);
1525
+ const creatorId = resourceId(creator);
1526
+ const roleItems = credit.Roles?.Role?.length ? credit.Roles.Role : ["Unknown"];
1527
+ for (const roleItem of roleItems) {
1528
+ const role = parseResourceItem(roleItem);
1529
+ list.push({
1530
+ name,
1531
+ role: resourceName(role),
1532
+ creatorId,
1533
+ roleId: resourceId(role)
1534
+ });
1535
+ }
1536
+ }
1537
+ metadata.credits = list;
1538
+ }
1539
+ if (root.LastModified !== void 0) metadata.lastModified = String(root.LastModified);
1540
+ const extra = {};
1541
+ for (const [key, value] of Object.entries(root)) if (!KNOWN_ROOT_KEYS.has(key)) extra[key] = value;
1542
+ if (Object.keys(extra).length) metadata.metronInfoExtra = extra;
1543
+ return metadata;
1544
+ }
1545
+ //#endregion
1546
+ //#region src/metadata/schemaVersions.ts
1547
+ /**
1548
+ * Update these when the XSDs in `schemas/` are replaced with a newer
1549
+ * release. Every place that needs to read a bundled schema file (tests,
1550
+ * dev tooling, runtime validation) derives its filename from here instead
1551
+ * of hardcoding a version number.
1552
+ */
1553
+ const SCHEMA_VERSIONS = {
1554
+ ComicInfo: "2.1",
1555
+ MetronInfo: "1.1"
1556
+ };
1557
+ /**
1558
+ * Walks up from this module's location to find the package root (the
1559
+ * directory containing `schemas/`). This module runs both from `src/`
1560
+ * (tests, ts-node) and from the single-file bundle in `dist/` — those sit
1561
+ * at different depths relative to the package root, so the depth can't be
1562
+ * hardcoded.
1563
+ */
1564
+ function findPackageRoot() {
1565
+ let dir = dirname(fileURLToPath(import.meta.url));
1566
+ for (let i = 0; i < 5; i++) {
1567
+ if (existsSync(join(dir, "schemas"))) return dir;
1568
+ dir = join(dir, "..");
1569
+ }
1570
+ throw new Error("Could not locate the \"schemas\" directory relative to the package root.");
1571
+ }
1572
+ /** Absolute path to the bundled XSD for `schema`, e.g. ".../schemas/ComicInfo v2.1.xsd". */
1573
+ function schemaFilePath(schema) {
1574
+ return join(findPackageRoot(), "schemas", `${schema} v${SCHEMA_VERSIONS[schema]}.xsd`);
1575
+ }
1576
+ //#endregion
1577
+ //#region src/metadata/validate.ts
1578
+ const schemaCache = /* @__PURE__ */ new Map();
1579
+ function loadSchema(schema) {
1580
+ const cached = schemaCache.get(schema);
1581
+ if (cached !== void 0) return cached;
1582
+ const xsd = readFileSync(schemaFilePath(schema), "utf8").replace(/<xs:assert\b[^>]*\/>\n?/g, "");
1583
+ schemaCache.set(schema, xsd);
1584
+ return xsd;
1585
+ }
1586
+ /**
1587
+ * Validates an XML document against the bundled ComicInfo.xml or
1588
+ * MetronInfo.xml XSD. Returns `{ valid: true, issues: [] }` when the
1589
+ * document conforms, or `{ valid: false, issues }` with one entry per
1590
+ * schema violation (element/attribute name and line number, where
1591
+ * available) otherwise.
1592
+ */
1593
+ async function validateMetadataXml(xml, schema) {
1594
+ const result = await validateXML({
1595
+ xml: xml.toString(),
1596
+ schema: loadSchema(schema)
1597
+ });
1598
+ return {
1599
+ valid: result.valid,
1600
+ issues: result.errors.map((error) => ({
1601
+ message: error.message,
1602
+ line: error.loc?.lineNumber
1603
+ }))
1604
+ };
1605
+ }
1606
+ //#endregion
1607
+ //#region src/metadata/index.ts
1608
+ var metadata_exports = /* @__PURE__ */ __exportAll({
1609
+ COMIC_INFO_AGE_RATING_VALUES: () => COMIC_INFO_AGE_RATING_VALUES,
1610
+ COMIC_INFO_CREDIT_ROLES: () => COMIC_INFO_CREDIT_ROLES,
1611
+ COMIC_INFO_MANGA_VALUES: () => COMIC_INFO_MANGA_VALUES,
1612
+ COMIC_INFO_PAGE_TYPE_VALUES: () => COMIC_INFO_PAGE_TYPE_VALUES,
1613
+ COMIC_INFO_YES_NO_VALUES: () => COMIC_INFO_YES_NO_VALUES,
1614
+ METRON_AGE_RATING_VALUES: () => METRON_AGE_RATING_VALUES,
1615
+ METRON_FORMAT_VALUES: () => METRON_FORMAT_VALUES,
1616
+ METRON_INFORMATION_SOURCE_VALUES: () => METRON_INFORMATION_SOURCE_VALUES,
1617
+ METRON_ROLE_VALUES: () => METRON_ROLE_VALUES,
1618
+ addMetadataToArchive: () => addMetadataToArchive,
1619
+ comicInfoXmlToMetadata: () => comicInfoXmlToMetadata,
1620
+ hasComicMetadata: () => hasComicMetadata,
1621
+ joinCommaList: () => joinCommaList,
1622
+ joinResourceNames: () => joinResourceNames,
1623
+ metadataToComicInfoXml: () => metadataToComicInfoXml,
1624
+ metadataToMetronInfoXml: () => metadataToMetronInfoXml,
1625
+ metadataToXml: () => metadataToXml,
1626
+ metronInfoXmlToMetadata: () => metronInfoXmlToMetadata,
1627
+ readArchiveMetadata: () => readArchiveMetadata,
1628
+ resourceId: () => resourceId,
1629
+ resourceName: () => resourceName,
1630
+ splitCommaList: () => splitCommaList,
1631
+ validateMetadataXml: () => validateMetadataXml,
1632
+ xmlToMetadata: () => xmlToMetadata
1633
+ });
1634
+ const FILE_NAMES = {
1635
+ ComicInfo: "ComicInfo.xml",
1636
+ MetronInfo: "MetronInfo.xml"
1637
+ };
1638
+ function metadataToXml(metadata, schema) {
1639
+ return schema === "ComicInfo" ? metadataToComicInfoXml(metadata) : metadataToMetronInfoXml(metadata);
1640
+ }
1641
+ function xmlToMetadata(xml, schema) {
1642
+ return schema === "ComicInfo" ? comicInfoXmlToMetadata(xml) : metronInfoXmlToMetadata(xml);
1643
+ }
1644
+ async function hasComicMetadata(input) {
1645
+ const type = await detectArchiveType(input);
1646
+ if (type === "asar") {
1647
+ const { header } = await parseAsarHeader((start, end) => readInputRange(input, start, end));
1648
+ if (hasRootFile(header, "ComicInfo.xml")) return {
1649
+ present: true,
1650
+ schema: "ComicInfo",
1651
+ path: "ComicInfo.xml"
1652
+ };
1653
+ if (hasRootFile(header, "MetronInfo.xml")) return {
1654
+ present: true,
1655
+ schema: "MetronInfo",
1656
+ path: "MetronInfo.xml"
1657
+ };
1658
+ return { present: false };
1659
+ }
1660
+ const adapter = getAdapter(type);
1661
+ for await (const entry of adapter.listEntries(input)) {
1662
+ const base = entry.path.split("/").pop();
1663
+ if (base === "ComicInfo.xml") return {
1664
+ present: true,
1665
+ schema: "ComicInfo",
1666
+ path: entry.path
1667
+ };
1668
+ if (base === "MetronInfo.xml") return {
1669
+ present: true,
1670
+ schema: "MetronInfo",
1671
+ path: entry.path
1672
+ };
1673
+ }
1674
+ return { present: false };
1675
+ }
1676
+ async function readArchiveMetadata(input) {
1677
+ const found = await hasComicMetadata(input);
1678
+ if (!found.present || !found.schema || !found.path) return null;
1679
+ const adapter = getAdapter(await detectArchiveType(input));
1680
+ for await (const entry of adapter.listEntries(input)) if (entry.path === found.path) {
1681
+ const buffer = await streamToBuffer(entry.openReadStream());
1682
+ return {
1683
+ schema: found.schema,
1684
+ metadata: xmlToMetadata(buffer, found.schema)
1685
+ };
1686
+ }
1687
+ return null;
1688
+ }
1689
+ async function addMetadataToArchive(input, metadata, schema, options = {}) {
1690
+ const adapter = getAdapter(await detectArchiveType(input));
1691
+ const fileName = FILE_NAMES[schema];
1692
+ const xmlBuffer = Buffer.from(metadataToXml(metadata, schema), "utf8");
1693
+ async function* entries() {
1694
+ let replaced = false;
1695
+ for await (const entry of adapter.listEntries(input, { tempDir: options.tempDir })) {
1696
+ if (entry.path.split("/").pop() === fileName) {
1697
+ if (!options.overwrite) throw new ArchiveFormatError(`Archive already contains ${fileName}; pass { overwrite: true } to replace it.`);
1698
+ replaced = true;
1699
+ yield {
1700
+ path: entry.path,
1701
+ size: xmlBuffer.length,
1702
+ content: Readable.from(xmlBuffer)
1703
+ };
1704
+ continue;
1705
+ }
1706
+ yield {
1707
+ path: entry.path,
1708
+ size: entry.size,
1709
+ content: entry.openReadStream()
1710
+ };
1711
+ }
1712
+ if (!replaced) yield {
1713
+ path: fileName,
1714
+ size: xmlBuffer.length,
1715
+ content: Readable.from(xmlBuffer)
1716
+ };
1717
+ }
1718
+ return withOutput(options.output, (destination) => adapter.write(entries(), destination, { tempDir: options.tempDir }));
1719
+ }
1720
+ //#endregion
1721
+ //#region src/images/phash.ts
1722
+ const HASH_SIZE = 32;
1723
+ const BLOCK_SIZE = 8;
1724
+ function dct1d(vector) {
1725
+ const n = vector.length;
1726
+ const result = Array.from({ length: n }, () => 0);
1727
+ for (let k = 0; k < n; k++) {
1728
+ let sum = 0;
1729
+ for (let i = 0; i < n; i++) sum += vector[i] * Math.cos(Math.PI / n * (i + .5) * k);
1730
+ result[k] = sum;
1731
+ }
1732
+ return result;
1733
+ }
1734
+ function dct2d(matrix) {
1735
+ const size = matrix.length;
1736
+ const rowsTransformed = matrix.map(dct1d);
1737
+ const result = Array.from({ length: size }, () => Array.from({ length: size }, () => 0));
1738
+ for (let col = 0; col < size; col++) {
1739
+ const transformed = dct1d(rowsTransformed.map((row) => row[col]));
1740
+ for (let row = 0; row < size; row++) result[row][col] = transformed[row];
1741
+ }
1742
+ return result;
1743
+ }
1744
+ function median(values) {
1745
+ const sorted = [...values].sort((a, b) => a - b);
1746
+ const mid = Math.floor(sorted.length / 2);
1747
+ return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
1748
+ }
1749
+ /**
1750
+ * Standard 8x8 DCT perceptual hash: resize to 32x32 greyscale, run a 2D
1751
+ * DCT-II, threshold the top-left 8x8 block against the median of that block
1752
+ * (excluding the DC term at [0][0] from the median calculation, though it is
1753
+ * still included positionally as bit 0). Returned as a bigint since JS
1754
+ * `Number` cannot losslessly represent all 64-bit patterns.
1755
+ */
1756
+ async function computeImagePHash(image) {
1757
+ const pixels = await sharp(image).resize(HASH_SIZE, HASH_SIZE, { fit: "fill" }).greyscale().raw().toBuffer();
1758
+ const matrix = [];
1759
+ for (let y = 0; y < HASH_SIZE; y++) {
1760
+ const row = [];
1761
+ for (let x = 0; x < HASH_SIZE; x++) row.push(pixels[y * HASH_SIZE + x]);
1762
+ matrix.push(row);
1763
+ }
1764
+ const dct = dct2d(matrix);
1765
+ const block = [];
1766
+ for (let y = 0; y < BLOCK_SIZE; y++) for (let x = 0; x < BLOCK_SIZE; x++) block.push(dct[y][x]);
1767
+ const threshold = median(block.slice(1));
1768
+ let hash = 0n;
1769
+ for (const value of block) hash = hash << 1n | (value > threshold ? 1n : 0n);
1770
+ return hash;
1771
+ }
1772
+ function phashToHex(hash) {
1773
+ return hash.toString(16).padStart(16, "0");
1774
+ }
1775
+ function hammingDistance(a, b) {
1776
+ let diff = a ^ b;
1777
+ let count = 0;
1778
+ while (diff > 0n) {
1779
+ count += Number(diff & 1n);
1780
+ diff >>= 1n;
1781
+ }
1782
+ return count;
1783
+ }
1784
+ async function computeArchiveImagePHash(input, entryPath) {
1785
+ const adapter = getAdapter(await detectArchiveType(input));
1786
+ for await (const entry of adapter.listEntries(input)) if (entry.path === entryPath) return computeImagePHash(await streamToBuffer(entry.openReadStream()));
1787
+ throw new MetadataNotFoundError(`No entry named "${entryPath}" was found in the archive.`);
1788
+ }
1789
+ //#endregion
1790
+ //#region src/hashing/asarContent.ts
1791
+ /**
1792
+ * Locates the byte range of an asar archive's content region (everything
1793
+ * after the header), so it can be hashed without the header/index — meaning
1794
+ * header-only changes (e.g. re-ordering the file index) never change the
1795
+ * content hash.
1796
+ */
1797
+ async function locateAsarContentRegion(input, totalSize) {
1798
+ const { contentOffset } = await parseAsarHeader((start, end) => readInputRange(input, start, end));
1799
+ return {
1800
+ start: contentOffset,
1801
+ end: totalSize
1802
+ };
1803
+ }
1804
+ //#endregion
1805
+ //#region src/hashing/sha256.ts
1806
+ async function hashStream(stream) {
1807
+ const hash = createHash("sha256");
1808
+ for await (const chunk of stream) hash.update(chunk);
1809
+ return hash.digest("hex");
1810
+ }
1811
+ async function sha256ArchiveEntry(input, entryPath) {
1812
+ const adapter = getAdapter(await detectArchiveType(input));
1813
+ for await (const entry of adapter.listEntries(input)) if (entry.path === entryPath) return hashStream(entry.openReadStream());
1814
+ throw new MetadataNotFoundError(`No entry named "${entryPath}" was found in the archive.`);
1815
+ }
1816
+ /**
1817
+ * SHA256 of the whole archive file — except for asar, where only the
1818
+ * content region (bytes after the header) is hashed, so header/index
1819
+ * reordering never changes the content hash.
1820
+ */
1821
+ async function sha256Archive(input) {
1822
+ if (await detectArchiveType(input) === "asar") {
1823
+ const { start, end } = await locateAsarContentRegion(input, await inputSize(input));
1824
+ return hashStream(openInputReadStream(input, {
1825
+ start,
1826
+ end
1827
+ }));
1828
+ }
1829
+ return hashStream(openInputReadStream(input));
1830
+ }
1831
+ //#endregion
1832
+ //#region src/internal/naturalSort.ts
1833
+ function naturalCompare(a, b) {
1834
+ return a.localeCompare(b, void 0, {
1835
+ numeric: true,
1836
+ sensitivity: "base"
1837
+ });
1838
+ }
1839
+ //#endregion
1840
+ //#region src/rename.ts
1841
+ /**
1842
+ * Renames image entries to the standard `P#####` pattern in natural sort
1843
+ * order of their current names, leaving non-image entries (like
1844
+ * ComicInfo.xml/MetronInfo.xml) untouched.
1845
+ *
1846
+ * This requires two passes over the archive: `listEntries` is called once to
1847
+ * collect image paths (metadata only — no content is read, so no
1848
+ * decompression work is wasted) to compute the sort-order rename map, then
1849
+ * called again to stream entries out under their new names. Streaming
1850
+ * adapters (zip in particular) can't be "rewound" mid-read, so the second
1851
+ * pass re-invokes `listEntries` from the start rather than reusing entry
1852
+ * objects collected in the first pass.
1853
+ */
1854
+ async function renameArchiveImagesSequentially(input, options = {}) {
1855
+ const adapter = getAdapter(await detectArchiveType(input));
1856
+ const pad = options.pad ?? 5;
1857
+ const start = options.start ?? 1;
1858
+ const imagePaths = [];
1859
+ for await (const entry of adapter.listEntries(input, { tempDir: options.tempDir })) if (isImagePath(entry.path)) imagePaths.push(entry.path);
1860
+ imagePaths.sort(naturalCompare);
1861
+ const renameMap = /* @__PURE__ */ new Map();
1862
+ imagePaths.forEach((entryPath, index) => {
1863
+ const ext = getExtension(entryPath);
1864
+ renameMap.set(entryPath, `P${String(start + index).padStart(pad, "0")}.${ext}`);
1865
+ });
1866
+ async function* output() {
1867
+ for await (const entry of adapter.listEntries(input, { tempDir: options.tempDir })) yield {
1868
+ path: renameMap.get(entry.path) ?? entry.path,
1869
+ size: entry.size,
1870
+ content: entry.openReadStream()
1871
+ };
1872
+ }
1873
+ return withOutput(options.output, (destination) => adapter.write(output(), destination, { tempDir: options.tempDir }));
1874
+ }
1875
+ //#endregion
1876
+ //#region src/strip.ts
1877
+ async function stripNonEssentialFiles(input, options = {}) {
1878
+ const adapter = getAdapter(await detectArchiveType(input));
1879
+ const keepExtensions = new Set([
1880
+ ...IMAGE_EXTENSIONS,
1881
+ "xml",
1882
+ ...options.extraKeepExtensions ?? []
1883
+ ].map((ext) => ext.toLowerCase()));
1884
+ async function* output() {
1885
+ for await (const entry of adapter.listEntries(input, { tempDir: options.tempDir })) {
1886
+ if (!keepExtensions.has(getExtension(entry.path))) continue;
1887
+ yield {
1888
+ path: entry.path,
1889
+ size: entry.size,
1890
+ content: entry.openReadStream()
1891
+ };
1892
+ }
1893
+ }
1894
+ return withOutput(options.output, (destination) => adapter.write(output(), destination, { tempDir: options.tempDir }));
1895
+ }
1896
+ //#endregion
1897
+ //#region src/listFiles.ts
1898
+ async function listArchiveFiles(input) {
1899
+ const adapter = getAdapter(await detectArchiveType(input));
1900
+ const paths = [];
1901
+ for await (const entry of adapter.listEntries(input)) paths.push(entry.path);
1902
+ return paths;
1903
+ }
1904
+ //#endregion
1905
+ //#region src/benchmark/timing.ts
1906
+ /** Runs `run` `iterations` times and returns the average duration in milliseconds. */
1907
+ async function averageDuration(iterations, run) {
1908
+ if (iterations <= 0) return 0;
1909
+ let total = 0;
1910
+ for (let i = 0; i < iterations; i++) {
1911
+ const start = performance.now();
1912
+ await run();
1913
+ total += performance.now() - start;
1914
+ }
1915
+ return total / iterations;
1916
+ }
1917
+ /** Times `run` once per item and returns the average duration in milliseconds. */
1918
+ async function averageDurationOverItems(items, run) {
1919
+ if (items.length === 0) return 0;
1920
+ let total = 0;
1921
+ for (const item of items) {
1922
+ const start = performance.now();
1923
+ await run(item);
1924
+ total += performance.now() - start;
1925
+ }
1926
+ return total / items.length;
1927
+ }
1928
+ //#endregion
1929
+ //#region src/benchmark/report.ts
1930
+ function formatBytes(bytes) {
1931
+ if (bytes < 1024) return `${Math.round(bytes)} B`;
1932
+ const units = [
1933
+ "KB",
1934
+ "MB",
1935
+ "GB"
1936
+ ];
1937
+ let value = bytes / 1024;
1938
+ let unitIndex = 0;
1939
+ while (value >= 1024 && unitIndex < units.length - 1) {
1940
+ value /= 1024;
1941
+ unitIndex += 1;
1942
+ }
1943
+ return `${value.toFixed(2)} ${units[unitIndex]}`;
1944
+ }
1945
+ function formatMs(ms) {
1946
+ return `${ms.toFixed(2)} ms`;
1947
+ }
1948
+ function topThree(variants, key) {
1949
+ return [...variants].sort((a, b) => key(a) - key(b)).slice(0, 3);
1950
+ }
1951
+ /** Ranks by image format rather than by variant: transfer size depends only on image format, not container choice. */
1952
+ function topThreeImageFormats(variants) {
1953
+ const seenFormats = /* @__PURE__ */ new Set();
1954
+ return [...variants].sort((a, b) => a.avgImageSizeBytes - b.avgImageSizeBytes).filter((variant) => {
1955
+ if (seenFormats.has(variant.imageFormat)) return false;
1956
+ seenFormats.add(variant.imageFormat);
1957
+ return true;
1958
+ }).slice(0, 3);
1959
+ }
1960
+ function renderRankedSection(title, description, ranked, format) {
1961
+ return `### ${title}\n\n${description}\n\n${ranked.map((variant, index) => `${index + 1}. **${variant.fileName}** (${variant.archiveType}/${variant.imageFormat}) — ${format(variant)}`).join("\n")}\n`;
1962
+ }
1963
+ function renderImageFormatRankedSection(title, description, ranked) {
1964
+ return `### ${title}\n\n${description}\n\n${ranked.map((variant, index) => `${index + 1}. **${variant.imageFormat}** — ${formatBytes(variant.avgImageSizeBytes)} avg. per page`).join("\n")}\n`;
1965
+ }
1966
+ function renderBenchmarkReportMarkdown(result) {
1967
+ const { variants } = result;
1968
+ const tableHeader = "| Archive | Image | File | Storage size | Avg. page size | Pages | Avg. creation time | Avg. random read time |\n| --- | --- | --- | --- | --- | --- | --- | --- |";
1969
+ const tableRows = variants.map((variant) => `| ${variant.archiveType} | ${variant.imageFormat} | \`${variant.fileName}\` | ${formatBytes(variant.fileSizeBytes)} | ${formatBytes(variant.avgImageSizeBytes)} | ${variant.pageCount} | ${formatMs(variant.avgCreationMs)} | ${formatMs(variant.avgSeekMs)} |`).join("\n");
1970
+ const readSpeed = renderRankedSection("Read speed", "Fastest average random single-file read time — best for serving individual pages on demand (e.g. a remote reader).", topThree(variants, (variant) => variant.avgSeekMs), (variant) => formatMs(variant.avgSeekMs));
1971
+ const storageSize = renderRankedSection("Storage size", "Smallest resulting archive on disk — best when total storage footprint is the priority.", topThree(variants, (variant) => variant.fileSizeBytes), (variant) => formatBytes(variant.fileSizeBytes));
1972
+ const transferSize = renderImageFormatRankedSection("Transfer size", "Smallest average individual page — best when the cost of sending a single page over the network is the priority (e.g. a client fetching one page at a time). Depends only on image format, not container choice.", topThreeImageFormats(variants));
1973
+ const creationSpeed = renderRankedSection("Creation speed", "Fastest average archive creation time — best when generating or converting archives on the fly.", topThree(variants, (variant) => variant.avgCreationMs), (variant) => formatMs(variant.avgCreationMs));
1974
+ return [
1975
+ "# Archive Benchmark Report",
1976
+ "",
1977
+ `- Source: \`${result.sourcePath}\``,
1978
+ `- Generated: ${result.generatedAt}`,
1979
+ `- Variants: ${variants.length}`,
1980
+ "",
1981
+ "## Results",
1982
+ "",
1983
+ tableHeader,
1984
+ tableRows,
1985
+ "",
1986
+ "## Summary",
1987
+ "",
1988
+ readSpeed,
1989
+ storageSize,
1990
+ transferSize,
1991
+ creationSpeed
1992
+ ].join("\n");
1993
+ }
1994
+ //#endregion
1995
+ //#region src/benchmark/types.ts
1996
+ const WRITABLE_ARCHIVE_TYPES = [
1997
+ "zip",
1998
+ "tar",
1999
+ "asar",
2000
+ "7z"
2001
+ ];
2002
+ const BENCHMARK_IMAGE_FORMATS = [
2003
+ "webp",
2004
+ "png",
2005
+ "jpg"
2006
+ ];
2007
+ /** Conventional comic archive extension for each writable container format. */
2008
+ const ARCHIVE_TYPE_EXTENSIONS = {
2009
+ zip: "cbz",
2010
+ tar: "cbt",
2011
+ asar: "cbas",
2012
+ "7z": "cb7"
2013
+ };
2014
+ //#endregion
2015
+ //#region src/benchmark/benchmarkArchive.ts
2016
+ function timestampForDirName(date) {
2017
+ return date.toISOString().replace(/:/g, "-").replace(/\.\d+Z$/, "Z");
2018
+ }
2019
+ function pickRandomSamples(items, count) {
2020
+ if (items.length === 0) return [];
2021
+ const samples = [];
2022
+ for (let i = 0; i < count; i++) samples.push(items[Math.floor(Math.random() * items.length)]);
2023
+ return samples;
2024
+ }
2025
+ /**
2026
+ * The average byte size of one converted image, independent of which
2027
+ * container it ends up packaged in ("transfer size" — what a client
2028
+ * actually downloads to fetch a single page).
2029
+ */
2030
+ async function averageConvertedImageSize(preConverted, imageFormat, tempDir) {
2031
+ const extractDir = path.join(tempDir, `preconverted-${imageFormat}`);
2032
+ const imagePaths = (await extractArchive(preConverted, extractDir, { tempDir })).filter((entryPath) => isImagePath(entryPath));
2033
+ let totalBytes = 0;
2034
+ for (const entryPath of imagePaths) {
2035
+ const { size } = await fsp.stat(path.join(extractDir, entryPath));
2036
+ totalBytes += size;
2037
+ }
2038
+ return totalBytes / imagePaths.length;
2039
+ }
2040
+ /**
2041
+ * Extracts a comic archive, validates it contains image files, then
2042
+ * generates one archive per (writable container format) x (page image
2043
+ * format) combination, benchmarks each variant's creation speed and random
2044
+ * single-file read speed, and writes a markdown report summarizing the
2045
+ * results.
2046
+ *
2047
+ * Images are re-encoded to each target format once per image format, before
2048
+ * any timing starts; `avgCreationMs` measures only packaging already-encoded
2049
+ * entries into the target container, not the image re-encoding cost.
2050
+ *
2051
+ * Throws `ArchiveFormatError` (undetectable format) or
2052
+ * `UnsupportedOperationError` (ACE) if `filePath` is not extractable,
2053
+ * `NoImagesFoundError` if the archive contains no image files, and
2054
+ * `RangeError` if `options.imageFormats` is empty or names an unsupported
2055
+ * format.
2056
+ */
2057
+ async function benchmarkArchive(filePath, options = {}) {
2058
+ try {
2059
+ await fsp.access(filePath);
2060
+ } catch {
2061
+ throw new FilesystemAccessError(`No file exists at "${filePath}".`);
2062
+ }
2063
+ const creationIterations = Math.max(1, options.creationIterations ?? 3);
2064
+ const seekSamples = Math.max(1, options.seekSamples ?? 10);
2065
+ const imageFormats = options.imageFormats ?? BENCHMARK_IMAGE_FORMATS;
2066
+ if (imageFormats.length === 0) throw new RangeError("options.imageFormats must include at least one image format.");
2067
+ const unsupportedFormats = imageFormats.filter((format) => !BENCHMARK_IMAGE_FORMATS.includes(format));
2068
+ if (unsupportedFormats.length > 0) throw new RangeError(`Unsupported image format(s): ${unsupportedFormats.join(", ")}. Supported formats: ${BENCHMARK_IMAGE_FORMATS.join(", ")}.`);
2069
+ const tempDir = await resolveWritableTempDir(options.tempDir);
2070
+ try {
2071
+ const generatedAt = /* @__PURE__ */ new Date();
2072
+ const reportsRoot = options.reportsDir ?? path.join(process.cwd(), "reports");
2073
+ const reportDir = path.join(reportsRoot, timestampForDirName(generatedAt));
2074
+ const sourceDir = path.join(reportDir, "source");
2075
+ const archivesDir = path.join(reportDir, "archives");
2076
+ await fsp.mkdir(archivesDir, { recursive: true });
2077
+ if (!(await extractArchive(filePath, sourceDir, { tempDir })).some((entryPath) => isImagePath(entryPath))) throw new NoImagesFoundError(`Archive "${filePath}" does not contain any image files.`);
2078
+ const stripped = await stripNonEssentialFiles(filePath, { tempDir });
2079
+ const variants = [];
2080
+ for (const imageFormat of imageFormats) {
2081
+ const preConverted = await convertArchiveImages(stripped, imageFormat, {
2082
+ ...options.image,
2083
+ tempDir
2084
+ });
2085
+ const avgImageSizeBytes = await averageConvertedImageSize(preConverted, imageFormat, tempDir);
2086
+ for (const archiveType of WRITABLE_ARCHIVE_TYPES) {
2087
+ const fileName = `${archiveType}-${imageFormat}.${ARCHIVE_TYPE_EXTENSIONS[archiveType]}`;
2088
+ const outputPath = path.join(archivesDir, fileName);
2089
+ const avgCreationMs = await averageDuration(creationIterations, async () => {
2090
+ await convertArchive(preConverted, archiveType, {
2091
+ tempDir,
2092
+ output: outputPath
2093
+ });
2094
+ });
2095
+ const imageEntries = (await listArchiveFiles(outputPath)).filter((entryPath) => isImagePath(entryPath));
2096
+ const avgSeekMs = await averageDurationOverItems(pickRandomSamples(imageEntries, seekSamples), async (entryPath) => {
2097
+ await sha256ArchiveEntry(outputPath, entryPath);
2098
+ });
2099
+ const { size } = await fsp.stat(outputPath);
2100
+ variants.push({
2101
+ archiveType,
2102
+ imageFormat,
2103
+ fileName,
2104
+ filePath: outputPath,
2105
+ fileSizeBytes: size,
2106
+ pageCount: imageEntries.length,
2107
+ avgImageSizeBytes,
2108
+ avgCreationMs,
2109
+ avgSeekMs
2110
+ });
2111
+ }
2112
+ }
2113
+ const result = {
2114
+ sourcePath: filePath,
2115
+ generatedAt: generatedAt.toISOString(),
2116
+ reportDir,
2117
+ reportPath: path.join(reportDir, "report.md"),
2118
+ sourceDir,
2119
+ archivesDir,
2120
+ variants
2121
+ };
2122
+ await fsp.writeFile(result.reportPath, renderBenchmarkReportMarkdown(result), "utf8");
2123
+ return result;
2124
+ } finally {
2125
+ await cleanupTempDir(tempDir);
2126
+ }
2127
+ }
2128
+ //#endregion
2129
+ //#region src/index.ts
2130
+ const comicArchiveHandler = {
2131
+ ...detect_exports,
2132
+ convertArchive,
2133
+ extractArchive,
2134
+ ...metadata_exports,
2135
+ convertImageBuffer,
2136
+ convertArchiveImages,
2137
+ computeImagePHash,
2138
+ phashToHex,
2139
+ computeArchiveImagePHash,
2140
+ hammingDistance,
2141
+ IMAGE_EXTENSIONS,
2142
+ isImagePath,
2143
+ sha256ArchiveEntry,
2144
+ sha256Archive,
2145
+ renameArchiveImagesSequentially,
2146
+ stripNonEssentialFiles,
2147
+ listArchiveFiles,
2148
+ benchmarkArchive,
2149
+ renderBenchmarkReportMarkdown,
2150
+ WRITABLE_ARCHIVE_TYPES,
2151
+ BENCHMARK_IMAGE_FORMATS,
2152
+ ARCHIVE_TYPE_EXTENSIONS
2153
+ };
2154
+ //#endregion
2155
+ export { ARCHIVE_TYPE_EXTENSIONS, ArchiveFormatError, BENCHMARK_IMAGE_FORMATS, COMIC_INFO_AGE_RATING_VALUES, COMIC_INFO_CREDIT_ROLES, COMIC_INFO_MANGA_VALUES, COMIC_INFO_PAGE_TYPE_VALUES, COMIC_INFO_YES_NO_VALUES, FilesystemAccessError, IMAGE_EXTENSIONS, METRON_AGE_RATING_VALUES, METRON_FORMAT_VALUES, METRON_INFORMATION_SOURCE_VALUES, METRON_ROLE_VALUES, MetadataNotFoundError, NoImagesFoundError, SevenZipUnavailableError, UnsupportedOperationError, WRITABLE_ARCHIVE_TYPES, addMetadataToArchive, benchmarkArchive, comicInfoXmlToMetadata, computeArchiveImagePHash, computeImagePHash, convertArchive, convertArchiveImages, convertImageBuffer, comicArchiveHandler as default, detectArchiveType, extractArchive, getExtension, hammingDistance, hasComicMetadata, is7z, isAce, isAsar, isImagePath, isRar, isTar, isZip, joinCommaList, joinResourceNames, listArchiveFiles, metadataToComicInfoXml, metadataToMetronInfoXml, metadataToXml, metronInfoXmlToMetadata, phashToHex, readArchiveMetadata, renameArchiveImagesSequentially, renderBenchmarkReportMarkdown, resourceId, resourceName, sha256Archive, sha256ArchiveEntry, splitCommaList, stripNonEssentialFiles, validateMetadataXml, xmlToMetadata };
2156
+
2157
+ //# sourceMappingURL=index.mjs.map