@likecoin/epubcheck-ts 0.5.0 → 0.6.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.cjs CHANGED
@@ -1668,143 +1668,79 @@ var CSSValidator = class {
1668
1668
  }
1669
1669
  };
1670
1670
 
1671
- // src/vocab/epub-ssv.ts
1672
- var EPUB_SSV_DEPRECATED = /* @__PURE__ */ new Set([
1673
- "annoref",
1674
- "annotation",
1675
- "biblioentry",
1676
- "bridgehead",
1677
- "endnote",
1678
- "help",
1679
- "marginalia",
1680
- "note",
1681
- "rearnote",
1682
- "rearnotes",
1683
- "sidebar",
1684
- "subchapter",
1685
- "warning"
1686
- ]);
1687
- var EPUB_SSV_DISALLOWED_ON_CONTENT = /* @__PURE__ */ new Set([
1688
- "aside",
1689
- "figure",
1690
- "list",
1691
- "list-item",
1692
- "table",
1693
- "table-cell",
1694
- "table-row"
1695
- ]);
1696
- var EPUB_SSV_ALL = /* @__PURE__ */ new Set([
1697
- ...EPUB_SSV_DEPRECATED,
1698
- ...EPUB_SSV_DISALLOWED_ON_CONTENT,
1699
- "abstract",
1700
- "acknowledgments",
1701
- "afterword",
1702
- "appendix",
1703
- "assessment",
1704
- "assessments",
1705
- "backlink",
1706
- "backmatter",
1707
- "balloon",
1708
- "bibliography",
1709
- "biblioref",
1710
- "bodymatter",
1711
- "case-study",
1712
- "chapter",
1713
- "colophon",
1714
- "concluding-sentence",
1715
- "conclusion",
1716
- "contributors",
1717
- "copyright-page",
1718
- "cover",
1719
- "covertitle",
1720
- "credit",
1721
- "credits",
1722
- "dedication",
1723
- "division",
1724
- "endnotes",
1725
- "epigraph",
1726
- "epilogue",
1727
- "errata",
1728
- "fill-in-the-blank-problem",
1729
- "footnote",
1730
- "footnotes",
1731
- "foreword",
1732
- "frontmatter",
1733
- "fulltitle",
1734
- "general-problem",
1735
- "glossary",
1736
- "glossdef",
1737
- "glossref",
1738
- "glossterm",
1739
- "halftitle",
1740
- "halftitlepage",
1741
- "imprimatur",
1742
- "imprint",
1743
- "index",
1744
- "index-editor-note",
1745
- "index-entry",
1746
- "index-entry-list",
1747
- "index-group",
1748
- "index-headnotes",
1749
- "index-legend",
1750
- "index-locator",
1751
- "index-locator-list",
1752
- "index-locator-range",
1753
- "index-term",
1754
- "index-term-categories",
1755
- "index-term-category",
1756
- "index-xref-preferred",
1757
- "index-xref-related",
1758
- "introduction",
1759
- "keyword",
1760
- "keywords",
1761
- "label",
1762
- "landmarks",
1763
- "learning-objective",
1764
- "learning-objectives",
1765
- "learning-outcome",
1766
- "learning-outcomes",
1767
- "learning-resource",
1768
- "learning-resources",
1769
- "learning-standard",
1770
- "learning-standards",
1771
- "loa",
1772
- "loi",
1773
- "lot",
1774
- "lov",
1775
- "match-problem",
1776
- "multiple-choice-problem",
1777
- "noteref",
1778
- "notice",
1779
- "ordinal",
1780
- "other-credits",
1781
- "page-list",
1782
- "pagebreak",
1783
- "panel",
1784
- "panel-group",
1785
- "part",
1786
- "practice",
1787
- "practices",
1788
- "preamble",
1789
- "preface",
1790
- "prologue",
1791
- "pullquote",
1792
- "qna",
1793
- "question",
1794
- "referrer",
1795
- "revision-history",
1796
- "seriespage",
1797
- "sound-area",
1798
- "subtitle",
1799
- "tip",
1800
- "title",
1801
- "titlepage",
1802
- "toc",
1803
- "toc-brief",
1804
- "topic-sentence",
1805
- "true-false-problem",
1806
- "volume"
1807
- ]);
1671
+ // src/references/url.ts
1672
+ function parseURL(urlString) {
1673
+ const hashIndex = urlString.indexOf("#");
1674
+ if (hashIndex === -1) {
1675
+ return {
1676
+ url: urlString,
1677
+ resource: urlString,
1678
+ hasFragment: false
1679
+ };
1680
+ }
1681
+ const resource = urlString.substring(0, hashIndex);
1682
+ const fragment = urlString.substring(hashIndex + 1);
1683
+ const result = {
1684
+ url: urlString,
1685
+ resource,
1686
+ hasFragment: true
1687
+ };
1688
+ if (fragment) {
1689
+ result.fragment = fragment;
1690
+ }
1691
+ return result;
1692
+ }
1693
+ function isDataURL(url) {
1694
+ return url.startsWith("data:");
1695
+ }
1696
+ function isFileURL(url) {
1697
+ return url.startsWith("file:");
1698
+ }
1699
+ function isRelativeURL(url) {
1700
+ const regex = /^[a-zA-Z][a-zA-Z0-9+.-]*:/;
1701
+ return regex.exec(url) === null;
1702
+ }
1703
+ function hasAbsolutePath(url) {
1704
+ return url.startsWith("/");
1705
+ }
1706
+ function isMalformedURL(url) {
1707
+ if (!url.trim()) return true;
1708
+ if (/[\s<>]/.test(url)) return true;
1709
+ return false;
1710
+ }
1711
+ function isHTTPS(url) {
1712
+ return url.startsWith("https://");
1713
+ }
1714
+ function isHTTP(url) {
1715
+ return url.startsWith("http://");
1716
+ }
1717
+ function isRemoteURL(url) {
1718
+ return isHTTP(url) || isHTTPS(url);
1719
+ }
1720
+ function checkUrlLeaking(href, resourcePath) {
1721
+ const TEST_BASE_A = "https://a.example.org/A/";
1722
+ const TEST_BASE_B = "https://b.example.org/B/";
1723
+ try {
1724
+ const baseA = resourcePath ? new URL(resourcePath, TEST_BASE_A).toString() : TEST_BASE_A;
1725
+ const baseB = resourcePath ? new URL(resourcePath, TEST_BASE_B).toString() : TEST_BASE_B;
1726
+ const urlA = new URL(href, baseA).toString();
1727
+ const urlB = new URL(href, baseB).toString();
1728
+ return !urlA.startsWith(TEST_BASE_A) || !urlB.startsWith(TEST_BASE_B);
1729
+ } catch {
1730
+ return false;
1731
+ }
1732
+ }
1733
+ function resolveManifestHref(opfDir, href) {
1734
+ if (isRemoteURL(href)) return href;
1735
+ try {
1736
+ const decoded = decodeURIComponent(href);
1737
+ const path = opfDir ? `${opfDir}/${decoded}` : decoded;
1738
+ return path.normalize("NFC");
1739
+ } catch {
1740
+ const path = opfDir ? `${opfDir}/${href}` : href;
1741
+ return path.normalize("NFC");
1742
+ }
1743
+ }
1808
1744
 
1809
1745
  // src/smil/clock.ts
1810
1746
  var FULL_CLOCK_RE = /^(\d+):([0-5]\d):([0-5]\d)(\.\d+)?$/;
@@ -1850,491 +1786,91 @@ function isValidSmilClock(value) {
1850
1786
  return !Number.isNaN(parseSmilClock(value));
1851
1787
  }
1852
1788
 
