@likecoin/epubcheck-ts 0.3.8 → 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
 
@@ -1890,6 +2135,17 @@ var ABSOLUTE_URI_RE = /^[a-zA-Z][a-zA-Z0-9+.-]*:/;
1890
2135
  var SPECIAL_URL_SCHEMES = /* @__PURE__ */ new Set(["http", "https", "ftp", "ws", "wss"]);
1891
2136
  var CSS_CHARSET_RE = /^@charset\s+"([^"]+)"\s*;/;
1892
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
+ ]);
1893
2149
  function validateAbsoluteHyperlinkURL(context, href, path, line) {
1894
2150
  const location = line != null ? { path, line } : { path };
1895
2151
  const scheme = href.slice(0, href.indexOf(":")).toLowerCase();
@@ -2209,6 +2465,11 @@ var ContentValidator = class {
2209
2465
  }
2210
2466
  }
2211
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
+ }
2212
2473
  for (const item of packageDoc.manifest) {
2213
2474
  if (item.mediaType === "application/xhtml+xml") {
2214
2475
  const fullPath = resolveManifestHref(opfDir, item.href);
@@ -2224,9 +2485,96 @@ var ContentValidator = class {
2224
2485
  if (refValidator) {
2225
2486
  this.extractSVGReferences(context, fullPath, opfDir, refValidator);
2226
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
+ }
2227
2524
  }
2228
2525
  this.validateMediaFile(context, item, opfDir);
2229
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
+ }
2230
2578
  }
2231
2579
  validateMediaFile(context, item, opfDir) {
2232
2580
  const declaredType = item.mediaType;
@@ -2324,6 +2672,7 @@ var ContentValidator = class {
2324
2672
  });
2325
2673
  }
2326
2674
  }
2675
+ this.checkMediaOverlayActiveClassCSS(context, path, root, manifestItem, svgContent);
2327
2676
  } finally {
2328
2677
  doc.dispose();
2329
2678
  }
