@storyteller-platform/epub 0.6.3 → 0.7.1

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.cjs CHANGED
@@ -40,8 +40,8 @@ var import_promises = require("node:fs/promises");
40
40
  var import_async_mutex = require("async-mutex");
41
41
  var import_fast_xml_parser = require("fast-xml-parser");
42
42
  var import_mem = __toESM(require("mem"), 1);
43
- var import_mime_types = require("mime-types");
44
43
  var import_nanoid = require("nanoid");
44
+ var import_media_types = require("@storyteller-platform/media-types");
45
45
  var import_path = require("@storyteller-platform/path");
46
46
  var import_tmpfs = require("./adapters/tmpfs.cjs");
47
47
  var Upgrade = __toESM(require("./upgrade.ts"), 1);
@@ -51,6 +51,61 @@ class EpubVersionError extends Error {
51
51
  }
52
52
  class EpubReadOnlyError extends Error {
53
53
  }
54
+ const OPF_NAMESPACE = "http://www.idpf.org/2007/opf";
55
+ const ENTRY_ORIGIN = "epub:///";
56
+ function entryToUrlPath(entry) {
57
+ return entry.split("/").map(encodeURIComponent).join("/");
58
+ }
59
+ function resolveEntryUrl(from, href) {
60
+ const base = new URL(entryToUrlPath(from), ENTRY_ORIGIN);
61
+ if (!URL.canParse(href, base.toString())) return null;
62
+ const url = new URL(href, base);
63
+ return url.href.startsWith(ENTRY_ORIGIN) ? url : null;
64
+ }
65
+ function renameOpfAttributes(attrs, prefix) {
66
+ const attrPrefix = `@_${prefix}:`;
67
+ const renamed = {};
68
+ for (const [key, value] of Object.entries(attrs)) {
69
+ if (key === `@_xmlns:${prefix}`) continue;
70
+ if (key.startsWith(attrPrefix)) {
71
+ renamed[`@_opf:${key.slice(attrPrefix.length)}`] = value;
72
+ } else {
73
+ renamed[key] = value;
74
+ }
75
+ }
76
+ return renamed;
77
+ }
78
+ function stripOpfPrefix(node, prefix) {
79
+ if (Epub.isXmlTextNode(node)) return node;
80
+ const name = Epub.getXmlElementName(node);
81
+ const bareName = name.startsWith(`${prefix}:`) ? name.slice(prefix.length + 1) : name;
82
+ const element = {
83
+ [bareName]: Epub.getXmlChildren(node).map(
84
+ (child) => stripOpfPrefix(child, prefix)
85
+ )
86
+ };
87
+ if (node[":@"]) {
88
+ element[":@"] = renameOpfAttributes(node[":@"], prefix);
89
+ }
90
+ return element;
91
+ }
92
+ function normalizeOpfNamespacePrefixes(doc) {
93
+ var _a;
94
+ const root = doc.find(
95
+ (node) => !Epub.isXmlTextNode(node) && Epub.getXmlElementName(node).endsWith(":package")
96
+ );
97
+ if (!root) return doc;
98
+ const prefix = Epub.getXmlElementName(root).slice(0, -":package".length);
99
+ if (((_a = root[":@"]) == null ? void 0 : _a[`@_xmlns:${prefix}`]) !== OPF_NAMESPACE) return doc;
100
+ const stripped = stripOpfPrefix(root, prefix);
101
+ stripped[":@"] = {
102
+ ...stripped[":@"],
103
+ "@_xmlns": OPF_NAMESPACE,
104
+ "@_xmlns:opf": OPF_NAMESPACE
105
+ };
106
+ doc[doc.indexOf(root)] = stripped;
107
+ return doc;
108
+ }
54
109
  class Epub {
55
110
  /**
56
111
  * Prefer the static factories ({@link Epub.using}, {@link Epub.from},
@@ -331,14 +386,7 @@ ${JSON.stringify(element, null, 2)}`
331
386
  );
332
387
  }
333
388
  const rootfile = await this.getRootfile();
334
- const filename = this.resolveInternalHref(rootfile, href);
335
- await this.adapter.remove(filename);
336
- }
337
- async getFileData(path, encoding) {
338
- if (encoding) {
339
- return this.adapter.read(path, encoding);
340
- }
341
- return this.adapter.read(path);
389
+ await this.adapter.remove(await this.resolveEntry(rootfile, href));
342
390
  }
343
391
  /**
344
392
  * Length of the underlying archive entry for a manifest item, in bytes
@@ -351,14 +399,15 @@ ${JSON.stringify(element, null, 2)}`
351
399
  const manifestItem = manifest[id];
352
400
  if (!manifestItem)
353
401
  throw new Error(`Could not find item with id "${id}" in manifest`);
354
- const path = this.resolveInternalHref(rootfile, manifestItem.href);
355
- return this.adapter.archiveLength(path);
402
+ return this.adapter.archiveLength(
403
+ await this.resolveEntry(rootfile, manifestItem.href)
404
+ );
356
405
  }
357
406
  async getRootfile() {
358
407
  var _a;
359
408
  if (this.rootfile !== null) return this.rootfile;
360
- const containerString = await this.getFileData(
361
- (0, import_path.join)(this.adapter.rootPath, "META-INF", "container.xml"),
409
+ const containerString = await this.adapter.read(
410
+ "META-INF/container.xml",
362
411
  "utf-8"
363
412
  );
364
413
  if (!containerString)
@@ -382,7 +431,7 @@ ${JSON.stringify(element, null, 2)}`
382
431
  Epub.getXmlChildren(rootfiles),
383
432
  (node) => {
384
433
  var _a2;
385
- return !Epub.isXmlTextNode(node) && ((_a2 = node[":@"]) == null ? void 0 : _a2["@_media-type"]) === "application/oebps-package+xml";
434
+ return !Epub.isXmlTextNode(node) && ((_a2 = node[":@"]) == null ? void 0 : _a2["@_media-type"]) === import_media_types.MediaType.OPF.mime;
386
435
  }
387
436
  );
388
437
  const fullPath = (_a = rootfile == null ? void 0 : rootfile[":@"]) == null ? void 0 : _a["@_full-path"];
@@ -390,12 +439,12 @@ ${JSON.stringify(element, null, 2)}`
390
439
  throw new Error(
391
440
  "Failed to parse EPUB container.xml: Found no rootfile element"
392
441
  );
393
- this.rootfile = (0, import_path.resolve)(this.adapter.rootPath, fullPath);
442
+ this.rootfile = await this.resolveEntry("", fullPath);
394
443
  return this.rootfile;
395
444
  }
396
445
  async getPackageDocument() {
397
446
  const rootfile = await this.getRootfile();
398
- const packageDocumentString = await this.getFileData(rootfile, "utf-8");
447
+ const packageDocumentString = await this.adapter.read(rootfile, "utf-8");
399
448
  if (!packageDocumentString)
400
449
  throw new Error(
401
450
  `Failed to parse EPUB: could not find package document at ${rootfile}`
@@ -403,7 +452,7 @@ ${JSON.stringify(element, null, 2)}`
403
452
  const packageDocument = Epub.xmlParser.parse(
404
453
  packageDocumentString
405
454
  );
406
- return packageDocument;
455
+ return normalizeOpfNamespacePrefixes(packageDocument);
407
456
  }
408
457
  async getPackageElement() {
409
458
  const packageDocument = await this.getPackageDocument();
@@ -582,12 +631,15 @@ ${JSON.stringify(element, null, 2)}`
582
631
  return metadata;
583
632
  }
584
633
  /**
585
- * Retrieve the identifier from the dc:identifier element
634
+ * Retrieve the first identifier from the dc:identifier element
586
635
  * in the EPUB metadata.
587
636
  *
588
637
  * If there is no dc:identifier element, returns null.
589
638
  *
590
639
  * @link https://www.w3.org/TR/epub-33/#sec-opf-dcidentifier
640
+ *
641
+ * @deprecated Use {@link getUniqueIdentifier} instead to get the unique identifier,
642
+ * or {@link getIdentifiers} to get all identifiers.
591
643
  */
592
644
  async getIdentifier() {
593
645
  const metadata = await this.getMetadata();
@@ -601,6 +653,8 @@ ${JSON.stringify(element, null, 2)}`
601
653
  * Otherwise creates a new element
602
654
  *
603
655
  * @link https://www.w3.org/TR/epub-33/#sec-opf-dcidentifier
656
+ *
657
+ * @deprecated Use {@link setUniqueIdentifier} instead.
604
658
  */
605
659
  async setIdentifier(identifier) {
606
660
  await this.replaceMetadata(({ type }) => type === "dc:identifier", {
@@ -609,6 +663,395 @@ ${JSON.stringify(element, null, 2)}`
609
663
  value: identifier
610
664
  });
