@clearmist-labs/comic-archive-handler 1.3.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/index.d.mts +50 -16
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +282 -139
- package/dist/index.mjs.map +1 -1
- package/docs/API.md +46 -11
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -14,10 +14,10 @@ import * as os from "node:os";
|
|
|
14
14
|
import { spawn } from "node:child_process";
|
|
15
15
|
import sevenBin from "7zip-bin-full";
|
|
16
16
|
import sharp from "sharp";
|
|
17
|
-
import { pipeline } from "node:stream/promises";
|
|
18
17
|
import { XMLBuilder, XMLParser } from "fast-xml-parser";
|
|
19
18
|
import { validateXML } from "xmllint-wasm";
|
|
20
19
|
import { fileURLToPath } from "node:url";
|
|
20
|
+
import { pipeline } from "node:stream/promises";
|
|
21
21
|
import { createHash } from "node:crypto";
|
|
22
22
|
//#region src/errors.ts
|
|
23
23
|
var UnsupportedOperationError = class extends Error {
|
|
@@ -90,6 +90,35 @@ function openInputReadStream(input, range) {
|
|
|
90
90
|
return Readable.from(slice);
|
|
91
91
|
}
|
|
92
92
|
//#endregion
|
|
93
|
+
//#region src/internal/streamUtils.ts
|
|
94
|
+
async function streamToBuffer(stream) {
|
|
95
|
+
const chunks = [];
|
|
96
|
+
for await (const chunk of stream) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
97
|
+
return Buffer.concat(chunks);
|
|
98
|
+
}
|
|
99
|
+
//#endregion
|
|
100
|
+
//#region src/internal/collectOutput.ts
|
|
101
|
+
/**
|
|
102
|
+
* Runs `run` against a destination stream. When `output` is given (a path or
|
|
103
|
+
* a Writable), the result streams directly there and this resolves to
|
|
104
|
+
* `undefined`. Otherwise the result is collected into a Buffer for
|
|
105
|
+
* convenience.
|
|
106
|
+
*/
|
|
107
|
+
async function withOutput(output, run) {
|
|
108
|
+
if (typeof output === "string") {
|
|
109
|
+
await run(fs.createWriteStream(output));
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (output) {
|
|
113
|
+
await run(output);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const pass = new PassThrough();
|
|
117
|
+
const bufferPromise = streamToBuffer(pass);
|
|
118
|
+
await run(pass);
|
|
119
|
+
return bufferPromise;
|
|
120
|
+
}
|
|
121
|
+
//#endregion
|
|
93
122
|
//#region src/internal/asarHeader.ts
|
|
94
123
|
/**
|
|
95
124
|
* Parses an asar archive's header from a byte reader.
|
|
@@ -138,10 +167,54 @@ function collectFiles(node, prefix, out) {
|
|
|
138
167
|
});
|
|
139
168
|
}
|
|
140
169
|
}
|
|
141
|
-
/**
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
170
|
+
/**
|
|
171
|
+
* Serializes a header object back to the on-disk byte layout `parseAsarHeader`
|
|
172
|
+
* decodes: the exact inverse, byte for byte (outer 8-byte pickle wrapping a
|
|
173
|
+
* uint32 `size`, then a header pickle of [4-byte ignored length][4-byte
|
|
174
|
+
* string byte-length][UTF-8 JSON][zero-padding to a 4-byte boundary]).
|
|
175
|
+
*/
|
|
176
|
+
function serializeAsarHeader(header) {
|
|
177
|
+
const jsonBytes = Buffer.from(JSON.stringify(header), "utf8");
|
|
178
|
+
const stringLength = jsonBytes.length;
|
|
179
|
+
const padding = (4 - stringLength % 4) % 4;
|
|
180
|
+
const payload = Buffer.concat([
|
|
181
|
+
uint32LE(stringLength),
|
|
182
|
+
jsonBytes,
|
|
183
|
+
Buffer.alloc(padding)
|
|
184
|
+
]);
|
|
185
|
+
const headerPickle = Buffer.concat([uint32LE(payload.length), payload]);
|
|
186
|
+
const outerPrefix = Buffer.concat([uint32LE(4), uint32LE(headerPickle.length)]);
|
|
187
|
+
return Buffer.concat([outerPrefix, headerPickle]);
|
|
188
|
+
}
|
|
189
|
+
function uint32LE(value) {
|
|
190
|
+
const buf = Buffer.alloc(4);
|
|
191
|
+
buf.writeUInt32LE(value, 0);
|
|
192
|
+
return buf;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Reads an asar archive's header, applies `mutate` to a shallow clone of it,
|
|
196
|
+
* and re-serializes the whole archive with the patched header followed by
|
|
197
|
+
* the original, byte-for-byte unchanged content region. No repackaging via
|
|
198
|
+
* `@electron/asar`'s `createPackage` needed, since file offsets are already
|
|
199
|
+
* relative to the content region's start and never need adjusting when the
|
|
200
|
+
* header's byte length changes.
|
|
201
|
+
*/
|
|
202
|
+
async function writeAsarHeaderPatch(input, mutate, options = {}) {
|
|
203
|
+
const { header, contentOffset } = await parseAsarHeader((start, end) => readInputRange(input, start, end));
|
|
204
|
+
const clone = { ...header };
|
|
205
|
+
mutate(clone);
|
|
206
|
+
const newHeaderBytes = serializeAsarHeader(clone);
|
|
207
|
+
const contentStream = openInputReadStream(input, {
|
|
208
|
+
start: contentOffset,
|
|
209
|
+
end: await inputSize(input)
|
|
210
|
+
});
|
|
211
|
+
return withOutput(options.output, (destination) => new Promise((resolve, reject) => {
|
|
212
|
+
contentStream.on("error", reject);
|
|
213
|
+
destination.on("error", reject);
|
|
214
|
+
destination.on("finish", resolve);
|
|
215
|
+
destination.write(newHeaderBytes);
|
|
216
|
+
contentStream.pipe(destination);
|
|
217
|
+
}));
|
|
145
218
|
}
|
|
146
219
|
//#endregion
|
|
147
220
|
//#region src/detect.ts
|
|
@@ -273,8 +346,8 @@ function readZip64ExtraField(extra, overflowed) {
|
|
|
273
346
|
throw new ArchiveFormatError("Not a valid zip64 archive: an oversized entry is missing its zip64 extra field.");
|
|
274
347
|
}
|
|
275
348
|
/**
|
|
276
|
-
* Parses a zip's central directory
|
|
277
|
-
* file
|
|
349
|
+
* Parses a zip's central directory, the entry index at the end of the
|
|
350
|
+
* file, into per-entry metadata (name, sizes, compression method, and the
|
|
278
351
|
* offset of its local file header). Reading this index costs a couple of
|
|
279
352
|
* small range reads regardless of archive size; it never reads or
|
|
280
353
|
* decompresses entry data itself, which is why entries can then be read in
|
|
@@ -324,7 +397,7 @@ async function parseZipCentralDirectory(input) {
|
|
|
324
397
|
}
|
|
325
398
|
/**
|
|
326
399
|
* Reads and decompresses one entry's data, using its central directory
|
|
327
|
-
* metadata to locate the bytes directly
|
|
400
|
+
* metadata to locate the bytes directly, with no scan through other entries.
|
|
328
401
|
* The local file header still has to be read first because its name/extra
|
|
329
402
|
* field lengths (which can differ from the central directory's) are what
|
|
330
403
|
* determine where the entry's actual data starts.
|
|
@@ -384,20 +457,13 @@ const zipAdapter = {
|
|
|
384
457
|
}
|
|
385
458
|
};
|
|
386
459
|
//#endregion
|
|
387
|
-
//#region src/internal/streamUtils.ts
|
|
388
|
-
async function streamToBuffer(stream) {
|
|
389
|
-
const chunks = [];
|
|
390
|
-
for await (const chunk of stream) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
391
|
-
return Buffer.concat(chunks);
|
|
392
|
-
}
|
|
393
|
-
//#endregion
|
|
394
460
|
//#region src/archive/tar.ts
|
|
395
461
|
/**
|
|
396
462
|
* The tar-stream v3 package is built on `streamx`, not Node's native streams;
|
|
397
463
|
* its `extract()` result is directly async-iterable over per-entry streams, and
|
|
398
464
|
* both directions interop with Node streams via `.pipe()`. Each entry's
|
|
399
|
-
* content is buffered fully before being yielded
|
|
400
|
-
* entry's size (one comic page), not the whole archive
|
|
465
|
+
* content is buffered fully before being yielded, bounded by a single
|
|
466
|
+
* entry's size (one comic page), not the whole archive, since the
|
|
401
467
|
* underlying iterator only advances once the current entry is drained,
|
|
402
468
|
* which doesn't reconcile with this package's lazily-pulled
|
|
403
469
|
* `ArchiveEntry.openReadStream()` contract.
|
|
@@ -535,7 +601,7 @@ function resolveSafeEntryPath(root, entryPath) {
|
|
|
535
601
|
/**
|
|
536
602
|
* Reads bypass @electron/asar's extract API entirely: the header is parsed
|
|
537
603
|
* directly (src/internal/asarHeader.ts) to get each file's byte offset/size,
|
|
538
|
-
* then entries are read via direct byte-range reads against the archive
|
|
604
|
+
* then entries are read via direct byte-range reads against the archive;
|
|
539
605
|
* no extraction step, no temp directory, for reads.
|
|
540
606
|
*
|
|
541
607
|
* Writes still require a staging directory, since @electron/asar's
|
|
@@ -578,6 +644,13 @@ const asarAdapter = {
|
|
|
578
644
|
}
|
|
579
645
|
const outputFile = path.join(tempDir, "output.asar");
|
|
580
646
|
await createPackage(stagingDir, outputFile);
|
|
647
|
+
const comicMetadata = options?.comicMetadata;
|
|
648
|
+
if (comicMetadata && Object.keys(comicMetadata).length > 0) {
|
|
649
|
+
await writeAsarHeaderPatch(outputFile, (header) => {
|
|
650
|
+
header.comicMetadata = comicMetadata;
|
|
651
|
+
}, { output: destination });
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
581
654
|
await new Promise((resolve, reject) => {
|
|
582
655
|
const readStream = fs.createReadStream(outputFile);
|
|
583
656
|
readStream.on("error", reject);
|
|
@@ -612,8 +685,8 @@ async function ensureBinaryAvailable() {
|
|
|
612
685
|
* going through the `node-7z` wrapper: that package has no way to set the
|
|
613
686
|
* child process's working directory, which is required here to get archive
|
|
614
687
|
* entries stored with paths relative to the staging directory (rather than
|
|
615
|
-
* either leaking absolute host paths
|
|
616
|
-
* implementation testing
|
|
688
|
+
* either leaking absolute host paths or, as discovered during
|
|
689
|
+
* implementation testing, silently operating against this process's actual
|
|
617
690
|
* cwd instead of the intended staging directory).
|
|
618
691
|
*/
|
|
619
692
|
function run7z(binPath, args, options) {
|
|
@@ -741,28 +814,6 @@ function getAdapter(type) {
|
|
|
741
814
|
return adapter;
|
|
742
815
|
}
|
|
743
816
|
//#endregion
|
|
744
|
-
//#region src/internal/collectOutput.ts
|
|
745
|
-
/**
|
|
746
|
-
* Runs `run` against a destination stream. When `output` is given (a path or
|
|
747
|
-
* a Writable), the result streams directly there and this resolves to
|
|
748
|
-
* `undefined`. Otherwise the result is collected into a Buffer for
|
|
749
|
-
* convenience.
|
|
750
|
-
*/
|
|
751
|
-
async function withOutput(output, run) {
|
|
752
|
-
if (typeof output === "string") {
|
|
753
|
-
await run(fs.createWriteStream(output));
|
|
754
|
-
return;
|
|
755
|
-
}
|
|
756
|
-
if (output) {
|
|
757
|
-
await run(output);
|
|
758
|
-
return;
|
|
759
|
-
}
|
|
760
|
-
const pass = new PassThrough();
|
|
761
|
-
const bufferPromise = streamToBuffer(pass);
|
|
762
|
-
await run(pass);
|
|
763
|
-
return bufferPromise;
|
|
764
|
-
}
|
|
765
|
-
//#endregion
|
|
766
817
|
//#region src/images/isImage.ts
|
|
767
818
|
const IMAGE_EXTENSIONS = [
|
|
768
819
|
"jpg",
|
|
@@ -829,66 +880,40 @@ async function convertArchiveImages(input, format, options = {}) {
|
|
|
829
880
|
return withOutput(output, (destination) => adapter.write(entries(), destination, { tempDir }));
|
|
830
881
|
}
|
|
831
882
|
//#endregion
|
|
832
|
-
//#region src/
|
|
833
|
-
function
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
const converted = await convertImageBuffer(await streamToBuffer(entry.openReadStream()), image.format, image.options);
|
|
883
|
+
//#region src/removeArchiveEntry.ts
|
|
884
|
+
async function removeArchiveEntry(input, entryPath, options = {}) {
|
|
885
|
+
const adapter = getAdapter(await detectArchiveType(input));
|
|
886
|
+
let found = false;
|
|
887
|
+
for await (const entry of adapter.listEntries(input, { tempDir: options.tempDir })) if (entry.path === entryPath) {
|
|
888
|
+
found = true;
|
|
889
|
+
break;
|
|
890
|
+
}
|
|
891
|
+
if (!found) throw new MetadataNotFoundError(`No entry named "${entryPath}" was found in the archive.`);
|
|
892
|
+
async function* output() {
|
|
893
|
+
for await (const entry of adapter.listEntries(input, { tempDir: options.tempDir })) {
|
|
894
|
+
if (entry.path === entryPath) continue;
|
|
845
895
|
yield {
|
|
846
|
-
path:
|
|
847
|
-
size:
|
|
848
|
-
content:
|
|
896
|
+
path: entry.path,
|
|
897
|
+
size: entry.size,
|
|
898
|
+
content: entry.openReadStream()
|
|
849
899
|
};
|
|
850
|
-
}
|
|
851
|
-
path: entry.path,
|
|
852
|
-
size: entry.size,
|
|
853
|
-
content: entry.openReadStream()
|
|
854
|
-
};
|
|
855
|
-
}
|
|
856
|
-
return withOutput(options.output, (destination) => targetAdapter.write(entries(), destination, { tempDir: options.tempDir }));
|
|
857
|
-
}
|
|
858
|
-
//#endregion
|
|
859
|
-
//#region src/extractArchive.ts
|
|
860
|
-
/**
|
|
861
|
-
* Extracts every entry in an archive to real files under `destDir`,
|
|
862
|
-
* preserving relative paths (`destDir` is created if missing). Throws
|
|
863
|
-
* `ArchiveFormatError` for an undetectable/unknown format, or
|
|
864
|
-
* `UnsupportedOperationError` for ACE. Returns the archive-relative entry
|
|
865
|
-
* paths that were written, in archive iteration order.
|
|
866
|
-
*/
|
|
867
|
-
async function extractArchive(input, destDir, options = {}) {
|
|
868
|
-
const adapter = getAdapter(await detectArchiveType(input));
|
|
869
|
-
await fs.promises.mkdir(destDir, { recursive: true });
|
|
870
|
-
const written = [];
|
|
871
|
-
for await (const entry of adapter.listEntries(input, { tempDir: options.tempDir })) {
|
|
872
|
-
const target = resolveSafeEntryPath(destDir, entry.path);
|
|
873
|
-
await fs.promises.mkdir(path.dirname(target), { recursive: true });
|
|
874
|
-
await pipeline(entry.openReadStream(), fs.createWriteStream(target));
|
|
875
|
-
written.push(entry.path);
|
|
900
|
+
}
|
|
876
901
|
}
|
|
877
|
-
return
|
|
902
|
+
return withOutput(options.output, (destination) => adapter.write(output(), destination, { tempDir: options.tempDir }));
|
|
878
903
|
}
|
|
879
904
|
//#endregion
|
|
880
905
|
//#region src/metadata/schema.ts
|
|
881
906
|
/**
|
|
882
907
|
* Authoritative field-mapping table between the canonical ComicMetadata
|
|
883
908
|
* shape and each external XML schema (ComicInfo.xml v2.1, MetronInfo.xml
|
|
884
|
-
* v1.1
|
|
909
|
+
* v1.1. See `schemas/`). Conversion is intentionally lossy in both
|
|
885
910
|
* directions; a field with no equivalent in the target schema is dropped
|
|
886
911
|
* unless the source and target schema happen to be the same one (in which
|
|
887
912
|
* case `comicInfoExtra`/`metronInfoExtra` round-trips it).
|
|
888
913
|
*
|
|
889
914
|
* | Canonical field | ComicInfo.xml | MetronInfo.xml |
|
|
890
915
|
* |-------------------------|-----------------------------------------------------|------------------------------------------------|
|
|
891
|
-
* | title | Title | (no equivalent
|
|
916
|
+
* | title | Title | (no equivalent; dropped) |
|
|
892
917
|
* | series | Series | Series > Name |
|
|
893
918
|
* | seriesSort | (no equivalent) | Series > SortName |
|
|
894
919
|
* | seriesId / seriesLang | (no equivalent) | Series `id`/`lang` attributes |
|
|
@@ -896,18 +921,18 @@ async function extractArchive(input, destDir, options = {}) {
|
|
|
896
921
|
* | seriesIssueCount | (no equivalent) | Series > IssueCount |
|
|
897
922
|
* | seriesVolumeCount | (no equivalent) | Series > VolumeCount |
|
|
898
923
|
* | seriesAlternativeNames | (no equivalent) | Series > AlternativeNames > AlternativeName[] |
|
|
899
|
-
* | seriesGroup | SeriesGroup | (no equivalent
|
|
924
|
+
* | seriesGroup | SeriesGroup | (no equivalent; dropped) |
|
|
900
925
|
* | volume | Volume | Series > Volume |
|
|
901
926
|
* | number | Number | Number |
|
|
902
927
|
* | alternateNumber | AlternateNumber | AlternativeNumber |
|
|
903
|
-
* | alternateSeries | AlternateSeries | (no equivalent
|
|
904
|
-
* | alternateCount | AlternateCount | (no equivalent
|
|
928
|
+
* | alternateSeries | AlternateSeries | (no equivalent; dropped) |
|
|
929
|
+
* | alternateCount | AlternateCount | (no equivalent; dropped) |
|
|
905
930
|
* | count | Count | (no equivalent) |
|
|
906
931
|
* | pageCount | PageCount | PageCount |
|
|
907
932
|
* | summary | Summary | Summary |
|
|
908
933
|
* | notes | Notes | Notes |
|
|
909
|
-
* | review | Review | (no equivalent
|
|
910
|
-
* | scanInformation | ScanInformation | (no equivalent
|
|
934
|
+
* | review | Review | (no equivalent; dropped) |
|
|
935
|
+
* | scanInformation | ScanInformation | (no equivalent; dropped) |
|
|
911
936
|
* | publisher / publisherId | Publisher | Publisher > Name / `id` attribute |
|
|
912
937
|
* | imprint / imprintId | Imprint | Publisher > Imprint / `id` attribute |
|
|
913
938
|
* | collectionTitle | (no equivalent) | CollectionTitle |
|
|
@@ -925,7 +950,7 @@ async function extractArchive(input, destDir, options = {}) {
|
|
|
925
950
|
* | teams | Teams (comma-separated string) | Teams > Team[] (`id` attribute) |
|
|
926
951
|
* | locations | Locations (comma-separated string) | Locations > Location[] (`id` attribute) |
|
|
927
952
|
* | storyArcs | StoryArc / StoryArcNumber (parallel comma lists) | Arcs > Arc[] (Name + Number + `id`) |
|
|
928
|
-
* | mainCharacterOrTeam | MainCharacterOrTeam | (no equivalent
|
|
953
|
+
* | mainCharacterOrTeam | MainCharacterOrTeam | (no equivalent; dropped) |
|
|
929
954
|
* | stories | (no equivalent) | Stories > Story[] |
|
|
930
955
|
* | reprints | (no equivalent) | Reprints > Reprint[] |
|
|
931
956
|
* | universes | (no equivalent) | Universes > Universe[] |
|
|
@@ -935,9 +960,9 @@ async function extractArchive(input, destDir, options = {}) {
|
|
|
935
960
|
* | web | Web (single URL) | URLs > URL[] (`primary` attribute) |
|
|
936
961
|
* | gtin | GTIN | GTIN > ISBN |
|
|
937
962
|
* | gtinUpc | (no equivalent) | GTIN > UPC |
|
|
938
|
-
* | blackAndWhite | BlackAndWhite (Yes/No) | (no equivalent
|
|
939
|
-
* | manga | Manga | (no equivalent
|
|
940
|
-
* | pages | Pages > Page[] (Image/Type/DoublePage/... attrs) | (no equivalent
|
|
963
|
+
* | blackAndWhite | BlackAndWhite (Yes/No) | (no equivalent; ComicInfo-only) |
|
|
964
|
+
* | manga | Manga | (no equivalent; ComicInfo-only) |
|
|
965
|
+
* | pages | Pages > Page[] (Image/Type/DoublePage/... attrs) | (no equivalent; ComicInfo-only) |
|
|
941
966
|
*/
|
|
942
967
|
const COMIC_INFO_CREDIT_ROLES = [
|
|
943
968
|
"Writer",
|
|
@@ -1641,7 +1666,7 @@ const SCHEMA_VERSIONS = {
|
|
|
1641
1666
|
/**
|
|
1642
1667
|
* Walks up from this module's location to find the package root (the
|
|
1643
1668
|
* directory containing `schemas/`). This module runs both from `src/`
|
|
1644
|
-
* (tests, ts-node) and from the single-file bundle in `dist
|
|
1669
|
+
* (tests, ts-node) and from the single-file bundle in `dist/`; those sit
|
|
1645
1670
|
* at different depths relative to the package root, so the depth can't be
|
|
1646
1671
|
* hardcoded.
|
|
1647
1672
|
*/
|
|
@@ -1709,6 +1734,7 @@ var metadata_exports = /* @__PURE__ */ __exportAll({
|
|
|
1709
1734
|
metadataToXml: () => metadataToXml,
|
|
1710
1735
|
metronInfoXmlToMetadata: () => metronInfoXmlToMetadata,
|
|
1711
1736
|
readArchiveMetadata: () => readArchiveMetadata,
|
|
1737
|
+
removeComicMetadata: () => removeComicMetadata,
|
|
1712
1738
|
resourceId: () => resourceId,
|
|
1713
1739
|
resourceName: () => resourceName,
|
|
1714
1740
|
splitCommaList: () => splitCommaList,
|
|
@@ -1725,21 +1751,30 @@ function metadataToXml(metadata, schema) {
|
|
|
1725
1751
|
function xmlToMetadata(xml, schema) {
|
|
1726
1752
|
return schema === "ComicInfo" ? comicInfoXmlToMetadata(xml) : metronInfoXmlToMetadata(xml);
|
|
1727
1753
|
}
|
|
1754
|
+
/** The archive's header `comicMetadata` object, if it's an asar archive with one; `{}` otherwise. */
|
|
1755
|
+
async function readAsarComicMetadataHeader(input) {
|
|
1756
|
+
const { header } = await parseAsarHeader((start, end) => readInputRange(input, start, end));
|
|
1757
|
+
return header.comicMetadata ?? {};
|
|
1758
|
+
}
|
|
1728
1759
|
async function hasComicMetadata(input) {
|
|
1729
1760
|
const type = await detectArchiveType(input);
|
|
1730
1761
|
if (type === "asar") {
|
|
1731
|
-
const
|
|
1732
|
-
|
|
1762
|
+
const comicMetadata = await readAsarComicMetadataHeader(input);
|
|
1763
|
+
const bothPresent = Boolean(comicMetadata.ComicInfo && comicMetadata.MetronInfo);
|
|
1764
|
+
if (comicMetadata.ComicInfo) return {
|
|
1733
1765
|
present: true,
|
|
1734
1766
|
schema: "ComicInfo",
|
|
1735
|
-
|
|
1767
|
+
bothPresent
|
|
1736
1768
|
};
|
|
1737
|
-
if (
|
|
1769
|
+
if (comicMetadata.MetronInfo) return {
|
|
1738
1770
|
present: true,
|
|
1739
1771
|
schema: "MetronInfo",
|
|
1740
|
-
|
|
1772
|
+
bothPresent
|
|
1773
|
+
};
|
|
1774
|
+
return {
|
|
1775
|
+
present: false,
|
|
1776
|
+
bothPresent: false
|
|
1741
1777
|
};
|
|
1742
|
-
return { present: false };
|
|
1743
1778
|
}
|
|
1744
1779
|
const adapter = getAdapter(type);
|
|
1745
1780
|
for await (const entry of adapter.listEntries(input)) {
|
|
@@ -1757,10 +1792,35 @@ async function hasComicMetadata(input) {
|
|
|
1757
1792
|
}
|
|
1758
1793
|
return { present: false };
|
|
1759
1794
|
}
|
|
1760
|
-
|
|
1795
|
+
/**
|
|
1796
|
+
* Reads embedded comic metadata. With no `schema`, returns whichever schema
|
|
1797
|
+
* `hasComicMetadata` finds first (asar prefers ComicInfo when both are
|
|
1798
|
+
* present). Pass `schema` to read that specific one regardless of which is
|
|
1799
|
+
* preferred. The only way to read a non-preferred schema back out of an
|
|
1800
|
+
* asar archive that has both, since its metadata isn't addressable by path.
|
|
1801
|
+
*/
|
|
1802
|
+
async function readArchiveMetadata(input, schema) {
|
|
1803
|
+
const type = await detectArchiveType(input);
|
|
1804
|
+
if (type === "asar") {
|
|
1805
|
+
const comicMetadata = await readAsarComicMetadataHeader(input);
|
|
1806
|
+
const resolvedSchema = schema ?? (comicMetadata.ComicInfo ? "ComicInfo" : comicMetadata.MetronInfo ? "MetronInfo" : void 0);
|
|
1807
|
+
const metadata = resolvedSchema && comicMetadata[resolvedSchema];
|
|
1808
|
+
return resolvedSchema && metadata ? {
|
|
1809
|
+
schema: resolvedSchema,
|
|
1810
|
+
metadata
|
|
1811
|
+
} : null;
|
|
1812
|
+
}
|
|
1813
|
+
const adapter = getAdapter(type);
|
|
1814
|
+
if (schema) {
|
|
1815
|
+
const fileName = FILE_NAMES[schema];
|
|
1816
|
+
for await (const entry of adapter.listEntries(input)) if (entry.path.split("/").pop() === fileName) return {
|
|
1817
|
+
schema,
|
|
1818
|
+
metadata: xmlToMetadata(await streamToBuffer(entry.openReadStream()), schema)
|
|
1819
|
+
};
|
|
1820
|
+
return null;
|
|
1821
|
+
}
|
|
1761
1822
|
const found = await hasComicMetadata(input);
|
|
1762
1823
|
if (!found.present || !found.schema || !found.path) return null;
|
|
1763
|
-
const adapter = getAdapter(await detectArchiveType(input));
|
|
1764
1824
|
for await (const entry of adapter.listEntries(input)) if (entry.path === found.path) {
|
|
1765
1825
|
const buffer = await streamToBuffer(entry.openReadStream());
|
|
1766
1826
|
return {
|
|
@@ -1771,7 +1831,14 @@ async function readArchiveMetadata(input) {
|
|
|
1771
1831
|
return null;
|
|
1772
1832
|
}
|
|
1773
1833
|
async function addMetadataToArchive(input, metadata, schema, options = {}) {
|
|
1774
|
-
const
|
|
1834
|
+
const type = await detectArchiveType(input);
|
|
1835
|
+
if (type === "asar") return writeAsarHeaderPatch(input, (header) => {
|
|
1836
|
+
const record = header;
|
|
1837
|
+
record.comicMetadata ??= {};
|
|
1838
|
+
if (!options.overwrite && record.comicMetadata[schema]) throw new ArchiveFormatError(`Archive already contains ${schema} metadata; pass { overwrite: true } to replace it.`);
|
|
1839
|
+
record.comicMetadata[schema] = metadata;
|
|
1840
|
+
}, options);
|
|
1841
|
+
const adapter = getAdapter(type);
|
|
1775
1842
|
const fileName = FILE_NAMES[schema];
|
|
1776
1843
|
const xmlBuffer = Buffer.from(metadataToXml(metadata, schema), "utf8");
|
|
1777
1844
|
async function* entries() {
|
|
@@ -1801,6 +1868,90 @@ async function addMetadataToArchive(input, metadata, schema, options = {}) {
|
|
|
1801
1868
|
}
|
|
1802
1869
|
return withOutput(options.output, (destination) => adapter.write(entries(), destination, { tempDir: options.tempDir }));
|
|
1803
1870
|
}
|
|
1871
|
+
/**
|
|
1872
|
+
* Removes the embedded `{schema}` metadata, if present; an asar header key
|
|
1873
|
+
* removal; or the matching `{schema}.xml` entry for every other format.
|
|
1874
|
+
* Throws `ArchiveFormatError` if the archive has no such metadata; a caller
|
|
1875
|
+
* that wants a no-op instead should check `hasComicMetadata` first.
|
|
1876
|
+
*/
|
|
1877
|
+
async function removeComicMetadata(input, schema, options = {}) {
|
|
1878
|
+
const type = await detectArchiveType(input);
|
|
1879
|
+
if (type === "asar") return writeAsarHeaderPatch(input, (header) => {
|
|
1880
|
+
const comicMetadata = header.comicMetadata;
|
|
1881
|
+
if (!comicMetadata?.[schema]) throw new ArchiveFormatError(`Archive does not contain ${schema} metadata.`);
|
|
1882
|
+
delete comicMetadata[schema];
|
|
1883
|
+
}, options);
|
|
1884
|
+
const adapter = getAdapter(type);
|
|
1885
|
+
const fileName = FILE_NAMES[schema];
|
|
1886
|
+
for await (const entry of adapter.listEntries(input, { tempDir: options.tempDir })) if (entry.path.split("/").pop() === fileName) return removeArchiveEntry(input, entry.path, options);
|
|
1887
|
+
throw new ArchiveFormatError(`Archive does not contain ${fileName}.`);
|
|
1888
|
+
}
|
|
1889
|
+
//#endregion
|
|
1890
|
+
//#region src/convertArchive.ts
|
|
1891
|
+
const METADATA_FILE_NAMES = /* @__PURE__ */ new Set(["ComicInfo.xml", "MetronInfo.xml"]);
|
|
1892
|
+
function replaceExtension(entryPath, format) {
|
|
1893
|
+
return `${entryPath.replace(/\.[^./\\]+$/, "")}.${format}`;
|
|
1894
|
+
}
|
|
1895
|
+
async function convertArchive(input, targetType, options = {}) {
|
|
1896
|
+
const sourceType = await detectArchiveType(input);
|
|
1897
|
+
if (sourceType === "unknown") throw new ArchiveFormatError("Could not determine the source archive type.");
|
|
1898
|
+
const sourceAdapter = getAdapter(sourceType);
|
|
1899
|
+
const targetAdapter = getAdapter(targetType);
|
|
1900
|
+
const image = options.image;
|
|
1901
|
+
const metadata = options.metadata;
|
|
1902
|
+
async function* entries() {
|
|
1903
|
+
for await (const entry of sourceAdapter.listEntries(input, { tempDir: options.tempDir })) {
|
|
1904
|
+
if (metadata && METADATA_FILE_NAMES.has(entry.path.split("/").pop() ?? "")) continue;
|
|
1905
|
+
if (image && isImagePath(entry.path) && getExtension(entry.path) !== (image.format === "jpg" ? "jpg" : image.format)) {
|
|
1906
|
+
const converted = await convertImageBuffer(await streamToBuffer(entry.openReadStream()), image.format, image.options);
|
|
1907
|
+
yield {
|
|
1908
|
+
path: replaceExtension(entry.path, image.format === "jpg" ? "jpg" : image.format),
|
|
1909
|
+
size: converted.length,
|
|
1910
|
+
content: Readable.from(converted)
|
|
1911
|
+
};
|
|
1912
|
+
} else yield {
|
|
1913
|
+
path: entry.path,
|
|
1914
|
+
size: entry.size,
|
|
1915
|
+
content: entry.openReadStream()
|
|
1916
|
+
};
|
|
1917
|
+
}
|
|
1918
|
+
if (metadata && targetType !== "asar") for (const schema of Object.keys(metadata)) {
|
|
1919
|
+
const value = metadata[schema];
|
|
1920
|
+
if (!value) continue;
|
|
1921
|
+
const xml = Buffer.from(metadataToXml(value, schema), "utf8");
|
|
1922
|
+
yield {
|
|
1923
|
+
path: `${schema}.xml`,
|
|
1924
|
+
size: xml.length,
|
|
1925
|
+
content: Readable.from(xml)
|
|
1926
|
+
};
|
|
1927
|
+
}
|
|
1928
|
+
}
|
|
1929
|
+
return withOutput(options.output, (destination) => targetAdapter.write(entries(), destination, {
|
|
1930
|
+
tempDir: options.tempDir,
|
|
1931
|
+
...metadata && targetType === "asar" ? { comicMetadata: metadata } : {}
|
|
1932
|
+
}));
|
|
1933
|
+
}
|
|
1934
|
+
//#endregion
|
|
1935
|
+
//#region src/extractArchive.ts
|
|
1936
|
+
/**
|
|
1937
|
+
* Extracts every entry in an archive to real files under `destDir`,
|
|
1938
|
+
* preserving relative paths (`destDir` is created if missing). Throws
|
|
1939
|
+
* `ArchiveFormatError` for an undetectable/unknown format, or
|
|
1940
|
+
* `UnsupportedOperationError` for ACE. Returns the archive-relative entry
|
|
1941
|
+
* paths that were written, in archive iteration order.
|
|
1942
|
+
*/
|
|
1943
|
+
async function extractArchive(input, destDir, options = {}) {
|
|
1944
|
+
const adapter = getAdapter(await detectArchiveType(input));
|
|
1945
|
+
await fs.promises.mkdir(destDir, { recursive: true });
|
|
1946
|
+
const written = [];
|
|
1947
|
+
for await (const entry of adapter.listEntries(input, { tempDir: options.tempDir })) {
|
|
1948
|
+
const target = resolveSafeEntryPath(destDir, entry.path);
|
|
1949
|
+
await fs.promises.mkdir(path.dirname(target), { recursive: true });
|
|
1950
|
+
await pipeline(entry.openReadStream(), fs.createWriteStream(target));
|
|
1951
|
+
written.push(entry.path);
|
|
1952
|
+
}
|
|
1953
|
+
return written;
|
|
1954
|
+
}
|
|
1804
1955
|
//#endregion
|
|
1805
1956
|
//#region src/images/phash.ts
|
|
1806
1957
|
const HASH_SIZE = 32;
|
|
@@ -1871,10 +2022,24 @@ async function computeArchiveImagePHash(input, entryPath) {
|
|
|
1871
2022
|
throw new MetadataNotFoundError(`No entry named "${entryPath}" was found in the archive.`);
|
|
1872
2023
|
}
|
|
1873
2024
|
//#endregion
|
|
2025
|
+
//#region src/images/dimensions.ts
|
|
2026
|
+
/** Reads an image's pixel dimensions from its header without decoding it; `null` when the buffer isn't an image `sharp` can read. */
|
|
2027
|
+
async function readImageDimensions(buffer) {
|
|
2028
|
+
try {
|
|
2029
|
+
const { width, height } = await sharp(buffer).metadata();
|
|
2030
|
+
return width !== void 0 && height !== void 0 ? {
|
|
2031
|
+
width,
|
|
2032
|
+
height
|
|
2033
|
+
} : null;
|
|
2034
|
+
} catch {
|
|
2035
|
+
return null;
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
//#endregion
|
|
1874
2039
|
//#region src/hashing/asarContent.ts
|
|
1875
2040
|
/**
|
|
1876
2041
|
* Locates the byte range of an asar archive's content region (everything
|
|
1877
|
-
* after the header), so it can be hashed without the header/index
|
|
2042
|
+
* after the header), so it can be hashed without the header/index, meaning
|
|
1878
2043
|
* header-only changes (e.g. re-ordering the file index) never change the
|
|
1879
2044
|
* content hash.
|
|
1880
2045
|
*/
|
|
@@ -1898,7 +2063,7 @@ async function sha256ArchiveEntry(input, entryPath) {
|
|
|
1898
2063
|
throw new MetadataNotFoundError(`No entry named "${entryPath}" was found in the archive.`);
|
|
1899
2064
|
}
|
|
1900
2065
|
/**
|
|
1901
|
-
* SHA256 of the whole archive file
|
|
2066
|
+
* SHA256 of the whole archive file, except for asar, where only the
|
|
1902
2067
|
* content region (bytes after the header) is hashed, so header/index
|
|
1903
2068
|
* reordering never changes the content hash.
|
|
1904
2069
|
*/
|
|
@@ -1999,7 +2164,7 @@ async function readArchiveEntry(input, entryPath) {
|
|
|
1999
2164
|
* Reads every entry's bytes and SHA256 in a single pass over the archive.
|
|
2000
2165
|
* Calling `readArchiveEntry`/`sha256ArchiveEntry` once per entry instead
|
|
2001
2166
|
* means re-running `listEntries` and re-locating the target entry on every
|
|
2002
|
-
* call
|
|
2167
|
+
* call: cheap for zip (backed by real central-directory random access) and
|
|
2003
2168
|
* tar/asar, but for the sequential/CLI-driven formats (rar, 7z, ace) each
|
|
2004
2169
|
* call re-reads the archive from the start, so reading every entry that way
|
|
2005
2170
|
* costs O(n^2). This also does half the I/O of calling both
|
|
@@ -2025,28 +2190,6 @@ async function* readArchiveEntries(input) {
|
|
|
2025
2190
|
}
|
|
2026
2191
|
}
|
|
2027
2192
|
//#endregion
|
|
2028
|
-
//#region src/removeArchiveEntry.ts
|
|
2029
|
-
async function removeArchiveEntry(input, entryPath, options = {}) {
|
|
2030
|
-
const adapter = getAdapter(await detectArchiveType(input));
|
|
2031
|
-
let found = false;
|
|
2032
|
-
for await (const entry of adapter.listEntries(input, { tempDir: options.tempDir })) if (entry.path === entryPath) {
|
|
2033
|
-
found = true;
|
|
2034
|
-
break;
|
|
2035
|
-
}
|
|
2036
|
-
if (!found) throw new MetadataNotFoundError(`No entry named "${entryPath}" was found in the archive.`);
|
|
2037
|
-
async function* output() {
|
|
2038
|
-
for await (const entry of adapter.listEntries(input, { tempDir: options.tempDir })) {
|
|
2039
|
-
if (entry.path === entryPath) continue;
|
|
2040
|
-
yield {
|
|
2041
|
-
path: entry.path,
|
|
2042
|
-
size: entry.size,
|
|
2043
|
-
content: entry.openReadStream()
|
|
2044
|
-
};
|
|
2045
|
-
}
|
|
2046
|
-
}
|
|
2047
|
-
return withOutput(options.output, (destination) => adapter.write(output(), destination, { tempDir: options.tempDir }));
|
|
2048
|
-
}
|
|
2049
|
-
//#endregion
|
|
2050
2193
|
//#region src/benchmark/timing.ts
|
|
2051
2194
|
/** Runs `run` `iterations` times and returns the average duration in milliseconds. */
|
|
2052
2195
|
async function averageDuration(iterations, run) {
|
|
@@ -2103,19 +2246,19 @@ function topThreeImageFormats(variants) {
|
|
|
2103
2246
|
}).slice(0, 3);
|
|
2104
2247
|
}
|
|
2105
2248
|
function renderRankedSection(title, description, ranked, format) {
|
|
2106
|
-
return `### ${title}\n\n${description}\n\n${ranked.map((variant, index) => `${index + 1}. **${variant.fileName}** (${variant.archiveType}/${variant.imageFormat})
|
|
2249
|
+
return `### ${title}\n\n${description}\n\n${ranked.map((variant, index) => `${index + 1}. **${variant.fileName}** (${variant.archiveType}/${variant.imageFormat}): ${format(variant)}`).join("\n")}\n`;
|
|
2107
2250
|
}
|
|
2108
2251
|
function renderImageFormatRankedSection(title, description, ranked) {
|
|
2109
|
-
return `### ${title}\n\n${description}\n\n${ranked.map((variant, index) => `${index + 1}. **${variant.imageFormat}
|
|
2252
|
+
return `### ${title}\n\n${description}\n\n${ranked.map((variant, index) => `${index + 1}. **${variant.imageFormat}**: ${formatBytes(variant.avgImageSizeBytes)} avg. per page`).join("\n")}\n`;
|
|
2110
2253
|
}
|
|
2111
2254
|
function renderBenchmarkReportMarkdown(result) {
|
|
2112
2255
|
const { variants } = result;
|
|
2113
2256
|
const tableHeader = "| Archive | Image | File | Storage size | Avg. page size | Pages | Avg. creation time | Avg. random read time |\n| --- | --- | --- | --- | --- | --- | --- | --- |";
|
|
2114
2257
|
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");
|
|
2115
|
-
const readSpeed = renderRankedSection("Read speed", "Fastest average random single-file read time
|
|
2116
|
-
const storageSize = renderRankedSection("Storage size", "Smallest resulting archive on disk
|
|
2117
|
-
const transferSize = renderImageFormatRankedSection("Transfer size", "Smallest average individual page
|
|
2118
|
-
const creationSpeed = renderRankedSection("Creation speed", "Fastest average archive creation time
|
|
2258
|
+
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));
|
|
2259
|
+
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));
|
|
2260
|
+
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));
|
|
2261
|
+
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));
|
|
2119
2262
|
return [
|
|
2120
2263
|
"# Archive Benchmark Report",
|
|
2121
2264
|
"",
|
|
@@ -2169,7 +2312,7 @@ function pickRandomSamples(items, count) {
|
|
|
2169
2312
|
}
|
|
2170
2313
|
/**
|
|
2171
2314
|
* The average byte size of one converted image, independent of which
|
|
2172
|
-
* container it ends up packaged in ("transfer size"
|
|
2315
|
+
* container it ends up packaged in ("transfer size": what a client
|
|
2173
2316
|
* actually downloads to fetch a single page).
|
|
2174
2317
|
*/
|
|
2175
2318
|
async function averageConvertedImageSize(preConverted, imageFormat, tempDir) {
|
|
@@ -2300,6 +2443,6 @@ const comicArchiveHandler = {
|
|
|
2300
2443
|
ARCHIVE_TYPE_EXTENSIONS
|
|
2301
2444
|
};
|
|
2302
2445
|
//#endregion
|
|
2303
|
-
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, readArchiveEntries, readArchiveEntry, readArchiveMetadata, removeArchiveEntry, renameArchiveImagesSequentially, renderBenchmarkReportMarkdown, resourceId, resourceName, sha256Archive, sha256ArchiveEntry, splitCommaList, stripNonEssentialFiles, validateMetadataXml, xmlToMetadata };
|
|
2446
|
+
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, readArchiveEntries, readArchiveEntry, readArchiveMetadata, readImageDimensions, removeArchiveEntry, removeComicMetadata, renameArchiveImagesSequentially, renderBenchmarkReportMarkdown, resourceId, resourceName, sha256Archive, sha256ArchiveEntry, splitCommaList, stripNonEssentialFiles, validateMetadataXml, xmlToMetadata };
|
|
2304
2447
|
|
|
2305
2448
|
//# sourceMappingURL=index.mjs.map
|