@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.js CHANGED
@@ -1602,31 +1602,276 @@ var CSSValidator = class {
1602
1602
  * Check for reserved media overlay class names
1603
1603
  */
1604
1604
  checkMediaOverlayClasses(context, ast, resourcePath) {
1605
- const reservedClassNames = /* @__PURE__ */ new Set([
1606
- "-epub-media-overlay-active",
1607
- "media-overlay-active",
1608
- "-epub-media-overlay-playing",
1609
- "media-overlay-playing"
1610
- ]);
1605
+ const activeClassNames = /* @__PURE__ */ new Set(["-epub-media-overlay-active", "media-overlay-active"]);
1606
+ const playbackClassNames = /* @__PURE__ */ new Set(["-epub-media-overlay-playing", "media-overlay-playing"]);
1611
1607
  walk(ast, (node) => {
1612
1608
  if (node.type === "ClassSelector") {
1613
1609
  const className = node.name.toLowerCase();
1614
- if (reservedClassNames.has(className)) {
1615
- const loc = node.loc;
1616
- const start = loc?.start;
1617
- const location = { path: resourcePath };
1618
- if (start) {
1619
- location.line = start.line;
1620
- location.column = start.column;
1621
- }
1610
+ const isActive = activeClassNames.has(className);
1611
+ const isPlayback = playbackClassNames.has(className);
1612
+ if (!isActive && !isPlayback) return;
1613
+ const isDeclared = isActive ? !!context.mediaActiveClass : !!context.mediaPlaybackActiveClass;
1614
+ if (isDeclared) return;
1615
+ const loc = node.loc;
1616
+ const start = loc?.start;
1617
+ const location = { path: resourcePath };
1618
+ if (start) {
1619
+ location.line = start.line;
1620
+ location.column = start.column;
1621
+ }
1622
+ const property = isActive ? "media:active-class" : "media:playback-active-class";
1623
+ pushMessage(context.messages, {
1624
+ id: MessageId.CSS_029,
1625
+ message: `Class name "${className}" is reserved for media overlays but "${property}" is not declared in the package document`,
1626
+ location
1627
+ });
1628
+ }
1629
+ });
1630
+ }
1631
+ };
1632
+
1633
+ // src/smil/clock.ts
1634
+ var FULL_CLOCK_RE = /^(\d+):([0-5]\d):([0-5]\d)(\.\d+)?$/;
1635
+ var PARTIAL_CLOCK_RE = /^([0-5]\d):([0-5]\d)(\.\d+)?$/;
1636
+ var TIMECOUNT_RE = /^(\d+(\.\d+)?)(h|min|s|ms)?$/;
1637
+ function parseSmilClock(value) {
1638
+ const trimmed = value.trim();
1639
+ const full = FULL_CLOCK_RE.exec(trimmed);
1640
+ if (full) {
1641
+ const hours = Number.parseInt(full[1] ?? "0", 10);
1642
+ const minutes = Number.parseInt(full[2] ?? "0", 10);
1643
+ const seconds = Number.parseInt(full[3] ?? "0", 10);
1644
+ const frac = full[4] ? Number.parseFloat(full[4]) : 0;
1645
+ return hours * 3600 + minutes * 60 + seconds + frac;
1646
+ }
1647
+ const partial = PARTIAL_CLOCK_RE.exec(trimmed);
1648
+ if (partial) {
1649
+ const minutes = Number.parseInt(partial[1] ?? "0", 10);
1650
+ const seconds = Number.parseInt(partial[2] ?? "0", 10);
1651
+ const frac = partial[3] ? Number.parseFloat(partial[3]) : 0;
1652
+ return minutes * 60 + seconds + frac;
1653
+ }
1654
+ const timecount = TIMECOUNT_RE.exec(trimmed);
1655
+ if (timecount) {
1656
+ const num = Number.parseFloat(timecount[1] ?? "0");
1657
+ const unit = timecount[3] ?? "s";
1658
+ switch (unit) {
1659
+ case "h":
1660
+ return num * 3600;
1661
+ case "min":
1662
+ return num * 60;
1663
+ case "s":
1664
+ return num;
1665
+ case "ms":
1666
+ return num / 1e3;
1667
+ default:
1668
+ return NaN;
1669
+ }
1670
+ }
1671
+ return NaN;
1672
+ }
1673
+ function isValidSmilClock(value) {
1674
+ return !Number.isNaN(parseSmilClock(value));
1675
+ }
1676
+
1677
+ // src/smil/validator.ts
1678
+ var SMIL_NS = { smil: "http://www.w3.org/ns/SMIL" };
1679
+ var BLESSED_AUDIO_TYPES = /* @__PURE__ */ new Set(["audio/mpeg", "audio/mp4"]);
1680
+ var BLESSED_AUDIO_PATTERN = /^audio\/ogg\s*;\s*codecs=opus$/i;
1681
+ function isBlessedAudioType(mimeType) {
1682
+ return BLESSED_AUDIO_TYPES.has(mimeType) || BLESSED_AUDIO_PATTERN.test(mimeType);
1683
+ }
1684
+ var SMILValidator = class {
1685
+ getAttribute(element, name) {
1686
+ return element.attr(name)?.value ?? null;
1687
+ }
1688
+ getEpubAttribute(element, localName) {
1689
+ return element.attr(localName, "epub")?.value ?? null;
1690
+ }
1691
+ validate(context, path, manifestByPath) {
1692
+ const result = {
1693
+ textReferences: [],
1694
+ referencedDocuments: /* @__PURE__ */ new Set(),
1695
+ hasRemoteResources: false
1696
+ };
1697
+ const data = context.files.get(path);
1698
+ if (!data) return result;
1699
+ const content = typeof data === "string" ? data : new TextDecoder().decode(data);
1700
+ let doc = null;
1701
+ try {
1702
+ doc = XmlDocument.fromString(content);
1703
+ } catch {
1704
+ pushMessage(context.messages, {
1705
+ id: MessageId.RSC_016,
1706
+ message: "Media Overlay document is not well-formed XML",
1707
+ location: { path }
1708
+ });
1709
+ return result;
1710
+ }
1711
+ try {
1712
+ const root = doc.root;
1713
+ this.validateStructure(context, path, root);
1714
+ this.validateAudioElements(context, path, root, manifestByPath, result);
1715
+ this.extractTextReferences(path, root, result);
1716
+ } finally {
1717
+ doc.dispose();
1718
+ }
1719
+ return result;
1720
+ }
1721
+ validateStructure(context, path, root) {
1722
+ try {
1723
+ for (const text of root.find(".//smil:seq/smil:text", SMIL_NS)) {
1724
+ pushMessage(context.messages, {
1725
+ id: MessageId.RSC_005,
1726
+ message: "element 'text' not allowed here; expected 'seq' or 'par'",
1727
+ location: { path, line: text.line }
1728
+ });
1729
+ }
1730
+ for (const audio of root.find(".//smil:seq/smil:audio", SMIL_NS)) {
1731
+ pushMessage(context.messages, {
1732
+ id: MessageId.RSC_005,
1733
+ message: "element 'audio' not allowed here; expected 'seq' or 'par'",
1734
+ location: { path, line: audio.line }
1735
+ });
1736
+ }
1737
+ } catch {
1738
+ }
1739
+ try {
1740
+ for (const seq of root.find(".//smil:par/smil:seq", SMIL_NS)) {
1741
+ pushMessage(context.messages, {
1742
+ id: MessageId.RSC_005,
1743
+ message: "element 'seq' not allowed here; expected 'text' or 'audio'",
1744
+ location: { path, line: seq.line }
1745
+ });
1746
+ }
1747
+ const parElements = root.find(".//smil:par", SMIL_NS);
1748
+ for (const par of parElements) {
1749
+ const textChildren = par.find("./smil:text", SMIL_NS);
1750
+ for (let i = 1; i < textChildren.length; i++) {
1751
+ const extra = textChildren[i];
1752
+ if (!extra) continue;
1622
1753
  pushMessage(context.messages, {
1623
- id: MessageId.CSS_029,
1624
- message: `Class name "${className}" is reserved for media overlays`,
1625
- location
1754
+ id: MessageId.RSC_005,
1755
+ message: "element 'text' not allowed here; only one 'text' element is allowed in 'par'",
1756
+ location: { path, line: extra.line }
1626
1757
  });
1627
1758
  }
1628
1759
  }
1629
- });
1760
+ } catch {
1761
+ }
1762
+ try {
1763
+ const headMetaElements = root.find(".//smil:head/smil:meta", SMIL_NS);
1764
+ for (const meta of headMetaElements) {
1765
+ pushMessage(context.messages, {
1766
+ id: MessageId.RSC_005,
1767
+ message: "element 'meta' not allowed here; expected 'metadata'",
1768
+ location: { path, line: meta.line }
1769
+ });
1770
+ }
1771
+ } catch {
1772
+ }
1773
+ }
1774
+ validateAudioElements(context, path, root, manifestByPath, result) {
1775
+ try {
1776
+ const audioElements = root.find(".//smil:audio", SMIL_NS);
1777
+ for (const audio of audioElements) {
1778
+ const elem = audio;
1779
+ const src = this.getAttribute(elem, "src");
1780
+ if (src) {
1781
+ if (/^https?:\/\//i.test(src)) {
1782
+ result.hasRemoteResources = true;
1783
+ }
1784
+ if (src.includes("#")) {
1785
+ pushMessage(context.messages, {
1786
+ id: MessageId.MED_014,
1787
+ message: `Media overlay audio file URLs must not have a fragment: "${src}"`,
1788
+ location: { path, line: audio.line }
1789
+ });
1790
+ }
1791
+ if (manifestByPath) {
1792
+ const audioPath = this.resolveRelativePath(path, src.split("#")[0] ?? src);
1793
+ const audioItem = manifestByPath.get(audioPath);
1794
+ if (audioItem && !isBlessedAudioType(audioItem.mediaType)) {
1795
+ pushMessage(context.messages, {
1796
+ id: MessageId.MED_005,
1797
+ message: `Media Overlay audio reference "${src}" to non-standard audio type "${audioItem.mediaType}"`,
1798
+ location: { path, line: audio.line }
1799
+ });
1800
+ }
1801
+ }
1802
+ }
1803
+ const clipBegin = this.getAttribute(elem, "clipBegin");
1804
+ const clipEnd = this.getAttribute(elem, "clipEnd");
1805
+ this.checkClipTiming(context, path, audio.line, clipBegin, clipEnd);
1806
+ }
1807
+ } catch {
1808
+ }
1809
+ }
1810
+ checkClipTiming(context, path, line, clipBegin, clipEnd) {
1811
+ if (clipEnd === null) return;
1812
+ const beginStr = clipBegin ?? "0";
1813
+ const start = parseSmilClock(beginStr);
1814
+ const end = parseSmilClock(clipEnd);
1815
+ if (Number.isNaN(start) || Number.isNaN(end)) return;
1816
+ const location = line != null ? { path, line } : { path };
1817
+ if (start > end) {
1818
+ pushMessage(context.messages, {
1819
+ id: MessageId.MED_008,
1820
+ message: "The time specified in the clipBegin attribute must not be after clipEnd",
1821
+ location
1822
+ });
1823
+ } else if (start === end) {
1824
+ pushMessage(context.messages, {
1825
+ id: MessageId.MED_009,
1826
+ message: "The time specified in the clipBegin attribute must not be the same as clipEnd",
1827
+ location
1828
+ });
1829
+ }
1830
+ }
1831
+ extractTextReferences(path, root, result) {
1832
+ try {
1833
+ const textElements = root.find(".//smil:text", SMIL_NS);
1834
+ for (const text of textElements) {
1835
+ const src = this.getAttribute(text, "src");
1836
+ if (!src) continue;
1837
+ const hashIndex = src.indexOf("#");
1838
+ const docRef = hashIndex >= 0 ? src.substring(0, hashIndex) : src;
1839
+ const fragment = hashIndex >= 0 ? src.substring(hashIndex + 1) : void 0;
1840
+ const docPath = this.resolveRelativePath(path, docRef);
1841
+ result.textReferences.push({ docPath, fragment, line: text.line });
1842
+ result.referencedDocuments.add(docPath);
1843
+ }
1844
+ const bodyElements = root.find(".//smil:body", SMIL_NS);
1845
+ const seqElements = root.find(".//smil:seq", SMIL_NS);
1846
+ for (const elem of [...bodyElements, ...seqElements]) {
1847
+ const textref = this.getEpubAttribute(elem, "textref");
1848
+ if (!textref) continue;
1849
+ const hashIndex = textref.indexOf("#");
1850
+ const docRef = hashIndex >= 0 ? textref.substring(0, hashIndex) : textref;
1851
+ const fragment = hashIndex >= 0 ? textref.substring(hashIndex + 1) : void 0;
1852
+ const docPath = this.resolveRelativePath(path, docRef);
1853
+ result.textReferences.push({ docPath, fragment, line: elem.line });
1854
+ result.referencedDocuments.add(docPath);
1855
+ }
1856
+ } catch {
1857
+ }
1858
+ }
1859
+ resolveRelativePath(basePath, relativePath) {
1860
+ if (relativePath.startsWith("/") || /^[a-zA-Z]+:/.test(relativePath)) {
1861
+ return relativePath;
1862
+ }
1863
+ const baseDir = basePath.includes("/") ? basePath.substring(0, basePath.lastIndexOf("/")) : "";
1864
+ if (!baseDir) return relativePath;
1865
+ const segments = `${baseDir}/${relativePath}`.split("/");
1866
+ const resolved = [];
1867
+ for (const seg of segments) {
1868
+ if (seg === "..") {
1869
+ resolved.pop();
1870
+ } else if (seg !== ".") {
1871
+ resolved.push(seg);
1872
+ }
1873
+ }
1874
+ return resolved.join("/");
1630
1875
  }