611
665
  }
666
+ /**
667
+ * Retrieve the identifier with the unique identifier id
668
+ * in the EPUB metadata.
669
+ *
670
+ * If there is no unique identifier id, returns the first dc:identifier element.
671
+ *
672
+ * @link https://www.w3.org/TR/epub-33/#sec-opf-dcidentifier
673
+ */
674
+ async getUniqueIdentifier() {
675
+ const metadata = await this.getMetadata();
676
+ const uniqueId = await this.getUniqueIdentifierId();
677
+ if (!uniqueId) {
678
+ return null;
679
+ }
680
+ const entry = metadata.find(
681
+ ({ type, id }) => type === "dc:identifier" && id === uniqueId
682
+ );
683
+ return (entry == null ? void 0 : entry.value) ?? null;
684
+ }
685
+ /**
686
+ * Set the unique identifier id for the EPUB.
687
+ *
688
+ * Updates the existing dc:identifier element referenced by the unique identifier id if one exists.
689
+ * Otherwise creates a new element with the provided identifier, and sets the unique identifier id to the new element's id.
690
+ *
691
+ * Note: you likely shouldn't change the unique identifier id unless you are producing a new EPUB.
692
+ *
693
+ * @link https://www.w3.org/TR/epub-33/#sec-opf-dcidentifier
694
+ */
695
+ async setUniqueIdentifier(identifier) {
696
+ await this.withPackage(async (packageElement) => {
697
+ const metadata = Epub.findXmlChildByName(
698
+ "metadata",
699
+ Epub.getXmlChildren(packageElement)
700
+ );
701
+ if (!metadata) {
702
+ throw new Error(
703
+ "Failed to parse EPUB: found no metadata element in package document"
704
+ );
705
+ }
706
+ let uniqueId = await this.getUniqueIdentifierId();
707
+ if (!uniqueId) {
708
+ const newUniqueId = (0, import_nanoid.nanoid)();
709
+ packageElement[":@"] = {
710
+ ...packageElement[":@"],
711
+ "@_unique-identifier": newUniqueId
712
+ };
713
+ uniqueId = newUniqueId;
714
+ }
715
+ const children = Epub.getXmlChildren(metadata);
716
+ const entry = Epub.findXmlChildByName(
717
+ "dc:identifier",
718
+ children,
719
+ (node) => {
720
+ var _a;
721
+ return ((_a = node[":@"]) == null ? void 0 : _a["@_id"]) === uniqueId;
722
+ }
723
+ );
724
+ if (entry) {
725
+ children.splice(
726
+ children.indexOf(entry),
727
+ 1,
728
+ Epub.createXmlElement("dc:identifier", { id: uniqueId }, [
729
+ Epub.createXmlTextNode(identifier)
730
+ ])
731
+ );
732
+ return;
733
+ }
734
+ children.push(
735
+ Epub.createXmlElement("dc:identifier", { id: uniqueId }, [
736
+ Epub.createXmlTextNode(identifier)
737
+ ])
738
+ );
739
+ });
740
+ }
741
+ /**
742
+ * Retrieve the id of the publication's unique identifier, as declared by the
743
+ * package element's `unique-identifier` attribute.
744
+ *
745
+ * @link https://www.w3.org/TR/epub-33/#sec-opf-dcidentifier
746
+ */
747
+ async getUniqueIdentifierId() {
748
+ var _a;
749
+ const packageElement = await this.getPackageElement();
750
+ return ((_a = packageElement[":@"]) == null ? void 0 : _a["@_unique-identifier"]) ?? null;
751
+ }
752
+ /**
753
+ * Collect `dc:identifier` or `dc:source` entries, attaching the value and
754
+ * scheme of any refining `identifier-type` meta (spec D.3.8) and a legacy
755
+ * `opf:scheme` attribute. Values are not interpreted.
756
+ *
757
+ * `onRefinement` is invoked for every other meta refining a collected entry,
758
+ * so callers can surface element-specific refinements (e.g. `source-of` on a
759
+ * `dc:source`).
760
+ */
761
+ static collectDcEntries(metadata, type, onRefinement) {
762
+ const entries = metadata.filter((entry) => entry.type === type && entry.value !== void 0).map((entry) => ({
763
+ // filtered above, so value is defined
764
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
765
+ value: entry.value,
766
+ ...entry.id && { id: entry.id },
767
+ ...entry.properties["opf:scheme"] && {
768
+ scheme: entry.properties["opf:scheme"]
769
+ }
770
+ }));
771
+ for (const meta of metadata) {
772
+ if (meta.type !== "meta" || meta.value === void 0) continue;
773
+ const property = meta.properties["property"];
774
+ const refines = meta.properties["refines"];
775
+ if (!property || !refines) continue;
776
+ const target = entries.find((t) => t.id === refines.slice(1));
777
+ if (!target) continue;
778
+ if (property === "identifier-type") {
779
+ target.identifierType = meta.value;
780
+ if (meta.properties["scheme"]) target.scheme = meta.properties["scheme"];
781
+ } else {
782
+ onRefinement == null ? void 0 : onRefinement(target, property, meta.value);
783
+ }
784
+ }
785
+ return entries;
786
+ }
787
+ /**
788
+ * Retrieve every `dc:identifier` entry, returned as found.
789
+ *
790
+ * Values are not interpreted. Any refining `identifier-type` meta (spec
791
+ * D.3.8) or legacy `opf:scheme` attribute is surfaced on the entry, but no
792
+ * parsing of the value itself is attempted. To read `dc:source` entries, use
793
+ * {@link getSources}.
794
+ *
795
+ * @link https://www.w3.org/TR/epub-33/#sec-opf-dcidentifier
796
+ */
797
+ async getIdentifiers() {
798
+ const metadata = await this.getMetadata();
799
+ return Epub.collectDcEntries(metadata, "dc:identifier");
800
+ }
801
+ /**
802
+ * Retrieve every `dc:source` entry, returned as found.
803
+ *
804
+ * Like {@link getIdentifiers}, values are not interpreted. In addition to a
805
+ * refining `identifier-type`, a refining `source-of` meta (spec D.3.11) is
806
+ * surfaced as `sourceOf`.
807
+ *
808
+ * @link https://www.w3.org/TR/epub-33/#sec-opf-dcsource
809
+ */
810
+ async getSources() {
811
+ const metadata = await this.getMetadata();
812
+ return Epub.collectDcEntries(
813
+ metadata,
814
+ "dc:source",
815
+ (source, property) => {
816
+ if (property === "source-of") {
817
+ source.isPageBreakSource = true;
818
+ }
819
+ }
820
+ );
821
+ }
822
+ /**
823
+ * Retrieve the `pageBreakSource` property (EPUB 3.4, spec D.2.9), the
824
+ * publication-level source for the source of its page break markers.
825
+ *
826
+ * This property replaces the refining `source-of="pagination"` meta (spec
827
+ * D.3.11), see {@link EpubSource.sourceOf}. If no `pageBreakSource` property is found,
828
+ * we fall back to finding a `dc:source` with a `source-of="pagination"` refinement.
829
+ *
830
+ * @link https://www.w3.org/TR/epub/#pageBreakSource
831
+ */
832
+ async getPageBreakSource() {
833
+ const entry = await this.findMetadataItem(
834
+ (item) => item.type === "meta" && item.properties["property"] === "pageBreakSource" && !!item.value
835
+ );
836
+ if (entry) {
837
+ return {
838
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
839
+ value: entry.value,
840
+ id: entry.id,
841
+ identifierType: void 0,
842
+ scheme: void 0,
843
+ isPageBreakSource: true
844
+ };
845
+ }
846
+ const sources = await this.getSources();
847
+ const source = sources.find((source2) => source2.isPageBreakSource);
848
+ if (source) {
849
+ return source;
850
+ }
851
+ return null;
852
+ }
853
+ /**
854
+ * Set the `pageBreakSource` property (EPUB 3.4, spec D.2.9), or remove it when
855
+ * passed null. Replaces an existing `pageBreakSource` meta if present.
856
+ *
857
+ * Pass `none` to indicate the pagination is unique to this publication.
858
+ *
859
+ * @link https://www.w3.org/TR/epub/#pageBreakSource
860
+ */
861
+ async setPageBreakSource(value) {
862
+ if (value === null) {
863
+ await this.removeMetadata(
864
+ (item) => item.properties["property"] === "pageBreakSource"
865
+ );
866
+ return;
867
+ }
868
+ await this.replaceMetadata(
869
+ (item) => item.properties["property"] === "pageBreakSource",
870
+ { type: "meta", properties: { property: "pageBreakSource" }, value }
871
+ );
872
+ }
873
+ /**
874
+ * Replace the publication's `dc:identifier` entries.
875
+ *
876
+ * This replaces ALL existing `dc:identifier` elements except the publication's
877
+ * unique identifier (the one referenced by the package element's
878
+ * `unique-identifier` attribute), which is always preserved and must not be
879
+ * included in the provided list. If included anyway, it is ignored. See
880
+ * {@link setUniqueIdentifier} to change it.
881
+ *
882
+ * Identifiers are placed in the order they are provided.
883
+ *
884
+ * `dc:source` entries are not touched, use {@link setSources} for those.
885
+ *
886
+ * When an entry has an `identifierType`, it is written in the refining form
887
+ * (a `meta` with `property="identifier-type"`, carrying the `scheme`
888
+ * attribute when provided). An entry with only a `scheme` is written using
889
+ * the legacy `opf:scheme` attribute.
890
+ *
891
+ * @link https://www.w3.org/TR/epub-33/#sec-opf-dcidentifier
892
+ */
893
+ async setIdentifiers(identifiers) {
894
+ const uniqueId = await this.getUniqueIdentifierId();
895
+ await this.withPackage((packageElement) => {
896
+ var _a;
897
+ const metadata = Epub.findXmlChildByName(
898
+ "metadata",
899
+ Epub.getXmlChildren(packageElement)
900
+ );
901
+ if (!metadata)
902
+ throw new Error(
903
+ "Failed to parse EPUB: found no metadata element in package document"
904
+ );
905
+ const children = Epub.getXmlChildren(metadata);
906
+ const removedIds = /* @__PURE__ */ new Set();
907
+ for (let i = children.length - 1; i >= 0; i--) {
908
+ const node = children[i];
909
+ if (Epub.isXmlTextNode(node) || Epub.getXmlElementName(node) !== "dc:identifier") {
910
+ continue;
911
+ }
912
+ const id = (_a = node[":@"]) == null ? void 0 : _a["@_id"];
913
+ if (id && id === uniqueId) {
914
+ continue;
915
+ }
916
+ if (id) {
917
+ removedIds.add(id);
918
+ }
919
+ children.splice(i, 1);
920
+ }
921
+ Epub.removeRefiningMetas(children, removedIds, ["identifier-type"]);
922
+ for (const identifier of identifiers) {
923
+ if (identifier.id && identifier.id === uniqueId) continue;
924
+ const id = identifier.id ?? (identifier.identifierType !== void 0 ? (0, import_nanoid.nanoid)() : void 0);
925
+ children.push(
926
+ Epub.createXmlElement(
927
+ "dc:identifier",
928
+ {
929
+ ...id && { id },
930
+ ...identifier.scheme && identifier.identifierType === void 0 && {
931
+ "opf:scheme": identifier.scheme
932
+ }
933
+ },
934
+ [Epub.createXmlTextNode(identifier.value)]
935
+ )
936
+ );
937
+ if (identifier.identifierType !== void 0 && id) {
938
+ children.push(
939
+ Epub.createXmlElement(
940
+ "meta",
941
+ {
942
+ refines: `#${id}`,
943
+ property: "identifier-type",
944
+ ...identifier.scheme && { scheme: identifier.scheme }
945
+ },
946
+ [Epub.createXmlTextNode(identifier.identifierType)]
947
+ )
948
+ );
949
+ }
950
+ }
951
+ });
952
+ }
953
+ /**
954
+ * Remove `meta` refinements pointing at any of the given ids,
955
+ * restricted to the given `property` values as a cleanup step
956
+ * Mutates `children` in place.
957
+ */
958
+ static removeRefiningMetas(children, ids, properties) {
959
+ var _a, _b, _c;
960
+ for (let i = children.length - 1; i >= 0; i--) {
961
+ const node = children[i];
962
+ if (Epub.isXmlTextNode(node) || Epub.getXmlElementName(node) !== "meta") {
963
+ continue;
964
+ }
965
+ const property = (_a = node[":@"]) == null ? void 0 : _a["@_property"];
966
+ if (!property || !properties.includes(property)) continue;
967
+ const refines = (_c = (_b = node[":@"]) == null ? void 0 : _b["@_refines"]) == null ? void 0 : _c.slice(1);
968
+ if (refines && ids.has(refines)) {
969
+ children.splice(i, 1);
970
+ }
971
+ }
972
+ }
973
+ /**
974
+ * Replace the publication's `dc:source` entries.
975
+ *
976
+ * This replaces ALL existing `dc:source` elements (and their refining
977
+ * `identifier-type` / `source-of` metas). `dc:identifier` entries are not
978
+ * touched; use {@link setIdentifiers} for those. Pass an empty array to
979
+ * remove all sources.
980
+ *
981
+ * When an entry has an `identifierType`, it is written in the refining form.
982
+ * A `sourceOf` value is written as a refining `source-of` meta (spec D.3.11).
983
+ * An entry with only a `scheme` uses the legacy `opf:scheme` attribute.
984
+ *
985
+ * @link https://www.w3.org/TR/epub-33/#sec-opf-dcsource
986
+ */
987
+ async setSources(sources) {
988
+ await this.withPackage((packageElement) => {
989
+ var _a;
990
+ const metadata = Epub.findXmlChildByName(
991
+ "metadata",
992
+ Epub.getXmlChildren(packageElement)
993
+ );
994
+ if (!metadata)
995
+ throw new Error(
996
+ "Failed to parse EPUB: found no metadata element in package document"
997
+ );
998
+ const children = Epub.getXmlChildren(metadata);
999
+ const removedIds = /* @__PURE__ */ new Set();
1000
+ for (let i = children.length - 1; i >= 0; i--) {
1001
+ const node = children[i];
1002
+ if (Epub.isXmlTextNode(node) || Epub.getXmlElementName(node) !== "dc:source") {
1003
+ continue;
1004
+ }
1005
+ const id = (_a = node[":@"]) == null ? void 0 : _a["@_id"];
1006
+ if (id) {
1007
+ removedIds.add(id);
1008
+ }
1009
+ children.splice(i, 1);
1010
+ }
1011
+ Epub.removeRefiningMetas(children, removedIds, [
1012
+ "identifier-type",
1013
+ "source-of"
1014
+ ]);
1015
+ for (const source of sources) {
1016
+ const needsId = source.identifierType !== void 0 || source.isPageBreakSource;
1017
+ const id = source.id ?? (needsId ? (0, import_nanoid.nanoid)() : void 0);
1018
+ children.push(
1019
+ Epub.createXmlElement(
1020
+ "dc:source",
1021
+ {
1022
+ ...id && { id },
1023
+ ...source.scheme && source.identifierType === void 0 && {
1024
+ "opf:scheme": source.scheme
1025
+ }
1026
+ },
1027
+ [Epub.createXmlTextNode(source.value)]
1028
+ )
1029
+ );
1030
+ if (source.identifierType !== void 0 && id) {
1031
+ children.push(
1032
+ Epub.createXmlElement(
1033
+ "meta",
1034
+ {
1035
+ refines: `#${id}`,
1036
+ property: "identifier-type",
1037
+ ...source.scheme && { scheme: source.scheme }
1038
+ },
1039
+ [Epub.createXmlTextNode(source.identifierType)]
1040
+ )
1041
+ );
1042
+ }
1043
+ if (source.isPageBreakSource && id) {
1044
+ children.push(
1045
+ Epub.createXmlElement(
1046
+ "meta",
1047
+ { refines: `#${id}`, property: "source-of" },
1048
+ [Epub.createXmlTextNode("pagination")]
1049
+ )
1050
+ );
1051
+ }
1052
+ }
1053
+ });
1054
+ }
612
1055
  /**
613
1056
  * Even "EPUB 3" publications sometimes still only use the
614
1057
  * EPUB 2 specification for identifying the cover image.
@@ -681,11 +1124,12 @@ ${JSON.stringify(element, null, 2)}`
681
1124
  * the provided href within the publication.
682
1125
  */
