@likecoin/epubcheck-ts 0.3.8 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -7,6 +7,13 @@ var fflate = require('fflate');
7
7
  // src/content/validator.ts
8
8
 
9
9
  // src/messages/messages.ts
10
+ var severityOverrides = /* @__PURE__ */ new Map();
11
+ function setSeverityOverrides(overrides) {
12
+ severityOverrides = overrides;
13
+ }
14
+ function clearSeverityOverrides() {
15
+ severityOverrides = /* @__PURE__ */ new Map();
16
+ }
10
17
  var MessageDefs = {
11
18
  // Package/Container errors (PKG-*)
12
19
  PKG_001: {
@@ -1177,10 +1184,15 @@ function formatMessageList() {
1177
1184
  function createMessage(options) {
1178
1185
  const { id, message, location, suggestion, severityOverride } = options;
1179
1186
  const registeredSeverity = getDefaultSeverity(id);
1180
- if (registeredSeverity === "suppressed" && !severityOverride) {
1187
+ const globalOverride = severityOverrides.get(id);
1188
+ const effectiveOverride = severityOverride ?? globalOverride;
1189
+ if (effectiveOverride === "suppressed") {
1190
+ return null;
1191
+ }
1192
+ if (registeredSeverity === "suppressed" && !effectiveOverride) {
1181
1193
  return null;
1182
1194
  }
1183
- const severity = severityOverride ?? registeredSeverity;
1195
+ const severity = effectiveOverride ? effectiveOverride : registeredSeverity;
1184
1196
  const result = {
1185
1197
  id,
1186
1198
  severity,
@@ -1200,6 +1212,30 @@ function pushMessage(messages, options) {
1200
1212
  messages.push(msg);
1201
1213
  }
1202
1214
  }
1215
+ function parseCustomMessages(content) {
1216
+ const overrides = /* @__PURE__ */ new Map();
1217
+ const validSeverities = /* @__PURE__ */ new Set([
1218
+ "fatal",
1219
+ "error",
1220
+ "warning",
1221
+ "info",
1222
+ "usage",
1223
+ "suppressed"
1224
+ ]);
1225
+ for (const line of content.split("\n")) {
1226
+ const trimmed = line.trim();
1227
+ if (!trimmed || trimmed.startsWith("#")) continue;
1228
+ if (trimmed.toLowerCase().startsWith("id ") || trimmed.toLowerCase() === "id") continue;
1229
+ const parts = trimmed.split(" ");
1230
+ const id = parts[0]?.trim();
1231
+ const severity = parts[1]?.trim().toLowerCase();
1232
+ if (!id || !severity) continue;
1233
+ if (validSeverities.has(severity)) {
1234
+ overrides.set(id, severity);
1235
+ }
1236
+ }
1237
+ return overrides;
1238
+ }
1203
1239
 
1204
1240
  // src/css/validator.ts
1205
1241
  var BLESSED_FONT_TYPES = /* @__PURE__ */ new Set([
@@ -1604,31 +1640,276 @@ var CSSValidator = class {
1604
1640
  * Check for reserved media overlay class names
1605
1641
  */
1606
1642
  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
- ]);
1643
+ const activeClassNames = /* @__PURE__ */ new Set(["-epub-media-overlay-active", "media-overlay-active"]);
1644
+ const playbackClassNames = /* @__PURE__ */ new Set(["-epub-media-overlay-playing", "media-overlay-playing"]);
1613
1645
  cssTree.walk(ast, (node) => {
1614
1646
  if (node.type === "ClassSelector") {
1615
1647
  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
- }
1648
+ const isActive = activeClassNames.has(className);
1649
+ const isPlayback = playbackClassNames.has(className);
1650
+ if (!isActive && !isPlayback) return;
1651
+ const isDeclared = isActive ? !!context.mediaActiveClass : !!context.mediaPlaybackActiveClass;
1652
+ if (isDeclared) return;
1653
+ const loc = node.loc;
1654
+ const start = loc?.start;
1655
+ const location = { path: resourcePath };
1656
+ if (start) {
1657
+ location.line = start.line;
1658
+ location.column = start.column;
1659
+ }
1660
+ const property = isActive ? "media:active-class" : "media:playback-active-class";
1661
+ pushMessage(context.messages, {
1662
+ id: MessageId.CSS_029,
1663
+ message: `Class name "${className}" is reserved for media overlays but "${property}" is not declared in the package document`,
1664
+ location
1665
+ });
1666
+ }
1667
+ });
1668
+ }
1669
+ };
1670
+
1671
+ // src/smil/clock.ts
1672
+ var FULL_CLOCK_RE = /^(\d+):([0-5]\d):([0-5]\d)(\.\d+)?$/;
1673
+ var PARTIAL_CLOCK_RE = /^([0-5]\d):([0-5]\d)(\.\d+)?$/;
1674
+ var TIMECOUNT_RE = /^(\d+(\.\d+)?)(h|min|s|ms)?$/;
1675
+ function parseSmilClock(value) {
1676
+ const trimmed = value.trim();
1677
+ const full = FULL_CLOCK_RE.exec(trimmed);
1678
+ if (full) {
1679
+ const hours = Number.parseInt(full[1] ?? "0", 10);
1680
+ const minutes = Number.parseInt(full[2] ?? "0", 10);
1681
+ const seconds = Number.parseInt(full[3] ?? "0", 10);
1682
+ const frac = full[4] ? Number.parseFloat(full[4]) : 0;
1683
+ return hours * 3600 + minutes * 60 + seconds + frac;
1684
+ }
1685
+ const partial = PARTIAL_CLOCK_RE.exec(trimmed);
1686
+ if (partial) {
1687
+ const minutes = Number.parseInt(partial[1] ?? "0", 10);
1688
+ const seconds = Number.parseInt(partial[2] ?? "0", 10);
1689
+ const frac = partial[3] ? Number.parseFloat(partial[3]) : 0;
1690
+ return minutes * 60 + seconds + frac;
1691
+ }
1692
+ const timecount = TIMECOUNT_RE.exec(trimmed);
1693
+ if (timecount) {
1694
+ const num = Number.parseFloat(timecount[1] ?? "0");
1695
+ const unit = timecount[3] ?? "s";
1696
+ switch (unit) {
1697
+ case "h":
1698
+ return num * 3600;
1699
+ case "min":
1700
+ return num * 60;
1701
+ case "s":
1702
+ return num;
1703
+ case "ms":
1704
+ return num / 1e3;
1705
+ default:
1706
+ return NaN;
1707
+ }
1708
+ }
1709
+ return NaN;
1710
+ }
1711
+ function isValidSmilClock(value) {
1712
+ return !Number.isNaN(parseSmilClock(value));
1713
+ }
1714
+
1715
+ // src/smil/validator.ts
1716
+ var SMIL_NS = { smil: "http://www.w3.org/ns/SMIL" };
1717
+ var BLESSED_AUDIO_TYPES = /* @__PURE__ */ new Set(["audio/mpeg", "audio/mp4"]);
1718
+ var BLESSED_AUDIO_PATTERN = /^audio\/ogg\s*;\s*codecs=opus$/i;
1719
+ function isBlessedAudioType(mimeType) {
1720
+ return BLESSED_AUDIO_TYPES.has(mimeType) || BLESSED_AUDIO_PATTERN.test(mimeType);
1721
+ }
1722
+ var SMILValidator = class {
1723
+ getAttribute(element, name) {
1724
+ return element.attr(name)?.value ?? null;
1725
+ }
1726
+ getEpubAttribute(element, localName) {
1727
+ return element.attr(localName, "epub")?.value ?? null;
1728
+ }
1729
+ validate(context, path, manifestByPath) {
1730
+ const result = {
1731
+ textReferences: [],
1732
+ referencedDocuments: /* @__PURE__ */ new Set(),
1733
+ hasRemoteResources: false
1734
+ };
1735
+ const data = context.files.get(path);
1736
+ if (!data) return result;
1737
+ const content = typeof data === "string" ? data : new TextDecoder().decode(data);
1738
+ let doc = null;
1739
+ try {
1740
+ doc = libxml2Wasm.XmlDocument.fromString(content);
1741
+ } catch {
1742
+ pushMessage(context.messages, {
1743
+ id: MessageId.RSC_016,
1744
+ message: "Media Overlay document is not well-formed XML",
1745
+ location: { path }
1746
+ });
1747
+ return result;
1748
+ }
1749
+ try {
1750
+ const root = doc.root;
1751
+ this.validateStructure(context, path, root);
1752
+ this.validateAudioElements(context, path, root, manifestByPath, result);
1753
+ this.extractTextReferences(path, root, result);
1754
+ } finally {
1755
+ doc.dispose();
1756
+ }
1757
+ return result;
1758
+ }
1759
+ validateStructure(context, path, root) {
1760
+ try {
1761
+ for (const text of root.find(".//smil:seq/smil:text", SMIL_NS)) {
1762
+ pushMessage(context.messages, {
1763
+ id: MessageId.RSC_005,
1764
+ message: "element 'text' not allowed here; expected 'seq' or 'par'",
1765
+ location: { path, line: text.line }
1766
+ });
1767
+ }
1768
+ for (const audio of root.find(".//smil:seq/smil:audio", SMIL_NS)) {
1769
+ pushMessage(context.messages, {
1770
+ id: MessageId.RSC_005,
1771
+ message: "element 'audio' not allowed here; expected 'seq' or 'par'",
1772
+ location: { path, line: audio.line }
1773
+ });
1774
+ }
1775
+ } catch {
1776
+ }
1777
+ try {
1778
+ for (const seq of root.find(".//smil:par/smil:seq", SMIL_NS)) {
1779
+ pushMessage(context.messages, {
1780
+ id: MessageId.RSC_005,
1781
+ message: "element 'seq' not allowed here; expected 'text' or 'audio'",
1782
+ location: { path, line: seq.line }
1783
+ });
1784
+ }
1785
+ const parElements = root.find(".//smil:par", SMIL_NS);
1786
+ for (const par of parElements) {
1787
+ const textChildren = par.find("./smil:text", SMIL_NS);
1788
+ for (let i = 1; i < textChildren.length; i++) {
1789
+ const extra = textChildren[i];
1790
+ if (!extra) continue;
1624
1791
  pushMessage(context.messages, {
1625
- id: MessageId.CSS_029,
1626
- message: `Class name "${className}" is reserved for media overlays`,
1627
- location
1792
+ id: MessageId.RSC_005,
1793
+ message: "element 'text' not allowed here; only one 'text' element is allowed in 'par'",
1794
+ location: { path, line: extra.line }
1628
1795
  });
1629
1796
  }
1630
1797
  }
1631
- });
1798
+ } catch {
1799
+ }
1800
+ try {
1801
+ const headMetaElements = root.find(".//smil:head/smil:meta", SMIL_NS);
1802
+ for (const meta of headMetaElements) {
1803
+ pushMessage(context.messages, {
1804
+ id: MessageId.RSC_005,
1805
+ message: "element 'meta' not allowed here; expected 'metadata'",
1806
+ location: { path, line: meta.line }
1807
+ });
1808
+ }
1809
+ } catch {
1810
+ }
1811
+ }
1812
+ validateAudioElements(context, path, root, manifestByPath, result) {
1813
+ try {
1814
+ const audioElements = root.find(".//smil:audio", SMIL_NS);
1815
+ for (const audio of audioElements) {
1816
+ const elem = audio;
1817
+ const src = this.getAttribute(elem, "src");
1818
+ if (src) {
1819
+ if (/^https?:\/\//i.test(src)) {
1820
+ result.hasRemoteResources = true;
1821
+ }
1822
+ if (src.includes("#")) {
1823
+ pushMessage(context.messages, {
1824
+ id: MessageId.MED_014,
1825
+ message: `Media overlay audio file URLs must not have a fragment: "${src}"`,
1826
+ location: { path, line: audio.line }
1827
+ });
1828
+ }
1829
+ if (manifestByPath) {
1830
+ const audioPath = this.resolveRelativePath(path, src.split("#")[0] ?? src);
1831
+ const audioItem = manifestByPath.get(audioPath);
1832
+ if (audioItem && !isBlessedAudioType(audioItem.mediaType)) {
1833
+ pushMessage(context.messages, {
1834
+ id: MessageId.MED_005,
1835
+ message: `Media Overlay audio reference "${src}" to non-standard audio type "${audioItem.mediaType}"`,
1836
+ location: { path, line: audio.line }
1837
+ });
1838
+ }
1839
+ }
1840
+ }
1841
+ const clipBegin = this.getAttribute(elem, "clipBegin");
1842
+ const clipEnd = this.getAttribute(elem, "clipEnd");
1843
+ this.checkClipTiming(context, path, audio.line, clipBegin, clipEnd);
1844
+ }
1845
+ } catch {
1846
+ }
1847
+ }
1848
+ checkClipTiming(context, path, line, clipBegin, clipEnd) {
1849
+ if (clipEnd === null) return;
1850
+ const beginStr = clipBegin ?? "0";
1851
+ const start = parseSmilClock(beginStr);
1852
+ const end = parseSmilClock(clipEnd);
1853
+ if (Number.isNaN(start) || Number.isNaN(end)) return;
1854
+ const location = line != null ? { path, line } : { path };
1855
+ if (start > end) {
1856
+ pushMessage(context.messages, {
1857
+ id: MessageId.MED_008,
1858
+ message: "The time specified in the clipBegin attribute must not be after clipEnd",
1859
+ location
1860
+ });
1861
+ } else if (start === end) {
1862
+ pushMessage(context.messages, {
1863
+ id: MessageId.MED_009,
1864
+ message: "The time specified in the clipBegin attribute must not be the same as clipEnd",
1865
+ location
1866
+ });
1867
+ }
1868
+ }
1869
+ extractTextReferences(path, root, result) {
1870
+ try {
1871
+ const textElements = root.find(".//smil:text", SMIL_NS);
1872
+ for (const text of textElements) {
1873
+ const src = this.getAttribute(text, "src");
1874
+ if (!src) continue;
1875
+ const hashIndex = src.indexOf("#");
1876
+ const docRef = hashIndex >= 0 ? src.substring(0, hashIndex) : src;
1877
+ const fragment = hashIndex >= 0 ? src.substring(hashIndex + 1) : void 0;
1878
+ const docPath = this.resolveRelativePath(path, docRef);
1879
+ result.textReferences.push({ docPath, fragment, line: text.line });
1880
+ result.referencedDocuments.add(docPath);
1881
+ }
1882
+ const bodyElements = root.find(".//smil:body", SMIL_NS);
1883
+ const seqElements = root.find(".//smil:seq", SMIL_NS);
1884
+ for (const elem of [...bodyElements, ...seqElements]) {
1885
+ const textref = this.getEpubAttribute(elem, "textref");
1886
+ if (!textref) continue;
1887
+ const hashIndex = textref.indexOf("#");
1888
+ const docRef = hashIndex >= 0 ? textref.substring(0, hashIndex) : textref;
1889
+ const fragment = hashIndex >= 0 ? textref.substring(hashIndex + 1) : void 0;
1890
+ const docPath = this.resolveRelativePath(path, docRef);
1891
+ result.textReferences.push({ docPath, fragment, line: elem.line });
1892
+ result.referencedDocuments.add(docPath);
1893
+ }
1894
+ } catch {
1895
+ }
1896
+ }
1897
+ resolveRelativePath(basePath, relativePath) {
1898
+ if (relativePath.startsWith("/") || /^[a-zA-Z]+:/.test(relativePath)) {
1899
+ return relativePath;
1900
+ }
1901
+ const baseDir = basePath.includes("/") ? basePath.substring(0, basePath.lastIndexOf("/")) : "";
1902
+ if (!baseDir) return relativePath;
1903
+ const segments = `${baseDir}/${relativePath}`.split("/");
1904
+ const resolved = [];
1905
+ for (const seg of segments) {
1906
+ if (seg === "..") {
1907
+ resolved.pop();
1908
+ } else if (seg !== ".") {
1909
+ resolved.push(seg);
1910
+ }
1911
+ }
1912
+ return resolved.join("/");
1632
1913
  }
1633
1914
  };
1634
1915
 
@@ -1890,6 +2171,18 @@ var ABSOLUTE_URI_RE = /^[a-zA-Z][a-zA-Z0-9+.-]*:/;
1890
2171
  var SPECIAL_URL_SCHEMES = /* @__PURE__ */ new Set(["http", "https", "ftp", "ws", "wss"]);
1891
2172
  var CSS_CHARSET_RE = /^@charset\s+"([^"]+)"\s*;/;