1631
1876
  };
1632
1877
 
@@ -1888,6 +2133,17 @@ var ABSOLUTE_URI_RE = /^[a-zA-Z][a-zA-Z0-9+.-]*:/;
1888
2133
  var SPECIAL_URL_SCHEMES = /* @__PURE__ */ new Set(["http", "https", "ftp", "ws", "wss"]);
1889
2134
  var CSS_CHARSET_RE = /^@charset\s+"([^"]+)"\s*;/;
1890
2135
  var EPUB_XMLNS_RE = /xmlns:epub\s*=\s*"([^"]*)"/;
2136
+ var XHTML_NS = { html: "http://www.w3.org/1999/xhtml" };
2137
+ var EPUB_TYPE_FORBIDDEN_ELEMENTS = /* @__PURE__ */ new Set([
2138
+ "head",
2139
+ "meta",
2140
+ "title",
2141
+ "style",
2142
+ "link",
2143
+ "script",
2144
+ "noscript",
2145
+ "base"
2146
+ ]);
1891
2147
  function validateAbsoluteHyperlinkURL(context, href, path, line) {
1892
2148
  const location = line != null ? { path, line } : { path };
1893
2149
  const scheme = href.slice(0, href.indexOf(":")).toLowerCase();
@@ -2207,6 +2463,11 @@ var ContentValidator = class {
2207
2463
  }
2208
2464
  }
2209
2465
  }
