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