@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/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
- /** 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);
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 — the entry index at the end of the
277
- * file — into per-entry metadata (name, sizes, compression method, and the
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 — no scan through other entries.
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 — bounded by a single
400
- * entry's size (one comic page), not the whole archive — since the
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, or — as discovered during
616
- * implementation testing — silently operating against this process's actual
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/convertArchive.ts
833
- function replaceExtension(entryPath, format) {
834
- return `${entryPath.replace(/\.[^./\\]+$/, "")}.${format}`;
835
- }
836
- async function convertArchive(input, targetType, options = {}) {
837
- const sourceType = await detectArchiveType(input);
838
- if (sourceType === "unknown") throw new ArchiveFormatError("Could not determine the source archive type.");
839
- const sourceAdapter = getAdapter(sourceType);
840
- const targetAdapter = getAdapter(targetType);
841
- const image = options.image;
842
- async function* entries() {
843
- 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)) {
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: replaceExtension(entry.path, image.format === "jpg" ? "jpg" : image.format),
847
- size: converted.length,
848
- content: Readable.from(converted)
896
+ path: entry.path,
897
+ size: entry.size,
898
+ content: entry.openReadStream()
849
899
  };
850
- } else yield {
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 written;
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 — see `schemas/`). Conversion is intentionally lossy in both
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 — dropped) |
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 — dropped) |
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 — dropped) |
904
- * | alternateCount | AlternateCount | (no equivalent — dropped) |
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 — dropped) |
910
- * | scanInformation | ScanInformation | (no equivalent — dropped) |
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 — dropped) |
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 — ComicInfo-only) |
939
- * | manga | Manga | (no equivalent — ComicInfo-only) |
940
- * | pages | Pages > Page[] (Image/Type/DoublePage/... attrs) | (no equivalent — ComicInfo-only) |
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/` — those sit
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 { header } = await parseAsarHeader((start, end) => readInputRange(input, start, end));
1732
- if (hasRootFile(header, "ComicInfo.xml")) return {
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
- path: "ComicInfo.xml"
1767
+ bothPresent
1736
1768
  };
1737
- if (hasRootFile(header, "MetronInfo.xml")) return {
1769
+ if (comicMetadata.MetronInfo) return {
1738
1770
  present: true,
1739
1771
  schema: "MetronInfo",
1740
- path: "MetronInfo.xml"
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
- async function readArchiveMetadata(input) {
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 adapter = getAdapter(await detectArchiveType(input));
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 — meaning
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 — except for asar, where only the
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 — cheap for zip (backed by real central-directory random access) and
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}) — ${format(variant)}`).join("\n")}\n`;
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}** — ${formatBytes(variant.avgImageSizeBytes)} avg. per page`).join("\n")}\n`;
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 — best for serving individual pages on demand (e.g. a remote reader).", topThree(variants, (variant) => variant.avgSeekMs), (variant) => formatMs(variant.avgSeekMs));
2116
- 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));
2117
- 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));
2118
- 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));
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" — what a client
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