@likecoin/epubcheck-ts 0.3.7 → 0.3.9

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.js CHANGED
@@ -1602,31 +1602,276 @@ var CSSValidator = class {
1602
1602
  * Check for reserved media overlay class names
1603
1603
  */
1604
1604
  checkMediaOverlayClasses(context, ast, resourcePath) {
1605
- const reservedClassNames = /* @__PURE__ */ new Set([
1606
- "-epub-media-overlay-active",
1607
- "media-overlay-active",
1608
- "-epub-media-overlay-playing",
1609
- "media-overlay-playing"
1610
- ]);
1605
+ const activeClassNames = /* @__PURE__ */ new Set(["-epub-media-overlay-active", "media-overlay-active"]);
1606
+ const playbackClassNames = /* @__PURE__ */ new Set(["-epub-media-overlay-playing", "media-overlay-playing"]);
1611
1607
  walk(ast, (node) => {
1612
1608
  if (node.type === "ClassSelector") {
1613
1609
  const className = node.name.toLowerCase();
1614
- if (reservedClassNames.has(className)) {
1615
- const loc = node.loc;
1616
- const start = loc?.start;
1617
- const location = { path: resourcePath };
1618
- if (start) {
1619
- location.line = start.line;
1620
- location.column = start.column;
1621
- }
1610
+ const isActive = activeClassNames.has(className);
1611
+ const isPlayback = playbackClassNames.has(className);
1612
+ if (!isActive && !isPlayback) return;
1613
+ const isDeclared = isActive ? !!context.mediaActiveClass : !!context.mediaPlaybackActiveClass;
1614
+ if (isDeclared) return;
1615
+ const loc = node.loc;
1616
+ const start = loc?.start;
1617
+ const location = { path: resourcePath };
1618
+ if (start) {
1619
+ location.line = start.line;
1620
+ location.column = start.column;
1621
+ }
1622
+ const property = isActive ? "media:active-class" : "media:playback-active-class";
1623
+ pushMessage(context.messages, {
1624
+ id: MessageId.CSS_029,
1625
+ message: `Class name "${className}" is reserved for media overlays but "${property}" is not declared in the package document`,
1626
+ location
1627
+ });
1628
+ }
1629
+ });
1630
+ }
1631
+ };
1632
+
1633
+ // src/smil/clock.ts
1634
+ var FULL_CLOCK_RE = /^(\d+):([0-5]\d):([0-5]\d)(\.\d+)?$/;
1635
+ var PARTIAL_CLOCK_RE = /^([0-5]\d):([0-5]\d)(\.\d+)?$/;
1636
+ var TIMECOUNT_RE = /^(\d+(\.\d+)?)(h|min|s|ms)?$/;
1637
+ function parseSmilClock(value) {
1638
+ const trimmed = value.trim();
1639
+ const full = FULL_CLOCK_RE.exec(trimmed);
1640
+ if (full) {
1641
+ const hours = Number.parseInt(full[1] ?? "0", 10);
1642
+ const minutes = Number.parseInt(full[2] ?? "0", 10);
1643
+ const seconds = Number.parseInt(full[3] ?? "0", 10);
1644
+ const frac = full[4] ? Number.parseFloat(full[4]) : 0;
1645
+ return hours * 3600 + minutes * 60 + seconds + frac;
1646
+ }
1647
+ const partial = PARTIAL_CLOCK_RE.exec(trimmed);
1648
+ if (partial) {
1649
+ const minutes = Number.parseInt(partial[1] ?? "0", 10);
1650
+ const seconds = Number.parseInt(partial[2] ?? "0", 10);
1651
+ const frac = partial[3] ? Number.parseFloat(partial[3]) : 0;
1652
+ return minutes * 60 + seconds + frac;
1653
+ }
1654
+ const timecount = TIMECOUNT_RE.exec(trimmed);
1655
+ if (timecount) {
1656
+ const num = Number.parseFloat(timecount[1] ?? "0");
1657
+ const unit = timecount[3] ?? "s";
1658
+ switch (unit) {
1659
+ case "h":
1660
+ return num * 3600;
1661
+ case "min":
1662
+ return num * 60;
1663
+ case "s":
1664
+ return num;
1665
+ case "ms":
1666
+ return num / 1e3;
1667
+ default:
1668
+ return NaN;
1669
+ }
1670
+ }
1671
+ return NaN;
1672
+ }
1673
+ function isValidSmilClock(value) {
1674
+ return !Number.isNaN(parseSmilClock(value));
1675
+ }
1676
+
1677
+ // src/smil/validator.ts
1678
+ var SMIL_NS = { smil: "http://www.w3.org/ns/SMIL" };
1679
+ var BLESSED_AUDIO_TYPES = /* @__PURE__ */ new Set(["audio/mpeg", "audio/mp4"]);
1680
+ var BLESSED_AUDIO_PATTERN = /^audio\/ogg\s*;\s*codecs=opus$/i;
1681
+ function isBlessedAudioType(mimeType) {
1682
+ return BLESSED_AUDIO_TYPES.has(mimeType) || BLESSED_AUDIO_PATTERN.test(mimeType);
1683
+ }
1684
+ var SMILValidator = class {
1685
+ getAttribute(element, name) {
1686
+ return element.attr(name)?.value ?? null;
1687
+ }
1688
+ getEpubAttribute(element, localName) {
1689
+ return element.attr(localName, "epub")?.value ?? null;
1690
+ }
1691
+ validate(context, path, manifestByPath) {
1692
+ const result = {
1693
+ textReferences: [],
1694
+ referencedDocuments: /* @__PURE__ */ new Set(),
1695
+ hasRemoteResources: false
1696
+ };
1697
+ const data = context.files.get(path);
1698
+ if (!data) return result;
1699
+ const content = typeof data === "string" ? data : new TextDecoder().decode(data);
1700
+ let doc = null;
1701
+ try {
1702
+ doc = XmlDocument.fromString(content);
1703
+ } catch {
1704
+ pushMessage(context.messages, {
1705
+ id: MessageId.RSC_016,
1706
+ message: "Media Overlay document is not well-formed XML",
1707
+ location: { path }
1708
+ });
1709
+ return result;
1710
+ }
1711
+ try {
1712
+ const root = doc.root;
1713
+ this.validateStructure(context, path, root);
1714
+ this.validateAudioElements(context, path, root, manifestByPath, result);
1715
+ this.extractTextReferences(path, root, result);
1716
+ } finally {
1717
+ doc.dispose();
1718
+ }
1719
+ return result;
1720
+ }
1721
+ validateStructure(context, path, root) {
1722
+ try {
1723
+ for (const text of root.find(".//smil:seq/smil:text", SMIL_NS)) {
1724
+ pushMessage(context.messages, {
1725
+ id: MessageId.RSC_005,
1726
+ message: "element 'text' not allowed here; expected 'seq' or 'par'",
1727
+ location: { path, line: text.line }
1728
+ });
1729
+ }
1730
+ for (const audio of root.find(".//smil:seq/smil:audio", SMIL_NS)) {
1731
+ pushMessage(context.messages, {
1732
+ id: MessageId.RSC_005,
1733
+ message: "element 'audio' not allowed here; expected 'seq' or 'par'",
1734
+ location: { path, line: audio.line }
1735
+ });
1736
+ }
1737
+ } catch {
1738
+ }
1739
+ try {
1740
+ for (const seq of root.find(".//smil:par/smil:seq", SMIL_NS)) {
1741
+ pushMessage(context.messages, {
1742
+ id: MessageId.RSC_005,
1743
+ message: "element 'seq' not allowed here; expected 'text' or 'audio'",
1744
+ location: { path, line: seq.line }
1745
+ });
1746
+ }
1747
+ const parElements = root.find(".//smil:par", SMIL_NS);
1748
+ for (const par of parElements) {
1749
+ const textChildren = par.find("./smil:text", SMIL_NS);
1750
+ for (let i = 1; i < textChildren.length; i++) {
1751
+ const extra = textChildren[i];
1752
+ if (!extra) continue;
1622
1753
  pushMessage(context.messages, {
1623
- id: MessageId.CSS_029,
1624
- message: `Class name "${className}" is reserved for media overlays`,
1625
- location
1754
+ id: MessageId.RSC_005,
1755
+ message: "element 'text' not allowed here; only one 'text' element is allowed in 'par'",
1756
+ location: { path, line: extra.line }
1626
1757
  });
1627
1758
  }
1628
1759
  }
1629
- });
1760
+ } catch {
1761
+ }
1762
+ try {
1763
+ const headMetaElements = root.find(".//smil:head/smil:meta", SMIL_NS);
1764
+ for (const meta of headMetaElements) {
1765
+ pushMessage(context.messages, {
1766
+ id: MessageId.RSC_005,
1767
+ message: "element 'meta' not allowed here; expected 'metadata'",
1768
+ location: { path, line: meta.line }
1769
+ });
1770
+ }
1771
+ } catch {
1772
+ }
1773
+ }
1774
+ validateAudioElements(context, path, root, manifestByPath, result) {
1775
+ try {
1776
+ const audioElements = root.find(".//smil:audio", SMIL_NS);
1777
+ for (const audio of audioElements) {
1778
+ const elem = audio;
1779
+ const src = this.getAttribute(elem, "src");
1780
+ if (src) {
1781
+ if (/^https?:\/\//i.test(src)) {
1782
+ result.hasRemoteResources = true;
1783
+ }
1784
+ if (src.includes("#")) {
1785
+ pushMessage(context.messages, {
1786
+ id: MessageId.MED_014,
1787
+ message: `Media overlay audio file URLs must not have a fragment: "${src}"`,
1788
+ location: { path, line: audio.line }
1789
+ });
1790
+ }
1791
+ if (manifestByPath) {
1792
+ const audioPath = this.resolveRelativePath(path, src.split("#")[0] ?? src);
1793
+ const audioItem = manifestByPath.get(audioPath);
1794
+ if (audioItem && !isBlessedAudioType(audioItem.mediaType)) {
1795
+ pushMessage(context.messages, {
1796
+ id: MessageId.MED_005,
1797
+ message: `Media Overlay audio reference "${src}" to non-standard audio type "${audioItem.mediaType}"`,
1798
+ location: { path, line: audio.line }
1799
+ });
1800
+ }
1801
+ }
1802
+ }
1803
+ const clipBegin = this.getAttribute(elem, "clipBegin");
1804
+ const clipEnd = this.getAttribute(elem, "clipEnd");
1805
+ this.checkClipTiming(context, path, audio.line, clipBegin, clipEnd);
1806
+ }
1807
+ } catch {
1808
+ }
1809
+ }
1810
+ checkClipTiming(context, path, line, clipBegin, clipEnd) {
1811
+ if (clipEnd === null) return;
1812
+ const beginStr = clipBegin ?? "0";
1813
+ const start = parseSmilClock(beginStr);
1814
+ const end = parseSmilClock(clipEnd);
1815
+ if (Number.isNaN(start) || Number.isNaN(end)) return;
1816
+ const location = line != null ? { path, line } : { path };
1817
+ if (start > end) {
1818
+ pushMessage(context.messages, {
1819
+ id: MessageId.MED_008,
1820
+ message: "The time specified in the clipBegin attribute must not be after clipEnd",
1821
+ location
1822
+ });
1823
+ } else if (start === end) {
1824
+ pushMessage(context.messages, {
1825
+ id: MessageId.MED_009,
1826
+ message: "The time specified in the clipBegin attribute must not be the same as clipEnd",
1827
+ location
1828
+ });
1829
+ }
1830
+ }
1831
+ extractTextReferences(path, root, result) {
1832
+ try {
1833
+ const textElements = root.find(".//smil:text", SMIL_NS);
1834
+ for (const text of textElements) {
1835
+ const src = this.getAttribute(text, "src");
1836
+ if (!src) continue;
1837
+ const hashIndex = src.indexOf("#");
1838
+ const docRef = hashIndex >= 0 ? src.substring(0, hashIndex) : src;
1839
+ const fragment = hashIndex >= 0 ? src.substring(hashIndex + 1) : void 0;
1840
+ const docPath = this.resolveRelativePath(path, docRef);
1841
+ result.textReferences.push({ docPath, fragment, line: text.line });
1842
+ result.referencedDocuments.add(docPath);
1843
+ }
1844
+ const bodyElements = root.find(".//smil:body", SMIL_NS);
1845
+ const seqElements = root.find(".//smil:seq", SMIL_NS);
1846
+ for (const elem of [...bodyElements, ...seqElements]) {
1847
+ const textref = this.getEpubAttribute(elem, "textref");
1848
+ if (!textref) continue;
1849
+ const hashIndex = textref.indexOf("#");
1850
+ const docRef = hashIndex >= 0 ? textref.substring(0, hashIndex) : textref;
1851
+ const fragment = hashIndex >= 0 ? textref.substring(hashIndex + 1) : void 0;
1852
+ const docPath = this.resolveRelativePath(path, docRef);
1853
+ result.textReferences.push({ docPath, fragment, line: elem.line });
1854
+ result.referencedDocuments.add(docPath);
1855
+ }
1856
+ } catch {
1857
+ }
1858
+ }
1859
+ resolveRelativePath(basePath, relativePath) {
1860
+ if (relativePath.startsWith("/") || /^[a-zA-Z]+:/.test(relativePath)) {
1861
+ return relativePath;
1862
+ }
1863
+ const baseDir = basePath.includes("/") ? basePath.substring(0, basePath.lastIndexOf("/")) : "";
1864
+ if (!baseDir) return relativePath;
1865
+ const segments = `${baseDir}/${relativePath}`.split("/");
1866
+ const resolved = [];
1867
+ for (const seg of segments) {
1868
+ if (seg === "..") {
1869
+ resolved.pop();
1870
+ } else if (seg !== ".") {
1871
+ resolved.push(seg);
1872
+ }
1873
+ }
1874
+ return resolved.join("/");
1630
1875
  }
