@likecoin/epubcheck-ts 0.5.0 → 0.5.1
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 +4 -4
- package/bin/epubcheck.js +1 -1
- package/bin/epubcheck.ts +1 -1
- package/dist/index.cjs +377 -375
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +377 -375
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -1806,379 +1806,6 @@ var EPUB_SSV_ALL = /* @__PURE__ */ new Set([
|
|
|
1806
1806
|
"volume"
|
|
1807
1807
|
]);
|
|
1808
1808
|
|
|
1809
|
-
// src/smil/clock.ts
|
|
1810
|
-
var FULL_CLOCK_RE = /^(\d+):([0-5]\d):([0-5]\d)(\.\d+)?$/;
|
|
1811
|
-
var PARTIAL_CLOCK_RE = /^([0-5]\d):([0-5]\d)(\.\d+)?$/;
|
|
1812
|
-
var TIMECOUNT_RE = /^(\d+(\.\d+)?)(h|min|s|ms)?$/;
|
|
1813
|
-
function parseSmilClock(value) {
|
|
1814
|
-
const trimmed = value.trim();
|
|
1815
|
-
const full = FULL_CLOCK_RE.exec(trimmed);
|
|
1816
|
-
if (full) {
|
|
1817
|
-
const hours = Number.parseInt(full[1] ?? "0", 10);
|
|
1818
|
-
const minutes = Number.parseInt(full[2] ?? "0", 10);
|
|
1819
|
-
const seconds = Number.parseInt(full[3] ?? "0", 10);
|
|
1820
|
-
const frac = full[4] ? Number.parseFloat(full[4]) : 0;
|
|
1821
|
-
return hours * 3600 + minutes * 60 + seconds + frac;
|
|
1822
|
-
}
|
|
1823
|
-
const partial = PARTIAL_CLOCK_RE.exec(trimmed);
|
|
1824
|
-
if (partial) {
|
|
1825
|
-
const minutes = Number.parseInt(partial[1] ?? "0", 10);
|
|
1826
|
-
const seconds = Number.parseInt(partial[2] ?? "0", 10);
|
|
1827
|
-
const frac = partial[3] ? Number.parseFloat(partial[3]) : 0;
|
|
1828
|
-
return minutes * 60 + seconds + frac;
|
|
1829
|
-
}
|
|
1830
|
-
const timecount = TIMECOUNT_RE.exec(trimmed);
|
|
1831
|
-
if (timecount) {
|
|
1832
|
-
const num = Number.parseFloat(timecount[1] ?? "0");
|
|
1833
|
-
const unit = timecount[3] ?? "s";
|
|
1834
|
-
switch (unit) {
|
|
1835
|
-
case "h":
|
|
1836
|
-
return num * 3600;
|
|
1837
|
-
case "min":
|
|
1838
|
-
return num * 60;
|
|
1839
|
-
case "s":
|
|
1840
|
-
return num;
|
|
1841
|
-
case "ms":
|
|
1842
|
-
return num / 1e3;
|
|
1843
|
-
default:
|
|
1844
|
-
return NaN;
|
|
1845
|
-
}
|
|
1846
|
-
}
|
|
1847
|
-
return NaN;
|
|
1848
|
-
}
|
|
1849
|
-
function isValidSmilClock(value) {
|
|
1850
|
-
return !Number.isNaN(parseSmilClock(value));
|
|
1851
|
-
}
|
|
1852
|
-
|
|
1853
|
-
// src/smil/validator.ts
|
|
1854
|
-
var SMIL_NS = { smil: "http://www.w3.org/ns/SMIL" };
|
|
1855
|
-
var BLESSED_AUDIO_TYPES = /* @__PURE__ */ new Set(["audio/mpeg", "audio/mp4"]);
|
|
1856
|
-
var BLESSED_AUDIO_PATTERN = /^audio\/ogg\s*;\s*codecs=opus$/i;
|
|
1857
|
-
function isBlessedAudioType(mimeType) {
|
|
1858
|
-
return BLESSED_AUDIO_TYPES.has(mimeType) || BLESSED_AUDIO_PATTERN.test(mimeType);
|
|
1859
|
-
}
|
|
1860
|
-
var SMILValidator = class {
|
|
1861
|
-
getAttribute(element, name) {
|
|
1862
|
-
return element.attr(name)?.value ?? null;
|
|
1863
|
-
}
|
|
1864
|
-
getEpubAttribute(element, localName) {
|
|
1865
|
-
return element.attr(localName, "epub")?.value ?? null;
|
|
1866
|
-
}
|
|
1867
|
-
validate(context, path, manifestByPath) {
|
|
1868
|
-
const result = {
|
|
1869
|
-
textReferences: [],
|
|
1870
|
-
referencedDocuments: /* @__PURE__ */ new Set(),
|
|
1871
|
-
hasRemoteResources: false
|
|
1872
|
-
};
|
|
1873
|
-
const data = context.files.get(path);
|
|
1874
|
-
if (!data) return result;
|
|
1875
|
-
const content = typeof data === "string" ? data : new TextDecoder().decode(data);
|
|
1876
|
-
let doc = null;
|
|
1877
|
-
try {
|
|
1878
|
-
doc = libxml2Wasm.XmlDocument.fromString(content);
|
|
1879
|
-
} catch {
|
|
1880
|
-
pushMessage(context.messages, {
|
|
1881
|
-
id: MessageId.RSC_016,
|
|
1882
|
-
message: "Media Overlay document is not well-formed XML",
|
|
1883
|
-
location: { path }
|
|
1884
|
-
});
|
|
1885
|
-
return result;
|
|
1886
|
-
}
|
|
1887
|
-
try {
|
|
1888
|
-
const root = doc.root;
|
|
1889
|
-
this.validateStructure(context, path, root);
|
|
1890
|
-
this.validateAudioElements(context, path, root, manifestByPath, result);
|
|
1891
|
-
this.validateEpubTypes(context, path, root);
|
|
1892
|
-
this.extractTextReferences(path, root, result);
|
|
1893
|
-
} finally {
|
|
1894
|
-
doc.dispose();
|
|
1895
|
-
}
|
|
1896
|
-
return result;
|
|
1897
|
-
}
|
|
1898
|
-
validateStructure(context, path, root) {
|
|
1899
|
-
try {
|
|
1900
|
-
for (const text of root.find(".//smil:seq/smil:text", SMIL_NS)) {
|
|
1901
|
-
pushMessage(context.messages, {
|
|
1902
|
-
id: MessageId.RSC_005,
|
|
1903
|
-
message: "element 'text' not allowed here; expected 'seq' or 'par'",
|
|
1904
|
-
location: { path, line: text.line }
|
|
1905
|
-
});
|
|
1906
|
-
}
|
|
1907
|
-
for (const audio of root.find(".//smil:seq/smil:audio", SMIL_NS)) {
|
|
1908
|
-
pushMessage(context.messages, {
|
|
1909
|
-
id: MessageId.RSC_005,
|
|
1910
|
-
message: "element 'audio' not allowed here; expected 'seq' or 'par'",
|
|
1911
|
-
location: { path, line: audio.line }
|
|
1912
|
-
});
|
|
1913
|
-
}
|
|
1914
|
-
} catch {
|
|
1915
|
-
}
|
|
1916
|
-
try {
|
|
1917
|
-
for (const seq of root.find(".//smil:par/smil:seq", SMIL_NS)) {
|
|
1918
|
-
pushMessage(context.messages, {
|
|
1919
|
-
id: MessageId.RSC_005,
|
|
1920
|
-
message: "element 'seq' not allowed here; expected 'text' or 'audio'",
|
|
1921
|
-
location: { path, line: seq.line }
|
|
1922
|
-
});
|
|
1923
|
-
}
|
|
1924
|
-
const parElements = root.find(".//smil:par", SMIL_NS);
|
|
1925
|
-
for (const par of parElements) {
|
|
1926
|
-
const textChildren = par.find("./smil:text", SMIL_NS);
|
|
1927
|
-
for (let i = 1; i < textChildren.length; i++) {
|
|
1928
|
-
const extra = textChildren[i];
|
|
1929
|
-
if (!extra) continue;
|
|
1930
|
-
pushMessage(context.messages, {
|
|
1931
|
-
id: MessageId.RSC_005,
|
|
1932
|
-
message: "element 'text' not allowed here; only one 'text' element is allowed in 'par'",
|
|
1933
|
-
location: { path, line: extra.line }
|
|
1934
|
-
});
|
|
1935
|
-
}
|
|
1936
|
-
}
|
|
1937
|
-
} catch {
|
|
1938
|
-
}
|
|
1939
|
-
try {
|
|
1940
|
-
const headMetaElements = root.find(".//smil:head/smil:meta", SMIL_NS);
|
|
1941
|
-
for (const meta of headMetaElements) {
|
|
1942
|
-
pushMessage(context.messages, {
|
|
1943
|
-
id: MessageId.RSC_005,
|
|
1944
|
-
message: "element 'meta' not allowed here; expected 'metadata'",
|
|
1945
|
-
location: { path, line: meta.line }
|
|
1946
|
-
});
|
|
1947
|
-
}
|
|
1948
|
-
} catch {
|
|
1949
|
-
}
|
|
1950
|
-
}
|
|
1951
|
-
validateAudioElements(context, path, root, manifestByPath, result) {
|
|
1952
|
-
try {
|
|
1953
|
-
const audioElements = root.find(".//smil:audio", SMIL_NS);
|
|
1954
|
-
for (const audio of audioElements) {
|
|
1955
|
-
const elem = audio;
|
|
1956
|
-
const src = this.getAttribute(elem, "src");
|
|
1957
|
-
if (src) {
|
|
1958
|
-
if (/^https?:\/\//i.test(src)) {
|
|
1959
|
-
result.hasRemoteResources = true;
|
|
1960
|
-
}
|
|
1961
|
-
if (src.includes("#")) {
|
|
1962
|
-
pushMessage(context.messages, {
|
|
1963
|
-
id: MessageId.MED_014,
|
|
1964
|
-
message: `Media overlay audio file URLs must not have a fragment: "${src}"`,
|
|
1965
|
-
location: { path, line: audio.line }
|
|
1966
|
-
});
|
|
1967
|
-
}
|
|
1968
|
-
if (manifestByPath) {
|
|
1969
|
-
const audioPath = this.resolveRelativePath(path, src.split("#")[0] ?? src);
|
|
1970
|
-
const audioItem = manifestByPath.get(audioPath);
|
|
1971
|
-
if (audioItem && !isBlessedAudioType(audioItem.mediaType)) {
|
|
1972
|
-
pushMessage(context.messages, {
|
|
1973
|
-
id: MessageId.MED_005,
|
|
1974
|
-
message: `Media Overlay audio reference "${src}" to non-standard audio type "${audioItem.mediaType}"`,
|
|
1975
|
-
location: { path, line: audio.line }
|
|
1976
|
-
});
|
|
1977
|
-
}
|
|
1978
|
-
}
|
|
1979
|
-
}
|
|
1980
|
-
const clipBegin = this.getAttribute(elem, "clipBegin");
|
|
1981
|
-
const clipEnd = this.getAttribute(elem, "clipEnd");
|
|
1982
|
-
this.checkClipTiming(context, path, audio.line, clipBegin, clipEnd);
|
|
1983
|
-
}
|
|
1984
|
-
} catch {
|
|
1985
|
-
}
|
|
1986
|
-
}
|
|
1987
|
-
checkClipTiming(context, path, line, clipBegin, clipEnd) {
|
|
1988
|
-
if (clipEnd === null) return;
|
|
1989
|
-
const beginStr = clipBegin ?? "0";
|
|
1990
|
-
const start = parseSmilClock(beginStr);
|
|
1991
|
-
const end = parseSmilClock(clipEnd);
|
|
1992
|
-
const location = line != null ? { path, line } : { path };
|
|
1993
|
-
if (clipBegin !== null && Number.isNaN(start)) {
|
|
1994
|
-
pushMessage(context.messages, {
|
|
1995
|
-
id: MessageId.RSC_005,
|
|
1996
|
-
message: `Invalid SMIL clock value "${clipBegin}" in clipBegin attribute`,
|
|
1997
|
-
location
|
|
1998
|
-
});
|
|
1999
|
-
}
|
|
2000
|
-
if (Number.isNaN(end)) {
|
|
2001
|
-
pushMessage(context.messages, {
|
|
2002
|
-
id: MessageId.RSC_005,
|
|
2003
|
-
message: `Invalid SMIL clock value "${clipEnd}" in clipEnd attribute`,
|
|
2004
|
-
location
|
|
2005
|
-
});
|
|
2006
|
-
}
|
|
2007
|
-
if (Number.isNaN(start) || Number.isNaN(end)) return;
|
|
2008
|
-
if (start > end) {
|
|
2009
|
-
pushMessage(context.messages, {
|
|
2010
|
-
id: MessageId.MED_008,
|
|
2011
|
-
message: "The time specified in the clipBegin attribute must not be after clipEnd",
|
|
2012
|
-
location
|
|
2013
|
-
});
|
|
2014
|
-
} else if (start === end) {
|
|
2015
|
-
pushMessage(context.messages, {
|
|
2016
|
-
id: MessageId.MED_009,
|
|
2017
|
-
message: "The time specified in the clipBegin attribute must not be the same as clipEnd",
|
|
2018
|
-
location
|
|
2019
|
-
});
|
|
2020
|
-
}
|
|
2021
|
-
}
|
|
2022
|
-
/**
|
|
2023
|
-
* Validate epub:type attribute values against the EPUB SSV vocabulary.
|
|
2024
|
-
* Only emits OPF-088 (usage) for unknown local names. Prefixed values
|
|
2025
|
-
* from declared vocabularies are allowed.
|
|
2026
|
-
*/
|
|
2027
|
-
validateEpubTypes(context, path, root) {
|
|
2028
|
-
try {
|
|
2029
|
-
const epubTypeElements = root.find(".//*[@epub:type]", {
|
|
2030
|
-
epub: "http://www.idpf.org/2007/ops"
|
|
2031
|
-
});
|
|
2032
|
-
for (const elem of epubTypeElements) {
|
|
2033
|
-
const elemTyped = elem;
|
|
2034
|
-
const epubTypeAttr = elemTyped.attr("type", "epub");
|
|
2035
|
-
if (!epubTypeAttr?.value) continue;
|
|
2036
|
-
for (const part of epubTypeAttr.value.split(/\s+/)) {
|
|
2037
|
-
if (!part || part.includes(":")) continue;
|
|
2038
|
-
if (!EPUB_SSV_ALL.has(part)) {
|
|
2039
|
-
pushMessage(context.messages, {
|
|
2040
|
-
id: MessageId.OPF_088,
|
|
2041
|
-
message: `Unrecognized epub:type value "${part}"`,
|
|
2042
|
-
location: { path, line: elem.line }
|
|
2043
|
-
});
|
|
2044
|
-
}
|
|
2045
|
-
}
|
|
2046
|
-
}
|
|
2047
|
-
} catch {
|
|
2048
|
-
}
|
|
2049
|
-
}
|
|
2050
|
-
extractTextReferences(path, root, result) {
|
|
2051
|
-
try {
|
|
2052
|
-
const textElements = root.find(".//smil:text", SMIL_NS);
|
|
2053
|
-
for (const text of textElements) {
|
|
2054
|
-
const src = this.getAttribute(text, "src");
|
|
2055
|
-
if (!src) continue;
|
|
2056
|
-
const hashIndex = src.indexOf("#");
|
|
2057
|
-
const docRef = hashIndex >= 0 ? src.substring(0, hashIndex) : src;
|
|
2058
|
-
const fragment = hashIndex >= 0 ? src.substring(hashIndex + 1) : void 0;
|
|
2059
|
-
const docPath = this.resolveRelativePath(path, docRef);
|
|
2060
|
-
result.textReferences.push({ docPath, fragment, line: text.line });
|
|
2061
|
-
result.referencedDocuments.add(docPath);
|
|
2062
|
-
}
|
|
2063
|
-
const bodyElements = root.find(".//smil:body", SMIL_NS);
|
|
2064
|
-
const seqElements = root.find(".//smil:seq", SMIL_NS);
|
|
2065
|
-
for (const elem of [...bodyElements, ...seqElements]) {
|
|
2066
|
-
const textref = this.getEpubAttribute(elem, "textref");
|
|
2067
|
-
if (!textref) continue;
|
|
2068
|
-
const hashIndex = textref.indexOf("#");
|
|
2069
|
-
const docRef = hashIndex >= 0 ? textref.substring(0, hashIndex) : textref;
|
|
2070
|
-
const fragment = hashIndex >= 0 ? textref.substring(hashIndex + 1) : void 0;
|
|
2071
|
-
const docPath = this.resolveRelativePath(path, docRef);
|
|
2072
|
-
result.textReferences.push({ docPath, fragment, line: elem.line });
|
|
2073
|
-
result.referencedDocuments.add(docPath);
|
|
2074
|
-
}
|
|
2075
|
-
} catch {
|
|
2076
|
-
}
|
|
2077
|
-
}
|
|
2078
|
-
resolveRelativePath(basePath, relativePath) {
|
|
2079
|
-
if (relativePath.startsWith("/") || /^[a-zA-Z]+:/.test(relativePath)) {
|
|
2080
|
-
return relativePath;
|
|
2081
|
-
}
|
|
2082
|
-
const baseDir = basePath.includes("/") ? basePath.substring(0, basePath.lastIndexOf("/")) : "";
|
|
2083
|
-
if (!baseDir) return relativePath;
|
|
2084
|
-
const segments = `${baseDir}/${relativePath}`.split("/");
|
|
2085
|
-
const resolved = [];
|
|
2086
|
-
for (const seg of segments) {
|
|
2087
|
-
if (seg === "..") {
|
|
2088
|
-
resolved.pop();
|
|
2089
|
-
} else if (seg !== ".") {
|
|
2090
|
-
resolved.push(seg);
|
|
2091
|
-
}
|
|
2092
|
-
}
|
|
2093
|
-
return resolved.join("/");
|
|
2094
|
-
}
|
|
2095
|
-
};
|
|
2096
|
-
|
|
2097
|
-
// src/opf/types.ts
|
|
2098
|
-
var CORE_MEDIA_TYPES = /* @__PURE__ */ new Set([
|
|
2099
|
-
// Image types
|
|
2100
|
-
"image/gif",
|
|
2101
|
-
"image/jpeg",
|
|
2102
|
-
"image/png",
|
|
2103
|
-
"image/svg+xml",
|
|
2104
|
-
"image/webp",
|
|
2105
|
-
// Audio types
|
|
2106
|
-
"audio/mpeg",
|
|
2107
|
-
"audio/mp4",
|
|
2108
|
-
"audio/ogg",
|
|
2109
|
-
// CSS
|
|
2110
|
-
"text/css",
|
|
2111
|
-
// Fonts
|
|
2112
|
-
"font/otf",
|
|
2113
|
-
"font/ttf",
|
|
2114
|
-
"font/woff",
|
|
2115
|
-
"font/woff2",
|
|
2116
|
-
"application/font-sfnt",
|
|
2117
|
-
// deprecated alias for font/otf, font/ttf
|
|
2118
|
-
"application/font-woff",
|
|
2119
|
-
// deprecated alias for font/woff
|
|
2120
|
-
"application/vnd.ms-opentype",
|
|
2121
|
-
// deprecated alias
|
|
2122
|
-
// Content documents
|
|
2123
|
-
"application/xhtml+xml",
|
|
2124
|
-
"application/x-dtbncx+xml",
|
|
2125
|
-
// NCX
|
|
2126
|
-
// JavaScript (EPUB 3)
|
|
2127
|
-
"text/javascript",
|
|
2128
|
-
"application/javascript",
|
|
2129
|
-
// Media overlays
|
|
2130
|
-
"application/smil+xml",
|
|
2131
|
-
// PLS (Pronunciation Lexicon)
|
|
2132
|
-
"application/pls+xml"
|
|
2133
|
-
]);
|
|
2134
|
-
function isCoreMediaType(mimeType) {
|
|
2135
|
-
if (CORE_MEDIA_TYPES.has(mimeType)) return true;
|
|
2136
|
-
if (mimeType.startsWith("video/")) return true;
|
|
2137
|
-
if (/^audio\/ogg\s*;\s*codecs=opus$/i.test(mimeType)) return true;
|
|
2138
|
-
const semicolonIndex = mimeType.indexOf(";");
|
|
2139
|
-
if (semicolonIndex >= 0) {
|
|
2140
|
-
const baseType = mimeType.substring(0, semicolonIndex).trim();
|
|
2141
|
-
if (CORE_MEDIA_TYPES.has(baseType)) return true;
|
|
2142
|
-
if (baseType.startsWith("video/")) return true;
|
|
2143
|
-
}
|
|
2144
|
-
return false;
|
|
2145
|
-
}
|
|
2146
|
-
var ITEM_PROPERTIES = /* @__PURE__ */ new Set([
|
|
2147
|
-
"cover-image",
|
|
2148
|
-
"data-nav",
|
|
2149
|
-
"dictionary",
|
|
2150
|
-
"glossary",
|
|
2151
|
-
"index",
|
|
2152
|
-
"mathml",
|
|
2153
|
-
"nav",
|
|
2154
|
-
"remote-resources",
|
|
2155
|
-
"scripted",
|
|
2156
|
-
"search-key-map",
|
|
2157
|
-
"svg",
|
|
2158
|
-
"switch"
|
|
2159
|
-
]);
|
|
2160
|
-
var LINK_PROPERTIES = /* @__PURE__ */ new Set(["onix", "marc21xml-record", "mods-record", "xmp-record"]);
|
|
2161
|
-
var SPINE_PROPERTIES = /* @__PURE__ */ new Set([
|
|
2162
|
-
"page-spread-left",
|
|
2163
|
-
"page-spread-right",
|
|
2164
|
-
"rendition:spread-none",
|
|
2165
|
-
"rendition:spread-landscape",
|
|
2166
|
-
"rendition:spread-portrait",
|
|
2167
|
-
"rendition:spread-both",
|
|
2168
|
-
"rendition:spread-auto",
|
|
2169
|
-
"rendition:page-spread-center",
|
|
2170
|
-
"rendition:layout-reflowable",
|
|
2171
|
-
"rendition:layout-pre-paginated",
|
|
2172
|
-
"rendition:orientation-auto",
|
|
2173
|
-
"rendition:orientation-landscape",
|
|
2174
|
-
"rendition:orientation-portrait",
|
|
2175
|
-
"rendition:flow-auto",
|
|
2176
|
-
"rendition:flow-paginated",
|
|
2177
|
-
"rendition:flow-scrolled-continuous",
|
|
2178
|
-
"rendition:flow-scrolled-doc",
|
|
2179
|
-
"rendition:align-x-center"
|
|
2180
|
-
]);
|
|
2181
|
-
|
|
2182
1809
|
// src/references/url.ts
|
|
2183
1810
|
function parseURL(urlString) {
|
|
2184
1811
|
const hashIndex = urlString.indexOf("#");
|
|
@@ -2250,6 +1877,50 @@ function resolveManifestHref(opfDir, href) {
|
|
|
2250
1877
|
}
|
|
2251
1878
|
}
|
|
2252
1879
|
|
|
1880
|
+
// src/smil/clock.ts
|
|
1881
|
+
var FULL_CLOCK_RE = /^(\d+):([0-5]\d):([0-5]\d)(\.\d+)?$/;
|
|
1882
|
+
var PARTIAL_CLOCK_RE = /^([0-5]\d):([0-5]\d)(\.\d+)?$/;
|
|
1883
|
+
var TIMECOUNT_RE = /^(\d+(\.\d+)?)(h|min|s|ms)?$/;
|
|
1884
|
+
function parseSmilClock(value) {
|
|
1885
|
+
const trimmed = value.trim();
|
|
1886
|
+
const full = FULL_CLOCK_RE.exec(trimmed);
|
|
1887
|
+
if (full) {
|
|
1888
|
+
const hours = Number.parseInt(full[1] ?? "0", 10);
|
|
1889
|
+
const minutes = Number.parseInt(full[2] ?? "0", 10);
|
|
1890
|
+
const seconds = Number.parseInt(full[3] ?? "0", 10);
|
|
1891
|
+
const frac = full[4] ? Number.parseFloat(full[4]) : 0;
|
|
1892
|
+
return hours * 3600 + minutes * 60 + seconds + frac;
|
|
1893
|
+
}
|
|
1894
|
+
const partial = PARTIAL_CLOCK_RE.exec(trimmed);
|
|
1895
|
+
if (partial) {
|
|
1896
|
+
const minutes = Number.parseInt(partial[1] ?? "0", 10);
|
|
1897
|
+
const seconds = Number.parseInt(partial[2] ?? "0", 10);
|
|
1898
|
+
const frac = partial[3] ? Number.parseFloat(partial[3]) : 0;
|
|
1899
|
+
return minutes * 60 + seconds + frac;
|
|
1900
|
+
}
|
|
1901
|
+
const timecount = TIMECOUNT_RE.exec(trimmed);
|
|
1902
|
+
if (timecount) {
|
|
1903
|
+
const num = Number.parseFloat(timecount[1] ?? "0");
|
|
1904
|
+
const unit = timecount[3] ?? "s";
|
|
1905
|
+
switch (unit) {
|
|
1906
|
+
case "h":
|
|
1907
|
+
return num * 3600;
|
|
1908
|
+
case "min":
|
|
1909
|
+
return num * 60;
|
|
1910
|
+
case "s":
|
|
1911
|
+
return num;
|
|
1912
|
+
case "ms":
|
|
1913
|
+
return num / 1e3;
|
|
1914
|
+
default:
|
|
1915
|
+
return NaN;
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
return NaN;
|
|
1919
|
+
}
|
|
1920
|
+
function isValidSmilClock(value) {
|
|
1921
|
+
return !Number.isNaN(parseSmilClock(value));
|
|
1922
|
+
}
|
|
1923
|
+
|
|
2253
1924
|
// src/types.ts
|
|
2254
1925
|
var EPUB_VERSIONS = ["2.0", "3.0", "3.1", "3.2", "3.3"];
|
|
2255
1926
|
|
|
@@ -2698,6 +2369,91 @@ function parseCollections(xml) {
|
|
|
2698
2369
|
return roots;
|
|
2699
2370
|
}
|
|
2700
2371
|
|
|
2372
|
+
// src/opf/types.ts
|
|
2373
|
+
var CORE_MEDIA_TYPES = /* @__PURE__ */ new Set([
|
|
2374
|
+
// Image types
|
|
2375
|
+
"image/gif",
|
|
2376
|
+
"image/jpeg",
|
|
2377
|
+
"image/png",
|
|
2378
|
+
"image/svg+xml",
|
|
2379
|
+
"image/webp",
|
|
2380
|
+
// Audio types
|
|
2381
|
+
"audio/mpeg",
|
|
2382
|
+
"audio/mp4",
|
|
2383
|
+
"audio/ogg",
|
|
2384
|
+
// CSS
|
|
2385
|
+
"text/css",
|
|
2386
|
+
// Fonts
|
|
2387
|
+
"font/otf",
|
|
2388
|
+
"font/ttf",
|
|
2389
|
+
"font/woff",
|
|
2390
|
+
"font/woff2",
|
|
2391
|
+
"application/font-sfnt",
|
|
2392
|
+
// deprecated alias for font/otf, font/ttf
|
|
2393
|
+
"application/font-woff",
|
|
2394
|
+
// deprecated alias for font/woff
|
|
2395
|
+
"application/vnd.ms-opentype",
|
|
2396
|
+
// deprecated alias
|
|
2397
|
+
// Content documents
|
|
2398
|
+
"application/xhtml+xml",
|
|
2399
|
+
"application/x-dtbncx+xml",
|
|
2400
|
+
// NCX
|
|
2401
|
+
// JavaScript (EPUB 3)
|
|
2402
|
+
"text/javascript",
|
|
2403
|
+
"application/javascript",
|
|
2404
|
+
// Media overlays
|
|
2405
|
+
"application/smil+xml",
|
|
2406
|
+
// PLS (Pronunciation Lexicon)
|
|
2407
|
+
"application/pls+xml"
|
|
2408
|
+
]);
|
|
2409
|
+
function isCoreMediaType(mimeType) {
|
|
2410
|
+
if (CORE_MEDIA_TYPES.has(mimeType)) return true;
|
|
2411
|
+
if (mimeType.startsWith("video/")) return true;
|
|
2412
|
+
if (/^audio\/ogg\s*;\s*codecs=opus$/i.test(mimeType)) return true;
|
|
2413
|
+
const semicolonIndex = mimeType.indexOf(";");
|
|
2414
|
+
if (semicolonIndex >= 0) {
|
|
2415
|
+
const baseType = mimeType.substring(0, semicolonIndex).trim();
|
|
2416
|
+
if (CORE_MEDIA_TYPES.has(baseType)) return true;
|
|
2417
|
+
if (baseType.startsWith("video/")) return true;
|
|
2418
|
+
}
|
|
2419
|
+
return false;
|
|
2420
|
+
}
|
|
2421
|
+
var ITEM_PROPERTIES = /* @__PURE__ */ new Set([
|
|
2422
|
+
"cover-image",
|
|
2423
|
+
"data-nav",
|
|
2424
|
+
"dictionary",
|
|
2425
|
+
"glossary",
|
|
2426
|
+
"index",
|
|
2427
|
+
"mathml",
|
|
2428
|
+
"nav",
|
|
2429
|
+
"remote-resources",
|
|
2430
|
+
"scripted",
|
|
2431
|
+
"search-key-map",
|
|
2432
|
+
"svg",
|
|
2433
|
+
"switch"
|
|
2434
|
+
]);
|
|
2435
|
+
var LINK_PROPERTIES = /* @__PURE__ */ new Set(["onix", "marc21xml-record", "mods-record", "xmp-record"]);
|
|
2436
|
+
var SPINE_PROPERTIES = /* @__PURE__ */ new Set([
|
|
2437
|
+
"page-spread-left",
|
|
2438
|
+
"page-spread-right",
|
|
2439
|
+
"rendition:spread-none",
|
|
2440
|
+
"rendition:spread-landscape",
|
|
2441
|
+
"rendition:spread-portrait",
|
|
2442
|
+
"rendition:spread-both",
|
|
2443
|
+
"rendition:spread-auto",
|
|
2444
|
+
"rendition:page-spread-center",
|
|
2445
|
+
"rendition:layout-reflowable",
|
|
2446
|
+
"rendition:layout-pre-paginated",
|
|
2447
|
+
"rendition:orientation-auto",
|
|
2448
|
+
"rendition:orientation-landscape",
|
|
2449
|
+
"rendition:orientation-portrait",
|
|
2450
|
+
"rendition:flow-auto",
|
|
2451
|
+
"rendition:flow-paginated",
|
|
2452
|
+
"rendition:flow-scrolled-continuous",
|
|
2453
|
+
"rendition:flow-scrolled-doc",
|
|
2454
|
+
"rendition:align-x-center"
|
|
2455
|
+
]);
|
|
2456
|
+
|
|
2701
2457
|
// src/opf/validator.ts
|
|
2702
2458
|
var DEPRECATED_MEDIA_TYPES = /* @__PURE__ */ new Set([
|
|
2703
2459
|
"text/x-oeb1-document",
|
|
@@ -5259,6 +5015,251 @@ function isValidW3CDateFormat(dateStr) {
|
|
|
5259
5015
|
return false;
|
|
5260
5016
|
}
|
|
5261
5017
|
|
|
5018
|
+
// src/smil/validator.ts
|
|
5019
|
+
var SMIL_NS = { smil: "http://www.w3.org/ns/SMIL" };
|
|
5020
|
+
var BLESSED_AUDIO_TYPES = /* @__PURE__ */ new Set(["audio/mpeg", "audio/mp4"]);
|
|
5021
|
+
var BLESSED_AUDIO_PATTERN = /^audio\/ogg\s*;\s*codecs=opus$/i;
|
|
5022
|
+
function isBlessedAudioType(mimeType) {
|
|
5023
|
+
return BLESSED_AUDIO_TYPES.has(mimeType) || BLESSED_AUDIO_PATTERN.test(mimeType);
|
|
5024
|
+
}
|
|
5025
|
+
var SMILValidator = class {
|
|
5026
|
+
getAttribute(element, name) {
|
|
5027
|
+
return element.attr(name)?.value ?? null;
|
|
5028
|
+
}
|
|
5029
|
+
getEpubAttribute(element, localName) {
|
|
5030
|
+
return element.attr(localName, "epub")?.value ?? null;
|
|
5031
|
+
}
|
|
5032
|
+
validate(context, path, manifestByPath) {
|
|
5033
|
+
const result = {
|
|
5034
|
+
textReferences: [],
|
|
5035
|
+
referencedDocuments: /* @__PURE__ */ new Set(),
|
|
5036
|
+
hasRemoteResources: false
|
|
5037
|
+
};
|
|
5038
|
+
const data = context.files.get(path);
|
|
5039
|
+
if (!data) return result;
|
|
5040
|
+
const content = typeof data === "string" ? data : new TextDecoder().decode(data);
|
|
5041
|
+
let doc = null;
|
|
5042
|
+
try {
|
|
5043
|
+
doc = libxml2Wasm.XmlDocument.fromString(content);
|
|
5044
|
+
} catch {
|
|
5045
|
+
pushMessage(context.messages, {
|
|
5046
|
+
id: MessageId.RSC_016,
|
|
5047
|
+
message: "Media Overlay document is not well-formed XML",
|
|
5048
|
+
location: { path }
|
|
5049
|
+
});
|
|
5050
|
+
return result;
|
|
5051
|
+
}
|
|
5052
|
+
try {
|
|
5053
|
+
const root = doc.root;
|
|
5054
|
+
this.validateStructure(context, path, root);
|
|
5055
|
+
this.validateAudioElements(context, path, root, manifestByPath, result);
|
|
5056
|
+
this.validateEpubTypes(context, path, root);
|
|
5057
|
+
this.extractTextReferences(path, root, result);
|
|
5058
|
+
} finally {
|
|
5059
|
+
doc.dispose();
|
|
5060
|
+
}
|
|
5061
|
+
return result;
|
|
5062
|
+
}
|
|
5063
|
+
validateStructure(context, path, root) {
|
|
5064
|
+
try {
|
|
5065
|
+
for (const text of root.find(".//smil:seq/smil:text", SMIL_NS)) {
|
|
5066
|
+
pushMessage(context.messages, {
|
|
5067
|
+
id: MessageId.RSC_005,
|
|
5068
|
+
message: "element 'text' not allowed here; expected 'seq' or 'par'",
|
|
5069
|
+
location: { path, line: text.line }
|
|
5070
|
+
});
|
|
5071
|
+
}
|
|
5072
|
+
for (const audio of root.find(".//smil:seq/smil:audio", SMIL_NS)) {
|
|
5073
|
+
pushMessage(context.messages, {
|
|
5074
|
+
id: MessageId.RSC_005,
|
|
5075
|
+
message: "element 'audio' not allowed here; expected 'seq' or 'par'",
|
|
5076
|
+
location: { path, line: audio.line }
|
|
5077
|
+
});
|
|
5078
|
+
}
|
|
5079
|
+
} catch {
|
|
5080
|
+
}
|
|
5081
|
+
try {
|
|
5082
|
+
for (const seq of root.find(".//smil:par/smil:seq", SMIL_NS)) {
|
|
5083
|
+
pushMessage(context.messages, {
|
|
5084
|
+
id: MessageId.RSC_005,
|
|
5085
|
+
message: "element 'seq' not allowed here; expected 'text' or 'audio'",
|
|
5086
|
+
location: { path, line: seq.line }
|
|
5087
|
+
});
|
|
5088
|
+
}
|
|
5089
|
+
const parElements = root.find(".//smil:par", SMIL_NS);
|
|
5090
|
+
for (const par of parElements) {
|
|
5091
|
+
const textChildren = par.find("./smil:text", SMIL_NS);
|
|
5092
|
+
for (let i = 1; i < textChildren.length; i++) {
|
|
5093
|
+
const extra = textChildren[i];
|
|
5094
|
+
if (!extra) continue;
|
|
5095
|
+
pushMessage(context.messages, {
|
|
5096
|
+
id: MessageId.RSC_005,
|
|
5097
|
+
message: "element 'text' not allowed here; only one 'text' element is allowed in 'par'",
|
|
5098
|
+
location: { path, line: extra.line }
|
|
5099
|
+
});
|
|
5100
|
+
}
|
|
5101
|
+
}
|
|
5102
|
+
} catch {
|
|
5103
|
+
}
|
|
5104
|
+
try {
|
|
5105
|
+
const headMetaElements = root.find(".//smil:head/smil:meta", SMIL_NS);
|
|
5106
|
+
for (const meta of headMetaElements) {
|
|
5107
|
+
pushMessage(context.messages, {
|
|
5108
|
+
id: MessageId.RSC_005,
|
|
5109
|
+
message: "element 'meta' not allowed here; expected 'metadata'",
|
|
5110
|
+
location: { path, line: meta.line }
|
|
5111
|
+
});
|
|
5112
|
+
}
|
|
5113
|
+
} catch {
|
|
5114
|
+
}
|
|
5115
|
+
}
|
|
5116
|
+
validateAudioElements(context, path, root, manifestByPath, result) {
|
|
5117
|
+
try {
|
|
5118
|
+
const audioElements = root.find(".//smil:audio", SMIL_NS);
|
|
5119
|
+
for (const audio of audioElements) {
|
|
5120
|
+
const elem = audio;
|
|
5121
|
+
const src = this.getAttribute(elem, "src");
|
|
5122
|
+
if (src) {
|
|
5123
|
+
if (isRemoteURL(src)) {
|
|
5124
|
+
result.hasRemoteResources = true;
|
|
5125
|
+
}
|
|
5126
|
+
if (src.includes("#")) {
|
|
5127
|
+
pushMessage(context.messages, {
|
|
5128
|
+
id: MessageId.MED_014,
|
|
5129
|
+
message: `Media overlay audio file URLs must not have a fragment: "${src}"`,
|
|
5130
|
+
location: { path, line: audio.line }
|
|
5131
|
+
});
|
|
5132
|
+
}
|
|
5133
|
+
if (manifestByPath) {
|
|
5134
|
+
const audioPath = this.resolveRelativePath(path, src.split("#")[0] ?? src);
|
|
5135
|
+
const audioItem = manifestByPath.get(audioPath);
|
|
5136
|
+
if (audioItem && !isBlessedAudioType(audioItem.mediaType)) {
|
|
5137
|
+
pushMessage(context.messages, {
|
|
5138
|
+
id: MessageId.MED_005,
|
|
5139
|
+
message: `Media Overlay audio reference "${src}" to non-standard audio type "${audioItem.mediaType}"`,
|
|
5140
|
+
location: { path, line: audio.line }
|
|
5141
|
+
});
|
|
5142
|
+
}
|
|
5143
|
+
}
|
|
5144
|
+
}
|
|
5145
|
+
const clipBegin = this.getAttribute(elem, "clipBegin");
|
|
5146
|
+
const clipEnd = this.getAttribute(elem, "clipEnd");
|
|
5147
|
+
this.checkClipTiming(context, path, audio.line, clipBegin, clipEnd);
|
|
5148
|
+
}
|
|
5149
|
+
} catch {
|
|
5150
|
+
}
|
|
5151
|
+
}
|
|
5152
|
+
checkClipTiming(context, path, line, clipBegin, clipEnd) {
|
|
5153
|
+
if (clipEnd === null) return;
|
|
5154
|
+
const beginStr = clipBegin ?? "0";
|
|
5155
|
+
const start = parseSmilClock(beginStr);
|
|
5156
|
+
const end = parseSmilClock(clipEnd);
|
|
5157
|
+
const location = line != null ? { path, line } : { path };
|
|
5158
|
+
if (clipBegin !== null && Number.isNaN(start)) {
|
|
5159
|
+
pushMessage(context.messages, {
|
|
5160
|
+
id: MessageId.RSC_005,
|
|
5161
|
+
message: `Invalid SMIL clock value "${clipBegin}" in clipBegin attribute`,
|
|
5162
|
+
location
|
|
5163
|
+
});
|
|
5164
|
+
}
|
|
5165
|
+
if (Number.isNaN(end)) {
|
|
5166
|
+
pushMessage(context.messages, {
|
|
5167
|
+
id: MessageId.RSC_005,
|
|
5168
|
+
message: `Invalid SMIL clock value "${clipEnd}" in clipEnd attribute`,
|
|
5169
|
+
location
|
|
5170
|
+
});
|
|
5171
|
+
}
|
|
5172
|
+
if (Number.isNaN(start) || Number.isNaN(end)) return;
|
|
5173
|
+
if (start > end) {
|
|
5174
|
+
pushMessage(context.messages, {
|
|
5175
|
+
id: MessageId.MED_008,
|
|
5176
|
+
message: "The time specified in the clipBegin attribute must not be after clipEnd",
|
|
5177
|
+
location
|
|
5178
|
+
});
|
|
5179
|
+
} else if (start === end) {
|
|
5180
|
+
pushMessage(context.messages, {
|
|
5181
|
+
id: MessageId.MED_009,
|
|
5182
|
+
message: "The time specified in the clipBegin attribute must not be the same as clipEnd",
|
|
5183
|
+
location
|
|
5184
|
+
});
|
|
5185
|
+
}
|
|
5186
|
+
}
|
|
5187
|
+
/**
|
|
5188
|
+
* Validate epub:type attribute values against the EPUB SSV vocabulary.
|
|
5189
|
+
* Only emits OPF-088 (usage) for unknown local names. Prefixed values
|
|
5190
|
+
* from declared vocabularies are allowed.
|
|
5191
|
+
*/
|
|
5192
|
+
validateEpubTypes(context, path, root) {
|
|
5193
|
+
try {
|
|
5194
|
+
const epubTypeElements = root.find(".//*[@epub:type]", {
|
|
5195
|
+
epub: "http://www.idpf.org/2007/ops"
|
|
5196
|
+
});
|
|
5197
|
+
for (const elem of epubTypeElements) {
|
|
5198
|
+
const elemTyped = elem;
|
|
5199
|
+
const epubTypeAttr = elemTyped.attr("type", "epub");
|
|
5200
|
+
if (!epubTypeAttr?.value) continue;
|
|
5201
|
+
for (const part of epubTypeAttr.value.split(/\s+/)) {
|
|
5202
|
+
if (!part || part.includes(":")) continue;
|
|
5203
|
+
if (!EPUB_SSV_ALL.has(part)) {
|
|
5204
|
+
pushMessage(context.messages, {
|
|
5205
|
+
id: MessageId.OPF_088,
|
|
5206
|
+
message: `Unrecognized epub:type value "${part}"`,
|
|
5207
|
+
location: { path, line: elem.line }
|
|
5208
|
+
});
|
|
5209
|
+
}
|
|
5210
|
+
}
|
|
5211
|
+
}
|
|
5212
|
+
} catch {
|
|
5213
|
+
}
|
|
5214
|
+
}
|
|
5215
|
+
extractTextReferences(path, root, result) {
|
|
5216
|
+
try {
|
|
5217
|
+
const textElements = root.find(".//smil:text", SMIL_NS);
|
|
5218
|
+
for (const text of textElements) {
|
|
5219
|
+
const src = this.getAttribute(text, "src");
|
|
5220
|
+
if (!src) continue;
|
|
5221
|
+
const hashIndex = src.indexOf("#");
|
|
5222
|
+
const docRef = hashIndex >= 0 ? src.substring(0, hashIndex) : src;
|
|
5223
|
+
const fragment = hashIndex >= 0 ? src.substring(hashIndex + 1) : void 0;
|
|
5224
|
+
const docPath = this.resolveRelativePath(path, docRef);
|
|
5225
|
+
result.textReferences.push({ docPath, fragment, line: text.line });
|
|
5226
|
+
result.referencedDocuments.add(docPath);
|
|
5227
|
+
}
|
|
5228
|
+
const bodyElements = root.find(".//smil:body", SMIL_NS);
|
|
5229
|
+
const seqElements = root.find(".//smil:seq", SMIL_NS);
|
|
5230
|
+
for (const elem of [...bodyElements, ...seqElements]) {
|
|
5231
|
+
const textref = this.getEpubAttribute(elem, "textref");
|
|
5232
|
+
if (!textref) continue;
|
|
5233
|
+
const hashIndex = textref.indexOf("#");
|
|
5234
|
+
const docRef = hashIndex >= 0 ? textref.substring(0, hashIndex) : textref;
|
|
5235
|
+
const fragment = hashIndex >= 0 ? textref.substring(hashIndex + 1) : void 0;
|
|
5236
|
+
const docPath = this.resolveRelativePath(path, docRef);
|
|
5237
|
+
result.textReferences.push({ docPath, fragment, line: elem.line });
|
|
5238
|
+
result.referencedDocuments.add(docPath);
|
|
5239
|
+
}
|
|
5240
|
+
} catch {
|
|
5241
|
+
}
|
|
5242
|
+
}
|
|
5243
|
+
resolveRelativePath(basePath, relativePath) {
|
|
5244
|
+
const decoded = tryDecodeUriComponent(relativePath);
|
|
5245
|
+
if (decoded.startsWith("/") || /^[a-zA-Z]+:/.test(decoded)) {
|
|
5246
|
+
return decoded.normalize("NFC");
|
|
5247
|
+
}
|
|
5248
|
+
const baseDir = basePath.includes("/") ? basePath.substring(0, basePath.lastIndexOf("/")) : "";
|
|
5249
|
+
if (!baseDir) return decoded.normalize("NFC");
|
|
5250
|
+
const segments = `${baseDir}/${decoded}`.split("/");
|
|
5251
|
+
const resolved = [];
|
|
5252
|
+
for (const seg of segments) {
|
|
5253
|
+
if (seg === "..") {
|
|
5254
|
+
resolved.pop();
|
|
5255
|
+
} else if (seg !== ".") {
|
|
5256
|
+
resolved.push(seg);
|
|
5257
|
+
}
|
|
5258
|
+
}
|
|
5259
|
+
return resolved.join("/").normalize("NFC");
|
|
5260
|
+
}
|
|
5261
|
+
};
|
|
5262
|
+
|
|
5262
5263
|
// src/references/types.ts
|
|
5263
5264
|
var PUBLICATION_RESOURCE_TYPES = /* @__PURE__ */ new Set([
|
|
5264
5265
|
"generic" /* GENERIC */,
|
|
@@ -7726,6 +7727,7 @@ var ContentValidator = class {
|
|
|
7726
7727
|
}
|
|
7727
7728
|
}
|
|
7728
7729
|
checkUsemapAttribute(context, path, root) {
|
|
7730
|
+
if (context.version === "2.0") return;
|
|
7729
7731
|
try {
|
|
7730
7732
|
const elements = root.find(".//html:*[@usemap]", XHTML_NS);
|
|
7731
7733
|
for (const elem of elements) {
|
|
@@ -9840,8 +9842,8 @@ var NCXValidator = class {
|
|
|
9840
9842
|
const hashIdx = src.indexOf("#");
|
|
9841
9843
|
const srcBase = hashIdx >= 0 ? src.substring(0, hashIdx) : src;
|
|
9842
9844
|
const fragment = hashIdx >= 0 ? src.substring(hashIdx + 1) : "";
|
|
9843
|
-
const isRemote =
|
|
9844
|
-
const fullPath = isRemote ? srcBase : resolvePath(ncxPath, srcBase);
|
|
9845
|
+
const isRemote = isRemoteURL(srcBase);
|
|
9846
|
+
const fullPath = isRemote ? srcBase : resolvePath(ncxPath, tryDecodeUriComponent(srcBase)).normalize("NFC");
|
|
9845
9847
|
if (!context.files.has(fullPath) && !isRemote) {
|
|
9846
9848
|
const line = contentElem.line;
|
|
9847
9849
|
pushMessage(context.messages, {
|