2466
+ const overlayDocMap = /* @__PURE__ */ new Map();
2467
+ const manifestByPath = /* @__PURE__ */ new Map();
2468
+ for (const item of packageDoc.manifest) {
2469
+ manifestByPath.set(resolveManifestHref(opfDir, item.href), item);
2470
+ }
2210
2471
  for (const item of packageDoc.manifest) {
2211
2472
  if (item.mediaType === "application/xhtml+xml") {
2212
2473
  const fullPath = resolveManifestHref(opfDir, item.href);
@@ -2222,9 +2483,96 @@ var ContentValidator = class {
2222
2483
  if (refValidator) {
2223
2484
  this.extractSVGReferences(context, fullPath, opfDir, refValidator);
2224
2485
  }
2486
+ } else if (item.mediaType === "application/smil+xml") {
2487
+ const fullPath = resolveManifestHref(opfDir, item.href);
2488
+ const smilValidator = new SMILValidator();
2489
+ const result = smilValidator.validate(context, fullPath, manifestByPath);
2490
+ overlayDocMap.set(item.id, result.referencedDocuments);
2491
+ if (refValidator) {
2492
+ for (const textRef of result.textReferences) {
2493
+ const refUrl = textRef.fragment ? `${textRef.docPath}#${textRef.fragment}` : textRef.docPath;
2494
+ const location = textRef.line != null ? { path: fullPath, line: textRef.line } : { path: fullPath };
2495
+ const ref = {
2496
+ url: refUrl,
2497
+ targetResource: textRef.docPath,
2498
+ type: "overlay-text-link" /* OVERLAY_TEXT_LINK */,
2499
+ location
2500
+ };
2501
+ if (textRef.fragment !== void 0) ref.fragment = textRef.fragment;
2502
+ refValidator.addReference(ref);
2503
+ context.overlayTextLinks ??= [];
2504
+ const link = {
2505
+ targetResource: textRef.docPath,
2506
+ location
2507
+ };
2508
+ if (textRef.fragment !== void 0) link.fragment = textRef.fragment;
2509
+ context.overlayTextLinks.push(link);
2510
+ }
2511
+ }
2512
+ if (result.hasRemoteResources) {
2513
+ const properties = item.properties ?? [];
2514
+ if (!properties.includes("remote-resources")) {
2515
+ pushMessage(context.messages, {
2516
+ id: MessageId.OPF_014,
2517
+ message: `The "remote-resources" property must be set on the media overlay item "${item.href}" because it references remote audio resources`,
2518
+ location: { path: context.opfPath ?? "" }
2519
+ });
2520
+ }
2521
+ }
2225
2522
  }
2226
2523
  this.validateMediaFile(context, item, opfDir);
2227
2524
  }
2525
+ this.validateMediaOverlayCrossRefs(context, packageDoc, opfDir, overlayDocMap);
2526
+ }
2527
+ validateMediaOverlayCrossRefs(context, packageDoc, opfDir, overlayDocMap) {
2528
+ if (overlayDocMap.size === 0) return;
2529
+ const docToOverlays = /* @__PURE__ */ new Map();
2530
+ for (const [overlayId, docPaths] of overlayDocMap) {
2531
+ for (const docPath of docPaths) {
2532
+ const existing = docToOverlays.get(docPath) ?? [];
2533
+ existing.push(overlayId);
2534
+ docToOverlays.set(docPath, existing);
2535
+ }
2536
+ }
2537
+ const opfPath = context.opfPath ?? "";
2538
+ for (const item of packageDoc.manifest) {
2539
+ if (item.mediaType !== "application/xhtml+xml" && item.mediaType !== "image/svg+xml") {
2540
+ continue;
2541
+ }
2542
+ const fullPath = resolveManifestHref(opfDir, item.href);
2543
+ const referencingOverlays = docToOverlays.get(fullPath);
2544
+ if (referencingOverlays && referencingOverlays.length > 0) {
2545
+ if (referencingOverlays.length > 1) {
2546
+ pushMessage(context.messages, {
2547
+ id: MessageId.MED_011,
2548
+ message: `EPUB Content Document "${item.href}" referenced from multiple Media Overlay Documents`,
2549
+ location: { path: opfPath }
2550
+ });
2551
+ }
2552
+ if (!item.mediaOverlay) {
2553
+ pushMessage(context.messages, {
2554
+ id: MessageId.MED_010,
2555
+ message: `EPUB Content Document "${item.href}" referenced from a Media Overlay must specify the "media-overlay" attribute`,
2556
+ location: { path: opfPath }
2557
+ });
2558
+ } else if (!referencingOverlays.includes(item.mediaOverlay)) {
2559
+ pushMessage(context.messages, {
2560
+ id: MessageId.MED_012,
2561
+ message: `The "media-overlay" attribute does not match the ID of the Media Overlay that refers to this document`,
2562
+ location: { path: opfPath }
2563
+ });
2564
+ }
2565
+ } else if (item.mediaOverlay) {
2566
+ const overlayDocs = overlayDocMap.get(item.mediaOverlay);
2567
+ if (overlayDocs && !overlayDocs.has(fullPath)) {
2568
+ pushMessage(context.messages, {
2569
+ id: MessageId.MED_013,
2570
+ message: `Media Overlay Document referenced from the "media-overlay" attribute does not contain a reference to this Content Document`,
2571
+ location: { path: opfPath }
2572
+ });
2573
+ }
2574
+ }
2575
+ }
2228
2576
  }
2229
2577
  validateMediaFile(context, item, opfDir) {
2230
2578
  const declaredType = item.mediaType;
@@ -2322,6 +2670,7 @@ var ContentValidator = class {
2322
2670
  });
2323
2671
  }
2324
2672
  }
2673
+ this.checkMediaOverlayActiveClassCSS(context, path, root, manifestItem, svgContent);
2325
2674
  } finally {
2326
2675
  doc.dispose();
2327
2676
  }
@@ -2846,6 +3195,7 @@ var ContentValidator = class {
2846
3195
  this.checkHttpEquivCharset(context, path, root);
2847
3196
  this.checkLangMismatch(context, path, root);
2848
3197
  this.checkDpubAriaDeprecated(context, path, root);
3198
+ this.validateIdRefs(context, path, root);
2849
3199
  this.checkTableBorder(context, path, root);
2850
3200
  this.checkTimeElement(context, path, root);
2851
3201
  this.checkMathMLAnnotations(context, path, root);
@@ -2853,6 +3203,7 @@ var ContentValidator = class {
2853
3203
  this.checkDataAttributes(context, path, root);
2854
3204
  this.checkAccessibility(context, path, root);
2855
3205
  this.validateImages(context, path, root);
3206
+ this.checkUsemapAttribute(context, path, root);
2856
3207
  if (context.version.startsWith("3")) {
2857
3208
  this.validateEpubTypes(context, path, root);
2858
3209
  }
@@ -2865,8 +3216,24 @@ var ContentValidator = class {
2865
3216
  this.extractAndRegisterIDs(path, root, registry);
2866
3217
  }
2867
3218
  if (refValidator && opfDir !== void 0) {
2868
- this.extractAndRegisterHyperlinks(context, path, root, opfDir, refValidator, !!isNavItem);
2869
- this.extractAndRegisterStylesheets(context, path, root, opfDir, refValidator);
3219
+ const remoteXmlBase = this.getRemoteXmlBase(root);
3220
+ this.extractAndRegisterHyperlinks(
3221
+ context,
3222
+ path,
3223
+ root,
3224
+ opfDir,
3225
+ refValidator,
3226
+ !!isNavItem,
3227
+ remoteXmlBase
3228
+ );
3229
+ this.extractAndRegisterStylesheets(
3230
+ context,
3231
+ path,
3232
+ root,
3233
+ opfDir,
3234
+ refValidator,
3235
+ remoteXmlBase
3236
+ );
2870
3237
  this.extractAndRegisterImages(context, path, root, opfDir, refValidator, registry);
2871
3238
  this.extractAndRegisterMathMLAltimg(path, root, opfDir, refValidator);
2872
3239
  this.extractAndRegisterScripts(path, root, opfDir, refValidator);
@@ -2881,10 +3248,65 @@ var ContentValidator = class {
2881
3248
  registry
2882
3249
  );
2883
3250
  }
3251
+ this.checkMediaOverlayActiveClassCSS(context, path, root, manifestItem);
2884
3252
  } finally {
2885
3253
  doc.dispose();
2886
3254
  }
2887
3255
  }