1631
1876
  };
1632
1877
 
@@ -1702,7 +1947,12 @@ var SPINE_PROPERTIES = /* @__PURE__ */ new Set([
1702
1947
  "rendition:layout-pre-paginated",
1703
1948
  "rendition:orientation-auto",
1704
1949
  "rendition:orientation-landscape",
1705
- "rendition:orientation-portrait"
1950
+ "rendition:orientation-portrait",
1951
+ "rendition:flow-auto",
1952
+ "rendition:flow-paginated",
1953
+ "rendition:flow-scrolled-continuous",
1954
+ "rendition:flow-scrolled-doc",
1955
+ "rendition:align-x-center"
1706
1956
  ]);
1707
1957
 
1708
1958
  // src/references/types.ts
@@ -1723,6 +1973,89 @@ function isPublicationResourceReference(type) {
1723
1973
  return PUBLICATION_RESOURCE_TYPES.has(type);
1724
1974
  }
1725
1975
 
1976
+ // src/references/uri-schemes.ts
1977
+ var URI_SCHEMES = /* @__PURE__ */ new Set([
1978
+ "aaa",
1979
+ "aaas",
1980
+ "acap",
1981
+ "afs",
1982
+ "cap",
1983
+ "cid",
1984
+ "crid",
1985
+ "data",
1986
+ "dav",
1987
+ "dict",
1988
+ "dns",
1989
+ "dtn",
1990
+ "fax",
1991
+ "file",
1992
+ "ftp",
1993
+ "go",
1994
+ "gopher",
1995
+ "h323",
1996
+ "http",
1997
+ "https",
1998
+ "iax",
1999
+ "icap",
2000
+ "im",
2001
+ "imap",
2002
+ "info",
2003
+ "ipp",
2004
+ "irc",
2005
+ "iris",
2006
+ "iris.beep",
2007
+ "iris.lwz",
2008
+ "iris.xpc",
2009
+ "iris.xpcs",
2010
+ "javascript",
2011
+ "ldap",
2012
+ "mailto",
2013
+ "mailserver",
2014
+ "mid",
2015
+ "modem",
2016
+ "msrp",
2017
+ "msrps",
2018
+ "mtqp",
2019
+ "mupdate",
2020
+ "news",
2021
+ "nfs",
2022
+ "nntp",
2023
+ "opaquelocktoken",
2024
+ "pack",
2025
+ "pop",
2026
+ "pres",
2027
+ "prospero",
2028
+ "rtsp",
2029
+ "service",
2030
+ "shttp",
2031
+ "sip",
2032
+ "sips",
2033
+ "snews",
2034
+ "snmp",
2035
+ "soap.beep",
2036
+ "soap.beeps",
2037
+ "tag",
2038
+ "tel",
2039
+ "telnet",
2040
+ "tftp",
2041
+ "thismessage",
2042
+ "tip",
2043
+ "tn3270",
2044
+ "tv",
2045
+ "urn",
2046
+ "vemmi",
2047
+ "videotex",
2048
+ "wais",
2049
+ "xmlrpc.beep",
2050
+ "xmlrpc.beeps",
2051
+ "xmpp",
2052
+ "z39.50r",
2053
+ "z39.50s"
2054
+ ]);
2055
+ function isRegisteredScheme(scheme) {
2056
+ return URI_SCHEMES.has(scheme.toLowerCase());
2057
+ }
2058
+
1726
2059
  // src/references/url.ts