@@ -2848,6 +3197,7 @@ var ContentValidator = class {
2848
3197
  this.checkHttpEquivCharset(context, path, root);
2849
3198
  this.checkLangMismatch(context, path, root);
2850
3199
  this.checkDpubAriaDeprecated(context, path, root);
3200
+ this.validateIdRefs(context, path, root);
2851
3201
  this.checkTableBorder(context, path, root);
2852
3202
  this.checkTimeElement(context, path, root);
2853
3203
  this.checkMathMLAnnotations(context, path, root);
@@ -2855,6 +3205,7 @@ var ContentValidator = class {
2855
3205
  this.checkDataAttributes(context, path, root);
2856
3206
  this.checkAccessibility(context, path, root);
2857
3207
  this.validateImages(context, path, root);
3208
+ this.checkUsemapAttribute(context, path, root);
2858
3209
  if (context.version.startsWith("3")) {
2859
3210
  this.validateEpubTypes(context, path, root);
2860
3211
  }
@@ -2867,8 +3218,24 @@ var ContentValidator = class {
2867
3218
  this.extractAndRegisterIDs(path, root, registry);
2868
3219
  }
2869
3220
  if (refValidator && opfDir !== void 0) {
2870
- this.extractAndRegisterHyperlinks(context, path, root, opfDir, refValidator, !!isNavItem);
2871
- this.extractAndRegisterStylesheets(context, 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
+ );
2872
3239
  this.extractAndRegisterImages(context, path, root, opfDir, refValidator, registry);
2873
3240
  this.extractAndRegisterMathMLAltimg(path, root, opfDir, refValidator);
2874
3241
  this.extractAndRegisterScripts(path, root, opfDir, refValidator);
@@ -2883,10 +3250,65 @@ var ContentValidator = class {
2883
3250
  registry
2884
3251
  );
2885
3252
  }
3253
+ this.checkMediaOverlayActiveClassCSS(context, path, root, manifestItem);
2886
3254
  } finally {
2887
3255
  doc.dispose();
2888
3256
  }
2889
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
+ }
2890
3312
  parseLibxmlError(error) {
2891
3313
  const lineRegex = /(?:Entity:\s*)?line\s+(\d+):/;
2892
3314
  const lineMatch = lineRegex.exec(error);
@@ -3627,6 +4049,92 @@ var ContentValidator = class {
3627
4049
  } catch {
3628
4050
  }
3629
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
+ }
3630
4138
  validateEpubSwitch(context, path, root) {
3631
4139
  const EPUB_NS = { epub: "http://www.idpf.org/2007/ops" };
3632
4140
  try {
@@ -3715,15 +4223,7 @@ var ContentValidator = class {
3715
4223
  try {
3716
4224
  const triggers = root.find(".//epub:trigger", EPUB_NS);
3717
4225
  if (triggers.length === 0) return;
3718
- const allIds = /* @__PURE__ */ new Set();
3719
- try {
3720
- const idElements = root.find(".//*[@id]");
3721
- for (const el of idElements) {
3722
- const idAttr = this.getAttribute(el, "id");
3723
- if (idAttr) allIds.add(idAttr);
3724
- }
3725
- } catch {
3726
- }
4226
+ const allIds = this.collectIds(root);
3727
4227
  for (const trigger of triggers) {
3728
4228
  pushMessage(context.messages, {
3729
4229
  id: MessageId.RSC_017,
@@ -3854,6 +4354,22 @@ var ContentValidator = class {
3854
4354
  } catch {
3855
4355
  }
3856
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
+ }
3857
4373
  checkTimeElement(context, path, root) {
3858
4374
  const HTML_NS = { html: "http://www.w3.org/1999/xhtml" };
3859
4375
  try {
@@ -4165,6 +4681,14 @@ var ContentValidator = class {
4165
4681
  const elemTyped = elem;
4166
4682
  const epubTypeAttr = elemTyped.attr("type", "epub");
4167
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
+ }
4168
4692
  for (const part of epubTypeAttr.value.split(/\s+/)) {
4169
4693
  if (!part) continue;
4170
4694
  const hasPrefix = part.includes(":");
@@ -4260,6 +4784,17 @@ var ContentValidator = class {
4260
4784
  const attr = attrs.find((a) => a.name === name);
4261
4785
  return attr?.value ?? null;
4262
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
+ }
4263
4798
  validateViewportMeta(context, path, root, manifestItem) {
4264
4799
  const packageDoc = context.packageDocument;
4265
4800
  const isFixedLayout = manifestItem && packageDoc ? isItemFixedLayout(packageDoc, manifestItem.id) : false;
@@ -4388,7 +4923,7 @@ var ContentValidator = class {
4388
4923
  }
4389
4924
  }
4390
4925
  }
4391
- extractAndRegisterHyperlinks(context, path, root, opfDir, refValidator, isNavDocument = false) {
4926
+ extractAndRegisterHyperlinks(context, path, root, opfDir, refValidator, isNavDocument = false, remoteXmlBase = null) {
4392
4927
  const docDir = path.includes("/") ? path.substring(0, path.lastIndexOf("/")) : "";
4393
4928
  const navAnchorTypes = /* @__PURE__ */ new Map();
4394
4929
  if (isNavDocument) {
@@ -4452,6 +4987,15 @@ var ContentValidator = class {
4452
4987
  });
4453
4988
  continue;
4454
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
+ }
4455
4999
  const resolvedPath = this.resolveRelativePath(docDir, href, opfDir);
4456
5000
  const hashIndex = resolvedPath.indexOf("#");
4457
5001
  const targetResource = hashIndex >= 0 ? resolvedPath.slice(0, hashIndex) : resolvedPath;
@@ -4561,11 +5105,12 @@ var ContentValidator = class {
4561
5105
  refValidator.addReference(svgRef);
4562
5106
  }
4563
5107
  }
4564
- extractAndRegisterStylesheets(context, path, root, opfDir, refValidator) {
5108
+ extractAndRegisterStylesheets(context, path, root, opfDir, refValidator, remoteXmlBase = null) {
4565
5109
  const docDir = path.includes("/") ? path.substring(0, path.lastIndexOf("/")) : "";
4566
5110
  const baseElem = root.get(".//html:base[@href]", { html: "http://www.w3.org/1999/xhtml" });
4567
5111
  const baseHref = baseElem ? this.getAttribute(baseElem, "href") : null;
4568
- const remoteBaseUrl = baseHref?.startsWith("http://") || baseHref?.startsWith("https://") ? baseHref : null;
5112
+ const effectiveBase = baseHref ?? remoteXmlBase;
5113
+ const remoteBaseUrl = effectiveBase?.startsWith("http://") || effectiveBase?.startsWith("https://") ? effectiveBase : null;
4569
5114
  const linkElements = root.find(".//html:link[@href]", { html: "http://www.w3.org/1999/xhtml" });
4570
5115
  for (const linkElem of linkElements) {
4571
5116
  const href = this.getAttribute(linkElem, "href");
@@ -5649,6 +6194,8 @@ var OCFValidator = class {
5649
6194
  this.validateUtf8Filenames(zip, context.messages);
5650
6195
  this.validateEmptyDirectories(zip, context.messages);
5651
6196
  this.parseEncryption(zip, context);
6197
+ this.validateEncryptionXml(context);
6198
+ this.validateSignaturesXml(context);
5652
6199
  }
5653
6200
  /**
5654
6201
  * Validate the mimetype file
@@ -6005,6 +6552,93 @@ var OCFValidator = class {
6005
6552
  context.obfuscatedResources = obfuscated;
6006
6553
  }
6007
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
+ }
6008
6642
  /**
6009
6643
  * Validate empty directories
6010
6644
  */
@@ -6032,6 +6666,65 @@ var OCFValidator = class {
6032
6666
  }
6033
6667
  };
6034
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
+
6035
6728
  // src/opf/parser.ts
6036
6729
  function parseOPF(xml) {
6037
6730
  const packageRegex = /<package[^>]*\sversion=["']([^"']+)["'][^>]*(?:\sunique-identifier=["']([^"']+)["'])?[^>]*>/;
@@ -6382,6 +7075,25 @@ var DEPRECATED_MEDIA_TYPES = /* @__PURE__ */ new Set([
6382
7075
  "application/x-oeb1-package",
6383
7076
  "text/x-oeb1-html"
6384
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
+ }
6385
7097
  var VALID_RELATOR_CODES = /* @__PURE__ */ new Set([
6386
7098
  "abr",
6387
7099
  "acp",
@@ -6718,7 +7430,6 @@ var RENDITION_META_RULES = [
6718
7430
  var KNOWN_RENDITION_META_PROPERTIES = new Set(
6719
7431
  RENDITION_META_RULES.map((r) => r.property.slice("rendition:".length))
6720
7432
  );
6721
- var SMIL3_CLOCK_RE = /^([0-9]+:[0-5][0-9]:[0-5][0-9](\.[0-9]+)?|[0-5][0-9]:[0-5][0-9](\.[0-9]+)?|[0-9]+(\.[0-9]+)?(h|min|s|ms)?)$/;
6722
7433
  var GRANDFATHERED_LANG_TAGS = /* @__PURE__ */ new Set([
6723
7434
  "en-GB-oed",
6724
7435
  "i-ami",
@@ -6772,6 +7483,20 @@ var OPFValidator = class {
6772
7483
  });
6773
7484
  return;
6774
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
+ }
6775
7500
  const opfXml = new TextDecoder().decode(opfData);
6776
7501
  try {
6777
7502
  this.packageDoc = parseOPF(opfXml);
@@ -7063,6 +7788,7 @@ var OPFValidator = class {
7063
7788
  this.validateMetaPropertiesVocab(context, opfPath, dcElements);
7064
7789
  this.validateRenditionVocab(context, opfPath);
7065
7790
  this.validateMediaOverlaysVocab(context, opfPath);
7791
+ this.validateMediaOverlayItems(context, opfPath);
7066
7792
  }
7067
7793
  if (this.packageDoc.version !== "2.0") {
7068
7794
  const modifiedMetas = this.packageDoc.metaElements.filter(
@@ -7430,19 +8156,42 @@ var OPFValidator = class {
7430
8156
  validateMediaOverlaysVocab(context, opfPath) {
7431
8157
  if (!this.packageDoc) return;
7432
8158
  const metas = this.packageDoc.metaElements;
7433
- for (const prop of ["media:active-class", "media:playback-active-class"]) {
7434
- if (metas.filter((m) => m.property === prop).length > 1) {
7435
- const displayName = prop.slice("media:".length);
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) {
7436
8167
  pushMessage(context.messages, {
7437
8168
  id: MessageId.RSC_005,
7438
8169
  message: `The '${displayName}' property must not occur more than one time in the package metadata`,
7439
8170
  location: { path: opfPath }
7440
8171
  });
7441
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
+ }
7442
8189
  }
8190
+ if (matchingActive[0]) context.mediaActiveClass = matchingActive[0].value.trim();
8191
+ if (matchingPlayback[0]) context.mediaPlaybackActiveClass = matchingPlayback[0].value.trim();
7443
8192
  for (const meta of metas) {
7444
8193
  if (meta.property === "media:duration") {
7445
- if (!SMIL3_CLOCK_RE.test(meta.value.trim())) {
8194
+ if (!isValidSmilClock(meta.value.trim())) {
7446
8195
  pushMessage(context.messages, {
7447
8196
  id: MessageId.RSC_005,
7448
8197
  message: `The value of the "media:duration" property must be a valid SMIL3 clock value`,
@@ -7451,6 +8200,84 @@ var OPFValidator = class {
7451
8200
  }
7452
8201
  }
7453
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
+ }
7454
8281
  }
7455
8282
  /**
7456
8283
  * Validate EPUB 3 link elements in metadata
@@ -7683,6 +8510,14 @@ var OPFValidator = class {
7683
8510
  location: { path: opfPath }
7684
8511
  });
7685
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
+ }
7686
8521
  if (this.packageDoc.version !== "2.0" && item.properties) {
7687
8522
  for (const prop of item.properties) {
7688
8523
  if (!ITEM_PROPERTIES.has(prop)) {
@@ -8438,7 +9273,7 @@ var ReferenceValidator = class {
8438
9273
  location: reference.location
8439
9274
  });
8440
9275
  }
8441
- if (!this.registry.hasResource(resourcePath)) {
9276
+ if (reference.type !== "overlay-text-link" /* OVERLAY_TEXT_LINK */ && !this.registry.hasResource(resourcePath)) {
8442
9277
  const fileExistsInContainer = context.files.has(resourcePath);
8443
9278
  if (fileExistsInContainer) {
8444
9279
  if (!context.referencedUndeclaredResources?.has(resourcePath)) {
@@ -8575,6 +9410,24 @@ var ReferenceValidator = class {
8575
9410
  });
8576
9411
  }
8577
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
+ }
8578
9431
  const parsedMimeTypes = ["application/xhtml+xml", "image/svg+xml"];
8579
9432
  if (resource && parsedMimeTypes.includes(resource.mimeType) && !isRemoteURL(resourcePath) && !fragment.includes("svgView(") && reference.type !== "svg-symbol" /* SVG_SYMBOL */) {
8580
9433
  if (!this.registry.hasID(resourcePath, fragment)) {
@@ -8586,6 +9439,18 @@ var ReferenceValidator = class {
8586
9439
  }
8587
9440
  }
8588
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
+ }
8589
9454
  /**
8590
9455
  * Check non-spine remote resources that have non-standard types.
8591
9456
  * Fires RSC-006 for remote items that aren't audio/video/font types
@@ -8645,7 +9510,7 @@ var ReferenceValidator = class {
8645
9510
  }
8646
9511
  }
8647
9512
  checkReadingOrder(context) {
8648
- if (!context.tocLinks || !context.packageDocument) return;
9513
+ if (!context.packageDocument) return;
8649
9514
  const packageDoc = context.packageDocument;
8650
9515
  const spine = packageDoc.spine;
8651
9516
  const opfPath = context.opfPath ?? "";
@@ -8657,15 +9522,35 @@ var ReferenceValidator = class {
8657
9522
  spinePositionMap.set(resolveManifestHref(opfDir, item.href), i);
8658
9523
  }
8659
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) {
8660
9545
  let lastSpinePosition = -1;
8661
9546
  let lastAnchorPosition = -1;
8662
- for (const link of context.tocLinks) {
9547
+ for (const link of links) {
8663
9548
  const spinePos = spinePositionMap.get(link.targetResource);
8664
9549
  if (spinePos === void 0) continue;
8665
9550
  if (spinePos < lastSpinePosition) {
8666
9551
  pushMessage(context.messages, {
8667
- id: MessageId.NAV_011,
8668
- 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`,
8669
9554
  location: link.location
8670
9555
  });
8671
9556
  lastSpinePosition = spinePos;
@@ -8680,8 +9565,8 @@ var ReferenceValidator = class {
8680
9565
  if (targetAnchorPosition < lastAnchorPosition) {
8681
9566
  const target = link.fragment ? `${link.targetResource}#${link.fragment}` : link.targetResource;
8682
9567
  pushMessage(context.messages, {
8683
- id: MessageId.NAV_011,
8684
- 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`,
8685
9570
  location: link.location
8686
9571
  });
8687
9572
  }
@@ -8900,11 +9785,11 @@ var RelaxNGValidator = class extends BaseSchemaValidator {
8900
9785
  try {
8901
9786
  const libxml2 = await import('libxml2-wasm');
8902
9787
  const LibRelaxNGValidator = libxml2.RelaxNGValidator;
8903
- const { XmlDocument: XmlDocument3 } = libxml2;
8904
- const doc = XmlDocument3.fromString(xml);
9788
+ const { XmlDocument: XmlDocument4 } = libxml2;
9789
+ const doc = XmlDocument4.fromString(xml);
8905
9790
  try {
8906
9791
  const schemaContent = await loadSchema(schemaPath);
8907
- const schemaDoc = XmlDocument3.fromString(schemaContent);
9792
+ const schemaDoc = XmlDocument4.fromString(schemaContent);
8908
9793
  try {
8909
9794
  const validator = LibRelaxNGValidator.fromDoc(schemaDoc);
8910
9795
  try {