@clearmist-labs/comic-archive-handler 1.3.0 → 1.4.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
@@ -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
@@ -612,8 +678,8 @@ async function ensureBinaryAvailable() {
612
678
  * going through the `node-7z` wrapper: that package has no way to set the
613
679
  * child process's working directory, which is required here to get archive
614
680
  * 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
681
+ * either leaking absolute host paths or, as discovered during
682
+ * implementation testing, silently operating against this process's actual
617
683
  * cwd instead of the intended staging directory).
618
684
  */
619
685
  function run7z(binPath, args, options) {
@@ -741,28 +807,6 @@ function getAdapter(type) {
741
807
  return adapter;
742
808
  }
743
809
  //#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
810
  //#region src/images/isImage.ts
767
811
  const IMAGE_EXTENSIONS = [
768
812
  "jpg",
@@ -877,18 +921,40 @@ async function extractArchive(input, destDir, options = {}) {
877
921
  return written;
878
922
  }
879
923
  //#endregion
924
+ //#region src/removeArchiveEntry.ts
925
+ async function removeArchiveEntry(input, entryPath, options = {}) {
926
+ const adapter = getAdapter(await detectArchiveType(input));
927
+ let found = false;
928
+ for await (const entry of adapter.listEntries(input, { tempDir: options.tempDir })) if (entry.path === entryPath) {
929
+ found = true;
930
+ break;
931
+ }
932
+ if (!found) throw new MetadataNotFoundError(`No entry named "${entryPath}" was found in the archive.`);
933
+ async function* output() {
934
+ for await (const entry of adapter.listEntries(input, { tempDir: options.tempDir })) {
935
+ if (entry.path === entryPath) continue;
936
+ yield {
937
+ path: entry.path,
938
+ size: entry.size,
939
+ content: entry.openReadStream()
940
+ };
941
+ }
942
+ }
943
+ return withOutput(options.output, (destination) => adapter.write(output(), destination, { tempDir: options.tempDir }));
944
+ }
945
+ //#endregion
880
946
  //#region src/metadata/schema.ts
881
947
  /**
882
948
  * Authoritative field-mapping table between the canonical ComicMetadata
883
949
  * shape and each external XML schema (ComicInfo.xml v2.1, MetronInfo.xml
884
- * v1.1 — see `schemas/`). Conversion is intentionally lossy in both
950
+ * v1.1. See `schemas/`). Conversion is intentionally lossy in both
885
951
  * directions; a field with no equivalent in the target schema is dropped
886
952
  * unless the source and target schema happen to be the same one (in which
887
953
  * case `comicInfoExtra`/`metronInfoExtra` round-trips it).
888
954
  *
889
955
  * | Canonical field | ComicInfo.xml | MetronInfo.xml |
890
956
  * |-------------------------|-----------------------------------------------------|------------------------------------------------|
891
- * | title | Title | (no equivalent — dropped) |
957
+ * | title | Title | (no equivalent; dropped) |
892
958
  * | series | Series | Series > Name |
893
959
  * | seriesSort | (no equivalent) | Series > SortName |
894
960
  * | seriesId / seriesLang | (no equivalent) | Series `id`/`lang` attributes |
@@ -896,18 +962,18 @@ async function extractArchive(input, destDir, options = {}) {
896
962
  * | seriesIssueCount | (no equivalent) | Series > IssueCount |
897
963
  * | seriesVolumeCount | (no equivalent) | Series > VolumeCount |
898
964
  * | seriesAlternativeNames | (no equivalent) | Series > AlternativeNames > AlternativeName[] |
899
- * | seriesGroup | SeriesGroup | (no equivalent — dropped) |
965
+ * | seriesGroup | SeriesGroup | (no equivalent; dropped) |
900
966
  * | volume | Volume | Series > Volume |
901
967
  * | number | Number | Number |
902
968
  * | alternateNumber | AlternateNumber | AlternativeNumber |
903
- * | alternateSeries | AlternateSeries | (no equivalent — dropped) |
904
- * | alternateCount | AlternateCount | (no equivalent — dropped) |
969
+ * | alternateSeries | AlternateSeries | (no equivalent; dropped) |
970
+ * | alternateCount | AlternateCount | (no equivalent; dropped) |
905
971
  * | count | Count | (no equivalent) |
906
972
  * | pageCount | PageCount | PageCount |
907
973
  * | summary | Summary | Summary |
908
974
  * | notes | Notes | Notes |
909
- * | review | Review | (no equivalent — dropped) |
910
- * | scanInformation | ScanInformation | (no equivalent — dropped) |
975
+ * | review | Review | (no equivalent; dropped) |
976
+ * | scanInformation | ScanInformation | (no equivalent; dropped) |
911
977
  * | publisher / publisherId | Publisher | Publisher > Name / `id` attribute |
912
978
  * | imprint / imprintId | Imprint | Publisher > Imprint / `id` attribute |
913
979
  * | collectionTitle | (no equivalent) | CollectionTitle |
@@ -925,7 +991,7 @@ async function extractArchive(input, destDir, options = {}) {
925
991
  * | teams | Teams (comma-separated string) | Teams > Team[] (`id` attribute) |
926
992
  * | locations | Locations (comma-separated string) | Locations > Location[] (`id` attribute) |
927
993
  * | storyArcs | StoryArc / StoryArcNumber (parallel comma lists) | Arcs > Arc[] (Name + Number + `id`) |
928
- * | mainCharacterOrTeam | MainCharacterOrTeam | (no equivalent — dropped) |
994
+ * | mainCharacterOrTeam | MainCharacterOrTeam | (no equivalent; dropped) |
929
995
  * | stories | (no equivalent) | Stories > Story[] |
930
996
  * | reprints | (no equivalent) | Reprints > Reprint[] |
931
997
  * | universes | (no equivalent) | Universes > Universe[] |
@@ -935,9 +1001,9 @@ async function extractArchive(input, destDir, options = {}) {
935
1001
  * | web | Web (single URL) | URLs > URL[] (`primary` attribute) |
936
1002
  * | gtin | GTIN | GTIN > ISBN |
937
1003
  * | 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) |
1004
+ * | blackAndWhite | BlackAndWhite (Yes/No) | (no equivalent; ComicInfo-only) |
1005
+ * | manga | Manga | (no equivalent; ComicInfo-only) |
1006
+ * | pages | Pages > Page[] (Image/Type/DoublePage/... attrs) | (no equivalent; ComicInfo-only) |
941
1007
  */
942
1008
  const COMIC_INFO_CREDIT_ROLES = [
943
1009
  "Writer",
@@ -1641,7 +1707,7 @@ const SCHEMA_VERSIONS = {
1641
1707
  /**
1642
1708
  * Walks up from this module's location to find the package root (the
1643
1709
  * directory containing `schemas/`). This module runs both from `src/`
1644
- * (tests, ts-node) and from the single-file bundle in `dist/` — those sit
1710
+ * (tests, ts-node) and from the single-file bundle in `dist/`; those sit
1645
1711
  * at different depths relative to the package root, so the depth can't be
1646
1712
  * hardcoded.
1647
1713
  */
@@ -1709,6 +1775,7 @@ var metadata_exports = /* @__PURE__ */ __exportAll({
1709
1775
  metadataToXml: () => metadataToXml,
1710
1776
  metronInfoXmlToMetadata: () => metronInfoXmlToMetadata,
1711
1777
  readArchiveMetadata: () => readArchiveMetadata,
1778
+ removeComicMetadata: () => removeComicMetadata,
1712
1779
  resourceId: () => resourceId,
1713
1780
  resourceName: () => resourceName,
1714
1781
  splitCommaList: () => splitCommaList,
@@ -1725,21 +1792,30 @@ function metadataToXml(metadata, schema) {
1725
1792
  function xmlToMetadata(xml, schema) {
1726
1793
  return schema === "ComicInfo" ? comicInfoXmlToMetadata(xml) : metronInfoXmlToMetadata(xml);
1727
1794
  }
1795
+ /** The archive's header `comicMetadata` object, if it's an asar archive with one; `{}` otherwise. */
1796
+ async function readAsarComicMetadataHeader(input) {
1797
+ const { header } = await parseAsarHeader((start, end) => readInputRange(input, start, end));
1798
+ return header.comicMetadata ?? {};
1799
+ }
1728
1800
  async function hasComicMetadata(input) {
1729
1801
  const type = await detectArchiveType(input);
1730
1802
  if (type === "asar") {
1731
- const { header } = await parseAsarHeader((start, end) => readInputRange(input, start, end));
1732
- if (hasRootFile(header, "ComicInfo.xml")) return {
1803
+ const comicMetadata = await readAsarComicMetadataHeader(input);
1804
+ const bothPresent = Boolean(comicMetadata.ComicInfo && comicMetadata.MetronInfo);
1805
+ if (comicMetadata.ComicInfo) return {
1733
1806
  present: true,
1734
1807
  schema: "ComicInfo",
1735
- path: "ComicInfo.xml"
1808
+ bothPresent
1736
1809
  };
1737
- if (hasRootFile(header, "MetronInfo.xml")) return {
1810
+ if (comicMetadata.MetronInfo) return {
1738
1811
  present: true,
1739
1812
  schema: "MetronInfo",
1740
- path: "MetronInfo.xml"
1813
+ bothPresent
1814
+ };
1815
+ return {
1816
+ present: false,
1817
+ bothPresent: false
1741
1818
  };
1742
- return { present: false };
1743
1819
  }
1744
1820
  const adapter = getAdapter(type);
1745
1821
  for await (const entry of adapter.listEntries(input)) {
@@ -1757,10 +1833,35 @@ async function hasComicMetadata(input) {
1757
1833
  }
1758
1834
  return { present: false };
1759
1835
  }
1760
- async function readArchiveMetadata(input) {
1836
+ /**
1837
+ * Reads embedded comic metadata. With no `schema`, returns whichever schema
1838
+ * `hasComicMetadata` finds first (asar prefers ComicInfo when both are
1839
+ * present). Pass `schema` to read that specific one regardless of which is
1840
+ * preferred. The only way to read a non-preferred schema back out of an
1841
+ * asar archive that has both, since its metadata isn't addressable by path.
1842
+ */
1843
+ async function readArchiveMetadata(input, schema) {
1844
+ const type = await detectArchiveType(input);
1845
+ if (type === "asar") {
1846
+ const comicMetadata = await readAsarComicMetadataHeader(input);
1847
+ const resolvedSchema = schema ?? (comicMetadata.ComicInfo ? "ComicInfo" : comicMetadata.MetronInfo ? "MetronInfo" : void 0);
1848
+ const metadata = resolvedSchema && comicMetadata[resolvedSchema];
1849
+ return resolvedSchema && metadata ? {
1850
+ schema: resolvedSchema,
1851
+ metadata
1852
+ } : null;
1853
+ }
1854
+ const adapter = getAdapter(type);
1855
+ if (schema) {
1856
+ const fileName = FILE_NAMES[schema];
1857
+ for await (const entry of adapter.listEntries(input)) if (entry.path.split("/").pop() === fileName) return {
1858
+ schema,
1859
+ metadata: xmlToMetadata(await streamToBuffer(entry.openReadStream()), schema)
1860
+ };
1861
+ return null;
1862
+ }
1761
1863
  const found = await hasComicMetadata(input);
1762
1864
  if (!found.present || !found.schema || !found.path) return null;
1763
- const adapter = getAdapter(await detectArchiveType(input));
1764
1865
  for await (const entry of adapter.listEntries(input)) if (entry.path === found.path) {
1765
1866
  const buffer = await streamToBuffer(entry.openReadStream());
1766
1867
  return {
@@ -1771,7 +1872,14 @@ async function readArchiveMetadata(input) {
1771
1872
  return null;
1772
1873
  }
1773
1874
  async function addMetadataToArchive(input, metadata, schema, options = {}) {
1774
- const adapter = getAdapter(await detectArchiveType(input));
1875
+ const type = await detectArchiveType(input);
1876
+ if (type === "asar") return writeAsarHeaderPatch(input, (header) => {
1877
+ const record = header;
1878
+ record.comicMetadata ??= {};
1879
+ if (!options.overwrite && record.comicMetadata[schema]) throw new ArchiveFormatError(`Archive already contains ${schema} metadata; pass { overwrite: true } to replace it.`);
1880
+ record.comicMetadata[schema] = metadata;
1881
+ }, options);
1882
+ const adapter = getAdapter(type);
1775
1883
  const fileName = FILE_NAMES[schema];
1776
1884
  const xmlBuffer = Buffer.from(metadataToXml(metadata, schema), "utf8");
1777
1885
  async function* entries() {
@@ -1801,6 +1909,24 @@ async function addMetadataToArchive(input, metadata, schema, options = {}) {
1801
1909
  }
1802
1910
  return withOutput(options.output, (destination) => adapter.write(entries(), destination, { tempDir: options.tempDir }));
1803
1911
  }
1912
+ /**
1913
+ * Removes the embedded `{schema}` metadata, if present; an asar header key
1914
+ * removal; or the matching `{schema}.xml` entry for every other format.
1915
+ * Throws `ArchiveFormatError` if the archive has no such metadata; a caller
1916
+ * that wants a no-op instead should check `hasComicMetadata` first.
1917
+ */
1918
+ async function removeComicMetadata(input, schema, options = {}) {
1919
+ const type = await detectArchiveType(input);
1920
+ if (type === "asar") return writeAsarHeaderPatch(input, (header) => {
1921
+ const comicMetadata = header.comicMetadata;
1922
+ if (!comicMetadata?.[schema]) throw new ArchiveFormatError(`Archive does not contain ${schema} metadata.`);
1923
+ delete comicMetadata[schema];
1924
+ }, options);
1925
+ const adapter = getAdapter(type);
1926
+ const fileName = FILE_NAMES[schema];
1927
+ for await (const entry of adapter.listEntries(input, { tempDir: options.tempDir })) if (entry.path.split("/").pop() === fileName) return removeArchiveEntry(input, entry.path, options);
1928
+ throw new ArchiveFormatError(`Archive does not contain ${fileName}.`);
1929
+ }
1804
1930
  //#endregion
1805
1931
  //#region src/images/phash.ts
1806
1932
  const HASH_SIZE = 32;
@@ -1874,7 +2000,7 @@ async function computeArchiveImagePHash(input, entryPath) {
1874
2000
  //#region src/hashing/asarContent.ts
1875
2001
  /**
1876
2002
  * 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
2003
+ * after the header), so it can be hashed without the header/index, meaning
1878
2004
  * header-only changes (e.g. re-ordering the file index) never change the
1879
2005
  * content hash.
1880
2006
  */
@@ -1898,7 +2024,7 @@ async function sha256ArchiveEntry(input, entryPath) {
1898
2024
  throw new MetadataNotFoundError(`No entry named "${entryPath}" was found in the archive.`);
1899
2025
  }
1900
2026
  /**
1901
- * SHA256 of the whole archive file — except for asar, where only the
2027
+ * SHA256 of the whole archive file, except for asar, where only the
1902
2028
  * content region (bytes after the header) is hashed, so header/index
1903
2029
  * reordering never changes the content hash.
1904
2030
  */
@@ -1999,7 +2125,7 @@ async function readArchiveEntry(input, entryPath) {
1999
2125
  * Reads every entry's bytes and SHA256 in a single pass over the archive.
2000
2126
  * Calling `readArchiveEntry`/`sha256ArchiveEntry` once per entry instead
2001
2127
  * 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
2128
+ * call: cheap for zip (backed by real central-directory random access) and
2003
2129
  * tar/asar, but for the sequential/CLI-driven formats (rar, 7z, ace) each
2004
2130
  * call re-reads the archive from the start, so reading every entry that way
2005
2131
  * costs O(n^2). This also does half the I/O of calling both
@@ -2025,28 +2151,6 @@ async function* readArchiveEntries(input) {
2025
2151
  }
2026
2152
  }
2027
2153
  //#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
2154
  //#region src/benchmark/timing.ts
2051
2155
  /** Runs `run` `iterations` times and returns the average duration in milliseconds. */
2052
2156
  async function averageDuration(iterations, run) {
@@ -2103,19 +2207,19 @@ function topThreeImageFormats(variants) {
2103
2207
  }).slice(0, 3);
2104
2208
  }
2105
2209
  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`;
2210
+ 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
2211
  }
2108
2212
  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`;
2213
+ 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
2214
  }
2111
2215
  function renderBenchmarkReportMarkdown(result) {
2112
2216
  const { variants } = result;
2113
2217
  const tableHeader = "| Archive | Image | File | Storage size | Avg. page size | Pages | Avg. creation time | Avg. random read time |\n| --- | --- | --- | --- | --- | --- | --- | --- |";
2114
2218
  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));
2219
+ 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));
2220
+ 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));
2221
+ 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));
2222
+ 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
2223
  return [
2120
2224
  "# Archive Benchmark Report",
2121
2225
  "",
@@ -2169,7 +2273,7 @@ function pickRandomSamples(items, count) {
2169
2273
  }
2170
2274
  /**
2171
2275
  * The average byte size of one converted image, independent of which
2172
- * container it ends up packaged in ("transfer size" — what a client
2276
+ * container it ends up packaged in ("transfer size": what a client
2173
2277
  * actually downloads to fetch a single page).
2174
2278
  */
2175
2279
  async function averageConvertedImageSize(preConverted, imageFormat, tempDir) {
@@ -2300,6 +2404,6 @@ const comicArchiveHandler = {
2300
2404
  ARCHIVE_TYPE_EXTENSIONS
2301
2405
  };
2302
2406
  //#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 };
2407
+ 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, removeComicMetadata, renameArchiveImagesSequentially, renderBenchmarkReportMarkdown, resourceId, resourceName, sha256Archive, sha256ArchiveEntry, splitCommaList, stripNonEssentialFiles, validateMetadataXml, xmlToMetadata };
2304
2408
 
2305
2409
  //# sourceMappingURL=index.mjs.map