1892
2173
  var EPUB_XMLNS_RE = /xmlns:epub\s*=\s*"([^"]*)"/;
2174
+ var XHTML_NS = { html: "http://www.w3.org/1999/xhtml" };
2175
+ var EPUB_OPS_NS = { epub: "http://www.idpf.org/2007/ops" };
2176
+ var EPUB_TYPE_FORBIDDEN_ELEMENTS = /* @__PURE__ */ new Set([
2177
+ "head",
2178
+ "meta",
2179
+ "title",
2180
+ "style",
2181
+ "link",
2182
+ "script",
2183
+ "noscript",
2184
+ "base"
2185
+ ]);
1893
2186
  function validateAbsoluteHyperlinkURL(context, href, path, line) {
1894
2187
  const location = line != null ? { path, line } : { path };
1895
2188
  const scheme = href.slice(0, href.indexOf(":")).toLowerCase();
@@ -2182,6 +2475,122 @@ var HTML_ENTITIES = /* @__PURE__ */ new Set([
2182
2475
  "thorn",
2183
2476
  "yuml"
2184
2477
  ]);
2478
+ var HTML5_ELEMENTS = /* @__PURE__ */ new Set([
2479
+ "a",
2480
+ "abbr",
2481
+ "address",
2482
+ "area",
2483
+ "article",
2484
+ "aside",
2485
+ "audio",
2486
+ "b",
2487
+ "base",
2488
+ "bdi",
2489
+ "bdo",
2490
+ "blockquote",
2491
+ "body",
2492
+ "br",
2493
+ "button",
2494
+ "canvas",
2495
+ "caption",
2496
+ "cite",
2497
+ "code",
2498
+ "col",
2499
+ "colgroup",
2500
+ "data",
2501
+ "datalist",
2502
+ "dd",
2503
+ "del",
2504
+ "details",
2505
+ "dfn",
2506
+ "dialog",
2507
+ "div",
2508
+ "dl",
2509
+ "dt",
2510
+ "em",
2511
+ "embed",
2512
+ "fieldset",
2513
+ "figcaption",
2514
+ "figure",
2515
+ "footer",
2516
+ "form",
2517
+ "h1",
2518
+ "h2",
2519
+ "h3",
2520
+ "h4",
2521
+ "h5",
2522
+ "h6",
2523
+ "head",
2524
+ "header",
2525
+ "hgroup",
2526
+ "hr",
2527
+ "html",
2528
+ "i",
2529
+ "iframe",
2530
+ "img",
2531
+ "input",
2532
+ "ins",
2533
+ "kbd",
2534
+ "label",
2535
+ "legend",
2536
+ "li",
2537
+ "link",
2538
+ "main",
2539
+ "map",
2540
+ "mark",
2541
+ "math",
2542
+ "menu",
2543
+ "meta",
2544
+ "meter",
2545
+ "nav",
2546
+ "noscript",
2547
+ "object",
2548
+ "ol",
2549
+ "optgroup",
2550
+ "option",
2551
+ "output",
2552
+ "p",
2553
+ "picture",
2554
+ "pre",
2555
+ "progress",
2556
+ "q",
2557
+ "rp",
2558
+ "rt",
2559
+ "ruby",
2560
+ "s",
2561
+ "samp",
2562
+ "script",
2563
+ "search",
2564
+ "section",
2565
+ "select",
2566
+ "slot",
2567
+ "small",
2568
+ "source",
2569
+ "span",
2570
+ "strong",
2571
+ "style",
2572
+ "sub",
2573
+ "summary",
2574
+ "sup",
2575
+ "svg",
2576
+ "table",
2577
+ "tbody",
2578
+ "td",
2579
+ "template",
2580
+ "textarea",
2581
+ "tfoot",
2582
+ "th",
2583
+ "thead",
2584
+ "time",
2585
+ "title",
2586
+ "tr",
2587
+ "track",
2588
+ "u",
2589
+ "ul",
2590
+ "var",
2591
+ "video",
2592
+ "wbr"
2593
+ ]);
2185
2594
  function isItemFixedLayout(packageDoc, itemId) {
2186
2595
  const spineItem = packageDoc.spine.find((s) => s.idref === itemId);
2187
2596
  if (!spineItem) return false;
@@ -2209,6 +2618,12 @@ var ContentValidator = class {
2209
2618
  }
2210
2619
  }
2211
2620
  }
2621
+ context.contentFeatures = {};
2622
+ const overlayDocMap = /* @__PURE__ */ new Map();
2623
+ const manifestByPath = /* @__PURE__ */ new Map();
2624
+ for (const item of packageDoc.manifest) {
2625
+ manifestByPath.set(resolveManifestHref(opfDir, item.href), item);
2626
+ }
2212
2627
  for (const item of packageDoc.manifest) {
2213
2628
  if (item.mediaType === "application/xhtml+xml") {
2214
2629
  const fullPath = resolveManifestHref(opfDir, item.href);
@@ -2224,9 +2639,96 @@ var ContentValidator = class {
2224
2639
  if (refValidator) {
2225
2640
  this.extractSVGReferences(context, fullPath, opfDir, refValidator);
2226
2641
  }
2642
+ } else if (item.mediaType === "application/smil+xml") {
2643
+ const fullPath = resolveManifestHref(opfDir, item.href);
2644
+ const smilValidator = new SMILValidator();
2645
+ const result = smilValidator.validate(context, fullPath, manifestByPath);
2646
+ overlayDocMap.set(item.id, result.referencedDocuments);
2647
+ if (refValidator) {
2648
+ for (const textRef of result.textReferences) {
2649
+ const refUrl = textRef.fragment ? `${textRef.docPath}#${textRef.fragment}` : textRef.docPath;
2650
+ const location = textRef.line != null ? { path: fullPath, line: textRef.line } : { path: fullPath };
2651
+ const ref = {
2652
+ url: refUrl,
2653
+ targetResource: textRef.docPath,
2654
+ type: "overlay-text-link" /* OVERLAY_TEXT_LINK */,
2655
+ location
2656
+ };
2657
+ if (textRef.fragment !== void 0) ref.fragment = textRef.fragment;
2658
+ refValidator.addReference(ref);
2659
+ context.overlayTextLinks ??= [];
2660
+ const link = {
2661
+ targetResource: textRef.docPath,
2662
+ location
2663
+ };
2664
+ if (textRef.fragment !== void 0) link.fragment = textRef.fragment;
2665
+ context.overlayTextLinks.push(link);
2666
+ }
2667
+ }
2668
+ if (result.hasRemoteResources) {
2669
+ const properties = item.properties ?? [];
2670
+ if (!properties.includes("remote-resources")) {
2671
+ pushMessage(context.messages, {
2672
+ id: MessageId.OPF_014,
2673
+ message: `The "remote-resources" property must be set on the media overlay item "${item.href}" because it references remote audio resources`,
2674
+ location: { path: context.opfPath ?? "" }
2675
+ });
2676
+ }
2677
+ }
2227
2678
  }
2228
2679
  this.validateMediaFile(context, item, opfDir);
2229
2680
  }
2681
+ this.validateMediaOverlayCrossRefs(context, packageDoc, opfDir, overlayDocMap);
2682
+ }
2683
+ validateMediaOverlayCrossRefs(context, packageDoc, opfDir, overlayDocMap) {
2684
+ if (overlayDocMap.size === 0) return;
2685
+ const docToOverlays = /* @__PURE__ */ new Map();
2686
+ for (const [overlayId, docPaths] of overlayDocMap) {
2687
+ for (const docPath of docPaths) {
2688
+ const existing = docToOverlays.get(docPath) ?? [];
2689
+ existing.push(overlayId);
2690
+ docToOverlays.set(docPath, existing);
2691
+ }
2692
+ }
2693
+ const opfPath = context.opfPath ?? "";
2694
+ for (const item of packageDoc.manifest) {
2695
+ if (item.mediaType !== "application/xhtml+xml" && item.mediaType !== "image/svg+xml") {
2696
+ continue;
2697
+ }
2698
+ const fullPath = resolveManifestHref(opfDir, item.href);
2699
+ const referencingOverlays = docToOverlays.get(fullPath);
2700
+ if (referencingOverlays && referencingOverlays.length > 0) {
2701
+ if (referencingOverlays.length > 1) {
2702
+ pushMessage(context.messages, {
2703
+ id: MessageId.MED_011,
2704
+ message: `EPUB Content Document "${item.href}" referenced from multiple Media Overlay Documents`,
2705
+ location: { path: opfPath }
2706
+ });
2707
+ }
2708
+ if (!item.mediaOverlay) {
2709
+ pushMessage(context.messages, {
2710
+ id: MessageId.MED_010,
2711
+ message: `EPUB Content Document "${item.href}" referenced from a Media Overlay must specify the "media-overlay" attribute`,
2712
+ location: { path: opfPath }
2713
+ });
2714
+ } else if (!referencingOverlays.includes(item.mediaOverlay)) {
2715
+ pushMessage(context.messages, {
2716
+ id: MessageId.MED_012,
2717
+ message: `The "media-overlay" attribute does not match the ID of the Media Overlay that refers to this document`,
2718
+ location: { path: opfPath }
2719
+ });
2720
+ }
2721
+ } else if (item.mediaOverlay) {
2722
+ const overlayDocs = overlayDocMap.get(item.mediaOverlay);
2723
+ if (overlayDocs && !overlayDocs.has(fullPath)) {
2724
+ pushMessage(context.messages, {
2725
+ id: MessageId.MED_013,
2726
+ message: `Media Overlay Document referenced from the "media-overlay" attribute does not contain a reference to this Content Document`,
2727
+ location: { path: opfPath }
2728
+ });
2729
+ }
2730
+ }
2731
+ }
2230
2732
  }
