@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/bin/epubcheck.js +1 -1
- package/bin/epubcheck.ts +1 -1
- package/dist/index.cjs +932 -47
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +10 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +932 -47
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
|
1606
|
-
|
|
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
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
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.
|
|
1624
|
-
message:
|
|
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.
|
|
2869
|
-
this.
|
|
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 =
|
|
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
|
|
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
|
-
|
|
7432
|
-
|
|
7433
|
-
|
|
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 (!
|
|
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.
|
|
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
|
|
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:
|
|
8666
|
-
message:
|
|
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:
|
|
8682
|
-
message:
|
|
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:
|
|
8902
|
-
const doc =
|
|
9786
|
+
const { XmlDocument: XmlDocument4 } = libxml2;
|
|
9787
|
+
const doc = XmlDocument4.fromString(xml);
|
|
8903
9788
|
try {
|
|
8904
9789
|
const schemaContent = await loadSchema(schemaPath);
|
|
8905
|
-
const schemaDoc =
|
|
9790
|
+
const schemaDoc = XmlDocument4.fromString(schemaContent);
|
|
8906
9791
|
try {
|
|
8907
9792
|
const validator = LibRelaxNGValidator.fromDoc(schemaDoc);
|
|
8908
9793
|
try {
|