1727
2060
  function parseURL(urlString) {
1728
2061
  const hashIndex = urlString.indexOf("#");
@@ -1758,15 +2091,9 @@ function hasParentDirectoryReference(url) {
1758
2091
  return url.includes("..");
1759
2092
  }
1760
2093
  function isMalformedURL(url) {
1761
- if (!url) return true;
1762
- try {
1763
- const trimmed = url.trim();
1764
- if (!trimmed) return true;
1765
- if (/[\s<>]/.test(trimmed)) return true;
1766
- return false;
1767
- } catch {
1768
- return true;
1769
- }
2094
+ if (!url.trim()) return true;
2095
+ if (/[\s<>]/.test(url)) return true;
2096
+ return false;
1770
2097
  }
1771
2098
  function isHTTPS(url) {
1772
2099
  return url.startsWith("https://");
@@ -1803,6 +2130,38 @@ function resolveManifestHref(opfDir, href) {
1803
2130
  // src/content/validator.ts
1804
2131
  var DISCOURAGED_ELEMENTS = /* @__PURE__ */ new Set(["base", "embed", "rp"]);
1805
2132
  var ABSOLUTE_URI_RE = /^[a-zA-Z][a-zA-Z0-9+.-]*:/;
2133
+ var SPECIAL_URL_SCHEMES = /* @__PURE__ */ new Set(["http", "https", "ftp", "ws", "wss"]);
2134
+ var CSS_CHARSET_RE = /^@charset\s+"([^"]+)"\s*;/;
2135
+ var EPUB_XMLNS_RE = /xmlns:epub\s*=\s*"([^"]*)"/;
2136
+ var XHTML_NS = { html: "http://www.w3.org/1999/xhtml" };
2137
+ var EPUB_TYPE_FORBIDDEN_ELEMENTS = /* @__PURE__ */ new Set([
2138
+ "head",
2139
+ "meta",
2140
+ "title",
2141
+ "style",
2142
+ "link",
2143
+ "script",
2144
+ "noscript",
2145
+ "base"
2146
+ ]);
2147
+ function validateAbsoluteHyperlinkURL(context, href, path, line) {
2148
+ const location = line != null ? { path, line } : { path };
2149
+ const scheme = href.slice(0, href.indexOf(":")).toLowerCase();
2150
+ if (!isRegisteredScheme(scheme)) {
2151
+ pushMessage(context.messages, {
2152
+ id: MessageId.HTM_025,
2153
+ message: "Hyperlink uses non-registered URI scheme type",
2154
+ location
2155
+ });
2156
+ }
2157
+ if (/[\s<>]/.test(href) || SPECIAL_URL_SCHEMES.has(scheme) && !href.slice(href.indexOf(":")).startsWith("://")) {
2158
+ pushMessage(context.messages, {
2159
+ id: MessageId.RSC_020,
2160
+ message: `URL is not valid: "${href}"`,
2161
+ location
2162
+ });
2163
+ }
2164
+ }
1806
2165
  var IMAGE_MAGIC = [
1807
2166
  { mime: "image/jpeg", bytes: [255, 216], extensions: [".jpg", ".jpeg", ".jpe"] },
1808
2167
  { mime: "image/gif", bytes: [71, 73, 70, 56], extensions: [".gif"] },
@@ -2077,6 +2436,16 @@ var HTML_ENTITIES = /* @__PURE__ */ new Set([
2077
2436
  "thorn",
2078
2437
  "yuml"
2079
2438
  ]);
2439
+ function isItemFixedLayout(packageDoc, itemId) {
2440
+ const spineItem = packageDoc.spine.find((s) => s.idref === itemId);
2441
+ if (!spineItem) return false;
2442
+ if (spineItem.properties?.includes("rendition:layout-pre-paginated")) return true;
2443
+ if (spineItem.properties?.includes("rendition:layout-reflowable")) return false;
2444
+ const globalLayout = packageDoc.metaElements.find(
2445
+ (m) => m.property === "rendition:layout" && !m.refines
2446
+ );
2447
+ return globalLayout?.value === "pre-paginated";
2448
+ }
2080
2449
  var ContentValidator = class {
2081
2450
  cssWithRemoteResources = /* @__PURE__ */ new Set();
2082
2451
  validate(context, registry, refValidator) {
@@ -2094,6 +2463,11 @@ var ContentValidator = class {
2094
2463
  }
2095
2464
  }
2096
2465
  }
2466
+ const overlayDocMap = /* @__PURE__ */ new Map();
2467
+ const manifestByPath = /* @__PURE__ */ new Map();
2468
+ for (const item of packageDoc.manifest) {
2469
+ manifestByPath.set(resolveManifestHref(opfDir, item.href), item);
2470
+ }
2097
2471
  for (const item of packageDoc.manifest) {
2098
2472
  if (item.mediaType === "application/xhtml+xml") {
2099
2473
  const fullPath = resolveManifestHref(opfDir, item.href);
@@ -2109,9 +2483,96 @@ var ContentValidator = class {
2109
2483
  if (refValidator) {
2110
2484
  this.extractSVGReferences(context, fullPath, opfDir, refValidator);
2111
2485
  }
2486
+ } else if (item.mediaType === "application/smil+xml") {
2487
+ const fullPath = resolveManifestHref(opfDir, item.href);
2488
+ const smilValidator = new SMILValidator();
2489
+ const result = smilValidator.validate(context, fullPath, manifestByPath);
2490
+ overlayDocMap.set(item.id, result.referencedDocuments);
2491
+ if (refValidator) {
2492
+ for (const textRef of result.textReferences) {
2493
+ const refUrl = textRef.fragment ? `${textRef.docPath}#${textRef.fragment}` : textRef.docPath;
2494
+ const location = textRef.line != null ? { path: fullPath, line: textRef.line } : { path: fullPath };
2495
+ const ref = {
2496
+ url: refUrl,
2497
+ targetResource: textRef.docPath,
2498
+ type: "overlay-text-link" /* OVERLAY_TEXT_LINK */,
2499
+ location
2500
+ };
2501
+ if (textRef.fragment !== void 0) ref.fragment = textRef.fragment;
2502
+ refValidator.addReference(ref);
2503
+ context.overlayTextLinks ??= [];
2504
+ const link = {
2505
+ targetResource: textRef.docPath,
2506
+ location
2507
+ };
2508
+ if (textRef.fragment !== void 0) link.fragment = textRef.fragment;
2509
+ context.overlayTextLinks.push(link);
2510
+ }
2511
+ }
2512
+ if (result.hasRemoteResources) {
2513
+ const properties = item.properties ?? [];
2514
+ if (!properties.includes("remote-resources")) {
2515
+ pushMessage(context.messages, {
2516
+ id: MessageId.OPF_014,
2517
+ message: `The "remote-resources" property must be set on the media overlay item "${item.href}" because it references remote audio resources`,
2518
+ location: { path: context.opfPath ?? "" }
2519
+ });
2520
+ }
2521
+ }
2112
2522
  }
2113
2523
  this.validateMediaFile(context, item, opfDir);
2114
2524
  }
2525
+ this.validateMediaOverlayCrossRefs(context, packageDoc, opfDir, overlayDocMap);
2526
+ }
2527
+ validateMediaOverlayCrossRefs(context, packageDoc, opfDir, overlayDocMap) {
2528
+ if (overlayDocMap.size === 0) return;
2529
+ const docToOverlays = /* @__PURE__ */ new Map();
2530
+ for (const [overlayId, docPaths] of overlayDocMap) {
2531
+ for (const docPath of docPaths) {
2532
+ const existing = docToOverlays.get(docPath) ?? [];
2533
+ existing.push(overlayId);
2534
+ docToOverlays.set(docPath, existing);
2535
+ }
2536
+ }
2537
+ const opfPath = context.opfPath ?? "";
2538
+ for (const item of packageDoc.manifest) {
2539
+ if (item.mediaType !== "application/xhtml+xml" && item.mediaType !== "image/svg+xml") {
2540
+ continue;
2541
+ }
2542
+ const fullPath = resolveManifestHref(opfDir, item.href);
2543
+ const referencingOverlays = docToOverlays.get(fullPath);
2544
+ if (referencingOverlays && referencingOverlays.length > 0) {
2545
+ if (referencingOverlays.length > 1) {
2546
+ pushMessage(context.messages, {
2547
+ id: MessageId.MED_011,
2548
+ message: `EPUB Content Document "${item.href}" referenced from multiple Media Overlay Documents`,
2549
+ location: { path: opfPath }
2550
+ });
2551
+ }
2552
+ if (!item.mediaOverlay) {
2553
+ pushMessage(context.messages, {
2554
+ id: MessageId.MED_010,
2555
+ message: `EPUB Content Document "${item.href}" referenced from a Media Overlay must specify the "media-overlay" attribute`,
2556
+ location: { path: opfPath }
2557
+ });
2558
+ } else if (!referencingOverlays.includes(item.mediaOverlay)) {
2559
+ pushMessage(context.messages, {
2560
+ id: MessageId.MED_012,
2561
+ message: `The "media-overlay" attribute does not match the ID of the Media Overlay that refers to this document`,
2562
+ location: { path: opfPath }
2563
+ });
2564
+ }
2565
+ } else if (item.mediaOverlay) {
2566
+ const overlayDocs = overlayDocMap.get(item.mediaOverlay);
2567
+ if (overlayDocs && !overlayDocs.has(fullPath)) {
2568
+ pushMessage(context.messages, {
2569
+ id: MessageId.MED_013,
2570
+ message: `Media Overlay Document referenced from the "media-overlay" attribute does not contain a reference to this Content Document`,
2571
+ location: { path: opfPath }
2572
+ });
2573
+ }
2574
+ }
2575
+ }
2115
2576
  }
2116
2577
  validateMediaFile(context, item, opfDir) {
2117
2578
  const declaredType = item.mediaType;
@@ -2197,6 +2658,19 @@ var ContentValidator = class {
2197
2658
  this.checkSVGInvalidIDs(context, path, root);
2198
2659
  this.validateSvgEpubType(context, path, root);
2199
2660
  this.checkUnknownEpubAttributes(context, path, root);
2661
+ this.checkSVGLinkAccessibility(context, path, root);
2662
+ const packageDoc = context.packageDocument;
2663
+ if (packageDoc && isItemFixedLayout(packageDoc, manifestItem.id)) {
2664
+ const viewBox = this.getAttribute(root, "viewBox");
2665
+ if (!viewBox) {
2666
+ pushMessage(context.messages, {
2667
+ id: MessageId.HTM_048,
2668
+ message: "SVG Fixed-Layout Documents must have a viewBox attribute on the outermost svg element",
2669
+ location: { path }
2670
+ });
2671
+ }
2672
+ }
2673
+ this.checkMediaOverlayActiveClassCSS(context, path, root, manifestItem, svgContent);
2200
2674
  } finally {
2201
2675
  doc.dispose();
2202
2676
  }
@@ -2382,7 +2856,26 @@ var ContentValidator = class {
2382
2856
  if (!cssData) {
2383
2857
  return;
2384
2858
  }
2385
- const cssContent = new TextDecoder().decode(cssData);
2859
+ let cssContent;
2860
+ const utf16Encoding = cssData.length >= 2 && cssData[0] === 254 && cssData[1] === 255 ? "utf-16be" : cssData.length >= 2 && cssData[0] === 255 && cssData[1] === 254 ? "utf-16le" : null;
2861
+ if (utf16Encoding) {
2862
+ pushMessage(context.messages, {
2863
+ id: MessageId.CSS_003,
2864
+ message: "CSS documents should be encoded in UTF-8, but UTF-16 was detected",
2865
+ location: { path }
2866
+ });
2867
+ cssContent = new TextDecoder(utf16Encoding).decode(cssData);
2868
+ } else {
2869
+ cssContent = new TextDecoder().decode(cssData);
2870
+ const charsetMatch = CSS_CHARSET_RE.exec(cssContent);
2871
+ if (charsetMatch?.[1] && charsetMatch[1].toLowerCase() !== "utf-8") {
2872
+ pushMessage(context.messages, {
2873
+ id: MessageId.CSS_004,
2874
+ message: `CSS documents must be encoded in UTF-8, but detected "${charsetMatch[1]}"`,
2875
+ location: { path }
2876
+ });
2877
+ }
2878
+ }
2386
2879
  const cssValidator = new CSSValidator();
2387
2880
  const result = cssValidator.validate(context, cssContent, path);
2388
2881
  const hasRemoteResources = result.references.some(
@@ -2475,11 +2968,28 @@ var ContentValidator = class {
2475
2968
  if (!data) {
2476
2969
  return;
2477
2970
  }
2478
- const content = new TextDecoder().decode(data);
2971
+ if (data.length >= 2 && (data[0] === 254 && data[1] === 255 || data[0] === 255 && data[1] === 254)) {
2972
+ pushMessage(context.messages, {
2973
+ id: MessageId.HTM_058,
2974
+ message: "HTML documents must be encoded in UTF-8, but UTF-16 was detected",
2975
+ location: { path }
2976
+ });
2977
+ return;
2978
+ }
2979
+ let content = new TextDecoder().decode(data);
2479
2980
  const packageDoc = context.packageDocument;
2480
2981
  if (!packageDoc) {
2481
2982
  return;
2482
2983
  }
2984
+ const epubNsMatch = EPUB_XMLNS_RE.exec(content);
2985
+ if (epubNsMatch?.[1] && epubNsMatch[1] !== "http://www.idpf.org/2007/ops") {
2986
+ pushMessage(context.messages, {
2987
+ id: MessageId.HTM_010,
2988
+ message: `Namespace URI "${epubNsMatch[1]}" is unusual for the "epub" prefix`,
2989
+ location: { path }
2990
+ });
2991
+ content = content.replace(epubNsMatch[0], 'xmlns:epub="http://www.idpf.org/2007/ops"');
2992
+ }
2483
2993
  this.checkUnescapedAmpersands(context, path, content);
2484
2994
  if (context.version !== "2.0") {
2485
2995
  const doctypeMatch = /<!DOCTYPE\s+html\b([\s\S]*?)>/i.exec(content);
@@ -2685,6 +3195,7 @@ var ContentValidator = class {
2685
3195
  this.checkHttpEquivCharset(context, path, root);
2686
3196
  this.checkLangMismatch(context, path, root);
2687
3197
  this.checkDpubAriaDeprecated(context, path, root);
3198
+ this.validateIdRefs(context, path, root);
2688
3199
  this.checkTableBorder(context, path, root);
2689
3200
  this.checkTimeElement(context, path, root);
2690
3201
  this.checkMathMLAnnotations(context, path, root);
@@ -2692,6 +3203,7 @@ var ContentValidator = class {
2692
3203
  this.checkDataAttributes(context, path, root);
2693
3204
  this.checkAccessibility(context, path, root);
2694
3205
  this.validateImages(context, path, root);
3206
+ this.checkUsemapAttribute(context, path, root);
2695
3207
  if (context.version.startsWith("3")) {
2696
3208
  this.validateEpubTypes(context, path, root);
2697
3209
  }
@@ -2704,8 +3216,24 @@ var ContentValidator = class {
2704
3216
  this.extractAndRegisterIDs(path, root, registry);
2705
3217
  }
2706
3218
  if (refValidator && opfDir !== void 0) {
2707
- this.extractAndRegisterHyperlinks(context, path, root, opfDir, refValidator, !!isNavItem);
2708
- this.extractAndRegisterStylesheets(path, root, opfDir, refValidator);
3219
+ const remoteXmlBase = this.getRemoteXmlBase(root);
3220
+ this.extractAndRegisterHyperlinks(
3221
+ context,
3222
+ path,
3223
+ root,
3224
+ opfDir,
3225
+ refValidator,
3226
+ !!isNavItem,
3227
+ remoteXmlBase
3228
+ );
3229
+ this.extractAndRegisterStylesheets(
3230
+ context,
3231
+ path,
3232
+ root,
3233
+ opfDir,
3234
+ refValidator,
3235
+ remoteXmlBase
3236
+ );
2709
3237
  this.extractAndRegisterImages(context, path, root, opfDir, refValidator, registry);
2710
3238
  this.extractAndRegisterMathMLAltimg(path, root, opfDir, refValidator);
2711
3239
  this.extractAndRegisterScripts(path, root, opfDir, refValidator);
@@ -2720,10 +3248,65 @@ var ContentValidator = class {
2720
3248
  registry
2721
3249
  );
2722
3250
  }
3251
+ this.checkMediaOverlayActiveClassCSS(context, path, root, manifestItem);
2723
3252
  } finally {
2724
3253
  doc.dispose();
2725
3254
  }
2726
3255
  }
3256
+ /**
3257
+ * CSS-030: If media:active-class or media:playback-active-class is declared in OPF,
3258
+ * and this content document has a media-overlay, it must have at least some CSS.
3259
+ */
3260
+ checkMediaOverlayActiveClassCSS(context, path, root, manifestItem, decodedContent) {
3261
+ if (!manifestItem?.mediaOverlay) return;
3262
+ if (!context.mediaActiveClass && !context.mediaPlaybackActiveClass) return;
3263
+ const isSVG = root.name === "svg" || root.name.endsWith(":svg");
3264
+ let hasCSS = false;
3265
+ if (isSVG) {
3266
+ try {
3267
+ const styles = root.find(".//svg:style", { svg: "http://www.w3.org/2000/svg" });
3268
+ if (styles.length > 0) hasCSS = true;
3269
+ } catch {
3270
+ }
3271
+ if (!hasCSS) {
3272
+ try {
3273
+ const links = root.find(".//html:link", XHTML_NS);
3274
+ if (links.length > 0) hasCSS = true;
3275
+ } catch {
3276
+ }
3277
+ }
3278
+ if (!hasCSS) {
3279
+ const content = decodedContent ?? new TextDecoder().decode(context.files.get(path));
3280
+ if (content.includes("<?xml-stylesheet")) hasCSS = true;
3281
+ }
3282
+ } else {
3283
+ try {
3284
+ const links = root.find(".//html:link[@rel]", XHTML_NS);
3285
+ for (const link of links) {
3286
+ const rel = this.getAttribute(link, "rel");
3287
+ if (rel?.toLowerCase().includes("stylesheet")) {
3288
+ hasCSS = true;
3289
+ break;
3290
+ }
3291
+ }
3292
+ } catch {
3293
+ }
3294
+ if (!hasCSS) {
3295
+ try {
3296
+ const styles = root.find(".//html:style", XHTML_NS);
3297
+ if (styles.length > 0) hasCSS = true;
3298
+ } catch {
3299
+ }
3300
+ }
3301
+ }
3302
+ if (!hasCSS) {
3303
+ pushMessage(context.messages, {
3304
+ id: MessageId.CSS_030,
3305
+ message: 'The "media:active-class" property is declared in the package document but no CSS was found in this content document',
3306
+ location: { path }
3307
+ });
3308
+ }
3309
+ }
2727
3310
  parseLibxmlError(error) {
2728
3311
  const lineRegex = /(?:Entity:\s*)?line\s+(\d+):/;
2729
3312
  const lineMatch = lineRegex.exec(error);
@@ -3464,6 +4047,92 @@ var ContentValidator = class {
3464
4047
  } catch {
3465
4048
  }
3466
4049
  }
4050
+ collectIds(root) {
4051
+ const ids = /* @__PURE__ */ new Set();
4052
+ try {
4053
+ for (const el of root.find(".//*[@id]")) {
4054
+ const id = this.getAttribute(el, "id");
4055
+ if (id) ids.add(id);
4056
+ }
4057
+ } catch {
4058
+ }
4059
+ return ids;
4060
+ }
4061
+ validateIdRefs(context, path, root) {
4062
+ try {
4063
+ const allIds = this.collectIds(root);
4064
+ const HTML_NS = { html: "http://www.w3.org/1999/xhtml" };
4065
+ const idrefsChecks = [
4066
+ { xpath: ".//*[@aria-describedby]", attr: "aria-describedby" },
4067
+ { xpath: ".//*[@aria-flowto]", attr: "aria-flowto" },
4068
+ { xpath: ".//*[@aria-labelledby]", attr: "aria-labelledby" },
4069
+ { xpath: ".//*[@aria-owns]", attr: "aria-owns" },
4070
+ { xpath: ".//*[@aria-controls]", attr: "aria-controls" },
4071
+ { xpath: ".//html:output[@for]", attr: "for", ns: HTML_NS },
4072
+ {
4073
+ xpath: ".//html:td[@headers] | .//html:th[@headers]",
4074
+ attr: "headers",
4075
+ ns: HTML_NS
4076
+ }
4077
+ ];
4078
+ for (const { xpath, attr, ns } of idrefsChecks) {
4079
+ const elements = ns ? root.find(xpath, ns) : root.find(xpath);
4080
+ for (const elem of elements) {
4081
+ const value = this.getAttribute(elem, attr);
4082
+ if (!value) continue;
4083
+ const idrefs = value.trim().split(/\s+/);
4084
+ if (idrefs.some((idref) => !allIds.has(idref))) {
4085
+ pushMessage(context.messages, {
4086
+ id: MessageId.RSC_005,
4087
+ message: `The ${attr} attribute must refer to elements in the same document (target ID missing)`,
4088
+ location: { path, line: elem.line }
4089
+ });
4090
+ }
4091
+ }
4092
+ }
4093
+ const activedescMsg = "The aria-activedescendant attribute must refer to a descendant element.";
4094
+ for (const elem of root.find(".//*[@aria-activedescendant]")) {
4095
+ const idref = this.getAttribute(elem, "aria-activedescendant");
4096
+ if (!idref) continue;
4097
+ if (!allIds.has(idref)) {
4098
+ pushMessage(context.messages, {
4099
+ id: MessageId.RSC_005,
4100
+ message: activedescMsg,
4101
+ location: { path, line: elem.line }
4102
+ });
4103
+ } else {
4104
+ try {
4105
+ if (elem.find(`.//*[@id="${idref}"]`).length === 0) {
4106
+ pushMessage(context.messages, {
4107
+ id: MessageId.RSC_005,
4108
+ message: activedescMsg,
4109
+ location: { path, line: elem.line }
4110
+ });
4111
+ }
4112
+ } catch {
4113
+ }
4114
+ }
4115
+ }
4116
+ for (const elem of root.find(".//*[@aria-describedat]")) {
4117
+ pushMessage(context.messages, {
4118
+ id: MessageId.RSC_005,
4119
+ message: 'attribute "aria-describedat" not allowed here',
4120
+ location: { path, line: elem.line }
4121
+ });
4122
+ }
4123
+ for (const elem of root.find(".//html:label[@for]", HTML_NS)) {
4124
+ const idref = this.getAttribute(elem, "for");
4125
+ if (idref && !allIds.has(idref)) {
4126
+ pushMessage(context.messages, {
4127
+ id: MessageId.RSC_005,
4128
+ message: `The for attribute must refer to an element in the same document (the ID "${idref}" does not exist).`,
4129
+ location: { path, line: elem.line }
4130
+ });
4131
+ }
4132
+ }
4133
+ } catch {
4134
+ }
4135
+ }
3467
4136
  validateEpubSwitch(context, path, root) {
3468
4137
  const EPUB_NS = { epub: "http://www.idpf.org/2007/ops" };
3469
4138
  try {
@@ -3552,15 +4221,7 @@ var ContentValidator = class {
3552
4221
  try {
3553
4222
  const triggers = root.find(".//epub:trigger", EPUB_NS);
3554
4223
  if (triggers.length === 0) return;
3555
- const allIds = /* @__PURE__ */ new Set();
3556
- try {
3557
- const idElements = root.find(".//*[@id]");
3558
- for (const el of idElements) {
3559
- const idAttr = this.getAttribute(el, "id");
3560
- if (idAttr) allIds.add(idAttr);
3561
- }
3562
- } catch {
3563
- }
4224
+ const allIds = this.collectIds(root);
3564
4225
  for (const trigger of triggers) {
3565
4226
  pushMessage(context.messages, {
3566
4227
  id: MessageId.RSC_017,
@@ -3691,6 +4352,22 @@ var ContentValidator = class {
3691
4352
  } catch {
3692
4353
  }
3693
4354
  }
4355
+ checkUsemapAttribute(context, path, root) {
4356
+ try {
4357
+ const elements = root.find(".//html:*[@usemap]", XHTML_NS);
4358
+ for (const elem of elements) {
4359
+ const usemap = this.getAttribute(elem, "usemap");
4360
+ if (usemap !== null && !/^#.+$/.test(usemap)) {
4361
+ pushMessage(context.messages, {
4362
+ id: MessageId.RSC_005,
4363
+ message: `value of attribute "usemap" is invalid; must be a string matching the regular expression "#.+"`,
4364
+ location: { path, line: elem.line }
4365
+ });
4366
+ }
4367
+ }
4368
+ } catch {
4369
+ }
4370
+ }
3694
4371
  checkTimeElement(context, path, root) {
3695
4372
  const HTML_NS = { html: "http://www.w3.org/1999/xhtml" };
3696
4373
  try {
@@ -3900,22 +4577,7 @@ var ContentValidator = class {
3900
4577
  });
3901
4578
  }
3902
4579
  }
3903
- const svgLinks = root.find(".//svg:a", {
3904
- svg: "http://www.w3.org/2000/svg",
3905
- xlink: "http://www.w3.org/1999/xlink"
3906
- });
3907
- for (const svgLink of svgLinks) {
3908
- const svgElem = svgLink;
3909
- const title = svgElem.get("./svg:title", { svg: "http://www.w3.org/2000/svg" });
3910
- const ariaLabel = this.getAttribute(svgElem, "aria-label");
3911
- if (!title && !ariaLabel) {
3912
- pushMessage(context.messages, {
3913
- id: MessageId.ACC_011,
3914
- message: "SVG hyperlink has no accessible name (missing title element or aria-label)",
3915
- location: { path }
3916
- });
3917
- }
3918
- }
4580
+ this.checkSVGLinkAccessibility(context, path, root);
3919
4581
  const mathElements = root.find(".//math:math", { math: "http://www.w3.org/1998/Math/MathML" });
3920
4582
  for (const mathElem of mathElements) {
3921
4583
  const elem = mathElem;
@@ -3933,6 +4595,29 @@ var ContentValidator = class {
3933
4595
  }
3934
4596
  }
3935
4597
  }
4598
+ hasSVGLinkAccessibleName(svgElem) {
4599
+ const ns = { svg: "http://www.w3.org/2000/svg" };
4600
+ if (svgElem.get(".//svg:title", ns)) return true;
4601
+ if (svgElem.get(".//svg:text", ns)) return true;
4602
+ if (this.getAttribute(svgElem, "aria-label")) return true;
4603
+ if (this.getAttribute(svgElem, "xlink:title")) return true;
4604
+ return false;
4605
+ }
4606
+ checkSVGLinkAccessibility(context, path, root) {
4607
+ const svgLinks = root.find(".//svg:a", {
4608
+ svg: "http://www.w3.org/2000/svg",
4609
+ xlink: "http://www.w3.org/1999/xlink"
4610
+ });
4611
+ for (const svgLink of svgLinks) {
4612
+ if (!this.hasSVGLinkAccessibleName(svgLink)) {
4613
+ pushMessage(context.messages, {
4614
+ id: MessageId.ACC_011,
4615
+ message: "SVG hyperlink has no accessible name (missing title element or aria-label)",
4616
+ location: { path }
4617
+ });
4618
+ }
4619
+ }
4620
+ }
3936
4621
  validateImages(context, path, root) {
3937
4622
  const packageDoc = context.packageDocument;
3938
4623
  if (!packageDoc) return;
@@ -3994,6 +4679,14 @@ var ContentValidator = class {
3994
4679
  const elemTyped = elem;
3995
4680
  const epubTypeAttr = elemTyped.attr("type", "epub");
3996
4681
  if (!epubTypeAttr?.value) continue;
4682
+ if (EPUB_TYPE_FORBIDDEN_ELEMENTS.has(elemTyped.name)) {
4683
+ pushMessage(context.messages, {
4684
+ id: MessageId.RSC_005,
4685
+ message: `attribute "epub:type" not allowed here`,
4686
+ location: { path, line: elem.line }
4687
+ });
4688
+ continue;
4689
+ }
3997
4690
  for (const part of epubTypeAttr.value.split(/\s+/)) {
3998
4691
  if (!part) continue;
3999
4692
  const hasPrefix = part.includes(":");
@@ -4089,41 +4782,131 @@ var ContentValidator = class {
4089
4782
  const attr = attrs.find((a) => a.name === name);
4090
4783
  return attr?.value ?? null;
4091
4784
  }
4785
+ /**
4786
+ * Get remote xml:base URL from the document root element.
4787
+ * Returns the URL if it's remote (http/https), or null otherwise.
4788
+ */
4789
+ getRemoteXmlBase(root) {
4790
+ const xmlBase = root.attr("base", "xml")?.value ?? null;
4791
+ if (xmlBase?.startsWith("http://") || xmlBase?.startsWith("https://")) {
4792
+ return xmlBase;
4793
+ }
4794
+ return null;
4795
+ }
4092
4796
  validateViewportMeta(context, path, root, manifestItem) {
4093
- const isFixedLayout = manifestItem?.properties?.includes("fixed-layout");
4094
- const metaTags = root.find(".//html:meta[@name]", { html: "http://www.w3.org/1999/xhtml" });
4095
- let hasViewportMeta = false;
4096
- for (const meta of metaTags) {
4797
+ const packageDoc = context.packageDocument;
4798
+ const isFixedLayout = manifestItem && packageDoc ? isItemFixedLayout(packageDoc, manifestItem.id) : false;
4799
+ const headMetas = root.find(".//html:head/html:meta[@name]", {
4800
+ html: "http://www.w3.org/1999/xhtml"
4801
+ });
4802
+ let viewportCount = 0;
4803
+ for (const meta of headMetas) {
4097
4804
  const nameAttr = this.getAttribute(meta, "name");
4098
- if (nameAttr === "viewport") {
4099
- hasViewportMeta = true;
4100
- const contentAttr = this.getAttribute(meta, "content");
4101
- if (isFixedLayout) {
4102
- if (!contentAttr) {
4103
- pushMessage(context.messages, {
4104
- id: MessageId.HTM_046,
4105
- message: "Viewport meta element should have a content attribute in fixed-layout documents",
4106
- location: { path }
4107
- });
4108
- continue;
4109
- }
4110
- } else {
4111
- pushMessage(context.messages, {
4112
- id: MessageId.HTM_060b,
4113
- message: `EPUB reading systems must ignore viewport meta elements in reflowable documents; viewport declaration "${contentAttr ?? ""}" will be ignored`,
4114
- location: { path }
4115
- });
4116
- }
4805
+ if (nameAttr !== "viewport") continue;
4806
+ viewportCount++;
4807
+ const contentAttr = this.getAttribute(meta, "content");
4808
+ if (!isFixedLayout) {
4809
+ pushMessage(context.messages, {
4810
+ id: MessageId.HTM_060b,
4811
+ message: `EPUB reading systems must ignore viewport meta elements in reflowable documents; viewport declaration "${contentAttr ?? ""}" will be ignored`,
4812
+ location: { path, line: meta.line }
4813
+ });
4814
+ continue;
4815
+ }
4816
+ if (viewportCount > 1) {
4817
+ pushMessage(context.messages, {
4818
+ id: MessageId.HTM_060a,
4819
+ message: `EPUB reading systems must ignore secondary viewport meta elements in fixed-layout documents; viewport declaration "${contentAttr ?? ""}" will be ignored`,
4820
+ location: { path, line: meta.line }
4821
+ });
4822
+ continue;
4823
+ }
4824
+ if (!contentAttr?.trim()) {
4825
+ pushMessage(context.messages, {
4826
+ id: MessageId.HTM_047,
4827
+ message: `Viewport metadata "${contentAttr ?? ""}" has a syntax error`,
4828
+ location: { path, line: meta.line }
4829
+ });
4830
+ continue;
4117
4831
  }
4832
+ this.parseViewportContent(context, path, contentAttr, meta.line);
4118
4833
  }
4119
- if (isFixedLayout && !hasViewportMeta) {
4834
+ if (isFixedLayout && viewportCount === 0) {
4120
4835
  pushMessage(context.messages, {
4121
- id: MessageId.HTM_049,
4122
- message: "Fixed-layout document should include a viewport meta element",
4836
+ id: MessageId.HTM_046,
4837
+ message: "Fixed layout document has no viewport meta element",
4123
4838
  location: { path }
4124
4839
  });
4125
4840
  }
4126
4841
  }
4842
+ parseViewportContent(context, path, content, line) {
4843
+ const location = line != null ? { path, line } : { path };
4844
+ const parts = content.split(/[,;]/);
4845
+ const seenKeys = /* @__PURE__ */ new Set();
4846
+ let hasWidth = false;
4847
+ let hasHeight = false;
4848
+ let hasSyntaxError = false;
4849
+ for (const part of parts) {
4850
+ const trimmed = part.trim();
4851
+ if (!trimmed) continue;
4852
+ const eqIndex = trimmed.indexOf("=");
4853
+ let key;
4854
+ let value;
4855
+ if (eqIndex < 0) {
4856
+ key = trimmed;
4857
+ value = "";
4858
+ } else {
4859
+ key = trimmed.substring(0, eqIndex).trim();
4860
+ const rawValue = trimmed.substring(eqIndex + 1);
4861
+ if (!rawValue.trim()) {
4862
+ pushMessage(context.messages, {
4863
+ id: MessageId.HTM_047,
4864
+ message: `Viewport metadata "${content}" has a syntax error`,
4865
+ location
4866
+ });
4867
+ hasSyntaxError = true;
4868
+ break;
4869
+ }
4870
+ value = rawValue.trim();
4871
+ }
4872
+ if (key === "width" || key === "height") {
4873
+ if (seenKeys.has(key)) {
4874
+ pushMessage(context.messages, {
4875
+ id: MessageId.HTM_059,
4876
+ message: `Viewport "${key}" property must not be defined more than once`,
4877
+ location
4878
+ });
4879
+ }
4880
+ seenKeys.add(key);
4881
+ if (key === "width") hasWidth = true;
4882
+ if (key === "height") hasHeight = true;
4883
+ const deviceKeyword = key === "width" ? "device-width" : "device-height";
4884
+ if (value === deviceKeyword) ; else if (value === "" || !/^[0-9]*\.?[0-9]+$/.test(value)) {
4885
+ pushMessage(context.messages, {
4886
+ id: MessageId.HTM_057,
4887
+ message: `Viewport "${key}" value must be a positive number or the keyword "${deviceKeyword}"`,
4888
+ location
4889
+ });
4890
+ }
4891
+ }
4892
+ }
4893
+ if (!hasSyntaxError) {
4894
+ if (!hasWidth) {
4895
+ pushMessage(context.messages, {
4896
+ id: MessageId.HTM_056,
4897
+ message: 'Viewport metadata has no "width" dimension (both "width" and "height" properties are required)',
4898
+ location
4899
+ });
4900
+ }
4901
+ if (!hasHeight) {
4902
+ pushMessage(context.messages, {
4903
+ id: MessageId.HTM_056,
4904
+ message: 'Viewport metadata has no "height" dimension (both "width" and "height" properties are required)',
4905
+ location
4906
+ });
4907
+ }
4908
+ }
4909
+ }
4127
4910
  extractAndRegisterIDs(path, root, registry) {
4128
4911
  const elementsWithId = root.find(".//*[@id]");
4129
4912
  for (const elem of elementsWithId) {
@@ -4138,7 +4921,7 @@ var ContentValidator = class {
4138
4921
  }
4139
4922
  }
4140
4923
  }
4141
- extractAndRegisterHyperlinks(context, path, root, opfDir, refValidator, isNavDocument = false) {
4924
+ extractAndRegisterHyperlinks(context, path, root, opfDir, refValidator, isNavDocument = false, remoteXmlBase = null) {
4142
4925
  const docDir = path.includes("/") ? path.substring(0, path.lastIndexOf("/")) : "";
4143
4926
  const navAnchorTypes = /* @__PURE__ */ new Map();
4144
4927
  if (isNavDocument) {
@@ -4174,7 +4957,17 @@ var ContentValidator = class {
4174
4957
  }
4175
4958
  const line = link.line;
4176
4959
  const refType = isNavDocument ? navAnchorTypes.get(`${String(line)}:${href}`) ?? "hyperlink" /* HYPERLINK */ : "hyperlink" /* HYPERLINK */;
4960
+ if (href.startsWith("data:") || href.startsWith("file:")) {
4961
+ refValidator.addReference({
4962
+ url: href,
4963
+ targetResource: href,
4964
+ type: refType,
4965
+ location: { path, line }
4966
+ });
4967
+ continue;
4968
+ }
4177
4969
  if (ABSOLUTE_URI_RE.test(href)) {
4970
+ validateAbsoluteHyperlinkURL(context, href, path, line);
4178
4971
  continue;
4179
4972
  }
4180
4973
  if (href.includes("#epubcfi(")) {
@@ -4192,6 +4985,15 @@ var ContentValidator = class {
4192
4985
  });
4193
4986
  continue;
4194
4987
  }
4988
+ if (remoteXmlBase && !ABSOLUTE_URI_RE.test(href)) {
4989
+ const resolvedUrl = new URL(href, remoteXmlBase).href;
4990
+ pushMessage(context.messages, {
4991
+ id: MessageId.RSC_006,
4992
+ message: `Remote resource reference is not allowed; resource "${resolvedUrl}" must be located in the EPUB container`,
4993
+ location: { path, line }
4994
+ });
4995
+ continue;
4996
+ }
4195
4997
  const resolvedPath = this.resolveRelativePath(docDir, href, opfDir);
4196
4998
  const hashIndex = resolvedPath.indexOf("#");
4197
4999
  const targetResource = hashIndex >= 0 ? resolvedPath.slice(0, hashIndex) : resolvedPath;
@@ -4212,7 +5014,19 @@ var ContentValidator = class {
4212
5014
  const href = this.getAttribute(area, "href")?.trim();
4213
5015
  if (!href) continue;
4214
5016
  const line = area.line;
4215
- if (ABSOLUTE_URI_RE.test(href)) continue;
5017
+ if (href.startsWith("data:") || href.startsWith("file:")) {
5018
+ refValidator.addReference({
5019
+ url: href,
5020
+ targetResource: href,
5021
+ type: "hyperlink" /* HYPERLINK */,
5022
+ location: { path, line }
5023
+ });
5024
+ continue;
5025
+ }
5026
+ if (ABSOLUTE_URI_RE.test(href)) {
5027
+ validateAbsoluteHyperlinkURL(context, href, path, line);
5028
+ continue;
5029
+ }
4216
5030
  if (href.includes("#epubcfi(")) continue;
4217
5031
  if (href.startsWith("#")) {
4218
5032
  refValidator.addReference({
@@ -4248,7 +5062,17 @@ var ContentValidator = class {
4248
5062
  const href = this.getAttribute(elem, "xlink:href") ?? this.getAttribute(elem, "href");
4249
5063
  if (!href) continue;
4250
5064
  const line = link.line;
4251
- if (href.startsWith("http://") || href.startsWith("https://")) {
5065
+ if (href.startsWith("data:") || href.startsWith("file:")) {
5066
+ refValidator.addReference({
5067
+ url: href,
5068
+ targetResource: href,
5069
+ type: "hyperlink" /* HYPERLINK */,
5070
+ location: { path, line }
5071
+ });
5072
+ continue;
5073
+ }
5074
+ if (ABSOLUTE_URI_RE.test(href)) {
5075
+ validateAbsoluteHyperlinkURL(context, href, path, line);
4252
5076
  continue;
4253
5077
  }
4254
5078
  if (href.startsWith("#")) {
@@ -4279,8 +5103,12 @@ var ContentValidator = class {
4279
5103
  refValidator.addReference(svgRef);
4280
5104
  }
4281
5105
  }
4282
- extractAndRegisterStylesheets(path, root, opfDir, refValidator) {
5106
+ extractAndRegisterStylesheets(context, path, root, opfDir, refValidator, remoteXmlBase = null) {
4283
5107
  const docDir = path.includes("/") ? path.substring(0, path.lastIndexOf("/")) : "";
5108
+ const baseElem = root.get(".//html:base[@href]", { html: "http://www.w3.org/1999/xhtml" });
5109
+ const baseHref = baseElem ? this.getAttribute(baseElem, "href") : null;
5110
+ const effectiveBase = baseHref ?? remoteXmlBase;
5111
+ const remoteBaseUrl = effectiveBase?.startsWith("http://") || effectiveBase?.startsWith("https://") ? effectiveBase : null;
4284
5112
  const linkElements = root.find(".//html:link[@href]", { html: "http://www.w3.org/1999/xhtml" });
4285
5113
  for (const linkElem of linkElements) {
4286
5114
  const href = this.getAttribute(linkElem, "href");
@@ -4298,6 +5126,15 @@ var ContentValidator = class {
4298
5126
  });
4299
5127
  continue;
4300
5128
  }
5129
+ if (remoteBaseUrl && !ABSOLUTE_URI_RE.test(href)) {
5130
+ const resolvedUrl = new URL(href, remoteBaseUrl).href;
5131
+ pushMessage(context.messages, {
5132
+ id: MessageId.RSC_006,
5133
+ message: `Remote resource reference is not allowed; resource "${resolvedUrl}" must be located in the EPUB container`,
5134
+ location: { path, line }
5135
+ });
5136
+ continue;
5137
+ }
4301
5138
  const resolvedPath = this.resolveRelativePath(docDir, href, opfDir);
4302
5139
  const hashIndex = resolvedPath.indexOf("#");
4303
5140
  const targetResource = hashIndex >= 0 ? resolvedPath.slice(0, hashIndex) : resolvedPath;
@@ -5355,6 +6192,8 @@ var OCFValidator = class {
5355
6192
  this.validateUtf8Filenames(zip, context.messages);
5356
6193
  this.validateEmptyDirectories(zip, context.messages);
5357
6194
  this.parseEncryption(zip, context);
6195
+ this.validateEncryptionXml(context);
6196
+ this.validateSignaturesXml(context);
5358
6197
  }
5359
6198
  /**
5360
6199
  * Validate the mimetype file
@@ -5695,14 +6534,109 @@ var OCFValidator = class {
5695
6534
  const xml = block[0];
5696
6535
  const algorithmMatch = /Algorithm=["']([^"']+)["']/.exec(xml);
5697
6536
  const uriMatch = /<(?:\w+:)?CipherReference[^>]+URI=["']([^"']+)["']/.exec(xml);
5698
- if (algorithmMatch?.[1] === IDPF_OBFUSCATION && uriMatch?.[1]) {
5699
- obfuscated.add(uriMatch[1]);
6537
+ const algorithm = algorithmMatch?.[1];
6538
+ const uri = uriMatch?.[1];
6539
+ if (!uri) continue;
6540
+ if (algorithm === IDPF_OBFUSCATION) {
6541
+ obfuscated.add(uri);
5700
6542
  }
6543
+ pushMessage(context.messages, {
6544
+ id: MessageId.RSC_004,
6545
+ message: `File "${uri}" is encrypted, its content will not be checked`,
6546
+ location: { path: encryptionPath }
6547
+ });
5701
6548
  }
5702
6549
  if (obfuscated.size > 0) {
5703
6550
  context.obfuscatedResources = obfuscated;
5704
6551
  }
5705
6552
  }
6553
+ /**
6554
+ * Validate encryption.xml structure:
6555
+ * - Root element must be "encryption" in OCF namespace
6556
+ * - Compression Method must be "0" or "8"
6557
+ * - Compression OriginalLength must be a non-negative integer
6558
+ * - All Id attributes must be unique
6559
+ */
6560
+ extractRootElementName(xml) {
6561
+ const match = /<(\w+)[\s>]/.exec(xml.replace(/<\?xml[^?]*\?>/, "").trimStart());
6562
+ return match?.[1] ?? null;
6563
+ }
6564
+ validateEncryptionXml(context) {
6565
+ const encPath = "META-INF/encryption.xml";
6566
+ const content = context.files.get(encPath);
6567
+ if (!content) return;
6568
+ const xml = new TextDecoder().decode(content);
6569
+ const rootName = this.extractRootElementName(xml);
6570
+ if (rootName !== null && rootName !== "encryption") {
6571
+ pushMessage(context.messages, {
6572
+ id: MessageId.RSC_005,
6573
+ message: `expected element "encryption" but found "${rootName}"`,
6574
+ location: { path: encPath }
6575
+ });
6576
+ return;
6577
+ }
6578
+ const idPattern = /\bId=["']([^"']+)["']/g;
6579
+ const ids = /* @__PURE__ */ new Map();
6580
+ let idMatch;
6581
+ while ((idMatch = idPattern.exec(xml)) !== null) {
6582
+ const id = idMatch[1] ?? "";
6583
+ ids.set(id, (ids.get(id) ?? 0) + 1);
6584
+ }
6585
+ for (const [id, count] of ids) {
6586
+ if (count > 1) {
6587
+ pushMessage(context.messages, {
6588
+ id: MessageId.RSC_005,
6589
+ message: `Duplicate "${id}"`,
6590
+ location: { path: encPath }
6591
+ });
6592
+ }
6593
+ }
6594
+ const compressionPattern = /<(?:\w+:)?Compression\s+([^>]*)\/?>/g;
6595
+ let compMatch;
6596
+ while ((compMatch = compressionPattern.exec(xml)) !== null) {
6597
+ const attrs = compMatch[1] ?? "";
6598
+ const methodMatch = /Method=["']([^"']*)["']/.exec(attrs);
6599
+ const lengthMatch = /OriginalLength=["']([^"']*)["']/.exec(attrs);
6600
+ if (methodMatch) {
6601
+ const method = methodMatch[1] ?? "";
6602
+ if (method !== "0" && method !== "8") {
6603
+ pushMessage(context.messages, {
6604
+ id: MessageId.RSC_005,
6605
+ message: `value of attribute "Method" is invalid; must be "0" or "8"`,
6606
+ location: { path: encPath }
6607
+ });
6608
+ }
6609
+ }
6610
+ if (lengthMatch) {
6611
+ const length = lengthMatch[1] ?? "";
6612
+ if (!/^\d+$/.test(length)) {
6613
+ pushMessage(context.messages, {
6614
+ id: MessageId.RSC_005,
6615
+ message: `value of attribute "OriginalLength" is invalid; must be a non-negative integer`,
6616
+ location: { path: encPath }
6617
+ });
6618
+ }
6619
+ }
6620
+ }
6621
+ }
6622
+ /**
6623
+ * Validate signatures.xml structure:
6624
+ * - Root element must be "signatures" in OCF namespace
6625
+ */
6626
+ validateSignaturesXml(context) {
6627
+ const sigPath = "META-INF/signatures.xml";
6628
+ const content = context.files.get(sigPath);
6629
+ if (!content) return;
6630
+ const xml = new TextDecoder().decode(content);
6631
+ const rootName = this.extractRootElementName(xml);
6632
+ if (rootName !== null && rootName !== "signatures") {
6633
+ pushMessage(context.messages, {
6634
+ id: MessageId.RSC_005,
6635
+ message: `expected element "signatures" but found "${rootName}"`,
6636
+ location: { path: sigPath }
6637
+ });
6638
+ }
6639
+ }
5706
6640
  /**
5707
6641
  * Validate empty directories
5708
6642
  */
@@ -5730,6 +6664,65 @@ var OCFValidator = class {
5730
6664
  }
5731
6665
  };
5732
6666
 
6667
+ // src/util/encoding.ts
6668
+ function sniffXmlEncoding(data) {
6669
+ if (data.length < 2) return null;
6670
+ if (data.length >= 4) {
6671
+ if (data[0] === 0 && data[1] === 0 && data[2] === 254 && data[3] === 255) {
6672
+ return "UCS-4";
6673
+ }
6674
+ if (data[0] === 255 && data[1] === 254 && data[2] === 0 && data[3] === 0) {
6675
+ return "UCS-4";
6676
+ }
6677
+ if (data[0] === 0 && data[1] === 0 && data[2] === 255 && data[3] === 254) {
6678
+ return "UCS-4";
6679
+ }
6680
+ if (data[0] === 254 && data[1] === 255 && data[2] === 0 && data[3] === 0) {
6681
+ return "UCS-4";
6682
+ }
6683
+ if (data[0] === 0 && data[1] === 0 && data[2] === 0 && data[3] === 60) {
6684
+ return "UCS-4";
6685
+ }
6686
+ if (data[0] === 60 && data[1] === 0 && data[2] === 0 && data[3] === 0) {
6687
+ return "UCS-4";
6688
+ }
6689
+ if (data[0] === 0 && data[1] === 0 && data[2] === 60 && data[3] === 0) {
6690
+ return "UCS-4";
6691
+ }
6692
+ if (data[0] === 0 && data[1] === 60 && data[2] === 0 && data[3] === 0) {
6693
+ return "UCS-4";
6694
+ }
6695
+ }
6696
+ if (data[0] === 254 && data[1] === 255) {
6697
+ return "UTF-16";
6698
+ }
6699
+ if (data[0] === 255 && data[1] === 254) {
6700
+ return "UTF-16";
6701
+ }
6702
+ if (data.length >= 4) {
6703
+ if (data[0] === 0 && data[1] === 60 && data[2] === 0 && data[3] === 63) {
6704
+ return "UTF-16";
6705
+ }
6706
+ if (data[0] === 60 && data[1] === 0 && data[2] === 63 && data[3] === 0) {
6707
+ return "UTF-16";
6708
+ }
6709
+ }
6710
+ if (data.length >= 3 && data[0] === 239 && data[1] === 187 && data[2] === 191) {
6711
+ return null;
6712
+ }
6713
+ if (data.length >= 4 && data[0] === 76 && data[1] === 111 && data[2] === 167 && data[3] === 148) {
6714
+ return "EBCDIC";
6715
+ }
6716
+ const prefix = String.fromCharCode(...data.slice(0, Math.min(256, data.length)));
6717
+ const match = /^<\?xml[^?]*\bencoding\s*=\s*["']([^"']+)["']/.exec(prefix);
6718
+ if (match) {
6719
+ const declared = (match[1] ?? "").toUpperCase();
6720
+ if (declared === "UTF-8") return null;
6721
+ return declared;
6722
+ }
6723
+ return null;
6724
+ }
6725
+
5733
6726
  // src/opf/parser.ts
5734
6727
  function parseOPF(xml) {
5735
6728
  const packageRegex = /<package[^>]*\sversion=["']([^"']+)["'][^>]*(?:\sunique-identifier=["']([^"']+)["'])?[^>]*>/;
@@ -6080,6 +7073,25 @@ var DEPRECATED_MEDIA_TYPES = /* @__PURE__ */ new Set([
6080
7073
  "application/x-oeb1-package",
6081
7074
  "text/x-oeb1-html"
6082
7075
  ]);
7076
+ function getPreferredMediaType(mimeType, path) {
7077
+ switch (mimeType) {
7078
+ case "application/font-sfnt":
7079
+ if (path.endsWith(".ttf")) return "font/ttf";
7080
+ if (path.endsWith(".otf")) return "font/otf";
7081
+ return "font/(ttf|otf)";
7082
+ case "application/vnd.ms-opentype":
7083
+ return "font/otf";
7084
+ case "application/font-woff":
7085
+ return "font/woff";
7086
+ case "application/x-font-ttf":
7087
+ return "font/ttf";
7088
+ case "text/javascript":
7089
+ case "application/ecmascript":
7090
+ return "application/javascript";
7091
+ default:
7092
+ return null;
7093
+ }
7094
+ }
6083
7095
  var VALID_RELATOR_CODES = /* @__PURE__ */ new Set([
6084
7096
  "abr",
6085
7097
  "acp",
@@ -6362,6 +7374,60 @@ var DEPRECATED_LINK_REL = /* @__PURE__ */ new Set([
6362
7374
  "xmp-record",
6363
7375
  "xml-signature"
6364
7376
  ]);
7377
+ var EXCLUSIVE_SPINE_GROUPS = [
7378
+ ["rendition:layout-reflowable", "rendition:layout-pre-paginated"],
7379
+ [
7380
+ "rendition:orientation-auto",
7381
+ "rendition:orientation-landscape",
7382
+ "rendition:orientation-portrait"
7383
+ ],
7384
+ [
7385
+ "rendition:spread-auto",
7386
+ "rendition:spread-both",
7387
+ "rendition:spread-landscape",
7388
+ "rendition:spread-none",
7389
+ "rendition:spread-portrait"
7390
+ ],
7391
+ ["page-spread-left", "page-spread-right", "rendition:page-spread-center"],
7392
+ [
7393
+ "rendition:flow-auto",
7394
+ "rendition:flow-paginated",
7395
+ "rendition:flow-scrolled-continuous",
7396
+ "rendition:flow-scrolled-doc"
7397
+ ]
7398
+ ];
7399
+ var RENDITION_META_RULES = [
7400
+ {
7401
+ property: "rendition:layout",
7402
+ allowedValues: /* @__PURE__ */ new Set(["reflowable", "pre-paginated"]),
7403
+ forbidRefines: true
7404
+ },
7405
+ {
7406
+ property: "rendition:orientation",
7407
+ allowedValues: /* @__PURE__ */ new Set(["landscape", "portrait", "auto"]),
7408
+ forbidRefines: true
7409
+ },
7410
+ {
7411
+ property: "rendition:spread",
7412
+ allowedValues: /* @__PURE__ */ new Set(["none", "landscape", "portrait", "both", "auto"]),
7413
+ forbidRefines: true,
7414
+ deprecatedValues: /* @__PURE__ */ new Set(["portrait"])
7415
+ },
7416
+ {
7417
+ property: "rendition:flow",
7418
+ allowedValues: /* @__PURE__ */ new Set(["paginated", "scrolled-continuous", "scrolled-doc", "auto"]),
7419
+ forbidRefines: true
7420
+ },
7421
+ {
7422
+ property: "rendition:viewport",
7423
+ deprecated: true,
7424
+ allowedValues: /* @__PURE__ */ new Set(),
7425
+ validateSyntax: (v) => /^(width=\d+,\s*height=\d+|height=\d+,\s*width=\d+)$/.test(v)
7426
+ }
7427
+ ];
7428
+ var KNOWN_RENDITION_META_PROPERTIES = new Set(
7429
+ RENDITION_META_RULES.map((r) => r.property.slice("rendition:".length))
7430
+ );
6365
7431
  var GRANDFATHERED_LANG_TAGS = /* @__PURE__ */ new Set([
6366
7432
  "en-GB-oed",
6367
7433
  "i-ami",
@@ -6415,6 +7481,20 @@ var OPFValidator = class {
6415
7481
  });
6416
7482
  return;
6417
7483
  }
7484
+ const encoding = sniffXmlEncoding(opfData);
7485
+ if (encoding === "UTF-16") {
7486
+ pushMessage(context.messages, {
7487
+ id: MessageId.RSC_027,
7488
+ message: `Detected non-UTF-8 encoding "${encoding}" in "${opfPath}"`,
7489
+ location: { path: opfPath }
7490
+ });
7491
+ } else if (encoding !== null) {
7492
+ pushMessage(context.messages, {
7493
+ id: MessageId.RSC_028,
7494
+ message: `Detected non-UTF-8 encoding "${encoding}" in "${opfPath}"`,
7495
+ location: { path: opfPath }
7496
+ });
7497
+ }
6418
7498
  const opfXml = new TextDecoder().decode(opfData);
6419
7499
  try {
6420
7500
  this.packageDoc = parseOPF(opfXml);
@@ -6704,6 +7784,9 @@ var OPFValidator = class {
6704
7784
  }
6705
7785
  if (this.packageDoc.version !== "2.0") {
6706
7786
  this.validateMetaPropertiesVocab(context, opfPath, dcElements);
7787
+ this.validateRenditionVocab(context, opfPath);
7788
+ this.validateMediaOverlaysVocab(context, opfPath);
7789
+ this.validateMediaOverlayItems(context, opfPath);
6707
7790
  }
6708
7791
  if (this.packageDoc.version !== "2.0") {
6709
7792
  const modifiedMetas = this.packageDoc.metaElements.filter(
@@ -6994,6 +8077,206 @@ var OPFValidator = class {
6994
8077
  }
6995
8078
  }
6996
8079
  }
8080
+ /**
8081
+ * Validate rendition vocabulary meta properties (rendition:layout, orientation, spread, flow, viewport).
8082
+ * Ports the Schematron rules from package-30.sch for the rendition vocabulary.
8083
+ */
8084
+ validateRenditionVocab(context, opfPath) {
8085
+ if (!this.packageDoc) return;
8086
+ const metas = this.packageDoc.metaElements;
8087
+ for (const rp of RENDITION_META_RULES) {
8088
+ const matching = metas.filter((m) => m.property === rp.property);
8089
+ for (const meta of matching) {
8090
+ if (meta.refines && rp.forbidRefines) {
8091
+ pushMessage(context.messages, {
8092
+ id: MessageId.RSC_005,
8093
+ message: `The "${rp.property}" property must not refine a publication resource`,
8094
+ location: { path: opfPath }
8095
+ });
8096
+ continue;
8097
+ }
8098
+ if (rp.deprecated) {
8099
+ pushMessage(context.messages, {
8100
+ id: MessageId.OPF_086,
8101
+ message: `The "${rp.property}" property is deprecated`,
8102
+ location: { path: opfPath }
8103
+ });
8104
+ }
8105
+ if (rp.validateSyntax) {
8106
+ if (!rp.validateSyntax(meta.value)) {
8107
+ pushMessage(context.messages, {
8108
+ id: MessageId.RSC_005,
8109
+ message: `The value of the "${rp.property}" property must be of the form "width=x, height=y"`,
8110
+ location: { path: opfPath }
8111
+ });
8112
+ }
8113
+ } else if (!rp.allowedValues.has(meta.value)) {
8114
+ pushMessage(context.messages, {
8115
+ id: MessageId.RSC_005,
8116
+ message: `The value of the "${rp.property}" property must be ${[...rp.allowedValues].map((v) => `"${v}"`).join(" or ")}`,
8117
+ location: { path: opfPath }
8118
+ });
8119
+ }
8120
+ if (rp.deprecatedValues?.has(meta.value)) {
8121
+ pushMessage(context.messages, {
8122
+ id: MessageId.OPF_086,
8123
+ message: `The "${rp.property}" property value "${meta.value}" is deprecated`,
8124
+ location: { path: opfPath }
8125
+ });
8126
+ }
8127
+ }
8128
+ const countable = rp.forbidRefines ? matching : matching.filter((m) => !m.refines);
8129
+ if (countable.length > 1) {
8130
+ pushMessage(context.messages, {
8131
+ id: MessageId.RSC_005,
8132
+ message: `The "${rp.property}" property must not occur more than one time as a global value`,
8133
+ location: { path: opfPath }
8134
+ });
8135
+ }
8136
+ }
8137
+ for (const meta of metas) {
8138
+ if (meta.property.startsWith("rendition:")) {
8139
+ const localName = meta.property.slice("rendition:".length);
8140
+ if (!KNOWN_RENDITION_META_PROPERTIES.has(localName)) {
8141
+ pushMessage(context.messages, {
8142
+ id: MessageId.OPF_027,
8143
+ message: `Undefined property: "${meta.property}"`,
8144
+ location: { path: opfPath }
8145
+ });
8146
+ }
8147
+ }
8148
+ }
8149
+ }
8150
+ /**
8151
+ * Validate media overlays vocabulary meta properties (media:active-class, playback-active-class, duration).
8152
+ * Ports the Schematron rules from package-30.sch for the media overlays vocabulary.
8153
+ */
8154
+ validateMediaOverlaysVocab(context, opfPath) {
8155
+ if (!this.packageDoc) return;
8156
+ const metas = this.packageDoc.metaElements;
8157
+ const matchingActive = metas.filter((m) => m.property === "media:active-class");
8158
+ const matchingPlayback = metas.filter((m) => m.property === "media:playback-active-class");
8159
+ for (const [prop, matching] of [
8160
+ ["media:active-class", matchingActive],
8161
+ ["media:playback-active-class", matchingPlayback]
8162
+ ]) {
8163
+ const displayName = prop.slice("media:".length);
8164
+ if (matching.length > 1) {
8165
+ pushMessage(context.messages, {
8166
+ id: MessageId.RSC_005,
8167
+ message: `The '${displayName}' property must not occur more than one time in the package metadata`,
8168
+ location: { path: opfPath }
8169
+ });
8170
+ }
8171
+ for (const meta of matching) {
8172
+ if (meta.refines) {
8173
+ pushMessage(context.messages, {
8174
+ id: MessageId.RSC_005,
8175
+ message: `@refines must not be used with the ${prop} property`,
8176
+ location: { path: opfPath }
8177
+ });
8178
+ }
8179
+ if (meta.value.trim().includes(" ")) {
8180
+ pushMessage(context.messages, {
8181
+ id: MessageId.RSC_005,
8182
+ message: `the '${displayName}' property must define a single class name`,
8183
+ location: { path: opfPath }
8184
+ });
8185
+ }
8186
+ }
8187
+ }
8188
+ if (matchingActive[0]) context.mediaActiveClass = matchingActive[0].value.trim();
8189
+ if (matchingPlayback[0]) context.mediaPlaybackActiveClass = matchingPlayback[0].value.trim();
8190
+ for (const meta of metas) {
8191
+ if (meta.property === "media:duration") {
8192
+ if (!isValidSmilClock(meta.value.trim())) {
8193
+ pushMessage(context.messages, {
8194
+ id: MessageId.RSC_005,
8195
+ message: `The value of the "media:duration" property must be a valid SMIL3 clock value`,
8196
+ location: { path: opfPath }
8197
+ });
8198
+ }
8199
+ }
8200
+ }
8201
+ const globalDuration = metas.find((m) => m.property === "media:duration" && !m.refines);
8202
+ if (globalDuration) {
8203
+ const totalSeconds = parseSmilClock(globalDuration.value.trim());
8204
+ if (!Number.isNaN(totalSeconds)) {
8205
+ let sumSeconds = 0;
8206
+ let allValid = true;
8207
+ for (const meta of metas) {
8208
+ if (meta.property === "media:duration" && meta.refines) {
8209
+ const s = parseSmilClock(meta.value.trim());
8210
+ if (Number.isNaN(s)) {
8211
+ allValid = false;
8212
+ break;
8213
+ }
8214
+ sumSeconds += s;
8215
+ }
8216
+ }
8217
+ if (allValid && Math.abs(totalSeconds - sumSeconds) > 1) {
8218
+ pushMessage(context.messages, {
8219
+ id: MessageId.MED_016,
8220
+ message: `Media Overlays total duration should be the sum of the durations of all Media Overlays documents.`,
8221
+ location: { path: opfPath }
8222
+ });
8223
+ }
8224
+ }
8225
+ }
8226
+ }
8227
+ /**
8228
+ * Validate media-overlay manifest item constraints:
8229
+ * - media-overlay must reference a SMIL item (application/smil+xml)
8230
+ * - media-overlay attribute only allowed on XHTML and SVG content documents
8231
+ * - Global media:duration required when overlays exist
8232
+ * - Per-item media:duration required for each overlay
8233
+ */
8234
+ validateMediaOverlayItems(context, opfPath) {
8235
+ if (!this.packageDoc) return;
8236
+ const manifest = this.packageDoc.manifest;
8237
+ const metas = this.packageDoc.metaElements;
8238
+ const itemsWithOverlay = manifest.filter((item) => item.mediaOverlay);
8239
+ if (itemsWithOverlay.length === 0) return;
8240
+ for (const item of itemsWithOverlay) {
8241
+ const moId = item.mediaOverlay;
8242
+ if (!moId) continue;
8243
+ const moItem = this.manifestById.get(moId);
8244
+ if (moItem && moItem.mediaType !== "application/smil+xml") {
8245
+ pushMessage(context.messages, {
8246
+ id: MessageId.RSC_005,
8247
+ message: `media overlay items must be of the "application/smil+xml" type (given type was "${moItem.mediaType}")`,
8248
+ location: { path: opfPath }
8249
+ });
8250
+ }
8251
+ if (item.mediaType !== "application/xhtml+xml" && item.mediaType !== "image/svg+xml") {
8252
+ pushMessage(context.messages, {
8253
+ id: MessageId.RSC_005,
8254
+ message: `The media-overlay attribute is only allowed on XHTML and SVG content documents.`,
8255
+ location: { path: opfPath }
8256
+ });
8257
+ }
8258
+ }
8259
+ if (!metas.some((m) => m.property === "media:duration" && !m.refines)) {
8260
+ pushMessage(context.messages, {
8261
+ id: MessageId.RSC_005,
8262
+ message: `global media:duration meta element not set`,
8263
+ location: { path: opfPath }
8264
+ });
8265
+ }
8266
+ const overlayIds = new Set(
8267
+ itemsWithOverlay.map((item) => item.mediaOverlay).filter((id) => id != null && this.manifestById.has(id))
8268
+ );
8269
+ for (const overlayId of overlayIds) {
8270
+ const refinesUri = `#${overlayId}`;
8271
+ if (!metas.some((m) => m.property === "media:duration" && m.refines === refinesUri)) {
8272
+ pushMessage(context.messages, {
8273
+ id: MessageId.RSC_005,
8274
+ message: `item media:duration meta element not set (expecting: meta property='media:duration' refines='${refinesUri}')`,
8275
+ location: { path: opfPath }
8276
+ });
8277
+ }
8278
+ }
8279
+ }
6997
8280
  /**
6998
8281
  * Validate EPUB 3 link elements in metadata
6999
8282
  */
@@ -7073,7 +8356,30 @@ var OPFValidator = class {
7073
8356
  });
7074
8357
  continue;
7075
8358
  }
8359
+ if (isDataURL(href)) {
8360
+ pushMessage(context.messages, {
8361
+ id: MessageId.RSC_029,
8362
+ message: `Data URLs are not allowed in the package document link href`,
8363
+ location: { path: opfPath }
8364
+ });
8365
+ continue;
8366
+ }
8367
+ if (isFileURL(href)) {
8368
+ pushMessage(context.messages, {
8369
+ id: MessageId.RSC_030,
8370
+ message: `File URLs are not allowed in the package document`,
8371
+ location: { path: opfPath }
8372
+ });
8373
+ continue;
8374
+ }
7076
8375
  const isRemote = /^[a-zA-Z][a-zA-Z0-9+\-.]*:/.test(href);
8376
+ if (!isRemote && href.includes("?")) {
8377
+ pushMessage(context.messages, {
8378
+ id: MessageId.RSC_033,
8379
+ message: `Relative URL strings must not have a query component: "${href}"`,
8380
+ location: { path: opfPath }
8381
+ });
8382
+ }
7077
8383
  if (hasVoicing && link.mediaType && !link.mediaType.startsWith("audio/")) {
7078
8384
  pushMessage(context.messages, {
7079
8385
  id: MessageId.OPF_095,
@@ -7098,10 +8404,12 @@ var OPFValidator = class {
7098
8404
  location: { path: opfPath }
7099
8405
  });
7100
8406
  }
7101
- const resolvedPath = resolvePath(opfPath, basePath);
7102
- const resolvedPathDecoded = basePathDecoded !== basePath ? resolvePath(opfPath, basePathDecoded) : resolvedPath;
8407
+ const basePathNoQuery = basePath.includes("?") ? basePath.substring(0, basePath.indexOf("?")) : basePath;
8408
+ const basePathDecodedNoQuery = basePathDecoded.includes("?") ? basePathDecoded.substring(0, basePathDecoded.indexOf("?")) : basePathDecoded;
8409
+ const resolvedPath = resolvePath(opfPath, basePathNoQuery);
8410
+ const resolvedPathDecoded = basePathDecodedNoQuery !== basePathNoQuery ? resolvePath(opfPath, basePathDecodedNoQuery) : resolvedPath;
7103
8411
  const fileExists = context.files.has(resolvedPath) || context.files.has(resolvedPathDecoded);
7104
- const inManifest = this.manifestByHref.has(basePath) || this.manifestByHref.has(basePathDecoded);
8412
+ const inManifest = this.manifestByHref.has(basePathNoQuery) || this.manifestByHref.has(basePathDecodedNoQuery);
7105
8413
  if (!fileExists && !inManifest) {
7106
8414
  pushMessage(context.messages, {
7107
8415
  id: MessageId.RSC_007w,
@@ -7135,7 +8443,24 @@ var OPFValidator = class {
7135
8443
  });
7136
8444
  }
7137
8445
  seenHrefs.add(item.href);
7138
- const fullPath = resolvePath(opfPath, item.href);
8446
+ if (isDataURL(item.href)) {
8447
+ pushMessage(context.messages, {
8448
+ id: MessageId.RSC_029,
8449
+ message: `Data URLs are not allowed in the manifest item href`,
8450
+ location: { path: opfPath }
8451
+ });
8452
+ continue;
8453
+ }
8454
+ const isRemoteItem = /^[a-zA-Z][a-zA-Z0-9+\-.]*:/.test(item.href);
8455
+ if (!isRemoteItem && item.href.includes("?")) {
8456
+ pushMessage(context.messages, {
8457
+ id: MessageId.RSC_033,
8458
+ message: `Relative URL strings must not have a query component: "${item.href}"`,
8459
+ location: { path: opfPath }
8460
+ });
8461
+ }
8462
+ const itemHrefBase = item.href.includes("?") ? item.href.substring(0, item.href.indexOf("?")) : item.href;
8463
+ const fullPath = resolvePath(opfPath, itemHrefBase);
7139
8464
  if (fullPath === opfPath) {
7140
8465
  pushMessage(context.messages, {
7141
8466
  id: MessageId.OPF_099,
@@ -7153,8 +8478,8 @@ var OPFValidator = class {
7153
8478
  });
7154
8479
  }
7155
8480
  }
7156
- const decodedHref = tryDecodeUriComponent(item.href);
7157
- const fullPathDecoded = decodedHref !== item.href ? resolvePath(opfPath, decodedHref).normalize("NFC") : fullPath;
8481
+ const decodedHref = tryDecodeUriComponent(itemHrefBase);
8482
+ const fullPathDecoded = decodedHref !== itemHrefBase ? resolvePath(opfPath, decodedHref).normalize("NFC") : fullPath;
7158
8483
  if (!context.files.has(fullPath) && !context.files.has(fullPathDecoded) && !item.href.startsWith("http")) {
7159
8484
  pushMessage(context.messages, {
7160
8485
  id: MessageId.RSC_001,
@@ -7183,6 +8508,14 @@ var OPFValidator = class {
7183
8508
  location: { path: opfPath }
7184
8509
  });
7185
8510
  }
8511
+ const preferred = getPreferredMediaType(item.mediaType, fullPath);
8512
+ if (preferred !== null) {
8513
+ pushMessage(context.messages, {
8514
+ id: MessageId.OPF_090,
8515
+ message: `Encouraged to use media type "${preferred}" instead of "${item.mediaType}"`,
8516
+ location: { path: opfPath }
8517
+ });
8518
+ }
7186
8519
  if (this.packageDoc.version !== "2.0" && item.properties) {
7187
8520
  for (const prop of item.properties) {
7188
8521
  if (!ITEM_PROPERTIES.has(prop)) {
@@ -7377,6 +8710,24 @@ var OPFValidator = class {
7377
8710
  location: { path: opfPath }
7378
8711
  });
7379
8712
  }
8713
+ if (prop === "rendition:spread-portrait") {
8714
+ pushMessage(context.messages, {
8715
+ id: MessageId.OPF_086,
8716
+ message: `The "rendition:spread-portrait" property is deprecated`,
8717
+ location: { path: opfPath }
8718
+ });
8719
+ }
8720
+ }
8721
+ const props = new Set(itemref.properties);
8722
+ for (const group of EXCLUSIVE_SPINE_GROUPS) {
8723
+ const found = group.filter((p) => props.has(p));
8724
+ if (found.length > 1) {
8725
+ pushMessage(context.messages, {
8726
+ id: MessageId.RSC_005,
8727
+ message: `Properties "${found.join('", "')}" are mutually exclusive`,
8728
+ location: { path: opfPath }
8729
+ });
8730
+ }
7380
8731
  }
7381
8732
  }
7382
8733
  }
@@ -7920,7 +9271,7 @@ var ReferenceValidator = class {
7920
9271
  location: reference.location
7921
9272
  });
7922
9273
  }
7923
- if (!this.registry.hasResource(resourcePath)) {
9274
+ if (reference.type !== "overlay-text-link" /* OVERLAY_TEXT_LINK */ && !this.registry.hasResource(resourcePath)) {
7924
9275
  const fileExistsInContainer = context.files.has(resourcePath);
7925
9276
  if (fileExistsInContainer) {
7926
9277
  if (!context.referencedUndeclaredResources?.has(resourcePath)) {
@@ -8057,6 +9408,24 @@ var ReferenceValidator = class {
8057
9408
  });
8058
9409
  }
8059
9410
  }
9411
+ if (reference.type === "overlay-text-link" /* OVERLAY_TEXT_LINK */ && resource) {
9412
+ if (resource.mimeType === "application/xhtml+xml" && /^\w+\(/.test(fragment)) {
9413
+ pushMessage(context.messages, {
9414
+ id: MessageId.MED_017,
9415
+ message: `URL fragment should indicate an element ID, but found '#${fragment}'`,
9416
+ location: reference.location
9417
+ });
9418
+ return;
9419
+ }
9420
+ if (resource.mimeType === "image/svg+xml" && !this.isValidSVGFragment(fragment)) {
9421
+ pushMessage(context.messages, {
9422
+ id: MessageId.MED_018,
9423
+ message: `URL fragment should be an SVG fragment identifier, but found '#${fragment}'`,
9424
+ location: reference.location
9425
+ });
9426
+ return;
9427
+ }
9428
+ }
8060
9429
  const parsedMimeTypes = ["application/xhtml+xml", "image/svg+xml"];
8061
9430
  if (resource && parsedMimeTypes.includes(resource.mimeType) && !isRemoteURL(resourcePath) && !fragment.includes("svgView(") && reference.type !== "svg-symbol" /* SVG_SYMBOL */) {
8062
9431
  if (!this.registry.hasID(resourcePath, fragment)) {
@@ -8068,6 +9437,18 @@ var ReferenceValidator = class {
8068
9437
  }
8069
9438
  }
8070
9439
  }
9440
+ /**
9441
+ * Check if a fragment is a valid SVG fragment identifier.
9442
+ * Valid forms: bare NCName ID, svgView(...), t=..., xywh=..., id=...
9443
+ */
9444
+ isValidSVGFragment(fragment) {
9445
+ if (/^svgView\(.*\)$/.test(fragment)) return true;
9446
+ if (fragment.startsWith("t=")) return true;
9447
+ if (fragment.startsWith("xywh=")) return true;
9448
+ if (fragment.startsWith("id=")) return true;
9449
+ if (!fragment.includes("=") && /^[a-zA-Z_][\w.-]*$/.test(fragment)) return true;
9450
+ return false;
9451
+ }
8071
9452
  /**
8072
9453
  * Check non-spine remote resources that have non-standard types.
8073
9454
  * Fires RSC-006 for remote items that aren't audio/video/font types
@@ -8127,7 +9508,7 @@ var ReferenceValidator = class {
8127
9508
  }
8128
9509
  }
8129
9510
  checkReadingOrder(context) {
8130
- if (!context.tocLinks || !context.packageDocument) return;
9511
+ if (!context.packageDocument) return;
8131
9512
  const packageDoc = context.packageDocument;
8132
9513
  const spine = packageDoc.spine;
8133
9514
  const opfPath = context.opfPath ?? "";
@@ -8139,15 +9520,35 @@ var ReferenceValidator = class {
8139
9520
  spinePositionMap.set(resolveManifestHref(opfDir, item.href), i);
8140
9521
  }
8141
9522
  }
9523
+ if (context.tocLinks) {
9524
+ this.checkLinkReadingOrder(
9525
+ context,
9526
+ spinePositionMap,
9527
+ context.tocLinks,
9528
+ MessageId.NAV_011,
9529
+ '"toc" nav'
9530
+ );
9531
+ }
9532
+ if (context.overlayTextLinks) {
9533
+ this.checkLinkReadingOrder(
9534
+ context,
9535
+ spinePositionMap,
9536
+ context.overlayTextLinks,
9537
+ MessageId.MED_015,
9538
+ "Media overlay text"
9539
+ );
9540
+ }
9541
+ }
9542
+ checkLinkReadingOrder(context, spinePositionMap, links, messageId, label) {
8142
9543
  let lastSpinePosition = -1;
8143
9544
  let lastAnchorPosition = -1;
8144
- for (const link of context.tocLinks) {
9545
+ for (const link of links) {
8145
9546
  const spinePos = spinePositionMap.get(link.targetResource);
8146
9547
  if (spinePos === void 0) continue;
8147
9548
  if (spinePos < lastSpinePosition) {
8148
9549
  pushMessage(context.messages, {
8149
- id: MessageId.NAV_011,
8150
- message: `"toc" nav must be in reading order; link target "${link.targetResource}" is before the previous link's target in spine order`,
9550
+ id: messageId,
9551
+ message: `${label} must be in reading order; link target "${link.targetResource}" is before the previous link's target in spine order`,
8151
9552
  location: link.location
8152
9553
  });
8153
9554
  lastSpinePosition = spinePos;
@@ -8162,8 +9563,8 @@ var ReferenceValidator = class {
8162
9563
  if (targetAnchorPosition < lastAnchorPosition) {
8163
9564
  const target = link.fragment ? `${link.targetResource}#${link.fragment}` : link.targetResource;
8164
9565
  pushMessage(context.messages, {
8165
- id: MessageId.NAV_011,
8166
- message: `"toc" nav must be in reading order; link target "${target}" is before the previous link's target in document order`,
9566
+ id: messageId,
9567
+ message: `${label} must be in reading order; link target "${target}" is before the previous link's target in document order`,
8167
9568
  location: link.location
8168
9569
  });
8169
9570
  }
@@ -8382,11 +9783,11 @@ var RelaxNGValidator = class extends BaseSchemaValidator {
8382
9783
  try {
8383
9784
  const libxml2 = await import('libxml2-wasm');
8384
9785
  const LibRelaxNGValidator = libxml2.RelaxNGValidator;
8385
- const { XmlDocument: XmlDocument3 } = libxml2;
8386
- const doc = XmlDocument3.fromString(xml);
9786
+ const { XmlDocument: XmlDocument4 } = libxml2;
9787
+ const doc = XmlDocument4.fromString(xml);
8387
9788
  try {
8388
9789
  const schemaContent = await loadSchema(schemaPath);
8389
- const schemaDoc = XmlDocument3.fromString(schemaContent);
9790
+ const schemaDoc = XmlDocument4.fromString(schemaContent);
8390
9791
  try {
8391
9792
  const validator = LibRelaxNGValidator.fromDoc(schemaDoc);
8392
9793
  try {