3256
+ /**
3257
+ * CSS-030: If media:active-class or media:playback-active-class is declared in OPF,
3258
+ * and this content document has a media-overlay, it must have at least some CSS.
3259
+ */
3260
+ checkMediaOverlayActiveClassCSS(context, path, root, manifestItem, decodedContent) {
3261
+ if (!manifestItem?.mediaOverlay) return;
3262
+ if (!context.mediaActiveClass && !context.mediaPlaybackActiveClass) return;
3263
+ const isSVG = root.name === "svg" || root.name.endsWith(":svg");
3264
+ let hasCSS = false;
3265
+ if (isSVG) {
3266
+ try {
3267
+ const styles = root.find(".//svg:style", { svg: "http://www.w3.org/2000/svg" });
3268
+ if (styles.length > 0) hasCSS = true;
3269
+ } catch {
3270
+ }
3271
+ if (!hasCSS) {
3272
+ try {
3273
+ const links = root.find(".//html:link", XHTML_NS);
3274
+ if (links.length > 0) hasCSS = true;
3275
+ } catch {
3276
+ }
3277
+ }
3278
+ if (!hasCSS) {
3279
+ const content = decodedContent ?? new TextDecoder().decode(context.files.get(path));
3280
+ if (content.includes("<?xml-stylesheet")) hasCSS = true;
3281
+ }
3282
+ } else {
3283
+ try {
3284
+ const links = root.find(".//html:link[@rel]", XHTML_NS);
3285
+ for (const link of links) {
3286
+ const rel = this.getAttribute(link, "rel");
3287
+ if (rel?.toLowerCase().includes("stylesheet")) {
3288
+ hasCSS = true;
3289
+ break;
3290
+ }
3291
+ }
3292
+ } catch {
3293
+ }
3294
+ if (!hasCSS) {
3295
+ try {
3296
+ const styles = root.find(".//html:style", XHTML_NS);
3297
+ if (styles.length > 0) hasCSS = true;
3298
+ } catch {
3299
+ }
3300
+ }
3301
+ }
3302
+ if (!hasCSS) {
3303
+ pushMessage(context.messages, {
3304
+ id: MessageId.CSS_030,
3305
+ message: 'The "media:active-class" property is declared in the package document but no CSS was found in this content document',
3306
+ location: { path }
3307
+ });
3308
+ }
3309
+ }
2888
3310
  parseLibxmlError(error) {
2889
3311
  const lineRegex = /(?:Entity:\s*)?line\s+(\d+):/;
2890
3312
  const lineMatch = lineRegex.exec(error);
@@ -3625,6 +4047,92 @@ var ContentValidator = class {
3625
4047
  } catch {
3626
4048
  }
3627
4049
  }
