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