1853
- // src/smil/validator.ts
1854
- var SMIL_NS = { smil: "http://www.w3.org/ns/SMIL" };
1855
- var BLESSED_AUDIO_TYPES = /* @__PURE__ */ new Set(["audio/mpeg", "audio/mp4"]);
1856
- var BLESSED_AUDIO_PATTERN = /^audio\/ogg\s*;\s*codecs=opus$/i;
1857
- function isBlessedAudioType(mimeType) {
1858
- return BLESSED_AUDIO_TYPES.has(mimeType) || BLESSED_AUDIO_PATTERN.test(mimeType);
1859
- }
1860
- var SMILValidator = class {
1861
- getAttribute(element, name) {
1862
- return element.attr(name)?.value ?? null;
1789
+ // src/types.ts
1790
+ var EPUB_VERSIONS = ["2.0", "3.0", "3.1", "3.2", "3.3"];
1791
+
1792
+ // src/util/doctype.ts
1793
+ var DOCTYPE_RE = /<!DOCTYPE\s+(\w+)\s*([^>]*)>/i;
1794
+ var PUBLIC_ID_RE = /\bPUBLIC\s+"([^"]*)"\s+"([^"]*)"/i;
1795
+ var SYSTEM_ID_RE = /\bSYSTEM\s+"([^"]*)"/i;
1796
+ var DEFAULT_MAX_BYTES = 2048;
1797
+ function parseDoctype(content, options = {}) {
1798
+ const { expectedRoot, maxBytes = DEFAULT_MAX_BYTES } = options;
1799
+ const scanned = content.length > maxBytes ? content.slice(0, maxBytes) : content;
1800
+ const doctypeMatch = DOCTYPE_RE.exec(scanned);
1801
+ if (!doctypeMatch) return null;
1802
+ const root = doctypeMatch[1] ?? "";
1803
+ if (expectedRoot && root.toLowerCase() !== expectedRoot.toLowerCase()) return null;
1804
+ const inner = doctypeMatch[2] ?? "";
1805
+ const publicMatch = PUBLIC_ID_RE.exec(inner);
1806
+ if (publicMatch) {
1807
+ return { root, publicId: publicMatch[1] ?? "", systemId: publicMatch[2] ?? "" };
1863
1808
  }
1864
- getEpubAttribute(element, localName) {
1865
- return element.attr(localName, "epub")?.value ?? null;
1809
+ const systemMatch = SYSTEM_ID_RE.exec(inner);
1810
+ if (systemMatch) {
1811
+ return { root, publicId: "", systemId: systemMatch[1] ?? "" };
1866
1812
  }
1867
- validate(context, path, manifestByPath) {
1868
- const result = {
1869
- textReferences: [],
1870
- referencedDocuments: /* @__PURE__ */ new Set(),
1871
- hasRemoteResources: false
1872
- };
1873
- const data = context.files.get(path);
1874
- if (!data) return result;
1875
- const content = typeof data === "string" ? data : new TextDecoder().decode(data);
1876
- let doc = null;
1877
- try {
1878
- doc = libxml2Wasm.XmlDocument.fromString(content);
1879
- } catch {
1880
- pushMessage(context.messages, {
1881
- id: MessageId.RSC_016,
1882
- message: "Media Overlay document is not well-formed XML",
1883
- location: { path }
1884
- });
1885
- return result;
1886
- }
1887
- try {
1888
- const root = doc.root;
1889
- this.validateStructure(context, path, root);
1890
- this.validateAudioElements(context, path, root, manifestByPath, result);
1891
- this.validateEpubTypes(context, path, root);
1892
- this.extractTextReferences(path, root, result);
1893
- } finally {
1894
- doc.dispose();
1813
+ return { root, publicId: "", systemId: "" };
1814
+ }
1815
+
1816
+ // src/util/encoding.ts
1817
+ function sniffXmlEncoding(data) {
1818
+ if (data.length < 2) return null;
1819
+ if (data.length >= 4) {
1820
+ if (data[0] === 0 && data[1] === 0 && data[2] === 254 && data[3] === 255) {
1821
+ return "UCS-4";
1895
1822
  }
1896
- return result;
1897
- }
1898
- validateStructure(context, path, root) {
1899
- try {
1900
- for (const text of root.find(".//smil:seq/smil:text", SMIL_NS)) {
1901
- pushMessage(context.messages, {
1902
- id: MessageId.RSC_005,
1903
- message: "element 'text' not allowed here; expected 'seq' or 'par'",
1904
- location: { path, line: text.line }
1905
- });
1906
- }
1907
- for (const audio of root.find(".//smil:seq/smil:audio", SMIL_NS)) {
1908
- pushMessage(context.messages, {
1909
- id: MessageId.RSC_005,
1910
- message: "element 'audio' not allowed here; expected 'seq' or 'par'",
1911
- location: { path, line: audio.line }
1912
- });
1913
- }
1914
- } catch {
1823
+ if (data[0] === 255 && data[1] === 254 && data[2] === 0 && data[3] === 0) {
1824
+ return "UCS-4";
1915
1825
  }
1916
- try {
1917
- for (const seq of root.find(".//smil:par/smil:seq", SMIL_NS)) {
1918
- pushMessage(context.messages, {
1919
- id: MessageId.RSC_005,
1920
- message: "element 'seq' not allowed here; expected 'text' or 'audio'",
1921
- location: { path, line: seq.line }
1922
- });
1923
- }
1924
- const parElements = root.find(".//smil:par", SMIL_NS);
1925
- for (const par of parElements) {
1926
- const textChildren = par.find("./smil:text", SMIL_NS);
1927
- for (let i = 1; i < textChildren.length; i++) {
1928
- const extra = textChildren[i];
1929
- if (!extra) continue;
1930
- pushMessage(context.messages, {
1931
- id: MessageId.RSC_005,
1932
- message: "element 'text' not allowed here; only one 'text' element is allowed in 'par'",
1933
- location: { path, line: extra.line }
1934
- });
1935
- }
1936
- }
1937
- } catch {
1826
+ if (data[0] === 0 && data[1] === 0 && data[2] === 255 && data[3] === 254) {
1827
+ return "UCS-4";
1938
1828
  }
1939
- try {
1940
- const headMetaElements = root.find(".//smil:head/smil:meta", SMIL_NS);
1941
- for (const meta of headMetaElements) {
1942
- pushMessage(context.messages, {
1943
- id: MessageId.RSC_005,
1944
- message: "element 'meta' not allowed here; expected 'metadata'",
1945
- location: { path, line: meta.line }
1946
- });
1947
- }
1948
- } catch {
1829
+ if (data[0] === 254 && data[1] === 255 && data[2] === 0 && data[3] === 0) {
1830
+ return "UCS-4";
1949
1831
  }
1950
- }
1951
- validateAudioElements(context, path, root, manifestByPath, result) {
1952
- try {
1953
- const audioElements = root.find(".//smil:audio", SMIL_NS);
1954
- for (const audio of audioElements) {
1955
- const elem = audio;
1956
- const src = this.getAttribute(elem, "src");
1957
- if (src) {
1958
- if (/^https?:\/\//i.test(src)) {
1959
- result.hasRemoteResources = true;
1960
- }
1961
- if (src.includes("#")) {
1962
- pushMessage(context.messages, {
1963
- id: MessageId.MED_014,
1964
- message: `Media overlay audio file URLs must not have a fragment: "${src}"`,
1965
- location: { path, line: audio.line }
1966
- });
1967
- }
1968
- if (manifestByPath) {
1969
- const audioPath = this.resolveRelativePath(path, src.split("#")[0] ?? src);
1970
- const audioItem = manifestByPath.get(audioPath);
1971
- if (audioItem && !isBlessedAudioType(audioItem.mediaType)) {
1972
- pushMessage(context.messages, {
1973
- id: MessageId.MED_005,
1974
- message: `Media Overlay audio reference "${src}" to non-standard audio type "${audioItem.mediaType}"`,
1975
- location: { path, line: audio.line }
1976
- });
1977
- }
1978
- }
1979
- }
1980
- const clipBegin = this.getAttribute(elem, "clipBegin");
1981
- const clipEnd = this.getAttribute(elem, "clipEnd");
1982
- this.checkClipTiming(context, path, audio.line, clipBegin, clipEnd);
1983
- }
1984
- } catch {
1832
+ if (data[0] === 0 && data[1] === 0 && data[2] === 0 && data[3] === 60) {
1833
+ return "UCS-4";
1985
1834
  }
1986
- }
1987
- checkClipTiming(context, path, line, clipBegin, clipEnd) {
1988
- if (clipEnd === null) return;
1989
- const beginStr = clipBegin ?? "0";
1990
- const start = parseSmilClock(beginStr);
1991
- const end = parseSmilClock(clipEnd);
1992
- const location = line != null ? { path, line } : { path };
1993
- if (clipBegin !== null && Number.isNaN(start)) {
1994
- pushMessage(context.messages, {
1995
- id: MessageId.RSC_005,
1996
- message: `Invalid SMIL clock value "${clipBegin}" in clipBegin attribute`,
1997
- location
1998
- });
1835
+ if (data[0] === 60 && data[1] === 0 && data[2] === 0 && data[3] === 0) {
1836
+ return "UCS-4";
1999
1837
  }
2000
- if (Number.isNaN(end)) {
2001
- pushMessage(context.messages, {
2002
- id: MessageId.RSC_005,
2003
- message: `Invalid SMIL clock value "${clipEnd}" in clipEnd attribute`,
2004
- location
2005
- });
1838
+ if (data[0] === 0 && data[1] === 0 && data[2] === 60 && data[3] === 0) {
1839
+ return "UCS-4";
2006
1840
  }
2007
- if (Number.isNaN(start) || Number.isNaN(end)) return;
2008
- if (start > end) {
2009
- pushMessage(context.messages, {
2010
- id: MessageId.MED_008,
2011
- message: "The time specified in the clipBegin attribute must not be after clipEnd",
2012
- location
2013
- });
2014
- } else if (start === end) {
2015
- pushMessage(context.messages, {
2016
- id: MessageId.MED_009,
2017
- message: "The time specified in the clipBegin attribute must not be the same as clipEnd",
2018
- location
2019
- });
1841
+ if (data[0] === 0 && data[1] === 60 && data[2] === 0 && data[3] === 0) {
1842
+ return "UCS-4";
2020
1843
  }
2021
1844
  }
2022
- /**
2023
- * Validate epub:type attribute values against the EPUB SSV vocabulary.
2024
- * Only emits OPF-088 (usage) for unknown local names. Prefixed values
2025
- * from declared vocabularies are allowed.
2026
- */
2027
- validateEpubTypes(context, path, root) {
2028
- try {
2029
- const epubTypeElements = root.find(".//*[@epub:type]", {
2030
- epub: "http://www.idpf.org/2007/ops"
2031
- });
2032
- for (const elem of epubTypeElements) {
2033
- const elemTyped = elem;
2034
- const epubTypeAttr = elemTyped.attr("type", "epub");
2035
- if (!epubTypeAttr?.value) continue;
2036
- for (const part of epubTypeAttr.value.split(/\s+/)) {
2037
- if (!part || part.includes(":")) continue;
2038
- if (!EPUB_SSV_ALL.has(part)) {
2039
- pushMessage(context.messages, {
2040
- id: MessageId.OPF_088,
2041
- message: `Unrecognized epub:type value "${part}"`,
2042
- location: { path, line: elem.line }
2043
- });
2044
- }
2045
- }
2046
- }
2047
- } catch {
2048
- }
1845
+ if (data[0] === 254 && data[1] === 255) {
1846
+ return "UTF-16";
2049
1847
  }
2050
- extractTextReferences(path, root, result) {
2051
- try {
2052
- const textElements = root.find(".//smil:text", SMIL_NS);
2053
- for (const text of textElements) {
2054
- const src = this.getAttribute(text, "src");
2055
- if (!src) continue;
2056
- const hashIndex = src.indexOf("#");
2057
- const docRef = hashIndex >= 0 ? src.substring(0, hashIndex) : src;
2058
- const fragment = hashIndex >= 0 ? src.substring(hashIndex + 1) : void 0;
2059
- const docPath = this.resolveRelativePath(path, docRef);
2060
- result.textReferences.push({ docPath, fragment, line: text.line });
2061
- result.referencedDocuments.add(docPath);
2062
- }
2063
- const bodyElements = root.find(".//smil:body", SMIL_NS);
2064
- const seqElements = root.find(".//smil:seq", SMIL_NS);
2065
- for (const elem of [...bodyElements, ...seqElements]) {
2066
- const textref = this.getEpubAttribute(elem, "textref");
2067
- if (!textref) continue;
2068
- const hashIndex = textref.indexOf("#");
2069
- const docRef = hashIndex >= 0 ? textref.substring(0, hashIndex) : textref;
2070
- const fragment = hashIndex >= 0 ? textref.substring(hashIndex + 1) : void 0;
2071
- const docPath = this.resolveRelativePath(path, docRef);
2072
- result.textReferences.push({ docPath, fragment, line: elem.line });
2073
- result.referencedDocuments.add(docPath);
2074
- }
2075
- } catch {
2076
- }
1848
+ if (data[0] === 255 && data[1] === 254) {
1849
+ return "UTF-16";
2077
1850
  }
2078
- resolveRelativePath(basePath, relativePath) {
2079
- if (relativePath.startsWith("/") || /^[a-zA-Z]+:/.test(relativePath)) {
2080
- return relativePath;
1851
+ if (data.length >= 4) {
1852
+ if (data[0] === 0 && data[1] === 60 && data[2] === 0 && data[3] === 63) {
1853
+ return "UTF-16";
2081
1854
  }
2082
- const baseDir = basePath.includes("/") ? basePath.substring(0, basePath.lastIndexOf("/")) : "";
2083
- if (!baseDir) return relativePath;
2084
- const segments = `${baseDir}/${relativePath}`.split("/");
2085
- const resolved = [];
2086
- for (const seg of segments) {
2087
- if (seg === "..") {
2088
- resolved.pop();
2089
- } else if (seg !== ".") {
2090
- resolved.push(seg);
2091
- }
1855
+ if (data[0] === 60 && data[1] === 0 && data[2] === 63 && data[3] === 0) {
1856
+ return "UTF-16";
2092
1857
  }
2093
- return resolved.join("/");
2094
1858
  }
2095
- };
2096
-
2097
- // src/opf/types.ts
2098
- var CORE_MEDIA_TYPES = /* @__PURE__ */ new Set([
2099
- // Image types
2100
- "image/gif",
2101
- "image/jpeg",
2102
- "image/png",
2103
- "image/svg+xml",
2104
- "image/webp",
2105
- // Audio types
2106
- "audio/mpeg",
2107
- "audio/mp4",
2108
- "audio/ogg",
2109
- // CSS
2110
- "text/css",
2111
- // Fonts
2112
- "font/otf",
2113
- "font/ttf",
2114
- "font/woff",
2115
- "font/woff2",
2116
- "application/font-sfnt",
2117
- // deprecated alias for font/otf, font/ttf
2118
- "application/font-woff",
2119
- // deprecated alias for font/woff
2120
- "application/vnd.ms-opentype",
2121
- // deprecated alias
2122
- // Content documents
2123
- "application/xhtml+xml",
2124
- "application/x-dtbncx+xml",
2125
- // NCX
2126
- // JavaScript (EPUB 3)
2127
- "text/javascript",
2128
- "application/javascript",
2129
- // Media overlays
2130
- "application/smil+xml",
2131
- // PLS (Pronunciation Lexicon)
2132
- "application/pls+xml"
2133
- ]);
2134
- function isCoreMediaType(mimeType) {
2135
- if (CORE_MEDIA_TYPES.has(mimeType)) return true;
2136
- if (mimeType.startsWith("video/")) return true;
2137
- if (/^audio\/ogg\s*;\s*codecs=opus$/i.test(mimeType)) return true;
2138
- const semicolonIndex = mimeType.indexOf(";");
2139
- if (semicolonIndex >= 0) {
2140
- const baseType = mimeType.substring(0, semicolonIndex).trim();
2141
- if (CORE_MEDIA_TYPES.has(baseType)) return true;
2142
- if (baseType.startsWith("video/")) return true;
2143
- }
2144
- return false;
2145
- }
2146
- var ITEM_PROPERTIES = /* @__PURE__ */ new Set([
2147
- "cover-image",
2148
- "data-nav",
2149
- "dictionary",
2150
- "glossary",
2151
- "index",
2152
- "mathml",
2153
- "nav",
2154
- "remote-resources",
2155
- "scripted",
2156
- "search-key-map",
2157
- "svg",
2158
- "switch"
2159
- ]);
2160
- var LINK_PROPERTIES = /* @__PURE__ */ new Set(["onix", "marc21xml-record", "mods-record", "xmp-record"]);
2161
- var SPINE_PROPERTIES = /* @__PURE__ */ new Set([
2162
- "page-spread-left",
2163
- "page-spread-right",
2164
- "rendition:spread-none",
2165
- "rendition:spread-landscape",
2166
- "rendition:spread-portrait",
2167
- "rendition:spread-both",
2168
- "rendition:spread-auto",
2169
- "rendition:page-spread-center",
2170
- "rendition:layout-reflowable",
2171
- "rendition:layout-pre-paginated",
2172
- "rendition:orientation-auto",
2173
- "rendition:orientation-landscape",
2174
- "rendition:orientation-portrait",
2175
- "rendition:flow-auto",
2176
- "rendition:flow-paginated",
2177
- "rendition:flow-scrolled-continuous",
2178
- "rendition:flow-scrolled-doc",
2179
- "rendition:align-x-center"
2180
- ]);
2181
-
2182
- // src/references/url.ts
2183
- function parseURL(urlString) {
2184
- const hashIndex = urlString.indexOf("#");
2185
- if (hashIndex === -1) {
2186
- return {
2187
- url: urlString,
2188
- resource: urlString,
2189
- hasFragment: false
2190
- };
2191
- }
2192
- const resource = urlString.substring(0, hashIndex);
2193
- const fragment = urlString.substring(hashIndex + 1);
2194
- const result = {
2195
- url: urlString,
2196
- resource,
2197
- hasFragment: true
2198
- };
2199
- if (fragment) {
2200
- result.fragment = fragment;
2201
- }
2202
- return result;
2203
- }
2204
- function isDataURL(url) {
2205
- return url.startsWith("data:");
2206
- }
2207
- function isFileURL(url) {
2208
- return url.startsWith("file:");
2209
- }
2210
- function hasAbsolutePath(url) {
2211
- return url.startsWith("/");
2212
- }
2213
- function hasParentDirectoryReference(url) {
2214
- return url.includes("..");
2215
- }
2216
- function isMalformedURL(url) {
2217
- if (!url.trim()) return true;
2218
- if (/[\s<>]/.test(url)) return true;
2219
- return false;
2220
- }
2221
- function isHTTPS(url) {
2222
- return url.startsWith("https://");
2223
- }
2224
- function isHTTP(url) {
2225
- return url.startsWith("http://");
2226
- }
2227
- function isRemoteURL(url) {
2228
- return isHTTP(url) || isHTTPS(url);
2229
- }
2230
- function checkUrlLeaking(href) {
2231
- const TEST_BASE_A = "https://a.example.org/A/";
2232
- const TEST_BASE_B = "https://b.example.org/B/";
2233
- try {
2234
- const urlA = new URL(href, TEST_BASE_A).toString();
2235
- const urlB = new URL(href, TEST_BASE_B).toString();
2236
- return !urlA.startsWith(TEST_BASE_A) || !urlB.startsWith(TEST_BASE_B);
2237
- } catch {
2238
- return false;
2239
- }
2240
- }
2241
- function resolveManifestHref(opfDir, href) {
2242
- if (isRemoteURL(href)) return href;
2243
- try {
2244
- const decoded = decodeURIComponent(href);
2245
- const path = opfDir ? `${opfDir}/${decoded}` : decoded;
2246
- return path.normalize("NFC");
2247
- } catch {
2248
- const path = opfDir ? `${opfDir}/${href}` : href;
2249
- return path.normalize("NFC");
2250
- }
2251
- }
2252
-
2253
- // src/types.ts
2254
- var EPUB_VERSIONS = ["2.0", "3.0", "3.1", "3.2", "3.3"];
2255
-
2256
- // src/util/doctype.ts
2257
- var DOCTYPE_RE = /<!DOCTYPE\s+(\w+)\s*([^>]*)>/i;
2258
- var PUBLIC_ID_RE = /\bPUBLIC\s+"([^"]*)"\s+"([^"]*)"/i;
2259
- var SYSTEM_ID_RE = /\bSYSTEM\s+"([^"]*)"/i;
2260
- var DEFAULT_MAX_BYTES = 2048;
2261
- function parseDoctype(content, options = {}) {
2262
- const { expectedRoot, maxBytes = DEFAULT_MAX_BYTES } = options;
2263
- const scanned = content.length > maxBytes ? content.slice(0, maxBytes) : content;
2264
- const doctypeMatch = DOCTYPE_RE.exec(scanned);
2265
- if (!doctypeMatch) return null;
2266
- const root = doctypeMatch[1] ?? "";
2267
- if (expectedRoot && root.toLowerCase() !== expectedRoot.toLowerCase()) return null;
2268
- const inner = doctypeMatch[2] ?? "";
2269
- const publicMatch = PUBLIC_ID_RE.exec(inner);
2270
- if (publicMatch) {
2271
- return { root, publicId: publicMatch[1] ?? "", systemId: publicMatch[2] ?? "" };
2272
- }
2273
- const systemMatch = SYSTEM_ID_RE.exec(inner);
2274
- if (systemMatch) {
2275
- return { root, publicId: "", systemId: systemMatch[1] ?? "" };
2276
- }
2277
- return { root, publicId: "", systemId: "" };
2278
- }
2279
-
2280
- // src/util/encoding.ts
2281
- function sniffXmlEncoding(data) {
2282
- if (data.length < 2) return null;
2283
- if (data.length >= 4) {
2284
- if (data[0] === 0 && data[1] === 0 && data[2] === 254 && data[3] === 255) {
2285
- return "UCS-4";
2286
- }
2287
- if (data[0] === 255 && data[1] === 254 && data[2] === 0 && data[3] === 0) {
2288
- return "UCS-4";
2289
- }
2290
- if (data[0] === 0 && data[1] === 0 && data[2] === 255 && data[3] === 254) {
2291
- return "UCS-4";
2292
- }
2293
- if (data[0] === 254 && data[1] === 255 && data[2] === 0 && data[3] === 0) {
2294
- return "UCS-4";
2295
- }
2296
- if (data[0] === 0 && data[1] === 0 && data[2] === 0 && data[3] === 60) {
2297
- return "UCS-4";
2298
- }
2299
- if (data[0] === 60 && data[1] === 0 && data[2] === 0 && data[3] === 0) {
2300
- return "UCS-4";
2301
- }
2302
- if (data[0] === 0 && data[1] === 0 && data[2] === 60 && data[3] === 0) {
2303
- return "UCS-4";
2304
- }
2305
- if (data[0] === 0 && data[1] === 60 && data[2] === 0 && data[3] === 0) {
2306
- return "UCS-4";
2307
- }
2308
- }
2309
- if (data[0] === 254 && data[1] === 255) {
2310
- return "UTF-16";
2311
- }
2312
- if (data[0] === 255 && data[1] === 254) {
2313
- return "UTF-16";
2314
- }
2315
- if (data.length >= 4) {
2316
- if (data[0] === 0 && data[1] === 60 && data[2] === 0 && data[3] === 63) {
2317
- return "UTF-16";
2318
- }
2319
- if (data[0] === 60 && data[1] === 0 && data[2] === 63 && data[3] === 0) {
2320
- return "UTF-16";
2321
- }
2322
- }
2323
- if (data.length >= 3 && data[0] === 239 && data[1] === 187 && data[2] === 191) {
2324
- return null;
2325
- }
2326
- if (data.length >= 4 && data[0] === 76 && data[1] === 111 && data[2] === 167 && data[3] === 148) {
2327
- return "EBCDIC";
2328
- }
2329
- const prefix = String.fromCharCode(...data.slice(0, Math.min(256, data.length)));
2330
- const match = /^<\?xml[^?]*\bencoding\s*=\s*["']([^"']+)["']/.exec(prefix);
2331
- if (match) {
2332
- const declared = (match[1] ?? "").toUpperCase();
2333
- if (declared === "UTF-8") return null;
2334
- return declared;
2335
- }
2336
- return null;
2337
- }
1859
+ if (data.length >= 3 && data[0] === 239 && data[1] === 187 && data[2] === 191) {
1860
+ return null;
1861
+ }
1862
+ if (data.length >= 4 && data[0] === 76 && data[1] === 111 && data[2] === 167 && data[3] === 148) {
1863
+ return "EBCDIC";
1864
+ }
1865
+ const prefix = String.fromCharCode(...data.slice(0, Math.min(256, data.length)));
1866
+ const match = /^<\?xml[^?]*\bencoding\s*=\s*["']([^"']+)["']/.exec(prefix);
1867
+ if (match) {
1868
+ const declared = (match[1] ?? "").toUpperCase();
1869
+ if (declared === "UTF-8") return null;
1870
+ return declared;
1871
+ }
1872
+ return null;
1873
+ }
2338
1874
 
2339
1875
  // src/opf/parser.ts
2340
1876
  function parseOPF(xml) {
@@ -2360,6 +1896,8 @@ function parseOPF(xml) {
2360
1896
  }
2361
1897
  }
2362
1898
  }
1899
+ const nsRegex = /<package[^>]*\sxmlns=["']([^"']+)["']/;
1900
+ const isLegacyOebps12 = nsRegex.exec(xml)?.[1] === "http://openebook.org/namespaces/oeb-package/1.0/";
2363
1901
  const prefixes = parsePrefixes(xml);
2364
1902
  const dirRegex = /<package[^>]*\sdir=["']([^"']+)["']/;
2365
1903
  const dirMatch = dirRegex.exec(xml);
@@ -2398,6 +1936,9 @@ function parseOPF(xml) {
2398
1936
  if (!versionDeclared) {
2399
1937
  result.versionDeclared = false;
2400
1938
  }
1939
+ if (isLegacyOebps12) {
1940
+ result.isLegacyOebps12 = true;
1941
+ }
2401
1942
  if (Object.keys(prefixes).length > 0) {
2402
1943
  result.prefixes = prefixes;
2403
1944
  }
@@ -2488,7 +2029,7 @@ function parseDCElements(metadataXml) {
2488
2029
  }
2489
2030
  function parseMetaElements(metadataXml) {
2490
2031
  const elements = [];
2491
- const metaRegex = /<meta([^>]*property=["'][^"']+["'][^>]*)>([^<]*)<\/meta>/g;
2032
+ const metaRegex = /<(?:[A-Za-z_][\w.-]*:)?meta([^>]*property=["'][^"']+["'][^>]*)>([^<]*)<\/(?:[A-Za-z_][\w.-]*:)?meta>/g;
2492
2033
  let match;
2493
2034
  while ((match = metaRegex.exec(metadataXml)) !== null) {
2494
2035
  const attrsStr = match[1] ?? "";
@@ -2698,44 +2239,129 @@ function parseCollections(xml) {
2698
2239
  return roots;
2699
2240
  }
2700
2241
 
2701
- // src/opf/validator.ts
2702
- var DEPRECATED_MEDIA_TYPES = /* @__PURE__ */ new Set([
2703
- "text/x-oeb1-document",
2704
- "text/x-oeb1-css",
2705
- "application/x-oeb1-package",
2706
- "text/x-oeb1-html"
2707
- ]);
2708
- function getPreferredMediaType(mimeType, path) {
2709
- switch (mimeType) {
2710
- case "application/font-sfnt":
2711
- if (path.endsWith(".ttf")) return "font/ttf";
2712
- if (path.endsWith(".otf")) return "font/otf";
2713
- return "font/(ttf|otf)";
2714
- case "application/vnd.ms-opentype":
2715
- return "font/otf";
2716
- case "application/font-woff":
2717
- return "font/woff";
2718
- case "application/x-font-ttf":
2719
- return "font/ttf";
2720
- case "text/javascript":
2721
- case "application/ecmascript":
2722
- return "application/javascript";
2723
- default:
2724
- return null;
2725
- }
2726
- }
2727
- var VALID_RELATOR_CODES = /* @__PURE__ */ new Set([
2728
- "abr",
2729
- "acp",
2730
- "act",
2731
- "adi",
2732
- "adp",
2733
- "aft",
2734
- "anl",
2735
- "anm",
2736
- "ann",
2737
- "ant",
2738
- "ape",
2242
+ // src/opf/types.ts
2243
+ var CORE_MEDIA_TYPES = /* @__PURE__ */ new Set([
2244
+ // Image types
2245
+ "image/gif",
2246
+ "image/jpeg",
2247
+ "image/png",
2248
+ "image/svg+xml",
2249
+ "image/webp",
2250
+ // Audio types
2251
+ "audio/mpeg",
2252
+ "audio/mp4",
2253
+ "audio/ogg",
2254
+ // CSS
2255
+ "text/css",
2256
+ // Fonts
2257
+ "font/otf",
2258
+ "font/ttf",
2259
+ "font/woff",
2260
+ "font/woff2",
2261
+ "application/font-sfnt",
2262
+ // deprecated alias for font/otf, font/ttf
2263
+ "application/font-woff",
2264
+ // deprecated alias for font/woff
2265
+ "application/vnd.ms-opentype",
2266
+ // deprecated alias
2267
+ // Content documents
2268
+ "application/xhtml+xml",
2269
+ "application/x-dtbncx+xml",
2270
+ // NCX
2271
+ // JavaScript (EPUB 3)
2272
+ "text/javascript",
2273
+ "application/javascript",
2274
+ // Media overlays
2275
+ "application/smil+xml",
2276
+ // PLS (Pronunciation Lexicon)
2277
+ "application/pls+xml"
2278
+ ]);
2279
+ function isCoreMediaType(mimeType) {
2280
+ if (CORE_MEDIA_TYPES.has(mimeType)) return true;
2281
+ if (mimeType.startsWith("video/")) return true;
2282
+ if (/^audio\/ogg\s*;\s*codecs=opus$/i.test(mimeType)) return true;
2283
+ const semicolonIndex = mimeType.indexOf(";");
2284
+ if (semicolonIndex >= 0) {
2285
+ const baseType = mimeType.substring(0, semicolonIndex).trim();
2286
+ if (CORE_MEDIA_TYPES.has(baseType)) return true;
2287
+ if (baseType.startsWith("video/")) return true;
2288
+ }
2289
+ return false;
2290
+ }
2291
+ var ITEM_PROPERTIES = /* @__PURE__ */ new Set([
2292
+ "cover-image",
2293
+ "data-nav",
2294
+ "dictionary",
2295
+ "glossary",
2296
+ "index",
2297
+ "mathml",
2298
+ "nav",
2299
+ "remote-resources",
2300
+ "scripted",
2301
+ "search-key-map",
2302
+ "svg",
2303
+ "switch"
2304
+ ]);
2305
+ var LINK_PROPERTIES = /* @__PURE__ */ new Set(["onix", "marc21xml-record", "mods-record", "xmp-record"]);
2306
+ var SPINE_PROPERTIES = /* @__PURE__ */ new Set([
2307
+ "page-spread-left",
2308
+ "page-spread-right",
2309
+ "rendition:spread-none",
2310
+ "rendition:spread-landscape",
2311
+ "rendition:spread-portrait",
2312
+ "rendition:spread-both",
2313
+ "rendition:spread-auto",
2314
+ "rendition:page-spread-center",
2315
+ "rendition:layout-reflowable",
2316
+ "rendition:layout-pre-paginated",
2317
+ "rendition:orientation-auto",
2318
+ "rendition:orientation-landscape",
2319
+ "rendition:orientation-portrait",
2320
+ "rendition:flow-auto",
2321
+ "rendition:flow-paginated",
2322
+ "rendition:flow-scrolled-continuous",
2323
+ "rendition:flow-scrolled-doc",
2324
+ "rendition:align-x-center"
2325
+ ]);
2326
+
2327
+ // src/opf/validator.ts
2328
+ var DEPRECATED_MEDIA_TYPES = /* @__PURE__ */ new Set([
2329
+ "text/x-oeb1-document",
2330
+ "text/x-oeb1-css",
2331
+ "application/x-oeb1-package",
2332
+ "text/x-oeb1-html"
2333
+ ]);
2334
+ function getPreferredMediaType(mimeType, path) {
2335
+ switch (mimeType) {
2336
+ case "application/font-sfnt":
2337
+ if (path.endsWith(".ttf")) return "font/ttf";
2338
+ if (path.endsWith(".otf")) return "font/otf";
2339
+ return "font/(ttf|otf)";
2340
+ case "application/vnd.ms-opentype":
2341
+ return "font/otf";
2342
+ case "application/font-woff":
2343
+ return "font/woff";
2344
+ case "application/x-font-ttf":
2345
+ return "font/ttf";
2346
+ case "text/javascript":
2347
+ case "application/ecmascript":
2348
+ return "application/javascript";
2349
+ default:
2350
+ return null;
2351
+ }
2352
+ }
2353
+ var VALID_RELATOR_CODES = /* @__PURE__ */ new Set([
2354
+ "abr",
2355
+ "acp",
2356
+ "act",
2357
+ "adi",
2358
+ "adp",
2359
+ "aft",
2360
+ "anl",
2361
+ "anm",
2362
+ "ann",
2363
+ "ant",
2364
+ "ape",
2739
2365
  "apl",
2740
2366
  "app",
2741
2367
  "aqt",
@@ -3173,6 +2799,9 @@ var OPFValidator = class {
3173
2799
  }
3174
2800
  this.validatePackageAttributes(context, opfPath);
3175
2801
  this.validateMetadata(context, opfPath);
2802
+ if (this.packageDoc.version !== "2.0") {
2803
+ this.validateMetaPrefixes(context, opfPath, opfXml);
2804
+ }
3176
2805
  this.validateLinkElements(context, opfPath);
3177
2806
  this.validateManifest(context, opfPath);
3178
2807
  this.validateSpine(context, opfPath);
@@ -4366,6 +3995,7 @@ var OPFValidator = class {
4366
3995
  if (!this.packageDoc) return;
4367
3996
  const seenIds = /* @__PURE__ */ new Set();
4368
3997
  const seenHrefs = /* @__PURE__ */ new Set();
3998
+ const declaredPrefixes = this.packageDoc.prefixes ?? {};
4369
3999
  for (const item of this.packageDoc.manifest) {
4370
4000
  if (seenIds.has(item.id)) {
4371
4001
  pushMessage(context.messages, {
@@ -4409,7 +4039,7 @@ var OPFValidator = class {
4409
4039
  });
4410
4040
  }
4411
4041
  if (!item.href.startsWith("http") && !item.href.startsWith("mailto:")) {
4412
- const leaked = checkUrlLeaking(item.href);
4042
+ const leaked = checkUrlLeaking(item.href, opfPath);
4413
4043
  if (leaked) {
4414
4044
  pushMessage(context.messages, {
4415
4045
  id: MessageId.RSC_026,
@@ -4444,11 +4074,11 @@ var OPFValidator = class {
4444
4074
  if (DEPRECATED_MEDIA_TYPES.has(item.mediaType) || item.mediaType === "text/html") {
4445
4075
  if (this.packageDoc.version === "2.0" && item.mediaType === "text/html") {
4446
4076
  pushMessage(context.messages, {
4447
- id: MessageId.OPF_035,
4077
+ id: this.packageDoc.isLegacyOebps12 ? MessageId.OPF_038 : MessageId.OPF_035,
4448
4078
  message: `XHTML Content Document "${item.id}" is declared as "text/html"`,
4449
4079
  location: { path: opfPath }
4450
4080
  });
4451
- } else if (this.packageDoc.version === "2.0") {
4081
+ } else if (this.packageDoc.version === "2.0" && !this.packageDoc.isLegacyOebps12) {
4452
4082
  pushMessage(context.messages, {
4453
4083
  id: MessageId.OPF_037,
4454
4084
  message: `Found deprecated media-type "${item.mediaType}"`,
@@ -4456,6 +4086,13 @@ var OPFValidator = class {
4456
4086
  });
4457
4087
  }
4458
4088
  }
4089
+ if (this.packageDoc.version === "2.0" && this.packageDoc.isLegacyOebps12 && item.mediaType === "text/css" && !item.fallback) {
4090
+ pushMessage(context.messages, {
4091
+ id: MessageId.OPF_039,
4092
+ message: `Media type "${item.mediaType}" requires a fallback in legacy OEBPS 1.2 context`,
4093
+ location: { path: opfPath }
4094
+ });
4095
+ }
4459
4096
  const preferred = getPreferredMediaType(item.mediaType, fullPath);
4460
4097
  if (preferred !== null) {
4461
4098
  pushMessage(context.messages, {
@@ -4466,13 +4103,14 @@ var OPFValidator = class {
4466
4103
  }
4467
4104
  if (this.packageDoc.version !== "2.0" && item.properties) {
4468
4105
  for (const prop of item.properties) {
4469
- if (!ITEM_PROPERTIES.has(prop)) {
4470
- pushMessage(context.messages, {
4471
- id: MessageId.OPF_027,
4472
- message: `Undefined property: "${prop}"`,
4473
- location: { path: opfPath }
4474
- });
4475
- }
4106
+ if (ITEM_PROPERTIES.has(prop)) continue;
4107
+ const colon = prop.indexOf(":");
4108
+ if (colon > 0 && declaredPrefixes[prop.slice(0, colon)] !== void 0) continue;
4109
+ pushMessage(context.messages, {
4110
+ id: MessageId.OPF_027,
4111
+ message: `Undefined property: "${prop}"`,
4112
+ location: { path: opfPath }
4113
+ });
4476
4114
  }
4477
4115
  if (item.properties.includes("nav")) {
4478
4116
  if (item.mediaType !== "application/xhtml+xml") {
@@ -4635,6 +4273,42 @@ var OPFValidator = class {
4635
4273
  * emits one assertion failure per offending element (so two duplicate ids
4636
4274
  * produce two RSC-005 messages).
4637
4275
  */
4276
+ validateMetaPrefixes(context, opfPath, opfXml) {
4277
+ if (!this.packageDoc) return;
4278
+ const RESERVED = /* @__PURE__ */ new Set([
4279
+ "dcterms",
4280
+ "marc",
4281
+ "onix",
4282
+ "schema",
4283
+ "xsd",
4284
+ "a11y",
4285
+ "media",
4286
+ "rendition"
4287
+ ]);
4288
+ const declared = new Set(Object.keys(this.packageDoc.prefixes ?? {}));
4289
+ const reported = /* @__PURE__ */ new Set();
4290
+ const reportIfUndeclared = (prefix) => {
4291
+ if (!prefix || reported.has(prefix)) return;
4292
+ if (RESERVED.has(prefix) || declared.has(prefix)) return;
4293
+ reported.add(prefix);
4294
+ pushMessage(context.messages, {
4295
+ id: MessageId.OPF_028,
4296
+ message: `Undeclared prefix: "${prefix}"`,
4297
+ location: { path: opfPath }
4298
+ });
4299
+ };
4300
+ const stripped = stripXmlComments(opfXml);
4301
+ const attrRegex = /\b(?:property|scheme|rel)\s*=\s*["']([^"']+)["']/g;
4302
+ for (const match of stripped.matchAll(attrRegex)) {
4303
+ const value = match[1]?.trim();
4304
+ if (!value) continue;
4305
+ for (const token of value.split(/\s+/)) {
4306
+ const colon = token.indexOf(":");
4307
+ if (colon <= 0) continue;
4308
+ reportIfUndeclared(token.slice(0, colon));
4309
+ }
4310
+ }
4311
+ }
4638
4312
  validateOpfIdUniqueness(context, opfPath, opfXml) {
4639
4313
  const stripped = stripXmlComments(opfXml);
4640
4314
  const counts = /* @__PURE__ */ new Map();
@@ -4666,11 +4340,12 @@ var OPFValidator = class {
4666
4340
  for (const item of this.packageDoc.manifest) {
4667
4341
  const hrefBase = item.href.split("?")[0] ?? item.href;
4668
4342
  if (/^[a-zA-Z][a-zA-Z0-9+\-.]*:/.test(hrefBase)) continue;
4669
- manifestPaths.add(resolvePath(opfPath, hrefBase).normalize("NFC"));
4343
+ manifestPaths.add(resolvePath(opfPath, tryDecodeUriComponent(hrefBase)).normalize("NFC"));
4670
4344
  }
4671
4345
  const rootfilePaths = new Set(context.rootfiles.map((r) => r.path.normalize("NFC")));
4672
4346
  for (const path of context.files.keys()) {
4673
4347
  if (path === "mimetype") continue;
4348
+ if (path.endsWith("/")) continue;
4674
4349
  if (path.startsWith("META-INF/")) continue;
4675
4350
  if (rootfilePaths.has(path)) continue;
4676
4351
  if (manifestPaths.has(path)) continue;
@@ -5256,26 +4931,493 @@ function isValidW3CDateFormat(dateStr) {
5256
4931
  if (seconds < 0 || seconds > 59) return false;
5257
4932
  return true;
5258
4933
  }
5259
- return false;
5260
- }
5261
-
5262
- // src/references/types.ts
5263
- var PUBLICATION_RESOURCE_TYPES = /* @__PURE__ */ new Set([
5264
- "generic" /* GENERIC */,
5265
- "stylesheet" /* STYLESHEET */,
5266
- "font" /* FONT */,
5267
- "image" /* IMAGE */,
5268
- "audio" /* AUDIO */,
5269
- "video" /* VIDEO */,
5270
- "track" /* TRACK */,
5271
- "media-overlay" /* MEDIA_OVERLAY */,
5272
- "svg-symbol" /* SVG_SYMBOL */,
5273
- "svg-paint" /* SVG_PAINT */,
5274
- "svg-clip-path" /* SVG_CLIP_PATH */
5275
- ]);
5276
- function isPublicationResourceReference(type) {
5277
- return PUBLICATION_RESOURCE_TYPES.has(type);
5278
- }
4934
+ return false;
4935
+ }
4936
+
4937
+ // src/references/types.ts
4938
+ var PUBLICATION_RESOURCE_TYPES = /* @__PURE__ */ new Set([
4939
+ "generic" /* GENERIC */,
4940
+ "stylesheet" /* STYLESHEET */,
4941
+ "font" /* FONT */,
4942
+ "image" /* IMAGE */,
4943
+ "audio" /* AUDIO */,
4944
+ "video" /* VIDEO */,
4945
+ "track" /* TRACK */,
4946
+ "media-overlay" /* MEDIA_OVERLAY */,
4947
+ "svg-symbol" /* SVG_SYMBOL */,
4948
+ "svg-paint" /* SVG_PAINT */,
4949
+ "svg-clip-path" /* SVG_CLIP_PATH */
4950
+ ]);
4951
+ function isPublicationResourceReference(type) {
4952
+ return PUBLICATION_RESOURCE_TYPES.has(type);
4953
+ }
4954
+
4955
+ // src/skm/validator.ts
4956
+ var OPS_NS_URI = "http://www.idpf.org/2007/ops";
4957
+ var SKM_NS = { ops: OPS_NS_URI };
4958
+ var SKMValidator = class {
4959
+ validate(context, path, refValidator) {
4960
+ const data = context.files.get(path);
4961
+ if (!data) return;
4962
+ const content = typeof data === "string" ? data : new TextDecoder().decode(data);
4963
+ let doc = null;
4964
+ try {
4965
+ doc = libxml2Wasm.XmlDocument.fromString(content);
4966
+ } catch {
4967
+ pushMessage(context.messages, {
4968
+ id: MessageId.RSC_016,
4969
+ message: "Search Key Map document is not well-formed XML",
4970
+ location: { path }
4971
+ });
4972
+ return;
4973
+ }
4974
+ try {
4975
+ const root = doc.root;
4976
+ if (root.namespaceUri !== OPS_NS_URI || root.name !== "search-key-map") {
4977
+ pushMessage(context.messages, {
4978
+ id: MessageId.RSC_005,
4979
+ message: `Root element must be "search-key-map" in the OPS namespace`,
4980
+ location: { path, line: root.line }
4981
+ });
4982
+ return;
4983
+ }
4984
+ const groups = root.find("./ops:search-key-group", SKM_NS);
4985
+ if (groups.length === 0) {
4986
+ pushMessage(context.messages, {
4987
+ id: MessageId.RSC_005,
4988
+ message: 'A "search-key-map" must contain at least one "search-key-group"',
4989
+ location: { path, line: root.line }
4990
+ });
4991
+ }
4992
+ for (const group of groups) {
4993
+ const groupEl = group;
4994
+ const href = groupEl.attr("href")?.value;
4995
+ if (!href) {
4996
+ pushMessage(context.messages, {
4997
+ id: MessageId.RSC_005,
4998
+ message: 'The "href" attribute is required on "search-key-group"',
4999
+ location: { path, line: groupEl.line }
5000
+ });
5001
+ } else if (refValidator) {
5002
+ registerSkmRef(refValidator, path, href, groupEl.line);
5003
+ }
5004
+ const matches = groupEl.find("./ops:match", SKM_NS);
5005
+ if (matches.length === 0) {
5006
+ pushMessage(context.messages, {
5007
+ id: MessageId.RSC_005,
5008
+ message: 'A "search-key-group" must contain at least one "match"',
5009
+ location: { path, line: groupEl.line }
5010
+ });
5011
+ }
5012
+ for (const match of matches) {
5013
+ const matchEl = match;
5014
+ const matchHref = matchEl.attr("href")?.value;
5015
+ if (matchHref && refValidator) {
5016
+ registerSkmRef(refValidator, path, matchHref, matchEl.line);
5017
+ }
5018
+ }
5019
+ }
5020
+ } finally {
5021
+ doc.dispose();
5022
+ }
5023
+ }
5024
+ };
5025
+ function registerSkmRef(refValidator, path, href, line) {
5026
+ const parsed = parseURL(href);
5027
+ const targetResource = resolvePath(path, tryDecodeUriComponent(parsed.resource)).normalize("NFC");
5028
+ const location = line != null ? { path, line } : { path };
5029
+ const ref = {
5030
+ url: parsed.hasFragment ? `${targetResource}#${parsed.fragment ?? ""}` : targetResource,
5031
+ targetResource,
5032
+ type: "search-key" /* SEARCH_KEY */,
5033
+ location
5034
+ };
5035
+ if (parsed.fragment !== void 0) ref.fragment = parsed.fragment;
5036
+ refValidator.addReference(ref);
5037
+ }
5038
+
5039
+ // src/vocab/epub-ssv.ts
5040
+ var EPUB_SSV_DEPRECATED = /* @__PURE__ */ new Set([
5041
+ "annoref",
5042
+ "annotation",
5043
+ "biblioentry",
5044
+ "bridgehead",
5045
+ "endnote",
5046
+ "help",
5047
+ "marginalia",
5048
+ "note",
5049
+ "rearnote",
5050
+ "rearnotes",
5051
+ "sidebar",
5052
+ "subchapter",
5053
+ "warning"
5054
+ ]);
5055
+ var EPUB_SSV_DISALLOWED_ON_CONTENT = /* @__PURE__ */ new Set([
5056
+ "aside",
5057
+ "figure",
5058
+ "list",
5059
+ "list-item",
5060
+ "table",
5061
+ "table-cell",
5062
+ "table-row"
5063
+ ]);
5064
+ var EPUB_SSV_ALL = /* @__PURE__ */ new Set([
5065
+ ...EPUB_SSV_DEPRECATED,
5066
+ ...EPUB_SSV_DISALLOWED_ON_CONTENT,
5067
+ "abstract",
5068
+ "acknowledgments",
5069
+ "afterword",
5070
+ "appendix",
5071
+ "assessment",
5072
+ "assessments",
5073
+ "backlink",
5074
+ "backmatter",
5075
+ "balloon",
5076
+ "bibliography",
5077
+ "biblioref",
5078
+ "bodymatter",
5079
+ "case-study",
5080
+ "chapter",
5081
+ "colophon",
5082
+ "concluding-sentence",
5083
+ "conclusion",
5084
+ "contributors",
5085
+ "copyright-page",
5086
+ "cover",
5087
+ "covertitle",
5088
+ "credit",
5089
+ "credits",
5090
+ "dedication",
5091
+ "division",
5092
+ "endnotes",
5093
+ "epigraph",
5094
+ "epilogue",
5095
+ "errata",
5096
+ "fill-in-the-blank-problem",
5097
+ "footnote",
5098
+ "footnotes",
5099
+ "foreword",
5100
+ "frontmatter",
5101
+ "fulltitle",
5102
+ "general-problem",
5103
+ "glossary",
5104
+ "glossdef",
5105
+ "glossref",
5106
+ "glossterm",
5107
+ "halftitle",
5108
+ "halftitlepage",
5109
+ "imprimatur",
5110
+ "imprint",
5111
+ "index",
5112
+ "index-editor-note",
5113
+ "index-entry",
5114
+ "index-entry-list",
5115
+ "index-group",
5116
+ "index-headnotes",
5117
+ "index-legend",
5118
+ "index-locator",
5119
+ "index-locator-list",
5120
+ "index-locator-range",
5121
+ "index-term",
5122
+ "index-term-categories",
5123
+ "index-term-category",
5124
+ "index-xref-preferred",
5125
+ "index-xref-related",
5126
+ "introduction",
5127
+ "keyword",
5128
+ "keywords",
5129
+ "label",
5130
+ "landmarks",
5131
+ "learning-objective",
5132
+ "learning-objectives",
5133
+ "learning-outcome",
5134
+ "learning-outcomes",
5135
+ "learning-resource",
5136
+ "learning-resources",
5137
+ "learning-standard",
5138
+ "learning-standards",
5139
+ "loa",
5140
+ "loi",
5141
+ "lot",
5142
+ "lov",
5143
+ "match-problem",
5144
+ "multiple-choice-problem",
5145
+ "noteref",
5146
+ "notice",
5147
+ "ordinal",
5148
+ "other-credits",
5149
+ "page-list",
5150
+ "pagebreak",
5151
+ "panel",
5152
+ "panel-group",
5153
+ "part",
5154
+ "practice",
5155
+ "practices",
5156
+ "preamble",
5157
+ "preface",
5158
+ "prologue",
5159
+ "pullquote",
5160
+ "qna",
5161
+ "question",
5162
+ "referrer",
5163
+ "revision-history",
5164
+ "seriespage",
5165
+ "sound-area",
5166
+ "subtitle",
5167
+ "tip",
5168
+ "title",
5169
+ "titlepage",
5170
+ "toc",
5171
+ "toc-brief",
5172
+ "topic-sentence",
5173
+ "true-false-problem",
5174
+ "volume"
5175
+ ]);
5176
+
5177
+ // src/smil/validator.ts
5178
+ var SMIL_NS = { smil: "http://www.w3.org/ns/SMIL" };
5179
+ var BLESSED_AUDIO_TYPES = /* @__PURE__ */ new Set(["audio/mpeg", "audio/mp4"]);
5180
+ var BLESSED_AUDIO_PATTERN = /^audio\/ogg\s*;\s*codecs=opus$/i;
5181
+ function isBlessedAudioType(mimeType) {
5182
+ return BLESSED_AUDIO_TYPES.has(mimeType) || BLESSED_AUDIO_PATTERN.test(mimeType);
5183
+ }
5184
+ var SMILValidator = class {
5185
+ getAttribute(element, name) {
5186
+ return element.attr(name)?.value ?? null;
5187
+ }
5188
+ getEpubAttribute(element, localName) {
5189
+ return element.attr(localName, "epub")?.value ?? null;
5190
+ }
5191
+ validate(context, path, manifestByPath) {
5192
+ const result = {
5193
+ textReferences: [],
5194
+ referencedDocuments: /* @__PURE__ */ new Set(),
5195
+ hasRemoteResources: false
5196
+ };
5197
+ const data = context.files.get(path);
5198
+ if (!data) return result;
5199
+ const content = typeof data === "string" ? data : new TextDecoder().decode(data);
5200
+ let doc = null;
5201
+ try {
5202
+ doc = libxml2Wasm.XmlDocument.fromString(content);
5203
+ } catch {
5204
+ pushMessage(context.messages, {
5205
+ id: MessageId.RSC_016,
5206
+ message: "Media Overlay document is not well-formed XML",
5207
+ location: { path }
5208
+ });
5209
+ return result;
5210
+ }
5211
+ try {
5212
+ const root = doc.root;
5213
+ this.validateStructure(context, path, root);
5214
+ this.validateAudioElements(context, path, root, manifestByPath, result);
5215
+ this.validateEpubTypes(context, path, root);
5216
+ this.extractTextReferences(path, root, result);
5217
+ } finally {
5218
+ doc.dispose();
5219
+ }
5220
+ return result;
5221
+ }
5222
+ validateStructure(context, path, root) {
5223
+ try {
5224
+ for (const text of root.find(".//smil:seq/smil:text", SMIL_NS)) {
5225
+ pushMessage(context.messages, {
5226
+ id: MessageId.RSC_005,
5227
+ message: "element 'text' not allowed here; expected 'seq' or 'par'",
5228
+ location: { path, line: text.line }
5229
+ });
5230
+ }
5231
+ for (const audio of root.find(".//smil:seq/smil:audio", SMIL_NS)) {
5232
+ pushMessage(context.messages, {
5233
+ id: MessageId.RSC_005,
5234
+ message: "element 'audio' not allowed here; expected 'seq' or 'par'",
5235
+ location: { path, line: audio.line }
5236
+ });
5237
+ }
5238
+ } catch {
5239
+ }
5240
+ try {
5241
+ for (const seq of root.find(".//smil:par/smil:seq", SMIL_NS)) {
5242
+ pushMessage(context.messages, {
5243
+ id: MessageId.RSC_005,
5244
+ message: "element 'seq' not allowed here; expected 'text' or 'audio'",
5245
+ location: { path, line: seq.line }
5246
+ });
5247
+ }
5248
+ const parElements = root.find(".//smil:par", SMIL_NS);
5249
+ for (const par of parElements) {
5250
+ const textChildren = par.find("./smil:text", SMIL_NS);
5251
+ for (let i = 1; i < textChildren.length; i++) {
5252
+ const extra = textChildren[i];
5253
+ if (!extra) continue;
5254
+ pushMessage(context.messages, {
5255
+ id: MessageId.RSC_005,
5256
+ message: "element 'text' not allowed here; only one 'text' element is allowed in 'par'",
5257
+ location: { path, line: extra.line }
5258
+ });
5259
+ }
5260
+ }
5261
+ } catch {
5262
+ }
5263
+ try {
5264
+ const headMetaElements = root.find(".//smil:head/smil:meta", SMIL_NS);
5265
+ for (const meta of headMetaElements) {
5266
+ pushMessage(context.messages, {
5267
+ id: MessageId.RSC_005,
5268
+ message: "element 'meta' not allowed here; expected 'metadata'",
5269
+ location: { path, line: meta.line }
5270
+ });
5271
+ }
5272
+ } catch {
5273
+ }
5274
+ }
5275
+ validateAudioElements(context, path, root, manifestByPath, result) {
5276
+ try {
5277
+ const audioElements = root.find(".//smil:audio", SMIL_NS);
5278
+ for (const audio of audioElements) {
5279
+ const elem = audio;
5280
+ const src = this.getAttribute(elem, "src");
5281
+ if (src) {
5282
+ if (isRemoteURL(src)) {
5283
+ result.hasRemoteResources = true;
5284
+ }
5285
+ if (src.includes("#")) {
5286
+ pushMessage(context.messages, {
5287
+ id: MessageId.MED_014,
5288
+ message: `Media overlay audio file URLs must not have a fragment: "${src}"`,
5289
+ location: { path, line: audio.line }
5290
+ });
5291
+ }
5292
+ if (manifestByPath) {
5293
+ const audioPath = this.resolveRelativePath(path, src.split("#")[0] ?? src);
5294
+ const audioItem = manifestByPath.get(audioPath);
5295
+ if (audioItem && !isBlessedAudioType(audioItem.mediaType)) {
5296
+ pushMessage(context.messages, {
5297
+ id: MessageId.MED_005,
5298
+ message: `Media Overlay audio reference "${src}" to non-standard audio type "${audioItem.mediaType}"`,
5299
+ location: { path, line: audio.line }
5300
+ });
5301
+ }
5302
+ }
5303
+ }
5304
+ const clipBegin = this.getAttribute(elem, "clipBegin");
5305
+ const clipEnd = this.getAttribute(elem, "clipEnd");
5306
+ this.checkClipTiming(context, path, audio.line, clipBegin, clipEnd);
5307
+ }
5308
+ } catch {
5309
+ }
5310
+ }
5311
+ checkClipTiming(context, path, line, clipBegin, clipEnd) {
5312
+ if (clipEnd === null) return;
5313
+ const beginStr = clipBegin ?? "0";
5314
+ const start = parseSmilClock(beginStr);
5315
+ const end = parseSmilClock(clipEnd);
5316
+ const location = line != null ? { path, line } : { path };
5317
+ if (clipBegin !== null && Number.isNaN(start)) {
5318
+ pushMessage(context.messages, {
5319
+ id: MessageId.RSC_005,
5320
+ message: `Invalid SMIL clock value "${clipBegin}" in clipBegin attribute`,
5321
+ location
5322
+ });
5323
+ }
5324
+ if (Number.isNaN(end)) {
5325
+ pushMessage(context.messages, {
5326
+ id: MessageId.RSC_005,
5327
+ message: `Invalid SMIL clock value "${clipEnd}" in clipEnd attribute`,
5328
+ location
5329
+ });
5330
+ }
5331
+ if (Number.isNaN(start) || Number.isNaN(end)) return;
5332
+ if (start > end) {
5333
+ pushMessage(context.messages, {
5334
+ id: MessageId.MED_008,
5335
+ message: "The time specified in the clipBegin attribute must not be after clipEnd",
5336
+ location
5337
+ });
5338
+ } else if (start === end) {
5339
+ pushMessage(context.messages, {
5340
+ id: MessageId.MED_009,
5341
+ message: "The time specified in the clipBegin attribute must not be the same as clipEnd",
5342
+ location
5343
+ });
5344
+ }
5345
+ }
5346
+ /**
5347
+ * Validate epub:type attribute values against the EPUB SSV vocabulary.
5348
+ * Only emits OPF-088 (usage) for unknown local names. Prefixed values
5349
+ * from declared vocabularies are allowed.
5350
+ */
5351
+ validateEpubTypes(context, path, root) {
5352
+ try {
5353
+ const epubTypeElements = root.find(".//*[@epub:type]", {
5354
+ epub: "http://www.idpf.org/2007/ops"
5355
+ });
5356
+ for (const elem of epubTypeElements) {
5357
+ const elemTyped = elem;
5358
+ const epubTypeAttr = elemTyped.attr("type", "epub");
5359
+ if (!epubTypeAttr?.value) continue;
5360
+ for (const part of epubTypeAttr.value.split(/\s+/)) {
5361
+ if (!part || part.includes(":")) continue;
5362
+ if (!EPUB_SSV_ALL.has(part)) {
5363
+ pushMessage(context.messages, {
5364
+ id: MessageId.OPF_088,
5365
+ message: `Unrecognized epub:type value "${part}"`,
5366
+ location: { path, line: elem.line }
5367
+ });
5368
+ }
5369
+ }
5370
+ }
5371
+ } catch {
5372
+ }
5373
+ }
5374
+ extractTextReferences(path, root, result) {
5375
+ try {
5376
+ const textElements = root.find(".//smil:text", SMIL_NS);
5377
+ for (const text of textElements) {
5378
+ const src = this.getAttribute(text, "src");
5379
+ if (!src) continue;
5380
+ const hashIndex = src.indexOf("#");
5381
+ const docRef = hashIndex >= 0 ? src.substring(0, hashIndex) : src;
5382
+ const fragment = hashIndex >= 0 ? src.substring(hashIndex + 1) : void 0;
5383
+ const docPath = this.resolveRelativePath(path, docRef);
5384
+ result.textReferences.push({ docPath, fragment, line: text.line });
5385
+ result.referencedDocuments.add(docPath);
5386
+ }
5387
+ const bodyElements = root.find(".//smil:body", SMIL_NS);
5388
+ const seqElements = root.find(".//smil:seq", SMIL_NS);
5389
+ for (const elem of [...bodyElements, ...seqElements]) {
5390
+ const textref = this.getEpubAttribute(elem, "textref");
5391
+ if (!textref) continue;
5392
+ const hashIndex = textref.indexOf("#");
5393
+ const docRef = hashIndex >= 0 ? textref.substring(0, hashIndex) : textref;
5394
+ const fragment = hashIndex >= 0 ? textref.substring(hashIndex + 1) : void 0;
5395
+ const docPath = this.resolveRelativePath(path, docRef);
5396
+ result.textReferences.push({ docPath, fragment, line: elem.line });
5397
+ result.referencedDocuments.add(docPath);
5398
+ }
5399
+ } catch {
5400
+ }
5401
+ }
5402
+ resolveRelativePath(basePath, relativePath) {
5403
+ const decoded = tryDecodeUriComponent(relativePath);
5404
+ if (decoded.startsWith("/") || /^[a-zA-Z]+:/.test(decoded)) {
5405
+ return decoded.normalize("NFC");
5406
+ }
5407
+ const baseDir = basePath.includes("/") ? basePath.substring(0, basePath.lastIndexOf("/")) : "";
5408
+ if (!baseDir) return decoded.normalize("NFC");
5409
+ const segments = `${baseDir}/${decoded}`.split("/");
5410
+ const resolved = [];
5411
+ for (const seg of segments) {
5412
+ if (seg === "..") {
5413
+ resolved.pop();
5414
+ } else if (seg !== ".") {
5415
+ resolved.push(seg);
5416
+ }
5417
+ }
5418
+ return resolved.join("/").normalize("NFC");
5419
+ }
5420
+ };
5279
5421
 
5280
5422
  // src/references/uri-schemes.ts
5281
5423
  var URI_SCHEMES = /* @__PURE__ */ new Set([
@@ -5366,7 +5508,9 @@ var ABSOLUTE_URI_RE = /^[a-zA-Z][a-zA-Z0-9+.-]*:/;
5366
5508
  var SPECIAL_URL_SCHEMES = /* @__PURE__ */ new Set(["http", "https", "ftp", "ws", "wss"]);
5367
5509
  var CSS_CHARSET_RE = /^@charset\s+"([^"]+)"\s*;/;
5368
5510
  var EPUB_XMLNS_RE = /xmlns:epub\s*=\s*"([^"]*)"/;
5369
- var XHTML_NS = { html: "http://www.w3.org/1999/xhtml" };
5511
+ var XHTML_NS_URI = "http://www.w3.org/1999/xhtml";
5512
+ var XML_NS_URI = "http://www.w3.org/XML/1998/namespace";
5513
+ var XHTML_NS = { html: XHTML_NS_URI };
5370
5514
  var EPUB_OPS_NS = { epub: "http://www.idpf.org/2007/ops" };
5371
5515
  var EPUB_TYPE_FORBIDDEN_ELEMENTS = /* @__PURE__ */ new Set([
5372
5516
  "head",
@@ -5650,6 +5794,112 @@ var HTML5_ELEMENTS = /* @__PURE__ */ new Set([
5650
5794
  "video",
5651
5795
  "wbr"
5652
5796
  ]);
5797
+ var XHTML11_ELEMENTS = /* @__PURE__ */ new Set([
5798
+ // struct
5799
+ "html",
5800
+ "head",
5801
+ "title",
5802
+ "body",
5803
+ "meta",
5804
+ "link",
5805
+ "base",
5806
+ "style",
5807
+ "script",
5808
+ "noscript",
5809
+ // text
5810
+ "br",
5811
+ "span",
5812
+ "abbr",
5813
+ "acronym",
5814
+ "cite",
5815
+ "code",
5816
+ "dfn",
5817
+ "em",
5818
+ "kbd",
5819
+ "q",
5820
+ "samp",
5821
+ "strong",
5822
+ "var",
5823
+ "div",
5824
+ "p",
5825
+ "address",
5826
+ "blockquote",
5827
+ "pre",
5828
+ "h1",
5829
+ "h2",
5830
+ "h3",
5831
+ "h4",
5832
+ "h5",
5833
+ "h6",
5834
+ // pres / legacy
5835
+ "hr",
5836
+ "b",
5837
+ "big",
5838
+ "i",
5839
+ "small",
5840
+ "sub",
5841
+ "sup",
5842
+ "tt",
5843
+ "basefont",
5844
+ "center",
5845
+ "font",
5846
+ "s",
5847
+ "strike",
5848
+ "u",
5849
+ "dir",
5850
+ "menu",
5851
+ "isindex",
5852
+ // list
5853
+ "dl",
5854
+ "dt",
5855
+ "dd",
5856
+ "ol",
5857
+ "ul",
5858
+ "li",
5859
+ // table
5860
+ "table",
5861
+ "caption",
5862
+ "tr",
5863
+ "th",
5864
+ "td",
5865
+ "col",
5866
+ "colgroup",
5867
+ "tbody",
5868
+ "thead",
5869
+ "tfoot",
5870
+ // hypertext / image / object / form / edit / ruby / map / iframe / applet / bdo / param
5871
+ "a",
5872
+ "img",
5873
+ "object",
5874
+ "param",
5875
+ "form",
5876
+ "label",
5877
+ "input",
5878
+ "select",
5879
+ "option",
5880
+ "optgroup",
5881
+ "fieldset",
5882
+ "button",
5883
+ "legend",
5884
+ "textarea",
5885
+ "ins",
5886
+ "del",
5887
+ "ruby",
5888
+ "rbc",
5889
+ "rtc",
5890
+ "rb",
5891
+ "rt",
5892
+ "rp",
5893
+ "map",
5894
+ "area",
5895
+ "iframe",
5896
+ "applet",
5897
+ "bdo",
5898
+ // frames
5899
+ "frameset",
5900
+ "frame",
5901
+ "noframes"
5902
+ ]);
5653
5903
  function isItemFixedLayout(packageDoc, itemId) {
5654
5904
  const spineItem = packageDoc.spine.find((s) => s.idref === itemId);
5655
5905
  if (!spineItem) return false;
@@ -5716,6 +5966,10 @@ var ContentValidator = class {
5716
5966
  if (refValidator) {
5717
5967
  this.extractSVGReferences(context, fullPath, opfDir, refValidator);
5718
5968
  }
5969
+ } else if (item.mediaType === "application/vnd.epub.search-key-map+xml") {
5970
+ const fullPath = resolveManifestHref(opfDir, item.href);
5971
+ const skmValidator = new SKMValidator();
5972
+ skmValidator.validate(context, fullPath, refValidator);
5719
5973
  } else if (item.mediaType === "application/smil+xml") {
5720
5974
  const fullPath = resolveManifestHref(opfDir, item.href);
5721
5975
  const smilValidator = new SMILValidator();
@@ -6478,6 +6732,9 @@ var ContentValidator = class {
6478
6732
  }
6479
6733
  }
6480
6734
  }
6735
+ if (context.version === "2.0") {
6736
+ this.checkEpub2XhtmlStrict(context, path, root);
6737
+ }
6481
6738
  this.checkDiscouragedElements(context, path, root);
6482
6739
  this.checkSSMLPh(context, path, root, content);
6483
6740
  this.checkObsoleteHTML(context, path, root);
@@ -6508,6 +6765,9 @@ var ContentValidator = class {
6508
6765
  this.validateEpubTypes(context, path, root);
6509
6766
  this.validateRegionBasedNav(context, path, root, manifestItem);
6510
6767
  }
6768
+ if (context.version.startsWith("3") && context.options.profile === "dict") {
6769
+ this.validateDictionaryContent(context, path, root);
6770
+ }
6511
6771
  if (context.version.startsWith("3") && context.options.profile === "edupub") {
6512
6772
  const isFxl = manifestItem && packageDoc ? isItemFixedLayout(packageDoc, manifestItem.id) : false;
6513
6773
  const isNonLinear = manifestItem && packageDoc ? packageDoc.spine.find((ref) => ref.idref === manifestItem.id)?.linear === false : false;
@@ -6516,7 +6776,7 @@ var ContentValidator = class {
6516
6776
  }
6517
6777
  }
6518
6778
  if (context.version.startsWith("3")) {
6519
- this.collectFeatures(context, root);
6779
+ this.collectFeatures(context, path, root);
6520
6780
  }
6521
6781
  this.validateEpubSwitch(context, path, root);
6522
6782
  this.validateEpubTrigger(context, path, root);
@@ -7726,6 +7986,7 @@ var ContentValidator = class {
7726
7986
  }
7727
7987
  }
7728
7988
  checkUsemapAttribute(context, path, root) {
7989
+ if (context.version === "2.0") return;
7729
7990
  try {
7730
7991
  const elements = root.find(".//html:*[@usemap]", XHTML_NS);
7731
7992
  for (const elem of elements) {
@@ -8038,7 +8299,7 @@ var ContentValidator = class {
8038
8299
  }
8039
8300
  }
8040
8301
  }
8041
- collectFeatures(context, root) {
8302
+ collectFeatures(context, path, root) {
8042
8303
  const features = context.contentFeatures;
8043
8304
  if (!features) return;
8044
8305
  if (!features.hasTable && root.get(".//html:table", XHTML_NS)) {
@@ -8053,22 +8314,20 @@ var ContentValidator = class {
8053
8314
  if (!features.hasVideo && root.get(".//html:video", XHTML_NS)) {
8054
8315
  features.hasVideo = true;
8055
8316
  }
8056
- if (!features.hasPageBreak || !features.hasDictionary || !features.hasIndex) {
8057
- const epubTypeElements = root.find(".//*[@epub:type]", EPUB_OPS_NS);
8058
- for (const el of epubTypeElements) {
8059
- const attr = el.attr("type", "epub");
8060
- if (!attr?.value) continue;
8061
- const tokens = attr.value.trim().split(/\s+/);
8062
- if (!features.hasPageBreak && tokens.includes("pagebreak")) {
8063
- features.hasPageBreak = true;
8064
- }
8065
- if (!features.hasDictionary && tokens.includes("dictionary")) {
8066
- features.hasDictionary = true;
8067
- }
8068
- if (!features.hasIndex && tokens.includes("index")) {
8069
- features.hasIndex = true;
8070
- }
8071
- if (features.hasPageBreak && features.hasDictionary && features.hasIndex) break;
8317
+ const epubTypeElements = root.find(".//*[@epub:type]", EPUB_OPS_NS);
8318
+ for (const el of epubTypeElements) {
8319
+ const attr = el.attr("type", "epub");
8320
+ if (!attr?.value) continue;
8321
+ const tokens = attr.value.trim().split(/\s+/);
8322
+ if (!features.hasPageBreak && tokens.includes("pagebreak")) {
8323
+ features.hasPageBreak = true;
8324
+ }
8325
+ if (tokens.includes("dictionary")) {
8326
+ features.hasDictionary = true;
8327
+ (features.dictionaryContentPaths ??= /* @__PURE__ */ new Set()).add(path);
8328
+ }
8329
+ if (!features.hasIndex && tokens.includes("index")) {
8330
+ features.hasIndex = true;
8072
8331
  }
8073
8332
  }
8074
8333
  if (!features.hasMicrodata && root.get(".//*[@itemscope]")) {
@@ -8169,6 +8428,158 @@ var ContentValidator = class {
8169
8428
  });
8170
8429
  }
8171
8430
  }
8431
+ this.validateRegionBasedNavRules(context, path, root);
8432
+ }
8433
+ }
8434
+ validateRegionBasedNavRules(context, path, root) {
8435
+ const XHTML_NS2 = { html: "http://www.w3.org/1999/xhtml" };
8436
+ let regionNavs;
8437
+ try {
8438
+ regionNavs = root.find(".//html:nav", XHTML_NS2);
8439
+ } catch {
8440
+ return;
8441
+ }
8442
+ const packageDoc = context.packageDocument;
8443
+ const opfDir = context.opfPath?.includes("/") ? context.opfPath.substring(0, context.opfPath.lastIndexOf("/")) : "";
8444
+ const docDir = path.includes("/") ? path.substring(0, path.lastIndexOf("/")) : "";
8445
+ const manifestByPath = packageDoc ? new Map(packageDoc.manifest.map((m) => [resolveManifestHref(opfDir, m.href), m])) : void 0;
8446
+ for (const nav of regionNavs) {
8447
+ const epubType = nav.attr("type", "epub")?.value ?? "";
8448
+ if (!epubType.split(/\s+/).includes("region-based")) continue;
8449
+ const childEls = nav.find("./html:*", XHTML_NS2);
8450
+ if (childEls.length !== 1 || childEls[0]?.name !== "ol") {
8451
+ pushMessage(context.messages, {
8452
+ id: MessageId.RSC_017,
8453
+ message: "A region-based nav element must contain exactly one child ol element.",
8454
+ location: { path, line: nav.line }
8455
+ });
8456
+ }
8457
+ const liElements = nav.find(".//html:li", XHTML_NS2);
8458
+ for (const li of liElements) {
8459
+ const liChildren = li.find("./html:*", XHTML_NS2);
8460
+ const first = liChildren[0];
8461
+ if (!first || first.name !== "a" && first.name !== "span") {
8462
+ pushMessage(context.messages, {
8463
+ id: MessageId.RSC_017,
8464
+ message: "The first child of a region-based nav list item must be either an 'a' or 'span' element.",
8465
+ location: { path, line: li.line }
8466
+ });
8467
+ }
8468
+ if (liChildren.length > 1 && (liChildren.length !== 2 || liChildren[1]?.name !== "ol")) {
8469
+ pushMessage(context.messages, {
8470
+ id: MessageId.RSC_017,
8471
+ message: "The first child of a region-based nav list item can only be followed by a single 'ol' element.",
8472
+ location: { path, line: li.line }
8473
+ });
8474
+ }
8475
+ }
8476
+ const spans = nav.find(".//html:span", XHTML_NS2);
8477
+ for (const span of spans) {
8478
+ const spanChildren = span.find("./html:*", XHTML_NS2);
8479
+ const aChildren = spanChildren.filter((c) => c.name === "a");
8480
+ if (spanChildren.length !== 2 || aChildren.length !== 2) {
8481
+ pushMessage(context.messages, {
8482
+ id: MessageId.RSC_017,
8483
+ message: "'span' elements in region-based navs must contain exactly two 'a' elements.",
8484
+ location: { path, line: span.line }
8485
+ });
8486
+ }
8487
+ }
8488
+ const anchors = nav.find(".//html:a", XHTML_NS2);
8489
+ for (const a of anchors) {
8490
+ if (a.content.trim() !== "") {
8491
+ pushMessage(context.messages, {
8492
+ id: MessageId.RSC_017,
8493
+ message: "'a' elements in region-based navs should not contain text labels.",
8494
+ location: { path, line: a.line }
8495
+ });
8496
+ }
8497
+ }
8498
+ if (!packageDoc || !manifestByPath) continue;
8499
+ for (const a of anchors) {
8500
+ const href = a.attr("href")?.value;
8501
+ if (!href || !isRelativeURL(href)) continue;
8502
+ const resolved = this.resolveRelativePath(docDir, href, opfDir);
8503
+ const targetPath = parseURL(resolved).resource;
8504
+ if (!targetPath) continue;
8505
+ const item = manifestByPath.get(targetPath);
8506
+ if (!item) continue;
8507
+ if (!isItemFixedLayout(packageDoc, item.id)) {
8508
+ pushMessage(context.messages, {
8509
+ id: MessageId.NAV_009,
8510
+ message: "Region-based navigation links must point to Fixed-Layout Documents.",
8511
+ location: { path, line: a.line }
8512
+ });
8513
+ }
8514
+ }
8515
+ }
8516
+ }
8517
+ /**
8518
+ * EPUB Dictionaries content document rules.
8519
+ *
8520
+ * Mirrors ../epubcheck/src/main/resources/com/adobe/epubcheck/schema/30/dict/dict-xhtml.sch
8521
+ * (minimum set: `dictionary` must be on body/section with article children; each article or
8522
+ * `dictentry` must have a `dfn` descendant outside of optional `condensed-entry`).
8523
+ */
8524
+ validateDictionaryContent(context, path, root) {
8525
+ let typedElements;
8526
+ try {
8527
+ typedElements = root.find(".//*[@epub:type]", EPUB_OPS_NS);
8528
+ } catch {
8529
+ return;
8530
+ }
8531
+ for (const el of typedElements) {
8532
+ const tokens = el.attr("type", "epub")?.value.split(/\s+/) ?? [];
8533
+ if (tokens.includes("dictionary")) {
8534
+ if (el.name !== "body" && el.name !== "section") {
8535
+ pushMessage(context.messages, {
8536
+ id: MessageId.RSC_005,
8537
+ message: 'The "dictionary" type is only allowed on "body" or "section" elements.',
8538
+ location: { path, line: el.line }
8539
+ });
8540
+ }
8541
+ const articles = el.find("./html:article", XHTML_NS);
8542
+ if (articles.length === 0) {
8543
+ pushMessage(context.messages, {
8544
+ id: MessageId.RSC_005,
8545
+ message: 'A "dictionary" must have at least one article child.',
8546
+ location: { path, line: el.line }
8547
+ });
8548
+ }
8549
+ for (const article of articles) {
8550
+ this.checkDictionaryEntry(context, path, article);
8551
+ }
8552
+ }
8553
+ if (tokens.includes("dictentry")) {
8554
+ if (el.name !== "article") {
8555
+ pushMessage(context.messages, {
8556
+ id: MessageId.RSC_005,
8557
+ message: 'The "dictentry" type is only allowed on "article" elements.',
8558
+ location: { path, line: el.line }
8559
+ });
8560
+ } else {
8561
+ this.checkDictionaryEntry(context, path, el);
8562
+ }
8563
+ }
8564
+ }
8565
+ }
8566
+ checkDictionaryEntry(context, path, article) {
8567
+ const dfns = article.find(".//html:dfn", XHTML_NS);
8568
+ const hasDfnOutsideCondensed = dfns.some((dfn) => {
8569
+ let parent = dfn.parent;
8570
+ while (parent) {
8571
+ const type = parent.attr("type", "epub")?.value;
8572
+ if (type?.split(/\s+/).includes("condensed-entry")) return false;
8573
+ parent = parent.parent;
8574
+ }
8575
+ return true;
8576
+ });
8577
+ if (!hasDfnOutsideCondensed) {
8578
+ pushMessage(context.messages, {
8579
+ id: MessageId.RSC_005,
8580
+ message: 'A dictionary entry must have at least one "dfn" descendant (outside of the optional condensed entry "aside").',
8581
+ location: { path, line: article.line }
8582
+ });
8172
8583
  }
8173
8584
  }
8174
8585
  /**
@@ -9565,13 +9976,11 @@ var ContentValidator = class {
9565
9976
  }
9566
9977
  }
9567
9978
  checkUnknownElements(context, path, root) {
9568
- const XHTML_NS2 = "http://www.w3.org/1999/xhtml";
9569
9979
  try {
9570
9980
  const allElements = root.find(".//*");
9571
9981
  for (const el of allElements) {
9572
9982
  const xmlEl = el;
9573
- const ns = xmlEl.namespaceUri;
9574
- if (ns !== XHTML_NS2) continue;
9983
+ if (xmlEl.namespaceUri !== XHTML_NS_URI) continue;
9575
9984
  const localName = xmlEl.name.includes(":") ? xmlEl.name.substring(xmlEl.name.indexOf(":") + 1) : xmlEl.name;
9576
9985
  if (localName.includes("-")) continue;
9577
9986
  if (!HTML5_ELEMENTS.has(localName)) {
@@ -9585,6 +9994,51 @@ var ContentValidator = class {
9585
9994
  } catch {
9586
9995
  }
9587
9996
  }
9997
+ checkEpub2XhtmlStrict(context, path, root) {
9998
+ if (!root.namespaceUri) {
9999
+ pushMessage(context.messages, {
10000
+ id: MessageId.RSC_005,
10001
+ message: `element "${root.name}" from namespace "" is not allowed`,
10002
+ location: { path, line: root.line }
10003
+ });
10004
+ return;
10005
+ }
10006
+ const checkElement = (xmlEl) => {
10007
+ if (xmlEl.namespaceUri !== XHTML_NS_URI) return;
10008
+ const localName = xmlEl.name.includes(":") ? xmlEl.name.substring(xmlEl.name.indexOf(":") + 1) : xmlEl.name;
10009
+ if (!XHTML11_ELEMENTS.has(localName)) {
10010
+ pushMessage(context.messages, {
10011
+ id: MessageId.RSC_005,
10012
+ message: `element "${localName}" not allowed here`,
10013
+ location: { path, line: xmlEl.line }
10014
+ });
10015
+ }
10016
+ for (const attr of xmlEl.attrs) {
10017
+ const ns = attr.namespaceUri;
10018
+ if (!ns || ns === XHTML_NS_URI || ns === XML_NS_URI) continue;
10019
+ const qname = attr.prefix ? `${attr.prefix}:${attr.name}` : attr.name;
10020
+ pushMessage(context.messages, {
10021
+ id: MessageId.RSC_005,
10022
+ message: `attribute "${qname}" not allowed here`,
10023
+ location: { path, line: xmlEl.line }
10024
+ });
10025
+ }
10026
+ };
10027
+ checkElement(root);
10028
+ try {
10029
+ for (const el of root.find(".//*")) {
10030
+ checkElement(el);
10031
+ }
10032
+ for (const a of root.find(".//html:a//html:a", XHTML_NS)) {
10033
+ pushMessage(context.messages, {
10034
+ id: MessageId.RSC_005,
10035
+ message: 'The "a" element cannot contain any nested "a" elements',
10036
+ location: { path, line: a.line }
10037
+ });
10038
+ }
10039
+ } catch {
10040
+ }
10041
+ }
9588
10042
  checkForeignObjectContent(context, path, root, isSVGDoc) {
9589
10043
  const SVG_NS = { svg: "http://www.w3.org/2000/svg" };
9590
10044
  const XHTML_URI = "http://www.w3.org/1999/xhtml";
@@ -9793,6 +10247,7 @@ var NCXValidator = class {
9793
10247
  this.checkContentSrc(context, root, ncxPath, registry);
9794
10248
  this.checkEmptyLabels(context, root, ncxPath);
9795
10249
  this.checkPageTargets(context, root, ncxPath);
10250
+ this.checkIdSyntax(context, root, ncxPath);
9796
10251
  } finally {
9797
10252
  doc.dispose();
9798
10253
  }
@@ -9840,8 +10295,8 @@ var NCXValidator = class {
9840
10295
  const hashIdx = src.indexOf("#");
9841
10296
  const srcBase = hashIdx >= 0 ? src.substring(0, hashIdx) : src;
9842
10297
  const fragment = hashIdx >= 0 ? src.substring(hashIdx + 1) : "";
9843
- const isRemote = srcBase.startsWith("http://") || srcBase.startsWith("https://");
9844
- const fullPath = isRemote ? srcBase : resolvePath(ncxPath, srcBase);
10298
+ const isRemote = isRemoteURL(srcBase);
10299
+ const fullPath = isRemote ? srcBase : resolvePath(ncxPath, tryDecodeUriComponent(srcBase)).normalize("NFC");
9845
10300
  if (!context.files.has(fullPath) && !isRemote) {
9846
10301
  const line = contentElem.line;
9847
10302
  pushMessage(context.messages, {
@@ -9896,10 +10351,21 @@ var NCXValidator = class {
9896
10351
  }
9897
10352
  }
9898
10353
  }
9899
- /**
9900
- * pageTarget@type must be one of "front", "normal", "special".
9901
- * Reported via RSC-005 to mirror Java Schematron output.
9902
- */
10354
+ checkIdSyntax(context, root, ncxPath) {
10355
+ const nodes = root.find(".//*[@id]", {
10356
+ ncx: "http://www.daisy.org/z3986/2005/ncx/"
10357
+ });
10358
+ for (const node of nodes) {
10359
+ const idValue = node.attr("id")?.value;
10360
+ if (idValue && !NC_NAME_REGEX.test(idValue)) {
10361
+ pushMessage(context.messages, {
10362
+ id: MessageId.RSC_005,
10363
+ message: `Invalid id "${idValue}"; must match xs:ID syntax (NCName)`,
10364
+ location: { path: ncxPath, line: node.line }
10365
+ });
10366
+ }
10367
+ }
10368
+ }
9903
10369
  checkPageTargets(context, root, ncxPath) {
9904
10370
  const pageTargets = root.find(".//ncx:pageTarget[@type]", {
9905
10371
  ncx: "http://www.daisy.org/z3986/2005/ncx/"
@@ -9917,6 +10383,7 @@ var NCXValidator = class {
9917
10383
  }
9918
10384
  }
9919
10385
  };
10386
+ var NC_NAME_REGEX = /^[A-Za-z_][A-Za-z0-9._-]*$/;
9920
10387
  var PAGE_TARGET_TYPES = /* @__PURE__ */ new Set(["front", "normal", "special"]);
9921
10388
  var OPS_MEDIA_TYPES = /* @__PURE__ */ new Set([
9922
10389
  "application/xhtml+xml",
@@ -10046,15 +10513,6 @@ function parseContainerContent(content, context, fileExists, getFileContent) {
10046
10513
  }
10047
10514
  }
10048
10515
  }
10049
- for (const rootfile of context.rootfiles) {
10050
- if (!fileExists(rootfile.path)) {
10051
- pushMessage(context.messages, {
10052
- id: MessageId.PKG_010,
10053
- message: `Rootfile "${rootfile.path}" not found in EPUB`,
10054
- location: { path: containerPath }
10055
- });
10056
- }
10057
- }
10058
10516
  }
10059
10517
  function validateMappingDocumentContent(xml, mappingPath, context) {
10060
10518
  const stripped = stripXmlComments(xml);
@@ -10973,19 +11431,7 @@ var ReferenceValidator = class {
10973
11431
  message: "Absolute paths are not allowed in EPUB",
10974
11432
  location: reference.location
10975
11433
  });
10976
- }
10977
- const forbiddenParentDirTypes = [
10978
- "hyperlink" /* HYPERLINK */,
10979
- "nav-toc-link" /* NAV_TOC_LINK */,
10980
- "nav-pagelist-link" /* NAV_PAGELIST_LINK */
10981
- ];
10982
- if (hasParentDirectoryReference(reference.url) && forbiddenParentDirTypes.includes(reference.type)) {
10983
- pushMessage(context.messages, {
10984
- id: MessageId.RSC_026,
10985
- message: "Parent directory references (..) are not allowed",
10986
- location: reference.location
10987
- });
10988
- } else if (!hasAbsolutePath(resourcePath) && !hasParentDirectoryReference(reference.url) && checkUrlLeaking(reference.url)) {
11434
+ } else if (checkUrlLeaking(reference.url, reference.location.path)) {
10989
11435
  pushMessage(context.messages, {
10990
11436
  id: MessageId.RSC_026,
10991
11437
  message: `URL "${reference.url}" leaks outside the container`,
@@ -11023,6 +11469,16 @@ var ReferenceValidator = class {
11023
11469
  location: reference.location
11024
11470
  });
11025
11471
  }
11472
+ if (reference.type === "search-key" /* SEARCH_KEY */ && !resource?.inSpine) {
11473
+ const isEpubCfi = reference.fragment?.startsWith("epubcfi(") ?? false;
11474
+ if (!isEpubCfi) {
11475
+ pushMessage(context.messages, {
11476
+ id: MessageId.RSC_021,
11477
+ message: `Search Key Map target "${resourcePath}" must be a Content Document in the spine`,
11478
+ location: reference.location
11479
+ });
11480
+ }
11481
+ }
11026
11482
  if (isHyperlinkLike || reference.type === "overlay-text-link" /* OVERLAY_TEXT_LINK */) {
11027
11483
  const targetMimeType = resource?.mimeType;
11028
11484
  if (targetMimeType && !this.isBlessedItemType(targetMimeType, context.version) && !this.isDeprecatedBlessedItemType(targetMimeType) && !resource.hasCoreMediaTypeFallback) {
@@ -11507,11 +11963,11 @@ var RelaxNGValidator = class extends BaseSchemaValidator {
11507
11963
  try {
11508
11964
  const libxml2 = await import('libxml2-wasm');
11509
11965
  const LibRelaxNGValidator = libxml2.RelaxNGValidator;
11510
- const { XmlDocument: XmlDocument4 } = libxml2;
11511
- const doc = XmlDocument4.fromString(xml);
11966
+ const { XmlDocument: XmlDocument5 } = libxml2;
11967
+ const doc = XmlDocument5.fromString(xml);
11512
11968
  try {
11513
11969
  const schemaContent = await loadSchema(schemaPath);
11514
- const schemaDoc = XmlDocument4.fromString(schemaContent);
11970
+ const schemaDoc = XmlDocument5.fromString(schemaContent);
11515
11971
  try {
11516
11972
  const validator = LibRelaxNGValidator.fromDoc(schemaDoc);
11517
11973
  try {
@@ -11975,6 +12431,73 @@ var EpubCheck = class _EpubCheck {
11975
12431
  location: { path: opfPath }
11976
12432
  });
11977
12433
  }
12434
+ if (profile === "dict" && context.packageDocument) {
12435
+ const opfDir = opfPath.includes("/") ? opfPath.substring(0, opfPath.lastIndexOf("/")) : "";
12436
+ const manifestByPath = /* @__PURE__ */ new Map();
12437
+ for (const item of context.packageDocument.manifest) {
12438
+ manifestByPath.set(resolveManifestHref(opfDir, item.href), item);
12439
+ }
12440
+ const dictPaths = features.dictionaryContentPaths ?? /* @__PURE__ */ new Set();
12441
+ for (const coll of context.packageDocument.collections) {
12442
+ if (coll.role !== "dictionary") continue;
12443
+ const hasDictContent = coll.links.some((href) => {
12444
+ const full = resolveManifestHref(opfDir, href);
12445
+ const item = manifestByPath.get(full);
12446
+ return item?.mediaType === "application/xhtml+xml" && dictPaths.has(full);
12447
+ });
12448
+ if (!hasDictContent) {
12449
+ pushMessage(context.messages, {
12450
+ id: MessageId.OPF_078,
12451
+ message: 'An EPUB Dictionary must contain at least one Content Document with dictionary content (epub:type "dictionary")',
12452
+ location: { path: opfPath }
12453
+ });
12454
+ }
12455
+ }
12456
+ }
12457
+ }
12458
+ /**
12459
+ * Mirrors ../epubcheck/src/main/resources/com/adobe/epubcheck/schema/30/edupub/
12460
+ * edu-ocf-metadata.sch (META-INF/metadata.xml) and edu-opf.sch (each OPF).
12461
+ *
12462
+ * Non-primary OPFs are otherwise unreached: runPipeline only runs OPFValidator
12463
+ * on context.opfPath.
12464
+ */
12465
+ validateEdupubMultiRendition(context) {
12466
+ if (context.options.profile !== "edupub") return;
12467
+ if (context.rootfiles.length <= 1) return;
12468
+ const metadataPath = "META-INF/metadata.xml";
12469
+ const metadataData = context.files.get(metadataPath);
12470
+ if (metadataData) {
12471
+ const content = typeof metadataData === "string" ? metadataData : new TextDecoder().decode(metadataData);
12472
+ const stripped = stripXmlComments(content);
12473
+ if (!/<dc:type\b[^>]*>\s*edupub\s*<\/dc:type>/i.test(stripped)) {
12474
+ pushMessage(context.messages, {
12475
+ id: MessageId.RSC_005,
12476
+ message: 'A dc:type element with the value "edupub" is required.',
12477
+ location: { path: metadataPath }
12478
+ });
12479
+ }
12480
+ }
12481
+ const primary = context.opfPath;
12482
+ for (const rootfile of context.rootfiles) {
12483
+ if (rootfile.path === primary) continue;
12484
+ if (rootfile.mediaType !== "application/oebps-package+xml") continue;
12485
+ const path = rootfile.path.normalize("NFC");
12486
+ const opfData = context.files.get(path);
12487
+ if (!opfData) continue;
12488
+ const xml = typeof opfData === "string" ? opfData : new TextDecoder().decode(opfData);
12489
+ const pkg = parseOPF(xml);
12490
+ const hasType = pkg.dcElements.some(
12491
+ (dc) => dc.name === "type" && dc.value.trim() === "edupub"
12492
+ );
12493
+ if (!hasType) {
12494
+ pushMessage(context.messages, {
12495
+ id: MessageId.RSC_005,
12496
+ message: 'The dc:type identifier "edupub" is required.',
12497
+ location: { path }
12498
+ });
12499
+ }
12500
+ }
11978
12501
  }
11979
12502
  /**
11980
12503
  * Validate NCX navigation document (EPUB 2 always, EPUB 3 when NCX present)
@@ -12120,6 +12643,7 @@ var EpubCheck = class _EpubCheck {
12120
12643
  const contentValidator = new ContentValidator();
12121
12644
  contentValidator.validate(context, registry, refValidator);
12122
12645
  this.validateCrossDocumentFeatures(context);
12646
+ this.validateEdupubMultiRendition(context);
12123
12647
  if (context.packageDocument) {
12124
12648
  this.validateNCX(context, registry);
12125
12649
  }