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