2231
2733
  validateMediaFile(context, item, opfDir) {
2232
2734
  const declaredType = item.mediaType;
@@ -2313,6 +2815,8 @@ var ContentValidator = class {
2313
2815
  this.validateSvgEpubType(context, path, root);
2314
2816
  this.checkUnknownEpubAttributes(context, path, root);
2315
2817
  this.checkSVGLinkAccessibility(context, path, root);
2818
+ this.checkForeignObjectContent(context, path, root, true);
2819
+ this.checkSVGTitleContent(context, path, root);
2316
2820
  const packageDoc = context.packageDocument;
2317
2821
  if (packageDoc && isItemFixedLayout(packageDoc, manifestItem.id)) {
2318
2822
  const viewBox = this.getAttribute(root, "viewBox");
@@ -2324,6 +2828,7 @@ var ContentValidator = class {
2324
2828
  });
2325
2829
  }
2326
2830
  }
2831
+ this.checkMediaOverlayActiveClassCSS(context, path, root, manifestItem, svgContent);
2327
2832
  } finally {
2328
2833
  doc.dispose();
2329
2834
  }
@@ -2534,21 +3039,24 @@ var ContentValidator = class {
2534
3039
  const hasRemoteResources = result.references.some(
2535
3040
  (ref) => ref.url.startsWith("http://") || ref.url.startsWith("https://")
2536
3041
  );
3042
+ const cssManifestItem = context.packageDocument?.manifest.find(
3043
+ (item) => path.endsWith(`/${item.href}`) || path === item.href
3044
+ );
2537
3045
  if (hasRemoteResources) {
2538
3046
  this.cssWithRemoteResources.add(path);
2539
- const packageDoc = context.packageDocument;
2540
- if (packageDoc) {
2541
- const manifestItem = packageDoc.manifest.find(
2542
- (item) => path.endsWith(`/${item.href}`) || path === item.href
2543
- );
2544
- if (manifestItem && !manifestItem.properties?.includes("remote-resources")) {
2545
- pushMessage(context.messages, {
2546
- id: MessageId.OPF_014,
2547
- message: 'CSS document references remote resources but manifest item is missing "remote-resources" property',
2548
- location: { path }
2549
- });
2550
- }
3047
+ if (cssManifestItem && !cssManifestItem.properties?.includes("remote-resources")) {
3048
+ pushMessage(context.messages, {
3049
+ id: MessageId.OPF_014,
3050
+ message: 'CSS document references remote resources but manifest item is missing "remote-resources" property',
3051
+ location: { path }
3052
+ });
2551
3053
  }
3054
+ } else if (cssManifestItem?.properties?.includes("remote-resources")) {
3055
+ pushMessage(context.messages, {
3056
+ id: MessageId.OPF_018,
3057
+ message: 'The "remote-resources" property was declared in the Package Document, but no reference to remote resources has been found',
3058
+ location: { path }
3059
+ });
2552
3060
  }
2553
3061
  const cssDir = path.includes("/") ? path.substring(0, path.lastIndexOf("/")) : "";
2554
3062
  for (const ref of result.references) {
@@ -2848,6 +3356,7 @@ var ContentValidator = class {
2848
3356
  this.checkHttpEquivCharset(context, path, root);
2849
3357
  this.checkLangMismatch(context, path, root);
2850
3358
  this.checkDpubAriaDeprecated(context, path, root);
3359
+ this.validateIdRefs(context, path, root);
2851
3360
  this.checkTableBorder(context, path, root);
2852
3361
  this.checkTimeElement(context, path, root);
2853
3362
  this.checkMathMLAnnotations(context, path, root);
@@ -2855,9 +3364,20 @@ var ContentValidator = class {
2855
3364
  this.checkDataAttributes(context, path, root);
2856
3365
  this.checkAccessibility(context, path, root);
2857
3366
  this.validateImages(context, path, root);
3367
+ this.checkUsemapAttribute(context, path, root);
3368
+ if (context.version.startsWith("3")) {
3369
+ this.checkDisallowedDescendants(context, path, root);
3370
+ this.checkMicrodataCoOccurrence(context, path, root);
3371
+ this.checkUnknownElements(context, path, root);
3372
+ this.checkForeignObjectContent(context, path, root, false);
3373
+ this.checkSVGTitleContent(context, path, root);
3374
+ }
2858
3375
  if (context.version.startsWith("3")) {
2859
3376
  this.validateEpubTypes(context, path, root);
2860
3377
  }
3378
+ if (context.version.startsWith("3")) {
3379
+ this.collectFeatures(context, root);
3380
+ }
2861
3381
  this.validateEpubSwitch(context, path, root);
2862
3382
  this.validateEpubTrigger(context, path, root);
2863
3383
  this.validateStyleAttributes(context, path, root);
@@ -2867,8 +3387,24 @@ var ContentValidator = class {
2867
3387
  this.extractAndRegisterIDs(path, root, registry);
2868
3388
  }
2869
3389
  if (refValidator && opfDir !== void 0) {
2870
- this.extractAndRegisterHyperlinks(context, path, root, opfDir, refValidator, !!isNavItem);
2871
- this.extractAndRegisterStylesheets(context, path, root, opfDir, refValidator);
3390
+ const remoteXmlBase = this.getRemoteXmlBase(root);
3391
+ this.extractAndRegisterHyperlinks(
3392
+ context,
3393
+ path,
3394
+ root,
3395
+ opfDir,
3396
+ refValidator,
3397
+ !!isNavItem,
3398
+ remoteXmlBase
3399
+ );
3400
+ this.extractAndRegisterStylesheets(
3401
+ context,
3402
+ path,
3403
+ root,
3404
+ opfDir,
3405
+ refValidator,
3406
+ remoteXmlBase
3407
+ );
2872
3408
  this.extractAndRegisterImages(context, path, root, opfDir, refValidator, registry);
2873
3409
  this.extractAndRegisterMathMLAltimg(path, root, opfDir, refValidator);
2874
3410
  this.extractAndRegisterScripts(path, root, opfDir, refValidator);
@@ -2883,10 +3419,65 @@ var ContentValidator = class {
2883
3419
  registry
2884
3420
  );
2885
3421
  }
3422
+ this.checkMediaOverlayActiveClassCSS(context, path, root, manifestItem);
2886
3423
  } finally {
2887
3424
  doc.dispose();
2888
3425
  }
2889
3426
  }
3427
+ /**
3428
+ * CSS-030: If media:active-class or media:playback-active-class is declared in OPF,
3429
+ * and this content document has a media-overlay, it must have at least some CSS.
3430
+ */
3431
+ checkMediaOverlayActiveClassCSS(context, path, root, manifestItem, decodedContent) {
3432
+ if (!manifestItem?.mediaOverlay) return;
3433
+ if (!context.mediaActiveClass && !context.mediaPlaybackActiveClass) return;
3434
+ const isSVG = root.name === "svg" || root.name.endsWith(":svg");
3435
+ let hasCSS = false;
3436
+ if (isSVG) {
3437
+ try {
3438
+ const styles = root.find(".//svg:style", { svg: "http://www.w3.org/2000/svg" });
3439
+ if (styles.length > 0) hasCSS = true;
3440
+ } catch {
3441
+ }
3442
+ if (!hasCSS) {
3443
+ try {
3444
+ const links = root.find(".//html:link", XHTML_NS);
3445
+ if (links.length > 0) hasCSS = true;
3446
+ } catch {
3447
+ }
3448
+ }
3449
+ if (!hasCSS) {
3450
+ const content = decodedContent ?? new TextDecoder().decode(context.files.get(path));
3451
+ if (content.includes("<?xml-stylesheet")) hasCSS = true;
3452
+ }
3453
+ } else {
3454
+ try {
3455
+ const links = root.find(".//html:link[@rel]", XHTML_NS);
3456
+ for (const link of links) {
3457
+ const rel = this.getAttribute(link, "rel");
3458
+ if (rel?.toLowerCase().includes("stylesheet")) {
3459
+ hasCSS = true;
3460
+ break;
3461
+ }
3462
+ }
3463
+ } catch {
3464
+ }
3465
+ if (!hasCSS) {
3466
+ try {
3467
+ const styles = root.find(".//html:style", XHTML_NS);
3468
+ if (styles.length > 0) hasCSS = true;
3469
+ } catch {
3470
+ }
3471
+ }
3472
+ }
3473
+ if (!hasCSS) {
3474
+ pushMessage(context.messages, {
3475
+ id: MessageId.CSS_030,
3476
+ message: 'The "media:active-class" property is declared in the package document but no CSS was found in this content document',
3477
+ location: { path }
3478
+ });
3479
+ }
3480
+ }
2890
3481
  parseLibxmlError(error) {
2891
3482
  const lineRegex = /(?:Entity:\s*)?line\s+(\d+):/;
2892
3483
  const lineMatch = lineRegex.exec(error);
@@ -2944,8 +3535,15 @@ var ContentValidator = class {
2944
3535
  if (types.includes("toc") && !tocNav) {
2945
3536
  tocNav = nav;
2946
3537
  }
2947
- if (types.includes("page-list")) pageListCount++;
3538
+ if (types.includes("page-list")) {
3539
+ pageListCount++;
3540
+ if (context.contentFeatures) context.contentFeatures.hasPageList = true;
3541
+ }
2948
3542
  if (types.includes("landmarks")) landmarksCount++;
3543
+ if (types.includes("loi") && context.contentFeatures) context.contentFeatures.hasLOI = true;
3544
+ if (types.includes("lot") && context.contentFeatures) context.contentFeatures.hasLOT = true;
3545
+ if (types.includes("loa") && context.contentFeatures) context.contentFeatures.hasLOA = true;
3546
+ if (types.includes("lov") && context.contentFeatures) context.contentFeatures.hasLOV = true;
2949
3547
  }
2950
3548
  if (!tocNav) {
2951
3549
  pushMessage(context.messages, {
@@ -2985,6 +3583,14 @@ var ContentValidator = class {
2985
3583
  if (!isStandard) {
2986
3584
  this.checkNavFirstChildHeading(context, path, navElem);
2987
3585
  }
3586
+ const flatNavType = types.includes("page-list") ? "page-list" : types.includes("landmarks") ? "landmarks" : null;
3587
+ if (flatNavType && navElem.find(".//html:ol", XHTML_NS).length > 1) {
3588
+ pushMessage(context.messages, {
3589
+ id: MessageId.RSC_017,
3590
+ message: `A "${flatNavType}" nav element should contain only a single ol descendant (no nested sublists)`,
3591
+ location: { path }
3592
+ });
3593
+ }
2988
3594
  if (types.includes("landmarks")) {
2989
3595
  this.checkNavLandmarks(context, path, navElem);
2990
3596
  }
@@ -3627,6 +4233,92 @@ var ContentValidator = class {
3627
4233
  } catch {
3628
4234
  }
3629
4235
  }
4236
+ collectIds(root) {
4237
+ const ids = /* @__PURE__ */ new Set();
4238
+ try {
4239
+ for (const el of root.find(".//*[@id]")) {
4240
+ const id = this.getAttribute(el, "id");
4241
+ if (id) ids.add(id);
4242
+ }
4243
+ } catch {
4244
+ }
4245
+ return ids;
4246
+ }
4247
+ validateIdRefs(context, path, root) {
4248
+ try {
4249
+ const allIds = this.collectIds(root);
4250
+ const HTML_NS = { html: "http://www.w3.org/1999/xhtml" };
4251
+ const idrefsChecks = [
4252
+ { xpath: ".//*[@aria-describedby]", attr: "aria-describedby" },
4253
+ { xpath: ".//*[@aria-flowto]", attr: "aria-flowto" },
4254
+ { xpath: ".//*[@aria-labelledby]", attr: "aria-labelledby" },
4255
+ { xpath: ".//*[@aria-owns]", attr: "aria-owns" },
4256
+ { xpath: ".//*[@aria-controls]", attr: "aria-controls" },
4257
+ { xpath: ".//html:output[@for]", attr: "for", ns: HTML_NS },
4258
+ {
4259
+ xpath: ".//html:td[@headers] | .//html:th[@headers]",
4260
+ attr: "headers",
4261
+ ns: HTML_NS
4262
+ }
4263
+ ];
4264
+ for (const { xpath, attr, ns } of idrefsChecks) {
4265
+ const elements = ns ? root.find(xpath, ns) : root.find(xpath);
4266
+ for (const elem of elements) {
4267
+ const value = this.getAttribute(elem, attr);
4268
+ if (!value) continue;
4269
+ const idrefs = value.trim().split(/\s+/);
4270
+ if (idrefs.some((idref) => !allIds.has(idref))) {
4271
+ pushMessage(context.messages, {
4272
+ id: MessageId.RSC_005,
4273
+ message: `The ${attr} attribute must refer to elements in the same document (target ID missing)`,
4274
+ location: { path, line: elem.line }
4275
+ });
4276
+ }
4277
+ }
4278
+ }
4279
+ const activedescMsg = "The aria-activedescendant attribute must refer to a descendant element.";
4280
+ for (const elem of root.find(".//*[@aria-activedescendant]")) {
4281
+ const idref = this.getAttribute(elem, "aria-activedescendant");
4282
+ if (!idref) continue;
4283
+ if (!allIds.has(idref)) {
4284
+ pushMessage(context.messages, {
4285
+ id: MessageId.RSC_005,
4286
+ message: activedescMsg,
4287
+ location: { path, line: elem.line }
4288
+ });
4289
+ } else {
4290
+ try {
4291
+ if (elem.find(`.//*[@id="${idref}"]`).length === 0) {
4292
+ pushMessage(context.messages, {
4293
+ id: MessageId.RSC_005,
4294
+ message: activedescMsg,
4295
+ location: { path, line: elem.line }
4296
+ });
4297
+ }
4298
+ } catch {
4299
+ }
4300
+ }
4301
+ }
4302
+ for (const elem of root.find(".//*[@aria-describedat]")) {
4303
+ pushMessage(context.messages, {
4304
+ id: MessageId.RSC_005,
4305
+ message: 'attribute "aria-describedat" not allowed here',
4306
+ location: { path, line: elem.line }
4307
+ });
4308
+ }
4309
+ for (const elem of root.find(".//html:label[@for]", HTML_NS)) {
4310
+ const idref = this.getAttribute(elem, "for");
4311
+ if (idref && !allIds.has(idref)) {
4312
+ pushMessage(context.messages, {
4313
+ id: MessageId.RSC_005,
4314
+ message: `The for attribute must refer to an element in the same document (the ID "${idref}" does not exist).`,
4315
+ location: { path, line: elem.line }
4316
+ });
4317
+ }
4318
+ }
4319
+ } catch {
4320
+ }
4321
+ }
3630
4322
  validateEpubSwitch(context, path, root) {
3631
4323
  const EPUB_NS = { epub: "http://www.idpf.org/2007/ops" };
3632
4324
  try {
@@ -3715,15 +4407,7 @@ var ContentValidator = class {
3715
4407
  try {
3716
4408
  const triggers = root.find(".//epub:trigger", EPUB_NS);
3717
4409
  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
- }
4410
+ const allIds = this.collectIds(root);
3727
4411
  for (const trigger of triggers) {
3728
4412
  pushMessage(context.messages, {
3729
4413
  id: MessageId.RSC_017,
@@ -3854,6 +4538,22 @@ var ContentValidator = class {
3854
4538
  } catch {
3855
4539
  }
3856
4540
  }
4541
+ checkUsemapAttribute(context, path, root) {
4542
+ try {
4543
+ const elements = root.find(".//html:*[@usemap]", XHTML_NS);
4544
+ for (const elem of elements) {
4545
+ const usemap = this.getAttribute(elem, "usemap");
4546
+ if (usemap !== null && !/^#.+$/.test(usemap)) {
4547
+ pushMessage(context.messages, {
4548
+ id: MessageId.RSC_005,
4549
+ message: `value of attribute "usemap" is invalid; must be a string matching the regular expression "#.+"`,
4550
+ location: { path, line: elem.line }
4551
+ });
4552
+ }
4553
+ }
4554
+ } catch {
4555
+ }
4556
+ }
3857
4557
  checkTimeElement(context, path, root) {
3858
4558
  const HTML_NS = { html: "http://www.w3.org/1999/xhtml" };
3859
4559
  try {
@@ -4057,7 +4757,7 @@ var ContentValidator = class {
4057
4757
  const altAttr = this.getAttribute(img, "alt");
4058
4758
  if (altAttr === null) {
4059
4759
  pushMessage(context.messages, {
4060
- id: MessageId.ACC_005,
4760
+ id: MessageId.ACC_001,
4061
4761
  message: "Image is missing alt attribute",
4062
4762
  location: { path }
4063
4763
  });
@@ -4080,6 +4780,53 @@ var ContentValidator = class {
4080
4780
  });
4081
4781
  }
4082
4782
  }
4783
+ const tables = root.find(".//html:table", XHTML_NS);
4784
+ for (const table of tables) {
4785
+ const tableElem = table;
4786
+ const thCells = tableElem.find(".//html:th", XHTML_NS);
4787
+ if (thCells.length === 0) {
4788
+ pushMessage(context.messages, {
4789
+ id: MessageId.ACC_005,
4790
+ message: 'Table heading cells should be identified by "th" elements for accessibility',
4791
+ location: { path }
4792
+ });
4793
+ }
4794
+ for (const th of thCells) {
4795
+ if (!th.content.trim()) {
4796
+ pushMessage(context.messages, {
4797
+ id: MessageId.ACC_014,
4798
+ message: "Table header cell is empty",
4799
+ location: { path }
4800
+ });
4801
+ }
4802
+ }
4803
+ if (!tableElem.get(".//html:thead", XHTML_NS)) {
4804
+ pushMessage(context.messages, {
4805
+ id: MessageId.ACC_006,
4806
+ message: 'Tables should include a "thead" element for accessibility',
4807
+ location: { path }
4808
+ });
4809
+ }
4810
+ if (!tableElem.get("./html:caption", XHTML_NS)) {
4811
+ pushMessage(context.messages, {
4812
+ id: MessageId.ACC_012,
4813
+ message: 'Table elements should include a "caption" element',
4814
+ location: { path }
4815
+ });
4816
+ }
4817
+ }
4818
+ if (context.packageDocument?.version.startsWith("3.")) {
4819
+ const epubTypeElements = root.find(".//*[@epub:type]", {
4820
+ epub: "http://www.idpf.org/2007/ops"
4821
+ });
4822
+ if (epubTypeElements.length === 0) {
4823
+ pushMessage(context.messages, {
4824
+ id: MessageId.ACC_007,
4825
+ message: 'Content Documents do not use "epub:type" attributes for semantic inflection',
4826
+ location: { path }
4827
+ });
4828
+ }
4829
+ }
4083
4830
  }
4084
4831
  hasSVGLinkAccessibleName(svgElem) {
4085
4832
  const ns = { svg: "http://www.w3.org/2000/svg" };
@@ -4104,6 +4851,46 @@ var ContentValidator = class {
4104
4851
  }
4105
4852
  }
4106
4853
  }
4854
+ collectFeatures(context, root) {
4855
+ const features = context.contentFeatures;
4856
+ if (!features) return;
4857
+ if (!features.hasTable && root.get(".//html:table", XHTML_NS)) {
4858
+ features.hasTable = true;
4859
+ }
4860
+ if (!features.hasFigure && root.get(".//html:figure", XHTML_NS)) {
4861
+ features.hasFigure = true;
4862
+ }
4863
+ if (!features.hasAudio && root.get(".//html:audio", XHTML_NS)) {
4864
+ features.hasAudio = true;
4865
+ }
4866
+ if (!features.hasVideo && root.get(".//html:video", XHTML_NS)) {
4867
+ features.hasVideo = true;
4868
+ }
4869
+ if (!features.hasPageBreak || !features.hasDictionary || !features.hasIndex) {
4870
+ const epubTypeElements = root.find(".//*[@epub:type]", EPUB_OPS_NS);
4871
+ for (const el of epubTypeElements) {
4872
+ const attr = el.attr("type", "epub");
4873
+ if (!attr?.value) continue;
4874
+ const tokens = attr.value.trim().split(/\s+/);
4875
+ if (!features.hasPageBreak && tokens.includes("pagebreak")) {
4876
+ features.hasPageBreak = true;
4877
+ }
4878
+ if (!features.hasDictionary && tokens.includes("dictionary")) {
4879
+ features.hasDictionary = true;
4880
+ }
4881
+ if (!features.hasIndex && tokens.includes("index")) {
4882
+ features.hasIndex = true;
4883
+ }
4884
+ if (features.hasPageBreak && features.hasDictionary && features.hasIndex) break;
4885
+ }
4886
+ }
4887
+ if (!features.hasMicrodata && root.get(".//*[@itemscope]")) {
4888
+ features.hasMicrodata = true;
4889
+ }
4890
+ if (!features.hasRDFa && root.get(".//*[@property]")) {
4891
+ features.hasRDFa = true;
4892
+ }
4893
+ }
4107
4894
  validateImages(context, path, root) {
4108
4895
  const packageDoc = context.packageDocument;
4109
4896
  if (!packageDoc) return;
@@ -4165,6 +4952,14 @@ var ContentValidator = class {
4165
4952
  const elemTyped = elem;
4166
4953
  const epubTypeAttr = elemTyped.attr("type", "epub");
4167
4954
  if (!epubTypeAttr?.value) continue;
4955
+ if (EPUB_TYPE_FORBIDDEN_ELEMENTS.has(elemTyped.name)) {
4956
+ pushMessage(context.messages, {
4957
+ id: MessageId.RSC_005,
4958
+ message: `attribute "epub:type" not allowed here`,
4959
+ location: { path, line: elem.line }
4960
+ });
4961
+ continue;
4962
+ }
4168
4963
  for (const part of epubTypeAttr.value.split(/\s+/)) {
4169
4964
  if (!part) continue;
4170
4965
  const hasPrefix = part.includes(":");
@@ -4194,41 +4989,30 @@ var ContentValidator = class {
4194
4989
  }
4195
4990
  validateStylesheetLinks(context, path, root) {
4196
4991
  const linkElements = root.find(".//html:link[@rel]", { html: "http://www.w3.org/1999/xhtml" });
4197
- const stylesheetTitles = /* @__PURE__ */ new Map();
4198
4992
  for (const linkElem of linkElements) {
4199
4993
  const elem = linkElem;
4200
4994
  const relAttr = this.getAttribute(elem, "rel");
4201
- const titleAttr = this.getAttribute(elem, "title");
4202
- const hrefAttr = this.getAttribute(elem, "href");
4203
- if (!relAttr || !hrefAttr) continue;
4204
- const rel = relAttr.toLowerCase();
4205
- const rels = rel.split(/\s+/);
4206
- if (rels.includes("stylesheet")) {
4207
- const isAlternate = rels.includes("alternate");
4208
- if (isAlternate && !titleAttr) {
4995
+ if (!relAttr) continue;
4996
+ const rels = relAttr.toLowerCase().split(/\s+/);
4997
+ const classAttr = this.getAttribute(elem, "class");
4998
+ if (classAttr) {
4999
+ const classSet = new Set(classAttr.toLowerCase().split(/\s+/));
5000
+ if (classSet.has("vertical") && classSet.has("horizontal") || classSet.has("day") && classSet.has("night")) {
5001
+ pushMessage(context.messages, {
5002
+ id: MessageId.CSS_005,
5003
+ message: `Conflicting Alt Style Tags found in class attribute: "${classAttr}"`,
5004
+ location: { path }
5005
+ });
5006
+ }
5007
+ }
5008
+ if (rels.includes("stylesheet") && rels.includes("alternate")) {
5009
+ if (!this.getAttribute(elem, "title")) {
4209
5010
  pushMessage(context.messages, {
4210
5011
  id: MessageId.CSS_015,
4211
5012
  message: "Alternate stylesheet must have a title attribute",
4212
5013
  location: { path }
4213
5014
  });
4214
5015
  }
4215
- if (titleAttr) {
4216
- const key = `${titleAttr}:${isAlternate ? "alt" : "persistent"}`;
4217
- const expectedRel = isAlternate ? "alternate" : "persistent";
4218
- const existing = stylesheetTitles.get(key);
4219
- if (existing) {
4220
- if (!existing.has(expectedRel)) {
4221
- pushMessage(context.messages, {
4222
- id: MessageId.CSS_005,
4223
- message: `Stylesheet with title "${titleAttr}" conflicts with another stylesheet with same title`,
4224
- location: { path }
4225
- });
4226
- }
4227
- existing.add(expectedRel);
4228
- } else {
4229
- stylesheetTitles.set(key, /* @__PURE__ */ new Set([expectedRel]));
4230
- }
4231
- }
4232
5016
  }
4233
5017
  }
4234
5018
  }
@@ -4260,6 +5044,17 @@ var ContentValidator = class {
4260
5044
  const attr = attrs.find((a) => a.name === name);
4261
5045
  return attr?.value ?? null;
4262
5046
  }
5047
+ /**
5048
+ * Get remote xml:base URL from the document root element.
5049
+ * Returns the URL if it's remote (http/https), or null otherwise.
5050
+ */
5051
+ getRemoteXmlBase(root) {
5052
+ const xmlBase = root.attr("base", "xml")?.value ?? null;
5053
+ if (xmlBase?.startsWith("http://") || xmlBase?.startsWith("https://")) {
5054
+ return xmlBase;
5055
+ }
5056
+ return null;
5057
+ }
4263
5058
  validateViewportMeta(context, path, root, manifestItem) {
4264
5059
  const packageDoc = context.packageDocument;
4265
5060
  const isFixedLayout = manifestItem && packageDoc ? isItemFixedLayout(packageDoc, manifestItem.id) : false;
@@ -4388,7 +5183,7 @@ var ContentValidator = class {
4388
5183
  }
4389
5184
  }
4390
5185
  }
4391
- extractAndRegisterHyperlinks(context, path, root, opfDir, refValidator, isNavDocument = false) {
5186
+ extractAndRegisterHyperlinks(context, path, root, opfDir, refValidator, isNavDocument = false, remoteXmlBase = null) {
4392
5187
  const docDir = path.includes("/") ? path.substring(0, path.lastIndexOf("/")) : "";
4393
5188
  const navAnchorTypes = /* @__PURE__ */ new Map();
4394
5189
  if (isNavDocument) {
@@ -4452,6 +5247,15 @@ var ContentValidator = class {
4452
5247
  });
4453
5248
  continue;
4454
5249
  }
5250
+ if (remoteXmlBase && !ABSOLUTE_URI_RE.test(href)) {
5251
+ const resolvedUrl = new URL(href, remoteXmlBase).href;
5252
+ pushMessage(context.messages, {
5253
+ id: MessageId.RSC_006,
5254
+ message: `Remote resource reference is not allowed; resource "${resolvedUrl}" must be located in the EPUB container`,
5255
+ location: { path, line }
5256
+ });
5257
+ continue;
5258
+ }
4455
5259
  const resolvedPath = this.resolveRelativePath(docDir, href, opfDir);
4456
5260
  const hashIndex = resolvedPath.indexOf("#");
4457
5261
  const targetResource = hashIndex >= 0 ? resolvedPath.slice(0, hashIndex) : resolvedPath;
@@ -4561,11 +5365,12 @@ var ContentValidator = class {
4561
5365
  refValidator.addReference(svgRef);
4562
5366
  }
4563
5367
  }
4564
- extractAndRegisterStylesheets(context, path, root, opfDir, refValidator) {
5368
+ extractAndRegisterStylesheets(context, path, root, opfDir, refValidator, remoteXmlBase = null) {
4565
5369
  const docDir = path.includes("/") ? path.substring(0, path.lastIndexOf("/")) : "";
4566
5370
  const baseElem = root.get(".//html:base[@href]", { html: "http://www.w3.org/1999/xhtml" });
4567
5371
  const baseHref = baseElem ? this.getAttribute(baseElem, "href") : null;
4568
- const remoteBaseUrl = baseHref?.startsWith("http://") || baseHref?.startsWith("https://") ? baseHref : null;
5372
+ const effectiveBase = baseHref ?? remoteXmlBase;
5373
+ const remoteBaseUrl = effectiveBase?.startsWith("http://") || effectiveBase?.startsWith("https://") ? effectiveBase : null;
4569
5374
  const linkElements = root.find(".//html:link[@href]", { html: "http://www.w3.org/1999/xhtml" });
4570
5375
  for (const linkElem of linkElements) {
4571
5376
  const href = this.getAttribute(linkElem, "href");
@@ -5150,56 +5955,309 @@ var ContentValidator = class {
5150
5955
  }
5151
5956
  }
5152
5957
  }
5153
- parseSrcset(srcset, docDir, opfDir, path, line, refValidator) {
5154
- const entries = srcset.split(",");
5155
- for (const entry of entries) {
5156
- const trimmed = entry.trim();
5157
- if (!trimmed) continue;
5158
- const url = trimmed.split(/\s+/)[0];
5159
- if (!url) continue;
5160
- const location = line !== void 0 ? { path, line } : { path };
5161
- if (url.startsWith("http://") || url.startsWith("https://")) {
5162
- refValidator.addReference({
5163
- url,
5164
- targetResource: url,
5165
- type: "image" /* IMAGE */,
5166
- location
5167
- });
5168
- } else {
5169
- const resolvedPath = this.resolveRelativePath(docDir, url, opfDir);
5170
- refValidator.addReference({
5171
- url,
5172
- targetResource: resolvedPath,
5173
- type: "image" /* IMAGE */,
5174
- location
5175
- });
5176
- }
5177
- }
5178
- }
5179
- resolveRelativePath(docDir, href, _opfDir) {
5180
- let decoded;
5958
+ parseSrcset(srcset, docDir, opfDir, path, line, refValidator) {
5959
+ const entries = srcset.split(",");
5960
+ for (const entry of entries) {
5961
+ const trimmed = entry.trim();
5962
+ if (!trimmed) continue;
5963
+ const url = trimmed.split(/\s+/)[0];
5964
+ if (!url) continue;
5965
+ const location = line !== void 0 ? { path, line } : { path };
5966
+ if (url.startsWith("http://") || url.startsWith("https://")) {
5967
+ refValidator.addReference({
5968
+ url,
5969
+ targetResource: url,
5970
+ type: "image" /* IMAGE */,
5971
+ location
5972
+ });
5973
+ } else {
5974
+ const resolvedPath = this.resolveRelativePath(docDir, url, opfDir);
5975
+ refValidator.addReference({
5976
+ url,
5977
+ targetResource: resolvedPath,
5978
+ type: "image" /* IMAGE */,
5979
+ location
5980
+ });
5981
+ }
5982
+ }
5983
+ }
5984
+ resolveRelativePath(docDir, href, _opfDir) {
5985
+ let decoded;
5986
+ try {
5987
+ decoded = decodeURIComponent(href);
5988
+ } catch {
5989
+ decoded = href;
5990
+ }
5991
+ const hrefWithoutFragment = decoded.split("#")[0] ?? decoded;
5992
+ const fragment = decoded.includes("#") ? decoded.split("#")[1] : "";
5993
+ if (hrefWithoutFragment.startsWith("/")) {
5994
+ const result2 = hrefWithoutFragment.slice(1).normalize("NFC");
5995
+ return fragment ? `${result2}#${fragment}` : result2;
5996
+ }
5997
+ const parts = docDir ? docDir.split("/") : [];
5998
+ const relParts = hrefWithoutFragment.split("/");
5999
+ for (const part of relParts) {
6000
+ if (part === "..") {
6001
+ parts.pop();
6002
+ } else if (part !== "." && part !== "") {
6003
+ parts.push(part);
6004
+ }
6005
+ }
6006
+ const result = parts.join("/").normalize("NFC");
6007
+ return fragment ? `${result}#${fragment}` : result;
6008
+ }
6009
+ // ── Schematron-equivalent checks ──────────────────────────────────────────
6010
+ checkDisallowedDescendants(context, path, root) {
6011
+ const pairsByAncestor = /* @__PURE__ */ new Map([
6012
+ ["dfn", ["dfn"]],
6013
+ ["form", ["form"]],
6014
+ ["progress", ["progress"]],
6015
+ ["meter", ["meter"]],
6016
+ ["header", ["header", "footer"]],
6017
+ ["footer", ["footer", "header"]],
6018
+ ["label", ["label"]],
6019
+ ["address", ["address", "header", "footer"]],
6020
+ ["caption", ["table"]],
6021
+ ["audio", ["audio", "video"]],
6022
+ ["video", ["video", "audio"]]
6023
+ ]);
6024
+ for (const [ancestor, descendants] of pairsByAncestor) {
6025
+ try {
6026
+ if (root.find(`.//html:${ancestor}`, XHTML_NS).length === 0) continue;
6027
+ } catch {
6028
+ continue;
6029
+ }
6030
+ for (const descendant of descendants) {
6031
+ try {
6032
+ const matches = root.find(`.//html:${ancestor}//html:${descendant}`, XHTML_NS);
6033
+ for (const el of matches) {
6034
+ pushMessage(context.messages, {
6035
+ id: MessageId.RSC_005,
6036
+ message: `The ${descendant} element must not appear inside ${ancestor} elements`,
6037
+ location: { path, line: el.line }
6038
+ });
6039
+ }
6040
+ } catch {
6041
+ }
6042
+ }
6043
+ }
6044
+ const interactiveExprs = [
6045
+ "html:a",
6046
+ "html:audio[@controls]",
6047
+ "html:button",
6048
+ "html:details",
6049
+ "html:embed",
6050
+ "html:iframe",
6051
+ "html:img[@usemap]",
6052
+ "html:input[not(@type='hidden')]",
6053
+ "html:label",
6054
+ "html:select",
6055
+ "html:textarea",
6056
+ "html:video[@controls]"
6057
+ ];
6058
+ for (const ancestor of ["a", "button"]) {
6059
+ try {
6060
+ if (root.find(`.//html:${ancestor}`, XHTML_NS).length === 0) continue;
6061
+ } catch {
6062
+ continue;
6063
+ }
6064
+ for (const expr of interactiveExprs) {
6065
+ try {
6066
+ const matches = root.find(`.//html:${ancestor}//${expr}`, XHTML_NS);
6067
+ for (const el of matches) {
6068
+ const xmlEl = el;
6069
+ const localName = xmlEl.name.includes(":") ? xmlEl.name.substring(xmlEl.name.indexOf(":") + 1) : xmlEl.name;
6070
+ pushMessage(context.messages, {
6071
+ id: MessageId.RSC_005,
6072
+ message: `The ${localName} element must not appear inside ${ancestor} elements`,
6073
+ location: { path, line: el.line }
6074
+ });
6075
+ }
6076
+ } catch {
6077
+ }
6078
+ }
6079
+ }
6080
+ try {
6081
+ const bdos = root.find(".//html:bdo[not(@dir)]", XHTML_NS);
6082
+ for (const el of bdos) {
6083
+ pushMessage(context.messages, {
6084
+ id: MessageId.RSC_005,
6085
+ message: "The bdo element must have a dir attribute",
6086
+ location: { path, line: el.line }
6087
+ });
6088
+ }
6089
+ } catch {
6090
+ }
6091
+ try {
6092
+ const maps = root.find(".//html:map[@id and @name]", XHTML_NS);
6093
+ for (const el of maps) {
6094
+ const id = this.getAttribute(el, "id");
6095
+ const name = this.getAttribute(el, "name");
6096
+ if (id && name && id !== name) {
6097
+ pushMessage(context.messages, {
6098
+ id: MessageId.RSC_005,
6099
+ message: "The id attribute on the map element must have the same value as the name attribute",
6100
+ location: { path, line: el.line }
6101
+ });
6102
+ }
6103
+ }
6104
+ } catch {
6105
+ }
6106
+ }
6107
+ checkMicrodataCoOccurrence(context, path, root) {
6108
+ try {
6109
+ const els = root.find(
6110
+ ".//html:a[@itemprop and not(@href)] | .//html:area[@itemprop and not(@href)]",
6111
+ XHTML_NS
6112
+ );
6113
+ for (const el of els) {
6114
+ pushMessage(context.messages, {
6115
+ id: MessageId.RSC_005,
6116
+ message: "If the itemprop is specified on an a element, then the href attribute must also be specified",
6117
+ location: { path, line: el.line }
6118
+ });
6119
+ }
6120
+ } catch {
6121
+ }
6122
+ try {
6123
+ const els = root.find(
6124
+ ".//html:iframe[@itemprop and not(@data)] | .//html:embed[@itemprop and not(@data)] | .//html:object[@itemprop and not(@data)]",
6125
+ XHTML_NS
6126
+ );
6127
+ for (const el of els) {
6128
+ pushMessage(context.messages, {
6129
+ id: MessageId.RSC_005,
6130
+ message: "If the itemprop is specified on an iframe, embed or object element, then the data attribute must also be specified",
6131
+ location: { path, line: el.line }
6132
+ });
6133
+ }
6134
+ } catch {
6135
+ }
6136
+ try {
6137
+ const els = root.find(
6138
+ ".//html:audio[@itemprop and not(@src)] | .//html:video[@itemprop and not(@src)]",
6139
+ XHTML_NS
6140
+ );
6141
+ for (const el of els) {
6142
+ pushMessage(context.messages, {
6143
+ id: MessageId.RSC_005,
6144
+ message: "If the itemprop is specified on an video or audio element, then the src attribute must also be specified",
6145
+ location: { path, line: el.line }
6146
+ });
6147
+ }
6148
+ } catch {
6149
+ }
6150
+ }
6151
+ checkUnknownElements(context, path, root) {
6152
+ const XHTML_NS2 = "http://www.w3.org/1999/xhtml";
6153
+ try {
6154
+ const allElements = root.find(".//*");
6155
+ for (const el of allElements) {
6156
+ const xmlEl = el;
6157
+ const ns = xmlEl.namespaceUri;
6158
+ if (ns !== XHTML_NS2) continue;
6159
+ const localName = xmlEl.name.includes(":") ? xmlEl.name.substring(xmlEl.name.indexOf(":") + 1) : xmlEl.name;
6160
+ if (localName.includes("-")) continue;
6161
+ if (!HTML5_ELEMENTS.has(localName)) {
6162
+ pushMessage(context.messages, {
6163
+ id: MessageId.RSC_005,
6164
+ message: `element "${localName}" not allowed here`,
6165
+ location: { path, line: el.line }
6166
+ });
6167
+ }
6168
+ }
6169
+ } catch {
6170
+ }
6171
+ }
6172
+ checkForeignObjectContent(context, path, root, isSVGDoc) {
6173
+ const SVG_NS = { svg: "http://www.w3.org/2000/svg" };
6174
+ const XHTML_URI = "http://www.w3.org/1999/xhtml";
6175
+ const DISALLOWED_FO_CHILDREN = /* @__PURE__ */ new Set(["body", "head", "html", "title"]);
6176
+ let foreignObjects;
6177
+ try {
6178
+ foreignObjects = root.find(".//svg:foreignObject", SVG_NS);
6179
+ } catch {
6180
+ return;
6181
+ }
6182
+ for (const fo of foreignObjects) {
6183
+ const foEl = fo;
6184
+ let children;
6185
+ try {
6186
+ children = foEl.find("./*");
6187
+ } catch {
6188
+ continue;
6189
+ }
6190
+ let bodyCount = 0;
6191
+ for (const child of children) {
6192
+ const childEl = child;
6193
+ const childNs = childEl.namespaceUri;
6194
+ const childLocal = childEl.name.includes(":") ? childEl.name.substring(childEl.name.indexOf(":") + 1) : childEl.name;
6195
+ if (isSVGDoc) {
6196
+ if (childNs !== XHTML_URI) {
6197
+ pushMessage(context.messages, {
6198
+ id: MessageId.RSC_005,
6199
+ message: `element "${childLocal}" not allowed here`,
6200
+ location: { path, line: child.line }
6201
+ });
6202
+ continue;
6203
+ }
6204
+ if (childLocal === "body") {
6205
+ bodyCount++;
6206
+ if (bodyCount > 1) {
6207
+ pushMessage(context.messages, {
6208
+ id: MessageId.RSC_005,
6209
+ message: 'element "body" not allowed here',
6210
+ location: { path, line: child.line }
6211
+ });
6212
+ }
6213
+ } else if (childLocal === "title" || childLocal === "head" || childLocal === "html") {
6214
+ pushMessage(context.messages, {
6215
+ id: MessageId.RSC_005,
6216
+ message: `element "${childLocal}" not allowed here`,
6217
+ location: { path, line: child.line }
6218
+ });
6219
+ }
6220
+ } else if (childNs === XHTML_URI && DISALLOWED_FO_CHILDREN.has(childLocal)) {
6221
+ pushMessage(context.messages, {
6222
+ id: MessageId.RSC_005,
6223
+ message: `element "${childLocal}" not allowed here`,
6224
+ location: { path, line: child.line }
6225
+ });
6226
+ }
6227
+ }
6228
+ }
6229
+ }
6230
+ checkSVGTitleContent(context, path, root) {
6231
+ const SVG_NS = { svg: "http://www.w3.org/2000/svg" };
6232
+ const XHTML_URI = "http://www.w3.org/1999/xhtml";
6233
+ let svgTitles;
5181
6234
  try {
5182
- decoded = decodeURIComponent(href);
6235
+ svgTitles = root.find(".//svg:title", SVG_NS);
5183
6236
  } catch {
5184
- decoded = href;
5185
- }
5186
- const hrefWithoutFragment = decoded.split("#")[0] ?? decoded;
5187
- const fragment = decoded.includes("#") ? decoded.split("#")[1] : "";
5188
- if (hrefWithoutFragment.startsWith("/")) {
5189
- const result2 = hrefWithoutFragment.slice(1).normalize("NFC");
5190
- return fragment ? `${result2}#${fragment}` : result2;
6237
+ return;
5191
6238
  }
5192
- const parts = docDir ? docDir.split("/") : [];
5193
- const relParts = hrefWithoutFragment.split("/");
5194
- for (const part of relParts) {
5195
- if (part === "..") {
5196
- parts.pop();
5197
- } else if (part !== "." && part !== "") {
5198
- parts.push(part);
6239
+ for (const titleNode of svgTitles) {
6240
+ const titleEl = titleNode;
6241
+ let descendants;
6242
+ try {
6243
+ descendants = titleEl.find(".//*");
6244
+ } catch {
6245
+ continue;
6246
+ }
6247
+ const reportedNamespaces = /* @__PURE__ */ new Set();
6248
+ for (const desc of descendants) {
6249
+ const descEl = desc;
6250
+ const descNs = descEl.namespaceUri;
6251
+ if (descNs && descNs !== XHTML_URI && !reportedNamespaces.has(descNs)) {
6252
+ reportedNamespaces.add(descNs);
6253
+ pushMessage(context.messages, {
6254
+ id: MessageId.RSC_005,
6255
+ message: `elements from namespace "${descNs}" are not allowed`,
6256
+ location: { path, line: desc.line }
6257
+ });
6258
+ }
5199
6259
  }
5200
6260
  }
5201
- const result = parts.join("/").normalize("NFC");
5202
- return fragment ? `${result}#${fragment}` : result;
5203
6261
  }
5204
6262
  };
5205
6263
 
@@ -5649,6 +6707,8 @@ var OCFValidator = class {
5649
6707
  this.validateUtf8Filenames(zip, context.messages);
5650
6708
  this.validateEmptyDirectories(zip, context.messages);
5651
6709
  this.parseEncryption(zip, context);
6710
+ this.validateEncryptionXml(context);
6711
+ this.validateSignaturesXml(context);
5652
6712
  }
5653
6713
  /**
5654
6714
  * Validate the mimetype file
@@ -6005,6 +7065,93 @@ var OCFValidator = class {
6005
7065
  context.obfuscatedResources = obfuscated;
6006
7066
  }
6007
7067
  }
7068
+ /**
7069
+ * Validate encryption.xml structure:
7070
+ * - Root element must be "encryption" in OCF namespace
7071
+ * - Compression Method must be "0" or "8"
7072
+ * - Compression OriginalLength must be a non-negative integer
7073
+ * - All Id attributes must be unique
7074
+ */
7075
+ extractRootElementName(xml) {
7076
+ const match = /<(\w+)[\s>]/.exec(xml.replace(/<\?xml[^?]*\?>/, "").trimStart());
7077
+ return match?.[1] ?? null;
7078
+ }
7079
+ validateEncryptionXml(context) {
7080
+ const encPath = "META-INF/encryption.xml";
7081
+ const content = context.files.get(encPath);
7082
+ if (!content) return;
7083
+ const xml = new TextDecoder().decode(content);
7084
+ const rootName = this.extractRootElementName(xml);
7085
+ if (rootName !== null && rootName !== "encryption") {
7086
+ pushMessage(context.messages, {
7087
+ id: MessageId.RSC_005,
7088
+ message: `expected element "encryption" but found "${rootName}"`,
7089
+ location: { path: encPath }
7090
+ });
7091
+ return;
7092
+ }
7093
+ const idPattern = /\bId=["']([^"']+)["']/g;
7094
+ const ids = /* @__PURE__ */ new Map();
7095
+ let idMatch;
7096
+ while ((idMatch = idPattern.exec(xml)) !== null) {
7097
+ const id = idMatch[1] ?? "";
7098
+ ids.set(id, (ids.get(id) ?? 0) + 1);
7099
+ }
7100
+ for (const [id, count] of ids) {
7101
+ if (count > 1) {
7102
+ pushMessage(context.messages, {
7103
+ id: MessageId.RSC_005,
7104
+ message: `Duplicate "${id}"`,
7105
+ location: { path: encPath }
7106
+ });
7107
+ }
7108
+ }
7109
+ const compressionPattern = /<(?:\w+:)?Compression\s+([^>]*)\/?>/g;
7110
+ let compMatch;
7111
+ while ((compMatch = compressionPattern.exec(xml)) !== null) {
7112
+ const attrs = compMatch[1] ?? "";
7113
+ const methodMatch = /Method=["']([^"']*)["']/.exec(attrs);
7114
+ const lengthMatch = /OriginalLength=["']([^"']*)["']/.exec(attrs);
7115
+ if (methodMatch) {
7116
+ const method = methodMatch[1] ?? "";
7117
+ if (method !== "0" && method !== "8") {
7118
+ pushMessage(context.messages, {
7119
+ id: MessageId.RSC_005,
7120
+ message: `value of attribute "Method" is invalid; must be "0" or "8"`,
7121
+ location: { path: encPath }
7122
+ });
7123
+ }
7124
+ }
7125
+ if (lengthMatch) {
7126
+ const length = lengthMatch[1] ?? "";
7127
+ if (!/^\d+$/.test(length)) {
7128
+ pushMessage(context.messages, {
7129
+ id: MessageId.RSC_005,
7130
+ message: `value of attribute "OriginalLength" is invalid; must be a non-negative integer`,
7131
+ location: { path: encPath }
7132
+ });
7133
+ }
7134
+ }
7135
+ }
7136
+ }
7137
+ /**
7138
+ * Validate signatures.xml structure:
7139
+ * - Root element must be "signatures" in OCF namespace
7140
+ */
7141
+ validateSignaturesXml(context) {
7142
+ const sigPath = "META-INF/signatures.xml";
7143
+ const content = context.files.get(sigPath);
7144
+ if (!content) return;
7145
+ const xml = new TextDecoder().decode(content);
7146
+ const rootName = this.extractRootElementName(xml);
7147
+ if (rootName !== null && rootName !== "signatures") {
7148
+ pushMessage(context.messages, {
7149
+ id: MessageId.RSC_005,
7150
+ message: `expected element "signatures" but found "${rootName}"`,
7151
+ location: { path: sigPath }
7152
+ });
7153
+ }
7154
+ }
6008
7155
  /**
6009
7156
  * Validate empty directories
6010
7157
  */
@@ -6032,6 +7179,65 @@ var OCFValidator = class {
6032
7179
  }
6033
7180
  };
6034
7181
 
7182
+ // src/util/encoding.ts
7183
+ function sniffXmlEncoding(data) {
7184
+ if (data.length < 2) return null;
7185
+ if (data.length >= 4) {
7186
+ if (data[0] === 0 && data[1] === 0 && data[2] === 254 && data[3] === 255) {
7187
+ return "UCS-4";
7188
+ }
7189
+ if (data[0] === 255 && data[1] === 254 && data[2] === 0 && data[3] === 0) {
7190
+ return "UCS-4";
7191
+ }
7192
+ if (data[0] === 0 && data[1] === 0 && data[2] === 255 && data[3] === 254) {
7193
+ return "UCS-4";
7194
+ }
7195
+ if (data[0] === 254 && data[1] === 255 && data[2] === 0 && data[3] === 0) {
7196
+ return "UCS-4";
7197
+ }
7198
+ if (data[0] === 0 && data[1] === 0 && data[2] === 0 && data[3] === 60) {
7199
+ return "UCS-4";
7200
+ }
7201
+ if (data[0] === 60 && data[1] === 0 && data[2] === 0 && data[3] === 0) {
7202
+ return "UCS-4";
7203
+ }
7204
+ if (data[0] === 0 && data[1] === 0 && data[2] === 60 && data[3] === 0) {
7205
+ return "UCS-4";
7206
+ }
7207
+ if (data[0] === 0 && data[1] === 60 && data[2] === 0 && data[3] === 0) {
7208
+ return "UCS-4";
7209
+ }
7210
+ }
7211
+ if (data[0] === 254 && data[1] === 255) {
7212
+ return "UTF-16";
7213
+ }
7214
+ if (data[0] === 255 && data[1] === 254) {
7215
+ return "UTF-16";
7216
+ }
7217
+ if (data.length >= 4) {
7218
+ if (data[0] === 0 && data[1] === 60 && data[2] === 0 && data[3] === 63) {
7219
+ return "UTF-16";
7220
+ }
7221
+ if (data[0] === 60 && data[1] === 0 && data[2] === 63 && data[3] === 0) {
7222
+ return "UTF-16";
7223
+ }
7224
+ }
7225
+ if (data.length >= 3 && data[0] === 239 && data[1] === 187 && data[2] === 191) {
7226
+ return null;
7227
+ }
7228
+ if (data.length >= 4 && data[0] === 76 && data[1] === 111 && data[2] === 167 && data[3] === 148) {
7229
+ return "EBCDIC";
7230
+ }
7231
+ const prefix = String.fromCharCode(...data.slice(0, Math.min(256, data.length)));
7232
+ const match = /^<\?xml[^?]*\bencoding\s*=\s*["']([^"']+)["']/.exec(prefix);
7233
+ if (match) {
7234
+ const declared = (match[1] ?? "").toUpperCase();
7235
+ if (declared === "UTF-8") return null;
7236
+ return declared;
7237
+ }
7238
+ return null;
7239
+ }
7240
+
6035
7241
  // src/opf/parser.ts
6036
7242
  function parseOPF(xml) {
6037
7243
  const packageRegex = /<package[^>]*\sversion=["']([^"']+)["'][^>]*(?:\sunique-identifier=["']([^"']+)["'])?[^>]*>/;
@@ -6382,6 +7588,25 @@ var DEPRECATED_MEDIA_TYPES = /* @__PURE__ */ new Set([
6382
7588
  "application/x-oeb1-package",
6383
7589
  "text/x-oeb1-html"
6384
7590
  ]);
7591
+ function getPreferredMediaType(mimeType, path) {
7592
+ switch (mimeType) {
7593
+ case "application/font-sfnt":
7594
+ if (path.endsWith(".ttf")) return "font/ttf";
7595
+ if (path.endsWith(".otf")) return "font/otf";
7596
+ return "font/(ttf|otf)";
7597
+ case "application/vnd.ms-opentype":
7598
+ return "font/otf";
7599
+ case "application/font-woff":
7600
+ return "font/woff";
7601
+ case "application/x-font-ttf":
7602
+ return "font/ttf";
7603
+ case "text/javascript":
7604
+ case "application/ecmascript":
7605
+ return "application/javascript";
7606
+ default:
7607
+ return null;
7608
+ }
7609
+ }
6385
7610
  var VALID_RELATOR_CODES = /* @__PURE__ */ new Set([
6386
7611
  "abr",
6387
7612
  "acp",
@@ -6718,7 +7943,6 @@ var RENDITION_META_RULES = [
6718
7943
  var KNOWN_RENDITION_META_PROPERTIES = new Set(
6719
7944
  RENDITION_META_RULES.map((r) => r.property.slice("rendition:".length))
6720
7945
  );
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
7946
  var GRANDFATHERED_LANG_TAGS = /* @__PURE__ */ new Set([
6723
7947
  "en-GB-oed",
6724
7948
  "i-ami",
@@ -6772,6 +7996,20 @@ var OPFValidator = class {
6772
7996
  });
6773
7997
  return;
6774
7998
  }
7999
+ const encoding = sniffXmlEncoding(opfData);
8000
+ if (encoding === "UTF-16") {
8001
+ pushMessage(context.messages, {
8002
+ id: MessageId.RSC_027,
8003
+ message: `Detected non-UTF-8 encoding "${encoding}" in "${opfPath}"`,
8004
+ location: { path: opfPath }
8005
+ });
8006
+ } else if (encoding !== null) {
8007
+ pushMessage(context.messages, {
8008
+ id: MessageId.RSC_028,
8009
+ message: `Detected non-UTF-8 encoding "${encoding}" in "${opfPath}"`,
8010
+ location: { path: opfPath }
8011
+ });
8012
+ }
6775
8013
  const opfXml = new TextDecoder().decode(opfData);
6776
8014
  try {
6777
8015
  this.packageDoc = parseOPF(opfXml);
@@ -6817,6 +8055,9 @@ var OPFValidator = class {
6817
8055
  }
6818
8056
  }
6819
8057
  }
8058
+ if (this.packageDoc.version.startsWith("3.")) {
8059
+ this.validateAccessibilityMetadata(context, opfPath);
8060
+ }
6820
8061
  }
6821
8062
  /**
6822
8063
  * Build lookup maps for manifest items
@@ -6866,6 +8107,41 @@ var OPFValidator = class {
6866
8107
  }
6867
8108
  }
6868
8109
  }
8110
+ /**
8111
+ * Validate accessibility metadata (ACC-002, ACC-003, ACC-010)
8112
+ */
8113
+ validateAccessibilityMetadata(context, opfPath) {
8114
+ if (!this.packageDoc) return;
8115
+ const metaElements = this.packageDoc.metaElements;
8116
+ const a11yProperties = [
8117
+ "schema:accessMode",
8118
+ "schema:accessibilityFeature",
8119
+ "schema:accessibilityHazard",
8120
+ "schema:accessibilitySummary"
8121
+ ];
8122
+ const hasAnyA11y = metaElements.some((m) => a11yProperties.includes(m.property));
8123
+ if (!hasAnyA11y) {
8124
+ pushMessage(context.messages, {
8125
+ id: MessageId.ACC_003,
8126
+ message: "Publication does not include any accessibility metadata",
8127
+ location: { path: opfPath }
8128
+ });
8129
+ }
8130
+ if (!metaElements.some((m) => m.property === "schema:accessibilityFeature")) {
8131
+ pushMessage(context.messages, {
8132
+ id: MessageId.ACC_002,
8133
+ message: 'Missing "schema:accessibilityFeature" metadata',
8134
+ location: { path: opfPath }
8135
+ });
8136
+ }
8137
+ if (!metaElements.some((m) => m.property === "schema:accessMode")) {
8138
+ pushMessage(context.messages, {
8139
+ id: MessageId.ACC_010,
8140
+ message: 'Missing "schema:accessMode" metadata',
8141
+ location: { path: opfPath }
8142
+ });
8143
+ }
8144
+ }
6869
8145
  /**
6870
8146
  * Validate metadata section
6871
8147
  */
@@ -7063,6 +8339,7 @@ var OPFValidator = class {
7063
8339
  this.validateMetaPropertiesVocab(context, opfPath, dcElements);
7064
8340
  this.validateRenditionVocab(context, opfPath);
7065
8341
  this.validateMediaOverlaysVocab(context, opfPath);
8342
+ this.validateMediaOverlayItems(context, opfPath);
7066
8343
  }
7067
8344
  if (this.packageDoc.version !== "2.0") {
7068
8345
  const modifiedMetas = this.packageDoc.metaElements.filter(
@@ -7430,19 +8707,42 @@ var OPFValidator = class {
7430
8707
  validateMediaOverlaysVocab(context, opfPath) {
7431
8708
  if (!this.packageDoc) return;
7432
8709
  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);
8710
+ const matchingActive = metas.filter((m) => m.property === "media:active-class");
8711
+ const matchingPlayback = metas.filter((m) => m.property === "media:playback-active-class");
8712
+ for (const [prop, matching] of [
8713
+ ["media:active-class", matchingActive],
8714
+ ["media:playback-active-class", matchingPlayback]
8715
+ ]) {
8716
+ const displayName = prop.slice("media:".length);
8717
+ if (matching.length > 1) {
7436
8718
  pushMessage(context.messages, {
7437
8719
  id: MessageId.RSC_005,
7438
8720
  message: `The '${displayName}' property must not occur more than one time in the package metadata`,
7439
8721
  location: { path: opfPath }
7440
8722
  });
7441
8723
  }
8724
+ for (const meta of matching) {
8725
+ if (meta.refines) {
8726
+ pushMessage(context.messages, {
8727
+ id: MessageId.RSC_005,
8728
+ message: `@refines must not be used with the ${prop} property`,
8729
+ location: { path: opfPath }
8730
+ });
8731
+ }
8732
+ if (meta.value.trim().includes(" ")) {
8733
+ pushMessage(context.messages, {
8734
+ id: MessageId.RSC_005,
8735
+ message: `the '${displayName}' property must define a single class name`,
8736
+ location: { path: opfPath }
8737
+ });
8738
+ }
8739
+ }
7442
8740
  }
8741
+ if (matchingActive[0]) context.mediaActiveClass = matchingActive[0].value.trim();
8742
+ if (matchingPlayback[0]) context.mediaPlaybackActiveClass = matchingPlayback[0].value.trim();
7443
8743
  for (const meta of metas) {
7444
8744
  if (meta.property === "media:duration") {
7445
- if (!SMIL3_CLOCK_RE.test(meta.value.trim())) {
8745
+ if (!isValidSmilClock(meta.value.trim())) {
7446
8746
  pushMessage(context.messages, {
7447
8747
  id: MessageId.RSC_005,
7448
8748
  message: `The value of the "media:duration" property must be a valid SMIL3 clock value`,
@@ -7451,6 +8751,84 @@ var OPFValidator = class {
7451
8751
  }
7452
8752
  }
7453
8753
  }
8754
+ const globalDuration = metas.find((m) => m.property === "media:duration" && !m.refines);
8755
+ if (globalDuration) {
8756
+ const totalSeconds = parseSmilClock(globalDuration.value.trim());
8757
+ if (!Number.isNaN(totalSeconds)) {
8758
+ let sumSeconds = 0;
8759
+ let allValid = true;
8760
+ for (const meta of metas) {
8761
+ if (meta.property === "media:duration" && meta.refines) {
8762
+ const s = parseSmilClock(meta.value.trim());
8763
+ if (Number.isNaN(s)) {
8764
+ allValid = false;
8765
+ break;
8766
+ }
8767
+ sumSeconds += s;
8768
+ }
8769
+ }
8770
+ if (allValid && Math.abs(totalSeconds - sumSeconds) > 1) {
8771
+ pushMessage(context.messages, {
8772
+ id: MessageId.MED_016,
8773
+ message: `Media Overlays total duration should be the sum of the durations of all Media Overlays documents.`,
8774
+ location: { path: opfPath }
8775
+ });
8776
+ }
8777
+ }
8778
+ }
8779
+ }
8780
+ /**
8781
+ * Validate media-overlay manifest item constraints:
8782
+ * - media-overlay must reference a SMIL item (application/smil+xml)
8783
+ * - media-overlay attribute only allowed on XHTML and SVG content documents
8784
+ * - Global media:duration required when overlays exist
8785
+ * - Per-item media:duration required for each overlay
8786
+ */
8787
+ validateMediaOverlayItems(context, opfPath) {
8788
+ if (!this.packageDoc) return;
8789
+ const manifest = this.packageDoc.manifest;
8790
+ const metas = this.packageDoc.metaElements;
8791
+ const itemsWithOverlay = manifest.filter((item) => item.mediaOverlay);
8792
+ if (itemsWithOverlay.length === 0) return;
8793
+ for (const item of itemsWithOverlay) {
8794
+ const moId = item.mediaOverlay;
8795
+ if (!moId) continue;
8796
+ const moItem = this.manifestById.get(moId);
8797
+ if (moItem && moItem.mediaType !== "application/smil+xml") {
8798
+ pushMessage(context.messages, {
8799
+ id: MessageId.RSC_005,
8800
+ message: `media overlay items must be of the "application/smil+xml" type (given type was "${moItem.mediaType}")`,
8801
+ location: { path: opfPath }
8802
+ });
8803
+ }
8804
+ if (item.mediaType !== "application/xhtml+xml" && item.mediaType !== "image/svg+xml") {
8805
+ pushMessage(context.messages, {
8806
+ id: MessageId.RSC_005,
8807
+ message: `The media-overlay attribute is only allowed on XHTML and SVG content documents.`,
8808
+ location: { path: opfPath }
8809
+ });
8810
+ }
8811
+ }
8812
+ if (!metas.some((m) => m.property === "media:duration" && !m.refines)) {
8813
+ pushMessage(context.messages, {
8814
+ id: MessageId.RSC_005,
8815
+ message: `global media:duration meta element not set`,
8816
+ location: { path: opfPath }
8817
+ });
8818
+ }
8819
+ const overlayIds = new Set(
8820
+ itemsWithOverlay.map((item) => item.mediaOverlay).filter((id) => id != null && this.manifestById.has(id))
8821
+ );
8822
+ for (const overlayId of overlayIds) {
8823
+ const refinesUri = `#${overlayId}`;
8824
+ if (!metas.some((m) => m.property === "media:duration" && m.refines === refinesUri)) {
8825
+ pushMessage(context.messages, {
8826
+ id: MessageId.RSC_005,
8827
+ message: `item media:duration meta element not set (expecting: meta property='media:duration' refines='${refinesUri}')`,
8828
+ location: { path: opfPath }
8829
+ });
8830
+ }
8831
+ }
7454
8832
  }
7455
8833
  /**
7456
8834
  * Validate EPUB 3 link elements in metadata
@@ -7683,6 +9061,14 @@ var OPFValidator = class {
7683
9061
  location: { path: opfPath }
7684
9062
  });
7685
9063
  }
9064
+ const preferred = getPreferredMediaType(item.mediaType, fullPath);
9065
+ if (preferred !== null) {
9066
+ pushMessage(context.messages, {
9067
+ id: MessageId.OPF_090,
9068
+ message: `Encouraged to use media type "${preferred}" instead of "${item.mediaType}"`,
9069
+ location: { path: opfPath }
9070
+ });
9071
+ }
7686
9072
  if (this.packageDoc.version !== "2.0" && item.properties) {
7687
9073
  for (const prop of item.properties) {
7688
9074
  if (!ITEM_PROPERTIES.has(prop)) {
@@ -8438,7 +9824,7 @@ var ReferenceValidator = class {
8438
9824
  location: reference.location
8439
9825
  });
8440
9826
  }
8441
- if (!this.registry.hasResource(resourcePath)) {
9827
+ if (reference.type !== "overlay-text-link" /* OVERLAY_TEXT_LINK */ && !this.registry.hasResource(resourcePath)) {
8442
9828
  const fileExistsInContainer = context.files.has(resourcePath);
8443
9829
  if (fileExistsInContainer) {
8444
9830
  if (!context.referencedUndeclaredResources?.has(resourcePath)) {
@@ -8575,6 +9961,24 @@ var ReferenceValidator = class {
8575
9961
  });
8576
9962
  }
8577
9963
  }
9964
+ if (reference.type === "overlay-text-link" /* OVERLAY_TEXT_LINK */ && resource) {
9965
+ if (resource.mimeType === "application/xhtml+xml" && /^\w+\(/.test(fragment)) {
9966
+ pushMessage(context.messages, {
9967
+ id: MessageId.MED_017,
9968
+ message: `URL fragment should indicate an element ID, but found '#${fragment}'`,
9969
+ location: reference.location
9970
+ });
9971
+ return;
9972
+ }
9973
+ if (resource.mimeType === "image/svg+xml" && !this.isValidSVGFragment(fragment)) {
9974
+ pushMessage(context.messages, {
9975
+ id: MessageId.MED_018,
9976
+ message: `URL fragment should be an SVG fragment identifier, but found '#${fragment}'`,
9977
+ location: reference.location
9978
+ });
9979
+ return;
9980
+ }
9981
+ }
8578
9982
  const parsedMimeTypes = ["application/xhtml+xml", "image/svg+xml"];
8579
9983
  if (resource && parsedMimeTypes.includes(resource.mimeType) && !isRemoteURL(resourcePath) && !fragment.includes("svgView(") && reference.type !== "svg-symbol" /* SVG_SYMBOL */) {
8580
9984
  if (!this.registry.hasID(resourcePath, fragment)) {
@@ -8586,6 +9990,18 @@ var ReferenceValidator = class {
8586
9990
  }
8587
9991
  }
8588
9992
  }
9993
+ /**
9994
+ * Check if a fragment is a valid SVG fragment identifier.
9995
+ * Valid forms: bare NCName ID, svgView(...), t=..., xywh=..., id=...
9996
+ */
9997
+ isValidSVGFragment(fragment) {
9998
+ if (/^svgView\(.*\)$/.test(fragment)) return true;
9999
+ if (fragment.startsWith("t=")) return true;
10000
+ if (fragment.startsWith("xywh=")) return true;
10001
+ if (fragment.startsWith("id=")) return true;
10002
+ if (!fragment.includes("=") && /^[a-zA-Z_][\w.-]*$/.test(fragment)) return true;
10003
+ return false;
10004
+ }
8589
10005
  /**
8590
10006
  * Check non-spine remote resources that have non-standard types.
8591
10007
  * Fires RSC-006 for remote items that aren't audio/video/font types
@@ -8645,7 +10061,7 @@ var ReferenceValidator = class {
8645
10061
  }
8646
10062
  }
8647
10063
  checkReadingOrder(context) {
8648
- if (!context.tocLinks || !context.packageDocument) return;
10064
+ if (!context.packageDocument) return;
8649
10065
  const packageDoc = context.packageDocument;
8650
10066
  const spine = packageDoc.spine;
8651
10067
  const opfPath = context.opfPath ?? "";
@@ -8657,15 +10073,35 @@ var ReferenceValidator = class {
8657
10073
  spinePositionMap.set(resolveManifestHref(opfDir, item.href), i);
8658
10074
  }
8659
10075
  }
10076
+ if (context.tocLinks) {
10077
+ this.checkLinkReadingOrder(
10078
+ context,
10079
+ spinePositionMap,
10080
+ context.tocLinks,
10081
+ MessageId.NAV_011,
10082
+ '"toc" nav'
10083
+ );
10084
+ }
10085
+ if (context.overlayTextLinks) {
10086
+ this.checkLinkReadingOrder(
10087
+ context,
10088
+ spinePositionMap,
10089
+ context.overlayTextLinks,
10090
+ MessageId.MED_015,
10091
+ "Media overlay text"
10092
+ );
10093
+ }
10094
+ }
10095
+ checkLinkReadingOrder(context, spinePositionMap, links, messageId, label) {
8660
10096
  let lastSpinePosition = -1;
8661
10097
  let lastAnchorPosition = -1;
8662
- for (const link of context.tocLinks) {
10098
+ for (const link of links) {
8663
10099
  const spinePos = spinePositionMap.get(link.targetResource);
8664
10100
  if (spinePos === void 0) continue;
8665
10101
  if (spinePos < lastSpinePosition) {
8666
10102
  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`,
10103
+ id: messageId,
10104
+ message: `${label} must be in reading order; link target "${link.targetResource}" is before the previous link's target in spine order`,
8669
10105
  location: link.location
8670
10106
  });
8671
10107
  lastSpinePosition = spinePos;
@@ -8680,8 +10116,8 @@ var ReferenceValidator = class {
8680
10116
  if (targetAnchorPosition < lastAnchorPosition) {
8681
10117
  const target = link.fragment ? `${link.targetResource}#${link.fragment}` : link.targetResource;
8682
10118
  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`,
10119
+ id: messageId,
10120
+ message: `${label} must be in reading order; link target "${target}" is before the previous link's target in document order`,
8685
10121
  location: link.location
8686
10122
  });
8687
10123
  }
@@ -8900,11 +10336,11 @@ var RelaxNGValidator = class extends BaseSchemaValidator {
8900
10336
  try {
8901
10337
  const libxml2 = await import('libxml2-wasm');
8902
10338
  const LibRelaxNGValidator = libxml2.RelaxNGValidator;
8903
- const { XmlDocument: XmlDocument3 } = libxml2;
8904
- const doc = XmlDocument3.fromString(xml);
10339
+ const { XmlDocument: XmlDocument4 } = libxml2;
10340
+ const doc = XmlDocument4.fromString(xml);
8905
10341
  try {
8906
10342
  const schemaContent = await loadSchema(schemaPath);
8907
- const schemaDoc = XmlDocument3.fromString(schemaContent);
10343
+ const schemaDoc = XmlDocument4.fromString(schemaContent);
8908
10344
  try {
8909
10345
  const validator = LibRelaxNGValidator.fromDoc(schemaDoc);
8910
10346
  try {
@@ -9072,7 +10508,8 @@ var DEFAULT_OPTIONS = {
9072
10508
  includeUsage: false,
9073
10509
  includeInfo: true,
9074
10510
  maxErrors: 0,
9075
- locale: "en"
10511
+ locale: "en",
10512
+ customMessages: /* @__PURE__ */ new Map()
9076
10513
  };
9077
10514
  var EpubCheck = class _EpubCheck {
9078
10515
  options;
@@ -9098,6 +10535,9 @@ var EpubCheck = class _EpubCheck {
9098
10535
  files: /* @__PURE__ */ new Map(),
9099
10536
  rootfiles: []
9100
10537
  };
10538
+ if (this.options.customMessages.size > 0) {
10539
+ setSeverityOverrides(this.options.customMessages);
10540
+ }
9101
10541
  try {
9102
10542
  const ocfValidator = new OCFValidator();
9103
10543
  ocfValidator.validate(context);
@@ -9115,6 +10555,7 @@ var EpubCheck = class _EpubCheck {
9115
10555
  }
9116
10556
  const contentValidator = new ContentValidator();
9117
10557
  contentValidator.validate(context, registry, refValidator);
10558
+ this.validateCrossDocumentFeatures(context);
9118
10559
  if (context.packageDocument) {
9119
10560
  this.validateNCX(context, registry);
9120
10561
  }
@@ -9126,6 +10567,8 @@ var EpubCheck = class _EpubCheck {
9126
10567
  id: MessageId.PKG_025,
9127
10568
  message: error instanceof Error ? error.message : "Unknown validation error"
9128
10569
  });
10570
+ } finally {
10571
+ clearSeverityOverrides();
9129
10572
  }
9130
10573
  const elapsedMs = performance.now() - startTime;
9131
10574
  const filteredMessages = context.messages.filter((msg) => {
@@ -9156,6 +10599,76 @@ var EpubCheck = class _EpubCheck {
9156
10599
  get version() {
9157
10600
  return this.options.version;
9158
10601
  }
10602
+ /**
10603
+ * Cross-document feature validation (Pattern B from Java EPUBCheck)
10604
+ */
10605
+ validateCrossDocumentFeatures(context) {
10606
+ const features = context.contentFeatures;
10607
+ if (!features || !context.version.startsWith("3")) return;
10608
+ const profile = context.options.profile;
10609
+ const opfPath = context.opfPath ?? "";
10610
+ if (profile === "edupub") {
10611
+ if (features.hasPageBreak && !features.hasPageList) {
10612
+ pushMessage(context.messages, {
10613
+ id: MessageId.NAV_003,
10614
+ message: 'The Navigation Document must have a page list when content contains page breaks (epub:type="pagebreak")',
10615
+ location: { path: opfPath }
10616
+ });
10617
+ }
10618
+ if (features.hasAudio && !features.hasLOA) {
10619
+ pushMessage(context.messages, {
10620
+ id: MessageId.NAV_005,
10621
+ message: 'Content documents contain "audio" elements but the Navigation Document does not have a listing of audio clips (epub:type="loa")',
10622
+ location: { path: opfPath }
10623
+ });
10624
+ }
10625
+ if (features.hasFigure && !features.hasLOI) {
10626
+ pushMessage(context.messages, {
10627
+ id: MessageId.NAV_006,
10628
+ message: 'Content documents contain "figure" elements but the Navigation Document does not have a listing of figures (epub:type="loi")',
10629
+ location: { path: opfPath }
10630
+ });
10631
+ }
10632
+ if (features.hasTable && !features.hasLOT) {
10633
+ pushMessage(context.messages, {
10634
+ id: MessageId.NAV_007,
10635
+ message: 'Content documents contain "table" elements but the Navigation Document does not have a listing of tables (epub:type="lot")',
10636
+ location: { path: opfPath }
10637
+ });
10638
+ }
10639
+ if (features.hasVideo && !features.hasLOV) {
10640
+ pushMessage(context.messages, {
10641
+ id: MessageId.NAV_008,
10642
+ message: 'Content documents contain "video" elements but the Navigation Document does not have a listing of video clips (epub:type="lov")',
10643
+ location: { path: opfPath }
10644
+ });
10645
+ }
10646
+ if (features.hasMicrodata && !features.hasRDFa) {
10647
+ pushMessage(context.messages, {
10648
+ id: MessageId.HTM_051,
10649
+ message: "Found Microdata but no RDFa; EDUPUB recommends the use of RDFa Lite",
10650
+ location: { path: opfPath }
10651
+ });
10652
+ }
10653
+ }
10654
+ const hasDictType = context.packageDocument?.dcElements.some(
10655
+ (dc) => dc.name === "type" && dc.value === "dictionary"
10656
+ ) ?? false;
10657
+ if (features.hasDictionary && !hasDictType) {
10658
+ pushMessage(context.messages, {
10659
+ id: MessageId.OPF_079,
10660
+ message: 'Dictionary content was found (epub:type "dictionary"), the Package Document should declare the dc:type "dictionary"',
10661
+ location: { path: opfPath }
10662
+ });
10663
+ }
10664
+ if (profile === "dict" && hasDictType && !features.hasDictionary) {
10665
+ pushMessage(context.messages, {
10666
+ id: MessageId.OPF_078,
10667
+ message: 'An EPUB Dictionary must contain at least one Content Document with dictionary content (epub:type "dictionary")',
10668
+ location: { path: opfPath }
10669
+ });
10670
+ }
10671
+ }
9159
10672
  /**
9160
10673
  * Validate NCX navigation document (EPUB 2 always, EPUB 3 when NCX present)
9161
10674
  */
@@ -9298,6 +10811,7 @@ exports.formatMessages = formatMessages;
9298
10811
  exports.getAllMessages = getAllMessages;
9299
10812
  exports.getDefaultSeverity = getDefaultSeverity;
9300
10813
  exports.getMessageInfo = getMessageInfo;
10814
+ exports.parseCustomMessages = parseCustomMessages;
9301
10815
  exports.pushMessage = pushMessage;
9302
10816
  exports.toJSONReport = toJSONReport;
9303
10817
  //# sourceMappingURL=index.cjs.map