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