@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.js
CHANGED
|
@@ -1602,31 +1602,276 @@ var CSSValidator = class {
|
|
|
1602
1602
|
* Check for reserved media overlay class names
|
|
1603
1603
|
*/
|
|
1604
1604
|
checkMediaOverlayClasses(context, ast, resourcePath) {
|
|
1605
|
-
const
|
|
1606
|
-
|
|
1607
|
-
"media-overlay-active",
|
|
1608
|
-
"-epub-media-overlay-playing",
|
|
1609
|
-
"media-overlay-playing"
|
|
1610
|
-
]);
|
|
1605
|
+
const activeClassNames = /* @__PURE__ */ new Set(["-epub-media-overlay-active", "media-overlay-active"]);
|
|
1606
|
+
const playbackClassNames = /* @__PURE__ */ new Set(["-epub-media-overlay-playing", "media-overlay-playing"]);
|
|
1611
1607
|
walk(ast, (node) => {
|
|
1612
1608
|
if (node.type === "ClassSelector") {
|
|
1613
1609
|
const className = node.name.toLowerCase();
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1610
|
+
const isActive = activeClassNames.has(className);
|
|
1611
|
+
const isPlayback = playbackClassNames.has(className);
|
|
1612
|
+
if (!isActive && !isPlayback) return;
|
|
1613
|
+
const isDeclared = isActive ? !!context.mediaActiveClass : !!context.mediaPlaybackActiveClass;
|
|
1614
|
+
if (isDeclared) return;
|
|
1615
|
+
const loc = node.loc;
|
|
1616
|
+
const start = loc?.start;
|
|
1617
|
+
const location = { path: resourcePath };
|
|
1618
|
+
if (start) {
|
|
1619
|
+
location.line = start.line;
|
|
1620
|
+
location.column = start.column;
|
|
1621
|
+
}
|
|
1622
|
+
const property = isActive ? "media:active-class" : "media:playback-active-class";
|
|
1623
|
+
pushMessage(context.messages, {
|
|
1624
|
+
id: MessageId.CSS_029,
|
|
1625
|
+
message: `Class name "${className}" is reserved for media overlays but "${property}" is not declared in the package document`,
|
|
1626
|
+
location
|
|
1627
|
+
});
|
|
1628
|
+
}
|
|
1629
|
+
});
|
|
1630
|
+
}
|
|
1631
|
+
};
|
|
1632
|
+
|
|
1633
|
+
// src/smil/clock.ts
|
|
1634
|
+
var FULL_CLOCK_RE = /^(\d+):([0-5]\d):([0-5]\d)(\.\d+)?$/;
|
|
1635
|
+
var PARTIAL_CLOCK_RE = /^([0-5]\d):([0-5]\d)(\.\d+)?$/;
|
|
1636
|
+
var TIMECOUNT_RE = /^(\d+(\.\d+)?)(h|min|s|ms)?$/;
|
|
1637
|
+
function parseSmilClock(value) {
|
|
1638
|
+
const trimmed = value.trim();
|
|
1639
|
+
const full = FULL_CLOCK_RE.exec(trimmed);
|
|
1640
|
+
if (full) {
|
|
1641
|
+
const hours = Number.parseInt(full[1] ?? "0", 10);
|
|
1642
|
+
const minutes = Number.parseInt(full[2] ?? "0", 10);
|
|
1643
|
+
const seconds = Number.parseInt(full[3] ?? "0", 10);
|
|
1644
|
+
const frac = full[4] ? Number.parseFloat(full[4]) : 0;
|
|
1645
|
+
return hours * 3600 + minutes * 60 + seconds + frac;
|
|
1646
|
+
}
|
|
1647
|
+
const partial = PARTIAL_CLOCK_RE.exec(trimmed);
|
|
1648
|
+
if (partial) {
|
|
1649
|
+
const minutes = Number.parseInt(partial[1] ?? "0", 10);
|
|
1650
|
+
const seconds = Number.parseInt(partial[2] ?? "0", 10);
|
|
1651
|
+
const frac = partial[3] ? Number.parseFloat(partial[3]) : 0;
|
|
1652
|
+
return minutes * 60 + seconds + frac;
|
|
1653
|
+
}
|
|
1654
|
+
const timecount = TIMECOUNT_RE.exec(trimmed);
|
|
1655
|
+
if (timecount) {
|
|
1656
|
+
const num = Number.parseFloat(timecount[1] ?? "0");
|
|
1657
|
+
const unit = timecount[3] ?? "s";
|
|
1658
|
+
switch (unit) {
|
|
1659
|
+
case "h":
|
|
1660
|
+
return num * 3600;
|
|
1661
|
+
case "min":
|
|
1662
|
+
return num * 60;
|
|
1663
|
+
case "s":
|
|
1664
|
+
return num;
|
|
1665
|
+
case "ms":
|
|
1666
|
+
return num / 1e3;
|
|
1667
|
+
default:
|
|
1668
|
+
return NaN;
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
return NaN;
|
|
1672
|
+
}
|
|
1673
|
+
function isValidSmilClock(value) {
|
|
1674
|
+
return !Number.isNaN(parseSmilClock(value));
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
// src/smil/validator.ts
|
|
1678
|
+
var SMIL_NS = { smil: "http://www.w3.org/ns/SMIL" };
|
|
1679
|
+
var BLESSED_AUDIO_TYPES = /* @__PURE__ */ new Set(["audio/mpeg", "audio/mp4"]);
|
|
1680
|
+
var BLESSED_AUDIO_PATTERN = /^audio\/ogg\s*;\s*codecs=opus$/i;
|
|
1681
|
+
function isBlessedAudioType(mimeType) {
|
|
1682
|
+
return BLESSED_AUDIO_TYPES.has(mimeType) || BLESSED_AUDIO_PATTERN.test(mimeType);
|
|
1683
|
+
}
|
|
1684
|
+
var SMILValidator = class {
|
|
1685
|
+
getAttribute(element, name) {
|
|
1686
|
+
return element.attr(name)?.value ?? null;
|
|
1687
|
+
}
|
|
1688
|
+
getEpubAttribute(element, localName) {
|
|
1689
|
+
return element.attr(localName, "epub")?.value ?? null;
|
|
1690
|
+
}
|
|
1691
|
+
validate(context, path, manifestByPath) {
|
|
1692
|
+
const result = {
|
|
1693
|
+
textReferences: [],
|
|
1694
|
+
referencedDocuments: /* @__PURE__ */ new Set(),
|
|
1695
|
+
hasRemoteResources: false
|
|
1696
|
+
};
|
|
1697
|
+
const data = context.files.get(path);
|
|
1698
|
+
if (!data) return result;
|
|
1699
|
+
const content = typeof data === "string" ? data : new TextDecoder().decode(data);
|
|
1700
|
+
let doc = null;
|
|
1701
|
+
try {
|
|
1702
|
+
doc = XmlDocument.fromString(content);
|
|
1703
|
+
} catch {
|
|
1704
|
+
pushMessage(context.messages, {
|
|
1705
|
+
id: MessageId.RSC_016,
|
|
1706
|
+
message: "Media Overlay document is not well-formed XML",
|
|
1707
|
+
location: { path }
|
|
1708
|
+
});
|
|
1709
|
+
return result;
|
|
1710
|
+
}
|
|
1711
|
+
try {
|
|
1712
|
+
const root = doc.root;
|
|
1713
|
+
this.validateStructure(context, path, root);
|
|
1714
|
+
this.validateAudioElements(context, path, root, manifestByPath, result);
|
|
1715
|
+
this.extractTextReferences(path, root, result);
|
|
1716
|
+
} finally {
|
|
1717
|
+
doc.dispose();
|
|
1718
|
+
}
|
|
1719
|
+
return result;
|
|
1720
|
+
}
|
|
1721
|
+
validateStructure(context, path, root) {
|
|
1722
|
+
try {
|
|
1723
|
+
for (const text of root.find(".//smil:seq/smil:text", SMIL_NS)) {
|
|
1724
|
+
pushMessage(context.messages, {
|
|
1725
|
+
id: MessageId.RSC_005,
|
|
1726
|
+
message: "element 'text' not allowed here; expected 'seq' or 'par'",
|
|
1727
|
+
location: { path, line: text.line }
|
|
1728
|
+
});
|
|
1729
|
+
}
|
|
1730
|
+
for (const audio of root.find(".//smil:seq/smil:audio", SMIL_NS)) {
|
|
1731
|
+
pushMessage(context.messages, {
|
|
1732
|
+
id: MessageId.RSC_005,
|
|
1733
|
+
message: "element 'audio' not allowed here; expected 'seq' or 'par'",
|
|
1734
|
+
location: { path, line: audio.line }
|
|
1735
|
+
});
|
|
1736
|
+
}
|
|
1737
|
+
} catch {
|
|
1738
|
+
}
|
|
1739
|
+
try {
|
|
1740
|
+
for (const seq of root.find(".//smil:par/smil:seq", SMIL_NS)) {
|
|
1741
|
+
pushMessage(context.messages, {
|
|
1742
|
+
id: MessageId.RSC_005,
|
|
1743
|
+
message: "element 'seq' not allowed here; expected 'text' or 'audio'",
|
|
1744
|
+
location: { path, line: seq.line }
|
|
1745
|
+
});
|
|
1746
|
+
}
|
|
1747
|
+
const parElements = root.find(".//smil:par", SMIL_NS);
|
|
1748
|
+
for (const par of parElements) {
|
|
1749
|
+
const textChildren = par.find("./smil:text", SMIL_NS);
|
|
1750
|
+
for (let i = 1; i < textChildren.length; i++) {
|
|
1751
|
+
const extra = textChildren[i];
|
|
1752
|
+
if (!extra) continue;
|
|
1622
1753
|
pushMessage(context.messages, {
|
|
1623
|
-
id: MessageId.
|
|
1624
|
-
message:
|
|
1625
|
-
location
|
|
1754
|
+
id: MessageId.RSC_005,
|
|
1755
|
+
message: "element 'text' not allowed here; only one 'text' element is allowed in 'par'",
|
|
1756
|
+
location: { path, line: extra.line }
|
|
1626
1757
|
});
|
|
1627
1758
|
}
|
|
1628
1759
|
}
|
|
1629
|
-
}
|
|
1760
|
+
} catch {
|
|
1761
|
+
}
|
|
1762
|
+
try {
|
|
1763
|
+
const headMetaElements = root.find(".//smil:head/smil:meta", SMIL_NS);
|
|
1764
|
+
for (const meta of headMetaElements) {
|
|
1765
|
+
pushMessage(context.messages, {
|
|
1766
|
+
id: MessageId.RSC_005,
|
|
1767
|
+
message: "element 'meta' not allowed here; expected 'metadata'",
|
|
1768
|
+
location: { path, line: meta.line }
|
|
1769
|
+
});
|
|
1770
|
+
}
|
|
1771
|
+
} catch {
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
validateAudioElements(context, path, root, manifestByPath, result) {
|
|
1775
|
+
try {
|
|
1776
|
+
const audioElements = root.find(".//smil:audio", SMIL_NS);
|
|
1777
|
+
for (const audio of audioElements) {
|
|
1778
|
+
const elem = audio;
|
|
1779
|
+
const src = this.getAttribute(elem, "src");
|
|
1780
|
+
if (src) {
|
|
1781
|
+
if (/^https?:\/\//i.test(src)) {
|
|
1782
|
+
result.hasRemoteResources = true;
|
|
1783
|
+
}
|
|
1784
|
+
if (src.includes("#")) {
|
|
1785
|
+
pushMessage(context.messages, {
|
|
1786
|
+
id: MessageId.MED_014,
|
|
1787
|
+
message: `Media overlay audio file URLs must not have a fragment: "${src}"`,
|
|
1788
|
+
location: { path, line: audio.line }
|
|
1789
|
+
});
|
|
1790
|
+
}
|
|
1791
|
+
if (manifestByPath) {
|
|
1792
|
+
const audioPath = this.resolveRelativePath(path, src.split("#")[0] ?? src);
|
|
1793
|
+
const audioItem = manifestByPath.get(audioPath);
|
|
1794
|
+
if (audioItem && !isBlessedAudioType(audioItem.mediaType)) {
|
|
1795
|
+
pushMessage(context.messages, {
|
|
1796
|
+
id: MessageId.MED_005,
|
|
1797
|
+
message: `Media Overlay audio reference "${src}" to non-standard audio type "${audioItem.mediaType}"`,
|
|
1798
|
+
location: { path, line: audio.line }
|
|
1799
|
+
});
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1803
|
+
const clipBegin = this.getAttribute(elem, "clipBegin");
|
|
1804
|
+
const clipEnd = this.getAttribute(elem, "clipEnd");
|
|
1805
|
+
this.checkClipTiming(context, path, audio.line, clipBegin, clipEnd);
|
|
1806
|
+
}
|
|
1807
|
+
} catch {
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
checkClipTiming(context, path, line, clipBegin, clipEnd) {
|
|
1811
|
+
if (clipEnd === null) return;
|
|
1812
|
+
const beginStr = clipBegin ?? "0";
|
|
1813
|
+
const start = parseSmilClock(beginStr);
|
|
1814
|
+
const end = parseSmilClock(clipEnd);
|
|
1815
|
+
if (Number.isNaN(start) || Number.isNaN(end)) return;
|
|
1816
|
+
const location = line != null ? { path, line } : { path };
|
|
1817
|
+
if (start > end) {
|
|
1818
|
+
pushMessage(context.messages, {
|
|
1819
|
+
id: MessageId.MED_008,
|
|
1820
|
+
message: "The time specified in the clipBegin attribute must not be after clipEnd",
|
|
1821
|
+
location
|
|
1822
|
+
});
|
|
1823
|
+
} else if (start === end) {
|
|
1824
|
+
pushMessage(context.messages, {
|
|
1825
|
+
id: MessageId.MED_009,
|
|
1826
|
+
message: "The time specified in the clipBegin attribute must not be the same as clipEnd",
|
|
1827
|
+
location
|
|
1828
|
+
});
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
extractTextReferences(path, root, result) {
|
|
1832
|
+
try {
|
|
1833
|
+
const textElements = root.find(".//smil:text", SMIL_NS);
|
|
1834
|
+
for (const text of textElements) {
|
|
1835
|
+
const src = this.getAttribute(text, "src");
|
|
1836
|
+
if (!src) continue;
|
|
1837
|
+
const hashIndex = src.indexOf("#");
|
|
1838
|
+
const docRef = hashIndex >= 0 ? src.substring(0, hashIndex) : src;
|
|
1839
|
+
const fragment = hashIndex >= 0 ? src.substring(hashIndex + 1) : void 0;
|
|
1840
|
+
const docPath = this.resolveRelativePath(path, docRef);
|
|
1841
|
+
result.textReferences.push({ docPath, fragment, line: text.line });
|
|
1842
|
+
result.referencedDocuments.add(docPath);
|
|
1843
|
+
}
|
|
1844
|
+
const bodyElements = root.find(".//smil:body", SMIL_NS);
|
|
1845
|
+
const seqElements = root.find(".//smil:seq", SMIL_NS);
|
|
1846
|
+
for (const elem of [...bodyElements, ...seqElements]) {
|
|
1847
|
+
const textref = this.getEpubAttribute(elem, "textref");
|
|
1848
|
+
if (!textref) continue;
|
|
1849
|
+
const hashIndex = textref.indexOf("#");
|
|
1850
|
+
const docRef = hashIndex >= 0 ? textref.substring(0, hashIndex) : textref;
|
|
1851
|
+
const fragment = hashIndex >= 0 ? textref.substring(hashIndex + 1) : void 0;
|
|
1852
|
+
const docPath = this.resolveRelativePath(path, docRef);
|
|
1853
|
+
result.textReferences.push({ docPath, fragment, line: elem.line });
|
|
1854
|
+
result.referencedDocuments.add(docPath);
|
|
1855
|
+
}
|
|
1856
|
+
} catch {
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
resolveRelativePath(basePath, relativePath) {
|
|
1860
|
+
if (relativePath.startsWith("/") || /^[a-zA-Z]+:/.test(relativePath)) {
|
|
1861
|
+
return relativePath;
|
|
1862
|
+
}
|
|
1863
|
+
const baseDir = basePath.includes("/") ? basePath.substring(0, basePath.lastIndexOf("/")) : "";
|
|
1864
|
+
if (!baseDir) return relativePath;
|
|
1865
|
+
const segments = `${baseDir}/${relativePath}`.split("/");
|
|
1866
|
+
const resolved = [];
|
|
1867
|
+
for (const seg of segments) {
|
|
1868
|
+
if (seg === "..") {
|
|
1869
|
+
resolved.pop();
|
|
1870
|
+
} else if (seg !== ".") {
|
|
1871
|
+
resolved.push(seg);
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1874
|
+
return resolved.join("/");
|
|
1630
1875
|
}
|
|
1631
1876
|
};
|
|
1632
1877
|
|
|
@@ -1702,7 +1947,12 @@ var SPINE_PROPERTIES = /* @__PURE__ */ new Set([
|
|
|
1702
1947
|
"rendition:layout-pre-paginated",
|
|
1703
1948
|
"rendition:orientation-auto",
|
|
1704
1949
|
"rendition:orientation-landscape",
|
|
1705
|
-
"rendition:orientation-portrait"
|
|
1950
|
+
"rendition:orientation-portrait",
|
|
1951
|
+
"rendition:flow-auto",
|
|
1952
|
+
"rendition:flow-paginated",
|
|
1953
|
+
"rendition:flow-scrolled-continuous",
|
|
1954
|
+
"rendition:flow-scrolled-doc",
|
|
1955
|
+
"rendition:align-x-center"
|
|
1706
1956
|
]);
|
|
1707
1957
|
|
|
1708
1958
|
// src/references/types.ts
|
|
@@ -1723,6 +1973,89 @@ function isPublicationResourceReference(type) {
|
|
|
1723
1973
|
return PUBLICATION_RESOURCE_TYPES.has(type);
|
|
1724
1974
|
}
|
|
1725
1975
|
|
|
1976
|
+
// src/references/uri-schemes.ts
|
|
1977
|
+
var URI_SCHEMES = /* @__PURE__ */ new Set([
|
|
1978
|
+
"aaa",
|
|
1979
|
+
"aaas",
|
|
1980
|
+
"acap",
|
|
1981
|
+
"afs",
|
|
1982
|
+
"cap",
|
|
1983
|
+
"cid",
|
|
1984
|
+
"crid",
|
|
1985
|
+
"data",
|
|
1986
|
+
"dav",
|
|
1987
|
+
"dict",
|
|
1988
|
+
"dns",
|
|
1989
|
+
"dtn",
|
|
1990
|
+
"fax",
|
|
1991
|
+
"file",
|
|
1992
|
+
"ftp",
|
|
1993
|
+
"go",
|
|
1994
|
+
"gopher",
|
|
1995
|
+
"h323",
|
|
1996
|
+
"http",
|
|
1997
|
+
"https",
|
|
1998
|
+
"iax",
|
|
1999
|
+
"icap",
|
|
2000
|
+
"im",
|
|
2001
|
+
"imap",
|
|
2002
|
+
"info",
|
|
2003
|
+
"ipp",
|
|
2004
|
+
"irc",
|
|
2005
|
+
"iris",
|
|
2006
|
+
"iris.beep",
|
|
2007
|
+
"iris.lwz",
|
|
2008
|
+
"iris.xpc",
|
|
2009
|
+
"iris.xpcs",
|
|
2010
|
+
"javascript",
|
|
2011
|
+
"ldap",
|
|
2012
|
+
"mailto",
|
|
2013
|
+
"mailserver",
|
|
2014
|
+
"mid",
|
|
2015
|
+
"modem",
|
|
2016
|
+
"msrp",
|
|
2017
|
+
"msrps",
|
|
2018
|
+
"mtqp",
|
|
2019
|
+
"mupdate",
|
|
2020
|
+
"news",
|
|
2021
|
+
"nfs",
|
|
2022
|
+
"nntp",
|
|
2023
|
+
"opaquelocktoken",
|
|
2024
|
+
"pack",
|
|
2025
|
+
"pop",
|
|
2026
|
+
"pres",
|
|
2027
|
+
"prospero",
|
|
2028
|
+
"rtsp",
|
|
2029
|
+
"service",
|
|
2030
|
+
"shttp",
|
|
2031
|
+
"sip",
|
|
2032
|
+
"sips",
|
|
2033
|
+
"snews",
|
|
2034
|
+
"snmp",
|
|
2035
|
+
"soap.beep",
|
|
2036
|
+
"soap.beeps",
|
|
2037
|
+
"tag",
|
|
2038
|
+
"tel",
|
|
2039
|
+
"telnet",
|
|
2040
|
+
"tftp",
|
|
2041
|
+
"thismessage",
|
|
2042
|
+
"tip",
|
|
2043
|
+
"tn3270",
|
|
2044
|
+
"tv",
|
|
2045
|
+
"urn",
|
|
2046
|
+
"vemmi",
|
|
2047
|
+
"videotex",
|
|
2048
|
+
"wais",
|
|
2049
|
+
"xmlrpc.beep",
|
|
2050
|
+
"xmlrpc.beeps",
|
|
2051
|
+
"xmpp",
|
|
2052
|
+
"z39.50r",
|
|
2053
|
+
"z39.50s"
|
|
2054
|
+
]);
|
|
2055
|
+
function isRegisteredScheme(scheme) {
|
|
2056
|
+
return URI_SCHEMES.has(scheme.toLowerCase());
|
|
2057
|
+
}
|
|
2058
|
+
|
|
1726
2059
|
// src/references/url.ts
|
|
1727
2060
|
function parseURL(urlString) {
|
|
1728
2061
|
const hashIndex = urlString.indexOf("#");
|
|
@@ -1758,15 +2091,9 @@ function hasParentDirectoryReference(url) {
|
|
|
1758
2091
|
return url.includes("..");
|
|
1759
2092
|
}
|
|
1760
2093
|
function isMalformedURL(url) {
|
|
1761
|
-
if (!url) return true;
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
if (!trimmed) return true;
|
|
1765
|
-
if (/[\s<>]/.test(trimmed)) return true;
|
|
1766
|
-
return false;
|
|
1767
|
-
} catch {
|
|
1768
|
-
return true;
|
|
1769
|
-
}
|
|
2094
|
+
if (!url.trim()) return true;
|
|
2095
|
+
if (/[\s<>]/.test(url)) return true;
|
|
2096
|
+
return false;
|
|
1770
2097
|
}
|
|
1771
2098
|
function isHTTPS(url) {
|
|
1772
2099
|
return url.startsWith("https://");
|
|
@@ -1803,6 +2130,38 @@ function resolveManifestHref(opfDir, href) {
|
|
|
1803
2130
|
// src/content/validator.ts
|
|
1804
2131
|
var DISCOURAGED_ELEMENTS = /* @__PURE__ */ new Set(["base", "embed", "rp"]);
|
|
1805
2132
|
var ABSOLUTE_URI_RE = /^[a-zA-Z][a-zA-Z0-9+.-]*:/;
|
|
2133
|
+
var SPECIAL_URL_SCHEMES = /* @__PURE__ */ new Set(["http", "https", "ftp", "ws", "wss"]);
|
|
2134
|
+
var CSS_CHARSET_RE = /^@charset\s+"([^"]+)"\s*;/;
|
|
2135
|
+
var EPUB_XMLNS_RE = /xmlns:epub\s*=\s*"([^"]*)"/;
|
|
2136
|
+
var XHTML_NS = { html: "http://www.w3.org/1999/xhtml" };
|
|
2137
|
+
var EPUB_TYPE_FORBIDDEN_ELEMENTS = /* @__PURE__ */ new Set([
|
|
2138
|
+
"head",
|
|
2139
|
+
"meta",
|
|
2140
|
+
"title",
|
|
2141
|
+
"style",
|
|
2142
|
+
"link",
|
|
2143
|
+
"script",
|
|
2144
|
+
"noscript",
|
|
2145
|
+
"base"
|
|
2146
|
+
]);
|
|
2147
|
+
function validateAbsoluteHyperlinkURL(context, href, path, line) {
|
|
2148
|
+
const location = line != null ? { path, line } : { path };
|
|
2149
|
+
const scheme = href.slice(0, href.indexOf(":")).toLowerCase();
|
|
2150
|
+
if (!isRegisteredScheme(scheme)) {
|
|
2151
|
+
pushMessage(context.messages, {
|
|
2152
|
+
id: MessageId.HTM_025,
|
|
2153
|
+
message: "Hyperlink uses non-registered URI scheme type",
|
|
2154
|
+
location
|
|
2155
|
+
});
|
|
2156
|
+
}
|
|
2157
|
+
if (/[\s<>]/.test(href) || SPECIAL_URL_SCHEMES.has(scheme) && !href.slice(href.indexOf(":")).startsWith("://")) {
|
|
2158
|
+
pushMessage(context.messages, {
|
|
2159
|
+
id: MessageId.RSC_020,
|
|
2160
|
+
message: `URL is not valid: "${href}"`,
|
|
2161
|
+
location
|
|
2162
|
+
});
|
|
2163
|
+
}
|
|
2164
|
+
}
|
|
1806
2165
|
var IMAGE_MAGIC = [
|
|
1807
2166
|
{ mime: "image/jpeg", bytes: [255, 216], extensions: [".jpg", ".jpeg", ".jpe"] },
|
|
1808
2167
|
{ mime: "image/gif", bytes: [71, 73, 70, 56], extensions: [".gif"] },
|
|
@@ -2077,6 +2436,16 @@ var HTML_ENTITIES = /* @__PURE__ */ new Set([
|
|
|
2077
2436
|
"thorn",
|
|
2078
2437
|
"yuml"
|
|
2079
2438
|
]);
|
|
2439
|
+
function isItemFixedLayout(packageDoc, itemId) {
|
|
2440
|
+
const spineItem = packageDoc.spine.find((s) => s.idref === itemId);
|
|
2441
|
+
if (!spineItem) return false;
|
|
2442
|
+
if (spineItem.properties?.includes("rendition:layout-pre-paginated")) return true;
|
|
2443
|
+
if (spineItem.properties?.includes("rendition:layout-reflowable")) return false;
|
|
2444
|
+
const globalLayout = packageDoc.metaElements.find(
|
|
2445
|
+
(m) => m.property === "rendition:layout" && !m.refines
|
|
2446
|
+
);
|
|
2447
|
+
return globalLayout?.value === "pre-paginated";
|
|
2448
|
+
}
|
|
2080
2449
|
var ContentValidator = class {
|
|
2081
2450
|
cssWithRemoteResources = /* @__PURE__ */ new Set();
|
|
2082
2451
|
validate(context, registry, refValidator) {
|
|
@@ -2094,6 +2463,11 @@ var ContentValidator = class {
|
|
|
2094
2463
|
}
|
|
2095
2464
|
}
|
|
2096
2465
|
}
|
|
2466
|
+
const overlayDocMap = /* @__PURE__ */ new Map();
|
|
2467
|
+
const manifestByPath = /* @__PURE__ */ new Map();
|
|
2468
|
+
for (const item of packageDoc.manifest) {
|
|
2469
|
+
manifestByPath.set(resolveManifestHref(opfDir, item.href), item);
|
|
2470
|
+
}
|
|
2097
2471
|
for (const item of packageDoc.manifest) {
|
|
2098
2472
|
if (item.mediaType === "application/xhtml+xml") {
|
|
2099
2473
|
const fullPath = resolveManifestHref(opfDir, item.href);
|
|
@@ -2109,9 +2483,96 @@ var ContentValidator = class {
|
|
|
2109
2483
|
if (refValidator) {
|
|
2110
2484
|
this.extractSVGReferences(context, fullPath, opfDir, refValidator);
|
|
2111
2485
|
}
|
|
2486
|
+
} else if (item.mediaType === "application/smil+xml") {
|
|
2487
|
+
const fullPath = resolveManifestHref(opfDir, item.href);
|
|
2488
|
+
const smilValidator = new SMILValidator();
|
|
2489
|
+
const result = smilValidator.validate(context, fullPath, manifestByPath);
|
|
2490
|
+
overlayDocMap.set(item.id, result.referencedDocuments);
|
|
2491
|
+
if (refValidator) {
|
|
2492
|
+
for (const textRef of result.textReferences) {
|
|
2493
|
+
const refUrl = textRef.fragment ? `${textRef.docPath}#${textRef.fragment}` : textRef.docPath;
|
|
2494
|
+
const location = textRef.line != null ? { path: fullPath, line: textRef.line } : { path: fullPath };
|
|
2495
|
+
const ref = {
|
|
2496
|
+
url: refUrl,
|
|
2497
|
+
targetResource: textRef.docPath,
|
|
2498
|
+
type: "overlay-text-link" /* OVERLAY_TEXT_LINK */,
|
|
2499
|
+
location
|
|
2500
|
+
};
|
|
2501
|
+
if (textRef.fragment !== void 0) ref.fragment = textRef.fragment;
|
|
2502
|
+
refValidator.addReference(ref);
|
|
2503
|
+
context.overlayTextLinks ??= [];
|
|
2504
|
+
const link = {
|
|
2505
|
+
targetResource: textRef.docPath,
|
|
2506
|
+
location
|
|
2507
|
+
};
|
|
2508
|
+
if (textRef.fragment !== void 0) link.fragment = textRef.fragment;
|
|
2509
|
+
context.overlayTextLinks.push(link);
|
|
2510
|
+
}
|
|
2511
|
+
}
|
|
2512
|
+
if (result.hasRemoteResources) {
|
|
2513
|
+
const properties = item.properties ?? [];
|
|
2514
|
+
if (!properties.includes("remote-resources")) {
|
|
2515
|
+
pushMessage(context.messages, {
|
|
2516
|
+
id: MessageId.OPF_014,
|
|
2517
|
+
message: `The "remote-resources" property must be set on the media overlay item "${item.href}" because it references remote audio resources`,
|
|
2518
|
+
location: { path: context.opfPath ?? "" }
|
|
2519
|
+
});
|
|
2520
|
+
}
|
|
2521
|
+
}
|
|
2112
2522
|
}
|
|
2113
2523
|
this.validateMediaFile(context, item, opfDir);
|
|
2114
2524
|
}
|
|
2525
|
+
this.validateMediaOverlayCrossRefs(context, packageDoc, opfDir, overlayDocMap);
|
|
2526
|
+
}
|
|
2527
|
+
validateMediaOverlayCrossRefs(context, packageDoc, opfDir, overlayDocMap) {
|
|
2528
|
+
if (overlayDocMap.size === 0) return;
|
|
2529
|
+
const docToOverlays = /* @__PURE__ */ new Map();
|
|
2530
|
+
for (const [overlayId, docPaths] of overlayDocMap) {
|
|
2531
|
+
for (const docPath of docPaths) {
|
|
2532
|
+
const existing = docToOverlays.get(docPath) ?? [];
|
|
2533
|
+
existing.push(overlayId);
|
|
2534
|
+
docToOverlays.set(docPath, existing);
|
|
2535
|
+
}
|
|
2536
|
+
}
|
|
2537
|
+
const opfPath = context.opfPath ?? "";
|
|
2538
|
+
for (const item of packageDoc.manifest) {
|
|
2539
|
+
if (item.mediaType !== "application/xhtml+xml" && item.mediaType !== "image/svg+xml") {
|
|
2540
|
+
continue;
|
|
2541
|
+
}
|
|
2542
|
+
const fullPath = resolveManifestHref(opfDir, item.href);
|
|
2543
|
+
const referencingOverlays = docToOverlays.get(fullPath);
|
|
2544
|
+
if (referencingOverlays && referencingOverlays.length > 0) {
|
|
2545
|
+
if (referencingOverlays.length > 1) {
|
|
2546
|
+
pushMessage(context.messages, {
|
|
2547
|
+
id: MessageId.MED_011,
|
|
2548
|
+
message: `EPUB Content Document "${item.href}" referenced from multiple Media Overlay Documents`,
|
|
2549
|
+
location: { path: opfPath }
|
|
2550
|
+
});
|
|
2551
|
+
}
|
|
2552
|
+
if (!item.mediaOverlay) {
|
|
2553
|
+
pushMessage(context.messages, {
|
|
2554
|
+
id: MessageId.MED_010,
|
|
2555
|
+
message: `EPUB Content Document "${item.href}" referenced from a Media Overlay must specify the "media-overlay" attribute`,
|
|
2556
|
+
location: { path: opfPath }
|
|
2557
|
+
});
|
|
2558
|
+
} else if (!referencingOverlays.includes(item.mediaOverlay)) {
|
|
2559
|
+
pushMessage(context.messages, {
|
|
2560
|
+
id: MessageId.MED_012,
|
|
2561
|
+
message: `The "media-overlay" attribute does not match the ID of the Media Overlay that refers to this document`,
|
|
2562
|
+
location: { path: opfPath }
|
|
2563
|
+
});
|
|
2564
|
+
}
|
|
2565
|
+
} else if (item.mediaOverlay) {
|
|
2566
|
+
const overlayDocs = overlayDocMap.get(item.mediaOverlay);
|
|
2567
|
+
if (overlayDocs && !overlayDocs.has(fullPath)) {
|
|
2568
|
+
pushMessage(context.messages, {
|
|
2569
|
+
id: MessageId.MED_013,
|
|
2570
|
+
message: `Media Overlay Document referenced from the "media-overlay" attribute does not contain a reference to this Content Document`,
|
|
2571
|
+
location: { path: opfPath }
|
|
2572
|
+
});
|
|
2573
|
+
}
|
|
2574
|
+
}
|
|
2575
|
+
}
|
|
2115
2576
|
}
|
|
2116
2577
|
validateMediaFile(context, item, opfDir) {
|
|
2117
2578
|
const declaredType = item.mediaType;
|
|
@@ -2197,6 +2658,19 @@ var ContentValidator = class {
|
|
|
2197
2658
|
this.checkSVGInvalidIDs(context, path, root);
|
|
2198
2659
|
this.validateSvgEpubType(context, path, root);
|
|
2199
2660
|
this.checkUnknownEpubAttributes(context, path, root);
|
|
2661
|
+
this.checkSVGLinkAccessibility(context, path, root);
|
|
2662
|
+
const packageDoc = context.packageDocument;
|
|
2663
|
+
if (packageDoc && isItemFixedLayout(packageDoc, manifestItem.id)) {
|
|
2664
|
+
const viewBox = this.getAttribute(root, "viewBox");
|
|
2665
|
+
if (!viewBox) {
|
|
2666
|
+
pushMessage(context.messages, {
|
|
2667
|
+
id: MessageId.HTM_048,
|
|
2668
|
+
message: "SVG Fixed-Layout Documents must have a viewBox attribute on the outermost svg element",
|
|
2669
|
+
location: { path }
|
|
2670
|
+
});
|
|
2671
|
+
}
|
|
2672
|
+
}
|
|
2673
|
+
this.checkMediaOverlayActiveClassCSS(context, path, root, manifestItem, svgContent);
|
|
2200
2674
|
} finally {
|
|
2201
2675
|
doc.dispose();
|
|
2202
2676
|
}
|
|
@@ -2382,7 +2856,26 @@ var ContentValidator = class {
|
|
|
2382
2856
|
if (!cssData) {
|
|
2383
2857
|
return;
|
|
2384
2858
|
}
|
|
2385
|
-
|
|
2859
|
+
let cssContent;
|
|
2860
|
+
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;
|
|
2861
|
+
if (utf16Encoding) {
|
|
2862
|
+
pushMessage(context.messages, {
|
|
2863
|
+
id: MessageId.CSS_003,
|
|
2864
|
+
message: "CSS documents should be encoded in UTF-8, but UTF-16 was detected",
|
|
2865
|
+
location: { path }
|
|
2866
|
+
});
|
|
2867
|
+
cssContent = new TextDecoder(utf16Encoding).decode(cssData);
|
|
2868
|
+
} else {
|
|
2869
|
+
cssContent = new TextDecoder().decode(cssData);
|
|
2870
|
+
const charsetMatch = CSS_CHARSET_RE.exec(cssContent);
|
|
2871
|
+
if (charsetMatch?.[1] && charsetMatch[1].toLowerCase() !== "utf-8") {
|
|
2872
|
+
pushMessage(context.messages, {
|
|
2873
|
+
id: MessageId.CSS_004,
|
|
2874
|
+
message: `CSS documents must be encoded in UTF-8, but detected "${charsetMatch[1]}"`,
|
|
2875
|
+
location: { path }
|
|
2876
|
+
});
|
|
2877
|
+
}
|
|
2878
|
+
}
|
|
2386
2879
|
const cssValidator = new CSSValidator();
|
|
2387
2880
|
const result = cssValidator.validate(context, cssContent, path);
|
|
2388
2881
|
const hasRemoteResources = result.references.some(
|
|
@@ -2475,11 +2968,28 @@ var ContentValidator = class {
|
|
|
2475
2968
|
if (!data) {
|
|
2476
2969
|
return;
|
|
2477
2970
|
}
|
|
2478
|
-
|
|
2971
|
+
if (data.length >= 2 && (data[0] === 254 && data[1] === 255 || data[0] === 255 && data[1] === 254)) {
|
|
2972
|
+
pushMessage(context.messages, {
|
|
2973
|
+
id: MessageId.HTM_058,
|
|
2974
|
+
message: "HTML documents must be encoded in UTF-8, but UTF-16 was detected",
|
|
2975
|
+
location: { path }
|
|
2976
|
+
});
|
|
2977
|
+
return;
|
|
2978
|
+
}
|
|
2979
|
+
let content = new TextDecoder().decode(data);
|
|
2479
2980
|
const packageDoc = context.packageDocument;
|
|
2480
2981
|
if (!packageDoc) {
|
|
2481
2982
|
return;
|
|
2482
2983
|
}
|
|
2984
|
+
const epubNsMatch = EPUB_XMLNS_RE.exec(content);
|
|
2985
|
+
if (epubNsMatch?.[1] && epubNsMatch[1] !== "http://www.idpf.org/2007/ops") {
|
|
2986
|
+
pushMessage(context.messages, {
|
|
2987
|
+
id: MessageId.HTM_010,
|
|
2988
|
+
message: `Namespace URI "${epubNsMatch[1]}" is unusual for the "epub" prefix`,
|
|
2989
|
+
location: { path }
|
|
2990
|
+
});
|
|
2991
|
+
content = content.replace(epubNsMatch[0], 'xmlns:epub="http://www.idpf.org/2007/ops"');
|
|
2992
|
+
}
|
|
2483
2993
|
this.checkUnescapedAmpersands(context, path, content);
|
|
2484
2994
|
if (context.version !== "2.0") {
|
|
2485
2995
|
const doctypeMatch = /<!DOCTYPE\s+html\b([\s\S]*?)>/i.exec(content);
|
|
@@ -2685,6 +3195,7 @@ var ContentValidator = class {
|
|
|
2685
3195
|
this.checkHttpEquivCharset(context, path, root);
|
|
2686
3196
|
this.checkLangMismatch(context, path, root);
|
|
2687
3197
|
this.checkDpubAriaDeprecated(context, path, root);
|
|
3198
|
+
this.validateIdRefs(context, path, root);
|
|
2688
3199
|
this.checkTableBorder(context, path, root);
|
|
2689
3200
|
this.checkTimeElement(context, path, root);
|
|
2690
3201
|
this.checkMathMLAnnotations(context, path, root);
|
|
@@ -2692,6 +3203,7 @@ var ContentValidator = class {
|
|
|
2692
3203
|
this.checkDataAttributes(context, path, root);
|
|
2693
3204
|
this.checkAccessibility(context, path, root);
|
|
2694
3205
|
this.validateImages(context, path, root);
|
|
3206
|
+
this.checkUsemapAttribute(context, path, root);
|
|
2695
3207
|
if (context.version.startsWith("3")) {
|
|
2696
3208
|
this.validateEpubTypes(context, path, root);
|
|
2697
3209
|
}
|
|
@@ -2704,8 +3216,24 @@ var ContentValidator = class {
|
|
|
2704
3216
|
this.extractAndRegisterIDs(path, root, registry);
|
|
2705
3217
|
}
|
|
2706
3218
|
if (refValidator && opfDir !== void 0) {
|
|
2707
|
-
this.
|
|
2708
|
-
this.
|
|
3219
|
+
const remoteXmlBase = this.getRemoteXmlBase(root);
|
|
3220
|
+
this.extractAndRegisterHyperlinks(
|
|
3221
|
+
context,
|
|
3222
|
+
path,
|
|
3223
|
+
root,
|
|
3224
|
+
opfDir,
|
|
3225
|
+
refValidator,
|
|
3226
|
+
!!isNavItem,
|
|
3227
|
+
remoteXmlBase
|
|
3228
|
+
);
|
|
3229
|
+
this.extractAndRegisterStylesheets(
|
|
3230
|
+
context,
|
|
3231
|
+
path,
|
|
3232
|
+
root,
|
|
3233
|
+
opfDir,
|
|
3234
|
+
refValidator,
|
|
3235
|
+
remoteXmlBase
|
|
3236
|
+
);
|
|
2709
3237
|
this.extractAndRegisterImages(context, path, root, opfDir, refValidator, registry);
|
|
2710
3238
|
this.extractAndRegisterMathMLAltimg(path, root, opfDir, refValidator);
|
|
2711
3239
|
this.extractAndRegisterScripts(path, root, opfDir, refValidator);
|
|
@@ -2720,10 +3248,65 @@ var ContentValidator = class {
|
|
|
2720
3248
|
registry
|
|
2721
3249
|
);
|
|
2722
3250
|
}
|
|
3251
|
+
this.checkMediaOverlayActiveClassCSS(context, path, root, manifestItem);
|
|
2723
3252
|
} finally {
|
|
2724
3253
|
doc.dispose();
|
|
2725
3254
|
}
|
|
2726
3255
|
}
|
|
3256
|
+
/**
|
|
3257
|
+
* CSS-030: If media:active-class or media:playback-active-class is declared in OPF,
|
|
3258
|
+
* and this content document has a media-overlay, it must have at least some CSS.
|
|
3259
|
+
*/
|
|
3260
|
+
checkMediaOverlayActiveClassCSS(context, path, root, manifestItem, decodedContent) {
|
|
3261
|
+
if (!manifestItem?.mediaOverlay) return;
|
|
3262
|
+
if (!context.mediaActiveClass && !context.mediaPlaybackActiveClass) return;
|
|
3263
|
+
const isSVG = root.name === "svg" || root.name.endsWith(":svg");
|
|
3264
|
+
let hasCSS = false;
|
|
3265
|
+
if (isSVG) {
|
|
3266
|
+
try {
|
|
3267
|
+
const styles = root.find(".//svg:style", { svg: "http://www.w3.org/2000/svg" });
|
|
3268
|
+
if (styles.length > 0) hasCSS = true;
|
|
3269
|
+
} catch {
|
|
3270
|
+
}
|
|
3271
|
+
if (!hasCSS) {
|
|
3272
|
+
try {
|
|
3273
|
+
const links = root.find(".//html:link", XHTML_NS);
|
|
3274
|
+
if (links.length > 0) hasCSS = true;
|
|
3275
|
+
} catch {
|
|
3276
|
+
}
|
|
3277
|
+
}
|
|
3278
|
+
if (!hasCSS) {
|
|
3279
|
+
const content = decodedContent ?? new TextDecoder().decode(context.files.get(path));
|
|
3280
|
+
if (content.includes("<?xml-stylesheet")) hasCSS = true;
|
|
3281
|
+
}
|
|
3282
|
+
} else {
|
|
3283
|
+
try {
|
|
3284
|
+
const links = root.find(".//html:link[@rel]", XHTML_NS);
|
|
3285
|
+
for (const link of links) {
|
|
3286
|
+
const rel = this.getAttribute(link, "rel");
|
|
3287
|
+
if (rel?.toLowerCase().includes("stylesheet")) {
|
|
3288
|
+
hasCSS = true;
|
|
3289
|
+
break;
|
|
3290
|
+
}
|
|
3291
|
+
}
|
|
3292
|
+
} catch {
|
|
3293
|
+
}
|
|
3294
|
+
if (!hasCSS) {
|
|
3295
|
+
try {
|
|
3296
|
+
const styles = root.find(".//html:style", XHTML_NS);
|
|
3297
|
+
if (styles.length > 0) hasCSS = true;
|
|
3298
|
+
} catch {
|
|
3299
|
+
}
|
|
3300
|
+
}
|
|
3301
|
+
}
|
|
3302
|
+
if (!hasCSS) {
|
|
3303
|
+
pushMessage(context.messages, {
|
|
3304
|
+
id: MessageId.CSS_030,
|
|
3305
|
+
message: 'The "media:active-class" property is declared in the package document but no CSS was found in this content document',
|
|
3306
|
+
location: { path }
|
|
3307
|
+
});
|
|
3308
|
+
}
|
|
3309
|
+
}
|
|
2727
3310
|
parseLibxmlError(error) {
|
|
2728
3311
|
const lineRegex = /(?:Entity:\s*)?line\s+(\d+):/;
|
|
2729
3312
|
const lineMatch = lineRegex.exec(error);
|
|
@@ -3464,6 +4047,92 @@ var ContentValidator = class {
|
|
|
3464
4047
|
} catch {
|
|
3465
4048
|
}
|
|
3466
4049
|
}
|
|
4050
|
+
collectIds(root) {
|
|
4051
|
+
const ids = /* @__PURE__ */ new Set();
|
|
4052
|
+
try {
|
|
4053
|
+
for (const el of root.find(".//*[@id]")) {
|
|
4054
|
+
const id = this.getAttribute(el, "id");
|
|
4055
|
+
if (id) ids.add(id);
|
|
4056
|
+
}
|
|
4057
|
+
} catch {
|
|
4058
|
+
}
|
|
4059
|
+
return ids;
|
|
4060
|
+
}
|
|
4061
|
+
validateIdRefs(context, path, root) {
|
|
4062
|
+
try {
|
|
4063
|
+
const allIds = this.collectIds(root);
|
|
4064
|
+
const HTML_NS = { html: "http://www.w3.org/1999/xhtml" };
|
|
4065
|
+
const idrefsChecks = [
|
|
4066
|
+
{ xpath: ".//*[@aria-describedby]", attr: "aria-describedby" },
|
|
4067
|
+
{ xpath: ".//*[@aria-flowto]", attr: "aria-flowto" },
|
|
4068
|
+
{ xpath: ".//*[@aria-labelledby]", attr: "aria-labelledby" },
|
|
4069
|
+
{ xpath: ".//*[@aria-owns]", attr: "aria-owns" },
|
|
4070
|
+
{ xpath: ".//*[@aria-controls]", attr: "aria-controls" },
|
|
4071
|
+
{ xpath: ".//html:output[@for]", attr: "for", ns: HTML_NS },
|
|
4072
|
+
{
|
|
4073
|
+
xpath: ".//html:td[@headers] | .//html:th[@headers]",
|
|
4074
|
+
attr: "headers",
|
|
4075
|
+
ns: HTML_NS
|
|
4076
|
+
}
|
|
4077
|
+
];
|
|
4078
|
+
for (const { xpath, attr, ns } of idrefsChecks) {
|
|
4079
|
+
const elements = ns ? root.find(xpath, ns) : root.find(xpath);
|
|
4080
|
+
for (const elem of elements) {
|
|
4081
|
+
const value = this.getAttribute(elem, attr);
|
|
4082
|
+
if (!value) continue;
|
|
4083
|
+
const idrefs = value.trim().split(/\s+/);
|
|
4084
|
+
if (idrefs.some((idref) => !allIds.has(idref))) {
|
|
4085
|
+
pushMessage(context.messages, {
|
|
4086
|
+
id: MessageId.RSC_005,
|
|
4087
|
+
message: `The ${attr} attribute must refer to elements in the same document (target ID missing)`,
|
|
4088
|
+
location: { path, line: elem.line }
|
|
4089
|
+
});
|
|
4090
|
+
}
|
|
4091
|
+
}
|
|
4092
|
+
}
|
|
4093
|
+
const activedescMsg = "The aria-activedescendant attribute must refer to a descendant element.";
|
|
4094
|
+
for (const elem of root.find(".//*[@aria-activedescendant]")) {
|
|
4095
|
+
const idref = this.getAttribute(elem, "aria-activedescendant");
|
|
4096
|
+
if (!idref) continue;
|
|
4097
|
+
if (!allIds.has(idref)) {
|
|
4098
|
+
pushMessage(context.messages, {
|
|
4099
|
+
id: MessageId.RSC_005,
|
|
4100
|
+
message: activedescMsg,
|
|
4101
|
+
location: { path, line: elem.line }
|
|
4102
|
+
});
|
|
4103
|
+
} else {
|
|
4104
|
+
try {
|
|
4105
|
+
if (elem.find(`.//*[@id="${idref}"]`).length === 0) {
|
|
4106
|
+
pushMessage(context.messages, {
|
|
4107
|
+
id: MessageId.RSC_005,
|
|
4108
|
+
message: activedescMsg,
|
|
4109
|
+
location: { path, line: elem.line }
|
|
4110
|
+
});
|
|
4111
|
+
}
|
|
4112
|
+
} catch {
|
|
4113
|
+
}
|
|
4114
|
+
}
|
|
4115
|
+
}
|
|
4116
|
+
for (const elem of root.find(".//*[@aria-describedat]")) {
|
|
4117
|
+
pushMessage(context.messages, {
|
|
4118
|
+
id: MessageId.RSC_005,
|
|
4119
|
+
message: 'attribute "aria-describedat" not allowed here',
|
|
4120
|
+
location: { path, line: elem.line }
|
|
4121
|
+
});
|
|
4122
|
+
}
|
|
4123
|
+
for (const elem of root.find(".//html:label[@for]", HTML_NS)) {
|
|
4124
|
+
const idref = this.getAttribute(elem, "for");
|
|
4125
|
+
if (idref && !allIds.has(idref)) {
|
|
4126
|
+
pushMessage(context.messages, {
|
|
4127
|
+
id: MessageId.RSC_005,
|
|
4128
|
+
message: `The for attribute must refer to an element in the same document (the ID "${idref}" does not exist).`,
|
|
4129
|
+
location: { path, line: elem.line }
|
|
4130
|
+
});
|
|
4131
|
+
}
|
|
4132
|
+
}
|
|
4133
|
+
} catch {
|
|
4134
|
+
}
|
|
4135
|
+
}
|
|
3467
4136
|
validateEpubSwitch(context, path, root) {
|
|
3468
4137
|
const EPUB_NS = { epub: "http://www.idpf.org/2007/ops" };
|
|
3469
4138
|
try {
|
|
@@ -3552,15 +4221,7 @@ var ContentValidator = class {
|
|
|
3552
4221
|
try {
|
|
3553
4222
|
const triggers = root.find(".//epub:trigger", EPUB_NS);
|
|
3554
4223
|
if (triggers.length === 0) return;
|
|
3555
|
-
const allIds =
|
|
3556
|
-
try {
|
|
3557
|
-
const idElements = root.find(".//*[@id]");
|
|
3558
|
-
for (const el of idElements) {
|
|
3559
|
-
const idAttr = this.getAttribute(el, "id");
|
|
3560
|
-
if (idAttr) allIds.add(idAttr);
|
|
3561
|
-
}
|
|
3562
|
-
} catch {
|
|
3563
|
-
}
|
|
4224
|
+
const allIds = this.collectIds(root);
|
|
3564
4225
|
for (const trigger of triggers) {
|
|
3565
4226
|
pushMessage(context.messages, {
|
|
3566
4227
|
id: MessageId.RSC_017,
|
|
@@ -3691,6 +4352,22 @@ var ContentValidator = class {
|
|
|
3691
4352
|
} catch {
|
|
3692
4353
|
}
|
|
3693
4354
|
}
|
|
4355
|
+
checkUsemapAttribute(context, path, root) {
|
|
4356
|
+
try {
|
|
4357
|
+
const elements = root.find(".//html:*[@usemap]", XHTML_NS);
|
|
4358
|
+
for (const elem of elements) {
|
|
4359
|
+
const usemap = this.getAttribute(elem, "usemap");
|
|
4360
|
+
if (usemap !== null && !/^#.+$/.test(usemap)) {
|
|
4361
|
+
pushMessage(context.messages, {
|
|
4362
|
+
id: MessageId.RSC_005,
|
|
4363
|
+
message: `value of attribute "usemap" is invalid; must be a string matching the regular expression "#.+"`,
|
|
4364
|
+
location: { path, line: elem.line }
|
|
4365
|
+
});
|
|
4366
|
+
}
|
|
4367
|
+
}
|
|
4368
|
+
} catch {
|
|
4369
|
+
}
|
|
4370
|
+
}
|
|
3694
4371
|
checkTimeElement(context, path, root) {
|
|
3695
4372
|
const HTML_NS = { html: "http://www.w3.org/1999/xhtml" };
|
|
3696
4373
|
try {
|
|
@@ -3900,22 +4577,7 @@ var ContentValidator = class {
|
|
|
3900
4577
|
});
|
|
3901
4578
|
}
|
|
3902
4579
|
}
|
|
3903
|
-
|
|
3904
|
-
svg: "http://www.w3.org/2000/svg",
|
|
3905
|
-
xlink: "http://www.w3.org/1999/xlink"
|
|
3906
|
-
});
|
|
3907
|
-
for (const svgLink of svgLinks) {
|
|
3908
|
-
const svgElem = svgLink;
|
|
3909
|
-
const title = svgElem.get("./svg:title", { svg: "http://www.w3.org/2000/svg" });
|
|
3910
|
-
const ariaLabel = this.getAttribute(svgElem, "aria-label");
|
|
3911
|
-
if (!title && !ariaLabel) {
|
|
3912
|
-
pushMessage(context.messages, {
|
|
3913
|
-
id: MessageId.ACC_011,
|
|
3914
|
-
message: "SVG hyperlink has no accessible name (missing title element or aria-label)",
|
|
3915
|
-
location: { path }
|
|
3916
|
-
});
|
|
3917
|
-
}
|
|
3918
|
-
}
|
|
4580
|
+
this.checkSVGLinkAccessibility(context, path, root);
|
|
3919
4581
|
const mathElements = root.find(".//math:math", { math: "http://www.w3.org/1998/Math/MathML" });
|
|
3920
4582
|
for (const mathElem of mathElements) {
|
|
3921
4583
|
const elem = mathElem;
|
|
@@ -3933,6 +4595,29 @@ var ContentValidator = class {
|
|
|
3933
4595
|
}
|
|
3934
4596
|
}
|
|
3935
4597
|
}
|
|
4598
|
+
hasSVGLinkAccessibleName(svgElem) {
|
|
4599
|
+
const ns = { svg: "http://www.w3.org/2000/svg" };
|
|
4600
|
+
if (svgElem.get(".//svg:title", ns)) return true;
|
|
4601
|
+
if (svgElem.get(".//svg:text", ns)) return true;
|
|
4602
|
+
if (this.getAttribute(svgElem, "aria-label")) return true;
|
|
4603
|
+
if (this.getAttribute(svgElem, "xlink:title")) return true;
|
|
4604
|
+
return false;
|
|
4605
|
+
}
|
|
4606
|
+
checkSVGLinkAccessibility(context, path, root) {
|
|
4607
|
+
const svgLinks = root.find(".//svg:a", {
|
|
4608
|
+
svg: "http://www.w3.org/2000/svg",
|
|
4609
|
+
xlink: "http://www.w3.org/1999/xlink"
|
|
4610
|
+
});
|
|
4611
|
+
for (const svgLink of svgLinks) {
|
|
4612
|
+
if (!this.hasSVGLinkAccessibleName(svgLink)) {
|
|
4613
|
+
pushMessage(context.messages, {
|
|
4614
|
+
id: MessageId.ACC_011,
|
|
4615
|
+
message: "SVG hyperlink has no accessible name (missing title element or aria-label)",
|
|
4616
|
+
location: { path }
|
|
4617
|
+
});
|
|
4618
|
+
}
|
|
4619
|
+
}
|
|
4620
|
+
}
|
|
3936
4621
|
validateImages(context, path, root) {
|
|
3937
4622
|
const packageDoc = context.packageDocument;
|
|
3938
4623
|
if (!packageDoc) return;
|
|
@@ -3994,6 +4679,14 @@ var ContentValidator = class {
|
|
|
3994
4679
|
const elemTyped = elem;
|
|
3995
4680
|
const epubTypeAttr = elemTyped.attr("type", "epub");
|
|
3996
4681
|
if (!epubTypeAttr?.value) continue;
|
|
4682
|
+
if (EPUB_TYPE_FORBIDDEN_ELEMENTS.has(elemTyped.name)) {
|
|
4683
|
+
pushMessage(context.messages, {
|
|
4684
|
+
id: MessageId.RSC_005,
|
|
4685
|
+
message: `attribute "epub:type" not allowed here`,
|
|
4686
|
+
location: { path, line: elem.line }
|
|
4687
|
+
});
|
|
4688
|
+
continue;
|
|
4689
|
+
}
|
|
3997
4690
|
for (const part of epubTypeAttr.value.split(/\s+/)) {
|
|
3998
4691
|
if (!part) continue;
|
|
3999
4692
|
const hasPrefix = part.includes(":");
|
|
@@ -4089,41 +4782,131 @@ var ContentValidator = class {
|
|
|
4089
4782
|
const attr = attrs.find((a) => a.name === name);
|
|
4090
4783
|
return attr?.value ?? null;
|
|
4091
4784
|
}
|
|
4785
|
+
/**
|
|
4786
|
+
* Get remote xml:base URL from the document root element.
|
|
4787
|
+
* Returns the URL if it's remote (http/https), or null otherwise.
|
|
4788
|
+
*/
|
|
4789
|
+
getRemoteXmlBase(root) {
|
|
4790
|
+
const xmlBase = root.attr("base", "xml")?.value ?? null;
|
|
4791
|
+
if (xmlBase?.startsWith("http://") || xmlBase?.startsWith("https://")) {
|
|
4792
|
+
return xmlBase;
|
|
4793
|
+
}
|
|
4794
|
+
return null;
|
|
4795
|
+
}
|
|
4092
4796
|
validateViewportMeta(context, path, root, manifestItem) {
|
|
4093
|
-
const
|
|
4094
|
-
const
|
|
4095
|
-
|
|
4096
|
-
|
|
4797
|
+
const packageDoc = context.packageDocument;
|
|
4798
|
+
const isFixedLayout = manifestItem && packageDoc ? isItemFixedLayout(packageDoc, manifestItem.id) : false;
|
|
4799
|
+
const headMetas = root.find(".//html:head/html:meta[@name]", {
|
|
4800
|
+
html: "http://www.w3.org/1999/xhtml"
|
|
4801
|
+
});
|
|
4802
|
+
let viewportCount = 0;
|
|
4803
|
+
for (const meta of headMetas) {
|
|
4097
4804
|
const nameAttr = this.getAttribute(meta, "name");
|
|
4098
|
-
if (nameAttr
|
|
4099
|
-
|
|
4100
|
-
|
|
4101
|
-
|
|
4102
|
-
|
|
4103
|
-
|
|
4104
|
-
|
|
4105
|
-
|
|
4106
|
-
|
|
4107
|
-
|
|
4108
|
-
|
|
4109
|
-
|
|
4110
|
-
|
|
4111
|
-
|
|
4112
|
-
|
|
4113
|
-
|
|
4114
|
-
|
|
4115
|
-
|
|
4116
|
-
|
|
4805
|
+
if (nameAttr !== "viewport") continue;
|
|
4806
|
+
viewportCount++;
|
|
4807
|
+
const contentAttr = this.getAttribute(meta, "content");
|
|
4808
|
+
if (!isFixedLayout) {
|
|
4809
|
+
pushMessage(context.messages, {
|
|
4810
|
+
id: MessageId.HTM_060b,
|
|
4811
|
+
message: `EPUB reading systems must ignore viewport meta elements in reflowable documents; viewport declaration "${contentAttr ?? ""}" will be ignored`,
|
|
4812
|
+
location: { path, line: meta.line }
|
|
4813
|
+
});
|
|
4814
|
+
continue;
|
|
4815
|
+
}
|
|
4816
|
+
if (viewportCount > 1) {
|
|
4817
|
+
pushMessage(context.messages, {
|
|
4818
|
+
id: MessageId.HTM_060a,
|
|
4819
|
+
message: `EPUB reading systems must ignore secondary viewport meta elements in fixed-layout documents; viewport declaration "${contentAttr ?? ""}" will be ignored`,
|
|
4820
|
+
location: { path, line: meta.line }
|
|
4821
|
+
});
|
|
4822
|
+
continue;
|
|
4823
|
+
}
|
|
4824
|
+
if (!contentAttr?.trim()) {
|
|
4825
|
+
pushMessage(context.messages, {
|
|
4826
|
+
id: MessageId.HTM_047,
|
|
4827
|
+
message: `Viewport metadata "${contentAttr ?? ""}" has a syntax error`,
|
|
4828
|
+
location: { path, line: meta.line }
|
|
4829
|
+
});
|
|
4830
|
+
continue;
|
|
4117
4831
|
}
|
|
4832
|
+
this.parseViewportContent(context, path, contentAttr, meta.line);
|
|
4118
4833
|
}
|
|
4119
|
-
if (isFixedLayout &&
|
|
4834
|
+
if (isFixedLayout && viewportCount === 0) {
|
|
4120
4835
|
pushMessage(context.messages, {
|
|
4121
|
-
id: MessageId.
|
|
4122
|
-
message: "Fixed
|
|
4836
|
+
id: MessageId.HTM_046,
|
|
4837
|
+
message: "Fixed layout document has no viewport meta element",
|
|
4123
4838
|
location: { path }
|
|
4124
4839
|
});
|
|
4125
4840
|
}
|
|
4126
4841
|
}
|
|
4842
|
+
parseViewportContent(context, path, content, line) {
|
|
4843
|
+
const location = line != null ? { path, line } : { path };
|
|
4844
|
+
const parts = content.split(/[,;]/);
|
|
4845
|
+
const seenKeys = /* @__PURE__ */ new Set();
|
|
4846
|
+
let hasWidth = false;
|
|
4847
|
+
let hasHeight = false;
|
|
4848
|
+
let hasSyntaxError = false;
|
|
4849
|
+
for (const part of parts) {
|
|
4850
|
+
const trimmed = part.trim();
|
|
4851
|
+
if (!trimmed) continue;
|
|
4852
|
+
const eqIndex = trimmed.indexOf("=");
|
|
4853
|
+
let key;
|
|
4854
|
+
let value;
|
|
4855
|
+
if (eqIndex < 0) {
|
|
4856
|
+
key = trimmed;
|
|
4857
|
+
value = "";
|
|
4858
|
+
} else {
|
|
4859
|
+
key = trimmed.substring(0, eqIndex).trim();
|
|
4860
|
+
const rawValue = trimmed.substring(eqIndex + 1);
|
|
4861
|
+
if (!rawValue.trim()) {
|
|
4862
|
+
pushMessage(context.messages, {
|
|
4863
|
+
id: MessageId.HTM_047,
|
|
4864
|
+
message: `Viewport metadata "${content}" has a syntax error`,
|
|
4865
|
+
location
|
|
4866
|
+
});
|
|
4867
|
+
hasSyntaxError = true;
|
|
4868
|
+
break;
|
|
4869
|
+
}
|
|
4870
|
+
value = rawValue.trim();
|
|
4871
|
+
}
|
|
4872
|
+
if (key === "width" || key === "height") {
|
|
4873
|
+
if (seenKeys.has(key)) {
|
|
4874
|
+
pushMessage(context.messages, {
|
|
4875
|
+
id: MessageId.HTM_059,
|
|
4876
|
+
message: `Viewport "${key}" property must not be defined more than once`,
|
|
4877
|
+
location
|
|
4878
|
+
});
|
|
4879
|
+
}
|
|
4880
|
+
seenKeys.add(key);
|
|
4881
|
+
if (key === "width") hasWidth = true;
|
|
4882
|
+
if (key === "height") hasHeight = true;
|
|
4883
|
+
const deviceKeyword = key === "width" ? "device-width" : "device-height";
|
|
4884
|
+
if (value === deviceKeyword) ; else if (value === "" || !/^[0-9]*\.?[0-9]+$/.test(value)) {
|
|
4885
|
+
pushMessage(context.messages, {
|
|
4886
|
+
id: MessageId.HTM_057,
|
|
4887
|
+
message: `Viewport "${key}" value must be a positive number or the keyword "${deviceKeyword}"`,
|
|
4888
|
+
location
|
|
4889
|
+
});
|
|
4890
|
+
}
|
|
4891
|
+
}
|
|
4892
|
+
}
|
|
4893
|
+
if (!hasSyntaxError) {
|
|
4894
|
+
if (!hasWidth) {
|
|
4895
|
+
pushMessage(context.messages, {
|
|
4896
|
+
id: MessageId.HTM_056,
|
|
4897
|
+
message: 'Viewport metadata has no "width" dimension (both "width" and "height" properties are required)',
|
|
4898
|
+
location
|
|
4899
|
+
});
|
|
4900
|
+
}
|
|
4901
|
+
if (!hasHeight) {
|
|
4902
|
+
pushMessage(context.messages, {
|
|
4903
|
+
id: MessageId.HTM_056,
|
|
4904
|
+
message: 'Viewport metadata has no "height" dimension (both "width" and "height" properties are required)',
|
|
4905
|
+
location
|
|
4906
|
+
});
|
|
4907
|
+
}
|
|
4908
|
+
}
|
|
4909
|
+
}
|
|
4127
4910
|
extractAndRegisterIDs(path, root, registry) {
|
|
4128
4911
|
const elementsWithId = root.find(".//*[@id]");
|
|
4129
4912
|
for (const elem of elementsWithId) {
|
|
@@ -4138,7 +4921,7 @@ var ContentValidator = class {
|
|
|
4138
4921
|
}
|
|
4139
4922
|
}
|
|
4140
4923
|
}
|
|
4141
|
-
extractAndRegisterHyperlinks(context, path, root, opfDir, refValidator, isNavDocument = false) {
|
|
4924
|
+
extractAndRegisterHyperlinks(context, path, root, opfDir, refValidator, isNavDocument = false, remoteXmlBase = null) {
|
|
4142
4925
|
const docDir = path.includes("/") ? path.substring(0, path.lastIndexOf("/")) : "";
|
|
4143
4926
|
const navAnchorTypes = /* @__PURE__ */ new Map();
|
|
4144
4927
|
if (isNavDocument) {
|
|
@@ -4174,7 +4957,17 @@ var ContentValidator = class {
|
|
|
4174
4957
|
}
|
|
4175
4958
|
const line = link.line;
|
|
4176
4959
|
const refType = isNavDocument ? navAnchorTypes.get(`${String(line)}:${href}`) ?? "hyperlink" /* HYPERLINK */ : "hyperlink" /* HYPERLINK */;
|
|
4960
|
+
if (href.startsWith("data:") || href.startsWith("file:")) {
|
|
4961
|
+
refValidator.addReference({
|
|
4962
|
+
url: href,
|
|
4963
|
+
targetResource: href,
|
|
4964
|
+
type: refType,
|
|
4965
|
+
location: { path, line }
|
|
4966
|
+
});
|
|
4967
|
+
continue;
|
|
4968
|
+
}
|
|
4177
4969
|
if (ABSOLUTE_URI_RE.test(href)) {
|
|
4970
|
+
validateAbsoluteHyperlinkURL(context, href, path, line);
|
|
4178
4971
|
continue;
|
|
4179
4972
|
}
|
|
4180
4973
|
if (href.includes("#epubcfi(")) {
|
|
@@ -4192,6 +4985,15 @@ var ContentValidator = class {
|
|
|
4192
4985
|
});
|
|
4193
4986
|
continue;
|
|
4194
4987
|
}
|
|
4988
|
+
if (remoteXmlBase && !ABSOLUTE_URI_RE.test(href)) {
|
|
4989
|
+
const resolvedUrl = new URL(href, remoteXmlBase).href;
|
|
4990
|
+
pushMessage(context.messages, {
|
|
4991
|
+
id: MessageId.RSC_006,
|
|
4992
|
+
message: `Remote resource reference is not allowed; resource "${resolvedUrl}" must be located in the EPUB container`,
|
|
4993
|
+
location: { path, line }
|
|
4994
|
+
});
|
|
4995
|
+
continue;
|
|
4996
|
+
}
|
|
4195
4997
|
const resolvedPath = this.resolveRelativePath(docDir, href, opfDir);
|
|
4196
4998
|
const hashIndex = resolvedPath.indexOf("#");
|
|
4197
4999
|
const targetResource = hashIndex >= 0 ? resolvedPath.slice(0, hashIndex) : resolvedPath;
|
|
@@ -4212,7 +5014,19 @@ var ContentValidator = class {
|
|
|
4212
5014
|
const href = this.getAttribute(area, "href")?.trim();
|
|
4213
5015
|
if (!href) continue;
|
|
4214
5016
|
const line = area.line;
|
|
4215
|
-
if (
|
|
5017
|
+
if (href.startsWith("data:") || href.startsWith("file:")) {
|
|
5018
|
+
refValidator.addReference({
|
|
5019
|
+
url: href,
|
|
5020
|
+
targetResource: href,
|
|
5021
|
+
type: "hyperlink" /* HYPERLINK */,
|
|
5022
|
+
location: { path, line }
|
|
5023
|
+
});
|
|
5024
|
+
continue;
|
|
5025
|
+
}
|
|
5026
|
+
if (ABSOLUTE_URI_RE.test(href)) {
|
|
5027
|
+
validateAbsoluteHyperlinkURL(context, href, path, line);
|
|
5028
|
+
continue;
|
|
5029
|
+
}
|
|
4216
5030
|
if (href.includes("#epubcfi(")) continue;
|
|
4217
5031
|
if (href.startsWith("#")) {
|
|
4218
5032
|
refValidator.addReference({
|
|
@@ -4248,7 +5062,17 @@ var ContentValidator = class {
|
|
|
4248
5062
|
const href = this.getAttribute(elem, "xlink:href") ?? this.getAttribute(elem, "href");
|
|
4249
5063
|
if (!href) continue;
|
|
4250
5064
|
const line = link.line;
|
|
4251
|
-
if (href.startsWith("
|
|
5065
|
+
if (href.startsWith("data:") || href.startsWith("file:")) {
|
|
5066
|
+
refValidator.addReference({
|
|
5067
|
+
url: href,
|
|
5068
|
+
targetResource: href,
|
|
5069
|
+
type: "hyperlink" /* HYPERLINK */,
|
|
5070
|
+
location: { path, line }
|
|
5071
|
+
});
|
|
5072
|
+
continue;
|
|
5073
|
+
}
|
|
5074
|
+
if (ABSOLUTE_URI_RE.test(href)) {
|
|
5075
|
+
validateAbsoluteHyperlinkURL(context, href, path, line);
|
|
4252
5076
|
continue;
|
|
4253
5077
|
}
|
|
4254
5078
|
if (href.startsWith("#")) {
|
|
@@ -4279,8 +5103,12 @@ var ContentValidator = class {
|
|
|
4279
5103
|
refValidator.addReference(svgRef);
|
|
4280
5104
|
}
|
|
4281
5105
|
}
|
|
4282
|
-
extractAndRegisterStylesheets(path, root, opfDir, refValidator) {
|
|
5106
|
+
extractAndRegisterStylesheets(context, path, root, opfDir, refValidator, remoteXmlBase = null) {
|
|
4283
5107
|
const docDir = path.includes("/") ? path.substring(0, path.lastIndexOf("/")) : "";
|
|
5108
|
+
const baseElem = root.get(".//html:base[@href]", { html: "http://www.w3.org/1999/xhtml" });
|
|
5109
|
+
const baseHref = baseElem ? this.getAttribute(baseElem, "href") : null;
|
|
5110
|
+
const effectiveBase = baseHref ?? remoteXmlBase;
|
|
5111
|
+
const remoteBaseUrl = effectiveBase?.startsWith("http://") || effectiveBase?.startsWith("https://") ? effectiveBase : null;
|
|
4284
5112
|
const linkElements = root.find(".//html:link[@href]", { html: "http://www.w3.org/1999/xhtml" });
|
|
4285
5113
|
for (const linkElem of linkElements) {
|
|
4286
5114
|
const href = this.getAttribute(linkElem, "href");
|
|
@@ -4298,6 +5126,15 @@ var ContentValidator = class {
|
|
|
4298
5126
|
});
|
|
4299
5127
|
continue;
|
|
4300
5128
|
}
|
|
5129
|
+
if (remoteBaseUrl && !ABSOLUTE_URI_RE.test(href)) {
|
|
5130
|
+
const resolvedUrl = new URL(href, remoteBaseUrl).href;
|
|
5131
|
+
pushMessage(context.messages, {
|
|
5132
|
+
id: MessageId.RSC_006,
|
|
5133
|
+
message: `Remote resource reference is not allowed; resource "${resolvedUrl}" must be located in the EPUB container`,
|
|
5134
|
+
location: { path, line }
|
|
5135
|
+
});
|
|
5136
|
+
continue;
|
|
5137
|
+
}
|
|
4301
5138
|
const resolvedPath = this.resolveRelativePath(docDir, href, opfDir);
|
|
4302
5139
|
const hashIndex = resolvedPath.indexOf("#");
|
|
4303
5140
|
const targetResource = hashIndex >= 0 ? resolvedPath.slice(0, hashIndex) : resolvedPath;
|
|
@@ -5355,6 +6192,8 @@ var OCFValidator = class {
|
|
|
5355
6192
|
this.validateUtf8Filenames(zip, context.messages);
|
|
5356
6193
|
this.validateEmptyDirectories(zip, context.messages);
|
|
5357
6194
|
this.parseEncryption(zip, context);
|
|
6195
|
+
this.validateEncryptionXml(context);
|
|
6196
|
+
this.validateSignaturesXml(context);
|
|
5358
6197
|
}
|
|
5359
6198
|
/**
|
|
5360
6199
|
* Validate the mimetype file
|
|
@@ -5695,14 +6534,109 @@ var OCFValidator = class {
|
|
|
5695
6534
|
const xml = block[0];
|
|
5696
6535
|
const algorithmMatch = /Algorithm=["']([^"']+)["']/.exec(xml);
|
|
5697
6536
|
const uriMatch = /<(?:\w+:)?CipherReference[^>]+URI=["']([^"']+)["']/.exec(xml);
|
|
5698
|
-
|
|
5699
|
-
|
|
6537
|
+
const algorithm = algorithmMatch?.[1];
|
|
6538
|
+
const uri = uriMatch?.[1];
|
|
6539
|
+
if (!uri) continue;
|
|
6540
|
+
if (algorithm === IDPF_OBFUSCATION) {
|
|
6541
|
+
obfuscated.add(uri);
|
|
5700
6542
|
}
|
|
6543
|
+
pushMessage(context.messages, {
|
|
6544
|
+
id: MessageId.RSC_004,
|
|
6545
|
+
message: `File "${uri}" is encrypted, its content will not be checked`,
|
|
6546
|
+
location: { path: encryptionPath }
|
|
6547
|
+
});
|
|
5701
6548
|
}
|
|
5702
6549
|
if (obfuscated.size > 0) {
|
|
5703
6550
|
context.obfuscatedResources = obfuscated;
|
|
5704
6551
|
}
|
|
5705
6552
|
}
|
|
6553
|
+
/**
|
|
6554
|
+
* Validate encryption.xml structure:
|
|
6555
|
+
* - Root element must be "encryption" in OCF namespace
|
|
6556
|
+
* - Compression Method must be "0" or "8"
|
|
6557
|
+
* - Compression OriginalLength must be a non-negative integer
|
|
6558
|
+
* - All Id attributes must be unique
|
|
6559
|
+
*/
|
|
6560
|
+
extractRootElementName(xml) {
|
|
6561
|
+
const match = /<(\w+)[\s>]/.exec(xml.replace(/<\?xml[^?]*\?>/, "").trimStart());
|
|
6562
|
+
return match?.[1] ?? null;
|
|
6563
|
+
}
|
|
6564
|
+
validateEncryptionXml(context) {
|
|
6565
|
+
const encPath = "META-INF/encryption.xml";
|
|
6566
|
+
const content = context.files.get(encPath);
|
|
6567
|
+
if (!content) return;
|
|
6568
|
+
const xml = new TextDecoder().decode(content);
|
|
6569
|
+
const rootName = this.extractRootElementName(xml);
|
|
6570
|
+
if (rootName !== null && rootName !== "encryption") {
|
|
6571
|
+
pushMessage(context.messages, {
|
|
6572
|
+
id: MessageId.RSC_005,
|
|
6573
|
+
message: `expected element "encryption" but found "${rootName}"`,
|
|
6574
|
+
location: { path: encPath }
|
|
6575
|
+
});
|
|
6576
|
+
return;
|
|
6577
|
+
}
|
|
6578
|
+
const idPattern = /\bId=["']([^"']+)["']/g;
|
|
6579
|
+
const ids = /* @__PURE__ */ new Map();
|
|
6580
|
+
let idMatch;
|
|
6581
|
+
while ((idMatch = idPattern.exec(xml)) !== null) {
|
|
6582
|
+
const id = idMatch[1] ?? "";
|
|
6583
|
+
ids.set(id, (ids.get(id) ?? 0) + 1);
|
|
6584
|
+
}
|
|
6585
|
+
for (const [id, count] of ids) {
|
|
6586
|
+
if (count > 1) {
|
|
6587
|
+
pushMessage(context.messages, {
|
|
6588
|
+
id: MessageId.RSC_005,
|
|
6589
|
+
message: `Duplicate "${id}"`,
|
|
6590
|
+
location: { path: encPath }
|
|
6591
|
+
});
|
|
6592
|
+
}
|
|
6593
|
+
}
|
|
6594
|
+
const compressionPattern = /<(?:\w+:)?Compression\s+([^>]*)\/?>/g;
|
|
6595
|
+
let compMatch;
|
|
6596
|
+
while ((compMatch = compressionPattern.exec(xml)) !== null) {
|
|
6597
|
+
const attrs = compMatch[1] ?? "";
|
|
6598
|
+
const methodMatch = /Method=["']([^"']*)["']/.exec(attrs);
|
|
6599
|
+
const lengthMatch = /OriginalLength=["']([^"']*)["']/.exec(attrs);
|
|
6600
|
+
if (methodMatch) {
|
|
6601
|
+
const method = methodMatch[1] ?? "";
|
|
6602
|
+
if (method !== "0" && method !== "8") {
|
|
6603
|
+
pushMessage(context.messages, {
|
|
6604
|
+
id: MessageId.RSC_005,
|
|
6605
|
+
message: `value of attribute "Method" is invalid; must be "0" or "8"`,
|
|
6606
|
+
location: { path: encPath }
|
|
6607
|
+
});
|
|
6608
|
+
}
|
|
6609
|
+
}
|
|
6610
|
+
if (lengthMatch) {
|
|
6611
|
+
const length = lengthMatch[1] ?? "";
|
|
6612
|
+
if (!/^\d+$/.test(length)) {
|
|
6613
|
+
pushMessage(context.messages, {
|
|
6614
|
+
id: MessageId.RSC_005,
|
|
6615
|
+
message: `value of attribute "OriginalLength" is invalid; must be a non-negative integer`,
|
|
6616
|
+
location: { path: encPath }
|
|
6617
|
+
});
|
|
6618
|
+
}
|
|
6619
|
+
}
|
|
6620
|
+
}
|
|
6621
|
+
}
|
|
6622
|
+
/**
|
|
6623
|
+
* Validate signatures.xml structure:
|
|
6624
|
+
* - Root element must be "signatures" in OCF namespace
|
|
6625
|
+
*/
|
|
6626
|
+
validateSignaturesXml(context) {
|
|
6627
|
+
const sigPath = "META-INF/signatures.xml";
|
|
6628
|
+
const content = context.files.get(sigPath);
|
|
6629
|
+
if (!content) return;
|
|
6630
|
+
const xml = new TextDecoder().decode(content);
|
|
6631
|
+
const rootName = this.extractRootElementName(xml);
|
|
6632
|
+
if (rootName !== null && rootName !== "signatures") {
|
|
6633
|
+
pushMessage(context.messages, {
|
|
6634
|
+
id: MessageId.RSC_005,
|
|
6635
|
+
message: `expected element "signatures" but found "${rootName}"`,
|
|
6636
|
+
location: { path: sigPath }
|
|
6637
|
+
});
|
|
6638
|
+
}
|
|
6639
|
+
}
|
|
5706
6640
|
/**
|
|
5707
6641
|
* Validate empty directories
|
|
5708
6642
|
*/
|
|
@@ -5730,6 +6664,65 @@ var OCFValidator = class {
|
|
|
5730
6664
|
}
|
|
5731
6665
|
};
|
|
5732
6666
|
|
|
6667
|
+
// src/util/encoding.ts
|
|
6668
|
+
function sniffXmlEncoding(data) {
|
|
6669
|
+
if (data.length < 2) return null;
|
|
6670
|
+
if (data.length >= 4) {
|
|
6671
|
+
if (data[0] === 0 && data[1] === 0 && data[2] === 254 && data[3] === 255) {
|
|
6672
|
+
return "UCS-4";
|
|
6673
|
+
}
|
|
6674
|
+
if (data[0] === 255 && data[1] === 254 && data[2] === 0 && data[3] === 0) {
|
|
6675
|
+
return "UCS-4";
|
|
6676
|
+
}
|
|
6677
|
+
if (data[0] === 0 && data[1] === 0 && data[2] === 255 && data[3] === 254) {
|
|
6678
|
+
return "UCS-4";
|
|
6679
|
+
}
|
|
6680
|
+
if (data[0] === 254 && data[1] === 255 && data[2] === 0 && data[3] === 0) {
|
|
6681
|
+
return "UCS-4";
|
|
6682
|
+
}
|
|
6683
|
+
if (data[0] === 0 && data[1] === 0 && data[2] === 0 && data[3] === 60) {
|
|
6684
|
+
return "UCS-4";
|
|
6685
|
+
}
|
|
6686
|
+
if (data[0] === 60 && data[1] === 0 && data[2] === 0 && data[3] === 0) {
|
|
6687
|
+
return "UCS-4";
|
|
6688
|
+
}
|
|
6689
|
+
if (data[0] === 0 && data[1] === 0 && data[2] === 60 && data[3] === 0) {
|
|
6690
|
+
return "UCS-4";
|
|
6691
|
+
}
|
|
6692
|
+
if (data[0] === 0 && data[1] === 60 && data[2] === 0 && data[3] === 0) {
|
|
6693
|
+
return "UCS-4";
|
|
6694
|
+
}
|
|
6695
|
+
}
|
|
6696
|
+
if (data[0] === 254 && data[1] === 255) {
|
|
6697
|
+
return "UTF-16";
|
|
6698
|
+
}
|
|
6699
|
+
if (data[0] === 255 && data[1] === 254) {
|
|
6700
|
+
return "UTF-16";
|
|
6701
|
+
}
|
|
6702
|
+
if (data.length >= 4) {
|
|
6703
|
+
if (data[0] === 0 && data[1] === 60 && data[2] === 0 && data[3] === 63) {
|
|
6704
|
+
return "UTF-16";
|
|
6705
|
+
}
|
|
6706
|
+
if (data[0] === 60 && data[1] === 0 && data[2] === 63 && data[3] === 0) {
|
|
6707
|
+
return "UTF-16";
|
|
6708
|
+
}
|
|
6709
|
+
}
|
|
6710
|
+
if (data.length >= 3 && data[0] === 239 && data[1] === 187 && data[2] === 191) {
|
|
6711
|
+
return null;
|
|
6712
|
+
}
|
|
6713
|
+
if (data.length >= 4 && data[0] === 76 && data[1] === 111 && data[2] === 167 && data[3] === 148) {
|
|
6714
|
+
return "EBCDIC";
|
|
6715
|
+
}
|
|
6716
|
+
const prefix = String.fromCharCode(...data.slice(0, Math.min(256, data.length)));
|
|
6717
|
+
const match = /^<\?xml[^?]*\bencoding\s*=\s*["']([^"']+)["']/.exec(prefix);
|
|
6718
|
+
if (match) {
|
|
6719
|
+
const declared = (match[1] ?? "").toUpperCase();
|
|
6720
|
+
if (declared === "UTF-8") return null;
|
|
6721
|
+
return declared;
|
|
6722
|
+
}
|
|
6723
|
+
return null;
|
|
6724
|
+
}
|
|
6725
|
+
|
|
5733
6726
|
// src/opf/parser.ts
|
|
5734
6727
|
function parseOPF(xml) {
|
|
5735
6728
|
const packageRegex = /<package[^>]*\sversion=["']([^"']+)["'][^>]*(?:\sunique-identifier=["']([^"']+)["'])?[^>]*>/;
|
|
@@ -6080,6 +7073,25 @@ var DEPRECATED_MEDIA_TYPES = /* @__PURE__ */ new Set([
|
|
|
6080
7073
|
"application/x-oeb1-package",
|
|
6081
7074
|
"text/x-oeb1-html"
|
|
6082
7075
|
]);
|
|
7076
|
+
function getPreferredMediaType(mimeType, path) {
|
|
7077
|
+
switch (mimeType) {
|
|
7078
|
+
case "application/font-sfnt":
|
|
7079
|
+
if (path.endsWith(".ttf")) return "font/ttf";
|
|
7080
|
+
if (path.endsWith(".otf")) return "font/otf";
|
|
7081
|
+
return "font/(ttf|otf)";
|
|
7082
|
+
case "application/vnd.ms-opentype":
|
|
7083
|
+
return "font/otf";
|
|
7084
|
+
case "application/font-woff":
|
|
7085
|
+
return "font/woff";
|
|
7086
|
+
case "application/x-font-ttf":
|
|
7087
|
+
return "font/ttf";
|
|
7088
|
+
case "text/javascript":
|
|
7089
|
+
case "application/ecmascript":
|
|
7090
|
+
return "application/javascript";
|
|
7091
|
+
default:
|
|
7092
|
+
return null;
|
|
7093
|
+
}
|
|
7094
|
+
}
|
|
6083
7095
|
var VALID_RELATOR_CODES = /* @__PURE__ */ new Set([
|
|
6084
7096
|
"abr",
|
|
6085
7097
|
"acp",
|
|
@@ -6362,6 +7374,60 @@ var DEPRECATED_LINK_REL = /* @__PURE__ */ new Set([
|
|
|
6362
7374
|
"xmp-record",
|
|
6363
7375
|
"xml-signature"
|
|
6364
7376
|
]);
|
|
7377
|
+
var EXCLUSIVE_SPINE_GROUPS = [
|
|
7378
|
+
["rendition:layout-reflowable", "rendition:layout-pre-paginated"],
|
|
7379
|
+
[
|
|
7380
|
+
"rendition:orientation-auto",
|
|
7381
|
+
"rendition:orientation-landscape",
|
|
7382
|
+
"rendition:orientation-portrait"
|
|
7383
|
+
],
|
|
7384
|
+
[
|
|
7385
|
+
"rendition:spread-auto",
|
|
7386
|
+
"rendition:spread-both",
|
|
7387
|
+
"rendition:spread-landscape",
|
|
7388
|
+
"rendition:spread-none",
|
|
7389
|
+
"rendition:spread-portrait"
|
|
7390
|
+
],
|
|
7391
|
+
["page-spread-left", "page-spread-right", "rendition:page-spread-center"],
|
|
7392
|
+
[
|
|
7393
|
+
"rendition:flow-auto",
|
|
7394
|
+
"rendition:flow-paginated",
|
|
7395
|
+
"rendition:flow-scrolled-continuous",
|
|
7396
|
+
"rendition:flow-scrolled-doc"
|
|
7397
|
+
]
|
|
7398
|
+
];
|
|
7399
|
+
var RENDITION_META_RULES = [
|
|
7400
|
+
{
|
|
7401
|
+
property: "rendition:layout",
|
|
7402
|
+
allowedValues: /* @__PURE__ */ new Set(["reflowable", "pre-paginated"]),
|
|
7403
|
+
forbidRefines: true
|
|
7404
|
+
},
|
|
7405
|
+
{
|
|
7406
|
+
property: "rendition:orientation",
|
|
7407
|
+
allowedValues: /* @__PURE__ */ new Set(["landscape", "portrait", "auto"]),
|
|
7408
|
+
forbidRefines: true
|
|
7409
|
+
},
|
|
7410
|
+
{
|
|
7411
|
+
property: "rendition:spread",
|
|
7412
|
+
allowedValues: /* @__PURE__ */ new Set(["none", "landscape", "portrait", "both", "auto"]),
|
|
7413
|
+
forbidRefines: true,
|
|
7414
|
+
deprecatedValues: /* @__PURE__ */ new Set(["portrait"])
|
|
7415
|
+
},
|
|
7416
|
+
{
|
|
7417
|
+
property: "rendition:flow",
|
|
7418
|
+
allowedValues: /* @__PURE__ */ new Set(["paginated", "scrolled-continuous", "scrolled-doc", "auto"]),
|
|
7419
|
+
forbidRefines: true
|
|
7420
|
+
},
|
|
7421
|
+
{
|
|
7422
|
+
property: "rendition:viewport",
|
|
7423
|
+
deprecated: true,
|
|
7424
|
+
allowedValues: /* @__PURE__ */ new Set(),
|
|
7425
|
+
validateSyntax: (v) => /^(width=\d+,\s*height=\d+|height=\d+,\s*width=\d+)$/.test(v)
|
|
7426
|
+
}
|
|
7427
|
+
];
|
|
7428
|
+
var KNOWN_RENDITION_META_PROPERTIES = new Set(
|
|
7429
|
+
RENDITION_META_RULES.map((r) => r.property.slice("rendition:".length))
|
|
7430
|
+
);
|
|
6365
7431
|
var GRANDFATHERED_LANG_TAGS = /* @__PURE__ */ new Set([
|
|
6366
7432
|
"en-GB-oed",
|
|
6367
7433
|
"i-ami",
|
|
@@ -6415,6 +7481,20 @@ var OPFValidator = class {
|
|
|
6415
7481
|
});
|
|
6416
7482
|
return;
|
|
6417
7483
|
}
|
|
7484
|
+
const encoding = sniffXmlEncoding(opfData);
|
|
7485
|
+
if (encoding === "UTF-16") {
|
|
7486
|
+
pushMessage(context.messages, {
|
|
7487
|
+
id: MessageId.RSC_027,
|
|
7488
|
+
message: `Detected non-UTF-8 encoding "${encoding}" in "${opfPath}"`,
|
|
7489
|
+
location: { path: opfPath }
|
|
7490
|
+
});
|
|
7491
|
+
} else if (encoding !== null) {
|
|
7492
|
+
pushMessage(context.messages, {
|
|
7493
|
+
id: MessageId.RSC_028,
|
|
7494
|
+
message: `Detected non-UTF-8 encoding "${encoding}" in "${opfPath}"`,
|
|
7495
|
+
location: { path: opfPath }
|
|
7496
|
+
});
|
|
7497
|
+
}
|
|
6418
7498
|
const opfXml = new TextDecoder().decode(opfData);
|
|
6419
7499
|
try {
|
|
6420
7500
|
this.packageDoc = parseOPF(opfXml);
|
|
@@ -6704,6 +7784,9 @@ var OPFValidator = class {
|
|
|
6704
7784
|
}
|
|
6705
7785
|
if (this.packageDoc.version !== "2.0") {
|
|
6706
7786
|
this.validateMetaPropertiesVocab(context, opfPath, dcElements);
|
|
7787
|
+
this.validateRenditionVocab(context, opfPath);
|
|
7788
|
+
this.validateMediaOverlaysVocab(context, opfPath);
|
|
7789
|
+
this.validateMediaOverlayItems(context, opfPath);
|
|
6707
7790
|
}
|
|
6708
7791
|
if (this.packageDoc.version !== "2.0") {
|
|
6709
7792
|
const modifiedMetas = this.packageDoc.metaElements.filter(
|
|
@@ -6994,6 +8077,206 @@ var OPFValidator = class {
|
|
|
6994
8077
|
}
|
|
6995
8078
|
}
|
|
6996
8079
|
}
|
|
8080
|
+
/**
|
|
8081
|
+
* Validate rendition vocabulary meta properties (rendition:layout, orientation, spread, flow, viewport).
|
|
8082
|
+
* Ports the Schematron rules from package-30.sch for the rendition vocabulary.
|
|
8083
|
+
*/
|
|
8084
|
+
validateRenditionVocab(context, opfPath) {
|
|
8085
|
+
if (!this.packageDoc) return;
|
|
8086
|
+
const metas = this.packageDoc.metaElements;
|
|
8087
|
+
for (const rp of RENDITION_META_RULES) {
|
|
8088
|
+
const matching = metas.filter((m) => m.property === rp.property);
|
|
8089
|
+
for (const meta of matching) {
|
|
8090
|
+
if (meta.refines && rp.forbidRefines) {
|
|
8091
|
+
pushMessage(context.messages, {
|
|
8092
|
+
id: MessageId.RSC_005,
|
|
8093
|
+
message: `The "${rp.property}" property must not refine a publication resource`,
|
|
8094
|
+
location: { path: opfPath }
|
|
8095
|
+
});
|
|
8096
|
+
continue;
|
|
8097
|
+
}
|
|
8098
|
+
if (rp.deprecated) {
|
|
8099
|
+
pushMessage(context.messages, {
|
|
8100
|
+
id: MessageId.OPF_086,
|
|
8101
|
+
message: `The "${rp.property}" property is deprecated`,
|
|
8102
|
+
location: { path: opfPath }
|
|
8103
|
+
});
|
|
8104
|
+
}
|
|
8105
|
+
if (rp.validateSyntax) {
|
|
8106
|
+
if (!rp.validateSyntax(meta.value)) {
|
|
8107
|
+
pushMessage(context.messages, {
|
|
8108
|
+
id: MessageId.RSC_005,
|
|
8109
|
+
message: `The value of the "${rp.property}" property must be of the form "width=x, height=y"`,
|
|
8110
|
+
location: { path: opfPath }
|
|
8111
|
+
});
|
|
8112
|
+
}
|
|
8113
|
+
} else if (!rp.allowedValues.has(meta.value)) {
|
|
8114
|
+
pushMessage(context.messages, {
|
|
8115
|
+
id: MessageId.RSC_005,
|
|
8116
|
+
message: `The value of the "${rp.property}" property must be ${[...rp.allowedValues].map((v) => `"${v}"`).join(" or ")}`,
|
|
8117
|
+
location: { path: opfPath }
|
|
8118
|
+
});
|
|
8119
|
+
}
|
|
8120
|
+
if (rp.deprecatedValues?.has(meta.value)) {
|
|
8121
|
+
pushMessage(context.messages, {
|
|
8122
|
+
id: MessageId.OPF_086,
|
|
8123
|
+
message: `The "${rp.property}" property value "${meta.value}" is deprecated`,
|
|
8124
|
+
location: { path: opfPath }
|
|
8125
|
+
});
|
|
8126
|
+
}
|
|
8127
|
+
}
|
|
8128
|
+
const countable = rp.forbidRefines ? matching : matching.filter((m) => !m.refines);
|
|
8129
|
+
if (countable.length > 1) {
|
|
8130
|
+
pushMessage(context.messages, {
|
|
8131
|
+
id: MessageId.RSC_005,
|
|
8132
|
+
message: `The "${rp.property}" property must not occur more than one time as a global value`,
|
|
8133
|
+
location: { path: opfPath }
|
|
8134
|
+
});
|
|
8135
|
+
}
|
|
8136
|
+
}
|
|
8137
|
+
for (const meta of metas) {
|
|
8138
|
+
if (meta.property.startsWith("rendition:")) {
|
|
8139
|
+
const localName = meta.property.slice("rendition:".length);
|
|
8140
|
+
if (!KNOWN_RENDITION_META_PROPERTIES.has(localName)) {
|
|
8141
|
+
pushMessage(context.messages, {
|
|
8142
|
+
id: MessageId.OPF_027,
|
|
8143
|
+
message: `Undefined property: "${meta.property}"`,
|
|
8144
|
+
location: { path: opfPath }
|
|
8145
|
+
});
|
|
8146
|
+
}
|
|
8147
|
+
}
|
|
8148
|
+
}
|
|
8149
|
+
}
|
|
8150
|
+
/**
|
|
8151
|
+
* Validate media overlays vocabulary meta properties (media:active-class, playback-active-class, duration).
|
|
8152
|
+
* Ports the Schematron rules from package-30.sch for the media overlays vocabulary.
|
|
8153
|
+
*/
|
|
8154
|
+
validateMediaOverlaysVocab(context, opfPath) {
|
|
8155
|
+
if (!this.packageDoc) return;
|
|
8156
|
+
const metas = this.packageDoc.metaElements;
|
|
8157
|
+
const matchingActive = metas.filter((m) => m.property === "media:active-class");
|
|
8158
|
+
const matchingPlayback = metas.filter((m) => m.property === "media:playback-active-class");
|
|
8159
|
+
for (const [prop, matching] of [
|
|
8160
|
+
["media:active-class", matchingActive],
|
|
8161
|
+
["media:playback-active-class", matchingPlayback]
|
|
8162
|
+
]) {
|
|
8163
|
+
const displayName = prop.slice("media:".length);
|
|
8164
|
+
if (matching.length > 1) {
|
|
8165
|
+
pushMessage(context.messages, {
|
|
8166
|
+
id: MessageId.RSC_005,
|
|
8167
|
+
message: `The '${displayName}' property must not occur more than one time in the package metadata`,
|
|
8168
|
+
location: { path: opfPath }
|
|
8169
|
+
});
|
|
8170
|
+
}
|
|
8171
|
+
for (const meta of matching) {
|
|
8172
|
+
if (meta.refines) {
|
|
8173
|
+
pushMessage(context.messages, {
|
|
8174
|
+
id: MessageId.RSC_005,
|
|
8175
|
+
message: `@refines must not be used with the ${prop} property`,
|
|
8176
|
+
location: { path: opfPath }
|
|
8177
|
+
});
|
|
8178
|
+
}
|
|
8179
|
+
if (meta.value.trim().includes(" ")) {
|
|
8180
|
+
pushMessage(context.messages, {
|
|
8181
|
+
id: MessageId.RSC_005,
|
|
8182
|
+
message: `the '${displayName}' property must define a single class name`,
|
|
8183
|
+
location: { path: opfPath }
|
|
8184
|
+
});
|
|
8185
|
+
}
|
|
8186
|
+
}
|
|
8187
|
+
}
|
|
8188
|
+
if (matchingActive[0]) context.mediaActiveClass = matchingActive[0].value.trim();
|
|
8189
|
+
if (matchingPlayback[0]) context.mediaPlaybackActiveClass = matchingPlayback[0].value.trim();
|
|
8190
|
+
for (const meta of metas) {
|
|
8191
|
+
if (meta.property === "media:duration") {
|
|
8192
|
+
if (!isValidSmilClock(meta.value.trim())) {
|
|
8193
|
+
pushMessage(context.messages, {
|
|
8194
|
+
id: MessageId.RSC_005,
|
|
8195
|
+
message: `The value of the "media:duration" property must be a valid SMIL3 clock value`,
|
|
8196
|
+
location: { path: opfPath }
|
|
8197
|
+
});
|
|
8198
|
+
}
|
|
8199
|
+
}
|
|
8200
|
+
}
|
|
8201
|
+
const globalDuration = metas.find((m) => m.property === "media:duration" && !m.refines);
|
|
8202
|
+
if (globalDuration) {
|
|
8203
|
+
const totalSeconds = parseSmilClock(globalDuration.value.trim());
|
|
8204
|
+
if (!Number.isNaN(totalSeconds)) {
|
|
8205
|
+
let sumSeconds = 0;
|
|
8206
|
+
let allValid = true;
|
|
8207
|
+
for (const meta of metas) {
|
|
8208
|
+
if (meta.property === "media:duration" && meta.refines) {
|
|
8209
|
+
const s = parseSmilClock(meta.value.trim());
|
|
8210
|
+
if (Number.isNaN(s)) {
|
|
8211
|
+
allValid = false;
|
|
8212
|
+
break;
|
|
8213
|
+
}
|
|
8214
|
+
sumSeconds += s;
|
|
8215
|
+
}
|
|
8216
|
+
}
|
|
8217
|
+
if (allValid && Math.abs(totalSeconds - sumSeconds) > 1) {
|
|
8218
|
+
pushMessage(context.messages, {
|
|
8219
|
+
id: MessageId.MED_016,
|
|
8220
|
+
message: `Media Overlays total duration should be the sum of the durations of all Media Overlays documents.`,
|
|
8221
|
+
location: { path: opfPath }
|
|
8222
|
+
});
|
|
8223
|
+
}
|
|
8224
|
+
}
|
|
8225
|
+
}
|
|
8226
|
+
}
|
|
8227
|
+
/**
|
|
8228
|
+
* Validate media-overlay manifest item constraints:
|
|
8229
|
+
* - media-overlay must reference a SMIL item (application/smil+xml)
|
|
8230
|
+
* - media-overlay attribute only allowed on XHTML and SVG content documents
|
|
8231
|
+
* - Global media:duration required when overlays exist
|
|
8232
|
+
* - Per-item media:duration required for each overlay
|
|
8233
|
+
*/
|
|
8234
|
+
validateMediaOverlayItems(context, opfPath) {
|
|
8235
|
+
if (!this.packageDoc) return;
|
|
8236
|
+
const manifest = this.packageDoc.manifest;
|
|
8237
|
+
const metas = this.packageDoc.metaElements;
|
|
8238
|
+
const itemsWithOverlay = manifest.filter((item) => item.mediaOverlay);
|
|
8239
|
+
if (itemsWithOverlay.length === 0) return;
|
|
8240
|
+
for (const item of itemsWithOverlay) {
|
|
8241
|
+
const moId = item.mediaOverlay;
|
|
8242
|
+
if (!moId) continue;
|
|
8243
|
+
const moItem = this.manifestById.get(moId);
|
|
8244
|
+
if (moItem && moItem.mediaType !== "application/smil+xml") {
|
|
8245
|
+
pushMessage(context.messages, {
|
|
8246
|
+
id: MessageId.RSC_005,
|
|
8247
|
+
message: `media overlay items must be of the "application/smil+xml" type (given type was "${moItem.mediaType}")`,
|
|
8248
|
+
location: { path: opfPath }
|
|
8249
|
+
});
|
|
8250
|
+
}
|
|
8251
|
+
if (item.mediaType !== "application/xhtml+xml" && item.mediaType !== "image/svg+xml") {
|
|
8252
|
+
pushMessage(context.messages, {
|
|
8253
|
+
id: MessageId.RSC_005,
|
|
8254
|
+
message: `The media-overlay attribute is only allowed on XHTML and SVG content documents.`,
|
|
8255
|
+
location: { path: opfPath }
|
|
8256
|
+
});
|
|
8257
|
+
}
|
|
8258
|
+
}
|
|
8259
|
+
if (!metas.some((m) => m.property === "media:duration" && !m.refines)) {
|
|
8260
|
+
pushMessage(context.messages, {
|
|
8261
|
+
id: MessageId.RSC_005,
|
|
8262
|
+
message: `global media:duration meta element not set`,
|
|
8263
|
+
location: { path: opfPath }
|
|
8264
|
+
});
|
|
8265
|
+
}
|
|
8266
|
+
const overlayIds = new Set(
|
|
8267
|
+
itemsWithOverlay.map((item) => item.mediaOverlay).filter((id) => id != null && this.manifestById.has(id))
|
|
8268
|
+
);
|
|
8269
|
+
for (const overlayId of overlayIds) {
|
|
8270
|
+
const refinesUri = `#${overlayId}`;
|
|
8271
|
+
if (!metas.some((m) => m.property === "media:duration" && m.refines === refinesUri)) {
|
|
8272
|
+
pushMessage(context.messages, {
|
|
8273
|
+
id: MessageId.RSC_005,
|
|
8274
|
+
message: `item media:duration meta element not set (expecting: meta property='media:duration' refines='${refinesUri}')`,
|
|
8275
|
+
location: { path: opfPath }
|
|
8276
|
+
});
|
|
8277
|
+
}
|
|
8278
|
+
}
|
|
8279
|
+
}
|
|
6997
8280
|
/**
|
|
6998
8281
|
* Validate EPUB 3 link elements in metadata
|
|
6999
8282
|
*/
|
|
@@ -7073,7 +8356,30 @@ var OPFValidator = class {
|
|
|
7073
8356
|
});
|
|
7074
8357
|
continue;
|
|
7075
8358
|
}
|
|
8359
|
+
if (isDataURL(href)) {
|
|
8360
|
+
pushMessage(context.messages, {
|
|
8361
|
+
id: MessageId.RSC_029,
|
|
8362
|
+
message: `Data URLs are not allowed in the package document link href`,
|
|
8363
|
+
location: { path: opfPath }
|
|
8364
|
+
});
|
|
8365
|
+
continue;
|
|
8366
|
+
}
|
|
8367
|
+
if (isFileURL(href)) {
|
|
8368
|
+
pushMessage(context.messages, {
|
|
8369
|
+
id: MessageId.RSC_030,
|
|
8370
|
+
message: `File URLs are not allowed in the package document`,
|
|
8371
|
+
location: { path: opfPath }
|
|
8372
|
+
});
|
|
8373
|
+
continue;
|
|
8374
|
+
}
|
|
7076
8375
|
const isRemote = /^[a-zA-Z][a-zA-Z0-9+\-.]*:/.test(href);
|
|
8376
|
+
if (!isRemote && href.includes("?")) {
|
|
8377
|
+
pushMessage(context.messages, {
|
|
8378
|
+
id: MessageId.RSC_033,
|
|
8379
|
+
message: `Relative URL strings must not have a query component: "${href}"`,
|
|
8380
|
+
location: { path: opfPath }
|
|
8381
|
+
});
|
|
8382
|
+
}
|
|
7077
8383
|
if (hasVoicing && link.mediaType && !link.mediaType.startsWith("audio/")) {
|
|
7078
8384
|
pushMessage(context.messages, {
|
|
7079
8385
|
id: MessageId.OPF_095,
|
|
@@ -7098,10 +8404,12 @@ var OPFValidator = class {
|
|
|
7098
8404
|
location: { path: opfPath }
|
|
7099
8405
|
});
|
|
7100
8406
|
}
|
|
7101
|
-
const
|
|
7102
|
-
const
|
|
8407
|
+
const basePathNoQuery = basePath.includes("?") ? basePath.substring(0, basePath.indexOf("?")) : basePath;
|
|
8408
|
+
const basePathDecodedNoQuery = basePathDecoded.includes("?") ? basePathDecoded.substring(0, basePathDecoded.indexOf("?")) : basePathDecoded;
|
|
8409
|
+
const resolvedPath = resolvePath(opfPath, basePathNoQuery);
|
|
8410
|
+
const resolvedPathDecoded = basePathDecodedNoQuery !== basePathNoQuery ? resolvePath(opfPath, basePathDecodedNoQuery) : resolvedPath;
|
|
7103
8411
|
const fileExists = context.files.has(resolvedPath) || context.files.has(resolvedPathDecoded);
|
|
7104
|
-
const inManifest = this.manifestByHref.has(
|
|
8412
|
+
const inManifest = this.manifestByHref.has(basePathNoQuery) || this.manifestByHref.has(basePathDecodedNoQuery);
|
|
7105
8413
|
if (!fileExists && !inManifest) {
|
|
7106
8414
|
pushMessage(context.messages, {
|
|
7107
8415
|
id: MessageId.RSC_007w,
|
|
@@ -7135,7 +8443,24 @@ var OPFValidator = class {
|
|
|
7135
8443
|
});
|
|
7136
8444
|
}
|
|
7137
8445
|
seenHrefs.add(item.href);
|
|
7138
|
-
|
|
8446
|
+
if (isDataURL(item.href)) {
|
|
8447
|
+
pushMessage(context.messages, {
|
|
8448
|
+
id: MessageId.RSC_029,
|
|
8449
|
+
message: `Data URLs are not allowed in the manifest item href`,
|
|
8450
|
+
location: { path: opfPath }
|
|
8451
|
+
});
|
|
8452
|
+
continue;
|
|
8453
|
+
}
|
|
8454
|
+
const isRemoteItem = /^[a-zA-Z][a-zA-Z0-9+\-.]*:/.test(item.href);
|
|
8455
|
+
if (!isRemoteItem && item.href.includes("?")) {
|
|
8456
|
+
pushMessage(context.messages, {
|
|
8457
|
+
id: MessageId.RSC_033,
|
|
8458
|
+
message: `Relative URL strings must not have a query component: "${item.href}"`,
|
|
8459
|
+
location: { path: opfPath }
|
|
8460
|
+
});
|
|
8461
|
+
}
|
|
8462
|
+
const itemHrefBase = item.href.includes("?") ? item.href.substring(0, item.href.indexOf("?")) : item.href;
|
|
8463
|
+
const fullPath = resolvePath(opfPath, itemHrefBase);
|
|
7139
8464
|
if (fullPath === opfPath) {
|
|
7140
8465
|
pushMessage(context.messages, {
|
|
7141
8466
|
id: MessageId.OPF_099,
|
|
@@ -7153,8 +8478,8 @@ var OPFValidator = class {
|
|
|
7153
8478
|
});
|
|
7154
8479
|
}
|
|
7155
8480
|
}
|
|
7156
|
-
const decodedHref = tryDecodeUriComponent(
|
|
7157
|
-
const fullPathDecoded = decodedHref !==
|
|
8481
|
+
const decodedHref = tryDecodeUriComponent(itemHrefBase);
|
|
8482
|
+
const fullPathDecoded = decodedHref !== itemHrefBase ? resolvePath(opfPath, decodedHref).normalize("NFC") : fullPath;
|
|
7158
8483
|
if (!context.files.has(fullPath) && !context.files.has(fullPathDecoded) && !item.href.startsWith("http")) {
|
|
7159
8484
|
pushMessage(context.messages, {
|
|
7160
8485
|
id: MessageId.RSC_001,
|
|
@@ -7183,6 +8508,14 @@ var OPFValidator = class {
|
|
|
7183
8508
|
location: { path: opfPath }
|
|
7184
8509
|
});
|
|
7185
8510
|
}
|
|
8511
|
+
const preferred = getPreferredMediaType(item.mediaType, fullPath);
|
|
8512
|
+
if (preferred !== null) {
|
|
8513
|
+
pushMessage(context.messages, {
|
|
8514
|
+
id: MessageId.OPF_090,
|
|
8515
|
+
message: `Encouraged to use media type "${preferred}" instead of "${item.mediaType}"`,
|
|
8516
|
+
location: { path: opfPath }
|
|
8517
|
+
});
|
|
8518
|
+
}
|
|
7186
8519
|
if (this.packageDoc.version !== "2.0" && item.properties) {
|
|
7187
8520
|
for (const prop of item.properties) {
|
|
7188
8521
|
if (!ITEM_PROPERTIES.has(prop)) {
|
|
@@ -7377,6 +8710,24 @@ var OPFValidator = class {
|
|
|
7377
8710
|
location: { path: opfPath }
|
|
7378
8711
|
});
|
|
7379
8712
|
}
|
|
8713
|
+
if (prop === "rendition:spread-portrait") {
|
|
8714
|
+
pushMessage(context.messages, {
|
|
8715
|
+
id: MessageId.OPF_086,
|
|
8716
|
+
message: `The "rendition:spread-portrait" property is deprecated`,
|
|
8717
|
+
location: { path: opfPath }
|
|
8718
|
+
});
|
|
8719
|
+
}
|
|
8720
|
+
}
|
|
8721
|
+
const props = new Set(itemref.properties);
|
|
8722
|
+
for (const group of EXCLUSIVE_SPINE_GROUPS) {
|
|
8723
|
+
const found = group.filter((p) => props.has(p));
|
|
8724
|
+
if (found.length > 1) {
|
|
8725
|
+
pushMessage(context.messages, {
|
|
8726
|
+
id: MessageId.RSC_005,
|
|
8727
|
+
message: `Properties "${found.join('", "')}" are mutually exclusive`,
|
|
8728
|
+
location: { path: opfPath }
|
|
8729
|
+
});
|
|
8730
|
+
}
|
|
7380
8731
|
}
|
|
7381
8732
|
}
|
|
7382
8733
|
}
|
|
@@ -7920,7 +9271,7 @@ var ReferenceValidator = class {
|
|
|
7920
9271
|
location: reference.location
|
|
7921
9272
|
});
|
|
7922
9273
|
}
|
|
7923
|
-
if (!this.registry.hasResource(resourcePath)) {
|
|
9274
|
+
if (reference.type !== "overlay-text-link" /* OVERLAY_TEXT_LINK */ && !this.registry.hasResource(resourcePath)) {
|
|
7924
9275
|
const fileExistsInContainer = context.files.has(resourcePath);
|
|
7925
9276
|
if (fileExistsInContainer) {
|
|
7926
9277
|
if (!context.referencedUndeclaredResources?.has(resourcePath)) {
|
|
@@ -8057,6 +9408,24 @@ var ReferenceValidator = class {
|
|
|
8057
9408
|
});
|
|
8058
9409
|
}
|
|
8059
9410
|
}
|
|
9411
|
+
if (reference.type === "overlay-text-link" /* OVERLAY_TEXT_LINK */ && resource) {
|
|
9412
|
+
if (resource.mimeType === "application/xhtml+xml" && /^\w+\(/.test(fragment)) {
|
|
9413
|
+
pushMessage(context.messages, {
|
|
9414
|
+
id: MessageId.MED_017,
|
|
9415
|
+
message: `URL fragment should indicate an element ID, but found '#${fragment}'`,
|
|
9416
|
+
location: reference.location
|
|
9417
|
+
});
|
|
9418
|
+
return;
|
|
9419
|
+
}
|
|
9420
|
+
if (resource.mimeType === "image/svg+xml" && !this.isValidSVGFragment(fragment)) {
|
|
9421
|
+
pushMessage(context.messages, {
|
|
9422
|
+
id: MessageId.MED_018,
|
|
9423
|
+
message: `URL fragment should be an SVG fragment identifier, but found '#${fragment}'`,
|
|
9424
|
+
location: reference.location
|
|
9425
|
+
});
|
|
9426
|
+
return;
|
|
9427
|
+
}
|
|
9428
|
+
}
|
|
8060
9429
|
const parsedMimeTypes = ["application/xhtml+xml", "image/svg+xml"];
|
|
8061
9430
|
if (resource && parsedMimeTypes.includes(resource.mimeType) && !isRemoteURL(resourcePath) && !fragment.includes("svgView(") && reference.type !== "svg-symbol" /* SVG_SYMBOL */) {
|
|
8062
9431
|
if (!this.registry.hasID(resourcePath, fragment)) {
|
|
@@ -8068,6 +9437,18 @@ var ReferenceValidator = class {
|
|
|
8068
9437
|
}
|
|
8069
9438
|
}
|
|
8070
9439
|
}
|
|
9440
|
+
/**
|
|
9441
|
+
* Check if a fragment is a valid SVG fragment identifier.
|
|
9442
|
+
* Valid forms: bare NCName ID, svgView(...), t=..., xywh=..., id=...
|
|
9443
|
+
*/
|
|
9444
|
+
isValidSVGFragment(fragment) {
|
|
9445
|
+
if (/^svgView\(.*\)$/.test(fragment)) return true;
|
|
9446
|
+
if (fragment.startsWith("t=")) return true;
|
|
9447
|
+
if (fragment.startsWith("xywh=")) return true;
|
|
9448
|
+
if (fragment.startsWith("id=")) return true;
|
|
9449
|
+
if (!fragment.includes("=") && /^[a-zA-Z_][\w.-]*$/.test(fragment)) return true;
|
|
9450
|
+
return false;
|
|
9451
|
+
}
|
|
8071
9452
|
/**
|
|
8072
9453
|
* Check non-spine remote resources that have non-standard types.
|
|
8073
9454
|
* Fires RSC-006 for remote items that aren't audio/video/font types
|
|
@@ -8127,7 +9508,7 @@ var ReferenceValidator = class {
|
|
|
8127
9508
|
}
|
|
8128
9509
|
}
|
|
8129
9510
|
checkReadingOrder(context) {
|
|
8130
|
-
if (!context.
|
|
9511
|
+
if (!context.packageDocument) return;
|
|
8131
9512
|
const packageDoc = context.packageDocument;
|
|
8132
9513
|
const spine = packageDoc.spine;
|
|
8133
9514
|
const opfPath = context.opfPath ?? "";
|
|
@@ -8139,15 +9520,35 @@ var ReferenceValidator = class {
|
|
|
8139
9520
|
spinePositionMap.set(resolveManifestHref(opfDir, item.href), i);
|
|
8140
9521
|
}
|
|
8141
9522
|
}
|
|
9523
|
+
if (context.tocLinks) {
|
|
9524
|
+
this.checkLinkReadingOrder(
|
|
9525
|
+
context,
|
|
9526
|
+
spinePositionMap,
|
|
9527
|
+
context.tocLinks,
|
|
9528
|
+
MessageId.NAV_011,
|
|
9529
|
+
'"toc" nav'
|
|
9530
|
+
);
|
|
9531
|
+
}
|
|
9532
|
+
if (context.overlayTextLinks) {
|
|
9533
|
+
this.checkLinkReadingOrder(
|
|
9534
|
+
context,
|
|
9535
|
+
spinePositionMap,
|
|
9536
|
+
context.overlayTextLinks,
|
|
9537
|
+
MessageId.MED_015,
|
|
9538
|
+
"Media overlay text"
|
|
9539
|
+
);
|
|
9540
|
+
}
|
|
9541
|
+
}
|
|
9542
|
+
checkLinkReadingOrder(context, spinePositionMap, links, messageId, label) {
|
|
8142
9543
|
let lastSpinePosition = -1;
|
|
8143
9544
|
let lastAnchorPosition = -1;
|
|
8144
|
-
for (const link of
|
|
9545
|
+
for (const link of links) {
|
|
8145
9546
|
const spinePos = spinePositionMap.get(link.targetResource);
|
|
8146
9547
|
if (spinePos === void 0) continue;
|
|
8147
9548
|
if (spinePos < lastSpinePosition) {
|
|
8148
9549
|
pushMessage(context.messages, {
|
|
8149
|
-
id:
|
|
8150
|
-
message:
|
|
9550
|
+
id: messageId,
|
|
9551
|
+
message: `${label} must be in reading order; link target "${link.targetResource}" is before the previous link's target in spine order`,
|
|
8151
9552
|
location: link.location
|
|
8152
9553
|
});
|
|
8153
9554
|
lastSpinePosition = spinePos;
|
|
@@ -8162,8 +9563,8 @@ var ReferenceValidator = class {
|
|
|
8162
9563
|
if (targetAnchorPosition < lastAnchorPosition) {
|
|
8163
9564
|
const target = link.fragment ? `${link.targetResource}#${link.fragment}` : link.targetResource;
|
|
8164
9565
|
pushMessage(context.messages, {
|
|
8165
|
-
id:
|
|
8166
|
-
message:
|
|
9566
|
+
id: messageId,
|
|
9567
|
+
message: `${label} must be in reading order; link target "${target}" is before the previous link's target in document order`,
|
|
8167
9568
|
location: link.location
|
|
8168
9569
|
});
|
|
8169
9570
|
}
|
|
@@ -8382,11 +9783,11 @@ var RelaxNGValidator = class extends BaseSchemaValidator {
|
|
|
8382
9783
|
try {
|
|
8383
9784
|
const libxml2 = await import('libxml2-wasm');
|
|
8384
9785
|
const LibRelaxNGValidator = libxml2.RelaxNGValidator;
|
|
8385
|
-
const { XmlDocument:
|
|
8386
|
-
const doc =
|
|
9786
|
+
const { XmlDocument: XmlDocument4 } = libxml2;
|
|
9787
|
+
const doc = XmlDocument4.fromString(xml);
|
|
8387
9788
|
try {
|
|
8388
9789
|
const schemaContent = await loadSchema(schemaPath);
|
|
8389
|
-
const schemaDoc =
|
|
9790
|
+
const schemaDoc = XmlDocument4.fromString(schemaContent);
|
|
8390
9791
|
try {
|
|
8391
9792
|
const validator = LibRelaxNGValidator.fromDoc(schemaDoc);
|
|
8392
9793
|
try {
|