4050
+ collectIds(root) {
4051
+ const ids = /* @__PURE__ */ new Set();
4052
+ try {
4053
+ for (const el of root.find(".//*[@id]")) {
4054
+ const id = this.getAttribute(el, "id");
4055
+ if (id) ids.add(id);
4056
+ }
4057
+ } catch {
4058
+ }
4059
+ return ids;
4060
+ }
4061
+ validateIdRefs(context, path, root) {
4062
+ try {
4063
+ const allIds = this.collectIds(root);
4064
+ const HTML_NS = { html: "http://www.w3.org/1999/xhtml" };
4065
+ const idrefsChecks = [
4066
+ { xpath: ".//*[@aria-describedby]", attr: "aria-describedby" },
4067
+ { xpath: ".//*[@aria-flowto]", attr: "aria-flowto" },
4068
+ { xpath: ".//*[@aria-labelledby]", attr: "aria-labelledby" },
4069
+ { xpath: ".//*[@aria-owns]", attr: "aria-owns" },
4070
+ { xpath: ".//*[@aria-controls]", attr: "aria-controls" },
4071
+ { xpath: ".//html:output[@for]", attr: "for", ns: HTML_NS },
4072
+ {
4073
+ xpath: ".//html:td[@headers] | .//html:th[@headers]",
4074
+ attr: "headers",
4075
+ ns: HTML_NS
4076
+ }
4077
+ ];
4078
+ for (const { xpath, attr, ns } of idrefsChecks) {
4079
+ const elements = ns ? root.find(xpath, ns) : root.find(xpath);
4080
+ for (const elem of elements) {
4081
+ const value = this.getAttribute(elem, attr);
4082
+ if (!value) continue;
4083
+ const idrefs = value.trim().split(/\s+/);
4084
+ if (idrefs.some((idref) => !allIds.has(idref))) {
4085
+ pushMessage(context.messages, {
4086
+ id: MessageId.RSC_005,
4087
+ message: `The ${attr} attribute must refer to elements in the same document (target ID missing)`,
4088
+ location: { path, line: elem.line }
4089
+ });
4090
+ }
4091
+ }
4092
+ }
4093
+ const activedescMsg = "The aria-activedescendant attribute must refer to a descendant element.";
4094
+ for (const elem of root.find(".//*[@aria-activedescendant]")) {
4095
+ const idref = this.getAttribute(elem, "aria-activedescendant");
4096
+ if (!idref) continue;
4097
+ if (!allIds.has(idref)) {
4098
+ pushMessage(context.messages, {
4099
+ id: MessageId.RSC_005,
4100
+ message: activedescMsg,
4101
+ location: { path, line: elem.line }
4102
+ });
4103
+ } else {
4104
+ try {
4105
+ if (elem.find(`.//*[@id="${idref}"]`).length === 0) {
4106
+ pushMessage(context.messages, {
4107
+ id: MessageId.RSC_005,
4108
+ message: activedescMsg,
4109
+ location: { path, line: elem.line }
4110
+ });
4111
+ }
4112
+ } catch {
4113
+ }
4114
+ }
4115
+ }
4116
+ for (const elem of root.find(".//*[@aria-describedat]")) {
4117
+ pushMessage(context.messages, {
4118
+ id: MessageId.RSC_005,
4119
+ message: 'attribute "aria-describedat" not allowed here',
4120
+ location: { path, line: elem.line }
4121
+ });
4122
+ }
4123
+ for (const elem of root.find(".//html:label[@for]", HTML_NS)) {
4124
+ const idref = this.getAttribute(elem, "for");
4125
+ if (idref && !allIds.has(idref)) {
4126
+ pushMessage(context.messages, {
4127
+ id: MessageId.RSC_005,
4128
+ message: `The for attribute must refer to an element in the same document (the ID "${idref}" does not exist).`,
4129
+ location: { path, line: elem.line }
4130
+ });
4131
+ }
4132
+ }
4133
+ } catch {
4134
+ }
4135
+ }
3628
4136
  validateEpubSwitch(context, path, root) {
3629
4137
  const EPUB_NS = { epub: "http://www.idpf.org/2007/ops" };
3630
4138
  try {
@@ -3713,15 +4221,7 @@ var ContentValidator = class {
3713
4221
  try {
3714
4222
  const triggers = root.find(".//epub:trigger", EPUB_NS);
3715
4223
  if (triggers.length === 0) return;
3716
- const allIds = /* @__PURE__ */ new Set();
3717
- try {
3718
- const idElements = root.find(".//*[@id]");
3719
- for (const el of idElements) {
3720
- const idAttr = this.getAttribute(el, "id");
3721
- if (idAttr) allIds.add(idAttr);
3722
- }
3723
- } catch {
3724
- }
4224
+ const allIds = this.collectIds(root);
3725
4225
  for (const trigger of triggers) {
3726
4226
  pushMessage(context.messages, {
3727
4227
  id: MessageId.RSC_017,
@@ -3852,6 +4352,22 @@ var ContentValidator = class {
3852
4352
  } catch {
3853
4353
  }
3854
4354
  }
4355
+ checkUsemapAttribute(context, path, root) {
4356
+ try {
4357
+ const elements = root.find(".//html:*[@usemap]", XHTML_NS);
4358
+ for (const elem of elements) {
4359
+ const usemap = this.getAttribute(elem, "usemap");
4360
+ if (usemap !== null && !/^#.+$/.test(usemap)) {
4361
+ pushMessage(context.messages, {
4362
+ id: MessageId.RSC_005,
4363
+ message: `value of attribute "usemap" is invalid; must be a string matching the regular expression "#.+"`,
4364
+ location: { path, line: elem.line }
4365
+ });
4366
+ }
4367
+ }
4368
+ } catch {
4369
+ }
4370
+ }
3855
4371
  checkTimeElement(context, path, root) {
3856
4372
  const HTML_NS = { html: "http://www.w3.org/1999/xhtml" };
3857
4373
  try {
@@ -4163,6 +4679,14 @@ var ContentValidator = class {
4163
4679
  const elemTyped = elem;
4164
4680
  const epubTypeAttr = elemTyped.attr("type", "epub");
4165
4681
  if (!epubTypeAttr?.value) continue;
4682
+ if (EPUB_TYPE_FORBIDDEN_ELEMENTS.has(elemTyped.name)) {
4683
+ pushMessage(context.messages, {
4684
+ id: MessageId.RSC_005,
4685
+ message: `attribute "epub:type" not allowed here`,
4686
+ location: { path, line: elem.line }
4687
+ });
4688
+ continue;
4689
+ }
4166
4690
  for (const part of epubTypeAttr.value.split(/\s+/)) {
4167
4691
  if (!part) continue;
4168
4692
  const hasPrefix = part.includes(":");
@@ -4258,6 +4782,17 @@ var ContentValidator = class {
4258
4782
  const attr = attrs.find((a) => a.name === name);
4259
4783
  return attr?.value ?? null;
4260
4784
  }
4785
+ /**
4786
+ * Get remote xml:base URL from the document root element.
4787
+ * Returns the URL if it's remote (http/https), or null otherwise.
4788
+ */
4789
+ getRemoteXmlBase(root) {
4790
+ const xmlBase = root.attr("base", "xml")?.value ?? null;
4791
+ if (xmlBase?.startsWith("http://") || xmlBase?.startsWith("https://")) {
4792
+ return xmlBase;
4793
+ }
4794
+ return null;
4795
+ }
4261
4796
  validateViewportMeta(context, path, root, manifestItem) {
4262
4797
  const packageDoc = context.packageDocument;
4263
4798
  const isFixedLayout = manifestItem && packageDoc ? isItemFixedLayout(packageDoc, manifestItem.id) : false;
@@ -4386,7 +4921,7 @@ var ContentValidator = class {
4386
4921
  }
4387
4922
  }
4388
4923
  }
4389
- extractAndRegisterHyperlinks(context, path, root, opfDir, refValidator, isNavDocument = false) {
4924
+ extractAndRegisterHyperlinks(context, path, root, opfDir, refValidator, isNavDocument = false, remoteXmlBase = null) {
4390
4925
  const docDir = path.includes("/") ? path.substring(0, path.lastIndexOf("/")) : "";
4391
4926
  const navAnchorTypes = /* @__PURE__ */ new Map();
4392
4927
  if (isNavDocument) {
@@ -4450,6 +4985,15 @@ var ContentValidator = class {
4450
4985
  });
4451
4986
  continue;
4452
4987
  }
4988
+ if (remoteXmlBase && !ABSOLUTE_URI_RE.test(href)) {
4989
+ const resolvedUrl = new URL(href, remoteXmlBase).href;
4990
+ pushMessage(context.messages, {
4991
+ id: MessageId.RSC_006,
4992
+ message: `Remote resource reference is not allowed; resource "${resolvedUrl}" must be located in the EPUB container`,
4993
+ location: { path, line }
4994
+ });
4995
+ continue;
4996
+ }
4453
4997
  const resolvedPath = this.resolveRelativePath(docDir, href, opfDir);
4454
4998
  const hashIndex = resolvedPath.indexOf("#");
4455
4999
  const targetResource = hashIndex >= 0 ? resolvedPath.slice(0, hashIndex) : resolvedPath;
@@ -4559,11 +5103,12 @@ var ContentValidator = class {
4559
5103
  refValidator.addReference(svgRef);
4560
5104
  }
4561
5105
  }
4562
- extractAndRegisterStylesheets(context, path, root, opfDir, refValidator) {
5106
+ extractAndRegisterStylesheets(context, path, root, opfDir, refValidator, remoteXmlBase = null) {
4563
5107
  const docDir = path.includes("/") ? path.substring(0, path.lastIndexOf("/")) : "";
4564
5108
  const baseElem = root.get(".//html:base[@href]", { html: "http://www.w3.org/1999/xhtml" });
4565
5109
  const baseHref = baseElem ? this.getAttribute(baseElem, "href") : null;
4566
- const remoteBaseUrl = baseHref?.startsWith("http://") || baseHref?.startsWith("https://") ? baseHref : null;
5110
+ const effectiveBase = baseHref ?? remoteXmlBase;
5111
+ const remoteBaseUrl = effectiveBase?.startsWith("http://") || effectiveBase?.startsWith("https://") ? effectiveBase : null;
4567
5112
  const linkElements = root.find(".//html:link[@href]", { html: "http://www.w3.org/1999/xhtml" });
4568
5113
  for (const linkElem of linkElements) {
4569
5114
  const href = this.getAttribute(linkElem, "href");
@@ -5647,6 +6192,8 @@ var OCFValidator = class {
5647
6192
  this.validateUtf8Filenames(zip, context.messages);
5648
6193
  this.validateEmptyDirectories(zip, context.messages);
5649
6194
  this.parseEncryption(zip, context);
6195
+ this.validateEncryptionXml(context);
6196
+ this.validateSignaturesXml(context);
5650
6197
  }
5651
6198
  /**
5652
6199
  * Validate the mimetype file
@@ -6003,6 +6550,93 @@ var OCFValidator = class {
6003
6550
  context.obfuscatedResources = obfuscated;
6004
6551
  }
6005
6552
  }
6553
+ /**
6554
+ * Validate encryption.xml structure:
6555
+ * - Root element must be "encryption" in OCF namespace
6556
+ * - Compression Method must be "0" or "8"
6557
+ * - Compression OriginalLength must be a non-negative integer
6558
+ * - All Id attributes must be unique
6559
+ */
6560
+ extractRootElementName(xml) {
6561
+ const match = /<(\w+)[\s>]/.exec(xml.replace(/<\?xml[^?]*\?>/, "").trimStart());
6562
+ return match?.[1] ?? null;
6563
+ }
6564
+ validateEncryptionXml(context) {
6565
+ const encPath = "META-INF/encryption.xml";
6566
+ const content = context.files.get(encPath);
6567
+ if (!content) return;
6568
+ const xml = new TextDecoder().decode(content);
6569
+ const rootName = this.extractRootElementName(xml);
6570
+ if (rootName !== null && rootName !== "encryption") {
6571
+ pushMessage(context.messages, {
6572
+ id: MessageId.RSC_005,
6573
+ message: `expected element "encryption" but found "${rootName}"`,
6574
+ location: { path: encPath }
6575
+ });
6576
+ return;
6577
+ }
6578
+ const idPattern = /\bId=["']([^"']+)["']/g;
6579
+ const ids = /* @__PURE__ */ new Map();
6580
+ let idMatch;
6581
+ while ((idMatch = idPattern.exec(xml)) !== null) {
6582
+ const id = idMatch[1] ?? "";
6583
+ ids.set(id, (ids.get(id) ?? 0) + 1);
6584
+ }
6585
+ for (const [id, count] of ids) {
6586
+ if (count > 1) {
6587
+ pushMessage(context.messages, {
6588
+ id: MessageId.RSC_005,
6589
+ message: `Duplicate "${id}"`,
6590
+ location: { path: encPath }
6591
+ });
6592
+ }
6593
+ }
6594
+ const compressionPattern = /<(?:\w+:)?Compression\s+([^>]*)\/?>/g;
6595
+ let compMatch;
6596
+ while ((compMatch = compressionPattern.exec(xml)) !== null) {
6597
+ const attrs = compMatch[1] ?? "";
6598
+ const methodMatch = /Method=["']([^"']*)["']/.exec(attrs);
6599
+ const lengthMatch = /OriginalLength=["']([^"']*)["']/.exec(attrs);
6600
+ if (methodMatch) {
6601
+ const method = methodMatch[1] ?? "";
6602
+ if (method !== "0" && method !== "8") {
6603
+ pushMessage(context.messages, {
6604
+ id: MessageId.RSC_005,
6605
+ message: `value of attribute "Method" is invalid; must be "0" or "8"`,
6606
+ location: { path: encPath }
6607
+ });
6608
+ }
6609
+ }
6610
+ if (lengthMatch) {
6611
+ const length = lengthMatch[1] ?? "";
6612
+ if (!/^\d+$/.test(length)) {
6613
+ pushMessage(context.messages, {
6614
+ id: MessageId.RSC_005,
6615
+ message: `value of attribute "OriginalLength" is invalid; must be a non-negative integer`,
6616
+ location: { path: encPath }
6617
+ });
6618
+ }
6619
+ }
6620
+ }
6621
+ }
6622
+ /**
6623
+ * Validate signatures.xml structure:
6624
+ * - Root element must be "signatures" in OCF namespace
6625
+ */
6626
+ validateSignaturesXml(context) {
6627
+ const sigPath = "META-INF/signatures.xml";
6628
+ const content = context.files.get(sigPath);
6629
+ if (!content) return;
6630
+ const xml = new TextDecoder().decode(content);
6631
+ const rootName = this.extractRootElementName(xml);
6632
+ if (rootName !== null && rootName !== "signatures") {
6633
+ pushMessage(context.messages, {
6634
+ id: MessageId.RSC_005,
6635
+ message: `expected element "signatures" but found "${rootName}"`,
6636
+ location: { path: sigPath }
6637
+ });
6638
+ }
6639
+ }
6006
6640
  /**
6007
6641
  * Validate empty directories
6008
6642
  */
@@ -6030,6 +6664,65 @@ var OCFValidator = class {
6030
6664
  }
6031
6665
  };
6032
6666
 
6667
+ // src/util/encoding.ts
6668
+ function sniffXmlEncoding(data) {
6669
+ if (data.length < 2) return null;
6670
+ if (data.length >= 4) {
6671
+ if (data[0] === 0 && data[1] === 0 && data[2] === 254 && data[3] === 255) {
6672
+ return "UCS-4";
6673
+ }
6674
+ if (data[0] === 255 && data[1] === 254 && data[2] === 0 && data[3] === 0) {
6675
+ return "UCS-4";
6676
+ }
6677
+ if (data[0] === 0 && data[1] === 0 && data[2] === 255 && data[3] === 254) {
6678
+ return "UCS-4";
6679
+ }
6680
+ if (data[0] === 254 && data[1] === 255 && data[2] === 0 && data[3] === 0) {
6681
+ return "UCS-4";
6682
+ }
6683
+ if (data[0] === 0 && data[1] === 0 && data[2] === 0 && data[3] === 60) {
6684
+ return "UCS-4";
6685
+ }
6686
+ if (data[0] === 60 && data[1] === 0 && data[2] === 0 && data[3] === 0) {
6687
+ return "UCS-4";
6688
+ }
6689
+ if (data[0] === 0 && data[1] === 0 && data[2] === 60 && data[3] === 0) {
6690
+ return "UCS-4";
6691
+ }
6692
+ if (data[0] === 0 && data[1] === 60 && data[2] === 0 && data[3] === 0) {
6693
+ return "UCS-4";
6694
+ }
6695
+ }
6696
+ if (data[0] === 254 && data[1] === 255) {
6697
+ return "UTF-16";
6698
+ }
6699
+ if (data[0] === 255 && data[1] === 254) {
6700
+ return "UTF-16";
6701
+ }
6702
+ if (data.length >= 4) {
6703
+ if (data[0] === 0 && data[1] === 60 && data[2] === 0 && data[3] === 63) {
6704
+ return "UTF-16";
6705
+ }
6706
+ if (data[0] === 60 && data[1] === 0 && data[2] === 63 && data[3] === 0) {
6707
+ return "UTF-16";
6708
+ }
6709
+ }
6710
+ if (data.length >= 3 && data[0] === 239 && data[1] === 187 && data[2] === 191) {
6711
+ return null;
6712
+ }
6713
+ if (data.length >= 4 && data[0] === 76 && data[1] === 111 && data[2] === 167 && data[3] === 148) {
6714
+ return "EBCDIC";
6715
+ }
6716
+ const prefix = String.fromCharCode(...data.slice(0, Math.min(256, data.length)));
6717
+ const match = /^<\?xml[^?]*\bencoding\s*=\s*["']([^"']+)["']/.exec(prefix);
6718
+ if (match) {
6719
+ const declared = (match[1] ?? "").toUpperCase();
6720
+ if (declared === "UTF-8") return null;
6721
+ return declared;
6722
+ }
6723
+ return null;
6724
+ }
6725
+
6033
6726
  // src/opf/parser.ts
6034
6727
  function parseOPF(xml) {
6035
6728
  const packageRegex = /<package[^>]*\sversion=["']([^"']+)["'][^>]*(?:\sunique-identifier=["']([^"']+)["'])?[^>]*>/;
@@ -6380,6 +7073,25 @@ var DEPRECATED_MEDIA_TYPES = /* @__PURE__ */ new Set([
6380
7073
  "application/x-oeb1-package",
6381
7074
  "text/x-oeb1-html"
6382
7075
  ]);
7076
+ function getPreferredMediaType(mimeType, path) {
7077
+ switch (mimeType) {
7078
+ case "application/font-sfnt":
7079
+ if (path.endsWith(".ttf")) return "font/ttf";
7080
+ if (path.endsWith(".otf")) return "font/otf";
7081
+ return "font/(ttf|otf)";
7082
+ case "application/vnd.ms-opentype":
7083
+ return "font/otf";
7084
+ case "application/font-woff":
7085
+ return "font/woff";
7086
+ case "application/x-font-ttf":
7087
+ return "font/ttf";
7088
+ case "text/javascript":
7089
+ case "application/ecmascript":
7090
+ return "application/javascript";
7091
+ default:
7092
+ return null;
7093
+ }
7094
+ }
6383
7095
  var VALID_RELATOR_CODES = /* @__PURE__ */ new Set([
6384
7096
  "abr",
6385
7097
  "acp",
@@ -6716,7 +7428,6 @@ var RENDITION_META_RULES = [
6716
7428
  var KNOWN_RENDITION_META_PROPERTIES = new Set(
6717
7429
  RENDITION_META_RULES.map((r) => r.property.slice("rendition:".length))
6718
7430
  );
6719
- 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)?)$/;
6720
7431
  var GRANDFATHERED_LANG_TAGS = /* @__PURE__ */ new Set([
6721
7432
  "en-GB-oed",
6722
7433
  "i-ami",
@@ -6770,6 +7481,20 @@ var OPFValidator = class {
6770
7481
  });
6771
7482
  return;
6772
7483
  }
7484
+ const encoding = sniffXmlEncoding(opfData);
7485
+ if (encoding === "UTF-16") {
7486
+ pushMessage(context.messages, {
7487
+ id: MessageId.RSC_027,
7488
+ message: `Detected non-UTF-8 encoding "${encoding}" in "${opfPath}"`,
7489
+ location: { path: opfPath }
7490
+ });
7491
+ } else if (encoding !== null) {
7492
+ pushMessage(context.messages, {
7493
+ id: MessageId.RSC_028,
7494
+ message: `Detected non-UTF-8 encoding "${encoding}" in "${opfPath}"`,
7495
+ location: { path: opfPath }
7496
+ });
7497
+ }
6773
7498
  const opfXml = new TextDecoder().decode(opfData);
6774
7499
  try {
6775
7500
  this.packageDoc = parseOPF(opfXml);
@@ -7061,6 +7786,7 @@ var OPFValidator = class {
7061
7786
  this.validateMetaPropertiesVocab(context, opfPath, dcElements);
7062
7787
  this.validateRenditionVocab(context, opfPath);
7063
7788
  this.validateMediaOverlaysVocab(context, opfPath);
7789
+ this.validateMediaOverlayItems(context, opfPath);
7064
7790
  }
7065
7791
  if (this.packageDoc.version !== "2.0") {
7066
7792
  const modifiedMetas = this.packageDoc.metaElements.filter(
@@ -7428,19 +8154,42 @@ var OPFValidator = class {
7428
8154
  validateMediaOverlaysVocab(context, opfPath) {
7429
8155
  if (!this.packageDoc) return;
7430
8156
  const metas = this.packageDoc.metaElements;
7431
- for (const prop of ["media:active-class", "media:playback-active-class"]) {
7432
- if (metas.filter((m) => m.property === prop).length > 1) {
7433
- const displayName = prop.slice("media:".length);
8157
+ const matchingActive = metas.filter((m) => m.property === "media:active-class");
8158
+ const matchingPlayback = metas.filter((m) => m.property === "media:playback-active-class");
8159
+ for (const [prop, matching] of [
8160
+ ["media:active-class", matchingActive],
8161
+ ["media:playback-active-class", matchingPlayback]
8162
+ ]) {
8163
+ const displayName = prop.slice("media:".length);
8164
+ if (matching.length > 1) {
7434
8165
  pushMessage(context.messages, {
7435
8166
  id: MessageId.RSC_005,
7436
8167
  message: `The '${displayName}' property must not occur more than one time in the package metadata`,
7437
8168
  location: { path: opfPath }
7438
8169
  });
7439
8170
  }
8171
+ for (const meta of matching) {
8172
+ if (meta.refines) {
8173
+ pushMessage(context.messages, {
8174
+ id: MessageId.RSC_005,
8175
+ message: `@refines must not be used with the ${prop} property`,
8176
+ location: { path: opfPath }
8177
+ });
8178
+ }
8179
+ if (meta.value.trim().includes(" ")) {
8180
+ pushMessage(context.messages, {
8181
+ id: MessageId.RSC_005,
8182
+ message: `the '${displayName}' property must define a single class name`,
8183
+ location: { path: opfPath }
8184
+ });
8185
+ }
8186
+ }
7440
8187
  }
8188
+ if (matchingActive[0]) context.mediaActiveClass = matchingActive[0].value.trim();
8189
+ if (matchingPlayback[0]) context.mediaPlaybackActiveClass = matchingPlayback[0].value.trim();
7441
8190
  for (const meta of metas) {
7442
8191
  if (meta.property === "media:duration") {
7443
- if (!SMIL3_CLOCK_RE.test(meta.value.trim())) {
8192
+ if (!isValidSmilClock(meta.value.trim())) {
7444
8193
  pushMessage(context.messages, {
7445
8194
  id: MessageId.RSC_005,
7446
8195
  message: `The value of the "media:duration" property must be a valid SMIL3 clock value`,
@@ -7449,6 +8198,84 @@ var OPFValidator = class {
7449
8198
  }
7450
8199
  }
7451
8200
  }
8201
+ const globalDuration = metas.find((m) => m.property === "media:duration" && !m.refines);
8202
+ if (globalDuration) {
8203
+ const totalSeconds = parseSmilClock(globalDuration.value.trim());
8204
+ if (!Number.isNaN(totalSeconds)) {
8205
+ let sumSeconds = 0;
8206
+ let allValid = true;
8207
+ for (const meta of metas) {
8208
+ if (meta.property === "media:duration" && meta.refines) {
8209
+ const s = parseSmilClock(meta.value.trim());
8210
+ if (Number.isNaN(s)) {
8211
+ allValid = false;
8212
+ break;
8213
+ }
8214
+ sumSeconds += s;
8215
+ }
8216
+ }
8217
+ if (allValid && Math.abs(totalSeconds - sumSeconds) > 1) {
8218
+ pushMessage(context.messages, {
8219
+ id: MessageId.MED_016,
8220
+ message: `Media Overlays total duration should be the sum of the durations of all Media Overlays documents.`,
8221
+ location: { path: opfPath }
8222
+ });
8223
+ }
8224
+ }
8225
+ }
8226
+ }
8227
+ /**
8228
+ * Validate media-overlay manifest item constraints:
8229
+ * - media-overlay must reference a SMIL item (application/smil+xml)
8230
+ * - media-overlay attribute only allowed on XHTML and SVG content documents
8231
+ * - Global media:duration required when overlays exist
8232
+ * - Per-item media:duration required for each overlay
8233
+ */
8234
+ validateMediaOverlayItems(context, opfPath) {
8235
+ if (!this.packageDoc) return;
8236
+ const manifest = this.packageDoc.manifest;
8237
+ const metas = this.packageDoc.metaElements;
8238
+ const itemsWithOverlay = manifest.filter((item) => item.mediaOverlay);
8239
+ if (itemsWithOverlay.length === 0) return;
8240
+ for (const item of itemsWithOverlay) {
8241
+ const moId = item.mediaOverlay;
8242
+ if (!moId) continue;
8243
+ const moItem = this.manifestById.get(moId);
8244
+ if (moItem && moItem.mediaType !== "application/smil+xml") {
8245
+ pushMessage(context.messages, {
8246
+ id: MessageId.RSC_005,
8247
+ message: `media overlay items must be of the "application/smil+xml" type (given type was "${moItem.mediaType}")`,
8248
+ location: { path: opfPath }
8249
+ });
8250
+ }
8251
+ if (item.mediaType !== "application/xhtml+xml" && item.mediaType !== "image/svg+xml") {
8252
+ pushMessage(context.messages, {
8253
+ id: MessageId.RSC_005,
8254
+ message: `The media-overlay attribute is only allowed on XHTML and SVG content documents.`,
8255
+ location: { path: opfPath }
8256
+ });
8257
+ }
8258
+ }
8259
+ if (!metas.some((m) => m.property === "media:duration" && !m.refines)) {
8260
+ pushMessage(context.messages, {
8261
+ id: MessageId.RSC_005,
8262
+ message: `global media:duration meta element not set`,
8263
+ location: { path: opfPath }
8264
+ });
8265
+ }
8266
+ const overlayIds = new Set(
8267
+ itemsWithOverlay.map((item) => item.mediaOverlay).filter((id) => id != null && this.manifestById.has(id))
8268
+ );
8269
+ for (const overlayId of overlayIds) {
8270
+ const refinesUri = `#${overlayId}`;
8271
+ if (!metas.some((m) => m.property === "media:duration" && m.refines === refinesUri)) {
8272
+ pushMessage(context.messages, {
8273
+ id: MessageId.RSC_005,
8274
+ message: `item media:duration meta element not set (expecting: meta property='media:duration' refines='${refinesUri}')`,
8275
+ location: { path: opfPath }
8276
+ });
8277
+ }
8278
+ }
7452
8279
  }
7453
8280
  /**
7454
8281
  * Validate EPUB 3 link elements in metadata
@@ -7681,6 +8508,14 @@ var OPFValidator = class {
7681
8508
  location: { path: opfPath }
7682
8509
  });
7683
8510
  }
8511
+ const preferred = getPreferredMediaType(item.mediaType, fullPath);
8512
+ if (preferred !== null) {
8513
+ pushMessage(context.messages, {
8514
+ id: MessageId.OPF_090,
8515
+ message: `Encouraged to use media type "${preferred}" instead of "${item.mediaType}"`,
8516
+ location: { path: opfPath }
8517
+ });
8518
+ }
7684
8519
  if (this.packageDoc.version !== "2.0" && item.properties) {
7685
8520
  for (const prop of item.properties) {
7686
8521
  if (!ITEM_PROPERTIES.has(prop)) {
@@ -8436,7 +9271,7 @@ var ReferenceValidator = class {
8436
9271
  location: reference.location
8437
9272
  });
8438
9273
  }
8439
- if (!this.registry.hasResource(resourcePath)) {
9274
+ if (reference.type !== "overlay-text-link" /* OVERLAY_TEXT_LINK */ && !this.registry.hasResource(resourcePath)) {
8440
9275
  const fileExistsInContainer = context.files.has(resourcePath);
8441
9276
  if (fileExistsInContainer) {
8442
9277
  if (!context.referencedUndeclaredResources?.has(resourcePath)) {
@@ -8573,6 +9408,24 @@ var ReferenceValidator = class {
8573
9408
  });
8574
9409
  }
8575
9410
  }
9411
+ if (reference.type === "overlay-text-link" /* OVERLAY_TEXT_LINK */ && resource) {
9412
+ if (resource.mimeType === "application/xhtml+xml" && /^\w+\(/.test(fragment)) {
9413
+ pushMessage(context.messages, {
9414
+ id: MessageId.MED_017,
9415
+ message: `URL fragment should indicate an element ID, but found '#${fragment}'`,
9416
+ location: reference.location
9417
+ });
9418
+ return;
9419
+ }
9420
+ if (resource.mimeType === "image/svg+xml" && !this.isValidSVGFragment(fragment)) {
9421
+ pushMessage(context.messages, {
9422
+ id: MessageId.MED_018,
9423
+ message: `URL fragment should be an SVG fragment identifier, but found '#${fragment}'`,
9424
+ location: reference.location
9425
+ });
9426
+ return;
9427
+ }
9428
+ }
8576
9429
  const parsedMimeTypes = ["application/xhtml+xml", "image/svg+xml"];
8577
9430
  if (resource && parsedMimeTypes.includes(resource.mimeType) && !isRemoteURL(resourcePath) && !fragment.includes("svgView(") && reference.type !== "svg-symbol" /* SVG_SYMBOL */) {
8578
9431
  if (!this.registry.hasID(resourcePath, fragment)) {
@@ -8584,6 +9437,18 @@ var ReferenceValidator = class {
8584
9437
  }
8585
9438
  }
8586
9439
  }
9440
+ /**
9441
+ * Check if a fragment is a valid SVG fragment identifier.
9442
+ * Valid forms: bare NCName ID, svgView(...), t=..., xywh=..., id=...
9443
+ */
9444
+ isValidSVGFragment(fragment) {
9445
+ if (/^svgView\(.*\)$/.test(fragment)) return true;
9446
+ if (fragment.startsWith("t=")) return true;
9447
+ if (fragment.startsWith("xywh=")) return true;
9448
+ if (fragment.startsWith("id=")) return true;
9449
+ if (!fragment.includes("=") && /^[a-zA-Z_][\w.-]*$/.test(fragment)) return true;
9450
+ return false;
9451
+ }
8587
9452
  /**
8588
9453
  * Check non-spine remote resources that have non-standard types.
8589
9454
  * Fires RSC-006 for remote items that aren't audio/video/font types
@@ -8643,7 +9508,7 @@ var ReferenceValidator = class {
8643
9508
  }
8644
9509
  }
8645
9510
  checkReadingOrder(context) {
8646
- if (!context.tocLinks || !context.packageDocument) return;
9511
+ if (!context.packageDocument) return;
8647
9512
  const packageDoc = context.packageDocument;
8648
9513
  const spine = packageDoc.spine;
8649
9514
  const opfPath = context.opfPath ?? "";
@@ -8655,15 +9520,35 @@ var ReferenceValidator = class {
8655
9520
  spinePositionMap.set(resolveManifestHref(opfDir, item.href), i);
8656
9521
  }
8657
9522
  }
9523
+ if (context.tocLinks) {
9524
+ this.checkLinkReadingOrder(
9525
+ context,
9526
+ spinePositionMap,
9527
+ context.tocLinks,
9528
+ MessageId.NAV_011,
9529
+ '"toc" nav'
9530
+ );
9531
+ }
9532
+ if (context.overlayTextLinks) {
9533
+ this.checkLinkReadingOrder(
9534
+ context,
9535
+ spinePositionMap,
9536
+ context.overlayTextLinks,
9537
+ MessageId.MED_015,
9538
+ "Media overlay text"
9539
+ );
9540
+ }
9541
+ }
9542
+ checkLinkReadingOrder(context, spinePositionMap, links, messageId, label) {
8658
9543
  let lastSpinePosition = -1;
8659
9544
  let lastAnchorPosition = -1;
8660
- for (const link of context.tocLinks) {
9545
+ for (const link of links) {
8661
9546
  const spinePos = spinePositionMap.get(link.targetResource);
8662
9547
  if (spinePos === void 0) continue;
8663
9548
  if (spinePos < lastSpinePosition) {
8664
9549
  pushMessage(context.messages, {
8665
- id: MessageId.NAV_011,
8666
- message: `"toc" nav must be in reading order; link target "${link.targetResource}" is before the previous link's target in spine order`,
9550
+ id: messageId,
9551
+ message: `${label} must be in reading order; link target "${link.targetResource}" is before the previous link's target in spine order`,
8667
9552
  location: link.location
8668
9553
  });
8669
9554
  lastSpinePosition = spinePos;
@@ -8678,8 +9563,8 @@ var ReferenceValidator = class {
8678
9563
  if (targetAnchorPosition < lastAnchorPosition) {
8679
9564
  const target = link.fragment ? `${link.targetResource}#${link.fragment}` : link.targetResource;
8680
9565
  pushMessage(context.messages, {
8681
- id: MessageId.NAV_011,
8682
- message: `"toc" nav must be in reading order; link target "${target}" is before the previous link's target in document order`,
9566
+ id: messageId,
9567
+ message: `${label} must be in reading order; link target "${target}" is before the previous link's target in document order`,
8683
9568
  location: link.location
8684
9569
  });
8685
9570
  }
@@ -8898,11 +9783,11 @@ var RelaxNGValidator = class extends BaseSchemaValidator {
8898
9783
  try {
8899
9784
  const libxml2 = await import('libxml2-wasm');
8900
9785
  const LibRelaxNGValidator = libxml2.RelaxNGValidator;
8901
- const { XmlDocument: XmlDocument3 } = libxml2;
8902
- const doc = XmlDocument3.fromString(xml);
9786
+ const { XmlDocument: XmlDocument4 } = libxml2;
9787
+ const doc = XmlDocument4.fromString(xml);
8903
9788
  try {
8904
9789
  const schemaContent = await loadSchema(schemaPath);
8905
- const schemaDoc = XmlDocument3.fromString(schemaContent);
9790
+ const schemaDoc = XmlDocument4.fromString(schemaContent);
8906
9791
  try {
8907
9792
  const validator = LibRelaxNGValidator.fromDoc(schemaDoc);
8908
9793
  try {