683
1126
  async setCoverImage(href, data) {
1127
+ var _a;
684
1128
  const coverImageItem = await this.getCoverImageItem();
685
1129
  if (coverImageItem) {
686
1130
  await this.removeManifestItem(coverImageItem.id);
687
1131
  }
688
- const mediaType = (0, import_mime_types.lookup)(href);
1132
+ const mediaType = (_a = import_media_types.MediaType.fromPath(href)) == null ? void 0 : _a.mime;
689
1133
  if (!mediaType)
690
1134
  throw new Error(`Invalid file extension for cover image: ${href}`);
691
1135
  await this.addManifestItem(
@@ -1664,35 +2108,81 @@ ${JSON.stringify(element, null, 2)}`
1664
2108
  return this.getNavigation("page-list", { resolveToRoot });
1665
2109
  }
1666
2110
  /**
1667
- * Returns a Zip Entry path for an HREF
2111
+ * Name the entry a resolved URL addresses.
2112
+ *
2113
+ * A correctly authored publication percent-encodes its hrefs, so the
2114
+ * decoded reading is the one the spec calls for and the one we prefer.
2115
+ * Some publications instead write the entry name verbatim, so when the two
2116
+ * readings differ we ask the archive which one it holds. A verbatim name
2117
+ * carrying a bare `%`, as `100%.xhtml` does, is not a valid encoding at all
2118
+ * and can only be its own answer.
1668
2119
  */
1669
- resolveInternalHref(from, href) {
1670
- const startPath = (0, import_path.dirname)(from);
1671
- return (0, import_path.resolve)(
1672
- this.adapter.rootPath,
1673
- (0, import_path.hrefToPlatformPath)(startPath),
1674
- (0, import_path.hrefToPlatformPath)(href)
1675
- );
2120
+ async entryForUrl(url) {
2121
+ const asWritten = url.pathname.slice(1);
2122
+ let decoded;
2123
+ try {
2124
+ decoded = decodeURIComponent(asWritten);
2125
+ } catch {
2126
+ return asWritten;
2127
+ }
2128
+ if (decoded === asWritten) {
2129
+ return decoded;
2130
+ }
2131
+ if (await this.adapter.exists(decoded)) {
2132
+ return decoded;
2133
+ }
2134
+ return await this.adapter.exists(asWritten) ? asWritten : decoded;
2135
+ }
2136
+ /**
2137
+ * Resolve an href to the name of the archive entry it addresses.
2138
+ *
2139
+ * @param from The entry the href appears in; it resolves against that
2140
+ * entry's directory
2141
+ * @throws when the href addresses something outside the publication
2142
+ */
2143
+ async resolveEntry(from, href) {
2144
+ const url = resolveEntryUrl(from, href);
2145
+ if (!url) {
2146
+ throw new Error(
2147
+ `href does not address an entry in this publication: ${href}`
2148
+ );
2149
+ }
2150
+ return this.entryForUrl(url);
1676
2151
  }
1677
2152
  /**
1678
2153
  * Returns a path-relative-scheme-less URL, relative to the
1679
2154
  * container root.
1680
2155
  *
2156
+ * An href that points outside the publication, such as an external link in
2157
+ * a navigation document, is handed back unchanged.
2158
+ *
1681
2159
  * @param href The href to resolve
1682
2160
  * @param [relativeTo] Optional - The href to resolve this href relative to.
1683
2161
  Use if resolving a relative href from a file other than the package document.
1684
2162
  */
1685
2163
  async resolveHref(href, relativeTo, { toRoot } = {}) {
2164
+ if (href.startsWith("#")) return href;
1686
2165
  const rootfile = await this.getRootfile();
1687
- const from = relativeTo ? this.resolveInternalHref(rootfile, relativeTo) : rootfile;
1688
- const path = this.resolveInternalHref(from, href);
1689
- return path.replace(toRoot ? this.adapter.rootPath : (0, import_path.dirname)(rootfile), "").slice(1);
2166
+ const from = relativeTo ? await this.resolveEntry(rootfile, relativeTo) : rootfile;
2167
+ const url = resolveEntryUrl(from, href);
2168
+ if (!url) return href;
2169
+ const entry = (await this.entryForUrl(url)).split("/");
2170
+ const base = toRoot ? [] : rootfile.split("/").slice(0, -1);
2171
+ let shared = 0;
2172
+ while (shared < base.length && base[shared] === entry[shared]) {
2173
+ shared++;
2174
+ }
2175
+ const relative = [
2176
+ ...Array(base.length - shared).fill(".."),
2177
+ ...entry.slice(shared).map(encodeURIComponent)
2178
+ ];
2179
+ return relative.join("/") + url.hash;
1690
2180
  }
1691
2181
  async readFileContents(href, relativeTo, encoding) {
1692
2182
  const rootfile = await this.getRootfile();
1693
- const from = relativeTo ? this.resolveInternalHref(rootfile, relativeTo) : rootfile;
1694
- const path = this.resolveInternalHref(from, href);
1695
- const itemEntry = encoding ? await this.getFileData(path, encoding) : await this.getFileData(path);
2183
+ const from = relativeTo ? await this.resolveEntry(rootfile, relativeTo) : rootfile;
2184
+ const entry = await this.resolveEntry(from, href);
2185
+ const itemEntry = encoding ? await this.adapter.read(entry, encoding) : await this.adapter.read(entry);
1696
2186
  return itemEntry;
1697
2187
  }
1698
2188
  async readItemContents(id, encoding) {
@@ -1701,8 +2191,8 @@ ${JSON.stringify(element, null, 2)}`
1701
2191
  const manifestItem = manifest[id];
1702
2192
  if (!manifestItem)
1703
2193
  throw new Error(`Could not find item with id "${id}" in manifest`);
1704
- const path = this.resolveInternalHref(rootfile, manifestItem.href);
1705
- const itemEntry = encoding ? await this.getFileData(path, encoding) : await this.getFileData(path);
2194
+ const entry = await this.resolveEntry(rootfile, manifestItem.href);
2195
+ const itemEntry = encoding ? await this.adapter.read(entry, encoding) : await this.adapter.read(entry);
1706
2196
  return itemEntry;
1707
2197
  }
1708
2198
  /**
@@ -1760,11 +2250,11 @@ ${JSON.stringify(element, null, 2)}`
1760
2250
  if (!manifestItem)
1761
2251
  throw new Error(`Could not find item with id "${id}" in manifest`);
1762
2252
  import_mem.default.clear(this.readXhtmlItemContents);
1763
- const href = this.resolveInternalHref(rootfile, manifestItem.href);
2253
+ const entry = await this.resolveEntry(rootfile, manifestItem.href);
1764
2254
  if (encoding === "utf-8") {
1765
- await this.writeEntryContents(href, contents, encoding);
2255
+ await this.writeEntryContents(entry, contents, encoding);
1766
2256
  } else {
1767
- await this.writeEntryContents(href, contents);
2257
+ await this.writeEntryContents(entry, contents);
1768
2258
  }
1769
2259
  }
1770
2260
  /**
@@ -1835,7 +2325,7 @@ ${JSON.stringify(element, null, 2)}`
1835
2325
  });
1836
2326
  this.manifest = null;
1837
2327
  const rootfile = await this.getRootfile();
1838
- const filename = this.resolveInternalHref(rootfile, item.href);
2328
+ const filename = await this.resolveEntry(rootfile, item.href);
1839
2329
  const data = encoding === "utf-8" || encoding === "xml" ? new TextEncoder().encode(
1840
2330
  encoding === "utf-8" ? contents : await Epub.xmlBuilder.build(
1841
2331
  contents
@@ -2019,10 +2509,7 @@ ${JSON.stringify(element, null, 2)}`
2019
2509
  );
2020
2510
  const spineTocId = (_a = spine == null ? void 0 : spine[":@"]) == null ? void 0 : _a["@_toc"];
2021
2511
  const ncxItem = spineTocId ? manifest[spineTocId] : Object.values(manifest).find(
2022
- (item) => {
2023
- var _a2;
2024
- return ((_a2 = item.mediaType) == null ? void 0 : _a2.toLowerCase()) === "application/x-dtbncx+xml";
2025
- }
2512
+ (item) => import_media_types.MediaType.fromMime(item.mediaType ?? "") === import_media_types.MediaType.NCX
2026
2513
  );
2027
2514
  if (!ncxItem) return [];
2028
2515
  const ncxContent = await this.readItemContents(ncxItem.id, "utf-8");
@@ -2163,7 +2650,12 @@ class EpubFactory {
2163
2650
  "This is not a valid EPUB publication. Could not read the package document."
2164
2651
  );
2165
2652
  }
2166
- await Epub.assertEpub3(epub);
2653
+ try {
2654
+ await Epub.assertEpub3(epub);
2655
+ } catch (error) {
2656
+ epub.discardAndClose();
2657
+ throw error;
2658
+ }
2167
2659
  return epub;
2168
2660
  }
2169
2661
  /**
@@ -2203,14 +2695,11 @@ class EpubFactory {
2203
2695
  const container = encoder.encode(`<?xml version="1.0"?>
2204
2696
  <container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
2205
2697
  <rootfiles>
2206
- <rootfile media-type="application/oebps-package+xml" full-path="OEBPS/content.opf"/>
2698
+ <rootfile media-type="${import_media_types.MediaType.OPF.mime}" full-path="OEBPS/content.opf"/>
2207
2699
  </rootfiles>
2208
2700
  </container>
2209
2701
  `);
2210
- await adapter.write(
2211
- (0, import_path.join)(adapter.rootPath, "META-INF", "container.xml"),
2212
- container
2213
- );
2702
+ await adapter.write("META-INF/container.xml", container);
2214
2703
  const packageDocument = encoder.encode(`<?xml version="1.0"?>
2215
2704
  <package unique-identifier="pub-id" dir="${language.textInfo.direction}" xml:lang="${language.toString()}" version="3.0" xmlns:dc="http://purl.org/dc/elements/1.1/">
2216
2705
  <metadata>
@@ -2221,10 +2710,7 @@ class EpubFactory {
2221
2710
  </spine>
2222
2711
  </package>
2223
2712
  `);
2224
- await adapter.write(
2225
- (0, import_path.join)(adapter.rootPath, "OEBPS", "content.opf"),
2226
- packageDocument
2227
- );
2713
+ await adapter.write("OEBPS/content.opf", packageDocument);
2228
2714
  const epub = new Epub(this.adapterClass, adapter, path);
2229
2715
  const metadata = [
2230
2716
  {
@@ -2273,7 +2759,6 @@ class EpubFactory {
2273
2759
  * @throws when the adapter is read-only
2274
2760
  */
2275
2761
  async upgrade(path, options = {}) {
2276
- var _a;
2277
2762
  if (!this.adapterClass.capabilities.writable) {
2278
2763
  throw new EpubReadOnlyError(
2279
2764
  `adapter ${this.adapterClass.kind} is read-only; cannot upgrade`
@@ -2299,49 +2784,55 @@ class EpubFactory {
2299
2784
  "This is not a valid EPUB publication. Could not read the package document."
2300
2785
  );
2301
2786
  }
2302
- const version = await epub.getVersion();
2303
- if (version.startsWith("3.")) {
2304
- return epub;
2305
- }
2306
- const tocEntries = await epub.getNcxTableOfContents();
2307
- let landmarks = [];
2308
- await epub.withPackage((pkg) => {
2309
- landmarks = Upgrade.extractGuideLandmarks(pkg);
2310
- Upgrade.upgradePackageMetadata(pkg);
2311
- Upgrade.fixFontMimeTypes(pkg);
2312
- Upgrade.removeGuide(pkg);
2787
+ try {
2788
+ const version = await epub.getVersion();
2789
+ if (version.startsWith("3.")) {
2790
+ return epub;
2791
+ }
2792
+ const tocEntries = await epub.getNcxTableOfContents();
2793
+ let landmarks = [];
2794
+ await epub.withPackage((pkg) => {
2795
+ landmarks = Upgrade.extractGuideLandmarks(pkg);
2796
+ Upgrade.upgradePackageMetadata(pkg);
2797
+ Upgrade.fixFontMimeTypes(pkg);
2798
+ Upgrade.removeGuide(pkg);
2799
+ if (removeNcx) {
2800
+ Upgrade.removeSpineTocRef(pkg);
2801
+ }
2802
+ Upgrade.setPackageVersion(pkg, "3.0");
2803
+ });
2804
+ await Upgrade.collectManifestProperties(epub);
2313
2805
  if (removeNcx) {
2314
- Upgrade.removeSpineTocRef(pkg);
2806
+ await Upgrade.removeNcx(epub);
2315
2807
  }
2316
- Upgrade.setPackageVersion(pkg, "3.0");
2317
- });
2318
- await Upgrade.collectManifestProperties(epub);
2319
- if (removeNcx) {
2320
- await Upgrade.removeNcx(epub);
2321
- }
2322
- const navHref = await Upgrade.chooseNavHref(epub);
2323
- const navContent = await Upgrade.buildNavDocument(
2324
- epub,
2325
- tocEntries,
2326
- landmarks
2327
- );
2328
- await epub.addManifestItem(
2329
- {
2330
- id: "nav",
2331
- href: navHref,
2332
- mediaType: "application/xhtml+xml",
2333
- properties: ["nav"]
2334
- },
2335
- navContent,
2336
- "utf-8"
2337
- );
2338
- const manifest = await epub.getManifest();
2339
- for (const item of Object.values(manifest)) {
2340
- if (((_a = item.mediaType) == null ? void 0 : _a.toLowerCase()) !== "application/xhtml+xml") continue;
2341
- const contents = await epub.readXhtmlItemContents(item.id);
2342
- await epub.writeXhtmlItemContents(item.id, contents);
2808
+ const navHref = await Upgrade.chooseNavHref(epub);
2809
+ const navContent = await Upgrade.buildNavDocument(
2810
+ epub,
2811
+ tocEntries,
2812
+ landmarks
2813
+ );
2814
+ await epub.addManifestItem(
2815
+ {
2816
+ id: "nav",
2817
+ href: navHref,
2818
+ mediaType: import_media_types.MediaType.XHTML.mime,
2819
+ properties: ["nav"]
2820
+ },
2821
+ navContent,
2822
+ "utf-8"
2823
+ );
2824
+ const manifest = await epub.getManifest();
2825
+ for (const item of Object.values(manifest)) {
2826
+ if (import_media_types.MediaType.fromMime(item.mediaType ?? "") !== import_media_types.MediaType.XHTML)
2827
+ continue;
2828
+ const contents = await epub.readXhtmlItemContents(item.id);
2829
+ await epub.writeXhtmlItemContents(item.id, contents);
2830
+ }
2831
+ return epub;
2832
+ } catch (error) {
2833
+ epub.discardAndClose();
2834
+ throw error;
2343
2835
  }
2344
- return epub;
2345
2836
  }
2346
2837
  }
2347
2838
  // Annotate the CommonJS export names for ESM import in node: