@likecoin/epubcheck-ts 0.3.7 → 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 +1506 -105
- 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 +1506 -105
- 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
|
|
|
@@ -1704,7 +1949,12 @@ var SPINE_PROPERTIES = /* @__PURE__ */ new Set([
|
|
|
1704
1949
|
"rendition:layout-pre-paginated",
|
|
1705
1950
|
"rendition:orientation-auto",
|
|
1706
1951
|
"rendition:orientation-landscape",
|
|
1707
|
-
"rendition:orientation-portrait"
|
|
1952
|
+
"rendition:orientation-portrait",
|
|
1953
|
+
"rendition:flow-auto",
|
|
1954
|
+
"rendition:flow-paginated",
|
|
1955
|
+
"rendition:flow-scrolled-continuous",
|
|
1956
|
+
"rendition:flow-scrolled-doc",
|
|
1957
|
+
"rendition:align-x-center"
|
|
1708
1958
|
]);
|
|
1709
1959
|
|
|
1710
1960
|
// src/references/types.ts
|
|
@@ -1725,6 +1975,89 @@ function isPublicationResourceReference(type) {
|
|
|
1725
1975
|
return PUBLICATION_RESOURCE_TYPES.has(type);
|
|
1726
1976
|
}
|
|
1727
1977
|
|
|
1978
|
+
// src/references/uri-schemes.ts
|
|
1979
|
+
var URI_SCHEMES = /* @__PURE__ */ new Set([
|
|
1980
|
+
"aaa",
|
|
1981
|
+
"aaas",
|
|
1982
|
+
"acap",
|
|
1983
|
+
"afs",
|
|
1984
|
+
"cap",
|
|
1985
|
+
"cid",
|
|
1986
|
+
"crid",
|
|
1987
|
+
"data",
|
|
1988
|
+
"dav",
|
|
1989
|
+
"dict",
|
|
1990
|
+
"dns",
|
|
1991
|
+
"dtn",
|
|
1992
|
+
"fax",
|
|
1993
|
+
"file",
|
|
1994
|
+
"ftp",
|
|
1995
|
+
"go",
|
|
1996
|
+
"gopher",
|
|
1997
|
+
"h323",
|
|
1998
|
+
"http",
|
|
1999
|
+
"https",
|
|
2000
|
+
"iax",
|
|
2001
|
+
"icap",
|
|
2002
|
+
"im",
|
|
2003
|
+
"imap",
|
|
2004
|
+
"info",
|
|
2005
|
+
"ipp",
|
|
2006
|
+
"irc",
|
|
2007
|
+
"iris",
|
|
2008
|
+
"iris.beep",
|
|
2009
|
+
"iris.lwz",
|
|
2010
|
+
"iris.xpc",
|
|
2011
|
+
"iris.xpcs",
|
|
2012
|
+
"javascript",
|
|
2013
|
+
"ldap",
|
|
2014
|
+
"mailto",
|
|
2015
|
+
"mailserver",
|
|
2016
|
+
"mid",
|
|
2017
|
+
"modem",
|
|
2018
|
+
"msrp",
|
|
2019
|
+
"msrps",
|
|
2020
|
+
"mtqp",
|
|
2021
|
+
"mupdate",
|
|
2022
|
+
"news",
|
|
2023
|
+
"nfs",
|
|
2024
|
+
"nntp",
|
|
2025
|
+
"opaquelocktoken",
|
|
2026
|
+
"pack",
|
|
2027
|
+
"pop",
|
|
2028
|
+
"pres",
|
|
2029
|
+
"prospero",
|
|
2030
|
+
"rtsp",
|
|
2031
|
+
"service",
|
|
2032
|
+
"shttp",
|
|
2033
|
+
"sip",
|
|
2034
|
+
"sips",
|
|
2035
|
+
"snews",
|
|
2036
|
+
"snmp",
|
|
2037
|
+
"soap.beep",
|
|
2038
|
+
"soap.beeps",
|
|
2039
|
+
"tag",
|
|
2040
|
+
"tel",
|
|
2041
|
+
"telnet",
|
|
2042
|
+
"tftp",
|
|
2043
|
+
"thismessage",
|
|
2044
|
+
"tip",
|
|
2045
|
+
"tn3270",
|
|
2046
|
+
"tv",
|
|
2047
|
+
"urn",
|
|
2048
|
+
"vemmi",
|
|
2049
|
+
"videotex",
|
|
2050
|
+
"wais",
|
|
2051
|
+
"xmlrpc.beep",
|
|
2052
|
+
"xmlrpc.beeps",
|
|
2053
|
+
"xmpp",
|
|
2054
|
+
"z39.50r",
|
|
2055
|
+
"z39.50s"
|
|
2056
|
+
]);
|
|
2057
|
+
function isRegisteredScheme(scheme) {
|
|
2058
|
+
return URI_SCHEMES.has(scheme.toLowerCase());
|
|
2059
|
+
}
|
|
2060
|
+
|
|
1728
2061
|
// src/references/url.ts
|
|
1729
2062
|
function parseURL(urlString) {
|
|
1730
2063
|
const hashIndex = urlString.indexOf("#");
|
|
@@ -1760,15 +2093,9 @@ function hasParentDirectoryReference(url) {
|
|
|
1760
2093
|
return url.includes("..");
|
|
1761
2094
|
}
|
|
1762
2095
|
function isMalformedURL(url) {
|
|
1763
|
-
if (!url) return true;
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
if (!trimmed) return true;
|
|
1767
|
-
if (/[\s<>]/.test(trimmed)) return true;
|
|
1768
|
-
return false;
|
|
1769
|
-
} catch {
|
|
1770
|
-
return true;
|
|
1771
|
-
}
|
|
2096
|
+
if (!url.trim()) return true;
|
|
2097
|
+
if (/[\s<>]/.test(url)) return true;
|
|
2098
|
+
return false;
|
|
1772
2099
|
}
|
|
1773
2100
|
function isHTTPS(url) {
|
|
1774
2101
|
return url.startsWith("https://");
|
|
@@ -1805,6 +2132,38 @@ function resolveManifestHref(opfDir, href) {
|
|
|
1805
2132
|
// src/content/validator.ts
|
|
1806
2133
|
var DISCOURAGED_ELEMENTS = /* @__PURE__ */ new Set(["base", "embed", "rp"]);
|
|
1807
2134
|
var ABSOLUTE_URI_RE = /^[a-zA-Z][a-zA-Z0-9+.-]*:/;
|
|
2135
|
+
var SPECIAL_URL_SCHEMES = /* @__PURE__ */ new Set(["http", "https", "ftp", "ws", "wss"]);
|
|
2136
|
+
var CSS_CHARSET_RE = /^@charset\s+"([^"]+)"\s*;/;
|
|
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
|
+
]);
|
|
2149
|
+
function validateAbsoluteHyperlinkURL(context, href, path, line) {
|
|
2150
|
+
const location = line != null ? { path, line } : { path };
|
|
2151
|
+
const scheme = href.slice(0, href.indexOf(":")).toLowerCase();
|
|
2152
|
+
if (!isRegisteredScheme(scheme)) {
|
|
2153
|
+
pushMessage(context.messages, {
|
|
2154
|
+
id: MessageId.HTM_025,
|
|
2155
|
+
message: "Hyperlink uses non-registered URI scheme type",
|
|
2156
|
+
location
|
|
2157
|
+
});
|
|
2158
|
+
}
|
|
2159
|
+
if (/[\s<>]/.test(href) || SPECIAL_URL_SCHEMES.has(scheme) && !href.slice(href.indexOf(":")).startsWith("://")) {
|
|
2160
|
+
pushMessage(context.messages, {
|
|
2161
|
+
id: MessageId.RSC_020,
|
|
2162
|
+
message: `URL is not valid: "${href}"`,
|
|
2163
|
+
location
|
|
2164
|
+
});
|
|
2165
|
+
}
|
|
2166
|
+
}
|
|
1808
2167
|
var IMAGE_MAGIC = [
|
|
1809
2168
|
{ mime: "image/jpeg", bytes: [255, 216], extensions: [".jpg", ".jpeg", ".jpe"] },
|
|
1810
2169
|
{ mime: "image/gif", bytes: [71, 73, 70, 56], extensions: [".gif"] },
|
|
@@ -2079,6 +2438,16 @@ var HTML_ENTITIES = /* @__PURE__ */ new Set([
|
|
|
2079
2438
|
"thorn",
|
|
2080
2439
|
"yuml"
|
|
2081
2440
|
]);
|
|
2441
|
+
function isItemFixedLayout(packageDoc, itemId) {
|
|
2442
|
+
const spineItem = packageDoc.spine.find((s) => s.idref === itemId);
|
|
2443
|
+
if (!spineItem) return false;
|
|
2444
|
+
if (spineItem.properties?.includes("rendition:layout-pre-paginated")) return true;
|
|
2445
|
+
if (spineItem.properties?.includes("rendition:layout-reflowable")) return false;
|
|
2446
|
+
const globalLayout = packageDoc.metaElements.find(
|
|
2447
|
+
(m) => m.property === "rendition:layout" && !m.refines
|
|
2448
|
+
);
|
|
2449
|
+
return globalLayout?.value === "pre-paginated";
|
|
2450
|
+
}
|
|
2082
2451
|
var ContentValidator = class {
|
|
2083
2452
|
cssWithRemoteResources = /* @__PURE__ */ new Set();
|
|
2084
2453
|
validate(context, registry, refValidator) {
|
|
@@ -2096,6 +2465,11 @@ var ContentValidator = class {
|
|
|
2096
2465
|
}
|
|
2097
2466
|
}
|
|
2098
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
|
+
}
|
|
2099
2473
|
for (const item of packageDoc.manifest) {
|
|
2100
2474
|
if (item.mediaType === "application/xhtml+xml") {
|
|
2101
2475
|
const fullPath = resolveManifestHref(opfDir, item.href);
|
|
@@ -2111,9 +2485,96 @@ var ContentValidator = class {
|
|
|
2111
2485
|
if (refValidator) {
|
|
2112
2486
|
this.extractSVGReferences(context, fullPath, opfDir, refValidator);
|
|
2113
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
|
+
}
|
|
2114
2524
|
}
|
|
2115
2525
|
this.validateMediaFile(context, item, opfDir);
|
|
2116
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
|
+
}
|
|
2117
2578
|
}
|
|
2118
2579
|
validateMediaFile(context, item, opfDir) {
|
|
2119
2580
|
const declaredType = item.mediaType;
|
|
@@ -2199,6 +2660,19 @@ var ContentValidator = class {
|
|
|
2199
2660
|
this.checkSVGInvalidIDs(context, path, root);
|
|
2200
2661
|
this.validateSvgEpubType(context, path, root);
|
|
2201
2662
|
this.checkUnknownEpubAttributes(context, path, root);
|
|
2663
|
+
this.checkSVGLinkAccessibility(context, path, root);
|
|
2664
|
+
const packageDoc = context.packageDocument;
|
|
2665
|
+
if (packageDoc && isItemFixedLayout(packageDoc, manifestItem.id)) {
|
|
2666
|
+
const viewBox = this.getAttribute(root, "viewBox");
|
|
2667
|
+
if (!viewBox) {
|
|
2668
|
+
pushMessage(context.messages, {
|
|
2669
|
+
id: MessageId.HTM_048,
|
|
2670
|
+
message: "SVG Fixed-Layout Documents must have a viewBox attribute on the outermost svg element",
|
|
2671
|
+
location: { path }
|
|
2672
|
+
});
|
|
2673
|
+
}
|
|
2674
|
+
}
|
|
2675
|
+
this.checkMediaOverlayActiveClassCSS(context, path, root, manifestItem, svgContent);
|
|
2202
2676
|
} finally {
|
|
2203
2677
|
doc.dispose();
|
|
2204
2678
|
}
|
|
@@ -2384,7 +2858,26 @@ var ContentValidator = class {
|
|
|
2384
2858
|
if (!cssData) {
|
|
2385
2859
|
return;
|
|
2386
2860
|
}
|
|
2387
|
-
|
|
2861
|
+
let cssContent;
|
|
2862
|
+
const utf16Encoding = cssData.length >= 2 && cssData[0] === 254 && cssData[1] === 255 ? "utf-16be" : cssData.length >= 2 && cssData[0] === 255 && cssData[1] === 254 ? "utf-16le" : null;
|
|
2863
|
+
if (utf16Encoding) {
|
|
2864
|
+
pushMessage(context.messages, {
|
|
2865
|
+
id: MessageId.CSS_003,
|
|
2866
|
+
message: "CSS documents should be encoded in UTF-8, but UTF-16 was detected",
|
|
2867
|
+
location: { path }
|
|
2868
|
+
});
|
|
2869
|
+
cssContent = new TextDecoder(utf16Encoding).decode(cssData);
|
|
2870
|
+
} else {
|
|
2871
|
+
cssContent = new TextDecoder().decode(cssData);
|
|
2872
|
+
const charsetMatch = CSS_CHARSET_RE.exec(cssContent);
|
|
2873
|
+
if (charsetMatch?.[1] && charsetMatch[1].toLowerCase() !== "utf-8") {
|
|
2874
|
+
pushMessage(context.messages, {
|
|
2875
|
+
id: MessageId.CSS_004,
|
|
2876
|
+
message: `CSS documents must be encoded in UTF-8, but detected "${charsetMatch[1]}"`,
|
|
2877
|
+
location: { path }
|
|
2878
|
+
});
|
|
2879
|
+
}
|
|
2880
|
+
}
|
|
2388
2881
|
const cssValidator = new CSSValidator();
|
|
2389
2882
|
const result = cssValidator.validate(context, cssContent, path);
|
|
2390
2883
|
const hasRemoteResources = result.references.some(
|
|
@@ -2477,11 +2970,28 @@ var ContentValidator = class {
|
|
|
2477
2970
|
if (!data) {
|
|
2478
2971
|
return;
|
|
2479
2972
|
}
|
|
2480
|
-
|
|
2973
|
+
if (data.length >= 2 && (data[0] === 254 && data[1] === 255 || data[0] === 255 && data[1] === 254)) {
|
|
2974
|
+
pushMessage(context.messages, {
|
|
2975
|
+
id: MessageId.HTM_058,
|
|
2976
|
+
message: "HTML documents must be encoded in UTF-8, but UTF-16 was detected",
|
|
2977
|
+
location: { path }
|
|
2978
|
+
});
|
|
2979
|
+
return;
|
|
2980
|
+
}
|
|
2981
|
+
let content = new TextDecoder().decode(data);
|
|
2481
2982
|
const packageDoc = context.packageDocument;
|
|
2482
2983
|
if (!packageDoc) {
|
|
2483
2984
|
return;
|
|
2484
2985
|
}
|
|
2986
|
+
const epubNsMatch = EPUB_XMLNS_RE.exec(content);
|
|
2987
|
+
if (epubNsMatch?.[1] && epubNsMatch[1] !== "http://www.idpf.org/2007/ops") {
|
|
2988
|
+
pushMessage(context.messages, {
|
|
2989
|
+
id: MessageId.HTM_010,
|
|
2990
|
+
message: `Namespace URI "${epubNsMatch[1]}" is unusual for the "epub" prefix`,
|
|
2991
|
+
location: { path }
|
|
2992
|
+
});
|
|
2993
|
+
content = content.replace(epubNsMatch[0], 'xmlns:epub="http://www.idpf.org/2007/ops"');
|
|
2994
|
+
}
|
|
2485
2995
|
this.checkUnescapedAmpersands(context, path, content);
|
|
2486
2996
|
if (context.version !== "2.0") {
|
|
2487
2997
|
const doctypeMatch = /<!DOCTYPE\s+html\b([\s\S]*?)>/i.exec(content);
|
|
@@ -2687,6 +3197,7 @@ var ContentValidator = class {
|
|
|
2687
3197
|
this.checkHttpEquivCharset(context, path, root);
|
|
2688
3198
|
this.checkLangMismatch(context, path, root);
|
|
2689
3199
|
this.checkDpubAriaDeprecated(context, path, root);
|
|
3200
|
+
this.validateIdRefs(context, path, root);
|
|
2690
3201
|
this.checkTableBorder(context, path, root);
|
|
2691
3202
|
this.checkTimeElement(context, path, root);
|
|
2692
3203
|
this.checkMathMLAnnotations(context, path, root);
|
|
@@ -2694,6 +3205,7 @@ var ContentValidator = class {
|
|
|
2694
3205
|
this.checkDataAttributes(context, path, root);
|
|
2695
3206
|
this.checkAccessibility(context, path, root);
|
|
2696
3207
|
this.validateImages(context, path, root);
|
|
3208
|
+
this.checkUsemapAttribute(context, path, root);
|
|
2697
3209
|
if (context.version.startsWith("3")) {
|
|
2698
3210
|
this.validateEpubTypes(context, path, root);
|
|
2699
3211
|
}
|
|
@@ -2706,8 +3218,24 @@ var ContentValidator = class {
|
|
|
2706
3218
|
this.extractAndRegisterIDs(path, root, registry);
|
|
2707
3219
|
}
|
|
2708
3220
|
if (refValidator && opfDir !== void 0) {
|
|
2709
|
-
this.
|
|
2710
|
-
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
|
+
);
|
|
2711
3239
|
this.extractAndRegisterImages(context, path, root, opfDir, refValidator, registry);
|
|
2712
3240
|
this.extractAndRegisterMathMLAltimg(path, root, opfDir, refValidator);
|
|
2713
3241
|
this.extractAndRegisterScripts(path, root, opfDir, refValidator);
|
|
@@ -2722,10 +3250,65 @@ var ContentValidator = class {
|
|
|
2722
3250
|
registry
|
|
2723
3251
|
);
|
|
2724
3252
|
}
|
|
3253
|
+
this.checkMediaOverlayActiveClassCSS(context, path, root, manifestItem);
|
|
2725
3254
|
} finally {
|
|
2726
3255
|
doc.dispose();
|
|
2727
3256
|
}
|
|
2728
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
|
+
}
|
|
2729
3312
|
parseLibxmlError(error) {
|
|
2730
3313
|
const lineRegex = /(?:Entity:\s*)?line\s+(\d+):/;
|
|
2731
3314
|
const lineMatch = lineRegex.exec(error);
|
|
@@ -3466,6 +4049,92 @@ var ContentValidator = class {
|
|
|
3466
4049
|
} catch {
|
|
3467
4050
|
}
|
|
3468
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
|
+
}
|
|
3469
4138
|
validateEpubSwitch(context, path, root) {
|
|
3470
4139
|
const EPUB_NS = { epub: "http://www.idpf.org/2007/ops" };
|
|
3471
4140
|
try {
|
|
@@ -3554,15 +4223,7 @@ var ContentValidator = class {
|
|
|
3554
4223
|
try {
|
|
3555
4224
|
const triggers = root.find(".//epub:trigger", EPUB_NS);
|
|
3556
4225
|
if (triggers.length === 0) return;
|
|
3557
|
-
const allIds =
|
|
3558
|
-
try {
|
|
3559
|
-
const idElements = root.find(".//*[@id]");
|
|
3560
|
-
for (const el of idElements) {
|
|
3561
|
-
const idAttr = this.getAttribute(el, "id");
|
|
3562
|
-
if (idAttr) allIds.add(idAttr);
|
|
3563
|
-
}
|
|
3564
|
-
} catch {
|
|
3565
|
-
}
|
|
4226
|
+
const allIds = this.collectIds(root);
|
|
3566
4227
|
for (const trigger of triggers) {
|
|
3567
4228
|
pushMessage(context.messages, {
|
|
3568
4229
|
id: MessageId.RSC_017,
|
|
@@ -3693,6 +4354,22 @@ var ContentValidator = class {
|
|
|
3693
4354
|
} catch {
|
|
3694
4355
|
}
|
|
3695
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
|
+
}
|
|
3696
4373
|
checkTimeElement(context, path, root) {
|
|
3697
4374
|
const HTML_NS = { html: "http://www.w3.org/1999/xhtml" };
|
|
3698
4375
|
try {
|
|
@@ -3902,22 +4579,7 @@ var ContentValidator = class {
|
|
|
3902
4579
|
});
|
|
3903
4580
|
}
|
|
3904
4581
|
}
|
|
3905
|
-
|
|
3906
|
-
svg: "http://www.w3.org/2000/svg",
|
|
3907
|
-
xlink: "http://www.w3.org/1999/xlink"
|
|
3908
|
-
});
|
|
3909
|
-
for (const svgLink of svgLinks) {
|
|
3910
|
-
const svgElem = svgLink;
|
|
3911
|
-
const title = svgElem.get("./svg:title", { svg: "http://www.w3.org/2000/svg" });
|
|
3912
|
-
const ariaLabel = this.getAttribute(svgElem, "aria-label");
|
|
3913
|
-
if (!title && !ariaLabel) {
|
|
3914
|
-
pushMessage(context.messages, {
|
|
3915
|
-
id: MessageId.ACC_011,
|
|
3916
|
-
message: "SVG hyperlink has no accessible name (missing title element or aria-label)",
|
|
3917
|
-
location: { path }
|
|
3918
|
-
});
|
|
3919
|
-
}
|
|
3920
|
-
}
|
|
4582
|
+
this.checkSVGLinkAccessibility(context, path, root);
|
|
3921
4583
|
const mathElements = root.find(".//math:math", { math: "http://www.w3.org/1998/Math/MathML" });
|
|
3922
4584
|
for (const mathElem of mathElements) {
|
|
3923
4585
|
const elem = mathElem;
|
|
@@ -3935,6 +4597,29 @@ var ContentValidator = class {
|
|
|
3935
4597
|
}
|
|
3936
4598
|
}
|
|
3937
4599
|
}
|
|
4600
|
+
hasSVGLinkAccessibleName(svgElem) {
|
|
4601
|
+
const ns = { svg: "http://www.w3.org/2000/svg" };
|
|
4602
|
+
if (svgElem.get(".//svg:title", ns)) return true;
|
|
4603
|
+
if (svgElem.get(".//svg:text", ns)) return true;
|
|
4604
|
+
if (this.getAttribute(svgElem, "aria-label")) return true;
|
|
4605
|
+
if (this.getAttribute(svgElem, "xlink:title")) return true;
|
|
4606
|
+
return false;
|
|
4607
|
+
}
|
|
4608
|
+
checkSVGLinkAccessibility(context, path, root) {
|
|
4609
|
+
const svgLinks = root.find(".//svg:a", {
|
|
4610
|
+
svg: "http://www.w3.org/2000/svg",
|
|
4611
|
+
xlink: "http://www.w3.org/1999/xlink"
|
|
4612
|
+
});
|
|
4613
|
+
for (const svgLink of svgLinks) {
|
|
4614
|
+
if (!this.hasSVGLinkAccessibleName(svgLink)) {
|
|
4615
|
+
pushMessage(context.messages, {
|
|
4616
|
+
id: MessageId.ACC_011,
|
|
4617
|
+
message: "SVG hyperlink has no accessible name (missing title element or aria-label)",
|
|
4618
|
+
location: { path }
|
|
4619
|
+
});
|
|
4620
|
+
}
|
|
4621
|
+
}
|
|
4622
|
+
}
|
|
3938
4623
|
validateImages(context, path, root) {
|
|
3939
4624
|
const packageDoc = context.packageDocument;
|
|
3940
4625
|
if (!packageDoc) return;
|
|
@@ -3996,6 +4681,14 @@ var ContentValidator = class {
|
|
|
3996
4681
|
const elemTyped = elem;
|
|
3997
4682
|
const epubTypeAttr = elemTyped.attr("type", "epub");
|
|
3998
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
|
+
}
|
|
3999
4692
|
for (const part of epubTypeAttr.value.split(/\s+/)) {
|
|
4000
4693
|
if (!part) continue;
|
|
4001
4694
|
const hasPrefix = part.includes(":");
|
|
@@ -4091,41 +4784,131 @@ var ContentValidator = class {
|
|
|
4091
4784
|
const attr = attrs.find((a) => a.name === name);
|
|
4092
4785
|
return attr?.value ?? null;
|
|
4093
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
|
+
}
|
|
4094
4798
|
validateViewportMeta(context, path, root, manifestItem) {
|
|
4095
|
-
const
|
|
4096
|
-
const
|
|
4097
|
-
|
|
4098
|
-
|
|
4799
|
+
const packageDoc = context.packageDocument;
|
|
4800
|
+
const isFixedLayout = manifestItem && packageDoc ? isItemFixedLayout(packageDoc, manifestItem.id) : false;
|
|
4801
|
+
const headMetas = root.find(".//html:head/html:meta[@name]", {
|
|
4802
|
+
html: "http://www.w3.org/1999/xhtml"
|
|
4803
|
+
});
|
|
4804
|
+
let viewportCount = 0;
|
|
4805
|
+
for (const meta of headMetas) {
|
|
4099
4806
|
const nameAttr = this.getAttribute(meta, "name");
|
|
4100
|
-
if (nameAttr
|
|
4101
|
-
|
|
4102
|
-
|
|
4103
|
-
|
|
4104
|
-
|
|
4105
|
-
|
|
4106
|
-
|
|
4107
|
-
|
|
4108
|
-
|
|
4109
|
-
|
|
4110
|
-
|
|
4111
|
-
|
|
4112
|
-
|
|
4113
|
-
|
|
4114
|
-
|
|
4115
|
-
|
|
4116
|
-
|
|
4117
|
-
|
|
4118
|
-
|
|
4807
|
+
if (nameAttr !== "viewport") continue;
|
|
4808
|
+
viewportCount++;
|
|
4809
|
+
const contentAttr = this.getAttribute(meta, "content");
|
|
4810
|
+
if (!isFixedLayout) {
|
|
4811
|
+
pushMessage(context.messages, {
|
|
4812
|
+
id: MessageId.HTM_060b,
|
|
4813
|
+
message: `EPUB reading systems must ignore viewport meta elements in reflowable documents; viewport declaration "${contentAttr ?? ""}" will be ignored`,
|
|
4814
|
+
location: { path, line: meta.line }
|
|
4815
|
+
});
|
|
4816
|
+
continue;
|
|
4817
|
+
}
|
|
4818
|
+
if (viewportCount > 1) {
|
|
4819
|
+
pushMessage(context.messages, {
|
|
4820
|
+
id: MessageId.HTM_060a,
|
|
4821
|
+
message: `EPUB reading systems must ignore secondary viewport meta elements in fixed-layout documents; viewport declaration "${contentAttr ?? ""}" will be ignored`,
|
|
4822
|
+
location: { path, line: meta.line }
|
|
4823
|
+
});
|
|
4824
|
+
continue;
|
|
4825
|
+
}
|
|
4826
|
+
if (!contentAttr?.trim()) {
|
|
4827
|
+
pushMessage(context.messages, {
|
|
4828
|
+
id: MessageId.HTM_047,
|
|
4829
|
+
message: `Viewport metadata "${contentAttr ?? ""}" has a syntax error`,
|
|
4830
|
+
location: { path, line: meta.line }
|
|
4831
|
+
});
|
|
4832
|
+
continue;
|
|
4119
4833
|
}
|
|
4834
|
+
this.parseViewportContent(context, path, contentAttr, meta.line);
|
|
4120
4835
|
}
|
|
4121
|
-
if (isFixedLayout &&
|
|
4836
|
+
if (isFixedLayout && viewportCount === 0) {
|
|
4122
4837
|
pushMessage(context.messages, {
|
|
4123
|
-
id: MessageId.
|
|
4124
|
-
message: "Fixed
|
|
4838
|
+
id: MessageId.HTM_046,
|
|
4839
|
+
message: "Fixed layout document has no viewport meta element",
|
|
4125
4840
|
location: { path }
|
|
4126
4841
|
});
|
|
4127
4842
|
}
|
|
4128
4843
|
}
|
|
4844
|
+
parseViewportContent(context, path, content, line) {
|
|
4845
|
+
const location = line != null ? { path, line } : { path };
|
|
4846
|
+
const parts = content.split(/[,;]/);
|
|
4847
|
+
const seenKeys = /* @__PURE__ */ new Set();
|
|
4848
|
+
let hasWidth = false;
|
|
4849
|
+
let hasHeight = false;
|
|
4850
|
+
let hasSyntaxError = false;
|
|
4851
|
+
for (const part of parts) {
|
|
4852
|
+
const trimmed = part.trim();
|
|
4853
|
+
if (!trimmed) continue;
|
|
4854
|
+
const eqIndex = trimmed.indexOf("=");
|
|
4855
|
+
let key;
|
|
4856
|
+
let value;
|
|
4857
|
+
if (eqIndex < 0) {
|
|
4858
|
+
key = trimmed;
|
|
4859
|
+
value = "";
|
|
4860
|
+
} else {
|
|
4861
|
+
key = trimmed.substring(0, eqIndex).trim();
|
|
4862
|
+
const rawValue = trimmed.substring(eqIndex + 1);
|
|
4863
|
+
if (!rawValue.trim()) {
|
|
4864
|
+
pushMessage(context.messages, {
|
|
4865
|
+
id: MessageId.HTM_047,
|
|
4866
|
+
message: `Viewport metadata "${content}" has a syntax error`,
|
|
4867
|
+
location
|
|
4868
|
+
});
|
|
4869
|
+
hasSyntaxError = true;
|
|
4870
|
+
break;
|
|
4871
|
+
}
|
|
4872
|
+
value = rawValue.trim();
|
|
4873
|
+
}
|
|
4874
|
+
if (key === "width" || key === "height") {
|
|
4875
|
+
if (seenKeys.has(key)) {
|
|
4876
|
+
pushMessage(context.messages, {
|
|
4877
|
+
id: MessageId.HTM_059,
|
|
4878
|
+
message: `Viewport "${key}" property must not be defined more than once`,
|
|
4879
|
+
location
|
|
4880
|
+
});
|
|
4881
|
+
}
|
|
4882
|
+
seenKeys.add(key);
|
|
4883
|
+
if (key === "width") hasWidth = true;
|
|
4884
|
+
if (key === "height") hasHeight = true;
|
|
4885
|
+
const deviceKeyword = key === "width" ? "device-width" : "device-height";
|
|
4886
|
+
if (value === deviceKeyword) ; else if (value === "" || !/^[0-9]*\.?[0-9]+$/.test(value)) {
|
|
4887
|
+
pushMessage(context.messages, {
|
|
4888
|
+
id: MessageId.HTM_057,
|
|
4889
|
+
message: `Viewport "${key}" value must be a positive number or the keyword "${deviceKeyword}"`,
|
|
4890
|
+
location
|
|
4891
|
+
});
|
|
4892
|
+
}
|
|
4893
|
+
}
|
|
4894
|
+
}
|
|
4895
|
+
if (!hasSyntaxError) {
|
|
4896
|
+
if (!hasWidth) {
|
|
4897
|
+
pushMessage(context.messages, {
|
|
4898
|
+
id: MessageId.HTM_056,
|
|
4899
|
+
message: 'Viewport metadata has no "width" dimension (both "width" and "height" properties are required)',
|
|
4900
|
+
location
|
|
4901
|
+
});
|
|
4902
|
+
}
|
|
4903
|
+
if (!hasHeight) {
|
|
4904
|
+
pushMessage(context.messages, {
|
|
4905
|
+
id: MessageId.HTM_056,
|
|
4906
|
+
message: 'Viewport metadata has no "height" dimension (both "width" and "height" properties are required)',
|
|
4907
|
+
location
|
|
4908
|
+
});
|
|
4909
|
+
}
|
|
4910
|
+
}
|
|
4911
|
+
}
|
|
4129
4912
|
extractAndRegisterIDs(path, root, registry) {
|
|
4130
4913
|
const elementsWithId = root.find(".//*[@id]");
|
|
4131
4914
|
for (const elem of elementsWithId) {
|
|
@@ -4140,7 +4923,7 @@ var ContentValidator = class {
|
|
|
4140
4923
|
}
|
|
4141
4924
|
}
|
|
4142
4925
|
}
|
|
4143
|
-
extractAndRegisterHyperlinks(context, path, root, opfDir, refValidator, isNavDocument = false) {
|
|
4926
|
+
extractAndRegisterHyperlinks(context, path, root, opfDir, refValidator, isNavDocument = false, remoteXmlBase = null) {
|
|
4144
4927
|
const docDir = path.includes("/") ? path.substring(0, path.lastIndexOf("/")) : "";
|
|
4145
4928
|
const navAnchorTypes = /* @__PURE__ */ new Map();
|
|
4146
4929
|
if (isNavDocument) {
|
|
@@ -4176,7 +4959,17 @@ var ContentValidator = class {
|
|
|
4176
4959
|
}
|
|
4177
4960
|
const line = link.line;
|
|
4178
4961
|
const refType = isNavDocument ? navAnchorTypes.get(`${String(line)}:${href}`) ?? "hyperlink" /* HYPERLINK */ : "hyperlink" /* HYPERLINK */;
|
|
4962
|
+
if (href.startsWith("data:") || href.startsWith("file:")) {
|
|
4963
|
+
refValidator.addReference({
|
|
4964
|
+
url: href,
|
|
4965
|
+
targetResource: href,
|
|
4966
|
+
type: refType,
|
|
4967
|
+
location: { path, line }
|
|
4968
|
+
});
|
|
4969
|
+
continue;
|
|
4970
|
+
}
|
|
4179
4971
|
if (ABSOLUTE_URI_RE.test(href)) {
|
|
4972
|
+
validateAbsoluteHyperlinkURL(context, href, path, line);
|
|
4180
4973
|
continue;
|
|
4181
4974
|
}
|
|
4182
4975
|
if (href.includes("#epubcfi(")) {
|
|
@@ -4194,6 +4987,15 @@ var ContentValidator = class {
|
|
|
4194
4987
|
});
|
|
4195
4988
|
continue;
|
|
4196
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
|
+
}
|
|
4197
4999
|
const resolvedPath = this.resolveRelativePath(docDir, href, opfDir);
|
|
4198
5000
|
const hashIndex = resolvedPath.indexOf("#");
|
|
4199
5001
|
const targetResource = hashIndex >= 0 ? resolvedPath.slice(0, hashIndex) : resolvedPath;
|
|
@@ -4214,7 +5016,19 @@ var ContentValidator = class {
|
|
|
4214
5016
|
const href = this.getAttribute(area, "href")?.trim();
|
|
4215
5017
|
if (!href) continue;
|
|
4216
5018
|
const line = area.line;
|
|
4217
|
-
if (
|
|
5019
|
+
if (href.startsWith("data:") || href.startsWith("file:")) {
|
|
5020
|
+
refValidator.addReference({
|
|
5021
|
+
url: href,
|
|
5022
|
+
targetResource: href,
|
|
5023
|
+
type: "hyperlink" /* HYPERLINK */,
|
|
5024
|
+
location: { path, line }
|
|
5025
|
+
});
|
|
5026
|
+
continue;
|
|
5027
|
+
}
|
|
5028
|
+
if (ABSOLUTE_URI_RE.test(href)) {
|
|
5029
|
+
validateAbsoluteHyperlinkURL(context, href, path, line);
|
|
5030
|
+
continue;
|
|
5031
|
+
}
|
|
4218
5032
|
if (href.includes("#epubcfi(")) continue;
|
|
4219
5033
|
if (href.startsWith("#")) {
|
|
4220
5034
|
refValidator.addReference({
|
|
@@ -4250,7 +5064,17 @@ var ContentValidator = class {
|
|
|
4250
5064
|
const href = this.getAttribute(elem, "xlink:href") ?? this.getAttribute(elem, "href");
|
|
4251
5065
|
if (!href) continue;
|
|
4252
5066
|
const line = link.line;
|
|
4253
|
-
if (href.startsWith("
|
|
5067
|
+
if (href.startsWith("data:") || href.startsWith("file:")) {
|
|
5068
|
+
refValidator.addReference({
|
|
5069
|
+
url: href,
|
|
5070
|
+
targetResource: href,
|
|
5071
|
+
type: "hyperlink" /* HYPERLINK */,
|
|
5072
|
+
location: { path, line }
|
|
5073
|
+
});
|
|
5074
|
+
continue;
|
|
5075
|
+
}
|
|
5076
|
+
if (ABSOLUTE_URI_RE.test(href)) {
|
|
5077
|
+
validateAbsoluteHyperlinkURL(context, href, path, line);
|
|
4254
5078
|
continue;
|
|
4255
5079
|
}
|
|
4256
5080
|
if (href.startsWith("#")) {
|
|
@@ -4281,8 +5105,12 @@ var ContentValidator = class {
|
|
|
4281
5105
|
refValidator.addReference(svgRef);
|
|
4282
5106
|
}
|
|
4283
5107
|
}
|
|
4284
|
-
extractAndRegisterStylesheets(path, root, opfDir, refValidator) {
|
|
5108
|
+
extractAndRegisterStylesheets(context, path, root, opfDir, refValidator, remoteXmlBase = null) {
|
|
4285
5109
|
const docDir = path.includes("/") ? path.substring(0, path.lastIndexOf("/")) : "";
|
|
5110
|
+
const baseElem = root.get(".//html:base[@href]", { html: "http://www.w3.org/1999/xhtml" });
|
|
5111
|
+
const baseHref = baseElem ? this.getAttribute(baseElem, "href") : null;
|
|
5112
|
+
const effectiveBase = baseHref ?? remoteXmlBase;
|
|
5113
|
+
const remoteBaseUrl = effectiveBase?.startsWith("http://") || effectiveBase?.startsWith("https://") ? effectiveBase : null;
|
|
4286
5114
|
const linkElements = root.find(".//html:link[@href]", { html: "http://www.w3.org/1999/xhtml" });
|
|
4287
5115
|
for (const linkElem of linkElements) {
|
|
4288
5116
|
const href = this.getAttribute(linkElem, "href");
|
|
@@ -4300,6 +5128,15 @@ var ContentValidator = class {
|
|
|
4300
5128
|
});
|
|
4301
5129
|
continue;
|
|
4302
5130
|
}
|
|
5131
|
+
if (remoteBaseUrl && !ABSOLUTE_URI_RE.test(href)) {
|
|
5132
|
+
const resolvedUrl = new URL(href, remoteBaseUrl).href;
|
|
5133
|
+
pushMessage(context.messages, {
|
|
5134
|
+
id: MessageId.RSC_006,
|
|
5135
|
+
message: `Remote resource reference is not allowed; resource "${resolvedUrl}" must be located in the EPUB container`,
|
|
5136
|
+
location: { path, line }
|
|
5137
|
+
});
|
|
5138
|
+
continue;
|
|
5139
|
+
}
|
|
4303
5140
|
const resolvedPath = this.resolveRelativePath(docDir, href, opfDir);
|
|
4304
5141
|
const hashIndex = resolvedPath.indexOf("#");
|
|
4305
5142
|
const targetResource = hashIndex >= 0 ? resolvedPath.slice(0, hashIndex) : resolvedPath;
|
|
@@ -5357,6 +6194,8 @@ var OCFValidator = class {
|
|
|
5357
6194
|
this.validateUtf8Filenames(zip, context.messages);
|
|
5358
6195
|
this.validateEmptyDirectories(zip, context.messages);
|
|
5359
6196
|
this.parseEncryption(zip, context);
|
|
6197
|
+
this.validateEncryptionXml(context);
|
|
6198
|
+
this.validateSignaturesXml(context);
|
|
5360
6199
|
}
|
|
5361
6200
|
/**
|
|
5362
6201
|
* Validate the mimetype file
|
|
@@ -5697,14 +6536,109 @@ var OCFValidator = class {
|
|
|
5697
6536
|
const xml = block[0];
|
|
5698
6537
|
const algorithmMatch = /Algorithm=["']([^"']+)["']/.exec(xml);
|
|
5699
6538
|
const uriMatch = /<(?:\w+:)?CipherReference[^>]+URI=["']([^"']+)["']/.exec(xml);
|
|
5700
|
-
|
|
5701
|
-
|
|
6539
|
+
const algorithm = algorithmMatch?.[1];
|
|
6540
|
+
const uri = uriMatch?.[1];
|
|
6541
|
+
if (!uri) continue;
|
|
6542
|
+
if (algorithm === IDPF_OBFUSCATION) {
|
|
6543
|
+
obfuscated.add(uri);
|
|
5702
6544
|
}
|
|
6545
|
+
pushMessage(context.messages, {
|
|
6546
|
+
id: MessageId.RSC_004,
|
|
6547
|
+
message: `File "${uri}" is encrypted, its content will not be checked`,
|
|
6548
|
+
location: { path: encryptionPath }
|
|
6549
|
+
});
|
|
5703
6550
|
}
|
|
5704
6551
|
if (obfuscated.size > 0) {
|
|
5705
6552
|
context.obfuscatedResources = obfuscated;
|
|
5706
6553
|
}
|
|
5707
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
|
+
}
|
|
5708
6642
|
/**
|
|
5709
6643
|
* Validate empty directories
|
|
5710
6644
|
*/
|
|
@@ -5732,6 +6666,65 @@ var OCFValidator = class {
|
|
|
5732
6666
|
}
|
|
5733
6667
|
};
|
|
5734
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
|
+
|
|
5735
6728
|
// src/opf/parser.ts
|
|
5736
6729
|
function parseOPF(xml) {
|
|
5737
6730
|
const packageRegex = /<package[^>]*\sversion=["']([^"']+)["'][^>]*(?:\sunique-identifier=["']([^"']+)["'])?[^>]*>/;
|
|
@@ -6082,6 +7075,25 @@ var DEPRECATED_MEDIA_TYPES = /* @__PURE__ */ new Set([
|
|
|
6082
7075
|
"application/x-oeb1-package",
|
|
6083
7076
|
"text/x-oeb1-html"
|
|
6084
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
|
+
}
|
|
6085
7097
|
var VALID_RELATOR_CODES = /* @__PURE__ */ new Set([
|
|
6086
7098
|
"abr",
|
|
6087
7099
|
"acp",
|
|
@@ -6364,6 +7376,60 @@ var DEPRECATED_LINK_REL = /* @__PURE__ */ new Set([
|
|
|
6364
7376
|
"xmp-record",
|
|
6365
7377
|
"xml-signature"
|
|
6366
7378
|
]);
|
|
7379
|
+
var EXCLUSIVE_SPINE_GROUPS = [
|
|
7380
|
+
["rendition:layout-reflowable", "rendition:layout-pre-paginated"],
|
|
7381
|
+
[
|
|
7382
|
+
"rendition:orientation-auto",
|
|
7383
|
+
"rendition:orientation-landscape",
|
|
7384
|
+
"rendition:orientation-portrait"
|
|
7385
|
+
],
|
|
7386
|
+
[
|
|
7387
|
+
"rendition:spread-auto",
|
|
7388
|
+
"rendition:spread-both",
|
|
7389
|
+
"rendition:spread-landscape",
|
|
7390
|
+
"rendition:spread-none",
|
|
7391
|
+
"rendition:spread-portrait"
|
|
7392
|
+
],
|
|
7393
|
+
["page-spread-left", "page-spread-right", "rendition:page-spread-center"],
|
|
7394
|
+
[
|
|
7395
|
+
"rendition:flow-auto",
|
|
7396
|
+
"rendition:flow-paginated",
|
|
7397
|
+
"rendition:flow-scrolled-continuous",
|
|
7398
|
+
"rendition:flow-scrolled-doc"
|
|
7399
|
+
]
|
|
7400
|
+
];
|
|
7401
|
+
var RENDITION_META_RULES = [
|
|
7402
|
+
{
|
|
7403
|
+
property: "rendition:layout",
|
|
7404
|
+
allowedValues: /* @__PURE__ */ new Set(["reflowable", "pre-paginated"]),
|
|
7405
|
+
forbidRefines: true
|
|
7406
|
+
},
|
|
7407
|
+
{
|
|
7408
|
+
property: "rendition:orientation",
|
|
7409
|
+
allowedValues: /* @__PURE__ */ new Set(["landscape", "portrait", "auto"]),
|
|
7410
|
+
forbidRefines: true
|
|
7411
|
+
},
|
|
7412
|
+
{
|
|
7413
|
+
property: "rendition:spread",
|
|
7414
|
+
allowedValues: /* @__PURE__ */ new Set(["none", "landscape", "portrait", "both", "auto"]),
|
|
7415
|
+
forbidRefines: true,
|
|
7416
|
+
deprecatedValues: /* @__PURE__ */ new Set(["portrait"])
|
|
7417
|
+
},
|
|
7418
|
+
{
|
|
7419
|
+
property: "rendition:flow",
|
|
7420
|
+
allowedValues: /* @__PURE__ */ new Set(["paginated", "scrolled-continuous", "scrolled-doc", "auto"]),
|
|
7421
|
+
forbidRefines: true
|
|
7422
|
+
},
|
|
7423
|
+
{
|
|
7424
|
+
property: "rendition:viewport",
|
|
7425
|
+
deprecated: true,
|
|
7426
|
+
allowedValues: /* @__PURE__ */ new Set(),
|
|
7427
|
+
validateSyntax: (v) => /^(width=\d+,\s*height=\d+|height=\d+,\s*width=\d+)$/.test(v)
|
|
7428
|
+
}
|
|
7429
|
+
];
|
|
7430
|
+
var KNOWN_RENDITION_META_PROPERTIES = new Set(
|
|
7431
|
+
RENDITION_META_RULES.map((r) => r.property.slice("rendition:".length))
|
|
7432
|
+
);
|
|
6367
7433
|
var GRANDFATHERED_LANG_TAGS = /* @__PURE__ */ new Set([
|
|
6368
7434
|
"en-GB-oed",
|
|
6369
7435
|
"i-ami",
|
|
@@ -6417,6 +7483,20 @@ var OPFValidator = class {
|
|
|
6417
7483
|
});
|
|
6418
7484
|
return;
|
|
6419
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
|
+
}
|
|
6420
7500
|
const opfXml = new TextDecoder().decode(opfData);
|
|
6421
7501
|
try {
|
|
6422
7502
|
this.packageDoc = parseOPF(opfXml);
|
|
@@ -6706,6 +7786,9 @@ var OPFValidator = class {
|
|
|
6706
7786
|
}
|
|
6707
7787
|
if (this.packageDoc.version !== "2.0") {
|
|
6708
7788
|
this.validateMetaPropertiesVocab(context, opfPath, dcElements);
|
|
7789
|
+
this.validateRenditionVocab(context, opfPath);
|
|
7790
|
+
this.validateMediaOverlaysVocab(context, opfPath);
|
|
7791
|
+
this.validateMediaOverlayItems(context, opfPath);
|
|
6709
7792
|
}
|
|
6710
7793
|
if (this.packageDoc.version !== "2.0") {
|
|
6711
7794
|
const modifiedMetas = this.packageDoc.metaElements.filter(
|
|
@@ -6996,6 +8079,206 @@ var OPFValidator = class {
|
|
|
6996
8079
|
}
|
|
6997
8080
|
}
|
|
6998
8081
|
}
|
|
8082
|
+
/**
|
|
8083
|
+
* Validate rendition vocabulary meta properties (rendition:layout, orientation, spread, flow, viewport).
|
|
8084
|
+
* Ports the Schematron rules from package-30.sch for the rendition vocabulary.
|
|
8085
|
+
*/
|
|
8086
|
+
validateRenditionVocab(context, opfPath) {
|
|
8087
|
+
if (!this.packageDoc) return;
|
|
8088
|
+
const metas = this.packageDoc.metaElements;
|
|
8089
|
+
for (const rp of RENDITION_META_RULES) {
|
|
8090
|
+
const matching = metas.filter((m) => m.property === rp.property);
|
|
8091
|
+
for (const meta of matching) {
|
|
8092
|
+
if (meta.refines && rp.forbidRefines) {
|
|
8093
|
+
pushMessage(context.messages, {
|
|
8094
|
+
id: MessageId.RSC_005,
|
|
8095
|
+
message: `The "${rp.property}" property must not refine a publication resource`,
|
|
8096
|
+
location: { path: opfPath }
|
|
8097
|
+
});
|
|
8098
|
+
continue;
|
|
8099
|
+
}
|
|
8100
|
+
if (rp.deprecated) {
|
|
8101
|
+
pushMessage(context.messages, {
|
|
8102
|
+
id: MessageId.OPF_086,
|
|
8103
|
+
message: `The "${rp.property}" property is deprecated`,
|
|
8104
|
+
location: { path: opfPath }
|
|
8105
|
+
});
|
|
8106
|
+
}
|
|
8107
|
+
if (rp.validateSyntax) {
|
|
8108
|
+
if (!rp.validateSyntax(meta.value)) {
|
|
8109
|
+
pushMessage(context.messages, {
|
|
8110
|
+
id: MessageId.RSC_005,
|
|
8111
|
+
message: `The value of the "${rp.property}" property must be of the form "width=x, height=y"`,
|
|
8112
|
+
location: { path: opfPath }
|
|
8113
|
+
});
|
|
8114
|
+
}
|
|
8115
|
+
} else if (!rp.allowedValues.has(meta.value)) {
|
|
8116
|
+
pushMessage(context.messages, {
|
|
8117
|
+
id: MessageId.RSC_005,
|
|
8118
|
+
message: `The value of the "${rp.property}" property must be ${[...rp.allowedValues].map((v) => `"${v}"`).join(" or ")}`,
|
|
8119
|
+
location: { path: opfPath }
|
|
8120
|
+
});
|
|
8121
|
+
}
|
|
8122
|
+
if (rp.deprecatedValues?.has(meta.value)) {
|
|
8123
|
+
pushMessage(context.messages, {
|
|
8124
|
+
id: MessageId.OPF_086,
|
|
8125
|
+
message: `The "${rp.property}" property value "${meta.value}" is deprecated`,
|
|
8126
|
+
location: { path: opfPath }
|
|
8127
|
+
});
|
|
8128
|
+
}
|
|
8129
|
+
}
|
|
8130
|
+
const countable = rp.forbidRefines ? matching : matching.filter((m) => !m.refines);
|
|
8131
|
+
if (countable.length > 1) {
|
|
8132
|
+
pushMessage(context.messages, {
|
|
8133
|
+
id: MessageId.RSC_005,
|
|
8134
|
+
message: `The "${rp.property}" property must not occur more than one time as a global value`,
|
|
8135
|
+
location: { path: opfPath }
|
|
8136
|
+
});
|
|
8137
|
+
}
|
|
8138
|
+
}
|
|
8139
|
+
for (const meta of metas) {
|
|
8140
|
+
if (meta.property.startsWith("rendition:")) {
|
|
8141
|
+
const localName = meta.property.slice("rendition:".length);
|
|
8142
|
+
if (!KNOWN_RENDITION_META_PROPERTIES.has(localName)) {
|
|
8143
|
+
pushMessage(context.messages, {
|
|
8144
|
+
id: MessageId.OPF_027,
|
|
8145
|
+
message: `Undefined property: "${meta.property}"`,
|
|
8146
|
+
location: { path: opfPath }
|
|
8147
|
+
});
|
|
8148
|
+
}
|
|
8149
|
+
}
|
|
8150
|
+
}
|
|
8151
|
+
}
|
|
8152
|
+
/**
|
|
8153
|
+
* Validate media overlays vocabulary meta properties (media:active-class, playback-active-class, duration).
|
|
8154
|
+
* Ports the Schematron rules from package-30.sch for the media overlays vocabulary.
|
|
8155
|
+
*/
|
|
8156
|
+
validateMediaOverlaysVocab(context, opfPath) {
|
|
8157
|
+
if (!this.packageDoc) return;
|
|
8158
|
+
const metas = this.packageDoc.metaElements;
|
|
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) {
|
|
8167
|
+
pushMessage(context.messages, {
|
|
8168
|
+
id: MessageId.RSC_005,
|
|
8169
|
+
message: `The '${displayName}' property must not occur more than one time in the package metadata`,
|
|
8170
|
+
location: { path: opfPath }
|
|
8171
|
+
});
|
|
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
|
+
}
|
|
8189
|
+
}
|
|
8190
|
+
if (matchingActive[0]) context.mediaActiveClass = matchingActive[0].value.trim();
|
|
8191
|
+
if (matchingPlayback[0]) context.mediaPlaybackActiveClass = matchingPlayback[0].value.trim();
|
|
8192
|
+
for (const meta of metas) {
|
|
8193
|
+
if (meta.property === "media:duration") {
|
|
8194
|
+
if (!isValidSmilClock(meta.value.trim())) {
|
|
8195
|
+
pushMessage(context.messages, {
|
|
8196
|
+
id: MessageId.RSC_005,
|
|
8197
|
+
message: `The value of the "media:duration" property must be a valid SMIL3 clock value`,
|
|
8198
|
+
location: { path: opfPath }
|
|
8199
|
+
});
|
|
8200
|
+
}
|
|
8201
|
+
}
|
|
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
|
+
}
|
|
8281
|
+
}
|
|
6999
8282
|
/**
|
|
7000
8283
|
* Validate EPUB 3 link elements in metadata
|
|
7001
8284
|
*/
|
|
@@ -7075,7 +8358,30 @@ var OPFValidator = class {
|
|
|
7075
8358
|
});
|
|
7076
8359
|
continue;
|
|
7077
8360
|
}
|
|
8361
|
+
if (isDataURL(href)) {
|
|
8362
|
+
pushMessage(context.messages, {
|
|
8363
|
+
id: MessageId.RSC_029,
|
|
8364
|
+
message: `Data URLs are not allowed in the package document link href`,
|
|
8365
|
+
location: { path: opfPath }
|
|
8366
|
+
});
|
|
8367
|
+
continue;
|
|
8368
|
+
}
|
|
8369
|
+
if (isFileURL(href)) {
|
|
8370
|
+
pushMessage(context.messages, {
|
|
8371
|
+
id: MessageId.RSC_030,
|
|
8372
|
+
message: `File URLs are not allowed in the package document`,
|
|
8373
|
+
location: { path: opfPath }
|
|
8374
|
+
});
|
|
8375
|
+
continue;
|
|
8376
|
+
}
|
|
7078
8377
|
const isRemote = /^[a-zA-Z][a-zA-Z0-9+\-.]*:/.test(href);
|
|
8378
|
+
if (!isRemote && href.includes("?")) {
|
|
8379
|
+
pushMessage(context.messages, {
|
|
8380
|
+
id: MessageId.RSC_033,
|
|
8381
|
+
message: `Relative URL strings must not have a query component: "${href}"`,
|
|
8382
|
+
location: { path: opfPath }
|
|
8383
|
+
});
|
|
8384
|
+
}
|
|
7079
8385
|
if (hasVoicing && link.mediaType && !link.mediaType.startsWith("audio/")) {
|
|
7080
8386
|
pushMessage(context.messages, {
|
|
7081
8387
|
id: MessageId.OPF_095,
|
|
@@ -7100,10 +8406,12 @@ var OPFValidator = class {
|
|
|
7100
8406
|
location: { path: opfPath }
|
|
7101
8407
|
});
|
|
7102
8408
|
}
|
|
7103
|
-
const
|
|
7104
|
-
const
|
|
8409
|
+
const basePathNoQuery = basePath.includes("?") ? basePath.substring(0, basePath.indexOf("?")) : basePath;
|
|
8410
|
+
const basePathDecodedNoQuery = basePathDecoded.includes("?") ? basePathDecoded.substring(0, basePathDecoded.indexOf("?")) : basePathDecoded;
|
|
8411
|
+
const resolvedPath = resolvePath(opfPath, basePathNoQuery);
|
|
8412
|
+
const resolvedPathDecoded = basePathDecodedNoQuery !== basePathNoQuery ? resolvePath(opfPath, basePathDecodedNoQuery) : resolvedPath;
|
|
7105
8413
|
const fileExists = context.files.has(resolvedPath) || context.files.has(resolvedPathDecoded);
|
|
7106
|
-
const inManifest = this.manifestByHref.has(
|
|
8414
|
+
const inManifest = this.manifestByHref.has(basePathNoQuery) || this.manifestByHref.has(basePathDecodedNoQuery);
|
|
7107
8415
|
if (!fileExists && !inManifest) {
|
|
7108
8416
|
pushMessage(context.messages, {
|
|
7109
8417
|
id: MessageId.RSC_007w,
|
|
@@ -7137,7 +8445,24 @@ var OPFValidator = class {
|
|
|
7137
8445
|
});
|
|
7138
8446
|
}
|
|
7139
8447
|
seenHrefs.add(item.href);
|
|
7140
|
-
|
|
8448
|
+
if (isDataURL(item.href)) {
|
|
8449
|
+
pushMessage(context.messages, {
|
|
8450
|
+
id: MessageId.RSC_029,
|
|
8451
|
+
message: `Data URLs are not allowed in the manifest item href`,
|
|
8452
|
+
location: { path: opfPath }
|
|
8453
|
+
});
|
|
8454
|
+
continue;
|
|
8455
|
+
}
|
|
8456
|
+
const isRemoteItem = /^[a-zA-Z][a-zA-Z0-9+\-.]*:/.test(item.href);
|
|
8457
|
+
if (!isRemoteItem && item.href.includes("?")) {
|
|
8458
|
+
pushMessage(context.messages, {
|
|
8459
|
+
id: MessageId.RSC_033,
|
|
8460
|
+
message: `Relative URL strings must not have a query component: "${item.href}"`,
|
|
8461
|
+
location: { path: opfPath }
|
|
8462
|
+
});
|
|
8463
|
+
}
|
|
8464
|
+
const itemHrefBase = item.href.includes("?") ? item.href.substring(0, item.href.indexOf("?")) : item.href;
|
|
8465
|
+
const fullPath = resolvePath(opfPath, itemHrefBase);
|
|
7141
8466
|
if (fullPath === opfPath) {
|
|
7142
8467
|
pushMessage(context.messages, {
|
|
7143
8468
|
id: MessageId.OPF_099,
|
|
@@ -7155,8 +8480,8 @@ var OPFValidator = class {
|
|
|
7155
8480
|
});
|
|
7156
8481
|
}
|
|
7157
8482
|
}
|
|
7158
|
-
const decodedHref = tryDecodeUriComponent(
|
|
7159
|
-
const fullPathDecoded = decodedHref !==
|
|
8483
|
+
const decodedHref = tryDecodeUriComponent(itemHrefBase);
|
|
8484
|
+
const fullPathDecoded = decodedHref !== itemHrefBase ? resolvePath(opfPath, decodedHref).normalize("NFC") : fullPath;
|
|
7160
8485
|
if (!context.files.has(fullPath) && !context.files.has(fullPathDecoded) && !item.href.startsWith("http")) {
|
|
7161
8486
|
pushMessage(context.messages, {
|
|
7162
8487
|
id: MessageId.RSC_001,
|
|
@@ -7185,6 +8510,14 @@ var OPFValidator = class {
|
|
|
7185
8510
|
location: { path: opfPath }
|
|
7186
8511
|
});
|
|
7187
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
|
+
}
|
|
7188
8521
|
if (this.packageDoc.version !== "2.0" && item.properties) {
|
|
7189
8522
|
for (const prop of item.properties) {
|
|
7190
8523
|
if (!ITEM_PROPERTIES.has(prop)) {
|
|
@@ -7379,6 +8712,24 @@ var OPFValidator = class {
|
|
|
7379
8712
|
location: { path: opfPath }
|
|
7380
8713
|
});
|
|
7381
8714
|
}
|
|
8715
|
+
if (prop === "rendition:spread-portrait") {
|
|
8716
|
+
pushMessage(context.messages, {
|
|
8717
|
+
id: MessageId.OPF_086,
|
|
8718
|
+
message: `The "rendition:spread-portrait" property is deprecated`,
|
|
8719
|
+
location: { path: opfPath }
|
|
8720
|
+
});
|
|
8721
|
+
}
|
|
8722
|
+
}
|
|
8723
|
+
const props = new Set(itemref.properties);
|
|
8724
|
+
for (const group of EXCLUSIVE_SPINE_GROUPS) {
|
|
8725
|
+
const found = group.filter((p) => props.has(p));
|
|
8726
|
+
if (found.length > 1) {
|
|
8727
|
+
pushMessage(context.messages, {
|
|
8728
|
+
id: MessageId.RSC_005,
|
|
8729
|
+
message: `Properties "${found.join('", "')}" are mutually exclusive`,
|
|
8730
|
+
location: { path: opfPath }
|
|
8731
|
+
});
|
|
8732
|
+
}
|
|
7382
8733
|
}
|
|
7383
8734
|
}
|
|
7384
8735
|
}
|
|
@@ -7922,7 +9273,7 @@ var ReferenceValidator = class {
|
|
|
7922
9273
|
location: reference.location
|
|
7923
9274
|
});
|
|
7924
9275
|
}
|
|
7925
|
-
if (!this.registry.hasResource(resourcePath)) {
|
|
9276
|
+
if (reference.type !== "overlay-text-link" /* OVERLAY_TEXT_LINK */ && !this.registry.hasResource(resourcePath)) {
|
|
7926
9277
|
const fileExistsInContainer = context.files.has(resourcePath);
|
|
7927
9278
|
if (fileExistsInContainer) {
|
|
7928
9279
|
if (!context.referencedUndeclaredResources?.has(resourcePath)) {
|
|
@@ -8059,6 +9410,24 @@ var ReferenceValidator = class {
|
|
|
8059
9410
|
});
|
|
8060
9411
|
}
|
|
8061
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
|
+
}
|
|
8062
9431
|
const parsedMimeTypes = ["application/xhtml+xml", "image/svg+xml"];
|
|
8063
9432
|
if (resource && parsedMimeTypes.includes(resource.mimeType) && !isRemoteURL(resourcePath) && !fragment.includes("svgView(") && reference.type !== "svg-symbol" /* SVG_SYMBOL */) {
|
|
8064
9433
|
if (!this.registry.hasID(resourcePath, fragment)) {
|
|
@@ -8070,6 +9439,18 @@ var ReferenceValidator = class {
|
|
|
8070
9439
|
}
|
|
8071
9440
|
}
|
|
8072
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
|
+
}
|
|
8073
9454
|
/**
|
|
8074
9455
|
* Check non-spine remote resources that have non-standard types.
|
|
8075
9456
|
* Fires RSC-006 for remote items that aren't audio/video/font types
|
|
@@ -8129,7 +9510,7 @@ var ReferenceValidator = class {
|
|
|
8129
9510
|
}
|
|
8130
9511
|
}
|
|
8131
9512
|
checkReadingOrder(context) {
|
|
8132
|
-
if (!context.
|
|
9513
|
+
if (!context.packageDocument) return;
|
|
8133
9514
|
const packageDoc = context.packageDocument;
|
|
8134
9515
|
const spine = packageDoc.spine;
|
|
8135
9516
|
const opfPath = context.opfPath ?? "";
|
|
@@ -8141,15 +9522,35 @@ var ReferenceValidator = class {
|
|
|
8141
9522
|
spinePositionMap.set(resolveManifestHref(opfDir, item.href), i);
|
|
8142
9523
|
}
|
|
8143
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) {
|
|
8144
9545
|
let lastSpinePosition = -1;
|
|
8145
9546
|
let lastAnchorPosition = -1;
|
|
8146
|
-
for (const link of
|
|
9547
|
+
for (const link of links) {
|
|
8147
9548
|
const spinePos = spinePositionMap.get(link.targetResource);
|
|
8148
9549
|
if (spinePos === void 0) continue;
|
|
8149
9550
|
if (spinePos < lastSpinePosition) {
|
|
8150
9551
|
pushMessage(context.messages, {
|
|
8151
|
-
id:
|
|
8152
|
-
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`,
|
|
8153
9554
|
location: link.location
|
|
8154
9555
|
});
|
|
8155
9556
|
lastSpinePosition = spinePos;
|
|
@@ -8164,8 +9565,8 @@ var ReferenceValidator = class {
|
|
|
8164
9565
|
if (targetAnchorPosition < lastAnchorPosition) {
|
|
8165
9566
|
const target = link.fragment ? `${link.targetResource}#${link.fragment}` : link.targetResource;
|
|
8166
9567
|
pushMessage(context.messages, {
|
|
8167
|
-
id:
|
|
8168
|
-
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`,
|
|
8169
9570
|
location: link.location
|
|
8170
9571
|
});
|
|
8171
9572
|
}
|
|
@@ -8384,11 +9785,11 @@ var RelaxNGValidator = class extends BaseSchemaValidator {
|
|
|
8384
9785
|
try {
|
|
8385
9786
|
const libxml2 = await import('libxml2-wasm');
|
|
8386
9787
|
const LibRelaxNGValidator = libxml2.RelaxNGValidator;
|
|
8387
|
-
const { XmlDocument:
|
|
8388
|
-
const doc =
|
|
9788
|
+
const { XmlDocument: XmlDocument4 } = libxml2;
|
|
9789
|
+
const doc = XmlDocument4.fromString(xml);
|
|
8389
9790
|
try {
|
|
8390
9791
|
const schemaContent = await loadSchema(schemaPath);
|
|
8391
|
-
const schemaDoc =
|
|
9792
|
+
const schemaDoc = XmlDocument4.fromString(schemaContent);
|
|
8392
9793
|
try {
|
|
8393
9794
|
const validator = LibRelaxNGValidator.fromDoc(schemaDoc);
|
|
8394
9795
|
try {
|